/** * Reachy Mini — Morse Code. App entry. * * index.html → #root (host shell mount) + #app (in-iframe surface) * main.js → dispatcher: standalone mountHost() | embed connectToHost() * * Standalone visit: mountHost() owns OAuth + robot picker + top bar, then * iframes back here with ?embedded=1. Embed visit: connectToHost() brings up * the WebRTC session and hands us a live `handle.reachy`. We build the UI on * it and register onLeave for safe teardown. (Mirrors marionette-js.) * * The robot is optional to the app's value: Compose→"This device", Listen, * and Learn all work with just a phone/laptop. The robot unlocks antenna * transmission and lets two Minis talk to each other. */ import { mountHost } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.dbe26af/host/dist/entry/auto.js"; import { connectToHost } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.dbe26af/host/dist/entry/embed.js"; import { safelyReturnToPose } from "./lib/animation-helpers.js"; import { makeTiming, SPEED_PRESETS } from "./lib/timing.js"; import { textToSchedule } from "./lib/wire.js"; import { DEFAULT_RULES } from "./lib/rules.js"; import { Synth } from "./lib/synth.js"; import { RobotTapper, DEFAULT_TAP_PROFILE } from "./lib/robot-tapper.js"; import { Mic } from "./lib/mic.js"; import { el, clear } from "./views/dom.js"; import { createCompose } from "./views/compose.js"; import { createListen } from "./views/listen.js"; import { createLearn } from "./views/learn.js"; import { createDemo } from "./views/demo.js"; import { createSettings } from "./views/settings.js"; const params = new URLSearchParams(window.location.search); const isEmbed = params.get("embedded") === "1" || params.get("embed") === "1"; if (!isEmbed) { mountHost({ appName: "Morse Code", appIconUrl: "public/icon.png", appEmoji: "📡", // We capture the LAPTOP/PHONE mic via getUserMedia for decoding; we do // not need the robot's inbound audio track, so no enableMicrophone. }); } else { bootEmbed().catch((err) => { console.error("[morse] bootEmbed failed:", err); document.body.innerHTML = `
` +
            `Morse Code failed to start in the iframe:\n\n${err?.message || err}\n\n` +
            `Try reloading the Space; check the daemon version and the SDK pin in main.js.
`; }); } async function bootEmbed() { const handle = await connectToHost(); const reachy = handle.reachy; document.documentElement.setAttribute("data-theme", handle.theme || "dark"); handle.onThemeChange?.((t) => document.documentElement.setAttribute("data-theme", t)); // ─── Shared, mutable config ─────────────────────────────────────── const config = { mode: "app", // "app" | "demo" speed: "normal", unitMs: SPEED_PRESETS.normal, emitter: reachy ? "robot" : "device", beepFreq: 2200, detector: { highpassHz: 2000, thresholdFactor: 4.0 }, tap: { ...DEFAULT_TAP_PROFILE }, demo: { rules: DEFAULT_RULES.map((r) => ({ ...r })) }, }; const synth = new Synth({ freq: config.beepFreq }); let tapper = reachy ? new RobotTapper(reachy, config.tap) : null; // Self-echo guard: while (and just after) THIS robot transmits, its own mic // hears the taps — Demo mode must not treat that as an incoming message. let selfEchoUntil = 0; let activeCancel = null; const ctx = { reachy, state: config, timing: () => makeTiming(config.unitMs), setSpeed(preset) { config.speed = preset; if (SPEED_PRESETS[preset]) config.unitMs = SPEED_PRESETS[preset]; }, synth: () => { synth.freq = config.beepFreq; return synth; }, tapper: () => { if (!tapper && reachy) tapper = new RobotTapper(reachy, config.tap); if (tapper) tapper.p = { ...DEFAULT_TAP_PROFILE, ...config.tap }; return tapper; }, makeMic: (cbs) => new Mic({ highpassHz: config.detector.highpassHz, detector: { thresholdFactor: config.detector.thresholdFactor }, ...cbs, }), selfEchoActive: () => performance.now() < selfEchoUntil, isTransmitting: () => !!activeCancel, // Transmit `text` on the robot antennas (default) or device speaker. // Sets a self-echo window covering the whole send plus the idle-finalize // tail, so Demo mode ignores this robot's own taps. transmit: async (text, { onProgress, via } = {}) => { const t = makeTiming(config.unitMs); const { onsets, durationMs } = textToSchedule(text, t); if (!onsets.length) return; const useRobot = (via ?? config.emitter) === "robot" && !!reachy; selfEchoUntil = performance.now() + durationMs + t.wordGapMs * 2.5 + 3000; try { if (useRobot) { const controller = new AbortController(); activeCancel = () => controller.abort(); ctxRef.tapper().p = { ...DEFAULT_TAP_PROFILE, ...config.tap }; await ctxRef.tapper().tap(onsets, { onTap: onProgress, signal: controller.signal }); } else { await new Promise((resolve) => { const cancel = synth.play(onsets, { onBlip: onProgress, onDone: resolve, clickMs: t.clickMs, }); activeCancel = () => { cancel(); resolve(); }; }); } } finally { activeCancel = null; // Extend past the finalize that fires after our last tap. selfEchoUntil = Math.max(selfEchoUntil, performance.now() + t.wordGapMs * 2.5 + 1500); } }, toast, }; const ctxRef = ctx; // ─── Views (single scrolling page: Compose, then always-on Listen, // then the Learn chart — scroll down to look anything up while a // live message keeps decoding) ──────────────────────────────────── const views = { compose: createCompose(ctx), listen: createListen(ctx), learn: createLearn(ctx), demo: createDemo(ctx), }; const app = document.getElementById("app"); clear(app); // Header: brand + App/Demo mode toggle + settings. const modeBtns = {}; const modeSeg = el("div.mode-seg"); [["app", "App"], ["demo", "Demo"]].forEach(([v, label]) => { const b = el("button.mode-btn", { onclick: () => setMode(v) }, label); modeBtns[v] = b; modeSeg.append(b); }); const header = el("header.appbar", {}, [ el("div.brand", {}, [ el("img.brand-logo-img", { src: "public/icon.png", alt: "" }), el("span.brand-name", {}, "Morse Code"), ]), modeSeg, el("button.icon-btn", { title: "Settings", onclick: openSettings }, "⚙️"), ]); // Two pages share one host; only the active page's view holds the mic. const appScroller = el("main.scroller", {}, [ views.compose.node, el("hr.divider"), views.listen.node, el("hr.divider"), views.learn.node, ]); const demoScroller = el("main.scroller", {}, [views.demo.node]); const pageHost = el("div.page-host"); app.append(header, pageHost); app.hidden = false; views.compose.onShow?.(); views.learn.onShow?.(); function setMode(mode) { config.mode = mode; Object.entries(modeBtns).forEach(([k, b]) => b.classList.toggle("active", k === mode)); views.listen.onHide?.(); views.demo.onHide?.(); clear(pageHost); if (mode === "demo") { pageHost.append(demoScroller); views.demo.onShow?.(); } else { pageHost.append(appScroller); // Always-on Listen: auto-start the mic (falls back to tap-to-start). views.listen.autoStart?.(); } } ctx.setMode = setMode; setMode("app"); // ─── Settings modal ───────────────────────────────────────────── function openSettings() { const overlay = el("div.overlay", { onclick: (e) => { if (e.target === overlay) close(); } }); const close = () => { overlay.remove(); ctx._closeSettings = null; }; ctx._closeSettings = close; const sheet = createSettings(ctx); const panel = el("div.sheet", {}, [ el("div.sheet-head", {}, [el("h2", {}, config.mode === "demo" ? "Demo setup" : "Settings"), el("button.icon-btn", { onclick: close }, "✕")]), sheet.node, ]); overlay.append(panel); document.body.append(overlay); } // ─── Safe teardown ──────────────────────────────────────────────── handle.onLeave?.(() => { try { tapper?.stop(); } catch { /* */ } try { synth.stop(); } catch { /* */ } try { views.listen.onHide?.(); } catch { /* */ } try { views.demo.onHide?.(); } catch { /* */ } if (reachy) return safelyReturnToPose(reachy); }); window.addEventListener("error", (e) => console.error("[morse] uncaught", e.message)); } // ─── Toast ──────────────────────────────────────────────────────────── let toastTimer = null; function toast(msg) { let t = document.getElementById("toast"); if (!t) { t = el("div#toast.toast"); document.body.append(t); } t.textContent = msg; t.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), 2600); }