This commit is contained in:
legop3
2026-03-16 21:30:56 -04:00
parent 4aa53e8f80
commit aaa93847e1
20 changed files with 567 additions and 140 deletions
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+25 -3
View File
@@ -1,10 +1,26 @@
# Use the Google Voice HAT soundcard as the primary device by name (card id is "sndrpigooglevoi")
options snd_rpi_googlevoicehat_soundcard index=0
# Mix multiple playback clients in software with a fixed low-cost format.
pcm.dmixer {
type dmix
ipc_key 1024
ipc_perm 0666
slave {
pcm "hw:0,0"
format S16_LE
rate 16000
channels 1
period_time 0
period_size 1024
buffer_size 4096
}
}
# Software playback volume (adjust with: amixer -c0 sset 'SoftMaster' 70%)
pcm.softvol {
type softvol
slave.pcm "plughw:0,0"
slave.pcm "dmixer"
control {
name "SoftMaster"
card 0
@@ -13,10 +29,16 @@ pcm.softvol {
max_dB 0.0
}
# Defaults: playback through softvol, capture raw on the HAT
# Allow clients with mismatched sample formats/rates to use the mixer.
pcm.playback {
type plug
slave.pcm "softvol"
}
# Defaults: playback through shared mixer + softvol, capture raw on the HAT
pcm.!default {
type asym
playback.pcm "softvol"
playback.pcm "playback"
capture.pcm "hw:0,0"
}
+58
View File
@@ -0,0 +1,58 @@
package roverd
type AudioLevels struct {
HornGain float64
TTSGain float64
ForwardGain float64
}
func clampAudioGain(v float64) float64 {
if v < 0 {
return 0
}
if v > 4 {
return 4
}
return v
}
func normalizeAudioLevels(v AudioLevels) AudioLevels {
v.HornGain = clampAudioGain(v.HornGain)
v.TTSGain = clampAudioGain(v.TTSGain)
v.ForwardGain = clampAudioGain(v.ForwardGain)
return v
}
func (c *WSClient) getAudioLevels() AudioLevels {
c.audioMu.RLock()
defer c.audioMu.RUnlock()
return c.audioLevels
}
func (c *WSClient) setAudioLevels(next AudioLevels) {
normalized := normalizeAudioLevels(next)
c.audioMu.Lock()
c.audioLevels = normalized
c.audioMu.Unlock()
if c.horn != nil {
c.horn.SetGlobalGain(normalized.HornGain)
}
}
func (c *WSClient) handleAudioLevels(payload *audioLevelsPayload) error {
if payload == nil {
return nil
}
levels := c.getAudioLevels()
if payload.HornGain != nil {
levels.HornGain = clampAudioGain(*payload.HornGain)
}
if payload.TTSGain != nil {
levels.TTSGain = clampAudioGain(*payload.TTSGain)
}
if payload.ForwardGain != nil {
levels.ForwardGain = clampAudioGain(*payload.ForwardGain)
}
c.setAudioLevels(levels)
return nil
}
+7
View File
@@ -29,6 +29,7 @@ type inboundMessage struct {
Servo *servoPayload `json:"servo,omitempty"`
TTS *ttsPayload `json:"tts,omitempty"`
Horn *hornPayload `json:"horn,omitempty"`
AudioLevels *audioLevelsPayload `json:"audioLevels,omitempty"`
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
Song *songPayload `json:"song,omitempty"`
Reboot *rebootPayload `json:"reboot,omitempty"`
@@ -73,6 +74,12 @@ type hornPayload struct {
Freqs []float64 `json:"freqs,omitempty"`
}
type audioLevelsPayload struct {
HornGain *float64 `json:"hornGain,omitempty"`
TTSGain *float64 `json:"ttsGain,omitempty"`
ForwardGain *float64 `json:"forwardGain,omitempty"`
}
type nightVisionPayload struct {
Action string `json:"action"`
}
+16 -4
View File
@@ -18,8 +18,9 @@ const (
)
type HornSynth struct {
cfg HornConfig
log *log.Logger
cfg HornConfig
log *log.Logger
gain float64
mu sync.Mutex
stop chan struct{}
@@ -29,11 +30,18 @@ type HornSynth struct {
func NewHornSynth(cfg HornConfig, logger *log.Logger) *HornSynth {
return &HornSynth{
cfg: cfg,
log: logger,
cfg: cfg,
log: logger,
gain: 1.0,
}
}
func (h *HornSynth) SetGlobalGain(gain float64) {
h.mu.Lock()
defer h.mu.Unlock()
h.gain = clampAudioGain(gain)
}
func (h *HornSynth) HandlePayload(payload *hornPayload) error {
if payload == nil {
return fmt.Errorf("horn payload required")
@@ -111,6 +119,10 @@ func (h *HornSynth) run(waveform string, freqs []float64, stop <-chan struct{})
if volume > 1 {
volume = 1
}
h.mu.Lock()
gain := h.gain
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 != "" {
+52 -5
View File
@@ -3,6 +3,7 @@ package roverd
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
@@ -47,29 +48,75 @@ 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{}
args := []string{"-w", outputWavPath}
if pitch > 0 {
args = append(args, "-p", fmt.Sprintf("%d", pitch))
}
args = append(args, text)
cmd = exec.CommandContext(runCtx, "espeak", args...)
cmd = exec.CommandContext(ctx, "espeak", args...)
case "flite", "f":
args := []string{}
if voice != "" {
args = append(args, "-voice", voice)
}
args = append(args, "-t", text)
cmd = exec.CommandContext(runCtx, "flite", args...)
args = append(args, "-t", text, "-o", outputWavPath)
cmd = exec.CommandContext(ctx, "flite", args...)
default:
return fmt.Errorf("unsupported tts engine: %s", engine)
}
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("tts exec failed: %w (%s)", err, string(out))
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 nil
}
+14 -1
View File
@@ -34,6 +34,8 @@ type WSClient struct {
rebootT *time.Timer
seekIssued bool
rebootIssued bool
audioLevels AudioLevels
audioMu sync.RWMutex
}
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
@@ -45,7 +47,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
if cfg.Horn.Enabled {
horn = NewHornSynth(cfg.Horn, logger)
}
return &WSClient{
client := &WSClient{
cfg: cfg,
adapter: adapter,
sensorFrames: frames,
@@ -56,7 +58,16 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
nightVision: nightVision,
log: logger,
ttsQueue: ttsQueue,
audioLevels: AudioLevels{
HornGain: 1.0,
TTSGain: 1.0,
ForwardGain: 1.0,
},
}
if client.horn != nil {
client.horn.SetGlobalGain(client.audioLevels.HornGain)
}
return client
}
func (c *WSClient) Run(ctx context.Context) error {
@@ -200,6 +211,8 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return fmt.Errorf("horn disabled")
}
return c.horn.HandlePayload(msg.Horn)
case msg.AudioLevels != nil:
return c.handleAudioLevels(msg.AudioLevels)
case msg.NightVision != nil:
if c.nightVision == nil {
return fmt.Errorf("night vision disabled")
+6
View File
@@ -28,6 +28,12 @@ audioForward:
# Optional stream suffix for fallback URL generation
streamSuffix: "-fwd"
audioLevels:
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
hornGain: 1.0
ttsGain: 1.0
forwardGain: 1.0
homeAssistant:
url: "http://homeassistant.local:8123"
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
+1
View File
@@ -30,6 +30,7 @@ require('./src/services/embedHttpService');
require('./src/services/logStreamService');
require('./src/services/adminLogService');
require('./src/services/homeAssistantService');
require('./src/services/audioLevelsService');
require('./src/services/audioForwardService');
require('./src/services/sessionService');
require('./src/services/batteryManager');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-5J3deGq0.js"></script>
<script type="module" crossorigin src="/assets/index-C4govqUc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-WqYsCIRI.css">
</head>
<body>
+12 -1
View File
@@ -7,6 +7,7 @@ const logger = require('../globals/logger').child('audioForwardService');
const { loadConfig } = require('../helpers/configLoader');
const roverManager = require('./roverManager');
const { isAdmin } = require('./roleService');
const { getAudioLevels, audioLevelsEvents } = require('./audioLevelsService');
const audioForwardEvents = new EventEmitter();
const config = loadConfig();
@@ -200,6 +201,7 @@ function buildSilenceWriterArgs() {
}
function buildClipWriterArgs(filePath) {
const forwardGain = Math.max(0, Number(getAudioLevels()?.forwardGain) || 1);
return [
'-hide_banner',
'-loglevel',
@@ -209,7 +211,7 @@ function buildClipWriterArgs(filePath) {
filePath,
'-vn',
'-af',
'aresample=16000,volume=12dB',
`aresample=16000,volume=${forwardGain}`,
'-f',
's16le',
'-ac',
@@ -394,6 +396,15 @@ roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
}
});
audioLevelsEvents.on('change', () => {
workers.forEach((worker, roverId) => {
if (worker?.contentKind === 'clip') {
// Restart clip writer so forward gain changes are immediately reflected.
startClipWriter(roverId, testAudioPath);
}
});
});
io.on('connection', (socket) => {
socket.on('audio:testPlay', ({ roverId } = {}, cb = () => {}) => {
try {
+153
View File
@@ -0,0 +1,153 @@
const fs = require('fs');
const path = require('path');
const EventEmitter = require('events');
const io = require('../globals/io');
const logger = require('../globals/logger').child('audioLevelsService');
const { loadConfig } = require('../helpers/configLoader');
const { isAdmin } = require('./roleService');
const roverManager = require('./roverManager');
const { issueCommand } = require('./commandService');
const audioLevelsEvents = new EventEmitter();
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const STORE_PATH = path.join(DATA_DIR, 'audio-levels.json');
const config = loadConfig();
const configuredDefaults = config.audioLevels || {};
const DEFAULTS = {
hornGain: clampGain(configuredDefaults.hornGain, 1),
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
};
function clampGain(value, fallback = 1) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(0, Math.min(4, num));
}
function normalizeStore(raw = {}) {
return {
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
};
}
let state = null;
function loadState() {
if (state) return state;
try {
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
state = normalizeStore(raw);
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load audio levels store', err.message);
}
state = normalizeStore({});
}
return state;
}
function persistState(next) {
fs.mkdirSync(DATA_DIR, { recursive: true });
const normalized = normalizeStore(next);
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
fs.renameSync(tempPath, STORE_PATH);
state = normalized;
return state;
}
function getAudioLevels() {
const current = loadState();
return {
hornGain: current.hornGain,
ttsGain: current.ttsGain,
forwardGain: current.forwardGain,
updatedAt: current.updatedAt,
updatedBy: current.updatedBy,
};
}
function emitChange(reason = 'update') {
audioLevelsEvents.emit('change', {
reason,
levels: getAudioLevels(),
});
}
function pushLevelsToRover(roverId) {
if (!roverId) return;
const record = roverManager.rovers.get(roverId);
if (!record || !record.ws) return;
try {
issueCommand(roverId, {
type: 'audioLevels',
audioLevels: getAudioLevels(),
});
} catch (err) {
logger.warn('Failed to push audio levels to rover', roverId, err.message);
}
}
function pushLevelsToAllRovers() {
roverManager.rovers.forEach((record, roverId) => {
if (record?.ws) {
pushLevelsToRover(roverId);
}
});
}
function setAudioLevels(input = {}, actor = null) {
const current = loadState();
const next = {
...current,
hornGain: clampGain(input.hornGain, current.hornGain),
ttsGain: clampGain(input.ttsGain, current.ttsGain),
forwardGain: clampGain(input.forwardGain, current.forwardGain),
updatedAt: Date.now(),
updatedBy: actor,
};
persistState(next);
pushLevelsToAllRovers();
emitChange('set');
return getAudioLevels();
}
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (action === 'upsert' && roverId) {
pushLevelsToRover(roverId);
}
});
io.on('connection', (socket) => {
socket.on('audioLevels:get', (_, cb = () => {}) => {
cb({ success: true, levels: getAudioLevels() });
});
socket.on('audioLevels:set', (payload = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
throw new Error('Not authorized');
}
const actor = socket?.data?.user?.username || null;
const levels = setAudioLevels(payload || {}, actor);
cb({ success: true, levels });
} catch (err) {
cb({ error: err.message });
}
});
});
loadState();
module.exports = {
getAudioLevels,
setAudioLevels,
pushLevelsToRover,
audioLevelsEvents,
};
+3
View File
@@ -67,6 +67,9 @@ io.on('connection', (socket) => {
if (!type) {
throw new Error('type required');
}
if (type === 'audioLevels') {
throw new Error('audioLevels command is service-managed');
}
const payload = data ? { ...data } : {};
const isRebootCommand = type === 'reboot';
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
+6
View File
@@ -23,6 +23,7 @@ const { getAdminReason } = require('./adminReasonService');
const { subscribe } = require('./eventBus');
const { getSocketIp, isLocalNetwork } = require('../helpers/ipResolver');
const { getAudioForwardState, audioForwardEvents } = require('./audioForwardService');
const { getAudioLevels, audioLevelsEvents } = require('./audioLevelsService');
const config = loadConfig();
const discordInvite = config.discord?.invite || null;
@@ -93,6 +94,7 @@ function buildSession(socket) {
verification: getVerificationStateForSocket(socket),
isVerified: Boolean(socket?.data?.isVerified),
audioForward: getAudioForwardState(),
audioLevels: getAudioLevels(),
};
}
@@ -264,6 +266,10 @@ audioForwardEvents.on('change', () => {
syncAll();
});
audioLevelsEvents.on('change', () => {
syncAll();
});
// sync all sockets 20 seconds
setInterval(() => {
logger.info('Periodic session sync for all clients');
+86
View File
@@ -22,6 +22,7 @@ export default function AdminPanel() {
rebootServer,
playTestAudio,
stopTestAudio,
setAudioLevels,
llmControl,
adminLogs,
llmCommentaryState,
@@ -39,6 +40,12 @@ export default function AdminPanel() {
const currentReason = session?.adminReason?.text || '';
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
const [reasonDraft, setReasonDraft] = useState(currentReason);
const currentAudioLevels = session?.audioLevels || {};
const [audioLevelDraft, setAudioLevelDraft] = useState({
hornGain: Number.isFinite(currentAudioLevels.hornGain) ? currentAudioLevels.hornGain : 1,
ttsGain: Number.isFinite(currentAudioLevels.ttsGain) ? currentAudioLevels.ttsGain : 1,
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
});
const isAdmin =
session?.role === 'admin' ||
@@ -170,6 +177,19 @@ export default function AdminPanel() {
}
};
const handleAudioLevelDraft = (key) => (event) => {
const next = Number(event.target.value);
setAudioLevelDraft((current) => ({ ...(current || {}), [key]: Number.isFinite(next) ? next : 1 }));
};
const handleAudioLevelsSave = async () => {
try {
await setAudioLevels(audioLevelDraft);
} catch (err) {
alert(err.message);
}
};
useEffect(() => {
setGoalDraft(currentGoal);
}, [currentGoal]);
@@ -178,6 +198,14 @@ export default function AdminPanel() {
setReasonDraft(currentReason);
}, [currentReason]);
useEffect(() => {
setAudioLevelDraft({
hornGain: Number.isFinite(currentAudioLevels.hornGain) ? currentAudioLevels.hornGain : 1,
ttsGain: Number.isFinite(currentAudioLevels.ttsGain) ? currentAudioLevels.ttsGain : 1,
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
});
}, [currentAudioLevels.forwardGain, currentAudioLevels.hornGain, currentAudioLevels.ttsGain]);
const lockMap = useMemo(() => {
const map = {};
roster.forEach((rover) => {
@@ -210,6 +238,64 @@ export default function AdminPanel() {
{serverRebooting ? 'Server rebooting...' : 'Reboot Server'}
</button>
</div>
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Global audio levels</span>
{session?.audioLevels?.updatedAt ? (
<span>Updated {new Date(session.audioLevels.updatedAt).toLocaleString()}</span>
) : null}
</div>
<label className="grid gap-0.5 text-xs text-slate-200">
<div className="flex items-center justify-between gap-0.5">
<span>Horn gain</span>
<span>{audioLevelDraft.hornGain.toFixed(2)}x</span>
</div>
<input
type="range"
min="0"
max="4"
step="0.01"
value={audioLevelDraft.hornGain}
onChange={handleAudioLevelDraft('hornGain')}
className="w-full accent-emerald-500"
/>
</label>
<label className="grid gap-0.5 text-xs text-slate-200">
<div className="flex items-center justify-between gap-0.5">
<span>TTS gain</span>
<span>{audioLevelDraft.ttsGain.toFixed(2)}x</span>
</div>
<input
type="range"
min="0"
max="4"
step="0.01"
value={audioLevelDraft.ttsGain}
onChange={handleAudioLevelDraft('ttsGain')}
className="w-full accent-emerald-500"
/>
</label>
<label className="grid gap-0.5 text-xs text-slate-200">
<div className="flex items-center justify-between gap-0.5">
<span>Forward gain</span>
<span>{audioLevelDraft.forwardGain.toFixed(2)}x</span>
</div>
<input
type="range"
min="0"
max="4"
step="0.01"
value={audioLevelDraft.forwardGain}
onChange={handleAudioLevelDraft('forwardGain')}
className="w-full accent-emerald-500"
/>
</label>
<div className="flex gap-0.5 text-xs">
<button type="button" onClick={handleAudioLevelsSave} className="button-dark">
Apply audio levels
</button>
</div>
</div>
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Community goal</span>
+2
View File
@@ -28,6 +28,7 @@ const SessionContext = createContext({
rebootServer: async () => {},
playTestAudio: async () => {},
stopTestAudio: async () => {},
setAudioLevels: async () => {},
llmControl: async () => {},
});
@@ -145,6 +146,7 @@ export function SessionProvider({ children }) {
rebootServer: () => emitWithAck('server:reboot'),
playTestAudio: (roverId) => emitWithAck('audio:testPlay', { roverId }),
stopTestAudio: (roverId) => emitWithAck('audio:testStop', { roverId }),
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
llmControl: (action, controls = {}) =>
emitWithAck('llm:control', { controls: { action, ...controls } }),
pushAlert: (alert) =>