This commit is contained in:
legop3
2026-03-17 03:11:04 -04:00
parent aaa93847e1
commit 84eaab3c9c
13 changed files with 107 additions and 79 deletions
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+51 -11
View File
@@ -17,29 +17,69 @@ pcm.dmixer {
} }
} }
# Software playback volume (adjust with: amixer -c0 sset 'SoftMaster' 70%) # TTS volume control (used by default playback path).
pcm.softvol { pcm.tts_softvol {
type softvol type softvol
slave.pcm "dmixer" slave.pcm "dmixer"
control { control {
name "SoftMaster" name "TTSMaster"
card 0 card 0
} }
min_dB -51.0 min_dB -60.0
max_dB 0.0 max_dB 12.0
} }
# Allow clients with mismatched sample formats/rates to use the mixer. # Horn volume control.
pcm.playback { 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 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 { pcm.!default {
type asym type asym
playback.pcm "playback" playback.pcm "tts"
capture.pcm "hw:0,0" capture.pcm "rovermic"
} }
ctl.!default { ctl.!default {
+1 -1
View File
@@ -13,7 +13,7 @@ fi
source "$ENV_FILE" source "$ENV_FILE"
: "${AUDIO_FORWARD_URL:?AUDIO_FORWARD_URL not set in ${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 if [[ -n "${FFMPEG_BIN:-}" ]]; then
FFMPEG_BIN_PATH="$FFMPEG_BIN" FFMPEG_BIN_PATH="$FFMPEG_BIN"
+1 -1
View File
@@ -223,7 +223,7 @@ AUDIO_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request
VIDEO_BITRATE=2000000 VIDEO_BITRATE=2000000
AUDIO_ENABLE=0 AUDIO_ENABLE=0
AUDIO_DEVICE=hw:0,0 AUDIO_DEVICE=hw:0,0
AUDIO_PLAYBACK_DEVICE=default AUDIO_PLAYBACK_DEVICE=forward
AUDIO_RATE=48000 AUDIO_RATE=48000
AUDIO_CHANNELS=2 AUDIO_CHANNELS=2
ENV ENV
+39 -3
View File
@@ -1,5 +1,11 @@
package roverd package roverd
import (
"fmt"
"math"
"os/exec"
)
type AudioLevels struct { type AudioLevels struct {
HornGain float64 HornGain float64
TTSGain float64 TTSGain float64
@@ -34,9 +40,7 @@ func (c *WSClient) setAudioLevels(next AudioLevels) {
c.audioMu.Lock() c.audioMu.Lock()
c.audioLevels = normalized c.audioLevels = normalized
c.audioMu.Unlock() c.audioMu.Unlock()
if c.horn != nil { c.applyAudioLevelsToMixer(normalized)
c.horn.SetGlobalGain(normalized.HornGain)
}
} }
func (c *WSClient) handleAudioLevels(payload *audioLevelsPayload) error { func (c *WSClient) handleAudioLevels(payload *audioLevelsPayload) error {
@@ -56,3 +60,35 @@ func (c *WSClient) handleAudioLevels(payload *audioLevelsPayload) error {
c.setAudioLevels(levels) c.setAudioLevels(levels)
return nil 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))
}
}
+3 -3
View File
@@ -172,7 +172,7 @@ func LoadConfig(path string) (*Config, error) {
Audio: AudioConfig{ Audio: AudioConfig{
CaptureEnabled: false, CaptureEnabled: false,
CaptureDevice: "rovermic", CaptureDevice: "rovermic",
PlaybackDevice: "default", PlaybackDevice: "forward",
SampleRate: 48000, SampleRate: 48000,
Channels: 2, Channels: 2,
Bitrate: 24000, Bitrate: 24000,
@@ -314,8 +314,8 @@ func validateAudioConfig(cfg *AudioConfig) {
if cfg.CaptureEnabled && cfg.CaptureDevice == "" { if cfg.CaptureEnabled && cfg.CaptureDevice == "" {
cfg.CaptureDevice = "hw:0,0" cfg.CaptureDevice = "hw:0,0"
} }
if cfg.PlaybackDevice == "" { if cfg.PlaybackDevice == "" || cfg.PlaybackDevice == "default" {
cfg.PlaybackDevice = "default" cfg.PlaybackDevice = "forward"
} }
if cfg.SampleRate <= 0 { if cfg.SampleRate <= 0 {
cfg.SampleRate = 48000 cfg.SampleRate = 48000
+4 -3
View File
@@ -124,10 +124,11 @@ func (h *HornSynth) run(waveform string, freqs []float64, stop <-chan struct{})
h.mu.Unlock() h.mu.Unlock()
volume *= gain volume *= gain
args := []string{"-q", "-f", "S16_LE", "-c", fmt.Sprintf("%d", channels), "-r", fmt.Sprintf("%d", rate), "-t", "raw"} device := strings.TrimSpace(h.cfg.Device)
if h.cfg.Device != "" { if device == "" {
args = append(args, "-D", h.cfg.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...) cmd := exec.Command("aplay", args...)
stdin, err := cmd.StdinPipe() stdin, err := cmd.StdinPipe()
if err != nil { if err != nil {
+1 -1
View File
@@ -54,7 +54,7 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice) fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice)
playbackDevice := audio.PlaybackDevice playbackDevice := audio.PlaybackDevice
if playbackDevice == "" { if playbackDevice == "" {
playbackDevice = "default" playbackDevice = "forward"
} }
fmt.Fprintf(&buf, "AUDIO_PLAYBACK_DEVICE=%s\n", playbackDevice) fmt.Fprintf(&buf, "AUDIO_PLAYBACK_DEVICE=%s\n", playbackDevice)
fmt.Fprintf(&buf, "AUDIO_RATE=%d\n", audio.SampleRate) fmt.Fprintf(&buf, "AUDIO_RATE=%d\n", audio.SampleRate)
+1 -1
View File
@@ -39,7 +39,7 @@ cameraServo:
audio: audio:
captureEnabled: false captureEnabled: false
captureDevice: hw:0,0 captureDevice: hw:0,0
playbackDevice: default playbackDevice: forward
sampleRate: 48000 sampleRate: 48000
channels: 2 channels: 2
bitrate: 24000 bitrate: 24000
+5 -52
View File
@@ -3,7 +3,6 @@ package roverd
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"strings" "strings"
"time" "time"
@@ -48,75 +47,29 @@ func (c *WSClient) handleTTSPayload(ctx context.Context, payload *ttsPayload) er
runCtx, cancel := context.WithTimeout(ctx, 12*time.Second) runCtx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel() 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 var cmd *exec.Cmd
switch engine { switch engine {
case "espeak", "e": case "espeak", "e":
args := []string{"-w", outputWavPath} args := []string{}
if pitch > 0 { if pitch > 0 {
args = append(args, "-p", fmt.Sprintf("%d", pitch)) args = append(args, "-p", fmt.Sprintf("%d", pitch))
} }
args = append(args, text) args = append(args, text)
cmd = exec.CommandContext(ctx, "espeak", args...) cmd = exec.CommandContext(runCtx, "espeak", args...)
case "flite", "f": case "flite", "f":
args := []string{} args := []string{}
if voice != "" { if voice != "" {
args = append(args, "-voice", voice) args = append(args, "-voice", voice)
} }
args = append(args, "-t", text, "-o", outputWavPath) args = append(args, "-t", text)
cmd = exec.CommandContext(ctx, "flite", args...) cmd = exec.CommandContext(runCtx, "flite", args...)
default: default:
return fmt.Errorf("unsupported tts engine: %s", engine) return fmt.Errorf("unsupported tts engine: %s", engine)
} }
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("tts synth failed: %w (%s)", err, string(out)) return fmt.Errorf("tts exec 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 nil return nil
} }
+1 -3
View File
@@ -64,9 +64,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
ForwardGain: 1.0, ForwardGain: 1.0,
}, },
} }
if client.horn != nil { client.applyAudioLevelsToMixer(client.audioLevels)
client.horn.SetGlobalGain(client.audioLevels.HornGain)
}
return client return client
} }