This commit is contained in:
legop3
2026-03-17 19:46:53 -04:00
parent 93204ec1f1
commit 73ef6261ed
8 changed files with 204 additions and 115 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ pcm.dmixer {
rate 16000
channels 1
period_time 0
period_size 1024
buffer_size 4096
period_size 256
buffer_size 1024
}
}
+5 -1
View File
@@ -41,6 +41,8 @@ run_pipeline() {
-loglevel warning \
-fflags nobuffer \
-flags low_delay \
-max_delay 0 \
-reorder_queue_size 0 \
-analyzeduration 0 \
-probesize 32 \
-i "${AUDIO_FORWARD_URL}" \
@@ -55,7 +57,9 @@ run_pipeline() {
-t raw \
-f S16_LE \
-r 16000 \
-c 1
-c 1 \
-B 40000 \
-F 10000
local rc=$?
local -a statuses=("${PIPESTATUS[@]}")
LAST_FFMPEG_STATUS="${statuses[0]:-unknown}"
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-CtTWAec1.js"></script>
<script type="module" crossorigin src="/assets/index-CXj15_94.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-WqYsCIRI.css">
</head>
<body>
+76 -70
View File
@@ -200,7 +200,7 @@ function buildPublisherArgs(fifoPath, outputUrl) {
'-application',
'lowdelay',
'-frame_duration',
'20',
'10',
'-compression_level',
'0',
'-fflags',
@@ -258,30 +258,6 @@ function buildUploadWriterArgs(filePath) {
];
}
function buildMicWriterArgs() {
return [
'-hide_banner',
'-loglevel',
'warning',
'-fflags',
'nobuffer',
'-flags',
'low_delay',
'-i',
'pipe:0',
'-vn',
'-af',
'aresample=16000',
'-f',
's16le',
'-ac',
'1',
'-ar',
'16000',
'pipe:1',
];
}
function attachWriterPipe(worker, proc) {
const writer = fs.createWriteStream(worker.fifoPath, { flags: 'w' });
writer.on('error', (err) => {
@@ -315,20 +291,36 @@ function cleanupUploadFile(worker) {
}
function stopContentWriter(worker) {
if (!worker?.contentProc) return;
if (!worker) return;
if (worker.micIdleTimer) {
clearTimeout(worker.micIdleTimer);
worker.micIdleTimer = null;
}
worker.micLastChunkAt = 0;
if (worker.contentProc.stdin && !worker.contentProc.stdin.destroyed) {
worker.micBackpressured = false;
if (worker.micWriter && !worker.micWriter.destroyed) {
try {
worker.micWriter.end();
} catch {
// noop
}
try {
worker.micWriter.destroy();
} catch {
// noop
}
}
worker.micWriter = null;
if (worker.contentProc && worker.contentProc.stdin && !worker.contentProc.stdin.destroyed) {
try {
worker.contentProc.stdin.destroy();
} catch {
// noop
}
}
stopProc(worker.contentProc);
if (worker.contentProc) {
stopProc(worker.contentProc);
}
worker.contentProc = null;
worker.contentKind = null;
worker.activeOwnerSocketId = null;
@@ -423,68 +415,80 @@ function startMicWriter(roverId, ownerSocketId = null) {
stopContentWriter(worker);
cleanupUploadFile(worker);
const proc = spawnProcess(roverId, 'mic-writer', buildMicWriterArgs(), {
captureStdout: true,
captureStdin: true,
const writer = fs.createWriteStream(worker.fifoPath, { flags: 'w' });
writer.on('error', (err) => {
const code = err?.code || 'unknown';
if (code !== 'EPIPE') {
logger.warn('mic fifo writer error', { roverId, code, message: err?.message || String(err) });
}
});
worker.contentProc = proc;
writer.on('drain', () => {
const current = workers.get(roverId);
if (!current || current.contentKind !== 'mic') return;
current.micBackpressured = false;
});
writer.on('close', () => {
const current = workers.get(roverId);
if (!current || current.contentKind !== 'mic') return;
current.micWriter = null;
current.micBackpressured = false;
});
worker.micWriter = writer;
worker.micBackpressured = false;
worker.contentProc = null;
worker.contentKind = 'mic';
worker.activeOwnerSocketId = ownerSocketId;
worker.micLastChunkAt = Date.now();
scheduleMicIdleTimeout(roverId);
const seq = ++worker.writerSeq;
attachWriterPipe(worker, proc);
proc.stdin?.on('error', (err) => {
const code = err?.code || 'unknown';
if (code !== 'EPIPE') {
logger.warn('mic writer stdin error', { roverId, code, message: err?.message || String(err) });
}
});
setState(roverId, { state: 'playing', source: 'mic', 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;
current.activeOwnerSocketId = null;
if (current.micIdleTimer) {
clearTimeout(current.micIdleTimer);
current.micIdleTimer = null;
}
current.micLastChunkAt = 0;
if (code != null && code !== 0 && signal !== 'SIGTERM') {
setState(roverId, { state: 'error', source: 'mic', error: `mic writer exited code=${code} signal=${signal || 'none'}` });
}
startSilenceWriter(roverId);
});
}
function pushMicChunk(roverId, ownerSocketId, dataBase64) {
function decodeMicChunk(payload = {}) {
const binary = payload?.data;
if (Buffer.isBuffer(binary)) {
return binary;
}
if (binary instanceof Uint8Array) {
return Buffer.from(binary.buffer, binary.byteOffset, binary.byteLength);
}
if (binary instanceof ArrayBuffer) {
return Buffer.from(binary);
}
if (typeof payload?.dataBase64 === 'string' && payload.dataBase64.trim()) {
return Buffer.from(payload.dataBase64.trim(), 'base64');
}
return Buffer.alloc(0);
}
function pushMicChunk(roverId, ownerSocketId, payload = {}) {
const worker = workers.get(roverId);
if (!worker) {
throw new Error('Audio forward worker unavailable');
}
if (worker.contentKind !== 'mic' || !worker.contentProc || worker.activeOwnerSocketId !== ownerSocketId) {
if (worker.contentKind !== 'mic' || !worker.micWriter || worker.activeOwnerSocketId !== ownerSocketId) {
throw new Error('Mic forwarding is not active');
}
const encoded = typeof dataBase64 === 'string' ? dataBase64.trim() : '';
if (!encoded) {
const bytes = decodeMicChunk(payload);
if (!bytes.length) {
throw new Error('Mic chunk missing');
}
const bytes = Buffer.from(encoded, 'base64');
if (!bytes.length) {
throw new Error('Mic chunk decode failed');
if (bytes.length > 64 * 1024) {
throw new Error('Mic chunk too large');
}
if (worker.contentProc.stdin?.writable !== true) {
if (worker.micWriter.writable !== true) {
throw new Error('Mic writer input is not writable');
}
if (worker.micBackpressured || worker.micWriter.writableNeedDrain) {
// Preserve low latency by dropping stale mic packets instead of queueing.
return;
}
worker.micLastChunkAt = Date.now();
worker.contentProc.stdin.write(bytes);
const wrote = worker.micWriter.write(bytes);
if (!wrote) {
worker.micBackpressured = true;
}
scheduleMicIdleTimeout(roverId);
}
@@ -520,8 +524,10 @@ function ensureWorker(roverId) {
contentKind: null,
activeOwnerSocketId: null,
activeUploadPath: null,
micWriter: null,
micLastChunkAt: 0,
micIdleTimer: null,
micBackpressured: false,
writerSeq: 0,
stopping: false,
};
@@ -691,7 +697,7 @@ io.on('connection', (socket) => {
try {
const normalized = String(payload?.roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
pushMicChunk(normalized, socket.id, payload?.dataBase64);
pushMicChunk(normalized, socket.id, payload);
if (typeof cb === 'function') cb({ success: true });
} catch (err) {
if (typeof cb === 'function') cb({ error: err.message });
@@ -8,6 +8,41 @@ import {
} from './constants.js';
import { useControlSystem } from '../../controls/index.js';
const TARGET_SAMPLE_RATE = 16000;
function downsampleTo16k(input, sampleRate) {
if (!input || !input.length) return new Float32Array(0);
if (sampleRate === TARGET_SAMPLE_RATE) return input;
if (!Number.isFinite(sampleRate) || sampleRate < TARGET_SAMPLE_RATE) return input;
const ratio = sampleRate / TARGET_SAMPLE_RATE;
const outputLength = Math.max(1, Math.round(input.length / ratio));
const output = new Float32Array(outputLength);
let inOffset = 0;
for (let outIdx = 0; outIdx < outputLength; outIdx += 1) {
const nextOffset = Math.min(input.length, Math.round((outIdx + 1) * ratio));
let sum = 0;
let count = 0;
for (let i = inOffset; i < nextOffset; i += 1) {
sum += input[i];
count += 1;
}
output[outIdx] = count > 0 ? sum / count : 0;
inOffset = nextOffset;
}
return output;
}
function floatToInt16Bytes(floatSamples) {
const bytes = new Uint8Array(floatSamples.length * 2);
const view = new DataView(bytes.buffer);
for (let i = 0; i < floatSamples.length; i += 1) {
const sample = Math.max(-1, Math.min(1, floatSamples[i]));
const int16 = sample < 0 ? Math.round(sample * 0x8000) : Math.round(sample * 0x7fff);
view.setInt16(i * 2, int16, true);
}
return bytes;
}
export default function VipAudioForwardingCard({
roster = [],
ownRoverId = '',
@@ -24,8 +59,11 @@ export default function VipAudioForwardingCard({
const [openMicEnabled, setOpenMicEnabled] = useState(false);
const [micState, setMicState] = useState('idle');
const [message, setMessage] = useState('');
const recorderRef = useRef(null);
const streamRef = useRef(null);
const audioContextRef = useRef(null);
const mediaSourceRef = useRef(null);
const processorRef = useRef(null);
const sinkRef = useRef(null);
const micActiveRef = useRef(false);
const activeRoverRef = useRef('');
const singleRoverId = roster.length === 1 ? roster[0].id : '';
@@ -93,14 +131,37 @@ export default function VipAudioForwardingCard({
micActiveRef.current = false;
setMicState('idle');
try {
const recorder = recorderRef.current;
if (recorder && recorder.state !== 'inactive') {
recorder.stop();
if (processorRef.current && mediaSourceRef.current) {
mediaSourceRef.current.disconnect(processorRef.current);
}
} catch {
// noop
}
recorderRef.current = null;
try {
if (processorRef.current && sinkRef.current) {
processorRef.current.disconnect(sinkRef.current);
}
} catch {
// noop
}
try {
if (sinkRef.current && audioContextRef.current?.destination) {
sinkRef.current.disconnect(audioContextRef.current.destination);
}
} catch {
// noop
}
if (audioContextRef.current) {
try {
await audioContextRef.current.close();
} catch {
// noop
}
}
processorRef.current = null;
mediaSourceRef.current = null;
sinkRef.current = null;
audioContextRef.current = null;
if (streamRef.current) {
try {
streamRef.current.getTracks().forEach((track) => track.stop());
@@ -130,12 +191,10 @@ export default function VipAudioForwardingCard({
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('Microphone capture is not supported in this browser.');
}
if (typeof MediaRecorder === 'undefined') {
throw new Error('MediaRecorder is not supported in this browser.');
}
await stopMicCapture(target);
setMicState('starting');
let stream = null;
let audioContext = null;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
@@ -147,29 +206,38 @@ export default function VipAudioForwardingCard({
});
streamRef.current = stream;
await startMicForward?.(target);
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
const recorder = new MediaRecorder(stream, { mimeType, audioBitsPerSecond: 64000 });
recorderRef.current = recorder;
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
if (!AudioContextCtor) {
throw new Error('Web Audio API is not supported in this browser.');
}
audioContext = new AudioContextCtor({ latencyHint: 'interactive' });
audioContextRef.current = audioContext;
const source = audioContext.createMediaStreamSource(stream);
mediaSourceRef.current = source;
const processor = audioContext.createScriptProcessor(1024, 1, 1);
processorRef.current = processor;
const sink = audioContext.createGain();
sink.gain.value = 0;
sinkRef.current = sink;
micActiveRef.current = true;
activeRoverRef.current = target;
recorder.ondataavailable = async (event) => {
try {
if (!micActiveRef.current || !event.data || event.data.size <= 0) return;
const buffer = await event.data.arrayBuffer();
const base64 = bytesToBase64(new Uint8Array(buffer));
sendMicChunk?.({ roverId: target, dataBase64: base64 });
} catch {
// noop
}
};
recorder.onstop = () => {
processor.onaudioprocess = (event) => {
if (!micActiveRef.current) return;
micActiveRef.current = false;
setMicState('idle');
const input = event.inputBuffer?.getChannelData(0);
if (!input || input.length === 0) return;
const downsampled = downsampleTo16k(input, audioContext.sampleRate);
if (!downsampled.length) return;
const pcmBytes = floatToInt16Bytes(downsampled);
sendMicChunk?.({ roverId: target, data: pcmBytes.buffer });
};
recorder.start(120);
source.connect(processor);
processor.connect(sink);
sink.connect(audioContext.destination);
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
setMicState('live');
} catch (err) {
if (stream) {
@@ -179,7 +247,18 @@ export default function VipAudioForwardingCard({
// noop
}
}
if (audioContext) {
try {
await audioContext.close();
} catch {
// noop
}
}
streamRef.current = null;
audioContextRef.current = null;
mediaSourceRef.current = null;
processorRef.current = null;
sinkRef.current = null;
throw err;
}
},
+2 -2
View File
@@ -152,8 +152,8 @@ export function SessionProvider({ children }) {
stopUploadedAudio: (roverId) => emitWithAck('audio:uploadStop', { roverId }),
startMicForward: (roverId) => emitWithAck('audio:micStart', { roverId }),
stopMicForward: (roverId) => emitWithAck('audio:micStop', { roverId }),
sendMicChunk: ({ roverId, dataBase64 }) => {
socket.emit('audio:micChunk', { roverId, dataBase64 });
sendMicChunk: ({ roverId, dataBase64, data }) => {
socket.emit('audio:micChunk', { roverId, dataBase64, data });
},
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
llmControl: (action, controls = {}) =>
+1 -1
View File
@@ -48,7 +48,7 @@ export const DEFAULT_KEYMAP = {
cameraDown: ['j'],
nightVisionToggle: ['e'],
hornHonk: ['h'],
micPtt: ['v'],
micPtt: ['m'],
driveMacro: ['f'],
dockMacro: ['g'],
chatFocus: ['enter'],