morse-code / views /demo.js
RemiFabre
feat: Demo mode for filming two robots conversing
a33a583
Raw
History Blame Contribute Delete
4.44 kB
/**
* 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,
};
}