responder/pkg/audiocap/virtmic.go
2026-04-09 10:21:20 +02:00

142 lines
3.1 KiB
Go

package audiocap
import (
"fmt"
"os/exec"
"strings"
)
const (
SinkName = "cheater_mic"
SourceName = "CheaterMic"
RecordingSink = "recording"
)
type Handle struct {
// virtmic modules
sinkModuleID string
sourceModuleID string
// recording modules
recordingSinkID string
callLoopbackID string
ttsLoopbackID string
}
func Setup() (*Handle, error) {
h := &Handle{}
// 1. virtmic null-sink
sinkID, err := pactlLoad("module-null-sink",
"sink_name="+SinkName,
"sink_properties=device.description=CheaterSink",
)
if err != nil {
return nil, fmt.Errorf("null-sink: %w", err)
}
h.sinkModuleID = sinkID
// 2. virtmic remap-source
sourceID, err := pactlLoad("module-remap-source",
"source_name="+SourceName,
"master="+SinkName+".monitor",
"source_properties=device.description=CheaterMic",
)
if err != nil {
h.Teardown()
return nil, fmt.Errorf("remap-source: %w", err)
}
h.sourceModuleID = sourceID
// 3. recording null-sink
recID, err := pactlLoad("module-null-sink",
"sink_name="+RecordingSink,
"sink_properties=device.description=Recording",
)
if err != nil {
h.Teardown()
return nil, fmt.Errorf("recording sink: %w", err)
}
h.recordingSinkID = recID
// 4. Call-audio loopback: resolve default sink monitor explicitly
defaultMonitor, err := defaultSinkMonitor()
if err != nil {
h.Teardown()
return nil, fmt.Errorf("resolve default monitor: %w", err)
}
callID, err := pactlLoad("module-loopback",
"source="+defaultMonitor,
"sink="+RecordingSink,
"latency_msec=20",
)
if err != nil {
h.Teardown()
return nil, fmt.Errorf("call loopback: %w", err)
}
h.callLoopbackID = callID
// 5. TTS loopback: cheater_mic monitor → recording
ttsID, err := pactlLoad("module-loopback",
"source="+SinkName+".monitor",
"sink="+RecordingSink,
"latency_msec=20",
)
if err != nil {
h.Teardown()
return nil, fmt.Errorf("tts loopback: %w", err)
}
h.ttsLoopbackID = ttsID
return h, nil
}
// Teardown unloads in reverse order. Safe to call even after partial Setup.
func (h *Handle) Teardown() {
if h.ttsLoopbackID != "" {
_ = pactlUnload(h.ttsLoopbackID)
}
if h.callLoopbackID != "" {
_ = pactlUnload(h.callLoopbackID)
}
if h.recordingSinkID != "" {
_ = pactlUnload(h.recordingSinkID)
}
if h.sourceModuleID != "" {
_ = pactlUnload(h.sourceModuleID)
}
if h.sinkModuleID != "" {
_ = pactlUnload(h.sinkModuleID)
}
}
func (h *Handle) SinkForPlayback() string {
return SinkName
}
// defaultSinkMonitor returns "<default-sink-name>.monitor"
func defaultSinkMonitor() (string, error) {
out, err := exec.Command("pactl", "get-default-sink").Output()
if err != nil {
return "", err
}
sink := strings.TrimSpace(string(out))
if sink == "" {
return "", fmt.Errorf("empty default sink")
}
return sink + ".monitor", nil
}
func pactlLoad(module string, args ...string) (string, error) {
cmdArgs := append([]string{"load-module", module}, args...)
out, err := exec.Command("pactl", cmdArgs...).Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
func pactlUnload(id string) error {
return exec.Command("pactl", "unload-module", id).Run()
}