// Reusable WAV recorder + presigned-S3-upload engine. // Call createRecorder(id, opts) once per recorder instance on the page. // Expects DOM elements with ids: ${id}-toggle, ${id}-play, ${id}-status, // ${id}-indicator, ${id}-mic-icon, ${id}-stop-icon, ${id}-play-icon, // ${id}-pause-icon, ${id}-audio, ${id}-record-start, ${id}-voice-onset. // The server's live_method must echo back the same `which` value it received // so responses route back to the right instance. const SAMPLE_RATE = 44100; const VOICE_RMS_THRESHOLD = 0.01; const _recorders = {}; function createRecorder(id, opts) { opts = opts || {}; const minSeconds = opts.minSeconds || 0; const onUploaded = opts.onUploaded || function () {}; const requireListen = opts.requireListen || false; const onReset = opts.onReset || function () {}; const recordToggle = document.getElementById(`${id}-toggle`); const playBtn = document.getElementById(`${id}-play`); const statusText = document.getElementById(`${id}-status`); const indicator = document.getElementById(`${id}-indicator`); const micIcon = document.getElementById(`${id}-mic-icon`); const stopIcon = document.getElementById(`${id}-stop-icon`); const playIcon = document.getElementById(`${id}-play-icon`); const pauseIcon = document.getElementById(`${id}-pause-icon`); const audioPlayback = document.getElementById(`${id}-audio`); const tsRecordStartInput = document.getElementById(`${id}-record-start`); const tsVoiceOnsetInput = document.getElementById(`${id}-voice-onset`); let audioContext, processor, source; let recordedChunks = []; let isRecording = false; let pendingBlob = null; let voiceDetected = false; let recordingStartedAt = null; let timerInterval = null; let awaitingListen = false; // Request mic with all browser processing disabled (important for pro mics) navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: SAMPLE_RATE, echoCancellation: false, noiseSuppression: false, autoGainControl: false, }, video: false, }).then(stream => { audioContext = new AudioContext({ sampleRate: SAMPLE_RATE }); source = audioContext.createMediaStreamSource(stream); }).catch(err => { console.error('Mic error:', err); statusText.textContent = 'Mic access denied'; recordToggle.disabled = true; }); recordToggle.addEventListener('click', () => { if (!isRecording) { startRecording(); } else if (minSeconds > 0 && (Date.now() - recordingStartedAt) / 1000 < minSeconds) { // ignore — nudge already shown by the ticking timer } else { stopRecording(); } }); function startRecording() { recordedChunks = []; isRecording = true; voiceDetected = false; recordingStartedAt = Date.now(); if (tsRecordStartInput) tsRecordStartInput.value = recordingStartedAt; if (requireListen) { awaitingListen = false; playBtn.classList.remove('needs-listen'); onReset(); } processor = audioContext.createScriptProcessor(4096, 1, 1); processor.onaudioprocess = (e) => { if (!isRecording) return; const samples = e.inputBuffer.getChannelData(0); recordedChunks.push(new Float32Array(samples)); if (!voiceDetected) { let sumSq = 0; for (let i = 0; i < samples.length; i++) sumSq += samples[i] * samples[i]; if (Math.sqrt(sumSq / samples.length) > VOICE_RMS_THRESHOLD) { voiceDetected = true; if (tsVoiceOnsetInput) tsVoiceOnsetInput.value = Date.now(); } } }; source.connect(processor); processor.connect(audioContext.destination); // Stop any active playback if (!audioPlayback.paused) { audioPlayback.pause(); audioPlayback.currentTime = 0; showPlayIcon(); } if (minSeconds > 0) { updateTimerDisplay(0); timerInterval = setInterval(() => { updateTimerDisplay((Date.now() - recordingStartedAt) / 1000); }, 200); } else { statusText.textContent = 'Recording...'; } indicator.classList.add('active'); indicator.classList.remove('has-recording'); micIcon.style.display = 'none'; stopIcon.style.display = 'block'; recordToggle.classList.add('recording'); playBtn.disabled = true; } function updateTimerDisplay(elapsedSeconds) { const remaining = Math.max(0, minSeconds - elapsedSeconds); statusText.textContent = remaining > 0 ? `Keep going… ${Math.ceil(remaining)}s` : 'Recording...'; } function stopRecording() { isRecording = false; if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } source.disconnect(processor); processor.disconnect(); processor = null; const wavBuffer = encodeWAV(recordedChunks, SAMPLE_RATE); pendingBlob = new Blob([wavBuffer], { type: 'audio/wav' }); audioPlayback.src = URL.createObjectURL(pendingBlob); statusText.textContent = 'Uploading...'; indicator.classList.remove('active'); indicator.classList.add('has-recording'); micIcon.style.display = 'block'; stopIcon.style.display = 'none'; recordToggle.classList.remove('recording'); playBtn.disabled = false; liveSend({ action: 'get_upload_url', which: id }); } // Play / Pause playBtn.addEventListener('click', () => { if (audioPlayback.paused) { audioPlayback.play(); showPauseIcon(); } else { audioPlayback.pause(); showPlayIcon(); } }); audioPlayback.addEventListener('ended', () => { showPlayIcon(); if (requireListen && awaitingListen) { awaitingListen = false; playBtn.classList.remove('needs-listen'); statusText.textContent = 'Saved'; onUploaded(); } }); function showPlayIcon() { playIcon.style.display = 'block'; pauseIcon.style.display = 'none'; } function showPauseIcon() { playIcon.style.display = 'none'; pauseIcon.style.display = 'block'; } function handleMessage(data) { if (data.upload_url !== undefined) { if (!data.upload_url) { statusText.textContent = 'Upload failed'; return; } fetch(data.upload_url, { method: 'PUT', body: pendingBlob }) .then(res => liveSend({ action: 'upload_done', which: id, success: res.ok })) .catch(() => liveSend({ action: 'upload_done', which: id, success: false })); return; } if (data.uploaded !== undefined) { if (data.uploaded) { if (requireListen) { awaitingListen = true; playBtn.classList.add('needs-listen'); statusText.textContent = 'Tap ▶ to listen'; } else { statusText.textContent = 'Saved'; onUploaded(); } } else { statusText.textContent = 'Upload failed'; } } } _recorders[id] = { handleMessage }; } // WAV encoder — 16-bit PCM, mono function encodeWAV(chunks, sampleRate) { let totalLength = 0; for (const c of chunks) totalLength += c.length; const samples = new Float32Array(totalLength); let offset = 0; for (const c of chunks) { samples.set(c, offset); offset += c.length; } const buffer = new ArrayBuffer(44 + samples.length * 2); const view = new DataView(buffer); function str(off, s) { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); } str(0, 'RIFF'); view.setUint32( 4, 36 + samples.length * 2, true); str(8, 'WAVE'); str(12, 'fmt '); view.setUint32(16, 16, true); // chunk size view.setUint16(20, 1, true); // PCM view.setUint16(22, 1, true); // mono view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true); // byte rate view.setUint16(32, 2, true); // block align view.setUint16(34, 16, true); // bits per sample str(36, 'data'); view.setUint32(40, samples.length * 2, true); let off = 44; for (let i = 0; i < samples.length; i++) { const s = Math.max(-1, Math.min(1, samples[i])); view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true); off += 2; } return buffer; } function liveRecv(data) { const rec = _recorders[data.which]; if (rec) rec.handleMessage(data); } // Pages call `window.__recorderQueue.push([id, opts])` inline within // {{ block content }}, which renders before this script (loaded via // {{ block scripts }}) exists. Drain anything queued so far, then make // future pushes create the recorder immediately. (window.__recorderQueue || []).forEach(([id, opts]) => createRecorder(id, opts)); window.__recorderQueue = { push: ([id, opts]) => createRecorder(id, opts) };