diff --git a/dist/hornverifier b/dist/hornverifier index 39a2e08d..b5312458 100755 Binary files a/dist/hornverifier and b/dist/hornverifier differ diff --git a/dist/roverd b/dist/roverd index 30efd030..203aa1f3 100755 Binary files a/dist/roverd and b/dist/roverd differ diff --git a/dist/servoverifier b/dist/servoverifier index 22b3502b..5435762b 100755 Binary files a/dist/servoverifier and b/dist/servoverifier differ diff --git a/pi/asound.conf b/pi/asound.conf index 33875998..b337553d 100644 --- a/pi/asound.conf +++ b/pi/asound.conf @@ -17,29 +17,69 @@ pcm.dmixer { } } -# Software playback volume (adjust with: amixer -c0 sset 'SoftMaster' 70%) -pcm.softvol { +# TTS volume control (used by default playback path). +pcm.tts_softvol { type softvol slave.pcm "dmixer" control { - name "SoftMaster" + name "TTSMaster" card 0 } - min_dB -51.0 - max_dB 0.0 + min_dB -60.0 + max_dB 12.0 } -# Allow clients with mismatched sample formats/rates to use the mixer. -pcm.playback { +# Horn volume control. +pcm.horn_softvol { + type softvol + slave.pcm "dmixer" + control { + name "HornMaster" + card 0 + } + min_dB -60.0 + max_dB 12.0 +} + +# Forwarded audio volume control. +pcm.forward_softvol { + type softvol + slave.pcm "dmixer" + control { + name "ForwardMaster" + card 0 + } + min_dB -60.0 + max_dB 12.0 +} + +# Per-source playback PCMs. +pcm.tts { type plug - slave.pcm "softvol" + slave.pcm "tts_softvol" } -# Defaults: playback through shared mixer + softvol, capture raw on the HAT +pcm.horn { + type plug + slave.pcm "horn_softvol" +} + +pcm.forward { + type plug + slave.pcm "forward_softvol" +} + +# Capture alias used by rover config defaults. +pcm.rovermic { + type plug + slave.pcm "hw:0,0" +} + +# Defaults: TTS direct playback + raw capture on the HAT. pcm.!default { type asym - playback.pcm "playback" - capture.pcm "hw:0,0" + playback.pcm "tts" + capture.pcm "rovermic" } ctl.!default { diff --git a/pi/bin/audio-forward-listener.sh b/pi/bin/audio-forward-listener.sh index f2160fed..6f67141e 100755 --- a/pi/bin/audio-forward-listener.sh +++ b/pi/bin/audio-forward-listener.sh @@ -13,7 +13,7 @@ fi source "$ENV_FILE" : "${AUDIO_FORWARD_URL:?AUDIO_FORWARD_URL not set in ${ENV_FILE}}" -PLAYBACK_DEVICE="${AUDIO_PLAYBACK_DEVICE:-default}" +PLAYBACK_DEVICE="${AUDIO_PLAYBACK_DEVICE:-forward}" if [[ -n "${FFMPEG_BIN:-}" ]]; then FFMPEG_BIN_PATH="$FFMPEG_BIN" diff --git a/pi/install_roverd.sh b/pi/install_roverd.sh index 9e0b6b5f..06360c47 100755 --- a/pi/install_roverd.sh +++ b/pi/install_roverd.sh @@ -223,7 +223,7 @@ AUDIO_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request VIDEO_BITRATE=2000000 AUDIO_ENABLE=0 AUDIO_DEVICE=hw:0,0 -AUDIO_PLAYBACK_DEVICE=default +AUDIO_PLAYBACK_DEVICE=forward AUDIO_RATE=48000 AUDIO_CHANNELS=2 ENV diff --git a/pi/roverd/audio_levels.go b/pi/roverd/audio_levels.go index acdab77c..73b6307f 100644 --- a/pi/roverd/audio_levels.go +++ b/pi/roverd/audio_levels.go @@ -1,5 +1,11 @@ package roverd +import ( + "fmt" + "math" + "os/exec" +) + type AudioLevels struct { HornGain float64 TTSGain float64 @@ -34,9 +40,7 @@ func (c *WSClient) setAudioLevels(next AudioLevels) { c.audioMu.Lock() c.audioLevels = normalized c.audioMu.Unlock() - if c.horn != nil { - c.horn.SetGlobalGain(normalized.HornGain) - } + c.applyAudioLevelsToMixer(normalized) } func (c *WSClient) handleAudioLevels(payload *audioLevelsPayload) error { @@ -56,3 +60,35 @@ func (c *WSClient) handleAudioLevels(payload *audioLevelsPayload) error { c.setAudioLevels(levels) return nil } + +func (c *WSClient) applyAudioLevelsToMixer(levels AudioLevels) { + c.applyMixerGain("HornMaster", levels.HornGain) + c.applyMixerGain("TTSMaster", levels.TTSGain) + c.applyMixerGain("ForwardMaster", levels.ForwardGain) +} + +func (c *WSClient) applyMixerGain(control string, gain float64) { + normalized := clampAudioGain(gain) + if normalized <= 0 { + out, err := exec.Command("amixer", "-q", "-c", "0", "sset", control, "0%").CombinedOutput() + if err != nil { + c.log.Printf("audio-levels: amixer mute %s failed: %v (%s)", control, err, string(out)) + } + return + } + + // Convert linear gain to dB, matching softvol max_dB=12.0 in /etc/asound.conf. + db := 20.0 * math.Log10(normalized) + if db > 12.0 { + db = 12.0 + } + if db < -60.0 { + db = -60.0 + } + + dbArg := fmt.Sprintf("%.2fdB", db) + out, err := exec.Command("amixer", "-q", "-c", "0", "sset", control, dbArg).CombinedOutput() + if err != nil { + c.log.Printf("audio-levels: amixer set %s=%s failed: %v (%s)", control, dbArg, err, string(out)) + } +} diff --git a/pi/roverd/config.go b/pi/roverd/config.go index bbf44564..a2ec48e6 100644 --- a/pi/roverd/config.go +++ b/pi/roverd/config.go @@ -172,7 +172,7 @@ func LoadConfig(path string) (*Config, error) { Audio: AudioConfig{ CaptureEnabled: false, CaptureDevice: "rovermic", - PlaybackDevice: "default", + PlaybackDevice: "forward", SampleRate: 48000, Channels: 2, Bitrate: 24000, @@ -314,8 +314,8 @@ func validateAudioConfig(cfg *AudioConfig) { if cfg.CaptureEnabled && cfg.CaptureDevice == "" { cfg.CaptureDevice = "hw:0,0" } - if cfg.PlaybackDevice == "" { - cfg.PlaybackDevice = "default" + if cfg.PlaybackDevice == "" || cfg.PlaybackDevice == "default" { + cfg.PlaybackDevice = "forward" } if cfg.SampleRate <= 0 { cfg.SampleRate = 48000 diff --git a/pi/roverd/horn.go b/pi/roverd/horn.go index 7c4b3c71..cdb5e496 100644 --- a/pi/roverd/horn.go +++ b/pi/roverd/horn.go @@ -124,10 +124,11 @@ func (h *HornSynth) run(waveform string, freqs []float64, stop <-chan struct{}) h.mu.Unlock() volume *= gain - args := []string{"-q", "-f", "S16_LE", "-c", fmt.Sprintf("%d", channels), "-r", fmt.Sprintf("%d", rate), "-t", "raw"} - if h.cfg.Device != "" { - args = append(args, "-D", h.cfg.Device) + device := strings.TrimSpace(h.cfg.Device) + if device == "" { + device = "horn" } + args := []string{"-q", "-D", device, "-f", "S16_LE", "-c", fmt.Sprintf("%d", channels), "-r", fmt.Sprintf("%d", rate), "-t", "raw"} cmd := exec.Command("aplay", args...) stdin, err := cmd.StdinPipe() if err != nil { diff --git a/pi/roverd/media_env.go b/pi/roverd/media_env.go index 4ef97b51..b2448baf 100644 --- a/pi/roverd/media_env.go +++ b/pi/roverd/media_env.go @@ -54,7 +54,7 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error { fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice) playbackDevice := audio.PlaybackDevice if playbackDevice == "" { - playbackDevice = "default" + playbackDevice = "forward" } fmt.Fprintf(&buf, "AUDIO_PLAYBACK_DEVICE=%s\n", playbackDevice) fmt.Fprintf(&buf, "AUDIO_RATE=%d\n", audio.SampleRate) diff --git a/pi/roverd/roverd.sample.yaml b/pi/roverd/roverd.sample.yaml index 439f733d..86163db6 100644 --- a/pi/roverd/roverd.sample.yaml +++ b/pi/roverd/roverd.sample.yaml @@ -39,7 +39,7 @@ cameraServo: audio: captureEnabled: false captureDevice: hw:0,0 - playbackDevice: default + playbackDevice: forward sampleRate: 48000 channels: 2 bitrate: 24000 diff --git a/pi/roverd/tts.go b/pi/roverd/tts.go index dc4a2825..1ec11a33 100644 --- a/pi/roverd/tts.go +++ b/pi/roverd/tts.go @@ -3,7 +3,6 @@ package roverd import ( "context" "fmt" - "os" "os/exec" "strings" "time" @@ -48,75 +47,29 @@ func (c *WSClient) handleTTSPayload(ctx context.Context, payload *ttsPayload) er runCtx, cancel := context.WithTimeout(ctx, 12*time.Second) defer cancel() - tmp, err := os.CreateTemp("", "roverd-tts-*.wav") - if err != nil { - return fmt.Errorf("tts temp file: %w", err) - } - tmpPath := tmp.Name() - _ = tmp.Close() - defer os.Remove(tmpPath) - - if err := synthTTS(runCtx, engine, voice, pitch, text, tmpPath); err != nil { - return err - } - if err := playTTSFile(runCtx, tmpPath, c.cfg.Audio.PlaybackDevice, c.getAudioLevels().TTSGain); err != nil { - return err - } - return nil -} - -func synthTTS(ctx context.Context, engine, voice string, pitch int, text, outputWavPath string) error { var cmd *exec.Cmd switch engine { case "espeak", "e": - args := []string{"-w", outputWavPath} + args := []string{} if pitch > 0 { args = append(args, "-p", fmt.Sprintf("%d", pitch)) } args = append(args, text) - cmd = exec.CommandContext(ctx, "espeak", args...) + cmd = exec.CommandContext(runCtx, "espeak", args...) case "flite", "f": args := []string{} if voice != "" { args = append(args, "-voice", voice) } - args = append(args, "-t", text, "-o", outputWavPath) - cmd = exec.CommandContext(ctx, "flite", args...) + args = append(args, "-t", text) + cmd = exec.CommandContext(runCtx, "flite", args...) default: return fmt.Errorf("unsupported tts engine: %s", engine) } out, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("tts synth failed: %w (%s)", err, string(out)) - } - return nil -} - -func playTTSFile(ctx context.Context, wavPath, playbackDevice string, gain float64) error { - if playbackDevice == "" { - playbackDevice = "default" - } - gain = clampAudioGain(gain) - if gain == 0 { - // Mute is an explicit value users may choose. - return nil - } - - args := []string{ - "-hide_banner", - "-loglevel", "warning", - "-i", wavPath, - "-af", fmt.Sprintf("aresample=16000,volume=%g", gain), - "-ac", "1", - "-ar", "16000", - "-f", "alsa", - playbackDevice, - } - cmd := exec.CommandContext(ctx, "ffmpeg", args...) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("tts playback failed: %w (%s)", err, string(out)) + return fmt.Errorf("tts exec failed: %w (%s)", err, string(out)) } return nil } diff --git a/pi/roverd/wsclient.go b/pi/roverd/wsclient.go index f683bfc7..3c1e4d92 100644 --- a/pi/roverd/wsclient.go +++ b/pi/roverd/wsclient.go @@ -64,9 +64,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even ForwardGain: 1.0, }, } - if client.horn != nil { - client.horn.SetGlobalGain(client.audioLevels.HornGain) - } + client.applyAudioLevelsToMixer(client.audioLevels) return client }