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
+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');