diff --git a/voice.js b/voice.js index d682127..61b5139 100644 --- a/voice.js +++ b/voice.js @@ -6,8 +6,14 @@ // 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. +// 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. @@ -40,6 +46,14 @@ class VoiceWidget { 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.'); @@ -84,15 +98,35 @@ class VoiceWidget { if (!blob.size) { this.say('Nothing was recorded.'); return; } this.preview.src = URL.createObjectURL(blob); this.preview.hidden = false; - this.say('Sending…'); - let pcm; + this.published = null; + this.setValue(null); try { - pcm = await this.toPcm16(await blob.arrayBuffer()); + this.pcm = await this.toPcm16(await blob.arrayBuffer()); } catch (e) { - this.say('Could not decode the recording.'); + this.pcm = null; + this.say('Could not decode the recording — try once more.'); return; } - this.upload(pcm); + 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. @@ -117,7 +151,7 @@ class VoiceWidget { return out; } - upload(pcm) { + 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; @@ -130,20 +164,28 @@ class VoiceWidget { 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.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.'); + 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.')); + 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 = JSON.stringify(value); + this.hidden.value = value ? JSON.stringify(value) : ''; this.hidden.dispatchEvent(new Event('input', { bubbles: true })); } @@ -154,6 +196,7 @@ class VoiceWidget { 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); } }