Spaces:
Running
Running
| /** | |
| * 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 = | |
| `<pre style="padding:24px;color:#c00;font:14px ui-monospace">` + | |
| `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.</pre>`; | |
| }); | |
| } | |
| 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); | |
| } | |