- Playback: Fixed future Chrome recordings in cyberchat-app.js (line 865):

Records one finalized WebM instead of 250ms fragments.
Waits for final audio data before releasing the microphone.
Handles startup/finalization failures cleanly.
Bumped frontend cache version to .8.
Verified JavaScript syntax, JSON, browser rendering, and no console 
errors. Existing uploaded clips remain unchanged. A real microphone test 
wasn’t available in the browser runner.
References: MDN MediaRecorder, Chromium WebM metadata issue.
This commit is contained in:
Ty Clifford
2026-06-09 11:19:28 -04:00
parent eb57cde99f
commit 5ca55bf7c1
4 changed files with 50 additions and 19 deletions
+44 -15
View File
@@ -862,12 +862,17 @@
if (!feature('recording_status')) return;
await api('messages.php', form({ action: 'recording', active: active ? '1' : '0' })).catch(() => {});
}
function releaseVoiceStream(stream) {
stream?.getTracks().forEach(track => track.stop());
if (state.voice.stream === stream) state.voice.stream = null;
}
async function toggleVoice() {
if (state.voice.recorder) return stopVoice(false);
discardVoice();
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Voice recording is not supported');
let stream = null;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const candidates = [
'audio/webm;codecs=opus', 'audio/webm',
'audio/mp4;codecs=mp4a.40.2', 'audio/mp4', 'audio/aac'
@@ -894,17 +899,37 @@
catch { recorder = new MediaRecorder(stream); }
}
state.voice.stream = stream;
state.voice.chunks = [];
state.voice.started = Date.now();
const recordedChunks = [];
state.voice.chunks = recordedChunks;
const startedAt = Date.now();
state.voice.started = startedAt;
const recordedMime = recorder.mimeType || mime || 'audio/webm';
recorder.addEventListener('dataavailable', event => { if (event.data.size) state.voice.chunks.push(event.data); });
recorder.addEventListener('dataavailable', event => { if (event.data.size) recordedChunks.push(event.data); });
recorder.addEventListener('stop', () => {
if (!recorder._discard) finishVoice(
new Blob(state.voice.chunks, { type: recordedMime }),
(Date.now() - state.voice.started) / 1000
);
releaseVoiceStream(stream);
if (!recorder._discard) {
const blob = recordedChunks.length === 1
? recordedChunks[0]
: new Blob(recordedChunks, { type: recordedMime });
finishVoice(blob, (Date.now() - startedAt) / 1000);
}
}, { once: true });
recorder.start(250);
recorder.addEventListener('error', () => {
clearInterval(state.voice.timer);
clearInterval(state.voice.heartbeat);
state.voice.timer = state.voice.heartbeat = null;
void recording(false);
recorder._discard = true;
releaseVoiceStream(stream);
if (state.voice.recorder === recorder) state.voice.recorder = null;
$('#record')?.classList.remove('recording');
if ($('#record-label')) $('#record-label').textContent = 'RECORD';
if ($('#voice-status')) {
$('#voice-status').textContent = `VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s · ${voiceBitrate()} KBPS`;
}
toast('The browser could not finalize this voice clip');
}, { once: true });
recorder.start();
state.voice.recorder = recorder;
$('#record').classList.add('recording');
$('#record-label').textContent = 'STOP';
@@ -912,7 +937,10 @@
state.voice.heartbeat = setInterval(() => recording(true), 4000);
state.voice.timer = setInterval(voiceTimer, 200);
voiceTimer();
} catch (e) { toast(e.name === 'NotAllowedError' ? 'Microphone permission was denied' : 'Could not access microphone'); }
} catch (e) {
releaseVoiceStream(stream);
toast(e.name === 'NotAllowedError' ? 'Microphone permission was denied' : 'Could not access microphone');
}
}
function voiceTimer() {
const max = state.config.voice?.max_duration_seconds || 60;
@@ -927,13 +955,14 @@
clearInterval(state.voice.heartbeat);
state.voice.timer = state.voice.heartbeat = null;
recording(false);
if (state.voice.recorder && state.voice.recorder.state !== 'inactive') {
state.voice.recorder._discard = discard;
state.voice.recorder.stop();
const recorder = state.voice.recorder;
if (recorder && recorder.state !== 'inactive') {
recorder._discard = discard;
recorder.stop();
} else {
releaseVoiceStream(state.voice.stream);
}
state.voice.recorder = null;
state.voice.stream?.getTracks().forEach(track => track.stop());
state.voice.stream = null;
$('#record')?.classList.remove('recording');
if ($('#record-label')) $('#record-label').textContent = 'RECORD';
}