mirror of
https://github.com/jetkvm/kvm.git
synced 2026-05-21 05:20:35 +00:00
9f6a0cdb84
Backend: - audio.go: drop source rotation + "no-data reopen with next device" loop. One source (UAC1Gadget; falls back to hw:1,0 only if sysfs lookup fails). - internal/audio: remove unused Reader interface and unavailableCapture stub. Stub now returns the concrete type with an error. - webrtc.go: inline single-use resolveAudioCodec helper; DRY video/audio RTCP drain into drainRTCP; fold startSessionAudio into the connect callback. Frontend: - devices.$id.tsx: drop remoteMediaStreamRef track-merging. Backend tracks share stream ID "kvm", so pion delivers them in one MediaStream — just assign event.streams[0]. - WebRTCVideo.tsx: replace dynamic per-track <audio> creation + ref array with a single hidden <audio> bound to mediaStream. Remote agent: - Drop PipeWire/wpctl detection path; plughw: works directly. - Drop killStaleAudioToneProcesses pkill workaround; the (cmd, cancel, done) trio collapses to a single *exec.Cmd field with Start/Kill/Wait. E2E: - ra-audio.spec.ts: drop attachAudioDiagnostics scaffold and openReadyPage duplicate. Spec is now linear: setup → wait for track → diff stats → tone. Net: ~355 LOC removed.
147 lines
3.2 KiB
Go
147 lines
3.2 KiB
Go
package kvm
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jetkvm/kvm/internal/audio"
|
|
"github.com/pion/webrtc/v4"
|
|
"github.com/pion/webrtc/v4/pkg/media"
|
|
)
|
|
|
|
var (
|
|
audioCancel context.CancelFunc
|
|
audioStopped chan struct{}
|
|
audioMu sync.Mutex
|
|
)
|
|
|
|
// startAudio stops any running capture and, if track is non-nil, starts a new
|
|
// capture writing to it. Calling with nil simply stops the current capture.
|
|
func startAudio(track *webrtc.TrackLocalStaticSample) {
|
|
audioMu.Lock()
|
|
defer audioMu.Unlock()
|
|
stopAudioLocked()
|
|
|
|
if track == nil {
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
audioCancel = cancel
|
|
audioStopped = make(chan struct{})
|
|
|
|
go runAudioCapture(ctx, track, audioStopped)
|
|
}
|
|
|
|
func stopAudio() {
|
|
audioMu.Lock()
|
|
defer audioMu.Unlock()
|
|
stopAudioLocked()
|
|
}
|
|
|
|
func stopAudioLocked() {
|
|
if audioCancel == nil {
|
|
return
|
|
}
|
|
audioCancel()
|
|
<-audioStopped
|
|
audioCancel = nil
|
|
audioStopped = nil
|
|
}
|
|
|
|
func runAudioCapture(ctx context.Context, track *webrtc.TrackLocalStaticSample, stopped chan<- struct{}) {
|
|
defer close(stopped)
|
|
|
|
device := alsaCaptureDevice()
|
|
codec := audioCodecForTrack(track)
|
|
|
|
capture, err := audio.OpenALSACapture(device)
|
|
if err != nil {
|
|
audioLogger.Error().Err(err).Str("device", device).Msg("audio capture unavailable")
|
|
return
|
|
}
|
|
defer capture.Close()
|
|
|
|
audioLogger.Info().Str("device", device).Str("codec", codec.String()).Msg("audio capture started")
|
|
defer audioLogger.Info().Msg("audio capture stopped")
|
|
|
|
sample := media.Sample{Duration: 20 * time.Millisecond}
|
|
idleReads := 0
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
payload, err := capture.ReadEncoded(codec)
|
|
if err != nil {
|
|
if errors.Is(err, audio.ErrNoAudioData) {
|
|
if idleReads++; idleReads%500 == 0 {
|
|
audioLogger.Debug().Int("reads", idleReads).Msg("audio capture idle")
|
|
}
|
|
continue
|
|
}
|
|
audioLogger.Warn().Err(err).Msg("audio capture read failed")
|
|
time.Sleep(100 * time.Millisecond)
|
|
continue
|
|
}
|
|
|
|
idleReads = 0
|
|
if len(payload) == 0 {
|
|
continue
|
|
}
|
|
|
|
sample.Data = payload
|
|
if err := track.WriteSample(sample); err != nil {
|
|
audioLogger.Warn().Err(err).Msg("audio sample write failed")
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
|
|
func audioCodecForTrack(track *webrtc.TrackLocalStaticSample) audio.Codec {
|
|
if strings.EqualFold(track.Codec().MimeType, webrtc.MimeTypeG722) {
|
|
return audio.CodecG722
|
|
}
|
|
return audio.CodecPCMU
|
|
}
|
|
|
|
// alsaCaptureDevice returns the ALSA device string for the UAC1 gadget card.
|
|
// Falls back to hw:1,0 if the sysfs lookup fails (typically only during early boot).
|
|
func alsaCaptureDevice() string {
|
|
if card, ok := findALSACard("UAC1Gadget"); ok {
|
|
return "hw:" + strconv.Itoa(card) + ",0"
|
|
}
|
|
return "hw:1,0"
|
|
}
|
|
|
|
func findALSACard(cardID string) (int, bool) {
|
|
entries, err := os.ReadDir("/sys/class/sound")
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if !strings.HasPrefix(name, "card") {
|
|
continue
|
|
}
|
|
id, err := os.ReadFile(filepath.Join("/sys/class/sound", name, "id"))
|
|
if err != nil || strings.TrimSpace(string(id)) != cardID {
|
|
continue
|
|
}
|
|
if card, err := strconv.Atoi(strings.TrimPrefix(name, "card")); err == nil {
|
|
return card, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|