diff --git a/index.html b/index.html index 16c5088..9ff87d8 100644 --- a/index.html +++ b/index.html @@ -217,7 +217,6 @@ const NOTE_BODY_PLAIN_FIELD = 'body_plain'; const NOTE_USERNAME_FIELD = 'Username'; let mediaRecorder = null; - let recordedChunks = []; let recordedBlob = null; let titleRecognition = null; let noteRecognition = null; @@ -647,105 +646,172 @@ ensureUserLogged(); } - // --- Audio recording setup with auto-stop on silence --- + // --- Audio recording setup with segmented silence handling --- let audioContext = null; let analyser = null; - let silenceStart = null; + let recordingStream = null; let silenceCheckInterval = null; + let lastSoundAt = 0; + let stoppingSegment = false; const SILENCE_THRESHOLD = 0.01; // Volume threshold for silence - const SILENCE_DURATION = 3000; // 3 seconds - - function checkSilence() { - if (!analyser) return; + const SEGMENT_SILENCE_MS = 2000; // pause a segment after 2s of silence + const SESSION_SILENCE_MS = 3500; // end entire session after 3.5s of silence + let recordingSessionActive = false; + let segmentActive = false; + let currentSegmentChunks = []; + let recordedSegments = []; + recordedBlob = null; // reuse existing variable for merged preview + let hasHeardSpeech = false; + let sessionStartAt = 0; + function computeRms() { + if (!analyser) return 0; const bufferLength = analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); analyser.getByteTimeDomainData(dataArray); - - // Calculate average volume let sum = 0; for (let i = 0; i < bufferLength; i++) { const normalized = (dataArray[i] - 128) / 128; sum += normalized * normalized; } - const rms = Math.sqrt(sum / bufferLength); - - if (rms < SILENCE_THRESHOLD) { - if (!silenceStart) { - silenceStart = Date.now(); - } else if (Date.now() - silenceStart >= SILENCE_DURATION) { - stopRecording(); - } - } else { - silenceStart = null; - } + return Math.sqrt(sum / bufferLength); } - async function setupRecorder() { - let stream; + async function ensureStream() { + if (recordingStream) return recordingStream; try { - stream = await navigator.mediaDevices.getUserMedia({ + recordingStream = await navigator.mediaDevices.getUserMedia({ audio: { - autoGainControl: true - } + autoGainControl: true, + }, }); } catch (err) { console.error('Microphone access failed:', err); document.getElementById('recordStatus').textContent = 'Microphone access denied or unavailable.'; throw err; } - recordedChunks = []; - recordedBlob = null; - // Setup audio context for silence detection audioContext = new (window.AudioContext || window.webkitAudioContext)(); analyser = audioContext.createAnalyser(); - const source = audioContext.createMediaStreamSource(stream); + const source = audioContext.createMediaStreamSource(recordingStream); source.connect(analyser); analyser.fftSize = 2048; + return recordingStream; + } - mediaRecorder = new MediaRecorder(stream); + function startSilenceMonitor() { + if (silenceCheckInterval) return; + silenceCheckInterval = setInterval(checkSilence, 120); + } + + function stopSilenceMonitor() { + if (silenceCheckInterval) { + clearInterval(silenceCheckInterval); + silenceCheckInterval = null; + } + } + + function cleanupStream() { + stopSilenceMonitor(); + if (audioContext) { + audioContext.close(); + audioContext = null; + } + mediaRecorder = null; + if (recordingStream) { + recordingStream.getTracks().forEach((t) => t.stop()); + recordingStream = null; + } + analyser = null; + } + + function updateCombinedPreview() { + const audioEl = document.getElementById('audioPreview'); + if (!recordedSegments.length) { + recordedBlob = null; + audioEl.src = ''; + audioEl.classList.add('hidden'); + document.getElementById('audioPlayer').classList.add('hidden'); + document.getElementById('recordStatus').textContent = ''; + return; + } + + const type = recordedSegments[0]?.blob?.type || 'audio/webm'; + recordedBlob = new Blob(recordedSegments.map((s) => s.blob), { type }); + audioEl.src = URL.createObjectURL(recordedBlob); + audioEl.volume = 1.0; + audioEl.classList.remove('hidden'); + document.getElementById('audioPlayer').classList.remove('hidden'); + document.getElementById('recordStatus').textContent = `Segments recorded: ${recordedSegments.length}`; + } + + function startSegment() { + if (!recordingStream) return; + currentSegmentChunks = []; + mediaRecorder = new MediaRecorder(recordingStream); + const recorderRef = mediaRecorder; + segmentActive = true; + stoppingSegment = false; + lastSoundAt = Date.now(); mediaRecorder.ondataavailable = (e) => { - if (e.data && e.data.size > 0) recordedChunks.push(e.data); + if (e.data && e.data.size > 0) currentSegmentChunks.push(e.data); }; mediaRecorder.onstop = () => { - if (silenceCheckInterval) { - clearInterval(silenceCheckInterval); - silenceCheckInterval = null; + if (currentSegmentChunks.length) { + const blob = new Blob(currentSegmentChunks, { type: recorderRef.mimeType }); + recordedSegments.push({ blob, name: `segment-${recordedSegments.length + 1}.webm` }); + updateCombinedPreview(); } - silenceStart = null; - if (audioContext) { - audioContext.close(); - audioContext = null; + segmentActive = false; + if (mediaRecorder === recorderRef) { + mediaRecorder = null; } - stream.getTracks().forEach(track => track.stop()); - recordedBlob = new Blob(recordedChunks, { type: mediaRecorder.mimeType }); - const audioEl = document.getElementById('audioPreview'); - audioEl.src = URL.createObjectURL(recordedBlob); - audioEl.volume = 1.0; - audioEl.classList.remove('hidden'); - document.getElementById('audioPlayer').classList.remove('hidden'); - document.getElementById('recordStatus').textContent = ''; - mediaRecorder = null; // allow fresh recorder next start + stoppingSegment = false; }; + mediaRecorder.start(); + document.getElementById('recordStatus').textContent = `Recording segment ${recordedSegments.length + 1}…`; + } + + function stopSegment() { + return new Promise((resolve) => { + if (stoppingSegment) { + resolve(); + return; + } + if (mediaRecorder && mediaRecorder.state === 'recording') { + stoppingSegment = true; + const onStop = () => { + mediaRecorder.removeEventListener('stop', onStop); + resolve(); + }; + mediaRecorder.addEventListener('stop', onStop); + mediaRecorder.stop(); + } else { + segmentActive = false; + stoppingSegment = false; + resolve(); + } + }); } async function startRecording() { const recordBtn = document.getElementById('recordBtn'); const stopBtn = document.getElementById('stopBtn'); try { - if (!mediaRecorder) await setupRecorder(); - silenceStart = null; - recordedChunks = []; + if (recordingSessionActive) return; + await ensureStream(); + recordedSegments = []; recordedBlob = null; - mediaRecorder.start(); - document.getElementById('recordStatus').textContent = 'Recording…'; + segmentActive = false; + hasHeardSpeech = false; + sessionStartAt = Date.now(); + recordingSessionActive = true; + lastSoundAt = sessionStartAt; + startSegment(); + startSilenceMonitor(); recordBtn.disabled = true; stopBtn.disabled = false; stopBtn.classList.remove('hidden'); - - // Start silence detection - silenceCheckInterval = setInterval(checkSilence, 100); } catch (err) { console.error('Failed to start recording:', err); document.getElementById('recordStatus').textContent = 'Recording not available.'; @@ -755,29 +821,60 @@ } } - function stopRecording() { - if (mediaRecorder && mediaRecorder.state === 'recording') { - mediaRecorder.stop(); - if (silenceCheckInterval) { - clearInterval(silenceCheckInterval); - silenceCheckInterval = null; + async function endRecordingSession(reason) { + recordingSessionActive = false; + stopSilenceMonitor(); + await stopSegment(); + cleanupStream(); + const recordBtn = document.getElementById('recordBtn'); + const stopBtn = document.getElementById('stopBtn'); + recordBtn.disabled = false; + stopBtn.disabled = true; + stopBtn.classList.add('hidden'); + if (reason) { + document.getElementById('recordStatus').textContent = reason; + } + hasHeardSpeech = false; + } + + async function stopRecording() { + await endRecordingSession('Recording stopped.'); + } + + function checkSilence() { + if (!recordingSessionActive || !analyser) return; + const now = Date.now(); + const rms = computeRms(); + + if (rms >= SILENCE_THRESHOLD) { + hasHeardSpeech = true; + lastSoundAt = now; + if (!segmentActive) { + startSegment(); } - document.getElementById('recordBtn').disabled = false; - const stopBtn = document.getElementById('stopBtn'); - stopBtn.disabled = true; - stopBtn.classList.add('hidden'); + return; + } + + // If we have not yet heard speech, allow up to SESSION_SILENCE_MS before ending + if (!hasHeardSpeech) { + if (now - sessionStartAt >= SESSION_SILENCE_MS) { + endRecordingSession('Stopped after silence.'); + } + return; + } + + if (segmentActive && !stoppingSegment && now - lastSoundAt >= SEGMENT_SILENCE_MS) { + stopSegment(); + } + + if (!segmentActive && now - lastSoundAt >= SESSION_SILENCE_MS) { + endRecordingSession('Stopped after silence.'); } } - function clearAudio() { - if (silenceCheckInterval) { - clearInterval(silenceCheckInterval); - silenceCheckInterval = null; - } - if (mediaRecorder && mediaRecorder.state === 'recording') { - mediaRecorder.stop(); - } - recordedChunks = []; + async function clearAudio() { + await endRecordingSession('Audio cleared.'); + recordedSegments = []; recordedBlob = null; const audioEl = document.getElementById('audioPreview'); audioEl.src = ''; @@ -788,15 +885,13 @@ document.getElementById('currentTime').textContent = '0:00'; document.getElementById('durationTime').textContent = '0:00'; document.getElementById('playPauseBtn').textContent = '▶'; - document.getElementById('recordBtn').disabled = false; - const stopBtn = document.getElementById('stopBtn'); - stopBtn.disabled = true; - stopBtn.classList.add('hidden'); document.getElementById('recordStatus').textContent = ''; } document.getElementById('recordBtn').addEventListener('click', startRecording); - document.getElementById('stopBtn').addEventListener('click', stopRecording); + document.getElementById('stopBtn').addEventListener('click', () => { + stopRecording(); + }); // Audio options menu const audioMenuContainer = document.getElementById('audioMenuContainer'); const audioMenuBtn = document.getElementById('audioMenuBtn'); @@ -853,10 +948,10 @@ e.preventDefault(); audioMenu.classList.toggle('hidden'); }); - removeAudioBtn.addEventListener('click', (e) => { + removeAudioBtn.addEventListener('click', async (e) => { e.preventDefault(); audioMenu.classList.add('hidden'); - clearAudio(); + await clearAudio(); }); document.addEventListener('click', (e) => { if (!audioMenuContainer.contains(e.target)) { @@ -1217,16 +1312,23 @@ } const noteRecord = await pb.collection(NOTES_COLLECTION).create(payload); - let attachmentRecord = null; - if (recordedBlob) { - const file = new File([recordedBlob], 'note-audio.webm', { type: recordedBlob.type || 'audio/webm' }); - const fd = new FormData(); - fd.append(ATTACHMENTS_FILE_FIELD, file); - fd.append(ATTACHMENTS_PBID_FIELD, noteRecord.id); - attachmentRecord = await pb.collection(ATTACHMENTS_COLLECTION).create(fd); + const attachmentRecords = []; + if (recordedSegments && recordedSegments.length) { + for (let i = 0; i < recordedSegments.length; i++) { + const seg = recordedSegments[i]; + const blobToUpload = seg.blob || seg; + const fileName = seg.name || `note-audio-seg-${i + 1}.webm`; + const file = new File([blobToUpload], fileName, { type: blobToUpload.type || 'audio/webm' }); + const fd = new FormData(); + fd.append(ATTACHMENTS_FILE_FIELD, file); + fd.append(ATTACHMENTS_PBID_FIELD, noteRecord.id); + const createdAtt = await pb.collection(ATTACHMENTS_COLLECTION).create(fd); + attachmentRecords.push(createdAtt); + } } - statusEl.textContent = `Saved note ${noteRecord.id}` + (attachmentRecord ? ` with audio ${attachmentRecord.id}` : ''); - clearAudio(); + const attachmentMsg = attachmentRecords.length ? ` with ${attachmentRecords.length} audio segment(s)` : ''; + statusEl.textContent = `Saved note ${noteRecord.id}${attachmentMsg}`; + await clearAudio(); document.getElementById('noteText').value = ''; document.getElementById('noteTitle').value = ''; document.getElementById('jobNote').value = 'no';