RemiFabre commited on
Commit
a33a583
·
1 Parent(s): 4b5a0c7

feat: Demo mode for filming two robots conversing

Browse files

App/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.

Files changed (8) hide show
  1. lib/rules.js +31 -0
  2. lib/version.js +1 -1
  3. main.js +81 -11
  4. style.css +39 -0
  5. tests/unit/rules.test.js +28 -0
  6. tests/unit/views.test.js +35 -0
  7. views/demo.js +135 -0
  8. 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-01 r5 compact layout";
 
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
- const scroller = el("main.scroller", {}, [
 
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, scroller);
119
  app.hidden = false;
120
  views.compose.onShow?.();
121
- views.listen.onShow?.();
122
  views.learn.onShow?.();
123
- // The Listen panel is always present and listening — auto-start the mic so
124
- // you can send from the robot and watch it decode live. Needs a user
125
- // gesture in some browsers; on failure the panel shows a tap-to-start button.
126
- views.listen.autoStart?.();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  }