This commit is contained in:
legop3
2026-03-20 23:12:09 -04:00
parent 1b7885e74b
commit cb97e73987
7 changed files with 375 additions and 24 deletions
+1
View File
@@ -23,6 +23,7 @@ audioForward:
enabled: true enabled: true
ffmpegBin: "ffmpeg" ffmpegBin: "ffmpeg"
streamSuffix: "-fwd" streamSuffix: "-fwd"
maxUploadBytes: 8388608
audioLevels: audioLevels:
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers. # Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
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-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BYvRyw7e.js"></script> <script type="module" crossorigin src="/assets/index-DnqjOvSX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dx4QsRNa.css"> <link rel="stylesheet" crossorigin href="/assets/index-Dx4QsRNa.css">
</head> </head>
<body> <body>
+231 -8
View File
@@ -2,9 +2,12 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { spawn, spawnSync } = require('child_process'); const { spawn, spawnSync } = require('child_process');
const EventEmitter = require('events'); const EventEmitter = require('events');
const io = require('../globals/io');
const logger = require('../globals/logger').child('audioForwardService'); const logger = require('../globals/logger').child('audioForwardService');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const turnService = require('./turnService');
const { isVerified } = require('./verificationService');
const audioForwardEvents = new EventEmitter(); const audioForwardEvents = new EventEmitter();
const config = loadConfig(); const config = loadConfig();
@@ -16,8 +19,12 @@ const streamSuffix =
? audioForwardConfig.streamSuffix.trim() ? audioForwardConfig.streamSuffix.trim()
: '-fwd'; : '-fwd';
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward'); const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
const uploadsDir = path.join(runtimeDir, 'uploads');
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
: 8 * 1024 * 1024;
const states = new Map(); // roverId -> { state, source, error, updatedAt } const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
const workers = new Map(); // roverId -> worker const workers = new Map(); // roverId -> worker
function publishStateChange(roverId) { function publishStateChange(roverId) {
@@ -30,6 +37,7 @@ function setState(roverId, next = {}) {
state: next.state || prev.state || 'idle', state: next.state || prev.state || 'idle',
source: Object.prototype.hasOwnProperty.call(next, 'source') ? next.source : prev.source || 'silence', source: Object.prototype.hasOwnProperty.call(next, 'source') ? next.source : prev.source || 'silence',
error: Object.prototype.hasOwnProperty.call(next, 'error') ? next.error : prev.error || null, error: Object.prototype.hasOwnProperty.call(next, 'error') ? next.error : prev.error || null,
startedAt: Object.prototype.hasOwnProperty.call(next, 'startedAt') ? next.startedAt : prev.startedAt || null,
updatedAt: Date.now(), updatedAt: Date.now(),
}; };
states.set(roverId, merged); states.set(roverId, merged);
@@ -46,12 +54,46 @@ function getAudioForwardState() {
function ensureRuntimeDir() { function ensureRuntimeDir() {
fs.mkdirSync(runtimeDir, { recursive: true }); fs.mkdirSync(runtimeDir, { recursive: true });
fs.mkdirSync(uploadsDir, { recursive: true });
} }
function sanitizeRoverId(roverId) { function sanitizeRoverId(roverId) {
return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_'); return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_');
} }
function sanitizeFileStem(name) {
return String(name || 'upload')
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
}
function extFromUpload(name, mime) {
const lowerName = String(name || '').toLowerCase();
const lowerMime = String(mime || '').toLowerCase();
if (lowerName.endsWith('.mp3') || lowerMime === 'audio/mpeg' || lowerMime === 'audio/mp3') return '.mp3';
if (lowerName.endsWith('.wav') || lowerMime === 'audio/wav' || lowerMime === 'audio/x-wav') return '.wav';
if (lowerName.endsWith('.ogg') || lowerMime === 'audio/ogg') return '.ogg';
throw new Error('Unsupported upload format (allowed: mp3, wav, ogg)');
}
function ensureVipVerified(socket) {
if (!isVerified(socket)) {
throw new Error('VIP verification required');
}
}
function ensureAudioForwardPermission(socket, roverId) {
ensureVipVerified(socket);
if (!roverManager.isDriver(roverId, socket)) {
throw new Error('Audio forwarding is only allowed on your own rover');
}
if (!turnService.canDrive(roverId, socket)) {
throw new Error('Only the current driver can play audio');
}
}
function ensureFifo(fifoPath) { function ensureFifo(fifoPath) {
try { try {
const stat = fs.statSync(fifoPath); const stat = fs.statSync(fifoPath);
@@ -178,16 +220,37 @@ function buildSilenceWriterArgs() {
]; ];
} }
function buildUploadWriterArgs(filePath) {
return [
'-hide_banner',
'-loglevel',
'warning',
'-re',
'-i',
filePath,
'-vn',
'-af',
'aresample=16000',
'-f',
's16le',
'-ac',
'1',
'-ar',
'16000',
'pipe:1',
];
}
function attachWriterPipe(worker, proc) { function attachWriterPipe(worker, proc) {
const writer = fs.createWriteStream(worker.fifoPath, { flags: 'w' }); const writer = fs.createWriteStream(worker.fifoPath, { flags: 'w' });
writer.on('error', (err) => { writer.on('error', (err) => {
const code = err?.code || 'unknown'; const code = err?.code || 'unknown';
if (code !== 'EPIPE') { if (code !== 'EPIPE') {
logger.warn('silence writer pipe error', { roverId: worker?.roverId, code, message: err?.message || String(err) }); logger.warn('writer pipe error', { roverId: worker?.roverId, code, message: err?.message || String(err) });
} }
}); });
proc.stdout.on('error', (err) => { proc.stdout.on('error', (err) => {
logger.warn('silence writer stdout error', { logger.warn('writer stdout error', {
roverId: worker?.roverId, roverId: worker?.roverId,
code: err?.code || 'unknown', code: err?.code || 'unknown',
message: err?.message || String(err), message: err?.message || String(err),
@@ -199,12 +262,23 @@ function attachWriterPipe(worker, proc) {
}); });
} }
function cleanupUploadFile(worker) {
if (!worker?.activeUploadPath) return;
try {
fs.unlinkSync(worker.activeUploadPath);
} catch {
// noop
}
worker.activeUploadPath = null;
}
function stopContentProc(worker) { function stopContentProc(worker) {
if (!worker) return; if (!worker) return;
if (worker.contentProc) { if (worker.contentProc) {
stopProc(worker.contentProc); stopProc(worker.contentProc);
} }
worker.contentProc = null; worker.contentProc = null;
worker.contentKind = null;
} }
function startSilenceWriter(roverId) { function startSilenceWriter(roverId) {
@@ -212,8 +286,11 @@ function startSilenceWriter(roverId) {
if (!worker || worker.stopping) return; if (!worker || worker.stopping) return;
stopContentProc(worker); stopContentProc(worker);
cleanupUploadFile(worker);
worker.activeOwnerSocketId = null;
const proc = spawnFfmpeg(roverId, 'silence-writer', buildSilenceWriterArgs(), { captureStdout: true }); const proc = spawnFfmpeg(roverId, 'silence-writer', buildSilenceWriterArgs(), { captureStdout: true });
worker.contentProc = proc; worker.contentProc = proc;
worker.contentKind = 'silence';
const seq = ++worker.writerSeq; const seq = ++worker.writerSeq;
attachWriterPipe(worker, proc); attachWriterPipe(worker, proc);
@@ -222,14 +299,55 @@ function startSilenceWriter(roverId) {
if (!current || current.stopping) return; if (!current || current.stopping) return;
if (current.writerSeq !== seq || current.contentProc !== proc) return; if (current.writerSeq !== seq || current.contentProc !== proc) return;
current.contentProc = null; current.contentProc = null;
current.contentKind = null;
if (code === 0 || signal === 'SIGTERM') return; if (code === 0 || signal === 'SIGTERM') return;
setState(roverId, { state: 'error', source: 'silence', error: `silence writer exited code=${code} signal=${signal || 'none'}` }); setState(roverId, {
state: 'error',
source: 'silence',
error: `silence writer exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
});
setTimeout(() => { setTimeout(() => {
if (workers.has(roverId)) startSilenceWriter(roverId); if (workers.has(roverId)) startSilenceWriter(roverId);
}, 300); }, 300);
}); });
setState(roverId, { state: 'idle', source: 'silence', error: null }); setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
}
function startUploadWriter(roverId, filePath, ownerSocketId) {
const worker = workers.get(roverId);
if (!worker || worker.stopping) return;
stopContentProc(worker);
cleanupUploadFile(worker);
worker.activeUploadPath = filePath;
worker.activeOwnerSocketId = ownerSocketId || null;
const proc = spawnFfmpeg(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
worker.contentProc = proc;
worker.contentKind = 'upload';
const seq = ++worker.writerSeq;
attachWriterPipe(worker, proc);
setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
proc.on('exit', (code, signal) => {
const current = workers.get(roverId);
if (!current || current.stopping) return;
if (current.writerSeq !== seq || current.contentProc !== proc) return;
current.contentProc = null;
current.contentKind = null;
if (code != null && code !== 0 && signal !== 'SIGTERM') {
setState(roverId, {
state: 'error',
source: 'upload',
error: `upload writer exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
});
}
startSilenceWriter(roverId);
});
} }
function ensureWorker(roverId) { function ensureWorker(roverId) {
@@ -256,7 +374,10 @@ function ensureWorker(roverId) {
outputUrl, outputUrl,
publisherProc: publisher, publisherProc: publisher,
contentProc: null, contentProc: null,
contentKind: null,
writerSeq: 0, writerSeq: 0,
activeOwnerSocketId: null,
activeUploadPath: null,
stopping: false, stopping: false,
}; };
workers.set(roverId, worker); workers.set(roverId, worker);
@@ -266,8 +387,9 @@ function ensureWorker(roverId) {
if (!current || current.publisherProc !== publisher || current.stopping) return; if (!current || current.publisherProc !== publisher || current.stopping) return;
setState(roverId, { setState(roverId, {
state: 'error', state: 'error',
source: 'publish', source: current.contentKind || 'publish',
error: `publisher exited code=${code} signal=${signal || 'none'}`, error: `publisher exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
}); });
}); });
@@ -282,6 +404,7 @@ function stopWorker(roverId) {
worker.stopping = true; worker.stopping = true;
stopContentProc(worker); stopContentProc(worker);
cleanupUploadFile(worker);
stopProc(worker.publisherProc); stopProc(worker.publisherProc);
try { try {
@@ -296,7 +419,57 @@ function stopWorker(roverId) {
} }
workers.delete(roverId); workers.delete(roverId);
setState(roverId, { state: 'offline', source: 'none', error: null }); setState(roverId, { state: 'offline', source: 'none', error: null, startedAt: null });
}
function writeUploadFile(roverId, payload = {}) {
const { name, mime, dataBase64 } = payload || {};
const ext = extFromUpload(name, mime);
const encoded = typeof dataBase64 === 'string' ? dataBase64.trim() : '';
if (!encoded) {
throw new Error('Upload payload missing');
}
const bytes = Buffer.from(encoded, 'base64');
if (!bytes.length) {
throw new Error('Upload decode failed');
}
if (bytes.length > maxUploadBytes) {
throw new Error(`Upload too large (max ${maxUploadBytes} bytes)`);
}
ensureRuntimeDir();
const stem = sanitizeFileStem(name || `upload-${Date.now()}`);
const filePath = path.join(uploadsDir, `${sanitizeRoverId(roverId)}-${Date.now()}-${stem}${ext}`);
fs.writeFileSync(filePath, bytes);
return filePath;
}
function playUploadedAudio(roverId, payload = {}, ownerSocketId = null) {
ensureWorker(roverId);
const uploadPath = writeUploadFile(roverId, payload);
startUploadWriter(roverId, uploadPath, ownerSocketId);
}
function stopPlayback(roverId) {
ensureWorker(roverId);
startSilenceWriter(roverId);
}
function stopOwnedUploadIfUnauthorized(roverId, ownerSocketId, reason = 'driver_change') {
if (!roverId || !ownerSocketId) return;
const worker = workers.get(roverId);
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== ownerSocketId) return;
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
if (ownerIsDriver && ownerCanDrive) return;
logger.info('Stopping upload audio due to ownership/driver change', {
roverId,
ownerSocketId,
reason,
});
startSilenceWriter(roverId);
} }
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => { roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
@@ -309,11 +482,61 @@ roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
try { try {
ensureWorker(roverId); ensureWorker(roverId);
} catch (err) { } catch (err) {
setState(roverId, { state: 'error', source: 'init', error: err.message }); setState(roverId, { state: 'error', source: 'init', error: err.message, startedAt: null });
} }
} }
}); });
roverManager.managerEvents.on('driver', ({ socketId, roverId, action } = {}) => {
if (!socketId || !roverId) return;
if (action === 'remove' || action === 'add') {
stopOwnedUploadIfUnauthorized(roverId, socketId, action);
}
});
turnService.turnEvents.on('activeDriver', ({ roverId } = {}) => {
if (!roverId) return;
const worker = workers.get(roverId);
if (!worker || worker.contentKind !== 'upload') return;
stopOwnedUploadIfUnauthorized(roverId, worker.activeOwnerSocketId, 'turn_change');
});
io.on('connection', (socket) => {
socket.on('audio:uploadPlay', (payload = {}, cb = () => {}) => {
try {
const roverId = String(payload?.roverId || '').trim();
ensureAudioForwardPermission(socket, roverId);
playUploadedAudio(roverId, payload, socket.id);
cb({ success: true, roverId });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:uploadStop', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
const worker = workers.get(normalized);
if (worker && worker.contentKind === 'upload' && worker.activeOwnerSocketId !== socket.id) {
throw new Error('Upload playback is owned by another session');
}
stopPlayback(normalized);
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('disconnect', () => {
workers.forEach((worker, roverId) => {
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== socket.id) return;
logger.info('Stopping owned upload audio due to socket disconnect', { roverId, socketId: socket.id });
startSilenceWriter(roverId);
});
});
});
module.exports = { module.exports = {
getAudioForwardState, getAudioForwardState,
audioForwardEvents, audioForwardEvents,
+9 -7
View File
@@ -2,11 +2,12 @@ import { useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js'; import { useSettingsNamespace } from '../settings/index.js';
import { COOKIE_KEY_REGEX, flowWrapClass } from './vip/constants.js'; import { COOKIE_KEY_REGEX, flowWrapClass } from './vip/constants.js';
import VipAudioUploadCard from './vip/VipAudioUploadCard.jsx';
import VipVerificationCard from './vip/VipVerificationCard.jsx'; import VipVerificationCard from './vip/VipVerificationCard.jsx';
import VipIdentityCard from './vip/VipIdentityCard.jsx'; import VipIdentityCard from './vip/VipIdentityCard.jsx';
export default function VipPanel() { export default function VipPanel() {
const { session, identifySession, requestVerification } = useSession(); const { session, identifySession, requestVerification, playUploadedAudio, stopUploadedAudio } = useSession();
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' }); const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
const { value: profile } = useSettingsNamespace('profile', { nickname: '' }); const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
@@ -14,6 +15,7 @@ export default function VipPanel() {
const nickname = (profile?.nickname || '').trim(); const nickname = (profile?.nickname || '').trim();
const isVerified = Boolean(session?.isVerified); const isVerified = Boolean(session?.isVerified);
const pendingRequestId = session?.verification?.pendingRequestId || null; const pendingRequestId = session?.verification?.pendingRequestId || null;
const ownRoverId = String(session?.assignment?.roverId || '').trim();
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const applyIdentityKey = async (nextRaw) => { const applyIdentityKey = async (nextRaw) => {
@@ -32,12 +34,12 @@ export default function VipPanel() {
return ( return (
<section className="panel-section space-y-0.5 text-base"> <section className="panel-section space-y-0.5 text-base">
{isVerified ? ( {isVerified ? (
<div className={`surface ${flowWrapClass}`}> <VipAudioUploadCard
<div className="mx-auto flex w-full max-w-md flex-col items-center space-y-0.5 text-center"> ownRoverId={ownRoverId}
<p className="text-sm text-slate-300">VIP verified.</p> audioForwardByRover={session?.audioForward || {}}
<p className="text-xs text-slate-500">Audio forwarding is disabled while this stack is being rebuilt.</p> playUploadedAudio={playUploadedAudio}
</div> stopUploadedAudio={stopUploadedAudio}
</div> />
) : ( ) : (
<VipVerificationCard <VipVerificationCard
pendingRequestId={pendingRequestId} pendingRequestId={pendingRequestId}
@@ -0,0 +1,120 @@
import { useMemo, useState } from 'react';
import { fieldClass, flowWrapClass, innerFlowClass } from './constants.js';
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
function bytesToBase64(bytes) {
let binary = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
export default function VipAudioUploadCard({
ownRoverId = '',
audioForwardByRover = {},
playUploadedAudio,
stopUploadedAudio,
}) {
const roverId = String(ownRoverId || '').trim();
const [selectedUpload, setSelectedUpload] = useState(null);
const [working, setWorking] = useState(false);
const [message, setMessage] = useState('');
const selectedForwardState = useMemo(
() => (roverId ? audioForwardByRover?.[roverId] || null : null),
[audioForwardByRover, roverId],
);
const handleUploadPlay = async () => {
if (!roverId) {
setMessage('Take control of your rover first.');
return;
}
if (!selectedUpload) {
setMessage('Select an audio file first.');
return;
}
if (selectedUpload.size > MAX_UPLOAD_BYTES) {
setMessage(`File too large (max ${MAX_UPLOAD_BYTES} bytes).`);
return;
}
setWorking(true);
setMessage('');
try {
const buffer = await selectedUpload.arrayBuffer();
const dataBase64 = bytesToBase64(new Uint8Array(buffer));
await playUploadedAudio?.({
roverId,
name: selectedUpload.name,
mime: selectedUpload.type || '',
dataBase64,
});
setMessage('Upload playback started.');
} catch (err) {
setMessage(err?.message || 'Failed to play upload.');
} finally {
setWorking(false);
}
};
const handleUploadStop = async () => {
if (!roverId) {
setMessage('Take control of your rover first.');
return;
}
setWorking(true);
setMessage('');
try {
await stopUploadedAudio?.(roverId);
setMessage('Upload playback stopped.');
} catch (err) {
setMessage(err?.message || 'Failed to stop upload.');
} finally {
setWorking(false);
}
};
return (
<section className={`surface ${flowWrapClass}`}>
<div className={innerFlowClass}>
<p className="text-sm text-slate-300">VIP Audio Upload</p>
<label className="grid w-full gap-0.5 text-xs text-slate-300">
<span>Audio file (mp3 / wav / ogg)</span>
<input
className={fieldClass}
type="file"
accept=".mp3,.wav,.ogg,audio/mpeg,audio/wav,audio/ogg"
disabled={working || !roverId}
onChange={(event) => setSelectedUpload(event.target.files?.[0] || null)}
/>
</label>
{selectedUpload ? (
<div className="surface-muted mx-auto w-full max-w-sm text-xs text-slate-300 text-center">
{selectedUpload.name} ({selectedUpload.size} bytes)
</div>
) : null}
<div className="flex justify-center gap-0.5">
<button type="button" className="button-dark text-sm" disabled={working || !roverId} onClick={handleUploadPlay}>
{working ? 'Working...' : 'Play Upload'}
</button>
<button type="button" className="button-dark text-sm" disabled={working || !roverId} onClick={handleUploadStop}>
Stop
</button>
</div>
{selectedForwardState ? (
<div className="surface-muted mx-auto w-full max-w-sm text-xs text-slate-300 text-center">
state: {selectedForwardState.state || 'idle'}
{selectedForwardState.error ? ` | error: ${selectedForwardState.error}` : ''}
</div>
) : null}
{message ? <div className="text-xs text-slate-400 text-center">{message}</div> : null}
</div>
</section>
);
}
+5
View File
@@ -26,6 +26,8 @@ const SessionContext = createContext({
setAdminReason: async () => {}, setAdminReason: async () => {},
rebootRover: async () => {}, rebootRover: async () => {},
rebootServer: async () => {}, rebootServer: async () => {},
playUploadedAudio: async () => {},
stopUploadedAudio: async () => {},
setAudioLevels: async () => {}, setAudioLevels: async () => {},
llmControl: async () => {}, llmControl: async () => {},
}); });
@@ -142,6 +144,9 @@ export function SessionProvider({ children }) {
rebootRover: (roverId) => rebootRover: (roverId) =>
emitWithAck('command', { roverId, type: 'reboot', data: { reboot: {} } }), emitWithAck('command', { roverId, type: 'reboot', data: { reboot: {} } }),
rebootServer: () => emitWithAck('server:reboot'), rebootServer: () => emitWithAck('server:reboot'),
playUploadedAudio: ({ roverId, name, mime, dataBase64 }) =>
emitWithAck('audio:uploadPlay', { roverId, name, mime, dataBase64 }),
stopUploadedAudio: (roverId) => emitWithAck('audio:uploadStop', { roverId }),
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels), setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
llmControl: (action, controls = {}) => llmControl: (action, controls = {}) =>
emitWithAck('llm:control', { controls: { action, ...controls } }), emitWithAck('llm:control', { controls: { action, ...controls } }),