Spaces:
Running
Running
RemiFabre commited on
Commit ·
a33a583
1
Parent(s): 4b5a0c7
feat: Demo mode for filming two robots conversing
Browse filesApp/Demo toggle in the header. Demo mode shows only the live detection (big
readout + scope + running conversation) and the alphabet — no compose box, so
nothing reveals a message before it's heard.
Hidden 'Demo setup' (gear) holds 'if you hear X, answer Y' rules + a per-rule
'Send' to kick one off. When this robot's mic decodes a finished message that
matches a rule, it taps the reply on its own antennas. A self-echo guard
(ctx.transmit sets a window covering the send + idle-finalize tail) stops a
robot replying to its own taps. Matching is normalized so 'U OK?'/'U OK'/'UOK'
are equivalent. Defaults: U OK->Y, SOS->WTF. Added lib/rules.js (+tests) and
ctx.transmit; only the active page holds the mic.
- lib/rules.js +31 -0
- lib/version.js +1 -1
- main.js +81 -11
- style.css +39 -0
- tests/unit/rules.test.js +28 -0
- tests/unit/views.test.js +35 -0
- views/demo.js +135 -0
- views/settings.js +42 -1
lib/rules.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Demo-mode auto-reply rules: "if you hear X, answer Y".
|
| 3 |
+
*
|
| 4 |
+
* Matching is normalized — uppercased, with spaces and punctuation stripped —
|
| 5 |
+
* so a rule typed as "U OK?" still fires on a decoded "U OK" or "UOK". Antenna
|
| 6 |
+
* Morse won't reliably carry punctuation, so we never require an exact match.
|
| 7 |
+
*
|
| 8 |
+
* Pure module (no DOM/audio) so the matching logic is unit-tested directly.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
export function normalizePhrase(s) {
|
| 12 |
+
return String(s).toUpperCase().replace(/[^A-Z0-9]/g, "");
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/** Default conversation for the two-robot demo (short = fewer antenna taps). */
|
| 16 |
+
export const DEFAULT_RULES = [
|
| 17 |
+
{ input: "U OK", output: "Y" },
|
| 18 |
+
{ input: "SOS", output: "WTF" },
|
| 19 |
+
];
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* Find the first rule whose (normalized) input equals the (normalized)
|
| 23 |
+
* decoded text. Returns the rule or null.
|
| 24 |
+
*/
|
| 25 |
+
export function matchRule(text, rules) {
|
| 26 |
+
const n = normalizePhrase(text);
|
| 27 |
+
if (!n) return null;
|
| 28 |
+
return (rules || []).find(
|
| 29 |
+
(r) => r && r.input && r.output && normalizePhrase(r.input) === n,
|
| 30 |
+
) || null;
|
| 31 |
+
}
|
lib/version.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
// Deployment version label, shown in Settings so you can verify which build
|
| 2 |
// is actually loaded after a push. Format: "YYYY-MM-DD rN", bumped on push.
|
| 3 |
-
export const APP_VERSION = "2026-06-
|
|
|
|
| 1 |
// Deployment version label, shown in Settings so you can verify which build
|
| 2 |
// is actually loaded after a push. Format: "YYYY-MM-DD rN", bumped on push.
|
| 3 |
+
export const APP_VERSION = "2026-06-02 r6 demo mode";
|
main.js
CHANGED
|
@@ -18,6 +18,8 @@ import { connectToHost } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/rea
|
|
| 18 |
import { safelyReturnToPose } from "./lib/animation-helpers.js";
|
| 19 |
|
| 20 |
import { makeTiming, SPEED_PRESETS } from "./lib/timing.js";
|
|
|
|
|
|
|
| 21 |
import { Synth } from "./lib/synth.js";
|
| 22 |
import { RobotTapper, DEFAULT_TAP_PROFILE } from "./lib/robot-tapper.js";
|
| 23 |
import { Mic } from "./lib/mic.js";
|
|
@@ -25,6 +27,7 @@ import { el, clear } from "./views/dom.js";
|
|
| 25 |
import { createCompose } from "./views/compose.js";
|
| 26 |
import { createListen } from "./views/listen.js";
|
| 27 |
import { createLearn } from "./views/learn.js";
|
|
|
|
| 28 |
import { createSettings } from "./views/settings.js";
|
| 29 |
|
| 30 |
const params = new URLSearchParams(window.location.search);
|
|
@@ -57,17 +60,24 @@ async function bootEmbed() {
|
|
| 57 |
|
| 58 |
// ─── Shared, mutable config ───────────────────────────────────────
|
| 59 |
const config = {
|
|
|
|
| 60 |
speed: "normal",
|
| 61 |
unitMs: SPEED_PRESETS.normal,
|
| 62 |
emitter: reachy ? "robot" : "device",
|
| 63 |
beepFreq: 2200,
|
| 64 |
detector: { highpassHz: 2000, thresholdFactor: 4.0 },
|
| 65 |
tap: { ...DEFAULT_TAP_PROFILE },
|
|
|
|
| 66 |
};
|
| 67 |
|
| 68 |
const synth = new Synth({ freq: config.beepFreq });
|
| 69 |
let tapper = reachy ? new RobotTapper(reachy, config.tap) : null;
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
const ctx = {
|
| 72 |
reachy,
|
| 73 |
state: config,
|
|
@@ -87,8 +97,40 @@ async function bootEmbed() {
|
|
| 87 |
detector: { thresholdFactor: config.detector.thresholdFactor },
|
| 88 |
...cbs,
|
| 89 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
toast,
|
| 91 |
};
|
|
|
|
| 92 |
|
| 93 |
// ─── Views (single scrolling page: Compose, then always-on Listen,
|
| 94 |
// then the Learn chart — scroll down to look anything up while a
|
|
@@ -97,41 +139,68 @@ async function bootEmbed() {
|
|
| 97 |
compose: createCompose(ctx),
|
| 98 |
listen: createListen(ctx),
|
| 99 |
learn: createLearn(ctx),
|
|
|
|
| 100 |
};
|
| 101 |
|
| 102 |
const app = document.getElementById("app");
|
| 103 |
clear(app);
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
const header = el("header.appbar", {}, [
|
| 106 |
-
el("div.brand", {}, [el("span.brand-logo", {}, "📡"), el("span", {}, "Morse Code")]),
|
|
|
|
| 107 |
el("button.icon-btn", { title: "Settings", onclick: openSettings }, "⚙️"),
|
| 108 |
]);
|
| 109 |
|
| 110 |
-
|
|
|
|
| 111 |
views.compose.node,
|
| 112 |
el("hr.divider"),
|
| 113 |
views.listen.node,
|
| 114 |
el("hr.divider"),
|
| 115 |
views.learn.node,
|
| 116 |
]);
|
|
|
|
|
|
|
| 117 |
|
| 118 |
-
app.append(header,
|
| 119 |
app.hidden = false;
|
| 120 |
views.compose.onShow?.();
|
| 121 |
-
views.listen.onShow?.();
|
| 122 |
views.learn.onShow?.();
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
// ─── Settings modal ─────────────────────────────────────────────
|
| 129 |
function openSettings() {
|
| 130 |
-
const sheet = createSettings(ctx);
|
| 131 |
const overlay = el("div.overlay", { onclick: (e) => { if (e.target === overlay) close(); } });
|
| 132 |
-
const close = () => overlay.remove();
|
|
|
|
|
|
|
| 133 |
const panel = el("div.sheet", {}, [
|
| 134 |
-
el("div.sheet-head", {}, [el("h2", {}, "Settings"),
|
| 135 |
el("button.icon-btn", { onclick: close }, "✕")]),
|
| 136 |
sheet.node,
|
| 137 |
]);
|
|
@@ -144,6 +213,7 @@ async function bootEmbed() {
|
|
| 144 |
try { tapper?.stop(); } catch { /* */ }
|
| 145 |
try { synth.stop(); } catch { /* */ }
|
| 146 |
try { views.listen.onHide?.(); } catch { /* */ }
|
|
|
|
| 147 |
if (reachy) return safelyReturnToPose(reachy);
|
| 148 |
});
|
| 149 |
|
|
|
|
| 18 |
import { safelyReturnToPose } from "./lib/animation-helpers.js";
|
| 19 |
|
| 20 |
import { makeTiming, SPEED_PRESETS } from "./lib/timing.js";
|
| 21 |
+
import { textToSchedule } from "./lib/wire.js";
|
| 22 |
+
import { DEFAULT_RULES } from "./lib/rules.js";
|
| 23 |
import { Synth } from "./lib/synth.js";
|
| 24 |
import { RobotTapper, DEFAULT_TAP_PROFILE } from "./lib/robot-tapper.js";
|
| 25 |
import { Mic } from "./lib/mic.js";
|
|
|
|
| 27 |
import { createCompose } from "./views/compose.js";
|
| 28 |
import { createListen } from "./views/listen.js";
|
| 29 |
import { createLearn } from "./views/learn.js";
|
| 30 |
+
import { createDemo } from "./views/demo.js";
|
| 31 |
import { createSettings } from "./views/settings.js";
|
| 32 |
|
| 33 |
const params = new URLSearchParams(window.location.search);
|
|
|
|
| 60 |
|
| 61 |
// ─── Shared, mutable config ───────────────────────────────────────
|
| 62 |
const config = {
|
| 63 |
+
mode: "app", // "app" | "demo"
|
| 64 |
speed: "normal",
|
| 65 |
unitMs: SPEED_PRESETS.normal,
|
| 66 |
emitter: reachy ? "robot" : "device",
|
| 67 |
beepFreq: 2200,
|
| 68 |
detector: { highpassHz: 2000, thresholdFactor: 4.0 },
|
| 69 |
tap: { ...DEFAULT_TAP_PROFILE },
|
| 70 |
+
demo: { rules: DEFAULT_RULES.map((r) => ({ ...r })) },
|
| 71 |
};
|
| 72 |
|
| 73 |
const synth = new Synth({ freq: config.beepFreq });
|
| 74 |
let tapper = reachy ? new RobotTapper(reachy, config.tap) : null;
|
| 75 |
|
| 76 |
+
// Self-echo guard: while (and just after) THIS robot transmits, its own mic
|
| 77 |
+
// hears the taps — Demo mode must not treat that as an incoming message.
|
| 78 |
+
let selfEchoUntil = 0;
|
| 79 |
+
let activeCancel = null;
|
| 80 |
+
|
| 81 |
const ctx = {
|
| 82 |
reachy,
|
| 83 |
state: config,
|
|
|
|
| 97 |
detector: { thresholdFactor: config.detector.thresholdFactor },
|
| 98 |
...cbs,
|
| 99 |
}),
|
| 100 |
+
selfEchoActive: () => performance.now() < selfEchoUntil,
|
| 101 |
+
isTransmitting: () => !!activeCancel,
|
| 102 |
+
// Transmit `text` on the robot antennas (default) or device speaker.
|
| 103 |
+
// Sets a self-echo window covering the whole send plus the idle-finalize
|
| 104 |
+
// tail, so Demo mode ignores this robot's own taps.
|
| 105 |
+
transmit: async (text, { onProgress, via } = {}) => {
|
| 106 |
+
const t = makeTiming(config.unitMs);
|
| 107 |
+
const { onsets, durationMs } = textToSchedule(text, t);
|
| 108 |
+
if (!onsets.length) return;
|
| 109 |
+
const useRobot = (via ?? config.emitter) === "robot" && !!reachy;
|
| 110 |
+
selfEchoUntil = performance.now() + durationMs + t.wordGapMs * 2.5 + 3000;
|
| 111 |
+
try {
|
| 112 |
+
if (useRobot) {
|
| 113 |
+
const controller = new AbortController();
|
| 114 |
+
activeCancel = () => controller.abort();
|
| 115 |
+
ctxRef.tapper().p = { ...DEFAULT_TAP_PROFILE, ...config.tap };
|
| 116 |
+
await ctxRef.tapper().tap(onsets, { onTap: onProgress, signal: controller.signal });
|
| 117 |
+
} else {
|
| 118 |
+
await new Promise((resolve) => {
|
| 119 |
+
const cancel = synth.play(onsets, {
|
| 120 |
+
onBlip: onProgress, onDone: resolve, clickMs: t.clickMs,
|
| 121 |
+
});
|
| 122 |
+
activeCancel = () => { cancel(); resolve(); };
|
| 123 |
+
});
|
| 124 |
+
}
|
| 125 |
+
} finally {
|
| 126 |
+
activeCancel = null;
|
| 127 |
+
// Extend past the finalize that fires after our last tap.
|
| 128 |
+
selfEchoUntil = Math.max(selfEchoUntil, performance.now() + t.wordGapMs * 2.5 + 1500);
|
| 129 |
+
}
|
| 130 |
+
},
|
| 131 |
toast,
|
| 132 |
};
|
| 133 |
+
const ctxRef = ctx;
|
| 134 |
|
| 135 |
// ─── Views (single scrolling page: Compose, then always-on Listen,
|
| 136 |
// then the Learn chart — scroll down to look anything up while a
|
|
|
|
| 139 |
compose: createCompose(ctx),
|
| 140 |
listen: createListen(ctx),
|
| 141 |
learn: createLearn(ctx),
|
| 142 |
+
demo: createDemo(ctx),
|
| 143 |
};
|
| 144 |
|
| 145 |
const app = document.getElementById("app");
|
| 146 |
clear(app);
|
| 147 |
|
| 148 |
+
// Header: brand + App/Demo mode toggle + settings.
|
| 149 |
+
const modeBtns = {};
|
| 150 |
+
const modeSeg = el("div.mode-seg");
|
| 151 |
+
[["app", "App"], ["demo", "Demo"]].forEach(([v, label]) => {
|
| 152 |
+
const b = el("button.mode-btn", { onclick: () => setMode(v) }, label);
|
| 153 |
+
modeBtns[v] = b;
|
| 154 |
+
modeSeg.append(b);
|
| 155 |
+
});
|
| 156 |
const header = el("header.appbar", {}, [
|
| 157 |
+
el("div.brand", {}, [el("span.brand-logo", {}, "📡"), el("span.brand-name", {}, "Morse Code")]),
|
| 158 |
+
modeSeg,
|
| 159 |
el("button.icon-btn", { title: "Settings", onclick: openSettings }, "⚙️"),
|
| 160 |
]);
|
| 161 |
|
| 162 |
+
// Two pages share one host; only the active page's view holds the mic.
|
| 163 |
+
const appScroller = el("main.scroller", {}, [
|
| 164 |
views.compose.node,
|
| 165 |
el("hr.divider"),
|
| 166 |
views.listen.node,
|
| 167 |
el("hr.divider"),
|
| 168 |
views.learn.node,
|
| 169 |
]);
|
| 170 |
+
const demoScroller = el("main.scroller", {}, [views.demo.node]);
|
| 171 |
+
const pageHost = el("div.page-host");
|
| 172 |
|
| 173 |
+
app.append(header, pageHost);
|
| 174 |
app.hidden = false;
|
| 175 |
views.compose.onShow?.();
|
|
|
|
| 176 |
views.learn.onShow?.();
|
| 177 |
+
|
| 178 |
+
function setMode(mode) {
|
| 179 |
+
config.mode = mode;
|
| 180 |
+
Object.entries(modeBtns).forEach(([k, b]) => b.classList.toggle("active", k === mode));
|
| 181 |
+
views.listen.onHide?.();
|
| 182 |
+
views.demo.onHide?.();
|
| 183 |
+
clear(pageHost);
|
| 184 |
+
if (mode === "demo") {
|
| 185 |
+
pageHost.append(demoScroller);
|
| 186 |
+
views.demo.onShow?.();
|
| 187 |
+
} else {
|
| 188 |
+
pageHost.append(appScroller);
|
| 189 |
+
// Always-on Listen: auto-start the mic (falls back to tap-to-start).
|
| 190 |
+
views.listen.autoStart?.();
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
ctx.setMode = setMode;
|
| 194 |
+
setMode("app");
|
| 195 |
|
| 196 |
// ─── Settings modal ─────────────────────────────────────────────
|
| 197 |
function openSettings() {
|
|
|
|
| 198 |
const overlay = el("div.overlay", { onclick: (e) => { if (e.target === overlay) close(); } });
|
| 199 |
+
const close = () => { overlay.remove(); ctx._closeSettings = null; };
|
| 200 |
+
ctx._closeSettings = close;
|
| 201 |
+
const sheet = createSettings(ctx);
|
| 202 |
const panel = el("div.sheet", {}, [
|
| 203 |
+
el("div.sheet-head", {}, [el("h2", {}, config.mode === "demo" ? "Demo setup" : "Settings"),
|
| 204 |
el("button.icon-btn", { onclick: close }, "✕")]),
|
| 205 |
sheet.node,
|
| 206 |
]);
|
|
|
|
| 213 |
try { tapper?.stop(); } catch { /* */ }
|
| 214 |
try { synth.stop(); } catch { /* */ }
|
| 215 |
try { views.listen.onHide?.(); } catch { /* */ }
|
| 216 |
+
try { views.demo.onHide?.(); } catch { /* */ }
|
| 217 |
if (reachy) return safelyReturnToPose(reachy);
|
| 218 |
});
|
| 219 |
|
style.css
CHANGED
|
@@ -80,6 +80,16 @@ html, body {
|
|
| 80 |
cursor: pointer;
|
| 81 |
}
|
| 82 |
.icon-btn:active { transform: scale(0.95); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
/* ─── Scrolling single page ───────────────────────────────── */
|
| 85 |
.scroller { flex: 1; padding: 12px 14px 40px; display: flex; flex-direction: column; gap: 12px; }
|
|
@@ -288,6 +298,35 @@ html, body {
|
|
| 288 |
}
|
| 289 |
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
| 290 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
@media (min-width: 560px) {
|
| 292 |
.tab-label { font-size: 13px; }
|
| 293 |
}
|
|
|
|
| 80 |
cursor: pointer;
|
| 81 |
}
|
| 82 |
.icon-btn:active { transform: scale(0.95); }
|
| 83 |
+
.icon-btn.tiny { width: 32px; height: 32px; font-size: 14px; }
|
| 84 |
+
|
| 85 |
+
/* App / Demo mode toggle in the header */
|
| 86 |
+
.mode-seg { display: flex; gap: 4px; background: var(--panel); border: 1px solid var(--line); border-radius: 11px; padding: 3px; }
|
| 87 |
+
.mode-btn {
|
| 88 |
+
appearance: none; background: transparent; border: 0; color: var(--muted);
|
| 89 |
+
padding: 7px 14px; border-radius: 8px; font-size: 13px; font-weight: 700; cursor: pointer;
|
| 90 |
+
}
|
| 91 |
+
.mode-btn.active { background: var(--panel-2); color: var(--text); box-shadow: var(--shadow); }
|
| 92 |
+
.page-host { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
| 93 |
|
| 94 |
/* ─── Scrolling single page ───────────────────────────────── */
|
| 95 |
.scroller { flex: 1; padding: 12px 14px 40px; display: flex; flex-direction: column; gap: 12px; }
|
|
|
|
| 298 |
}
|
| 299 |
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
| 300 |
|
| 301 |
+
/* ─── Demo mode ───────────────────────────────────────────── */
|
| 302 |
+
.scope-big { height: 110px; }
|
| 303 |
+
.demo-decoded {
|
| 304 |
+
font-family: "JetBrains Mono", ui-monospace, monospace;
|
| 305 |
+
font-size: 40px;
|
| 306 |
+
font-weight: 800;
|
| 307 |
+
letter-spacing: 0.04em;
|
| 308 |
+
text-align: center;
|
| 309 |
+
min-height: 50px;
|
| 310 |
+
word-break: break-word;
|
| 311 |
+
color: var(--accent);
|
| 312 |
+
}
|
| 313 |
+
.demo-status { text-align: center; min-height: 16px; }
|
| 314 |
+
.btn.small { padding: 8px 12px; font-size: 13px; border-radius: 10px; flex: 0 0 auto; }
|
| 315 |
+
|
| 316 |
+
.demo-setup { display: flex; flex-direction: column; gap: 8px; padding-bottom: 6px; border-bottom: 1px solid var(--line); }
|
| 317 |
+
.tiny { font-size: 12px; }
|
| 318 |
+
.rule-list { display: flex; flex-direction: column; gap: 8px; }
|
| 319 |
+
.rule-row { display: flex; align-items: center; gap: 6px; }
|
| 320 |
+
.rule-input {
|
| 321 |
+
flex: 1; min-width: 0; padding: 9px 10px; font-size: 14px; font-family: inherit;
|
| 322 |
+
color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 9px; outline: none;
|
| 323 |
+
}
|
| 324 |
+
.rule-input:focus { border-color: var(--accent); }
|
| 325 |
+
.rule-arrow { color: var(--muted); font-weight: 800; }
|
| 326 |
+
|
| 327 |
+
@media (max-width: 420px) {
|
| 328 |
+
.brand-name { display: none; }
|
| 329 |
+
}
|
| 330 |
@media (min-width: 560px) {
|
| 331 |
.tab-label { font-size: 13px; }
|
| 332 |
}
|
tests/unit/rules.test.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from "vitest";
|
| 2 |
+
import { normalizePhrase, matchRule, DEFAULT_RULES } from "../../lib/rules.js";
|
| 3 |
+
|
| 4 |
+
describe("normalizePhrase", () => {
|
| 5 |
+
it("uppercases and strips spaces + punctuation", () => {
|
| 6 |
+
expect(normalizePhrase("U OK?")).toBe("UOK");
|
| 7 |
+
expect(normalizePhrase("u ok")).toBe("UOK");
|
| 8 |
+
expect(normalizePhrase("S.O.S")).toBe("SOS");
|
| 9 |
+
});
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
describe("matchRule", () => {
|
| 13 |
+
it("fires on punctuation/spacing variants of the input", () => {
|
| 14 |
+
expect(matchRule("U OK", DEFAULT_RULES)?.output).toBe("Y");
|
| 15 |
+
expect(matchRule("UOK", DEFAULT_RULES)?.output).toBe("Y");
|
| 16 |
+
expect(matchRule("SOS", DEFAULT_RULES)?.output).toBe("WTF");
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
it("returns null for no match or empty text", () => {
|
| 20 |
+
expect(matchRule("HELLO", DEFAULT_RULES)).toBeNull();
|
| 21 |
+
expect(matchRule("", DEFAULT_RULES)).toBeNull();
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
it("ignores rules missing input or output", () => {
|
| 25 |
+
const rules = [{ input: "A", output: "" }, { input: "", output: "B" }];
|
| 26 |
+
expect(matchRule("A", rules)).toBeNull();
|
| 27 |
+
});
|
| 28 |
+
});
|
tests/unit/views.test.js
CHANGED
|
@@ -5,16 +5,20 @@ import { createCompose } from "../../views/compose.js";
|
|
| 5 |
import { createLearn } from "../../views/learn.js";
|
| 6 |
import { createSettings } from "../../views/settings.js";
|
| 7 |
import { createListen } from "../../views/listen.js";
|
|
|
|
| 8 |
import { DEFAULT_TAP_PROFILE } from "../../lib/robot-tapper.js";
|
|
|
|
| 9 |
|
| 10 |
function makeCtx(overrides = {}) {
|
| 11 |
const state = {
|
|
|
|
| 12 |
speed: "normal",
|
| 13 |
unitMs: SPEED_PRESETS.normal,
|
| 14 |
emitter: "device",
|
| 15 |
beepFreq: 2200,
|
| 16 |
detector: { highpassHz: 2000, thresholdFactor: 4.0 },
|
| 17 |
tap: { ...DEFAULT_TAP_PROFILE },
|
|
|
|
| 18 |
};
|
| 19 |
const synth = { play: vi.fn(), stop: vi.fn(), freq: 2200 };
|
| 20 |
return {
|
|
@@ -25,6 +29,9 @@ function makeCtx(overrides = {}) {
|
|
| 25 |
synth: () => synth,
|
| 26 |
tapper: () => null,
|
| 27 |
makeMic: () => ({ start: vi.fn(), stop: vi.fn(), now: () => 0 }),
|
|
|
|
|
|
|
|
|
|
| 28 |
toast: vi.fn(),
|
| 29 |
_synth: synth,
|
| 30 |
...overrides,
|
|
@@ -94,3 +101,31 @@ describe("Listen view", () => {
|
|
| 94 |
expect(node.querySelector(".btn.primary").textContent).toMatch(/Start listening/);
|
| 95 |
});
|
| 96 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import { createLearn } from "../../views/learn.js";
|
| 6 |
import { createSettings } from "../../views/settings.js";
|
| 7 |
import { createListen } from "../../views/listen.js";
|
| 8 |
+
import { createDemo } from "../../views/demo.js";
|
| 9 |
import { DEFAULT_TAP_PROFILE } from "../../lib/robot-tapper.js";
|
| 10 |
+
import { DEFAULT_RULES } from "../../lib/rules.js";
|
| 11 |
|
| 12 |
function makeCtx(overrides = {}) {
|
| 13 |
const state = {
|
| 14 |
+
mode: "app",
|
| 15 |
speed: "normal",
|
| 16 |
unitMs: SPEED_PRESETS.normal,
|
| 17 |
emitter: "device",
|
| 18 |
beepFreq: 2200,
|
| 19 |
detector: { highpassHz: 2000, thresholdFactor: 4.0 },
|
| 20 |
tap: { ...DEFAULT_TAP_PROFILE },
|
| 21 |
+
demo: { rules: DEFAULT_RULES.map((r) => ({ ...r })) },
|
| 22 |
};
|
| 23 |
const synth = { play: vi.fn(), stop: vi.fn(), freq: 2200 };
|
| 24 |
return {
|
|
|
|
| 29 |
synth: () => synth,
|
| 30 |
tapper: () => null,
|
| 31 |
makeMic: () => ({ start: vi.fn(), stop: vi.fn(), now: () => 0 }),
|
| 32 |
+
selfEchoActive: () => false,
|
| 33 |
+
isTransmitting: () => false,
|
| 34 |
+
transmit: vi.fn(() => Promise.resolve()),
|
| 35 |
toast: vi.fn(),
|
| 36 |
_synth: synth,
|
| 37 |
...overrides,
|
|
|
|
| 101 |
expect(node.querySelector(".btn.primary").textContent).toMatch(/Start listening/);
|
| 102 |
});
|
| 103 |
});
|
| 104 |
+
|
| 105 |
+
describe("Demo view", () => {
|
| 106 |
+
it("renders the big live readout and embeds the alphabet chart", () => {
|
| 107 |
+
const ctx = makeCtx();
|
| 108 |
+
const { node } = createDemo(ctx);
|
| 109 |
+
expect(node.querySelector(".demo-decoded")).toBeTruthy();
|
| 110 |
+
expect(node.querySelector(".scope-big")).toBeTruthy();
|
| 111 |
+
// alphabet chart embedded below
|
| 112 |
+
expect(node.querySelectorAll(".chart-cell").length).toBe(26 + 10);
|
| 113 |
+
});
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
describe("Settings — demo mode", () => {
|
| 117 |
+
it("shows editable conversation rules with the defaults", () => {
|
| 118 |
+
const ctx = makeCtx({ state: { ...makeCtx().state, mode: "demo" } });
|
| 119 |
+
const { node } = createSettings(ctx);
|
| 120 |
+
const rows = node.querySelectorAll(".rule-row");
|
| 121 |
+
expect(rows.length).toBe(DEFAULT_RULES.length);
|
| 122 |
+
// first rule input reflects the default "U OK?"
|
| 123 |
+
expect(node.querySelector(".rule-input").value).toBe(DEFAULT_RULES[0].input);
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
it("omits the rules editor in app mode", () => {
|
| 127 |
+
const ctx = makeCtx();
|
| 128 |
+
const { node } = createSettings(ctx);
|
| 129 |
+
expect(node.querySelector(".rule-row")).toBeNull();
|
| 130 |
+
});
|
| 131 |
+
});
|
views/demo.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Demo mode — for filming two robots "talking".
|
| 3 |
+
*
|
| 4 |
+
* The screen shows ONLY the live detection (a big decoded readout + scope +
|
| 5 |
+
* a running conversation) and the alphabet chart below. There is no compose
|
| 6 |
+
* box and nothing reveals a message before it is heard — the magic is that the
|
| 7 |
+
* robots appear to converse on their own.
|
| 8 |
+
*
|
| 9 |
+
* Hidden in Demo setup (the gear) are "if you hear X, answer Y" rules. When
|
| 10 |
+
* this robot's mic decodes a finished message that matches a rule, it waits a
|
| 11 |
+
* beat and taps out the reply on its own antennas. A self-echo guard
|
| 12 |
+
* (ctx.selfEchoActive) stops a robot from replying to its own taps.
|
| 13 |
+
*
|
| 14 |
+
* Scripted demo: robot A is given "U OK?" (Send from setup) → robot B hears it
|
| 15 |
+
* and replies "Y". Then you finger-tap "SOS" near both → both reply "WTF".
|
| 16 |
+
*/
|
| 17 |
+
import { el, clear } from "./dom.js";
|
| 18 |
+
import { decodeOnsets } from "../lib/wire.js";
|
| 19 |
+
import { matchRule } from "../lib/rules.js";
|
| 20 |
+
import { Scope } from "../lib/viz.js";
|
| 21 |
+
import { createLearn } from "./learn.js";
|
| 22 |
+
|
| 23 |
+
export function createDemo(ctx) {
|
| 24 |
+
const root = el("section.view#view-demo");
|
| 25 |
+
|
| 26 |
+
const canvas = el("canvas.scope.scope-big");
|
| 27 |
+
const scope = new Scope(canvas);
|
| 28 |
+
const bigText = el("div.demo-decoded", {}, "…");
|
| 29 |
+
const bigMorse = el("div.decoded-morse muted");
|
| 30 |
+
const statusEl = el("div.demo-status muted", {}, "Listening…");
|
| 31 |
+
const transcript = el("div.transcript");
|
| 32 |
+
const clearBtn = el("button.btn.ghost.small", { onclick: clearAll }, "Clear");
|
| 33 |
+
|
| 34 |
+
let mic = null;
|
| 35 |
+
let onsets = [];
|
| 36 |
+
let lastOnsetMs = 0;
|
| 37 |
+
let idleTimer = null;
|
| 38 |
+
|
| 39 |
+
const timing = () => ctx.timing();
|
| 40 |
+
|
| 41 |
+
async function start() {
|
| 42 |
+
if (mic) return;
|
| 43 |
+
try {
|
| 44 |
+
mic = ctx.makeMic({
|
| 45 |
+
onOnset: (t) => onOnset(t),
|
| 46 |
+
onLevel: (lvl) => scope.pushLevel(lvl),
|
| 47 |
+
});
|
| 48 |
+
await mic.start();
|
| 49 |
+
scope.start();
|
| 50 |
+
idleTimer = setInterval(checkIdle, 250);
|
| 51 |
+
statusEl.textContent = "Listening…";
|
| 52 |
+
} catch {
|
| 53 |
+
mic = null;
|
| 54 |
+
statusEl.textContent = "Tap anywhere to enable the microphone.";
|
| 55 |
+
root.addEventListener("click", start, { once: true });
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
async function stop() {
|
| 60 |
+
if (idleTimer) { clearInterval(idleTimer); idleTimer = null; }
|
| 61 |
+
scope.stop();
|
| 62 |
+
if (mic) { await mic.stop(); mic = null; }
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function onOnset(t) {
|
| 66 |
+
onsets.push(t);
|
| 67 |
+
lastOnsetMs = t;
|
| 68 |
+
scope.markOnset();
|
| 69 |
+
render();
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function render() {
|
| 73 |
+
const { morse, text } = decodeOnsets(onsets, timing());
|
| 74 |
+
bigText.textContent = text || "…";
|
| 75 |
+
bigMorse.textContent = morse;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function checkIdle() {
|
| 79 |
+
if (!mic || onsets.length === 0) return;
|
| 80 |
+
if (mic.now() - lastOnsetMs > timing().wordGapMs * 2.5) finalize();
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function finalize() {
|
| 84 |
+
if (onsets.length === 0) return;
|
| 85 |
+
const { morse, text } = decodeOnsets(onsets, timing());
|
| 86 |
+
onsets = [];
|
| 87 |
+
render();
|
| 88 |
+
if (!text) return;
|
| 89 |
+
transcript.prepend(el("div.line", {}, [
|
| 90 |
+
el("span.line-text", {}, text),
|
| 91 |
+
el("span.line-morse muted", {}, morse),
|
| 92 |
+
]));
|
| 93 |
+
maybeReply(text);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function maybeReply(text) {
|
| 97 |
+
// Don't react to our own taps / their echo tail.
|
| 98 |
+
if (ctx.selfEchoActive?.()) return;
|
| 99 |
+
const rule = matchRule(text, ctx.state.demo.rules);
|
| 100 |
+
if (!rule) return;
|
| 101 |
+
statusEl.textContent = `Heard “${text}” → replying “${rule.output}”`;
|
| 102 |
+
// A short beat so it feels like an answer, not an interruption.
|
| 103 |
+
setTimeout(() => {
|
| 104 |
+
ctx.transmit(rule.output, { via: ctx.reachy ? "robot" : "device" })
|
| 105 |
+
.then(() => { statusEl.textContent = "Listening…"; })
|
| 106 |
+
.catch(() => { statusEl.textContent = "Listening…"; });
|
| 107 |
+
}, 500);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
function clearAll() {
|
| 111 |
+
onsets = [];
|
| 112 |
+
clear(transcript);
|
| 113 |
+
render();
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
const learn = createLearn(ctx);
|
| 117 |
+
render();
|
| 118 |
+
root.append(
|
| 119 |
+
el("h2", {}, "Demo"),
|
| 120 |
+
canvas,
|
| 121 |
+
bigText,
|
| 122 |
+
bigMorse,
|
| 123 |
+
el("div.row.between", {}, [statusEl, clearBtn]),
|
| 124 |
+
el("label.field-label", {}, "Conversation"),
|
| 125 |
+
transcript,
|
| 126 |
+
el("hr.divider"),
|
| 127 |
+
learn.node,
|
| 128 |
+
);
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
node: root,
|
| 132 |
+
onShow: () => { start(); learn.onShow?.(); },
|
| 133 |
+
onHide: stop,
|
| 134 |
+
};
|
| 135 |
+
}
|
views/settings.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
* the values we tune against real hardware, surfaced so calibration needs no
|
| 5 |
* code edit.
|
| 6 |
*/
|
| 7 |
-
import { el } from "./dom.js";
|
| 8 |
import { SPEED_PRESETS, makeTiming } from "../lib/timing.js";
|
| 9 |
import { APP_VERSION } from "../lib/version.js";
|
| 10 |
|
|
@@ -62,5 +62,46 @@ export function createSettings(ctx) {
|
|
| 62 |
if (ctx.reachy) ctx.tapper().testClap();
|
| 63 |
}
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
return { node: body };
|
| 66 |
}
|
|
|
|
| 4 |
* the values we tune against real hardware, surfaced so calibration needs no
|
| 5 |
* code edit.
|
| 6 |
*/
|
| 7 |
+
import { el, clear } from "./dom.js";
|
| 8 |
import { SPEED_PRESETS, makeTiming } from "../lib/timing.js";
|
| 9 |
import { APP_VERSION } from "../lib/version.js";
|
| 10 |
|
|
|
|
| 62 |
if (ctx.reachy) ctx.tapper().testClap();
|
| 63 |
}
|
| 64 |
|
| 65 |
+
// ── Demo mode: hidden conversation rules + kickoff sends ───────────
|
| 66 |
+
if (c.mode === "demo") {
|
| 67 |
+
c.demo = c.demo || { rules: [] };
|
| 68 |
+
const list = el("div.rule-list");
|
| 69 |
+
|
| 70 |
+
const ruleRow = (rule) => {
|
| 71 |
+
const inp = el("input.rule-input", {
|
| 72 |
+
type: "text", value: rule.input, placeholder: "hears…",
|
| 73 |
+
oninput: (e) => { rule.input = e.target.value; },
|
| 74 |
+
});
|
| 75 |
+
const out = el("input.rule-input", {
|
| 76 |
+
type: "text", value: rule.output, placeholder: "replies…",
|
| 77 |
+
oninput: (e) => { rule.output = e.target.value; },
|
| 78 |
+
});
|
| 79 |
+
const send = el("button.btn.ghost.small", {
|
| 80 |
+
title: "Send this phrase now",
|
| 81 |
+
onclick: () => {
|
| 82 |
+
ctx._closeSettings?.();
|
| 83 |
+
setTimeout(() => ctx.transmit(rule.input, { via: ctx.reachy ? "robot" : "device" }), 150);
|
| 84 |
+
},
|
| 85 |
+
}, "Send ▶");
|
| 86 |
+
const del = el("button.icon-btn.tiny", {
|
| 87 |
+
title: "Remove", onclick: () => { c.demo.rules.splice(c.demo.rules.indexOf(rule), 1); rebuild(); },
|
| 88 |
+
}, "✕");
|
| 89 |
+
return el("div.rule-row", {}, [inp, el("span.rule-arrow", {}, "→"), out, send, del]);
|
| 90 |
+
};
|
| 91 |
+
const rebuild = () => { clear(list); c.demo.rules.forEach((r) => list.append(ruleRow(r))); };
|
| 92 |
+
rebuild();
|
| 93 |
+
|
| 94 |
+
const addBtn = el("button.btn.ghost.small", {
|
| 95 |
+
onclick: () => { c.demo.rules.push({ input: "", output: "" }); rebuild(); },
|
| 96 |
+
}, "+ Add rule");
|
| 97 |
+
|
| 98 |
+
body.prepend(el("div.demo-setup", {}, [
|
| 99 |
+
el("h3", {}, "Conversation rules"),
|
| 100 |
+
el("p.muted.tiny", {}, "When this robot hears the left phrase, it taps the right one back. “Send ▶” kicks one off now (the sheet closes so the camera sees only the demo)."),
|
| 101 |
+
list,
|
| 102 |
+
addBtn,
|
| 103 |
+
]));
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
return { node: body };
|
| 107 |
}
|