diff --git a/gesture.js b/gesture.js index 5ef6c8e..f25d141 100644 --- a/gesture.js +++ b/gesture.js @@ -274,6 +274,10 @@ class GestureWidget { this.selected = null; } else { this.selected = { key, path: JSON.parse(thumb.dataset.path), distance: Number(thumb.dataset.distance) }; + // The relay counts picks per place for ranking. + if (this.ws && this.ws.readyState === WebSocket.OPEN && this.ownKey) { + this.ws.send(JSON.stringify({ type: 'select', key, from: this.ownKey, distance: this.selected.distance })); + } } this.applySelection(); } diff --git a/src/app.rs b/src/app.rs index 13e24b1..539b210 100644 --- a/src/app.rs +++ b/src/app.rs @@ -498,6 +498,28 @@ mod prosekit { // prosekit's hidden-input bridge for the value, yes.js's typed-handle // lifecycle for cleanup - it may own a live WebSocket to a // redoal-relay, which SPA navigation must close (`stop()`). +#[cfg(feature = "hydrate")] +#[cfg(feature = "hydrate")] +mod voice { + use wasm_bindgen::prelude::*; + + #[wasm_bindgen(module = "/voice.js")] + extern "C" { + pub type VoiceWidget; + + #[wasm_bindgen(js_name = mountVoice)] + pub fn mount_voice( + container: &web_sys::HtmlDivElement, + hidden: &web_sys::HtmlInputElement, + relay_url: &str, + key: &str, + ) -> VoiceWidget; + + #[wasm_bindgen(method)] + pub fn stop(this: &VoiceWidget); + } +} + #[cfg(feature = "hydrate")] mod gesture { use wasm_bindgen::prelude::*; @@ -766,12 +788,16 @@ fn AlternativeCard( .or_insert_with(|| RwSignal::new(Vec::new())); select_multi.insert(req.name.clone(), req.multiple); } else { - if req.kind == "gesture" { + // Widget-owned fields hold JSON objects, not strings. + if req.kind == "gesture" || req.kind == "voice" { gesture_fields.insert(req.name.clone()); } + // A voice field's preset is the key it records at, + // not a value to submit. + let preset = if req.kind == "voice" { String::new() } else { req.value.clone().unwrap_or_default() }; field_map .entry(req.name.clone()) - .or_insert_with(|| RwSignal::new(String::new())); + .or_insert_with(|| RwSignal::new(preset)); } } } @@ -1138,6 +1164,54 @@ fn AlternativeCard( .into_any(); } + if req.kind == "voice" { + let container_ref: NodeRef = NodeRef::new(); + let hidden_ref: NodeRef = NodeRef::new(); + + #[cfg(feature = "hydrate")] + { + let relay = req.relay.clone().unwrap_or_default(); + let key = req.value.clone().unwrap_or_default(); + let widget: StoredValue, LocalStorage> = + StoredValue::new_local(None); + Effect::new(move |_| { + let (Some(container), Some(hidden)) = + (container_ref.get(), hidden_ref.get()) + else { + return; + }; + if widget.with_value(|w| w.is_some()) { + return; + } + widget.set_value(Some(voice::mount_voice( + &container, &hidden, &relay, &key, + ))); + }); + on_cleanup(move || { + widget.update_value(|opt| { + if let Some(w) = opt.take() { + w.stop(); + } + }); + }); + } + + return view! { +
+ {label_text} + +
+
+ } + .into_any(); + } + if req.kind == "prosekit" { let container_ref: NodeRef = NodeRef::new(); let hidden_ref: NodeRef = NodeRef::new(); @@ -1454,8 +1528,11 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView { let name = text_field(&obj, &["name", "title"]); let description = text_field(&obj, &["description"]); let url = text_field(&obj, &["url", "html_url"]); + // A playable recording (an https URL to audio) renders as a + // player, not a link - the relay's /blob/.wav. + let audio = text_field(&obj, &["audio"]).filter(|a| a.starts_with("https://")); - let known = ["name", "title", "description", "url", "html_url"]; + let known = ["name", "title", "description", "url", "html_url", "audio"]; let extra: Vec<(String, String)> = obj .iter() .filter(|(k, v)| !known.contains(&k.as_str()) && !v.is_null()) @@ -1495,6 +1572,7 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView { } })} {description.map(|d| view! {

{d}

})} + {audio.map(|src| view! { })} {(!extra.is_empty()) .then(|| { view! { diff --git a/src/content.rs b/src/content.rs index 020b266..4c0070e 100644 --- a/src/content.rs +++ b/src/content.rs @@ -439,7 +439,13 @@ pub struct Requirement { /// the selected file's current content. #[serde(default)] pub bind: Option, - /// `type: gesture` only - ws(s):// URL of a redoal-relay instance + /// A preset value the field starts with. On a dynamic page the + /// URL segment substitutes into `{name}` placeholders here, like + /// resource keys - `value: "{key}"` on a hidden field carries the + /// page's key into the answer. + #[serde(default)] + pub value: Option, + /// `type: gesture` / `type: voice` - ws(s):// URL of a redoal-relay instance /// the drawing widget connects to for live echoes of similar /// strokes. Absent means the widget works offline: the drawn path /// still submits, it just never gets a network-computed key or @@ -817,15 +823,26 @@ pub fn resolve_question( } } }; + // A url source's address takes the segment too: + // `https://relay.redoal.com/place/{key}`. + let substitute_url = |src: &mut ResourceSource| { + if let ResourceSource::Url { url } = src { + for (name, value) in &captures { + *url = url.replace(&format!("{{{name}}}"), value); + } + } + }; for alt in &mut resolved.alternatives { for feature in &mut alt.features { if let Some(res) = &mut feature.resource { substitute(&mut res.key); + substitute_url(&mut res.source); } for req in &mut feature.requirements { if let Some(res) = &mut req.resource { substitute(&mut res.key); } + substitute(&mut req.value); } } } @@ -1151,9 +1168,9 @@ pub fn validate_questions( ); } if let Some(relay) = &requirement.relay { - if requirement.kind != "gesture" { + if requirement.kind != "gesture" && requirement.kind != "voice" { anyhow::bail!( - "question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture", + "question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture or voice", question.id, alternative.name, feature.name, requirement.name, requirement.kind ); } @@ -2019,4 +2036,28 @@ alternatives: let odd = serde_json::json!({"k": "a/b c"}); assert_eq!(template_action("/p/{k}", &odd).as_deref(), Some("/p/a-b-c")); } + + #[test] + fn dynamic_page_fills_requirement_values() { + let q: Question = serde_yaml::from_str( + "id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: key\n type: hidden\n value: \"{key}\"\n", + ) + .unwrap(); + let mut qs = std::collections::HashMap::new(); + qs.insert(q.id.clone(), q); + let page = resolve_question(&qs, "/shape/20aa98").unwrap(); + assert_eq!(page.alternatives[0].features[0].requirements[0].value.as_deref(), Some("20aa98")); + + let q: Question = serde_yaml::from_str( + "id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n resource:\n source: { kind: url, url: \"https://relay.example/place/{key}\" }\n public: true\n", + ) + .unwrap(); + let mut qs = std::collections::HashMap::new(); + qs.insert(q.id.clone(), q); + let page = resolve_question(&qs, "/shape/20aa98").unwrap(); + match &page.alternatives[0].features[0].resource.as_ref().unwrap().source { + ResourceSource::Url { url } => assert_eq!(url, "https://relay.example/place/20aa98"), + _ => panic!(), + } + } } diff --git a/style/main.css b/style/main.css index 5abc08a..3983108 100644 --- a/style/main.css +++ b/style/main.css @@ -448,6 +448,57 @@ main.not-found { margin-bottom: 0.15em; } +/* voice field (voice.js): a record button, a status line, a preview */ +.voice-wrap { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem 1rem; +} + +.voice-button { + font: inherit; + padding: 0.55rem 1.1rem; + border: 0.06rem solid var(--accent); + border-radius: 2rem; + background: transparent; + color: var(--ink); + cursor: pointer; +} + +.voice-button:hover:not(:disabled) { + background: var(--accent); + color: var(--paper); +} + +.voice-button.recording { + background: var(--accent); + color: var(--paper); + animation: voice-pulse 1.2s ease-in-out infinite; +} + +.voice-button:disabled { + opacity: 0.5; + cursor: default; +} + +@keyframes voice-pulse { + 50% { opacity: 0.6; } +} + +.voice-status { + font-size: 0.85rem; + color: var(--ink-dim); +} + +.voice-preview, +.item-card-audio { + display: block; + width: 100%; + max-width: 28rem; + margin-top: 0.4rem; +} + /* links inside markdown descriptions */ .alt-description a, .feature p a { diff --git a/voice.js b/voice.js new file mode 100644 index 0000000..d682127 --- /dev/null +++ b/voice.js @@ -0,0 +1,162 @@ +// 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); +}