morse-code / main.js
RemiFabre
feat: Reachy Mini logo as app icon (PNG via Git LFS)
9355f7d
Raw
History Blame Contribute Delete
10.3 kB
/**
* 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);
}