buncha stuff, charging complete sounds, room display button feedback, etc

This commit is contained in:
legop3
2026-06-10 01:46:03 -04:00
parent 2ce2f23b0a
commit 9826d4245f
15 changed files with 364 additions and 163 deletions
@@ -0,0 +1,43 @@
// audio Forward Service charge complete sound
// Purpose: Plays the built-in rover sound effect when the battery manager marks a charging cycle done.
// Scope: Keeps the battery policy and audio pipeline decoupled by listening to the server event bus only.
const path = require('path');
const { subscribe } = require('../eventBus');
const DONE_CHARGING_SOUND_PATH = path.resolve(__dirname, '..', '..', '..', 'public', 'donecharging.wav');
function registerChargeCompleteSound(deps) {
const {
logger,
playServerAudioFile,
} = deps;
subscribe('battery.unlocked', (event = {}) => {
const roverId = String(event?.payload?.roverId || '').trim();
if (!roverId) return;
try {
// battery.unlocked is the existing server-side definition of "done
// charging" because it only fires after the battery manager has waited
// through its full charge-release policy. Playing from this event avoids
// duplicating charging thresholds or raw sensor-state guesses here.
playServerAudioFile(roverId, DONE_CHARGING_SOUND_PATH, {
source: 'charge-complete',
});
logger.info('Played charging complete sound', { roverId, filePath: DONE_CHARGING_SOUND_PATH });
} catch (err) {
// A missing/offline rover or unavailable ffmpeg should not affect the
// battery manager's unlock decision. The sound is an announcement layered
// on top of the state transition, so failures are logged and contained.
logger.warn('Failed to play charging complete sound', {
roverId,
filePath: DONE_CHARGING_SOUND_PATH,
error: err?.message || String(err),
});
}
});
}
module.exports = {
registerChargeCompleteSound,
};
@@ -13,6 +13,7 @@ const videoSessions = require('../videoSessions');
const { createAudioForwardPolicy } = require('./policy');
const { createAudioForwardWorkerEngine } = require('./workerEngine');
const { registerAudioForwardHooks } = require('./hooks');
const { registerChargeCompleteSound } = require('./chargeCompleteSound');
const audioForwardEvents = new EventEmitter();
const config = loadConfig();
@@ -95,6 +96,7 @@ const {
ensureWorker,
stopWorker,
playUploadedAudio,
playServerAudioFile,
stopPlayback,
revokeWhipSessionForRover,
stopWhipForRover,
@@ -125,6 +127,11 @@ registerAudioForwardHooks({
startSilenceWriter,
});
registerChargeCompleteSound({
logger,
playServerAudioFile,
});
module.exports = {
getAudioForwardState,
audioForwardEvents,
@@ -158,7 +158,7 @@ function createAudioForwardWorkerEngine(deps) {
];
}
function buildUploadWriterArgs(filePath) {
function buildFileWriterArgs(filePath) {
return [
'-hide_banner',
'-loglevel',
@@ -253,21 +253,41 @@ function createAudioForwardWorkerEngine(deps) {
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
}
function startUploadWriter(roverId, filePath, ownerSocketId) {
function startFileWriter(roverId, filePath, options = {}) {
const worker = workers.get(roverId);
if (!worker || worker.stopping) return;
const source = options.source || 'upload';
const contentKind = options.contentKind || source;
const ownerSocketId = options.ownerSocketId || null;
const cleanupAfterPlayback = Boolean(options.cleanupAfterPlayback);
stopContentProc(worker);
cleanupUploadFile(worker);
worker.activeUploadPath = filePath;
worker.activeOwnerSocketId = ownerSocketId || null;
const proc = spawnFfmpeg(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
// Browser-uploaded files are temporary files that this service owns, so the
// worker remembers them and deletes them when playback is interrupted or
// replaced. Built-in server assets are deliberately not tracked here because
// they are checked-in files shared across all rovers and must survive after
// a single playback finishes.
if (cleanupAfterPlayback) {
worker.activeUploadPath = filePath;
} else {
worker.activeUploadPath = null;
}
// Socket ownership is meaningful only for user-started upload playback.
// Server-started sounds such as the charging-complete cue pass no owner so
// turn changes and browser disconnects do not treat the built-in sound as a
// stale client session.
worker.activeOwnerSocketId = ownerSocketId;
const proc = spawnFfmpeg(roverId, `${source}-writer`, buildFileWriterArgs(filePath), { captureStdout: true });
worker.contentProc = proc;
worker.contentKind = 'upload';
worker.contentKind = contentKind;
const seq = ++worker.writerSeq;
attachWriterPipe(worker, proc);
setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
setState(roverId, { state: 'playing', source, error: null, startedAt: Date.now() });
proc.on('exit', (code, signal) => {
const current = workers.get(roverId);
@@ -279,8 +299,8 @@ function createAudioForwardWorkerEngine(deps) {
if (code != null && code !== 0 && signal !== 'SIGTERM') {
setState(roverId, {
state: 'error',
source: 'upload',
error: `upload writer exited code=${code} signal=${signal || 'none'}`,
source,
error: `${source} writer exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
});
}
@@ -414,7 +434,34 @@ function createAudioForwardWorkerEngine(deps) {
stopWhipForRover(roverId, 'upload_override');
ensureWorker(roverId);
const uploadPath = writeUploadFile(roverId, payload);
startUploadWriter(roverId, uploadPath, ownerSocketId);
startFileWriter(roverId, uploadPath, {
source: 'upload',
contentKind: 'upload',
ownerSocketId,
cleanupAfterPlayback: true,
});
}
function playServerAudioFile(roverId, filePath, options = {}) {
const source = options.source || 'server-file';
const normalizedPath = path.resolve(filePath || '');
const stat = fs.statSync(normalizedPath);
if (!stat.isFile()) {
throw new Error(`Audio file is not a regular file: ${normalizedPath}`);
}
// Built-in sounds intentionally interrupt mic forwarding just like uploads
// do. The rover can only publish one forwarded audio stream at a time, so
// keeping WHIP alive would leave the automatic cue inaudible or mixed with
// a stale publisher process.
stopWhipForRover(roverId, `${source}_override`);
ensureWorker(roverId);
startFileWriter(roverId, normalizedPath, {
source,
contentKind: source,
ownerSocketId: null,
cleanupAfterPlayback: false,
});
}
function stopPlayback(roverId) {
@@ -451,6 +498,7 @@ function createAudioForwardWorkerEngine(deps) {
ensureWorker,
stopWorker,
playUploadedAudio,
playServerAudioFile,
stopPlayback,
revokeWhipSessionForRover,
stopWhipForRover,