doxa-maps/backend/rudder-engine/devices.go
Doxa Maps 65c1339fb2
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source aab42616
2026-09-27 08:37:20 +00:00

456 lines
15 KiB
Go

// devices.go — microphone discovery, hot-plug, and the sound-server environment.
//
// The Jitsi Meet pattern (lib-jitsi-meet modules/RTC/RTCUtils.js), done natively because Rudder records
// outside the browser:
//
// Jitsi (browser) Rudder (native, PipeWire/PulseAudio)
// navigator.mediaDevices.enumerateDevices() → pactl -f json list sources
// 'devicechange' event + polling fallback → pactl subscribe (+ re-list on every source/card event)
// compareAvailableMediaDevices(new) → sameDevices(old, new) — only emit on a real change
// getUserMedia({audio:{deviceId:{exact:id}}}) → parec --device=<id>
//
// Jitsi also reports the device list to analytics (sendDeviceListToAnalytics). Rudder never does:
// the list goes to the local plugin over SSE and nowhere else.
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"math"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
)
// audioEnv gives child processes a path to the user's sound server even when the engine was launched
// from a context without a desktop session (a detached shell, a service, an agent's terminal). Measured
// 2026-09-27: started with no XDG_RUNTIME_DIR, parec exited instantly with "Connection refused" and every
// recording captured nothing, although the socket sat at /run/user/<uid>/pulse/native all along.
func audioEnv() []string {
env := os.Environ()
rt := os.Getenv("XDG_RUNTIME_DIR")
if rt == "" {
rt = fmt.Sprintf("/run/user/%d", os.Getuid())
if st, err := os.Stat(rt); err == nil && st.IsDir() {
env = append(env, "XDG_RUNTIME_DIR="+rt)
}
}
// systemctl --user (a headset-reset command may restart PipeWire) needs the session bus.
if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" {
if _, err := os.Stat(rt + "/bus"); err == nil {
env = append(env, "DBUS_SESSION_BUS_ADDRESS=unix:path="+rt+"/bus")
}
}
if os.Getenv("PULSE_SERVER") == "" {
if _, err := os.Stat(rt + "/pulse/native"); err == nil {
env = append(env, "PULSE_SERVER=unix:"+rt+"/pulse/native")
}
}
return env
}
// Device is one microphone as the plugin shows it.
type Device struct {
ID string `json:"id"` // the sound server's source name: what parec --device takes
Label string `json:"label"` // human name ("My Headset", "Built-in Audio Analog Stereo")
Bus string `json:"bus"` // bluetooth | usb | pci | ...
Default bool `json:"default"` // the system's default input
Muted bool `json:"muted"`
Volume string `json:"volume"` // e.g. "52%"
State string `json:"state"` // RUNNING | IDLE | SUSPENDED
}
// DeviceList is the whole picture: the mics, plus sound cards that have a mic but are switched off
// (the laptop's built-in card, profile "off", is the common case — its mic simply doesn't exist until
// the card is turned on in Sound settings).
type DeviceList struct {
Devices []Device `json:"devices"`
Default string `json:"default"`
OffCards []string `json:"offCards"`
Error string `json:"error,omitempty"`
}
func runPactl(args ...string) ([]byte, error) {
cmd := exec.Command("pactl", args...)
cmd.Env = audioEnv()
return cmd.Output()
}
func listDevices() DeviceList {
out := DeviceList{Devices: []Device{}, OffCards: []string{}}
if _, err := exec.LookPath("pactl"); err != nil {
out.Error = "pactl not found: install pipewire-pulse or pulseaudio-utils to list microphones"
return out
}
if b, err := runPactl("get-default-source"); err == nil {
out.Default = strings.TrimSpace(string(b))
}
b, err := runPactl("-f", "json", "list", "sources")
if err != nil {
out.Error = "cannot reach the sound server (pactl list sources failed: " + err.Error() + ")"
return out
}
var raw []struct {
Name string `json:"name"`
Description string `json:"description"`
State string `json:"state"`
Mute bool `json:"mute"`
Volume map[string]any `json:"volume"`
Properties map[string]any `json:"properties"`
}
if err := json.Unmarshal(b, &raw); err != nil {
out.Error = "unexpected pactl output: " + err.Error()
return out
}
for _, s := range raw {
// A ".monitor" source is the loopback of a SPEAKER, not a microphone: listing it would let the
// user "record" their own music. Hidden, as Jitsi lists only audioinput kinds.
if strings.HasSuffix(s.Name, ".monitor") || s.Properties["device.class"] == "monitor" {
continue
}
d := Device{ID: s.Name, Label: s.Description, State: s.State, Muted: s.Mute, Default: s.Name == out.Default}
if bus, ok := s.Properties["device.bus"].(string); ok {
d.Bus = bus
}
for _, ch := range s.Volume { // first channel is representative
if m, ok := ch.(map[string]any); ok {
if p, ok := m["value_percent"].(string); ok {
d.Volume = p
break
}
}
}
out.Devices = append(out.Devices, d)
}
// Cards whose active profile is "off" but which offer an input profile.
if b, err := runPactl("-f", "json", "list", "cards"); err == nil {
var cards []struct {
Name string `json:"name"`
ActiveProfile string `json:"active_profile"`
Properties map[string]any `json:"properties"`
Profiles map[string]struct {
Sources int `json:"sources"`
Available bool `json:"available"`
} `json:"profiles"`
}
if json.Unmarshal(b, &cards) == nil {
for _, c := range cards {
if c.ActiveProfile != "off" {
continue
}
for _, p := range c.Profiles {
if p.Sources > 0 && p.Available {
label, _ := c.Properties["device.description"].(string)
if label == "" {
label = c.Name
}
out.OffCards = append(out.OffCards, label)
break
}
}
}
}
}
return out
}
// sameDevices mirrors Jitsi's compareAvailableMediaDevices: emit only when something a user would
// notice changed (a mic came, went, was muted, or the default moved), not on every state flicker.
func sameDevices(a, b DeviceList) bool {
if a.Default != b.Default || len(a.Devices) != len(b.Devices) || a.Error != b.Error || len(a.OffCards) != len(b.OffCards) {
return false
}
for i := range a.Devices {
x, y := a.Devices[i], b.Devices[i]
if x.ID != y.ID || x.Label != y.Label || x.Muted != y.Muted || x.Volume != y.Volume {
return false
}
}
return true
}
// deviceWatcher keeps the current list and pushes a "devices" event on every real change. It listens
// to `pactl subscribe` (the native 'devicechange') and, like Jitsi, also polls as a fallback in case
// the subscription dies.
type deviceWatcher struct {
mu sync.Mutex
last DeviceList
hub *hub
logs *ring
}
func (w *deviceWatcher) current() DeviceList {
w.mu.Lock()
defer w.mu.Unlock()
return w.last
}
func (w *deviceWatcher) refresh(reason string) {
now := listDevices()
w.mu.Lock()
changed := !sameDevices(w.last, now)
w.last = now
w.mu.Unlock()
if changed {
w.logs.add("microphones (%s): %d found, default=%s %s", reason, len(now.Devices), now.Default, now.Error)
w.hub.send(event{"type": "devices", "devices": now})
}
}
func (w *deviceWatcher) run() {
w.refresh("startup")
// Poll fallback (Jitsi polls enumerateDevices when devicechange is unavailable).
go func() {
for range time.Tick(5 * time.Second) {
w.refresh("poll")
}
}()
go func() {
for {
cmd := exec.Command("pactl", "subscribe")
cmd.Env = audioEnv()
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
out, err := cmd.StdoutPipe()
if err == nil && cmd.Start() == nil {
sc := bufio.NewScanner(out)
var debounce *time.Timer
for sc.Scan() {
l := sc.Text()
if strings.Contains(l, " card ") || (strings.Contains(l, " source ") && !strings.Contains(l, "source-output")) {
w.logs.add("sound-server event: %s", l)
}
if strings.Contains(l, " source ") || strings.Contains(l, " card ") || strings.Contains(l, " server ") {
if debounce != nil {
debounce.Stop()
}
debounce = time.AfterFunc(300*time.Millisecond, func() { w.refresh("hot-plug") })
}
}
_ = cmd.Wait()
}
time.Sleep(3 * time.Second) // sound server restarted or unreachable: retry
}
}()
}
// micTest records ~3 s from one device WITHOUT transcribing and says, in plain words, whether the mic
// works — Rudder's version of Jitsi's pre-join audio preview. Level events stream to the plugin while
// it runs so the meter moves.
func (e *engine) micTest(device string) event {
cmds := recorderCmds(device)
var cmd *exec.Cmd
for _, c := range cmds {
if _, err := exec.LookPath(c[0]); err == nil {
cmd = exec.Command(c[0], c[1:]...)
break
}
}
if cmd == nil {
return event{"ok": false, "verdict": "No recorder found (need parec, pw-record or arecord)."}
}
cmd.Env = audioEnv()
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
var errBuf strings.Builder
cmd.Stderr = &errBuf
out, err := cmd.StdoutPipe()
if err != nil || cmd.Start() != nil {
return event{"ok": false, "verdict": "Could not start the recorder."}
}
defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }()
frame := make([]byte, 3200) // 100 ms
peak, frames, zeroFrames := 0, 0, 0
var sumSq float64
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
n, err := io.ReadFull(out, frame)
if err != nil {
why := strings.TrimSpace(errBuf.String())
e.logs.add("mic test on %q failed after %d frames: %v | %s", device, frames, err, why)
return event{"ok": false, "verdict": "The microphone produced no audio. " + why, "recorder": cmd.Path}
}
fp := 0
for i := 0; i+1 < n; i += 2 {
v := int(int16(uint16(frame[i]) | uint16(frame[i+1])<<8))
sumSq += float64(v * v)
if v < 0 {
v = -v
}
if v > fp {
fp = v
}
}
if fp == 0 {
zeroFrames++
}
if fp > peak {
peak = fp
}
frames++
e.hub.send(event{"type": "level", "peak": float64(fp) / 32768, "test": true})
}
rms := 0.0
if frames > 0 {
rms = math.Sqrt(sumSq/float64(frames*1600)) / 32768
}
res := event{"ok": true, "peak": float64(peak) / 32768, "rms": rms, "frames": frames}
switch {
case zeroFrames == frames:
res["ok"] = false
res["verdict"] = "Pure silence — the mic is connected but sends nothing. It may be muted, or a Bluetooth headset's mic link may have dropped (try reconnecting the headset or pick another mic)."
case peak < 330: // below about -40 dBFS
res["verdict"] = "Very quiet — Rudder can hear the mic, but barely. Speak closer or raise its input volume."
default:
res["verdict"] = "Working — Rudder can hear you."
}
e.logs.add("mic test on %q: peak=%d rms=%.4f frames=%d verdict=%s", device, peak, rms, frames, res["verdict"])
return res
}
// ─── Optional Bluetooth headset reset ─────────────────────────────────────────────────────────────
//
// Some Bluetooth headsets drop the mic side of the HFP link (exact-zero audio while everything looks
// connected; the kernel logs `hci0: corrupted SCO packet`). Re-opening the recorder does not revive
// it; a full reset (disconnect → restart PipeWire/WirePlumber → reconnect) can. That is machine-specific,
// so Rudder only runs one if RUDDER_BT_RESET names an executable that does it (exit 0 = headset works
// again). Rate-limited: never more than once per 20 s.
func btResetPath() string {
p := os.Getenv("RUDDER_BT_RESET")
if p == "" {
return ""
}
if st, err := os.Stat(p); err == nil && !st.IsDir() && st.Mode()&0o111 != 0 {
return p
}
return ""
}
var (
resetMu sync.Mutex
lastReset time.Time
)
// runHeadsetReset runs the RUDDER_BT_RESET command and reports whether it said the headset works again.
func runHeadsetReset(logs *ring) (ok bool, ran bool) {
path := btResetPath()
if path == "" {
return false, false
}
resetMu.Lock()
if time.Since(lastReset) < 20*time.Second {
resetMu.Unlock()
logs.add("headset reset skipped: last one was %.0fs ago (rate limit 20s)", time.Since(lastReset).Seconds())
return false, false
}
lastReset = time.Now()
resetMu.Unlock()
logs.add("running %s (headset reset + CVSD)", path)
cmd := exec.Command(path)
cmd.Env = audioEnv()
done := make(chan struct{})
var out []byte
var err error
go func() { out, err = cmd.CombinedOutput(); close(done) }()
select {
case <-done:
case <-time.After(60 * time.Second):
_ = cmd.Process.Kill()
logs.add("headset reset timed out after 60s")
return false, true
}
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if l != "" {
logs.add("[headset reset] %s", l)
}
}
if err != nil {
logs.add("headset reset failed: %v", err)
return false, true
}
return true, true
}
// ─── Bluetooth voice-profile helpers ────────────────────────────────────────────────────────────
// btCard maps a Bluetooth mic (bluez_input.<MAC>.<n>, or "" = the default input) to its card.
func btCard(device string) string {
if device == "" {
if b, err := runPactl("get-default-source"); err == nil {
device = strings.TrimSpace(string(b))
}
}
if !strings.HasPrefix(device, "bluez_input.") {
return ""
}
mac := strings.TrimPrefix(device, "bluez_input.")
if i := strings.LastIndex(mac, "."); i > 0 {
mac = mac[:i]
}
return "bluez_card." + mac
}
func currentProfile(card string) string {
if card == "" {
return "(not bluetooth)"
}
b, err := runPactl("-f", "json", "list", "cards")
if err != nil {
return "?"
}
var cards []struct {
Name string `json:"name"`
ActiveProfile string `json:"active_profile"`
}
if json.Unmarshal(b, &cards) != nil {
return "?"
}
for _, c := range cards {
if c.Name == card {
return c.ActiveProfile
}
}
return "?"
}
// switchProfile forces a fresh voice link: CVSD if the card is on anything else, otherwise a bounce
// through mSBC and back to CVSD (a same-profile set is a no-op and would not renegotiate the link).
func switchProfile(card string, logs *ring) error {
before := currentProfile(card)
target := "headset-head-unit-cvsd"
if before == target {
if _, err := runPactl("set-card-profile", card, "headset-head-unit-msbc"); err != nil {
logs.add("profile bounce via msbc failed: %v", err)
}
time.Sleep(700 * time.Millisecond)
}
if out, err := runPactl("set-card-profile", card, target); err != nil {
return fmt.Errorf("pactl set-card-profile %s %s: %v %s", card, target, err, strings.TrimSpace(string(out)))
}
time.Sleep(1500 * time.Millisecond) // let the new SCO link and the source come up
logs.add("voice profile %s → %s", before, currentProfile(card))
return nil
}
// watchKernelBluetooth copies kernel Bluetooth messages into the log while a session runs
// (`journalctl -k` is readable without root on this system).
func watchKernelBluetooth(ctx context.Context, logs *ring) {
cmd := exec.CommandContext(ctx, "journalctl", "-k", "-f", "-n", "0", "-o", "cat")
out, err := cmd.StdoutPipe()
if err != nil || cmd.Start() != nil {
return
}
sc := bufio.NewScanner(out)
for sc.Scan() {
if l := sc.Text(); strings.Contains(strings.ToLower(l), "bluetooth") {
logs.add("kernel: %s", l)
}
}
_ = cmd.Wait()
}