// A `type: voice` requirement: leave a recording at a gesture key. // Same contract as gesture.js - portal mounts it with the field's // hidden input and the relay URL, and calls stop() on navigation. // // The browser records with MediaRecorder (whatever container it // likes), decodes that to PCM with WebAudio, resamples to mono 48 kHz, // and ships one binary frame `[0x01][20-byte key][i16le PCM]` to the // relay, which encodes with the one recording codec, signs as itself, // keeps it and announces it (ADR-0015). // // Nothing leaves the browser until the alternative's own submit // ("Keep it here") is pressed: the widget intercepts that click, // uploads, sets the field's value to {key, digest, duration_ms} on // the relay's confirmation, and only then lets the submit through. // A relay error keeps the recording for another try and submits // nothing. // // Palette: colors come from the page's custom properties, no copy here. const MAX_SECONDS = 60; const SAMPLE_RATE = 48000; class VoiceWidget { constructor(container, hidden, relayUrl, key) { this.container = container; this.hidden = hidden || null; this.relayUrl = relayUrl || ''; this.key = (key || '').trim(); this.stopped = false; this.recorder = null; this.stream = null; this.chunks = []; this.ws = null; this.button = document.createElement('button'); this.button.type = 'button'; this.button.className = 'voice-button'; this.button.textContent = 'Record'; this.status = document.createElement('span'); this.status.className = 'voice-status'; this.preview = document.createElement('audio'); this.preview.className = 'voice-preview'; this.preview.controls = true; this.preview.hidden = true; container.append(this.button, this.status, this.preview); this._onClick = () => this.toggle(); this.button.addEventListener('click', this._onClick); this.pcm = null; // decoded, ready to upload this.published = null; this.passthrough = false; // The alternative's submit button: hold it until the recording // is kept at the relay. this.card = container.closest('.alt-card'); this._onSubmit = (e) => this.onSubmitClick(e); if (this.card) this.card.addEventListener('click', this._onSubmit, true); if (!this.key || this.key.length !== 40) { this.button.disabled = true; this.say('This page has no address to leave a voice at.'); } else if (!navigator.mediaDevices || !window.MediaRecorder) { this.button.disabled = true; this.say('Recording needs a browser with a microphone API.'); } } say(text) { this.status.textContent = text; } async toggle() { if (this.recorder && this.recorder.state === 'recording') { this.recorder.stop(); return; } try { this.stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch { this.say('Microphone access was declined.'); return; } this.chunks = []; this.recorder = new MediaRecorder(this.stream); this.recorder.addEventListener('dataavailable', (e) => { if (e.data.size) this.chunks.push(e.data); }); this.recorder.addEventListener('stop', () => this.finish()); this.recorder.start(); this.button.textContent = 'Stop'; this.button.classList.add('recording'); this.say(`Recording — up to ${MAX_SECONDS} seconds.`); this.capTimer = setTimeout(() => { if (this.recorder && this.recorder.state === 'recording') this.recorder.stop(); }, MAX_SECONDS * 1000); } async finish() { clearTimeout(this.capTimer); this.button.textContent = 'Record again'; this.button.classList.remove('recording'); if (this.stream) { this.stream.getTracks().forEach((t) => t.stop()); this.stream = null; } const blob = new Blob(this.chunks, { type: this.recorder.mimeType || 'audio/webm' }); if (!blob.size) { this.say('Nothing was recorded.'); return; } this.preview.src = URL.createObjectURL(blob); this.preview.hidden = false; this.published = null; this.setValue(null); try { this.pcm = await this.toPcm16(await blob.arrayBuffer()); } catch (e) { this.pcm = null; this.say('Could not decode the recording — try once more.'); return; } this.say(`${Math.round(this.pcm.length / 2 / SAMPLE_RATE)} s recorded. Listen back, then press the button below to keep it here.`); } onSubmitClick(e) { const button = e.target.closest && e.target.closest('.alt-submit'); if (!button || this.passthrough) return; if (this.published) return; // already kept: let the submit go e.preventDefault(); e.stopPropagation(); if (!this.pcm) { this.say(this.recorder && this.recorder.state === 'recording' ? 'Stop the recording first.' : 'Record something first.'); return; } this.say('Sending to the relay…'); this.upload(this.pcm, () => { // The relay confirmed: now the answer itself is submitted. this.passthrough = true; button.click(); this.passthrough = false; }); } // Decode → mono 48 kHz Float32 → Int16 little-endian bytes. async toPcm16(buffer) { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const decoded = await ctx.decodeAudioData(buffer); await ctx.close(); const frames = Math.ceil(decoded.duration * SAMPLE_RATE); const offline = new OfflineAudioContext(1, frames, SAMPLE_RATE); const src = offline.createBufferSource(); src.buffer = decoded; src.connect(offline.destination); src.start(); const rendered = await offline.startRendering(); const f32 = rendered.getChannelData(0); const out = new Uint8Array(f32.length * 2); const view = new DataView(out.buffer); for (let i = 0; i < f32.length; i++) { const s = Math.max(-1, Math.min(1, f32[i])); view.setInt16(i * 2, s < 0 ? s * 32768 : s * 32767, true); } return out; } upload(pcm, onPublished) { if (!this.relayUrl) { this.say('No relay to send to.'); return; } const frame = new Uint8Array(1 + 20 + pcm.length); frame[0] = 1; for (let i = 0; i < 20; i++) frame[1 + i] = parseInt(this.key.substr(i * 2, 2), 16); frame.set(pcm, 21); const ws = new WebSocket(this.relayUrl); ws.binaryType = 'arraybuffer'; this.ws = ws; ws.addEventListener('open', () => ws.send(frame)); ws.addEventListener('message', (e) => { let msg; try { msg = JSON.parse(e.data); } catch { return; } if (msg.type === 'published') { this.published = { key: this.key, digest: msg.digest, duration_ms: msg.duration_ms }; this.setValue(this.published); this.say(`Kept at this address — ${Math.round(msg.duration_ms / 1000)} s.`); this.button.disabled = true; ws.close(); if (onPublished) onPublished(); } else if (msg.type === 'error') { this.say((msg.message || 'The relay declined the recording.') + ' Your recording is still here — try again.'); ws.close(); } }); ws.addEventListener('error', () => this.say('The relay could not be reached. Your recording is still here — try again.')); ws.addEventListener('close', (e) => { if (!this.published && e.code !== 1000 && e.code !== 1005) { this.say('The relay closed the connection. Your recording is still here — try again.'); } }); } setValue(value) { if (!this.hidden) return; this.hidden.value = value ? JSON.stringify(value) : ''; this.hidden.dispatchEvent(new Event('input', { bubbles: true })); } stop() { this.stopped = true; clearTimeout(this.capTimer); if (this.recorder && this.recorder.state === 'recording') this.recorder.stop(); if (this.stream) this.stream.getTracks().forEach((t) => t.stop()); if (this.ws) this.ws.close(); this.button.removeEventListener('click', this._onClick); if (this.card) this.card.removeEventListener('click', this._onSubmit, true); } } export function mountVoice(container, hidden, relayUrl, key) { return new VoiceWidget(container, hidden, relayUrl, key); }