/** * Demo mode — for filming two robots "talking". * * The screen shows ONLY the live detection (a big decoded readout + scope + * a running conversation) and the alphabet chart below. There is no compose * box and nothing reveals a message before it is heard — the magic is that the * robots appear to converse on their own. * * Hidden in Demo setup (the gear) are "if you hear X, answer Y" rules. When * this robot's mic decodes a finished message that matches a rule, it waits a * beat and taps out the reply on its own antennas. A self-echo guard * (ctx.selfEchoActive) stops a robot from replying to its own taps. * * Scripted demo: robot A is given "U OK?" (Send from setup) → robot B hears it * and replies "Y". Then you finger-tap "SOS" near both → both reply "WTF". */ import { el, clear } from "./dom.js"; import { decodeOnsets } from "../lib/wire.js"; import { matchRule } from "../lib/rules.js"; import { Scope } from "../lib/viz.js"; import { createLearn } from "./learn.js"; export function createDemo(ctx) { const root = el("section.view#view-demo"); const canvas = el("canvas.scope.scope-big"); const scope = new Scope(canvas); const bigText = el("div.demo-decoded", {}, "…"); const bigMorse = el("div.decoded-morse muted"); const statusEl = el("div.demo-status muted", {}, "Listening…"); const transcript = el("div.transcript"); const clearBtn = el("button.btn.ghost.small", { onclick: clearAll }, "Clear"); let mic = null; let onsets = []; let lastOnsetMs = 0; let idleTimer = null; const timing = () => ctx.timing(); async function start() { if (mic) return; try { mic = ctx.makeMic({ onOnset: (t) => onOnset(t), onLevel: (lvl) => scope.pushLevel(lvl), }); await mic.start(); scope.start(); idleTimer = setInterval(checkIdle, 250); statusEl.textContent = "Listening…"; } catch { mic = null; statusEl.textContent = "Tap anywhere to enable the microphone."; root.addEventListener("click", start, { once: true }); } } async function stop() { if (idleTimer) { clearInterval(idleTimer); idleTimer = null; } scope.stop(); if (mic) { await mic.stop(); mic = null; } } function onOnset(t) { onsets.push(t); lastOnsetMs = t; scope.markOnset(); render(); } function render() { const { morse, text } = decodeOnsets(onsets, timing()); bigText.textContent = text || "…"; bigMorse.textContent = morse; } function checkIdle() { if (!mic || onsets.length === 0) return; if (mic.now() - lastOnsetMs > timing().wordGapMs * 2.5) finalize(); } function finalize() { if (onsets.length === 0) return; const { morse, text } = decodeOnsets(onsets, timing()); onsets = []; render(); if (!text) return; transcript.prepend(el("div.line", {}, [ el("span.line-text", {}, text), el("span.line-morse muted", {}, morse), ])); maybeReply(text); } function maybeReply(text) { // Don't react to our own taps / their echo tail. if (ctx.selfEchoActive?.()) return; const rule = matchRule(text, ctx.state.demo.rules); if (!rule) return; statusEl.textContent = `Heard “${text}” → replying “${rule.output}”`; // A short beat so it feels like an answer, not an interruption. setTimeout(() => { ctx.transmit(rule.output, { via: ctx.reachy ? "robot" : "device" }) .then(() => { statusEl.textContent = "Listening…"; }) .catch(() => { statusEl.textContent = "Listening…"; }); }, 500); } function clearAll() { onsets = []; clear(transcript); render(); } const learn = createLearn(ctx); render(); root.append( el("h2", {}, "Demo"), canvas, bigText, bigMorse, el("div.row.between", {}, [statusEl, clearBtn]), el("label.field-label", {}, "Conversation"), transcript, el("hr.divider"), learn.node, ); return { node: root, onShow: () => { start(); learn.onShow?.(); }, onHide: stop, }; }