diff --git a/README.md b/README.md index b1903ad..d3e503a 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,8 @@ Recording selection is browser-driven: 5. AAC 6. Browser default MediaRecorder format +The recorder collects one finalized blob at stop instead of concatenating short live WebM slices. This avoids Chrome-generated fragment metadata that other browsers may reject. + When FFmpeg is disabled, the accepted browser recording is stored unchanged. WebM remains the preferred default when the browser supports it. Safari and iPhone can upload MP4/AAC directly. When FFmpeg is enabled: @@ -427,7 +429,7 @@ The diagnostic endpoint exposes deployment details and should be restricted or r - + diff --git a/assets/js/cyberchat-app.js b/assets/js/cyberchat-app.js index 1502660..2e5c842 100644 --- a/assets/js/cyberchat-app.js +++ b/assets/js/cyberchat-app.js @@ -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'; } diff --git a/embed-example.html b/embed-example.html index 9a7819a..3ebb276 100644 --- a/embed-example.html +++ b/embed-example.html @@ -94,7 +94,7 @@ <div id="my-chat" style="width:100%; height:600px"></div> <!-- 4. Script + init --> -<script src="assets/js/cyberchat-app.js?v=20260609.7"></script> +<script src="assets/js/cyberchat-app.js?v=20260609.8"></script> <script> window.CYBERCHAT_BASE = '/path/to/cyberchat'; // adjust to your install path CyberChat.init('#my-chat'); @@ -104,7 +104,7 @@ - + - +