// 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). The field's value becomes // {key, digest, duration_ms} once the relay confirms. // // 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); 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.say('Sending…'); let pcm; try { pcm = await this.toPcm16(await blob.arrayBuffer()); } catch (e) { this.say('Could not decode the recording.'); return; } this.upload(pcm); } // 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) { 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.setValue({ key: this.key, digest: msg.digest, duration_ms: msg.duration_ms }); this.say(`Kept at this address — ${Math.round(msg.duration_ms / 1000)} s.`); ws.close(); } else if (msg.type === 'error') { this.say(msg.message || 'The relay declined the recording.'); ws.close(); } }); ws.addEventListener('error', () => this.say('The relay could not be reached.')); } setValue(value) { if (!this.hidden) return; this.hidden.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); } } export function mountVoice(container, hidden, relayUrl, key) { return new VoiceWidget(container, hidden, relayUrl, key); }