Spaces:
Running
Running
RemiFabre commited on
Commit ·
843a4b2
0
Parent(s):
feat: initial Reachy Mini Morse Code app
Browse filesTalk to Reachy Mini in Morse: the robot transmits by clapping its antennas
(each hit = an audible click), the phone/laptop transmits with beeps, and any
device (or a second Mini) decodes incoming hits live through its mic.
- Onset-only impulse wire code (dot=1 tap, dash=2 taps) so robot clicks,
phone beeps and robot<->robot all share one detector.
- Transient-onset detector ported from the Marionette audio-sync tests
(highpass -> 5ms energy -> spectral flux -> peak pick), offline + streaming.
- Compose / Listen / Learn UI, mobile-first, host-shell + CDN SDK (sdk: static).
- 42 unit tests (codec, detector round-trip, view DOM) + browser smoke tests.
- .gitattributes +2 -0
- .gitignore +5 -0
- README.md +82 -0
- index.html +62 -0
- lib/animation-helpers.js +15 -0
- lib/detector.js +179 -0
- lib/mic.js +106 -0
- lib/morse.js +113 -0
- lib/robot-tapper.js +119 -0
- lib/synth.js +86 -0
- lib/timing.js +59 -0
- lib/version.js +3 -0
- lib/viz.js +89 -0
- lib/wire.js +114 -0
- main.js +172 -0
- package-lock.json +2018 -0
- package.json +17 -0
- plan.md +111 -0
- playwright.config.js +19 -0
- public/icon.svg +17 -0
- style.css +290 -0
- tests/e2e/smoke.spec.js +34 -0
- tests/stubs/animation-helpers.js +10 -0
- tests/unit/detector.test.js +98 -0
- tests/unit/morse.test.js +71 -0
- tests/unit/views.test.js +96 -0
- tests/unit/wire.test.js +73 -0
- views/compose.js +164 -0
- views/dom.js +44 -0
- views/learn.js +66 -0
- views/listen.js +116 -0
- views/settings.js +66 -0
- vitest.config.js +20 -0
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.wav filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.DS_Store
|
| 2 |
+
node_modules/
|
| 3 |
+
test-results/
|
| 4 |
+
playwright-report/
|
| 5 |
+
.env
|
README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Morse Code
|
| 3 |
+
emoji: 📡
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: static
|
| 7 |
+
pinned: false
|
| 8 |
+
hf_oauth: true
|
| 9 |
+
hf_oauth_expiration_minutes: 480
|
| 10 |
+
hf_oauth_scopes:
|
| 11 |
+
- read-repos
|
| 12 |
+
- write-repos
|
| 13 |
+
- manage-repos
|
| 14 |
+
short_description: Talk to Reachy Mini in Morse — antenna taps, beeps, mic decode.
|
| 15 |
+
tags:
|
| 16 |
+
- reachy_mini
|
| 17 |
+
- reachy_mini_js_app
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
# Morse Code
|
| 21 |
+
|
| 22 |
+
Make your Reachy Mini speak and understand **Morse code**. The robot transmits
|
| 23 |
+
by *clapping its antennas together* — each hit is an audible click. Your phone
|
| 24 |
+
or laptop can transmit too, with beeps. Either device (or a **second Reachy
|
| 25 |
+
Mini** sitting across the table) listens through its microphone, detects the
|
| 26 |
+
hits, and decodes them back to text in real time.
|
| 27 |
+
|
| 28 |
+
Open the Space URL, sign in with Hugging Face, pick your robot. No install.
|
| 29 |
+
|
| 30 |
+
## Three tabs
|
| 31 |
+
|
| 32 |
+
- **Compose** — type a message, see its Morse, and pick who sends it: the
|
| 33 |
+
robot's antennas or this device's speaker. Watch it transmit.
|
| 34 |
+
- **Listen** — turn on the mic and decode incoming clicks/beeps live, with a
|
| 35 |
+
scope that drops a marker on every detected hit.
|
| 36 |
+
- **Learn** — the letter ↔ rhythm chart. Tap any tile to *feel* the rhythm
|
| 37 |
+
(a letter is just a pattern of short and long beats).
|
| 38 |
+
|
| 39 |
+
## How it works (the impulse wire code)
|
| 40 |
+
|
| 41 |
+
An antenna hit is **impulsive** — one short click. You can't hold a click long
|
| 42 |
+
the way you hold a tone, so classic "short tone = dot, long tone = dash" can't
|
| 43 |
+
work on the robot. Instead every channel here uses one **onset-only** code so a
|
| 44 |
+
tapping robot, a beeping phone, and a second robot are all mutually
|
| 45 |
+
intelligible through a single detector:
|
| 46 |
+
|
| 47 |
+
- **dot** = one impulse
|
| 48 |
+
- **dash** = a quick double-tap (two impulses)
|
| 49 |
+
- letters and words are separated by progressively longer gaps
|
| 50 |
+
|
| 51 |
+
It's still real Morse — the Learn chart shows the standard `·–` patterns — just
|
| 52 |
+
carried as a rhythm of hits instead of tone length.
|
| 53 |
+
|
| 54 |
+
Detection is a transient-onset detector (high-pass → short-term energy →
|
| 55 |
+
spectral flux → peak pick) ported from the Marionette audio-sync tests and
|
| 56 |
+
validated end-to-end against synthetic audio (`npm test`). All timing and
|
| 57 |
+
detection thresholds live in Settings, so the robot clap and mic sensitivity
|
| 58 |
+
can be calibrated on real hardware without code changes.
|
| 59 |
+
|
| 60 |
+
## Develop
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
npm install
|
| 64 |
+
npm test # unit tests: morse codec, wire code, detector round-trip
|
| 65 |
+
npm run test:e2e # playwright (optional)
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
No build step — `sdk: static`, the host shell + SDK load from jsDelivr. Local
|
| 69 |
+
dev: serve the folder (e.g. `python3 -m http.server`) and open with a
|
| 70 |
+
`?embedded=1`-capable dev token, or just push to the Space.
|
| 71 |
+
|
| 72 |
+
### SDK pin
|
| 73 |
+
|
| 74 |
+
`@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.dbe26af`, pinned in three
|
| 75 |
+
places that must stay in sync: the two `modulepreload` links in `index.html`,
|
| 76 |
+
the two import URLs in `main.js`, and the re-export in
|
| 77 |
+
`lib/animation-helpers.js`.
|
| 78 |
+
|
| 79 |
+
## Credits
|
| 80 |
+
|
| 81 |
+
Built for Reachy Mini by Pollen Robotics. Antenna-collision recipe and the
|
| 82 |
+
transient detector adapted from the Marionette project.
|
index.html
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
| 6 |
+
<title>Morse Code · Reachy Mini</title>
|
| 7 |
+
<meta name="description" content="Talk to Reachy Mini in Morse code — antenna taps and beeps, decoded live through the mic." />
|
| 8 |
+
<meta name="theme-color" content="#0d1117" />
|
| 9 |
+
<link rel="icon" type="image/svg+xml" href="public/icon.svg" />
|
| 10 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 11 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 12 |
+
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
| 13 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
|
| 14 |
+
<link rel="stylesheet" href="style.css" />
|
| 15 |
+
|
| 16 |
+
<!-- Modern host-shell SDK entries (standalone → host/auto = mountHost;
|
| 17 |
+
embedded iframe → host/embed = connectToHost). Pinned to the exact
|
| 18 |
+
tarball the Pollen reference apps + marionette-js ship against so
|
| 19 |
+
SDK / host / daemon stay in lockstep. Bumping it means editing these
|
| 20 |
+
two links AND the two import URLs in main.js AND lib/animation-helpers.js. -->
|
| 21 |
+
<link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.dbe26af/host/dist/entry/auto.js" crossorigin />
|
| 22 |
+
<link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.dbe26af/host/dist/entry/embed.js" crossorigin />
|
| 23 |
+
|
| 24 |
+
<!-- HF Spaces helper variables. In production (sdk: static + hf_oauth: true)
|
| 25 |
+
HF rewrites these placeholders at serve time and the SDK reads them off
|
| 26 |
+
window.huggingface.variables. Locally the placeholders stay raw (leading
|
| 27 |
+
__) so we drop the global and mountHost() falls back to the dev path. -->
|
| 28 |
+
<script>
|
| 29 |
+
(function () {
|
| 30 |
+
var clientId = "__OAUTH_CLIENT_ID__";
|
| 31 |
+
var scopes = "__OAUTH_SCOPES__";
|
| 32 |
+
var spaceHost = "__SPACE_HOST__";
|
| 33 |
+
var spaceId = "__SPACE_ID__";
|
| 34 |
+
var looksSubstituted = clientId && clientId.indexOf("__") !== 0;
|
| 35 |
+
if (looksSubstituted) {
|
| 36 |
+
window.huggingface = window.huggingface || {};
|
| 37 |
+
window.huggingface.variables = {
|
| 38 |
+
OAUTH_CLIENT_ID: clientId,
|
| 39 |
+
OAUTH_SCOPES: scopes && scopes.indexOf("__") !== 0 ? scopes : "openid profile",
|
| 40 |
+
SPACE_HOST: spaceHost && spaceHost.indexOf("__") !== 0 ? spaceHost : "",
|
| 41 |
+
SPACE_ID: spaceId && spaceId.indexOf("__") !== 0 ? spaceId : "",
|
| 42 |
+
};
|
| 43 |
+
}
|
| 44 |
+
})();
|
| 45 |
+
</script>
|
| 46 |
+
|
| 47 |
+
<!-- `process` shim: the CDN-loaded host bundle expects a bundler to have
|
| 48 |
+
substituted process.env.NODE_ENV; loaded raw it would throw. -->
|
| 49 |
+
<script>
|
| 50 |
+
globalThis.process ??= { env: {} };
|
| 51 |
+
globalThis.process.env ??= {};
|
| 52 |
+
globalThis.process.env.NODE_ENV ??= 'production';
|
| 53 |
+
</script>
|
| 54 |
+
</head>
|
| 55 |
+
<body>
|
| 56 |
+
<!-- Host shell mount point (standalone): sign-in, robot picker, top bar. -->
|
| 57 |
+
<div id="root"></div>
|
| 58 |
+
<!-- In-iframe app surface, revealed once connectToHost() resolves. -->
|
| 59 |
+
<div id="app" hidden></div>
|
| 60 |
+
<script type="module" src="main.js"></script>
|
| 61 |
+
</body>
|
| 62 |
+
</html>
|
lib/animation-helpers.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Re-export of the SDK's /animation subpath, indirected through this short
|
| 2 |
+
// stable path so the rest of the app imports motion helpers from one place
|
| 3 |
+
// AND vitest can alias this single module to a stub (Node's ESM loader can't
|
| 4 |
+
// resolve `https:` imports). If you bump the SDK pin here, also bump the two
|
| 5 |
+
// import URLs in main.js and the two modulepreload links in index.html — they
|
| 6 |
+
// must all match (silent protocol drift otherwise). See README "SDK pin".
|
| 7 |
+
export {
|
| 8 |
+
INIT_POSE,
|
| 9 |
+
INIT_ANTENNAS_RAD,
|
| 10 |
+
DEFAULT_SCALED_DURATION_PRESET,
|
| 11 |
+
distanceBetweenPoses,
|
| 12 |
+
scaledDuration,
|
| 13 |
+
safelyReturnToPose,
|
| 14 |
+
installShutdownHandler,
|
| 15 |
+
} from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.dbe26af/dist/animation/index.js";
|
lib/detector.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Transient-onset detector — clicks (antenna hits) and short beeps.
|
| 3 |
+
*
|
| 4 |
+
* Ported from marionette/tests/audio_analysis.py `detect_transient_onsets`:
|
| 5 |
+
* highpass -> short-term energy (5 ms windows) -> spectral flux
|
| 6 |
+
* (positive energy differences) -> peak pick with min separation.
|
| 7 |
+
*
|
| 8 |
+
* Two entry points sharing the same DSP core:
|
| 9 |
+
* - detectTransientOnsets(samples, sr, opts): pure, offline. Used by unit
|
| 10 |
+
* tests (synthetic audio) and the visualizer (on recorded buffers).
|
| 11 |
+
* - StreamingOnsetDetector: live mic. The highpass is done by a Web Audio
|
| 12 |
+
* BiquadFilterNode upstream; this class consumes filtered blocks and
|
| 13 |
+
* emits onsets in real time with an adaptive (noise-floor) threshold.
|
| 14 |
+
*
|
| 15 |
+
* Onset times are in MILLISECONDS to match wire.js / timing.js.
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
// ──────── Pure DSP ────────────────────────────────────────────────
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Single 2nd-order Butterworth high-pass (RBJ cookbook). Cascade twice for
|
| 22 |
+
* a ~4th-order roll-off (matches the Python `butter(4, ...)` intent).
|
| 23 |
+
*/
|
| 24 |
+
export function biquadHighpassOnce(samples, sr, fc, q = Math.SQRT1_2) {
|
| 25 |
+
const w0 = (2 * Math.PI * fc) / sr;
|
| 26 |
+
const cos = Math.cos(w0);
|
| 27 |
+
const sin = Math.sin(w0);
|
| 28 |
+
const alpha = sin / (2 * q);
|
| 29 |
+
const b0 = (1 + cos) / 2;
|
| 30 |
+
const b1 = -(1 + cos);
|
| 31 |
+
const b2 = (1 + cos) / 2;
|
| 32 |
+
const a0 = 1 + alpha;
|
| 33 |
+
const a1 = -2 * cos;
|
| 34 |
+
const a2 = 1 - alpha;
|
| 35 |
+
const nb0 = b0 / a0, nb1 = b1 / a0, nb2 = b2 / a0, na1 = a1 / a0, na2 = a2 / a0;
|
| 36 |
+
const out = new Float64Array(samples.length);
|
| 37 |
+
let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
|
| 38 |
+
for (let i = 0; i < samples.length; i++) {
|
| 39 |
+
const x0 = samples[i];
|
| 40 |
+
const y0 = nb0 * x0 + nb1 * x1 + nb2 * x2 - na1 * y1 - na2 * y2;
|
| 41 |
+
x2 = x1; x1 = x0; y2 = y1; y1 = y0;
|
| 42 |
+
out[i] = y0;
|
| 43 |
+
}
|
| 44 |
+
return out;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export function highpass(samples, sr, fc) {
|
| 48 |
+
return biquadHighpassOnce(biquadHighpassOnce(samples, sr, fc), sr, fc);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* Short-term energy per non-overlapping window, then positive flux.
|
| 53 |
+
* @returns {{flux:Float64Array, winSamples:number}}
|
| 54 |
+
*/
|
| 55 |
+
export function energyFlux(filtered, sr, winMs = 5) {
|
| 56 |
+
const winSamples = Math.max(1, Math.round((winMs / 1000) * sr));
|
| 57 |
+
const nWin = Math.floor(filtered.length / winSamples);
|
| 58 |
+
const energy = new Float64Array(nWin);
|
| 59 |
+
for (let w = 0; w < nWin; w++) {
|
| 60 |
+
let e = 0;
|
| 61 |
+
const base = w * winSamples;
|
| 62 |
+
for (let j = 0; j < winSamples; j++) {
|
| 63 |
+
const v = filtered[base + j];
|
| 64 |
+
e += v * v;
|
| 65 |
+
}
|
| 66 |
+
energy[w] = e;
|
| 67 |
+
}
|
| 68 |
+
const flux = new Float64Array(Math.max(0, nWin - 1));
|
| 69 |
+
for (let i = 1; i < nWin; i++) flux[i - 1] = Math.max(0, energy[i] - energy[i - 1]);
|
| 70 |
+
return { flux, winSamples };
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/**
|
| 74 |
+
* Offline transient detection on a full buffer.
|
| 75 |
+
* @returns {number[]} onset times in ms.
|
| 76 |
+
*/
|
| 77 |
+
export function detectTransientOnsets(samples, sr, opts = {}) {
|
| 78 |
+
const { highpassHz = 2000, winMs = 5, thresholdRatio = 0.1, minSepMs = 150 } = opts;
|
| 79 |
+
if (!samples || samples.length < sr * (winMs / 1000) * 2) return [];
|
| 80 |
+
const filtered = highpass(samples, sr, highpassHz);
|
| 81 |
+
const { flux, winSamples } = energyFlux(filtered, sr, winMs);
|
| 82 |
+
if (flux.length < 2) return [];
|
| 83 |
+
|
| 84 |
+
let peak = 0;
|
| 85 |
+
for (let i = 0; i < flux.length; i++) if (flux[i] > peak) peak = flux[i];
|
| 86 |
+
if (peak < 1e-12) return [];
|
| 87 |
+
const threshold = peak * thresholdRatio;
|
| 88 |
+
const minDistWin = Math.max(1, Math.round((minSepMs / 1000) * sr / winSamples));
|
| 89 |
+
|
| 90 |
+
// local-maxima peak pick with min distance (mirrors scipy find_peaks)
|
| 91 |
+
const onsets = [];
|
| 92 |
+
let lastPeakWin = -Infinity;
|
| 93 |
+
for (let i = 1; i < flux.length - 1; i++) {
|
| 94 |
+
if (flux[i] < threshold) continue;
|
| 95 |
+
if (flux[i] < flux[i - 1] || flux[i] < flux[i + 1]) continue; // local max
|
| 96 |
+
if (i - lastPeakWin < minDistWin) {
|
| 97 |
+
// keep the stronger of the two within the refractory window
|
| 98 |
+
if (onsets.length && flux[i] > flux[lastPeakWin]) {
|
| 99 |
+
onsets[onsets.length - 1] = ((i * winSamples) / sr) * 1000;
|
| 100 |
+
lastPeakWin = i;
|
| 101 |
+
}
|
| 102 |
+
continue;
|
| 103 |
+
}
|
| 104 |
+
onsets.push(((i * winSamples) / sr) * 1000);
|
| 105 |
+
lastPeakWin = i;
|
| 106 |
+
}
|
| 107 |
+
return onsets;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
// ──────── Streaming (live mic) ─────────────────────────────────────
|
| 111 |
+
|
| 112 |
+
/**
|
| 113 |
+
* Adaptive, real-time onset detector fed by filtered audio blocks.
|
| 114 |
+
*
|
| 115 |
+
* Threshold adapts to a slowly-tracked noise floor so it works without a
|
| 116 |
+
* global maximum (impossible to know live). An onset fires when the flux
|
| 117 |
+
* spikes well above the floor and the refractory window has elapsed.
|
| 118 |
+
*/
|
| 119 |
+
export class StreamingOnsetDetector {
|
| 120 |
+
/**
|
| 121 |
+
* @param {object} o
|
| 122 |
+
* @param {number} o.sampleRate
|
| 123 |
+
* @param {(timeMs:number, strength:number)=>void} o.onOnset
|
| 124 |
+
* @param {number} [o.winMs] energy window (ms)
|
| 125 |
+
* @param {number} [o.refractoryMs] min gap between onsets
|
| 126 |
+
* @param {number} [o.thresholdFactor] flux must exceed floor*factor
|
| 127 |
+
* @param {number} [o.absMin] absolute floor on flux energy
|
| 128 |
+
*/
|
| 129 |
+
constructor(o) {
|
| 130 |
+
this.sr = o.sampleRate;
|
| 131 |
+
this.onOnset = o.onOnset;
|
| 132 |
+
this.winMs = o.winMs ?? 5;
|
| 133 |
+
this.refractoryMs = o.refractoryMs ?? 120;
|
| 134 |
+
this.thresholdFactor = o.thresholdFactor ?? 4.0;
|
| 135 |
+
this.absMin = o.absMin ?? 1e-5;
|
| 136 |
+
this.winSamples = Math.max(1, Math.round((this.winMs / 1000) * this.sr));
|
| 137 |
+
this._acc = 0; // energy accumulator for current window
|
| 138 |
+
this._accN = 0;
|
| 139 |
+
this._winIndex = 0; // running window counter since start
|
| 140 |
+
this._prevEnergy = null;
|
| 141 |
+
this._floor = this.absMin; // EMA noise floor of flux
|
| 142 |
+
this._lastOnsetMs = -Infinity;
|
| 143 |
+
// EMA coefficient (~300 ms time constant on the flux floor)
|
| 144 |
+
const tau = 0.3;
|
| 145 |
+
this._alpha = Math.exp(-(this.winMs / 1000) / tau);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
/** Feed one block of already-highpassed samples. baseTimeMs = block start. */
|
| 149 |
+
process(block, baseTimeMs) {
|
| 150 |
+
for (let i = 0; i < block.length; i++) {
|
| 151 |
+
const v = block[i];
|
| 152 |
+
this._acc += v * v;
|
| 153 |
+
this._accN += 1;
|
| 154 |
+
if (this._accN >= this.winSamples) {
|
| 155 |
+
const energy = this._acc;
|
| 156 |
+
const winTimeMs = baseTimeMs + ((i + 1) / this.sr) * 1000;
|
| 157 |
+
this._onWindow(energy, winTimeMs);
|
| 158 |
+
this._acc = 0;
|
| 159 |
+
this._accN = 0;
|
| 160 |
+
this._winIndex += 1;
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
_onWindow(energy, timeMs) {
|
| 166 |
+
if (this._prevEnergy !== null) {
|
| 167 |
+
const flux = Math.max(0, energy - this._prevEnergy);
|
| 168 |
+
const trigger = Math.max(this.absMin, this._floor * this.thresholdFactor);
|
| 169 |
+
if (flux > trigger && timeMs - this._lastOnsetMs >= this.refractoryMs) {
|
| 170 |
+
this._lastOnsetMs = timeMs;
|
| 171 |
+
this.onOnset?.(timeMs, flux);
|
| 172 |
+
} else {
|
| 173 |
+
// only let the floor track quiet/background frames
|
| 174 |
+
this._floor = this._alpha * this._floor + (1 - this._alpha) * flux;
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
this._prevEnergy = energy;
|
| 178 |
+
}
|
| 179 |
+
}
|
lib/mic.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Live microphone capture → onset stream.
|
| 3 |
+
*
|
| 4 |
+
* Web Audio graph:
|
| 5 |
+
* getUserMedia → MediaStreamSource → BiquadFilter(highpass) →
|
| 6 |
+
* ScriptProcessor → (muted gain → destination, just to keep it pumping)
|
| 7 |
+
*
|
| 8 |
+
* The ScriptProcessor receives already-high-passed blocks and feeds them to
|
| 9 |
+
* the StreamingOnsetDetector (lib/detector.js). Each detected onset is
|
| 10 |
+
* reported with a session-relative timestamp (ms) suitable for wire.js
|
| 11 |
+
* decoding. A level callback drives the visualizer.
|
| 12 |
+
*
|
| 13 |
+
* Browser audio "enhancements" (echo cancellation, noise suppression, auto
|
| 14 |
+
* gain) are explicitly DISABLED — they treat sharp clicks as noise and would
|
| 15 |
+
* gut exactly the signal we need.
|
| 16 |
+
*/
|
| 17 |
+
import { StreamingOnsetDetector } from "./detector.js";
|
| 18 |
+
|
| 19 |
+
export class Mic {
|
| 20 |
+
/**
|
| 21 |
+
* @param {object} o
|
| 22 |
+
* @param {(timeMs:number, strength:number)=>void} o.onOnset
|
| 23 |
+
* @param {(level:number, wave:Float32Array)=>void} [o.onLevel]
|
| 24 |
+
* @param {number} [o.highpassHz]
|
| 25 |
+
* @param {object} [o.detector] extra StreamingOnsetDetector options
|
| 26 |
+
*/
|
| 27 |
+
constructor(o) {
|
| 28 |
+
this.opts = o;
|
| 29 |
+
this.highpassHz = o.highpassHz ?? 2000;
|
| 30 |
+
this.ctx = null;
|
| 31 |
+
this.stream = null;
|
| 32 |
+
this._nodes = [];
|
| 33 |
+
this._detector = null;
|
| 34 |
+
this._elapsedMs = 0;
|
| 35 |
+
this.running = false;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
async start() {
|
| 39 |
+
const AC = window.AudioContext || window.webkitAudioContext;
|
| 40 |
+
this.stream = await navigator.mediaDevices.getUserMedia({
|
| 41 |
+
audio: {
|
| 42 |
+
echoCancellation: false,
|
| 43 |
+
noiseSuppression: false,
|
| 44 |
+
autoGainControl: false,
|
| 45 |
+
channelCount: 1,
|
| 46 |
+
},
|
| 47 |
+
});
|
| 48 |
+
this.ctx = new AC();
|
| 49 |
+
if (this.ctx.state === "suspended") await this.ctx.resume();
|
| 50 |
+
const sr = this.ctx.sampleRate;
|
| 51 |
+
|
| 52 |
+
const src = this.ctx.createMediaStreamSource(this.stream);
|
| 53 |
+
const hp = this.ctx.createBiquadFilter();
|
| 54 |
+
hp.type = "highpass";
|
| 55 |
+
hp.frequency.value = this.highpassHz;
|
| 56 |
+
hp.Q.value = Math.SQRT1_2;
|
| 57 |
+
|
| 58 |
+
const bufferSize = 2048;
|
| 59 |
+
const proc = this.ctx.createScriptProcessor(bufferSize, 1, 1);
|
| 60 |
+
const mute = this.ctx.createGain();
|
| 61 |
+
mute.gain.value = 0;
|
| 62 |
+
|
| 63 |
+
this._detector = new StreamingOnsetDetector({
|
| 64 |
+
sampleRate: sr,
|
| 65 |
+
onOnset: (timeMs, strength) => this.opts.onOnset?.(timeMs, strength),
|
| 66 |
+
...(this.opts.detector || {}),
|
| 67 |
+
});
|
| 68 |
+
this._elapsedMs = 0;
|
| 69 |
+
this.running = true;
|
| 70 |
+
|
| 71 |
+
proc.onaudioprocess = (e) => {
|
| 72 |
+
if (!this.running) return;
|
| 73 |
+
const block = e.inputBuffer.getChannelData(0);
|
| 74 |
+
const base = this._elapsedMs;
|
| 75 |
+
this._detector.process(block, base);
|
| 76 |
+
this._elapsedMs += (block.length / sr) * 1000;
|
| 77 |
+
if (this.opts.onLevel) {
|
| 78 |
+
let sum = 0;
|
| 79 |
+
for (let i = 0; i < block.length; i++) sum += block[i] * block[i];
|
| 80 |
+
this.opts.onLevel(Math.sqrt(sum / block.length), block);
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
src.connect(hp);
|
| 85 |
+
hp.connect(proc);
|
| 86 |
+
proc.connect(mute);
|
| 87 |
+
mute.connect(this.ctx.destination);
|
| 88 |
+
this._nodes = [src, hp, proc, mute];
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/** Current session clock (ms since start) — handy to time out a message. */
|
| 92 |
+
now() {
|
| 93 |
+
return this._elapsedMs;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
async stop() {
|
| 97 |
+
this.running = false;
|
| 98 |
+
this._nodes.forEach((n) => { try { n.disconnect(); } catch { /* */ } });
|
| 99 |
+
this._nodes = [];
|
| 100 |
+
if (this.stream) {
|
| 101 |
+
this.stream.getTracks().forEach((t) => t.stop());
|
| 102 |
+
this.stream = null;
|
| 103 |
+
}
|
| 104 |
+
if (this.ctx) { try { await this.ctx.close(); } catch { /* */ } this.ctx = null; }
|
| 105 |
+
}
|
| 106 |
+
}
|
lib/morse.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Morse code tables + text <-> morse-string conversion.
|
| 3 |
+
*
|
| 4 |
+
* A "morse string" here uses the conventional ASCII rendering:
|
| 5 |
+
* - '.' dot
|
| 6 |
+
* - '-' dash
|
| 7 |
+
* - ' ' (single space) gap between letters
|
| 8 |
+
* - '/' gap between words (we accept ' / ' or '/' on input)
|
| 9 |
+
*
|
| 10 |
+
* Example: "SOS" <-> "... --- ..."
|
| 11 |
+
*
|
| 12 |
+
* This module is pure (no DOM, no audio, no SDK) so it is unit-tested
|
| 13 |
+
* directly in Node. The physical *timing* of dots/dashes lives in
|
| 14 |
+
* wire.js + timing.js; this layer is purely symbolic.
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
// International Morse (ITU): letters, digits, and the common punctuation
|
| 18 |
+
// that survives an impulse channel. Prosigns are intentionally omitted.
|
| 19 |
+
export const CHAR_TO_MORSE = Object.freeze({
|
| 20 |
+
A: ".-", B: "-...", C: "-.-.", D: "-..", E: ".", F: "..-.",
|
| 21 |
+
G: "--.", H: "....", I: "..", J: ".---", K: "-.-", L: ".-..",
|
| 22 |
+
M: "--", N: "-.", O: "---", P: ".--.", Q: "--.-", R: ".-.",
|
| 23 |
+
S: "...", T: "-", U: "..-", V: "...-", W: ".--", X: "-..-",
|
| 24 |
+
Y: "-.--", Z: "--..",
|
| 25 |
+
0: "-----", 1: ".----", 2: "..---", 3: "...--", 4: "....-",
|
| 26 |
+
5: ".....", 6: "-....", 7: "--...", 8: "---..", 9: "----.",
|
| 27 |
+
".": ".-.-.-", ",": "--..--", "?": "..--..", "'": ".----.",
|
| 28 |
+
"!": "-.-.--", "/": "-..-.", "(": "-.--.", ")": "-.--.-",
|
| 29 |
+
"&": ".-...", ":": "---...", ";": "-.-.-.", "=": "-...-",
|
| 30 |
+
"+": ".-.-.", "-": "-....-", "_": "..--.-", '"': ".-..-.",
|
| 31 |
+
"$": "...-..-", "@": ".--.-.",
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
// Reverse lookup: morse pattern -> character.
|
| 35 |
+
export const MORSE_TO_CHAR = Object.freeze(
|
| 36 |
+
Object.fromEntries(Object.entries(CHAR_TO_MORSE).map(([c, m]) => [m, c])),
|
| 37 |
+
);
|
| 38 |
+
|
| 39 |
+
/** True if `ch` (single char) can be transmitted. Case-insensitive. */
|
| 40 |
+
export function isEncodable(ch) {
|
| 41 |
+
return Object.prototype.hasOwnProperty.call(CHAR_TO_MORSE, ch.toUpperCase());
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* Encode arbitrary text to a morse string.
|
| 46 |
+
*
|
| 47 |
+
* - Case-insensitive.
|
| 48 |
+
* - Runs of whitespace collapse to a single word break ('/').
|
| 49 |
+
* - Unknown characters are dropped, but reported in `.skipped` so the UI
|
| 50 |
+
* can warn ("é, 😀 can't be sent in Morse").
|
| 51 |
+
*
|
| 52 |
+
* Returns { morse, skipped }.
|
| 53 |
+
*/
|
| 54 |
+
export function textToMorse(text) {
|
| 55 |
+
const words = String(text).trim().toUpperCase().split(/\s+/).filter(Boolean);
|
| 56 |
+
const skipped = [];
|
| 57 |
+
const encodedWords = words.map((word) => {
|
| 58 |
+
const letters = [];
|
| 59 |
+
for (const ch of word) {
|
| 60 |
+
const code = CHAR_TO_MORSE[ch];
|
| 61 |
+
if (code) letters.push(code);
|
| 62 |
+
else skipped.push(ch);
|
| 63 |
+
}
|
| 64 |
+
return letters.join(" ");
|
| 65 |
+
}).filter(Boolean);
|
| 66 |
+
return { morse: encodedWords.join(" / "), skipped };
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* Decode a morse string back to text. Tolerant of input shape:
|
| 71 |
+
* "... --- ...", "...|---|...", "-.-. -.-- / -.-." etc.
|
| 72 |
+
* Letter separators: one or more spaces. Word separators: '/' (optionally
|
| 73 |
+
* space-padded). Unknown patterns become '?'.
|
| 74 |
+
*/
|
| 75 |
+
export function morseToText(morse) {
|
| 76 |
+
return String(morse)
|
| 77 |
+
.trim()
|
| 78 |
+
.split(/\s*\/\s*|\s{2,}\/?\s*/) // word breaks
|
| 79 |
+
.map((word) =>
|
| 80 |
+
word
|
| 81 |
+
.trim()
|
| 82 |
+
.split(/\s+/)
|
| 83 |
+
.filter(Boolean)
|
| 84 |
+
.map((pat) => MORSE_TO_CHAR[pat] ?? (pat ? "?" : ""))
|
| 85 |
+
.join(""),
|
| 86 |
+
)
|
| 87 |
+
.filter((w) => w.length > 0)
|
| 88 |
+
.join(" ");
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/**
|
| 92 |
+
* Flatten a morse string into the element/gap token stream the wire layer
|
| 93 |
+
* consumes. Tokens:
|
| 94 |
+
* 'dot' | 'dah' | 'elemGap' | 'letterGap' | 'wordGap'
|
| 95 |
+
*
|
| 96 |
+
* No leading/trailing gaps are emitted.
|
| 97 |
+
*/
|
| 98 |
+
export function morseToTokens(morse) {
|
| 99 |
+
const tokens = [];
|
| 100 |
+
const words = String(morse).trim().split(/\s*\/\s*/).filter(Boolean);
|
| 101 |
+
words.forEach((word, wi) => {
|
| 102 |
+
if (wi > 0) tokens.push("wordGap");
|
| 103 |
+
const letters = word.trim().split(/\s+/).filter(Boolean);
|
| 104 |
+
letters.forEach((letter, li) => {
|
| 105 |
+
if (li > 0) tokens.push("letterGap");
|
| 106 |
+
[...letter].forEach((sym, si) => {
|
| 107 |
+
if (si > 0) tokens.push("elemGap");
|
| 108 |
+
tokens.push(sym === "." ? "dot" : "dah");
|
| 109 |
+
});
|
| 110 |
+
});
|
| 111 |
+
});
|
| 112 |
+
return tokens;
|
| 113 |
+
}
|
lib/robot-tapper.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Robot transmitter: claps the two antennas together on schedule so each
|
| 3 |
+
* onset produces an audible click. Consumes the same onset schedule from
|
| 4 |
+
* wire.js that the speaker Synth uses, so robot and device speak one wire code.
|
| 5 |
+
*
|
| 6 |
+
* Physics (from marionette/tests/test_antenna_collision.py, proven safe):
|
| 7 |
+
* - Right antenna held at a fixed angle.
|
| 8 |
+
* - Left antenna slams in past the contact point and is commanded to hold
|
| 9 |
+
* briefly — the low-PID antennas stall at contact (no damage) and make a
|
| 10 |
+
* crisp click — then returns to rest.
|
| 11 |
+
*
|
| 12 |
+
* We drive it in real time with `reachy.setAntennasDeg(right, left)` at
|
| 13 |
+
* ~50 Hz, shaping the left antenna's target as a function of time. All
|
| 14 |
+
* geometry/timing is configurable so it can be calibrated on hardware
|
| 15 |
+
* (contact angle, hold, command→sound latency) without touching logic.
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
export const DEFAULT_TAP_PROFILE = Object.freeze({
|
| 19 |
+
rightRestDeg: -39, // right antenna held here (≈ -0.68 rad)
|
| 20 |
+
leftRestDeg: 0, // left antenna resting position
|
| 21 |
+
collisionDeg: 40, // left commanded past contact (~34°) so it stalls + clicks
|
| 22 |
+
approachMs: 80, // ramp rest → collision
|
| 23 |
+
holdMs: 50, // dwell at collision (stall against right antenna)
|
| 24 |
+
returnMs: 80, // ramp back to rest
|
| 25 |
+
rateHz: 50, // command rate
|
| 26 |
+
leadMs: 0, // advance whole timeline to offset command→sound latency
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
export class RobotTapper {
|
| 30 |
+
constructor(reachy, profile = {}) {
|
| 31 |
+
this.reachy = reachy;
|
| 32 |
+
this.p = { ...DEFAULT_TAP_PROFILE, ...profile };
|
| 33 |
+
this._timer = null;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/** Left-antenna angle (deg) at time `t` ms, given the slam windows. */
|
| 37 |
+
_leftAngleAt(t, onsets) {
|
| 38 |
+
const { leftRestDeg, collisionDeg, approachMs, holdMs } = this.p;
|
| 39 |
+
for (const T of onsets) {
|
| 40 |
+
const a0 = T - approachMs; // start approaching
|
| 41 |
+
const hEnd = T + holdMs; // end of hold
|
| 42 |
+
const rEnd = hEnd + this.p.returnMs; // back to rest
|
| 43 |
+
if (t < a0 || t > rEnd) continue;
|
| 44 |
+
if (t <= T) {
|
| 45 |
+
const f = approachMs > 0 ? (t - a0) / approachMs : 1;
|
| 46 |
+
return leftRestDeg + (collisionDeg - leftRestDeg) * easeIn(f);
|
| 47 |
+
}
|
| 48 |
+
if (t <= hEnd) return collisionDeg;
|
| 49 |
+
const f = (t - hEnd) / this.p.returnMs;
|
| 50 |
+
return collisionDeg + (leftRestDeg - collisionDeg) * easeOut(f);
|
| 51 |
+
}
|
| 52 |
+
return leftRestDeg;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* Run the clap timeline.
|
| 57 |
+
* @param {number[]} onsetsMs
|
| 58 |
+
* @param {object} [o]
|
| 59 |
+
* @param {(i:number)=>void} [o.onTap] fires near each impact (UI highlight)
|
| 60 |
+
* @param {AbortSignal} [o.signal]
|
| 61 |
+
* @returns {Promise<void>} resolves when the sequence completes/aborts
|
| 62 |
+
*/
|
| 63 |
+
tap(onsetsMs, { onTap, signal } = {}) {
|
| 64 |
+
const onsets = onsetsMs.map((t) => t - this.p.leadMs);
|
| 65 |
+
const period = Math.max(10, Math.round(1000 / this.p.rateHz));
|
| 66 |
+
const endMs = (onsets.length ? onsets[onsets.length - 1] : 0)
|
| 67 |
+
+ this.p.holdMs + this.p.returnMs + 200;
|
| 68 |
+
|
| 69 |
+
return new Promise((resolve) => {
|
| 70 |
+
const t0 = performance.now();
|
| 71 |
+
let nextTapIdx = 0;
|
| 72 |
+
const tapMarks = onsetsMs.slice(); // original (UI) onset times
|
| 73 |
+
const finish = () => {
|
| 74 |
+
clearInterval(this._timer);
|
| 75 |
+
this._timer = null;
|
| 76 |
+
// settle antennas back to rest
|
| 77 |
+
try { this.reachy.setAntennasDeg(this.p.rightRestDeg, this.p.leftRestDeg); } catch { /* ignore */ }
|
| 78 |
+
resolve();
|
| 79 |
+
};
|
| 80 |
+
signal?.addEventListener("abort", finish, { once: true });
|
| 81 |
+
|
| 82 |
+
this._timer = setInterval(() => {
|
| 83 |
+
const t = performance.now() - t0;
|
| 84 |
+
if (t > endMs || signal?.aborted) return finish();
|
| 85 |
+
const left = this._leftAngleAt(t, onsets);
|
| 86 |
+
try {
|
| 87 |
+
this.reachy.setAntennasDeg(this.p.rightRestDeg, left);
|
| 88 |
+
} catch { /* transient send errors are non-fatal */ }
|
| 89 |
+
// UI tap highlight when we cross an original onset time
|
| 90 |
+
while (nextTapIdx < tapMarks.length && t >= tapMarks[nextTapIdx]) {
|
| 91 |
+
onTap?.(nextTapIdx);
|
| 92 |
+
nextTapIdx += 1;
|
| 93 |
+
}
|
| 94 |
+
}, period);
|
| 95 |
+
});
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/** One calibration clap right now (no schedule). */
|
| 99 |
+
async testClap() {
|
| 100 |
+
await this.tap([100]);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
stop() {
|
| 104 |
+
if (this._timer) {
|
| 105 |
+
clearInterval(this._timer);
|
| 106 |
+
this._timer = null;
|
| 107 |
+
}
|
| 108 |
+
try { this.reachy.setAntennasDeg(this.p.rightRestDeg, this.p.leftRestDeg); } catch { /* ignore */ }
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function easeIn(x) {
|
| 113 |
+
const c = Math.min(1, Math.max(0, x));
|
| 114 |
+
return c * c; // accelerate into the impact
|
| 115 |
+
}
|
| 116 |
+
function easeOut(x) {
|
| 117 |
+
const c = Math.min(1, Math.max(0, x));
|
| 118 |
+
return 1 - (1 - c) * (1 - c);
|
| 119 |
+
}
|
lib/synth.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Web Audio emitter — plays an onset schedule as audible clicks/beeps from
|
| 3 |
+
* the device speaker (phone or laptop). This is the "device transmits" path;
|
| 4 |
+
* the robot path lives in robot-tapper.js. Both consume the same schedule
|
| 5 |
+
* from wire.js, so a beeping phone and a tapping robot are interoperable.
|
| 6 |
+
*
|
| 7 |
+
* Each onset is a short percussive blip: a fast-decaying tone with a hard
|
| 8 |
+
* attack so the listener's onset detector keys cleanly off its start. The
|
| 9 |
+
* default tone sits in the detector's high-pass band (~2 kHz+) for a clean,
|
| 10 |
+
* Morse-buzzer feel.
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
export class Synth {
|
| 14 |
+
constructor({ freq = 2200, clickMs = 50 } = {}) {
|
| 15 |
+
this.freq = freq;
|
| 16 |
+
this.clickMs = clickMs;
|
| 17 |
+
this.ctx = null;
|
| 18 |
+
this._stopFns = [];
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
_ensureCtx() {
|
| 22 |
+
if (!this.ctx) {
|
| 23 |
+
const AC = window.AudioContext || window.webkitAudioContext;
|
| 24 |
+
this.ctx = new AC();
|
| 25 |
+
}
|
| 26 |
+
if (this.ctx.state === "suspended") this.ctx.resume();
|
| 27 |
+
return this.ctx;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/** Schedule one blip at AudioContext time `when` (seconds). */
|
| 31 |
+
_blip(when, { freq = this.freq, durMs = this.clickMs } = {}) {
|
| 32 |
+
const ctx = this.ctx;
|
| 33 |
+
const osc = ctx.createOscillator();
|
| 34 |
+
const gain = ctx.createGain();
|
| 35 |
+
osc.type = "sine";
|
| 36 |
+
osc.frequency.value = freq;
|
| 37 |
+
const dur = durMs / 1000;
|
| 38 |
+
// Hard attack (1 ms), exponential-ish decay → percussive click.
|
| 39 |
+
gain.gain.setValueAtTime(0.0001, when);
|
| 40 |
+
gain.gain.exponentialRampToValueAtTime(0.9, when + 0.001);
|
| 41 |
+
gain.gain.exponentialRampToValueAtTime(0.0001, when + dur);
|
| 42 |
+
osc.connect(gain).connect(ctx.destination);
|
| 43 |
+
osc.start(when);
|
| 44 |
+
osc.stop(when + dur + 0.02);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* Play a schedule (onset times in ms from start).
|
| 49 |
+
* @param {number[]} onsetsMs
|
| 50 |
+
* @param {object} [o]
|
| 51 |
+
* @param {(i:number, total:number)=>void} [o.onBlip] progress per blip
|
| 52 |
+
* @param {()=>void} [o.onDone]
|
| 53 |
+
* @returns {()=>void} cancel function
|
| 54 |
+
*/
|
| 55 |
+
play(onsetsMs, { onBlip, onDone, clickMs } = {}) {
|
| 56 |
+
const ctx = this._ensureCtx();
|
| 57 |
+
const t0 = ctx.currentTime + 0.08; // small lead so the first blip isn't clipped
|
| 58 |
+
const dur = clickMs ?? this.clickMs;
|
| 59 |
+
onsetsMs.forEach((ms) => this._blip(t0 + ms / 1000, { durMs: dur }));
|
| 60 |
+
|
| 61 |
+
// UI progress + completion via wall-clock timers (audio is sample-accurate;
|
| 62 |
+
// these are just for the highlight animation).
|
| 63 |
+
const startWall = performance.now();
|
| 64 |
+
const total = onsetsMs.length;
|
| 65 |
+
const timers = [];
|
| 66 |
+
onsetsMs.forEach((ms, i) => {
|
| 67 |
+
timers.push(setTimeout(() => onBlip?.(i, total), ms + 80));
|
| 68 |
+
});
|
| 69 |
+
const endMs = (onsetsMs.length ? onsetsMs[onsetsMs.length - 1] : 0) + dur + 120;
|
| 70 |
+
const doneTimer = setTimeout(() => onDone?.(), endMs);
|
| 71 |
+
|
| 72 |
+
const cancel = () => {
|
| 73 |
+
timers.forEach(clearTimeout);
|
| 74 |
+
clearTimeout(doneTimer);
|
| 75 |
+
};
|
| 76 |
+
this._stopFns.push(cancel);
|
| 77 |
+
void startWall;
|
| 78 |
+
return cancel;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/** Cancel any pending UI timers (scheduled audio blips are tiny and let ring). */
|
| 82 |
+
stop() {
|
| 83 |
+
this._stopFns.forEach((fn) => fn());
|
| 84 |
+
this._stopFns = [];
|
| 85 |
+
}
|
| 86 |
+
}
|
lib/timing.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Timing configuration for the impulse wire code.
|
| 3 |
+
*
|
| 4 |
+
* Everything is expressed as a multiple of one base `unitMs`, the single
|
| 5 |
+
* speed knob. The gap hierarchy must stay well-separated so the decoder can
|
| 6 |
+
* bin each inter-onset interval unambiguously:
|
| 7 |
+
*
|
| 8 |
+
* dahGapMs < elemGapMs < letterGapMs < wordGapMs
|
| 9 |
+
*
|
| 10 |
+
* Defaults are deliberately *slow and robust* (large gaps) — through-air
|
| 11 |
+
* impulse detection with room reverb needs generous spacing. Calibration on
|
| 12 |
+
* real hardware can tighten these; nothing in the codec hard-codes a value.
|
| 13 |
+
*
|
| 14 |
+
* Note: the detector enforces a minimum onset separation (~150-200 ms) to
|
| 15 |
+
* reject reverb tails, so `dahGapMs` (the *smallest* meaningful gap, between
|
| 16 |
+
* the two taps of a dash) must stay comfortably above that floor.
|
| 17 |
+
*/
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Build a full timing profile from a base unit.
|
| 21 |
+
*
|
| 22 |
+
* @param {number} unitMs base time unit in milliseconds
|
| 23 |
+
* @returns {{unitMs:number, dahGapMs:number, elemGapMs:number,
|
| 24 |
+
* letterGapMs:number, wordGapMs:number, clickMs:number,
|
| 25 |
+
* decisions:{elem:number, letter:number, word:number}}}
|
| 26 |
+
*/
|
| 27 |
+
export function makeTiming(unitMs = 120) {
|
| 28 |
+
const u = Math.max(40, Math.round(unitMs));
|
| 29 |
+
const dahGapMs = 2 * u; // between the two taps of a dash
|
| 30 |
+
const elemGapMs = 4 * u; // between elements of one letter
|
| 31 |
+
const letterGapMs = 8 * u; // between letters
|
| 32 |
+
const wordGapMs = 14 * u; // between words
|
| 33 |
+
return Object.freeze({
|
| 34 |
+
unitMs: u,
|
| 35 |
+
dahGapMs,
|
| 36 |
+
elemGapMs,
|
| 37 |
+
letterGapMs,
|
| 38 |
+
wordGapMs,
|
| 39 |
+
// Nominal audible click length on the speaker (synth only). The
|
| 40 |
+
// detector keys off onsets, so this does not affect decoding.
|
| 41 |
+
clickMs: Math.min(60, Math.round(u * 0.5)),
|
| 42 |
+
// Decision boundaries (geometric midpoints) used by the decoder to
|
| 43 |
+
// classify an observed inter-onset interval into a gap class.
|
| 44 |
+
decisions: Object.freeze({
|
| 45 |
+
elem: Math.round(Math.sqrt(dahGapMs * elemGapMs)), // dah|elem split
|
| 46 |
+
letter: Math.round(Math.sqrt(elemGapMs * letterGapMs)),
|
| 47 |
+
word: Math.round(Math.sqrt(letterGapMs * wordGapMs)),
|
| 48 |
+
}),
|
| 49 |
+
});
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/** Named speed presets (unit in ms). Smaller = faster. */
|
| 53 |
+
export const SPEED_PRESETS = Object.freeze({
|
| 54 |
+
relaxed: 160,
|
| 55 |
+
normal: 120,
|
| 56 |
+
brisk: 90,
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
export const DEFAULT_TIMING = makeTiming(SPEED_PRESETS.normal);
|
lib/version.js
ADDED
|
@@ -0,0 +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 r1 initial";
|
lib/viz.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Canvas visualizer: a scrolling level meter with vertical markers at each
|
| 3 |
+
* detected onset, plus a recent-waveform trace. Purely presentational; it
|
| 4 |
+
* reads values pushed from the Mic level callback and onset events.
|
| 5 |
+
*/
|
| 6 |
+
export class Scope {
|
| 7 |
+
constructor(canvas) {
|
| 8 |
+
this.canvas = canvas;
|
| 9 |
+
this.ctx = canvas.getContext("2d");
|
| 10 |
+
this.level = new Float32Array(600); // ring buffer of recent levels
|
| 11 |
+
this.head = 0;
|
| 12 |
+
this.onsets = []; // {age} markers scrolling left
|
| 13 |
+
this._raf = null;
|
| 14 |
+
this._peak = 0.02;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
pushLevel(level) {
|
| 18 |
+
this.level[this.head] = level;
|
| 19 |
+
this.head = (this.head + 1) % this.level.length;
|
| 20 |
+
if (level > this._peak) this._peak = level;
|
| 21 |
+
else this._peak = this._peak * 0.999 + level * 0.001;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
markOnset() {
|
| 25 |
+
// a marker positioned at the current write head
|
| 26 |
+
this.onsets.push({ pos: this.head });
|
| 27 |
+
if (this.onsets.length > 64) this.onsets.shift();
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
start() {
|
| 31 |
+
const draw = () => {
|
| 32 |
+
this._draw();
|
| 33 |
+
this._raf = requestAnimationFrame(draw);
|
| 34 |
+
};
|
| 35 |
+
draw();
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
stop() {
|
| 39 |
+
if (this._raf) cancelAnimationFrame(this._raf);
|
| 40 |
+
this._raf = null;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
_draw() {
|
| 44 |
+
const { ctx, canvas } = this;
|
| 45 |
+
const dpr = window.devicePixelRatio || 1;
|
| 46 |
+
const w = canvas.clientWidth, h = canvas.clientHeight;
|
| 47 |
+
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
|
| 48 |
+
canvas.width = w * dpr; canvas.height = h * dpr;
|
| 49 |
+
}
|
| 50 |
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
| 51 |
+
const css = getComputedStyle(canvas);
|
| 52 |
+
ctx.clearRect(0, 0, w, h);
|
| 53 |
+
|
| 54 |
+
const n = this.level.length;
|
| 55 |
+
const scale = 1 / (this._peak * 1.4 + 1e-6);
|
| 56 |
+
const mid = h / 2;
|
| 57 |
+
|
| 58 |
+
// level fill
|
| 59 |
+
ctx.beginPath();
|
| 60 |
+
ctx.moveTo(0, mid);
|
| 61 |
+
for (let i = 0; i < n; i++) {
|
| 62 |
+
const idx = (this.head + i) % n;
|
| 63 |
+
const x = (i / n) * w;
|
| 64 |
+
const v = Math.min(1, this.level[idx] * scale);
|
| 65 |
+
ctx.lineTo(x, mid - v * (h * 0.45));
|
| 66 |
+
}
|
| 67 |
+
for (let i = n - 1; i >= 0; i--) {
|
| 68 |
+
const idx = (this.head + i) % n;
|
| 69 |
+
const x = (i / n) * w;
|
| 70 |
+
const v = Math.min(1, this.level[idx] * scale);
|
| 71 |
+
ctx.lineTo(x, mid + v * (h * 0.45));
|
| 72 |
+
}
|
| 73 |
+
ctx.closePath();
|
| 74 |
+
ctx.fillStyle = css.getPropertyValue("--scope-fill") || "rgba(120,160,255,0.5)";
|
| 75 |
+
ctx.fill();
|
| 76 |
+
|
| 77 |
+
// onset markers (their stored head pos maps to a position in the ring)
|
| 78 |
+
ctx.strokeStyle = css.getPropertyValue("--scope-mark") || "rgba(255,90,120,0.9)";
|
| 79 |
+
ctx.lineWidth = 2;
|
| 80 |
+
for (const o of this.onsets) {
|
| 81 |
+
let rel = (o.pos - this.head + n) % n; // distance behind head
|
| 82 |
+
const x = (rel / n) * w;
|
| 83 |
+
ctx.beginPath();
|
| 84 |
+
ctx.moveTo(x, 4);
|
| 85 |
+
ctx.lineTo(x, h - 4);
|
| 86 |
+
ctx.stroke();
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
}
|
lib/wire.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* The "wire code": the bridge between symbolic Morse and physical impulses.
|
| 3 |
+
*
|
| 4 |
+
* Encoding (emit): morse tokens -> a schedule of onset times (ms from start).
|
| 5 |
+
* - dot = 1 impulse
|
| 6 |
+
* - dash = 2 impulses spaced by `dahGapMs`
|
| 7 |
+
* - elem/letter/word gaps advance the cursor between elements
|
| 8 |
+
*
|
| 9 |
+
* Decoding (listen): a list of detected onset times (ms) -> morse -> text.
|
| 10 |
+
* - Cluster onsets separated by < the dah/elem decision boundary into one
|
| 11 |
+
* element (1 impulse = dot, >=2 = dash).
|
| 12 |
+
* - Classify the gap between clusters as elem / letter / word.
|
| 13 |
+
*
|
| 14 |
+
* Onset-only: nothing here depends on how *long* a sound is, so a robot's
|
| 15 |
+
* antenna click and a phone's short beep are the same on the wire. Pure
|
| 16 |
+
* module (no DOM / audio / SDK) — unit-tested directly.
|
| 17 |
+
*/
|
| 18 |
+
import { morseToTokens, morseToText, textToMorse } from "./morse.js";
|
| 19 |
+
import { DEFAULT_TIMING } from "./timing.js";
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* morse tokens -> onset schedule.
|
| 23 |
+
* @returns {number[]} onset times in ms, starting at 0, sorted ascending.
|
| 24 |
+
*/
|
| 25 |
+
export function tokensToOnsets(tokens, timing = DEFAULT_TIMING) {
|
| 26 |
+
const onsets = [];
|
| 27 |
+
let cursor = 0;
|
| 28 |
+
for (const tok of tokens) {
|
| 29 |
+
switch (tok) {
|
| 30 |
+
case "dot":
|
| 31 |
+
onsets.push(cursor);
|
| 32 |
+
break;
|
| 33 |
+
case "dah":
|
| 34 |
+
onsets.push(cursor);
|
| 35 |
+
cursor += timing.dahGapMs;
|
| 36 |
+
onsets.push(cursor);
|
| 37 |
+
break;
|
| 38 |
+
case "elemGap":
|
| 39 |
+
cursor += timing.elemGapMs;
|
| 40 |
+
break;
|
| 41 |
+
case "letterGap":
|
| 42 |
+
cursor += timing.letterGapMs;
|
| 43 |
+
break;
|
| 44 |
+
case "wordGap":
|
| 45 |
+
cursor += timing.wordGapMs;
|
| 46 |
+
break;
|
| 47 |
+
default:
|
| 48 |
+
break;
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
return onsets;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* text -> everything an emitter needs.
|
| 56 |
+
* @returns {{morse:string, onsets:number[], durationMs:number, skipped:string[]}}
|
| 57 |
+
*/
|
| 58 |
+
export function textToSchedule(text, timing = DEFAULT_TIMING) {
|
| 59 |
+
const { morse, skipped } = textToMorse(text);
|
| 60 |
+
const onsets = tokensToOnsets(morseToTokens(morse), timing);
|
| 61 |
+
const durationMs = onsets.length ? onsets[onsets.length - 1] + timing.clickMs : 0;
|
| 62 |
+
return { morse, onsets, durationMs, skipped };
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/**
|
| 66 |
+
* Detected onset times (ms, any order) -> reconstructed morse string.
|
| 67 |
+
* Robust to jitter via the geometric decision boundaries in `timing`.
|
| 68 |
+
*/
|
| 69 |
+
export function onsetsToMorse(onsetTimes, timing = DEFAULT_TIMING) {
|
| 70 |
+
const onsets = [...onsetTimes].sort((a, b) => a - b);
|
| 71 |
+
if (onsets.length === 0) return "";
|
| 72 |
+
|
| 73 |
+
// 1) Cluster impulses into elements. A gap below the dah/elem boundary
|
| 74 |
+
// means "same element" (the two taps of a dash).
|
| 75 |
+
const clusters = []; // { size, gapBefore } gapBefore = ioi from prev cluster's last onset
|
| 76 |
+
let size = 1;
|
| 77 |
+
let prevClusterLast = null;
|
| 78 |
+
for (let i = 1; i <= onsets.length; i++) {
|
| 79 |
+
const ioi = i < onsets.length ? onsets[i] - onsets[i - 1] : Infinity;
|
| 80 |
+
if (i < onsets.length && ioi < timing.decisions.elem) {
|
| 81 |
+
size += 1; // still same element
|
| 82 |
+
continue;
|
| 83 |
+
}
|
| 84 |
+
// close current cluster
|
| 85 |
+
const gapBefore = prevClusterLast === null
|
| 86 |
+
? null
|
| 87 |
+
: onsets[i - size] - prevClusterLast;
|
| 88 |
+
clusters.push({ size, gapBefore });
|
| 89 |
+
prevClusterLast = onsets[i - 1];
|
| 90 |
+
size = 1;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// 2) Walk clusters, emitting symbols and letter/word separators.
|
| 94 |
+
const d = timing.decisions;
|
| 95 |
+
let out = "";
|
| 96 |
+
clusters.forEach((c, idx) => {
|
| 97 |
+
if (idx > 0) {
|
| 98 |
+
const g = c.gapBefore;
|
| 99 |
+
if (g >= d.word) out += " / ";
|
| 100 |
+
else if (g >= d.letter) out += " ";
|
| 101 |
+
// else elem gap -> no separator (same letter)
|
| 102 |
+
}
|
| 103 |
+
out += c.size >= 2 ? "-" : ".";
|
| 104 |
+
});
|
| 105 |
+
return out;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/**
|
| 109 |
+
* Detected onsets -> { morse, text }.
|
| 110 |
+
*/
|
| 111 |
+
export function decodeOnsets(onsetTimes, timing = DEFAULT_TIMING) {
|
| 112 |
+
const morse = onsetsToMorse(onsetTimes, timing);
|
| 113 |
+
return { morse, text: morseToText(morse) };
|
| 114 |
+
}
|
main.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Reachy Mini — Morse Code. App entry.
|
| 3 |
+
*
|
| 4 |
+
* index.html → #root (host shell mount) + #app (in-iframe surface)
|
| 5 |
+
* main.js → dispatcher: standalone mountHost() | embed connectToHost()
|
| 6 |
+
*
|
| 7 |
+
* Standalone visit: mountHost() owns OAuth + robot picker + top bar, then
|
| 8 |
+
* iframes back here with ?embedded=1. Embed visit: connectToHost() brings up
|
| 9 |
+
* the WebRTC session and hands us a live `handle.reachy`. We build the UI on
|
| 10 |
+
* it and register onLeave for safe teardown. (Mirrors marionette-js.)
|
| 11 |
+
*
|
| 12 |
+
* The robot is optional to the app's value: Compose→"This device", Listen,
|
| 13 |
+
* and Learn all work with just a phone/laptop. The robot unlocks antenna
|
| 14 |
+
* transmission and lets two Minis talk to each other.
|
| 15 |
+
*/
|
| 16 |
+
import { mountHost } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.dbe26af/host/dist/entry/auto.js";
|
| 17 |
+
import { connectToHost } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.dbe26af/host/dist/entry/embed.js";
|
| 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";
|
| 24 |
+
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);
|
| 31 |
+
const isEmbed = params.get("embedded") === "1" || params.get("embed") === "1";
|
| 32 |
+
|
| 33 |
+
if (!isEmbed) {
|
| 34 |
+
mountHost({
|
| 35 |
+
appName: "Morse Code",
|
| 36 |
+
appIconUrl: "public/icon.svg",
|
| 37 |
+
appEmoji: "📡",
|
| 38 |
+
// We capture the LAPTOP/PHONE mic via getUserMedia for decoding; we do
|
| 39 |
+
// not need the robot's inbound audio track, so no enableMicrophone.
|
| 40 |
+
});
|
| 41 |
+
} else {
|
| 42 |
+
bootEmbed().catch((err) => {
|
| 43 |
+
console.error("[morse] bootEmbed failed:", err);
|
| 44 |
+
document.body.innerHTML =
|
| 45 |
+
`<pre style="padding:24px;color:#c00;font:14px ui-monospace">` +
|
| 46 |
+
`Morse Code failed to start in the iframe:\n\n${err?.message || err}\n\n` +
|
| 47 |
+
`Try reloading the Space; check the daemon version and the SDK pin in main.js.</pre>`;
|
| 48 |
+
});
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
async function bootEmbed() {
|
| 52 |
+
const handle = await connectToHost();
|
| 53 |
+
const reachy = handle.reachy;
|
| 54 |
+
|
| 55 |
+
document.documentElement.setAttribute("data-theme", handle.theme || "dark");
|
| 56 |
+
handle.onThemeChange?.((t) => document.documentElement.setAttribute("data-theme", t));
|
| 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,
|
| 74 |
+
timing: () => makeTiming(config.unitMs),
|
| 75 |
+
setSpeed(preset) {
|
| 76 |
+
config.speed = preset;
|
| 77 |
+
if (SPEED_PRESETS[preset]) config.unitMs = SPEED_PRESETS[preset];
|
| 78 |
+
},
|
| 79 |
+
synth: () => { synth.freq = config.beepFreq; return synth; },
|
| 80 |
+
tapper: () => {
|
| 81 |
+
if (!tapper && reachy) tapper = new RobotTapper(reachy, config.tap);
|
| 82 |
+
if (tapper) tapper.p = { ...DEFAULT_TAP_PROFILE, ...config.tap };
|
| 83 |
+
return tapper;
|
| 84 |
+
},
|
| 85 |
+
makeMic: (cbs) => new Mic({
|
| 86 |
+
highpassHz: config.detector.highpassHz,
|
| 87 |
+
detector: { thresholdFactor: config.detector.thresholdFactor },
|
| 88 |
+
...cbs,
|
| 89 |
+
}),
|
| 90 |
+
toast,
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
// ─── Views ────────────────────────────────────────────────────────
|
| 94 |
+
const views = {
|
| 95 |
+
compose: createCompose(ctx),
|
| 96 |
+
listen: createListen(ctx),
|
| 97 |
+
learn: createLearn(ctx),
|
| 98 |
+
};
|
| 99 |
+
const order = ["compose", "listen", "learn"];
|
| 100 |
+
const labels = { compose: "Compose", listen: "Listen", learn: "Learn" };
|
| 101 |
+
const icons = { compose: "✏️", listen: "🎧", learn: "📖" };
|
| 102 |
+
|
| 103 |
+
const app = document.getElementById("app");
|
| 104 |
+
clear(app);
|
| 105 |
+
|
| 106 |
+
const viewHost = el("main.view-host");
|
| 107 |
+
let current = null;
|
| 108 |
+
function show(name) {
|
| 109 |
+
if (current === name) return;
|
| 110 |
+
if (current && views[current].onHide) views[current].onHide();
|
| 111 |
+
current = name;
|
| 112 |
+
clear(viewHost);
|
| 113 |
+
viewHost.append(views[name].node);
|
| 114 |
+
views[name].onShow?.();
|
| 115 |
+
[...tabbar.children].forEach((b) =>
|
| 116 |
+
b.classList.toggle("active", b.dataset.view === name));
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const tabbar = el("nav.tabbar");
|
| 120 |
+
order.forEach((name) => {
|
| 121 |
+
tabbar.append(el("button.tab", {
|
| 122 |
+
"data-view": name,
|
| 123 |
+
onclick: () => show(name),
|
| 124 |
+
}, [el("span.tab-ico", {}, icons[name]), el("span.tab-label", {}, labels[name])]));
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
const header = el("header.appbar", {}, [
|
| 128 |
+
el("div.brand", {}, [el("span.brand-logo", {}, "📡"), el("span", {}, "Morse Code")]),
|
| 129 |
+
el("button.icon-btn", { title: "Settings", onclick: openSettings }, "⚙️"),
|
| 130 |
+
]);
|
| 131 |
+
|
| 132 |
+
app.append(header, viewHost, tabbar);
|
| 133 |
+
app.hidden = false;
|
| 134 |
+
show("compose");
|
| 135 |
+
|
| 136 |
+
// ─── Settings modal ─────────────────────────────────────────────
|
| 137 |
+
function openSettings() {
|
| 138 |
+
const sheet = createSettings(ctx);
|
| 139 |
+
const overlay = el("div.overlay", { onclick: (e) => { if (e.target === overlay) close(); } });
|
| 140 |
+
const close = () => overlay.remove();
|
| 141 |
+
const panel = el("div.sheet", {}, [
|
| 142 |
+
el("div.sheet-head", {}, [el("h2", {}, "Settings"),
|
| 143 |
+
el("button.icon-btn", { onclick: close }, "✕")]),
|
| 144 |
+
sheet.node,
|
| 145 |
+
]);
|
| 146 |
+
overlay.append(panel);
|
| 147 |
+
document.body.append(overlay);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
// ─── Safe teardown ────────────────────────────────────────────────
|
| 151 |
+
handle.onLeave?.(() => {
|
| 152 |
+
try { tapper?.stop(); } catch { /* */ }
|
| 153 |
+
try { synth.stop(); } catch { /* */ }
|
| 154 |
+
if (reachy) return safelyReturnToPose(reachy);
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
window.addEventListener("error", (e) => console.error("[morse] uncaught", e.message));
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// ─── Toast ────────────────────────────────────────────────────────────
|
| 161 |
+
let toastTimer = null;
|
| 162 |
+
function toast(msg) {
|
| 163 |
+
let t = document.getElementById("toast");
|
| 164 |
+
if (!t) {
|
| 165 |
+
t = el("div#toast.toast");
|
| 166 |
+
document.body.append(t);
|
| 167 |
+
}
|
| 168 |
+
t.textContent = msg;
|
| 169 |
+
t.classList.add("show");
|
| 170 |
+
clearTimeout(toastTimer);
|
| 171 |
+
toastTimer = setTimeout(() => t.classList.remove("show"), 2600);
|
| 172 |
+
}
|
package-lock.json
ADDED
|
@@ -0,0 +1,2018 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "reachy-mini-morse-code",
|
| 3 |
+
"version": "0.0.0",
|
| 4 |
+
"lockfileVersion": 3,
|
| 5 |
+
"requires": true,
|
| 6 |
+
"packages": {
|
| 7 |
+
"": {
|
| 8 |
+
"name": "reachy-mini-morse-code",
|
| 9 |
+
"version": "0.0.0",
|
| 10 |
+
"devDependencies": {
|
| 11 |
+
"@playwright/test": "^1.48.0",
|
| 12 |
+
"jsdom": "^29.1.1",
|
| 13 |
+
"vitest": "^2.1.0"
|
| 14 |
+
}
|
| 15 |
+
},
|
| 16 |
+
"node_modules/@asamuzakjp/css-color": {
|
| 17 |
+
"version": "5.1.11",
|
| 18 |
+
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
| 19 |
+
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
| 20 |
+
"dev": true,
|
| 21 |
+
"license": "MIT",
|
| 22 |
+
"dependencies": {
|
| 23 |
+
"@asamuzakjp/generational-cache": "^1.0.1",
|
| 24 |
+
"@csstools/css-calc": "^3.2.0",
|
| 25 |
+
"@csstools/css-color-parser": "^4.1.0",
|
| 26 |
+
"@csstools/css-parser-algorithms": "^4.0.0",
|
| 27 |
+
"@csstools/css-tokenizer": "^4.0.0"
|
| 28 |
+
},
|
| 29 |
+
"engines": {
|
| 30 |
+
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
| 31 |
+
}
|
| 32 |
+
},
|
| 33 |
+
"node_modules/@asamuzakjp/dom-selector": {
|
| 34 |
+
"version": "7.1.1",
|
| 35 |
+
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
| 36 |
+
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
| 37 |
+
"dev": true,
|
| 38 |
+
"license": "MIT",
|
| 39 |
+
"dependencies": {
|
| 40 |
+
"@asamuzakjp/generational-cache": "^1.0.1",
|
| 41 |
+
"@asamuzakjp/nwsapi": "^2.3.9",
|
| 42 |
+
"bidi-js": "^1.0.3",
|
| 43 |
+
"css-tree": "^3.2.1",
|
| 44 |
+
"is-potential-custom-element-name": "^1.0.1"
|
| 45 |
+
},
|
| 46 |
+
"engines": {
|
| 47 |
+
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
| 48 |
+
}
|
| 49 |
+
},
|
| 50 |
+
"node_modules/@asamuzakjp/generational-cache": {
|
| 51 |
+
"version": "1.0.1",
|
| 52 |
+
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
| 53 |
+
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
| 54 |
+
"dev": true,
|
| 55 |
+
"license": "MIT",
|
| 56 |
+
"engines": {
|
| 57 |
+
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
| 58 |
+
}
|
| 59 |
+
},
|
| 60 |
+
"node_modules/@asamuzakjp/nwsapi": {
|
| 61 |
+
"version": "2.3.9",
|
| 62 |
+
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
| 63 |
+
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
| 64 |
+
"dev": true,
|
| 65 |
+
"license": "MIT"
|
| 66 |
+
},
|
| 67 |
+
"node_modules/@bramus/specificity": {
|
| 68 |
+
"version": "2.4.2",
|
| 69 |
+
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
| 70 |
+
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
| 71 |
+
"dev": true,
|
| 72 |
+
"license": "MIT",
|
| 73 |
+
"dependencies": {
|
| 74 |
+
"css-tree": "^3.0.0"
|
| 75 |
+
},
|
| 76 |
+
"bin": {
|
| 77 |
+
"specificity": "bin/cli.js"
|
| 78 |
+
}
|
| 79 |
+
},
|
| 80 |
+
"node_modules/@csstools/color-helpers": {
|
| 81 |
+
"version": "6.0.2",
|
| 82 |
+
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
| 83 |
+
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
| 84 |
+
"dev": true,
|
| 85 |
+
"funding": [
|
| 86 |
+
{
|
| 87 |
+
"type": "github",
|
| 88 |
+
"url": "https://github.com/sponsors/csstools"
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"type": "opencollective",
|
| 92 |
+
"url": "https://opencollective.com/csstools"
|
| 93 |
+
}
|
| 94 |
+
],
|
| 95 |
+
"license": "MIT-0",
|
| 96 |
+
"engines": {
|
| 97 |
+
"node": ">=20.19.0"
|
| 98 |
+
}
|
| 99 |
+
},
|
| 100 |
+
"node_modules/@csstools/css-calc": {
|
| 101 |
+
"version": "3.2.1",
|
| 102 |
+
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
|
| 103 |
+
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
|
| 104 |
+
"dev": true,
|
| 105 |
+
"funding": [
|
| 106 |
+
{
|
| 107 |
+
"type": "github",
|
| 108 |
+
"url": "https://github.com/sponsors/csstools"
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
"type": "opencollective",
|
| 112 |
+
"url": "https://opencollective.com/csstools"
|
| 113 |
+
}
|
| 114 |
+
],
|
| 115 |
+
"license": "MIT",
|
| 116 |
+
"engines": {
|
| 117 |
+
"node": ">=20.19.0"
|
| 118 |
+
},
|
| 119 |
+
"peerDependencies": {
|
| 120 |
+
"@csstools/css-parser-algorithms": "^4.0.0",
|
| 121 |
+
"@csstools/css-tokenizer": "^4.0.0"
|
| 122 |
+
}
|
| 123 |
+
},
|
| 124 |
+
"node_modules/@csstools/css-color-parser": {
|
| 125 |
+
"version": "4.1.1",
|
| 126 |
+
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
|
| 127 |
+
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
|
| 128 |
+
"dev": true,
|
| 129 |
+
"funding": [
|
| 130 |
+
{
|
| 131 |
+
"type": "github",
|
| 132 |
+
"url": "https://github.com/sponsors/csstools"
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
"type": "opencollective",
|
| 136 |
+
"url": "https://opencollective.com/csstools"
|
| 137 |
+
}
|
| 138 |
+
],
|
| 139 |
+
"license": "MIT",
|
| 140 |
+
"dependencies": {
|
| 141 |
+
"@csstools/color-helpers": "^6.0.2",
|
| 142 |
+
"@csstools/css-calc": "^3.2.1"
|
| 143 |
+
},
|
| 144 |
+
"engines": {
|
| 145 |
+
"node": ">=20.19.0"
|
| 146 |
+
},
|
| 147 |
+
"peerDependencies": {
|
| 148 |
+
"@csstools/css-parser-algorithms": "^4.0.0",
|
| 149 |
+
"@csstools/css-tokenizer": "^4.0.0"
|
| 150 |
+
}
|
| 151 |
+
},
|
| 152 |
+
"node_modules/@csstools/css-parser-algorithms": {
|
| 153 |
+
"version": "4.0.0",
|
| 154 |
+
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
| 155 |
+
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
| 156 |
+
"dev": true,
|
| 157 |
+
"funding": [
|
| 158 |
+
{
|
| 159 |
+
"type": "github",
|
| 160 |
+
"url": "https://github.com/sponsors/csstools"
|
| 161 |
+
},
|
| 162 |
+
{
|
| 163 |
+
"type": "opencollective",
|
| 164 |
+
"url": "https://opencollective.com/csstools"
|
| 165 |
+
}
|
| 166 |
+
],
|
| 167 |
+
"license": "MIT",
|
| 168 |
+
"engines": {
|
| 169 |
+
"node": ">=20.19.0"
|
| 170 |
+
},
|
| 171 |
+
"peerDependencies": {
|
| 172 |
+
"@csstools/css-tokenizer": "^4.0.0"
|
| 173 |
+
}
|
| 174 |
+
},
|
| 175 |
+
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
| 176 |
+
"version": "1.1.4",
|
| 177 |
+
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
|
| 178 |
+
"integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
|
| 179 |
+
"dev": true,
|
| 180 |
+
"funding": [
|
| 181 |
+
{
|
| 182 |
+
"type": "github",
|
| 183 |
+
"url": "https://github.com/sponsors/csstools"
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
"type": "opencollective",
|
| 187 |
+
"url": "https://opencollective.com/csstools"
|
| 188 |
+
}
|
| 189 |
+
],
|
| 190 |
+
"license": "MIT-0",
|
| 191 |
+
"peerDependencies": {
|
| 192 |
+
"css-tree": "^3.2.1"
|
| 193 |
+
},
|
| 194 |
+
"peerDependenciesMeta": {
|
| 195 |
+
"css-tree": {
|
| 196 |
+
"optional": true
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
},
|
| 200 |
+
"node_modules/@csstools/css-tokenizer": {
|
| 201 |
+
"version": "4.0.0",
|
| 202 |
+
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
| 203 |
+
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
| 204 |
+
"dev": true,
|
| 205 |
+
"funding": [
|
| 206 |
+
{
|
| 207 |
+
"type": "github",
|
| 208 |
+
"url": "https://github.com/sponsors/csstools"
|
| 209 |
+
},
|
| 210 |
+
{
|
| 211 |
+
"type": "opencollective",
|
| 212 |
+
"url": "https://opencollective.com/csstools"
|
| 213 |
+
}
|
| 214 |
+
],
|
| 215 |
+
"license": "MIT",
|
| 216 |
+
"engines": {
|
| 217 |
+
"node": ">=20.19.0"
|
| 218 |
+
}
|
| 219 |
+
},
|
| 220 |
+
"node_modules/@esbuild/aix-ppc64": {
|
| 221 |
+
"version": "0.21.5",
|
| 222 |
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
| 223 |
+
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
| 224 |
+
"cpu": [
|
| 225 |
+
"ppc64"
|
| 226 |
+
],
|
| 227 |
+
"dev": true,
|
| 228 |
+
"license": "MIT",
|
| 229 |
+
"optional": true,
|
| 230 |
+
"os": [
|
| 231 |
+
"aix"
|
| 232 |
+
],
|
| 233 |
+
"engines": {
|
| 234 |
+
"node": ">=12"
|
| 235 |
+
}
|
| 236 |
+
},
|
| 237 |
+
"node_modules/@esbuild/android-arm": {
|
| 238 |
+
"version": "0.21.5",
|
| 239 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
| 240 |
+
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
| 241 |
+
"cpu": [
|
| 242 |
+
"arm"
|
| 243 |
+
],
|
| 244 |
+
"dev": true,
|
| 245 |
+
"license": "MIT",
|
| 246 |
+
"optional": true,
|
| 247 |
+
"os": [
|
| 248 |
+
"android"
|
| 249 |
+
],
|
| 250 |
+
"engines": {
|
| 251 |
+
"node": ">=12"
|
| 252 |
+
}
|
| 253 |
+
},
|
| 254 |
+
"node_modules/@esbuild/android-arm64": {
|
| 255 |
+
"version": "0.21.5",
|
| 256 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
| 257 |
+
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
| 258 |
+
"cpu": [
|
| 259 |
+
"arm64"
|
| 260 |
+
],
|
| 261 |
+
"dev": true,
|
| 262 |
+
"license": "MIT",
|
| 263 |
+
"optional": true,
|
| 264 |
+
"os": [
|
| 265 |
+
"android"
|
| 266 |
+
],
|
| 267 |
+
"engines": {
|
| 268 |
+
"node": ">=12"
|
| 269 |
+
}
|
| 270 |
+
},
|
| 271 |
+
"node_modules/@esbuild/android-x64": {
|
| 272 |
+
"version": "0.21.5",
|
| 273 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
| 274 |
+
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
| 275 |
+
"cpu": [
|
| 276 |
+
"x64"
|
| 277 |
+
],
|
| 278 |
+
"dev": true,
|
| 279 |
+
"license": "MIT",
|
| 280 |
+
"optional": true,
|
| 281 |
+
"os": [
|
| 282 |
+
"android"
|
| 283 |
+
],
|
| 284 |
+
"engines": {
|
| 285 |
+
"node": ">=12"
|
| 286 |
+
}
|
| 287 |
+
},
|
| 288 |
+
"node_modules/@esbuild/darwin-arm64": {
|
| 289 |
+
"version": "0.21.5",
|
| 290 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
| 291 |
+
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
| 292 |
+
"cpu": [
|
| 293 |
+
"arm64"
|
| 294 |
+
],
|
| 295 |
+
"dev": true,
|
| 296 |
+
"license": "MIT",
|
| 297 |
+
"optional": true,
|
| 298 |
+
"os": [
|
| 299 |
+
"darwin"
|
| 300 |
+
],
|
| 301 |
+
"engines": {
|
| 302 |
+
"node": ">=12"
|
| 303 |
+
}
|
| 304 |
+
},
|
| 305 |
+
"node_modules/@esbuild/darwin-x64": {
|
| 306 |
+
"version": "0.21.5",
|
| 307 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
| 308 |
+
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
| 309 |
+
"cpu": [
|
| 310 |
+
"x64"
|
| 311 |
+
],
|
| 312 |
+
"dev": true,
|
| 313 |
+
"license": "MIT",
|
| 314 |
+
"optional": true,
|
| 315 |
+
"os": [
|
| 316 |
+
"darwin"
|
| 317 |
+
],
|
| 318 |
+
"engines": {
|
| 319 |
+
"node": ">=12"
|
| 320 |
+
}
|
| 321 |
+
},
|
| 322 |
+
"node_modules/@esbuild/freebsd-arm64": {
|
| 323 |
+
"version": "0.21.5",
|
| 324 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
| 325 |
+
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
| 326 |
+
"cpu": [
|
| 327 |
+
"arm64"
|
| 328 |
+
],
|
| 329 |
+
"dev": true,
|
| 330 |
+
"license": "MIT",
|
| 331 |
+
"optional": true,
|
| 332 |
+
"os": [
|
| 333 |
+
"freebsd"
|
| 334 |
+
],
|
| 335 |
+
"engines": {
|
| 336 |
+
"node": ">=12"
|
| 337 |
+
}
|
| 338 |
+
},
|
| 339 |
+
"node_modules/@esbuild/freebsd-x64": {
|
| 340 |
+
"version": "0.21.5",
|
| 341 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
| 342 |
+
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
| 343 |
+
"cpu": [
|
| 344 |
+
"x64"
|
| 345 |
+
],
|
| 346 |
+
"dev": true,
|
| 347 |
+
"license": "MIT",
|
| 348 |
+
"optional": true,
|
| 349 |
+
"os": [
|
| 350 |
+
"freebsd"
|
| 351 |
+
],
|
| 352 |
+
"engines": {
|
| 353 |
+
"node": ">=12"
|
| 354 |
+
}
|
| 355 |
+
},
|
| 356 |
+
"node_modules/@esbuild/linux-arm": {
|
| 357 |
+
"version": "0.21.5",
|
| 358 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
| 359 |
+
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
| 360 |
+
"cpu": [
|
| 361 |
+
"arm"
|
| 362 |
+
],
|
| 363 |
+
"dev": true,
|
| 364 |
+
"license": "MIT",
|
| 365 |
+
"optional": true,
|
| 366 |
+
"os": [
|
| 367 |
+
"linux"
|
| 368 |
+
],
|
| 369 |
+
"engines": {
|
| 370 |
+
"node": ">=12"
|
| 371 |
+
}
|
| 372 |
+
},
|
| 373 |
+
"node_modules/@esbuild/linux-arm64": {
|
| 374 |
+
"version": "0.21.5",
|
| 375 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
| 376 |
+
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
| 377 |
+
"cpu": [
|
| 378 |
+
"arm64"
|
| 379 |
+
],
|
| 380 |
+
"dev": true,
|
| 381 |
+
"license": "MIT",
|
| 382 |
+
"optional": true,
|
| 383 |
+
"os": [
|
| 384 |
+
"linux"
|
| 385 |
+
],
|
| 386 |
+
"engines": {
|
| 387 |
+
"node": ">=12"
|
| 388 |
+
}
|
| 389 |
+
},
|
| 390 |
+
"node_modules/@esbuild/linux-ia32": {
|
| 391 |
+
"version": "0.21.5",
|
| 392 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
| 393 |
+
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
| 394 |
+
"cpu": [
|
| 395 |
+
"ia32"
|
| 396 |
+
],
|
| 397 |
+
"dev": true,
|
| 398 |
+
"license": "MIT",
|
| 399 |
+
"optional": true,
|
| 400 |
+
"os": [
|
| 401 |
+
"linux"
|
| 402 |
+
],
|
| 403 |
+
"engines": {
|
| 404 |
+
"node": ">=12"
|
| 405 |
+
}
|
| 406 |
+
},
|
| 407 |
+
"node_modules/@esbuild/linux-loong64": {
|
| 408 |
+
"version": "0.21.5",
|
| 409 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
| 410 |
+
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
| 411 |
+
"cpu": [
|
| 412 |
+
"loong64"
|
| 413 |
+
],
|
| 414 |
+
"dev": true,
|
| 415 |
+
"license": "MIT",
|
| 416 |
+
"optional": true,
|
| 417 |
+
"os": [
|
| 418 |
+
"linux"
|
| 419 |
+
],
|
| 420 |
+
"engines": {
|
| 421 |
+
"node": ">=12"
|
| 422 |
+
}
|
| 423 |
+
},
|
| 424 |
+
"node_modules/@esbuild/linux-mips64el": {
|
| 425 |
+
"version": "0.21.5",
|
| 426 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
| 427 |
+
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
| 428 |
+
"cpu": [
|
| 429 |
+
"mips64el"
|
| 430 |
+
],
|
| 431 |
+
"dev": true,
|
| 432 |
+
"license": "MIT",
|
| 433 |
+
"optional": true,
|
| 434 |
+
"os": [
|
| 435 |
+
"linux"
|
| 436 |
+
],
|
| 437 |
+
"engines": {
|
| 438 |
+
"node": ">=12"
|
| 439 |
+
}
|
| 440 |
+
},
|
| 441 |
+
"node_modules/@esbuild/linux-ppc64": {
|
| 442 |
+
"version": "0.21.5",
|
| 443 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
| 444 |
+
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
| 445 |
+
"cpu": [
|
| 446 |
+
"ppc64"
|
| 447 |
+
],
|
| 448 |
+
"dev": true,
|
| 449 |
+
"license": "MIT",
|
| 450 |
+
"optional": true,
|
| 451 |
+
"os": [
|
| 452 |
+
"linux"
|
| 453 |
+
],
|
| 454 |
+
"engines": {
|
| 455 |
+
"node": ">=12"
|
| 456 |
+
}
|
| 457 |
+
},
|
| 458 |
+
"node_modules/@esbuild/linux-riscv64": {
|
| 459 |
+
"version": "0.21.5",
|
| 460 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
| 461 |
+
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
| 462 |
+
"cpu": [
|
| 463 |
+
"riscv64"
|
| 464 |
+
],
|
| 465 |
+
"dev": true,
|
| 466 |
+
"license": "MIT",
|
| 467 |
+
"optional": true,
|
| 468 |
+
"os": [
|
| 469 |
+
"linux"
|
| 470 |
+
],
|
| 471 |
+
"engines": {
|
| 472 |
+
"node": ">=12"
|
| 473 |
+
}
|
| 474 |
+
},
|
| 475 |
+
"node_modules/@esbuild/linux-s390x": {
|
| 476 |
+
"version": "0.21.5",
|
| 477 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
| 478 |
+
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
| 479 |
+
"cpu": [
|
| 480 |
+
"s390x"
|
| 481 |
+
],
|
| 482 |
+
"dev": true,
|
| 483 |
+
"license": "MIT",
|
| 484 |
+
"optional": true,
|
| 485 |
+
"os": [
|
| 486 |
+
"linux"
|
| 487 |
+
],
|
| 488 |
+
"engines": {
|
| 489 |
+
"node": ">=12"
|
| 490 |
+
}
|
| 491 |
+
},
|
| 492 |
+
"node_modules/@esbuild/linux-x64": {
|
| 493 |
+
"version": "0.21.5",
|
| 494 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
| 495 |
+
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
| 496 |
+
"cpu": [
|
| 497 |
+
"x64"
|
| 498 |
+
],
|
| 499 |
+
"dev": true,
|
| 500 |
+
"license": "MIT",
|
| 501 |
+
"optional": true,
|
| 502 |
+
"os": [
|
| 503 |
+
"linux"
|
| 504 |
+
],
|
| 505 |
+
"engines": {
|
| 506 |
+
"node": ">=12"
|
| 507 |
+
}
|
| 508 |
+
},
|
| 509 |
+
"node_modules/@esbuild/netbsd-x64": {
|
| 510 |
+
"version": "0.21.5",
|
| 511 |
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
| 512 |
+
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
| 513 |
+
"cpu": [
|
| 514 |
+
"x64"
|
| 515 |
+
],
|
| 516 |
+
"dev": true,
|
| 517 |
+
"license": "MIT",
|
| 518 |
+
"optional": true,
|
| 519 |
+
"os": [
|
| 520 |
+
"netbsd"
|
| 521 |
+
],
|
| 522 |
+
"engines": {
|
| 523 |
+
"node": ">=12"
|
| 524 |
+
}
|
| 525 |
+
},
|
| 526 |
+
"node_modules/@esbuild/openbsd-x64": {
|
| 527 |
+
"version": "0.21.5",
|
| 528 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
| 529 |
+
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
| 530 |
+
"cpu": [
|
| 531 |
+
"x64"
|
| 532 |
+
],
|
| 533 |
+
"dev": true,
|
| 534 |
+
"license": "MIT",
|
| 535 |
+
"optional": true,
|
| 536 |
+
"os": [
|
| 537 |
+
"openbsd"
|
| 538 |
+
],
|
| 539 |
+
"engines": {
|
| 540 |
+
"node": ">=12"
|
| 541 |
+
}
|
| 542 |
+
},
|
| 543 |
+
"node_modules/@esbuild/sunos-x64": {
|
| 544 |
+
"version": "0.21.5",
|
| 545 |
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
| 546 |
+
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
| 547 |
+
"cpu": [
|
| 548 |
+
"x64"
|
| 549 |
+
],
|
| 550 |
+
"dev": true,
|
| 551 |
+
"license": "MIT",
|
| 552 |
+
"optional": true,
|
| 553 |
+
"os": [
|
| 554 |
+
"sunos"
|
| 555 |
+
],
|
| 556 |
+
"engines": {
|
| 557 |
+
"node": ">=12"
|
| 558 |
+
}
|
| 559 |
+
},
|
| 560 |
+
"node_modules/@esbuild/win32-arm64": {
|
| 561 |
+
"version": "0.21.5",
|
| 562 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
| 563 |
+
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
| 564 |
+
"cpu": [
|
| 565 |
+
"arm64"
|
| 566 |
+
],
|
| 567 |
+
"dev": true,
|
| 568 |
+
"license": "MIT",
|
| 569 |
+
"optional": true,
|
| 570 |
+
"os": [
|
| 571 |
+
"win32"
|
| 572 |
+
],
|
| 573 |
+
"engines": {
|
| 574 |
+
"node": ">=12"
|
| 575 |
+
}
|
| 576 |
+
},
|
| 577 |
+
"node_modules/@esbuild/win32-ia32": {
|
| 578 |
+
"version": "0.21.5",
|
| 579 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
| 580 |
+
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
| 581 |
+
"cpu": [
|
| 582 |
+
"ia32"
|
| 583 |
+
],
|
| 584 |
+
"dev": true,
|
| 585 |
+
"license": "MIT",
|
| 586 |
+
"optional": true,
|
| 587 |
+
"os": [
|
| 588 |
+
"win32"
|
| 589 |
+
],
|
| 590 |
+
"engines": {
|
| 591 |
+
"node": ">=12"
|
| 592 |
+
}
|
| 593 |
+
},
|
| 594 |
+
"node_modules/@esbuild/win32-x64": {
|
| 595 |
+
"version": "0.21.5",
|
| 596 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
| 597 |
+
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
| 598 |
+
"cpu": [
|
| 599 |
+
"x64"
|
| 600 |
+
],
|
| 601 |
+
"dev": true,
|
| 602 |
+
"license": "MIT",
|
| 603 |
+
"optional": true,
|
| 604 |
+
"os": [
|
| 605 |
+
"win32"
|
| 606 |
+
],
|
| 607 |
+
"engines": {
|
| 608 |
+
"node": ">=12"
|
| 609 |
+
}
|
| 610 |
+
},
|
| 611 |
+
"node_modules/@exodus/bytes": {
|
| 612 |
+
"version": "1.15.1",
|
| 613 |
+
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
| 614 |
+
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
| 615 |
+
"dev": true,
|
| 616 |
+
"license": "MIT",
|
| 617 |
+
"engines": {
|
| 618 |
+
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
| 619 |
+
},
|
| 620 |
+
"peerDependencies": {
|
| 621 |
+
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
| 622 |
+
},
|
| 623 |
+
"peerDependenciesMeta": {
|
| 624 |
+
"@noble/hashes": {
|
| 625 |
+
"optional": true
|
| 626 |
+
}
|
| 627 |
+
}
|
| 628 |
+
},
|
| 629 |
+
"node_modules/@jridgewell/sourcemap-codec": {
|
| 630 |
+
"version": "1.5.5",
|
| 631 |
+
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
| 632 |
+
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
| 633 |
+
"dev": true,
|
| 634 |
+
"license": "MIT"
|
| 635 |
+
},
|
| 636 |
+
"node_modules/@playwright/test": {
|
| 637 |
+
"version": "1.60.0",
|
| 638 |
+
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
| 639 |
+
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
| 640 |
+
"dev": true,
|
| 641 |
+
"license": "Apache-2.0",
|
| 642 |
+
"dependencies": {
|
| 643 |
+
"playwright": "1.60.0"
|
| 644 |
+
},
|
| 645 |
+
"bin": {
|
| 646 |
+
"playwright": "cli.js"
|
| 647 |
+
},
|
| 648 |
+
"engines": {
|
| 649 |
+
"node": ">=18"
|
| 650 |
+
}
|
| 651 |
+
},
|
| 652 |
+
"node_modules/@rollup/rollup-android-arm-eabi": {
|
| 653 |
+
"version": "4.61.0",
|
| 654 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz",
|
| 655 |
+
"integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==",
|
| 656 |
+
"cpu": [
|
| 657 |
+
"arm"
|
| 658 |
+
],
|
| 659 |
+
"dev": true,
|
| 660 |
+
"license": "MIT",
|
| 661 |
+
"optional": true,
|
| 662 |
+
"os": [
|
| 663 |
+
"android"
|
| 664 |
+
]
|
| 665 |
+
},
|
| 666 |
+
"node_modules/@rollup/rollup-android-arm64": {
|
| 667 |
+
"version": "4.61.0",
|
| 668 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz",
|
| 669 |
+
"integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==",
|
| 670 |
+
"cpu": [
|
| 671 |
+
"arm64"
|
| 672 |
+
],
|
| 673 |
+
"dev": true,
|
| 674 |
+
"license": "MIT",
|
| 675 |
+
"optional": true,
|
| 676 |
+
"os": [
|
| 677 |
+
"android"
|
| 678 |
+
]
|
| 679 |
+
},
|
| 680 |
+
"node_modules/@rollup/rollup-darwin-arm64": {
|
| 681 |
+
"version": "4.61.0",
|
| 682 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz",
|
| 683 |
+
"integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==",
|
| 684 |
+
"cpu": [
|
| 685 |
+
"arm64"
|
| 686 |
+
],
|
| 687 |
+
"dev": true,
|
| 688 |
+
"license": "MIT",
|
| 689 |
+
"optional": true,
|
| 690 |
+
"os": [
|
| 691 |
+
"darwin"
|
| 692 |
+
]
|
| 693 |
+
},
|
| 694 |
+
"node_modules/@rollup/rollup-darwin-x64": {
|
| 695 |
+
"version": "4.61.0",
|
| 696 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz",
|
| 697 |
+
"integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==",
|
| 698 |
+
"cpu": [
|
| 699 |
+
"x64"
|
| 700 |
+
],
|
| 701 |
+
"dev": true,
|
| 702 |
+
"license": "MIT",
|
| 703 |
+
"optional": true,
|
| 704 |
+
"os": [
|
| 705 |
+
"darwin"
|
| 706 |
+
]
|
| 707 |
+
},
|
| 708 |
+
"node_modules/@rollup/rollup-freebsd-arm64": {
|
| 709 |
+
"version": "4.61.0",
|
| 710 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz",
|
| 711 |
+
"integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==",
|
| 712 |
+
"cpu": [
|
| 713 |
+
"arm64"
|
| 714 |
+
],
|
| 715 |
+
"dev": true,
|
| 716 |
+
"license": "MIT",
|
| 717 |
+
"optional": true,
|
| 718 |
+
"os": [
|
| 719 |
+
"freebsd"
|
| 720 |
+
]
|
| 721 |
+
},
|
| 722 |
+
"node_modules/@rollup/rollup-freebsd-x64": {
|
| 723 |
+
"version": "4.61.0",
|
| 724 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz",
|
| 725 |
+
"integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==",
|
| 726 |
+
"cpu": [
|
| 727 |
+
"x64"
|
| 728 |
+
],
|
| 729 |
+
"dev": true,
|
| 730 |
+
"license": "MIT",
|
| 731 |
+
"optional": true,
|
| 732 |
+
"os": [
|
| 733 |
+
"freebsd"
|
| 734 |
+
]
|
| 735 |
+
},
|
| 736 |
+
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
| 737 |
+
"version": "4.61.0",
|
| 738 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz",
|
| 739 |
+
"integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==",
|
| 740 |
+
"cpu": [
|
| 741 |
+
"arm"
|
| 742 |
+
],
|
| 743 |
+
"dev": true,
|
| 744 |
+
"license": "MIT",
|
| 745 |
+
"optional": true,
|
| 746 |
+
"os": [
|
| 747 |
+
"linux"
|
| 748 |
+
]
|
| 749 |
+
},
|
| 750 |
+
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
| 751 |
+
"version": "4.61.0",
|
| 752 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz",
|
| 753 |
+
"integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==",
|
| 754 |
+
"cpu": [
|
| 755 |
+
"arm"
|
| 756 |
+
],
|
| 757 |
+
"dev": true,
|
| 758 |
+
"license": "MIT",
|
| 759 |
+
"optional": true,
|
| 760 |
+
"os": [
|
| 761 |
+
"linux"
|
| 762 |
+
]
|
| 763 |
+
},
|
| 764 |
+
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
| 765 |
+
"version": "4.61.0",
|
| 766 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz",
|
| 767 |
+
"integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==",
|
| 768 |
+
"cpu": [
|
| 769 |
+
"arm64"
|
| 770 |
+
],
|
| 771 |
+
"dev": true,
|
| 772 |
+
"license": "MIT",
|
| 773 |
+
"optional": true,
|
| 774 |
+
"os": [
|
| 775 |
+
"linux"
|
| 776 |
+
]
|
| 777 |
+
},
|
| 778 |
+
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
| 779 |
+
"version": "4.61.0",
|
| 780 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz",
|
| 781 |
+
"integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==",
|
| 782 |
+
"cpu": [
|
| 783 |
+
"arm64"
|
| 784 |
+
],
|
| 785 |
+
"dev": true,
|
| 786 |
+
"license": "MIT",
|
| 787 |
+
"optional": true,
|
| 788 |
+
"os": [
|
| 789 |
+
"linux"
|
| 790 |
+
]
|
| 791 |
+
},
|
| 792 |
+
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
| 793 |
+
"version": "4.61.0",
|
| 794 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz",
|
| 795 |
+
"integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==",
|
| 796 |
+
"cpu": [
|
| 797 |
+
"loong64"
|
| 798 |
+
],
|
| 799 |
+
"dev": true,
|
| 800 |
+
"license": "MIT",
|
| 801 |
+
"optional": true,
|
| 802 |
+
"os": [
|
| 803 |
+
"linux"
|
| 804 |
+
]
|
| 805 |
+
},
|
| 806 |
+
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
| 807 |
+
"version": "4.61.0",
|
| 808 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz",
|
| 809 |
+
"integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==",
|
| 810 |
+
"cpu": [
|
| 811 |
+
"loong64"
|
| 812 |
+
],
|
| 813 |
+
"dev": true,
|
| 814 |
+
"license": "MIT",
|
| 815 |
+
"optional": true,
|
| 816 |
+
"os": [
|
| 817 |
+
"linux"
|
| 818 |
+
]
|
| 819 |
+
},
|
| 820 |
+
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
| 821 |
+
"version": "4.61.0",
|
| 822 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz",
|
| 823 |
+
"integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==",
|
| 824 |
+
"cpu": [
|
| 825 |
+
"ppc64"
|
| 826 |
+
],
|
| 827 |
+
"dev": true,
|
| 828 |
+
"license": "MIT",
|
| 829 |
+
"optional": true,
|
| 830 |
+
"os": [
|
| 831 |
+
"linux"
|
| 832 |
+
]
|
| 833 |
+
},
|
| 834 |
+
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
| 835 |
+
"version": "4.61.0",
|
| 836 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz",
|
| 837 |
+
"integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==",
|
| 838 |
+
"cpu": [
|
| 839 |
+
"ppc64"
|
| 840 |
+
],
|
| 841 |
+
"dev": true,
|
| 842 |
+
"license": "MIT",
|
| 843 |
+
"optional": true,
|
| 844 |
+
"os": [
|
| 845 |
+
"linux"
|
| 846 |
+
]
|
| 847 |
+
},
|
| 848 |
+
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
| 849 |
+
"version": "4.61.0",
|
| 850 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz",
|
| 851 |
+
"integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==",
|
| 852 |
+
"cpu": [
|
| 853 |
+
"riscv64"
|
| 854 |
+
],
|
| 855 |
+
"dev": true,
|
| 856 |
+
"license": "MIT",
|
| 857 |
+
"optional": true,
|
| 858 |
+
"os": [
|
| 859 |
+
"linux"
|
| 860 |
+
]
|
| 861 |
+
},
|
| 862 |
+
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
| 863 |
+
"version": "4.61.0",
|
| 864 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz",
|
| 865 |
+
"integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==",
|
| 866 |
+
"cpu": [
|
| 867 |
+
"riscv64"
|
| 868 |
+
],
|
| 869 |
+
"dev": true,
|
| 870 |
+
"license": "MIT",
|
| 871 |
+
"optional": true,
|
| 872 |
+
"os": [
|
| 873 |
+
"linux"
|
| 874 |
+
]
|
| 875 |
+
},
|
| 876 |
+
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
| 877 |
+
"version": "4.61.0",
|
| 878 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz",
|
| 879 |
+
"integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==",
|
| 880 |
+
"cpu": [
|
| 881 |
+
"s390x"
|
| 882 |
+
],
|
| 883 |
+
"dev": true,
|
| 884 |
+
"license": "MIT",
|
| 885 |
+
"optional": true,
|
| 886 |
+
"os": [
|
| 887 |
+
"linux"
|
| 888 |
+
]
|
| 889 |
+
},
|
| 890 |
+
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
| 891 |
+
"version": "4.61.0",
|
| 892 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz",
|
| 893 |
+
"integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==",
|
| 894 |
+
"cpu": [
|
| 895 |
+
"x64"
|
| 896 |
+
],
|
| 897 |
+
"dev": true,
|
| 898 |
+
"license": "MIT",
|
| 899 |
+
"optional": true,
|
| 900 |
+
"os": [
|
| 901 |
+
"linux"
|
| 902 |
+
]
|
| 903 |
+
},
|
| 904 |
+
"node_modules/@rollup/rollup-linux-x64-musl": {
|
| 905 |
+
"version": "4.61.0",
|
| 906 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz",
|
| 907 |
+
"integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==",
|
| 908 |
+
"cpu": [
|
| 909 |
+
"x64"
|
| 910 |
+
],
|
| 911 |
+
"dev": true,
|
| 912 |
+
"license": "MIT",
|
| 913 |
+
"optional": true,
|
| 914 |
+
"os": [
|
| 915 |
+
"linux"
|
| 916 |
+
]
|
| 917 |
+
},
|
| 918 |
+
"node_modules/@rollup/rollup-openbsd-x64": {
|
| 919 |
+
"version": "4.61.0",
|
| 920 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz",
|
| 921 |
+
"integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==",
|
| 922 |
+
"cpu": [
|
| 923 |
+
"x64"
|
| 924 |
+
],
|
| 925 |
+
"dev": true,
|
| 926 |
+
"license": "MIT",
|
| 927 |
+
"optional": true,
|
| 928 |
+
"os": [
|
| 929 |
+
"openbsd"
|
| 930 |
+
]
|
| 931 |
+
},
|
| 932 |
+
"node_modules/@rollup/rollup-openharmony-arm64": {
|
| 933 |
+
"version": "4.61.0",
|
| 934 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz",
|
| 935 |
+
"integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==",
|
| 936 |
+
"cpu": [
|
| 937 |
+
"arm64"
|
| 938 |
+
],
|
| 939 |
+
"dev": true,
|
| 940 |
+
"license": "MIT",
|
| 941 |
+
"optional": true,
|
| 942 |
+
"os": [
|
| 943 |
+
"openharmony"
|
| 944 |
+
]
|
| 945 |
+
},
|
| 946 |
+
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
| 947 |
+
"version": "4.61.0",
|
| 948 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz",
|
| 949 |
+
"integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==",
|
| 950 |
+
"cpu": [
|
| 951 |
+
"arm64"
|
| 952 |
+
],
|
| 953 |
+
"dev": true,
|
| 954 |
+
"license": "MIT",
|
| 955 |
+
"optional": true,
|
| 956 |
+
"os": [
|
| 957 |
+
"win32"
|
| 958 |
+
]
|
| 959 |
+
},
|
| 960 |
+
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
| 961 |
+
"version": "4.61.0",
|
| 962 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz",
|
| 963 |
+
"integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==",
|
| 964 |
+
"cpu": [
|
| 965 |
+
"ia32"
|
| 966 |
+
],
|
| 967 |
+
"dev": true,
|
| 968 |
+
"license": "MIT",
|
| 969 |
+
"optional": true,
|
| 970 |
+
"os": [
|
| 971 |
+
"win32"
|
| 972 |
+
]
|
| 973 |
+
},
|
| 974 |
+
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
| 975 |
+
"version": "4.61.0",
|
| 976 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz",
|
| 977 |
+
"integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==",
|
| 978 |
+
"cpu": [
|
| 979 |
+
"x64"
|
| 980 |
+
],
|
| 981 |
+
"dev": true,
|
| 982 |
+
"license": "MIT",
|
| 983 |
+
"optional": true,
|
| 984 |
+
"os": [
|
| 985 |
+
"win32"
|
| 986 |
+
]
|
| 987 |
+
},
|
| 988 |
+
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
| 989 |
+
"version": "4.61.0",
|
| 990 |
+
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz",
|
| 991 |
+
"integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==",
|
| 992 |
+
"cpu": [
|
| 993 |
+
"x64"
|
| 994 |
+
],
|
| 995 |
+
"dev": true,
|
| 996 |
+
"license": "MIT",
|
| 997 |
+
"optional": true,
|
| 998 |
+
"os": [
|
| 999 |
+
"win32"
|
| 1000 |
+
]
|
| 1001 |
+
},
|
| 1002 |
+
"node_modules/@types/estree": {
|
| 1003 |
+
"version": "1.0.9",
|
| 1004 |
+
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
| 1005 |
+
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
| 1006 |
+
"dev": true,
|
| 1007 |
+
"license": "MIT"
|
| 1008 |
+
},
|
| 1009 |
+
"node_modules/@vitest/expect": {
|
| 1010 |
+
"version": "2.1.9",
|
| 1011 |
+
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
| 1012 |
+
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
|
| 1013 |
+
"dev": true,
|
| 1014 |
+
"license": "MIT",
|
| 1015 |
+
"dependencies": {
|
| 1016 |
+
"@vitest/spy": "2.1.9",
|
| 1017 |
+
"@vitest/utils": "2.1.9",
|
| 1018 |
+
"chai": "^5.1.2",
|
| 1019 |
+
"tinyrainbow": "^1.2.0"
|
| 1020 |
+
},
|
| 1021 |
+
"funding": {
|
| 1022 |
+
"url": "https://opencollective.com/vitest"
|
| 1023 |
+
}
|
| 1024 |
+
},
|
| 1025 |
+
"node_modules/@vitest/mocker": {
|
| 1026 |
+
"version": "2.1.9",
|
| 1027 |
+
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
|
| 1028 |
+
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
|
| 1029 |
+
"dev": true,
|
| 1030 |
+
"license": "MIT",
|
| 1031 |
+
"dependencies": {
|
| 1032 |
+
"@vitest/spy": "2.1.9",
|
| 1033 |
+
"estree-walker": "^3.0.3",
|
| 1034 |
+
"magic-string": "^0.30.12"
|
| 1035 |
+
},
|
| 1036 |
+
"funding": {
|
| 1037 |
+
"url": "https://opencollective.com/vitest"
|
| 1038 |
+
},
|
| 1039 |
+
"peerDependencies": {
|
| 1040 |
+
"msw": "^2.4.9",
|
| 1041 |
+
"vite": "^5.0.0"
|
| 1042 |
+
},
|
| 1043 |
+
"peerDependenciesMeta": {
|
| 1044 |
+
"msw": {
|
| 1045 |
+
"optional": true
|
| 1046 |
+
},
|
| 1047 |
+
"vite": {
|
| 1048 |
+
"optional": true
|
| 1049 |
+
}
|
| 1050 |
+
}
|
| 1051 |
+
},
|
| 1052 |
+
"node_modules/@vitest/pretty-format": {
|
| 1053 |
+
"version": "2.1.9",
|
| 1054 |
+
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
|
| 1055 |
+
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
|
| 1056 |
+
"dev": true,
|
| 1057 |
+
"license": "MIT",
|
| 1058 |
+
"dependencies": {
|
| 1059 |
+
"tinyrainbow": "^1.2.0"
|
| 1060 |
+
},
|
| 1061 |
+
"funding": {
|
| 1062 |
+
"url": "https://opencollective.com/vitest"
|
| 1063 |
+
}
|
| 1064 |
+
},
|
| 1065 |
+
"node_modules/@vitest/runner": {
|
| 1066 |
+
"version": "2.1.9",
|
| 1067 |
+
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
|
| 1068 |
+
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
|
| 1069 |
+
"dev": true,
|
| 1070 |
+
"license": "MIT",
|
| 1071 |
+
"dependencies": {
|
| 1072 |
+
"@vitest/utils": "2.1.9",
|
| 1073 |
+
"pathe": "^1.1.2"
|
| 1074 |
+
},
|
| 1075 |
+
"funding": {
|
| 1076 |
+
"url": "https://opencollective.com/vitest"
|
| 1077 |
+
}
|
| 1078 |
+
},
|
| 1079 |
+
"node_modules/@vitest/snapshot": {
|
| 1080 |
+
"version": "2.1.9",
|
| 1081 |
+
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
|
| 1082 |
+
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
|
| 1083 |
+
"dev": true,
|
| 1084 |
+
"license": "MIT",
|
| 1085 |
+
"dependencies": {
|
| 1086 |
+
"@vitest/pretty-format": "2.1.9",
|
| 1087 |
+
"magic-string": "^0.30.12",
|
| 1088 |
+
"pathe": "^1.1.2"
|
| 1089 |
+
},
|
| 1090 |
+
"funding": {
|
| 1091 |
+
"url": "https://opencollective.com/vitest"
|
| 1092 |
+
}
|
| 1093 |
+
},
|
| 1094 |
+
"node_modules/@vitest/spy": {
|
| 1095 |
+
"version": "2.1.9",
|
| 1096 |
+
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
|
| 1097 |
+
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
|
| 1098 |
+
"dev": true,
|
| 1099 |
+
"license": "MIT",
|
| 1100 |
+
"dependencies": {
|
| 1101 |
+
"tinyspy": "^3.0.2"
|
| 1102 |
+
},
|
| 1103 |
+
"funding": {
|
| 1104 |
+
"url": "https://opencollective.com/vitest"
|
| 1105 |
+
}
|
| 1106 |
+
},
|
| 1107 |
+
"node_modules/@vitest/utils": {
|
| 1108 |
+
"version": "2.1.9",
|
| 1109 |
+
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
|
| 1110 |
+
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
|
| 1111 |
+
"dev": true,
|
| 1112 |
+
"license": "MIT",
|
| 1113 |
+
"dependencies": {
|
| 1114 |
+
"@vitest/pretty-format": "2.1.9",
|
| 1115 |
+
"loupe": "^3.1.2",
|
| 1116 |
+
"tinyrainbow": "^1.2.0"
|
| 1117 |
+
},
|
| 1118 |
+
"funding": {
|
| 1119 |
+
"url": "https://opencollective.com/vitest"
|
| 1120 |
+
}
|
| 1121 |
+
},
|
| 1122 |
+
"node_modules/assertion-error": {
|
| 1123 |
+
"version": "2.0.1",
|
| 1124 |
+
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
| 1125 |
+
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
| 1126 |
+
"dev": true,
|
| 1127 |
+
"license": "MIT",
|
| 1128 |
+
"engines": {
|
| 1129 |
+
"node": ">=12"
|
| 1130 |
+
}
|
| 1131 |
+
},
|
| 1132 |
+
"node_modules/bidi-js": {
|
| 1133 |
+
"version": "1.0.3",
|
| 1134 |
+
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
| 1135 |
+
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
| 1136 |
+
"dev": true,
|
| 1137 |
+
"license": "MIT",
|
| 1138 |
+
"dependencies": {
|
| 1139 |
+
"require-from-string": "^2.0.2"
|
| 1140 |
+
}
|
| 1141 |
+
},
|
| 1142 |
+
"node_modules/cac": {
|
| 1143 |
+
"version": "6.7.14",
|
| 1144 |
+
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
| 1145 |
+
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
| 1146 |
+
"dev": true,
|
| 1147 |
+
"license": "MIT",
|
| 1148 |
+
"engines": {
|
| 1149 |
+
"node": ">=8"
|
| 1150 |
+
}
|
| 1151 |
+
},
|
| 1152 |
+
"node_modules/chai": {
|
| 1153 |
+
"version": "5.3.3",
|
| 1154 |
+
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
| 1155 |
+
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
| 1156 |
+
"dev": true,
|
| 1157 |
+
"license": "MIT",
|
| 1158 |
+
"dependencies": {
|
| 1159 |
+
"assertion-error": "^2.0.1",
|
| 1160 |
+
"check-error": "^2.1.1",
|
| 1161 |
+
"deep-eql": "^5.0.1",
|
| 1162 |
+
"loupe": "^3.1.0",
|
| 1163 |
+
"pathval": "^2.0.0"
|
| 1164 |
+
},
|
| 1165 |
+
"engines": {
|
| 1166 |
+
"node": ">=18"
|
| 1167 |
+
}
|
| 1168 |
+
},
|
| 1169 |
+
"node_modules/check-error": {
|
| 1170 |
+
"version": "2.1.3",
|
| 1171 |
+
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
| 1172 |
+
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
| 1173 |
+
"dev": true,
|
| 1174 |
+
"license": "MIT",
|
| 1175 |
+
"engines": {
|
| 1176 |
+
"node": ">= 16"
|
| 1177 |
+
}
|
| 1178 |
+
},
|
| 1179 |
+
"node_modules/css-tree": {
|
| 1180 |
+
"version": "3.2.1",
|
| 1181 |
+
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
| 1182 |
+
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
| 1183 |
+
"dev": true,
|
| 1184 |
+
"license": "MIT",
|
| 1185 |
+
"dependencies": {
|
| 1186 |
+
"mdn-data": "2.27.1",
|
| 1187 |
+
"source-map-js": "^1.2.1"
|
| 1188 |
+
},
|
| 1189 |
+
"engines": {
|
| 1190 |
+
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
| 1191 |
+
}
|
| 1192 |
+
},
|
| 1193 |
+
"node_modules/data-urls": {
|
| 1194 |
+
"version": "7.0.0",
|
| 1195 |
+
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
| 1196 |
+
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
| 1197 |
+
"dev": true,
|
| 1198 |
+
"license": "MIT",
|
| 1199 |
+
"dependencies": {
|
| 1200 |
+
"whatwg-mimetype": "^5.0.0",
|
| 1201 |
+
"whatwg-url": "^16.0.0"
|
| 1202 |
+
},
|
| 1203 |
+
"engines": {
|
| 1204 |
+
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
| 1205 |
+
}
|
| 1206 |
+
},
|
| 1207 |
+
"node_modules/debug": {
|
| 1208 |
+
"version": "4.4.3",
|
| 1209 |
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
| 1210 |
+
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
| 1211 |
+
"dev": true,
|
| 1212 |
+
"license": "MIT",
|
| 1213 |
+
"dependencies": {
|
| 1214 |
+
"ms": "^2.1.3"
|
| 1215 |
+
},
|
| 1216 |
+
"engines": {
|
| 1217 |
+
"node": ">=6.0"
|
| 1218 |
+
},
|
| 1219 |
+
"peerDependenciesMeta": {
|
| 1220 |
+
"supports-color": {
|
| 1221 |
+
"optional": true
|
| 1222 |
+
}
|
| 1223 |
+
}
|
| 1224 |
+
},
|
| 1225 |
+
"node_modules/decimal.js": {
|
| 1226 |
+
"version": "10.6.0",
|
| 1227 |
+
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
| 1228 |
+
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
| 1229 |
+
"dev": true,
|
| 1230 |
+
"license": "MIT"
|
| 1231 |
+
},
|
| 1232 |
+
"node_modules/deep-eql": {
|
| 1233 |
+
"version": "5.0.2",
|
| 1234 |
+
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
| 1235 |
+
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
| 1236 |
+
"dev": true,
|
| 1237 |
+
"license": "MIT",
|
| 1238 |
+
"engines": {
|
| 1239 |
+
"node": ">=6"
|
| 1240 |
+
}
|
| 1241 |
+
},
|
| 1242 |
+
"node_modules/entities": {
|
| 1243 |
+
"version": "8.0.0",
|
| 1244 |
+
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
| 1245 |
+
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
| 1246 |
+
"dev": true,
|
| 1247 |
+
"license": "BSD-2-Clause",
|
| 1248 |
+
"engines": {
|
| 1249 |
+
"node": ">=20.19.0"
|
| 1250 |
+
},
|
| 1251 |
+
"funding": {
|
| 1252 |
+
"url": "https://github.com/fb55/entities?sponsor=1"
|
| 1253 |
+
}
|
| 1254 |
+
},
|
| 1255 |
+
"node_modules/es-module-lexer": {
|
| 1256 |
+
"version": "1.7.0",
|
| 1257 |
+
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
| 1258 |
+
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
| 1259 |
+
"dev": true,
|
| 1260 |
+
"license": "MIT"
|
| 1261 |
+
},
|
| 1262 |
+
"node_modules/esbuild": {
|
| 1263 |
+
"version": "0.21.5",
|
| 1264 |
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
| 1265 |
+
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
| 1266 |
+
"dev": true,
|
| 1267 |
+
"hasInstallScript": true,
|
| 1268 |
+
"license": "MIT",
|
| 1269 |
+
"bin": {
|
| 1270 |
+
"esbuild": "bin/esbuild"
|
| 1271 |
+
},
|
| 1272 |
+
"engines": {
|
| 1273 |
+
"node": ">=12"
|
| 1274 |
+
},
|
| 1275 |
+
"optionalDependencies": {
|
| 1276 |
+
"@esbuild/aix-ppc64": "0.21.5",
|
| 1277 |
+
"@esbuild/android-arm": "0.21.5",
|
| 1278 |
+
"@esbuild/android-arm64": "0.21.5",
|
| 1279 |
+
"@esbuild/android-x64": "0.21.5",
|
| 1280 |
+
"@esbuild/darwin-arm64": "0.21.5",
|
| 1281 |
+
"@esbuild/darwin-x64": "0.21.5",
|
| 1282 |
+
"@esbuild/freebsd-arm64": "0.21.5",
|
| 1283 |
+
"@esbuild/freebsd-x64": "0.21.5",
|
| 1284 |
+
"@esbuild/linux-arm": "0.21.5",
|
| 1285 |
+
"@esbuild/linux-arm64": "0.21.5",
|
| 1286 |
+
"@esbuild/linux-ia32": "0.21.5",
|
| 1287 |
+
"@esbuild/linux-loong64": "0.21.5",
|
| 1288 |
+
"@esbuild/linux-mips64el": "0.21.5",
|
| 1289 |
+
"@esbuild/linux-ppc64": "0.21.5",
|
| 1290 |
+
"@esbuild/linux-riscv64": "0.21.5",
|
| 1291 |
+
"@esbuild/linux-s390x": "0.21.5",
|
| 1292 |
+
"@esbuild/linux-x64": "0.21.5",
|
| 1293 |
+
"@esbuild/netbsd-x64": "0.21.5",
|
| 1294 |
+
"@esbuild/openbsd-x64": "0.21.5",
|
| 1295 |
+
"@esbuild/sunos-x64": "0.21.5",
|
| 1296 |
+
"@esbuild/win32-arm64": "0.21.5",
|
| 1297 |
+
"@esbuild/win32-ia32": "0.21.5",
|
| 1298 |
+
"@esbuild/win32-x64": "0.21.5"
|
| 1299 |
+
}
|
| 1300 |
+
},
|
| 1301 |
+
"node_modules/estree-walker": {
|
| 1302 |
+
"version": "3.0.3",
|
| 1303 |
+
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
| 1304 |
+
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
| 1305 |
+
"dev": true,
|
| 1306 |
+
"license": "MIT",
|
| 1307 |
+
"dependencies": {
|
| 1308 |
+
"@types/estree": "^1.0.0"
|
| 1309 |
+
}
|
| 1310 |
+
},
|
| 1311 |
+
"node_modules/expect-type": {
|
| 1312 |
+
"version": "1.3.0",
|
| 1313 |
+
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
| 1314 |
+
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
| 1315 |
+
"dev": true,
|
| 1316 |
+
"license": "Apache-2.0",
|
| 1317 |
+
"engines": {
|
| 1318 |
+
"node": ">=12.0.0"
|
| 1319 |
+
}
|
| 1320 |
+
},
|
| 1321 |
+
"node_modules/fsevents": {
|
| 1322 |
+
"version": "2.3.2",
|
| 1323 |
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
| 1324 |
+
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
| 1325 |
+
"dev": true,
|
| 1326 |
+
"hasInstallScript": true,
|
| 1327 |
+
"license": "MIT",
|
| 1328 |
+
"optional": true,
|
| 1329 |
+
"os": [
|
| 1330 |
+
"darwin"
|
| 1331 |
+
],
|
| 1332 |
+
"engines": {
|
| 1333 |
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
| 1334 |
+
}
|
| 1335 |
+
},
|
| 1336 |
+
"node_modules/html-encoding-sniffer": {
|
| 1337 |
+
"version": "6.0.0",
|
| 1338 |
+
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
| 1339 |
+
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
| 1340 |
+
"dev": true,
|
| 1341 |
+
"license": "MIT",
|
| 1342 |
+
"dependencies": {
|
| 1343 |
+
"@exodus/bytes": "^1.6.0"
|
| 1344 |
+
},
|
| 1345 |
+
"engines": {
|
| 1346 |
+
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
| 1347 |
+
}
|
| 1348 |
+
},
|
| 1349 |
+
"node_modules/is-potential-custom-element-name": {
|
| 1350 |
+
"version": "1.0.1",
|
| 1351 |
+
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
| 1352 |
+
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
| 1353 |
+
"dev": true,
|
| 1354 |
+
"license": "MIT"
|
| 1355 |
+
},
|
| 1356 |
+
"node_modules/jsdom": {
|
| 1357 |
+
"version": "29.1.1",
|
| 1358 |
+
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
| 1359 |
+
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
| 1360 |
+
"dev": true,
|
| 1361 |
+
"license": "MIT",
|
| 1362 |
+
"dependencies": {
|
| 1363 |
+
"@asamuzakjp/css-color": "^5.1.11",
|
| 1364 |
+
"@asamuzakjp/dom-selector": "^7.1.1",
|
| 1365 |
+
"@bramus/specificity": "^2.4.2",
|
| 1366 |
+
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
| 1367 |
+
"@exodus/bytes": "^1.15.0",
|
| 1368 |
+
"css-tree": "^3.2.1",
|
| 1369 |
+
"data-urls": "^7.0.0",
|
| 1370 |
+
"decimal.js": "^10.6.0",
|
| 1371 |
+
"html-encoding-sniffer": "^6.0.0",
|
| 1372 |
+
"is-potential-custom-element-name": "^1.0.1",
|
| 1373 |
+
"lru-cache": "^11.3.5",
|
| 1374 |
+
"parse5": "^8.0.1",
|
| 1375 |
+
"saxes": "^6.0.0",
|
| 1376 |
+
"symbol-tree": "^3.2.4",
|
| 1377 |
+
"tough-cookie": "^6.0.1",
|
| 1378 |
+
"undici": "^7.25.0",
|
| 1379 |
+
"w3c-xmlserializer": "^5.0.0",
|
| 1380 |
+
"webidl-conversions": "^8.0.1",
|
| 1381 |
+
"whatwg-mimetype": "^5.0.0",
|
| 1382 |
+
"whatwg-url": "^16.0.1",
|
| 1383 |
+
"xml-name-validator": "^5.0.0"
|
| 1384 |
+
},
|
| 1385 |
+
"engines": {
|
| 1386 |
+
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
| 1387 |
+
},
|
| 1388 |
+
"peerDependencies": {
|
| 1389 |
+
"canvas": "^3.0.0"
|
| 1390 |
+
},
|
| 1391 |
+
"peerDependenciesMeta": {
|
| 1392 |
+
"canvas": {
|
| 1393 |
+
"optional": true
|
| 1394 |
+
}
|
| 1395 |
+
}
|
| 1396 |
+
},
|
| 1397 |
+
"node_modules/loupe": {
|
| 1398 |
+
"version": "3.2.1",
|
| 1399 |
+
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
| 1400 |
+
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
| 1401 |
+
"dev": true,
|
| 1402 |
+
"license": "MIT"
|
| 1403 |
+
},
|
| 1404 |
+
"node_modules/lru-cache": {
|
| 1405 |
+
"version": "11.5.1",
|
| 1406 |
+
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
| 1407 |
+
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
| 1408 |
+
"dev": true,
|
| 1409 |
+
"license": "BlueOak-1.0.0",
|
| 1410 |
+
"engines": {
|
| 1411 |
+
"node": "20 || >=22"
|
| 1412 |
+
}
|
| 1413 |
+
},
|
| 1414 |
+
"node_modules/magic-string": {
|
| 1415 |
+
"version": "0.30.21",
|
| 1416 |
+
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
| 1417 |
+
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
| 1418 |
+
"dev": true,
|
| 1419 |
+
"license": "MIT",
|
| 1420 |
+
"dependencies": {
|
| 1421 |
+
"@jridgewell/sourcemap-codec": "^1.5.5"
|
| 1422 |
+
}
|
| 1423 |
+
},
|
| 1424 |
+
"node_modules/mdn-data": {
|
| 1425 |
+
"version": "2.27.1",
|
| 1426 |
+
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
| 1427 |
+
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
| 1428 |
+
"dev": true,
|
| 1429 |
+
"license": "CC0-1.0"
|
| 1430 |
+
},
|
| 1431 |
+
"node_modules/ms": {
|
| 1432 |
+
"version": "2.1.3",
|
| 1433 |
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
| 1434 |
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
| 1435 |
+
"dev": true,
|
| 1436 |
+
"license": "MIT"
|
| 1437 |
+
},
|
| 1438 |
+
"node_modules/nanoid": {
|
| 1439 |
+
"version": "3.3.12",
|
| 1440 |
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
| 1441 |
+
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
| 1442 |
+
"dev": true,
|
| 1443 |
+
"funding": [
|
| 1444 |
+
{
|
| 1445 |
+
"type": "github",
|
| 1446 |
+
"url": "https://github.com/sponsors/ai"
|
| 1447 |
+
}
|
| 1448 |
+
],
|
| 1449 |
+
"license": "MIT",
|
| 1450 |
+
"bin": {
|
| 1451 |
+
"nanoid": "bin/nanoid.cjs"
|
| 1452 |
+
},
|
| 1453 |
+
"engines": {
|
| 1454 |
+
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
| 1455 |
+
}
|
| 1456 |
+
},
|
| 1457 |
+
"node_modules/parse5": {
|
| 1458 |
+
"version": "8.0.1",
|
| 1459 |
+
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
| 1460 |
+
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
| 1461 |
+
"dev": true,
|
| 1462 |
+
"license": "MIT",
|
| 1463 |
+
"dependencies": {
|
| 1464 |
+
"entities": "^8.0.0"
|
| 1465 |
+
},
|
| 1466 |
+
"funding": {
|
| 1467 |
+
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
| 1468 |
+
}
|
| 1469 |
+
},
|
| 1470 |
+
"node_modules/pathe": {
|
| 1471 |
+
"version": "1.1.2",
|
| 1472 |
+
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
|
| 1473 |
+
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
|
| 1474 |
+
"dev": true,
|
| 1475 |
+
"license": "MIT"
|
| 1476 |
+
},
|
| 1477 |
+
"node_modules/pathval": {
|
| 1478 |
+
"version": "2.0.1",
|
| 1479 |
+
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
| 1480 |
+
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
| 1481 |
+
"dev": true,
|
| 1482 |
+
"license": "MIT",
|
| 1483 |
+
"engines": {
|
| 1484 |
+
"node": ">= 14.16"
|
| 1485 |
+
}
|
| 1486 |
+
},
|
| 1487 |
+
"node_modules/picocolors": {
|
| 1488 |
+
"version": "1.1.1",
|
| 1489 |
+
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
| 1490 |
+
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
| 1491 |
+
"dev": true,
|
| 1492 |
+
"license": "ISC"
|
| 1493 |
+
},
|
| 1494 |
+
"node_modules/playwright": {
|
| 1495 |
+
"version": "1.60.0",
|
| 1496 |
+
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
| 1497 |
+
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
| 1498 |
+
"dev": true,
|
| 1499 |
+
"license": "Apache-2.0",
|
| 1500 |
+
"dependencies": {
|
| 1501 |
+
"playwright-core": "1.60.0"
|
| 1502 |
+
},
|
| 1503 |
+
"bin": {
|
| 1504 |
+
"playwright": "cli.js"
|
| 1505 |
+
},
|
| 1506 |
+
"engines": {
|
| 1507 |
+
"node": ">=18"
|
| 1508 |
+
},
|
| 1509 |
+
"optionalDependencies": {
|
| 1510 |
+
"fsevents": "2.3.2"
|
| 1511 |
+
}
|
| 1512 |
+
},
|
| 1513 |
+
"node_modules/playwright-core": {
|
| 1514 |
+
"version": "1.60.0",
|
| 1515 |
+
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
| 1516 |
+
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
| 1517 |
+
"dev": true,
|
| 1518 |
+
"license": "Apache-2.0",
|
| 1519 |
+
"bin": {
|
| 1520 |
+
"playwright-core": "cli.js"
|
| 1521 |
+
},
|
| 1522 |
+
"engines": {
|
| 1523 |
+
"node": ">=18"
|
| 1524 |
+
}
|
| 1525 |
+
},
|
| 1526 |
+
"node_modules/postcss": {
|
| 1527 |
+
"version": "8.5.15",
|
| 1528 |
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
| 1529 |
+
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
| 1530 |
+
"dev": true,
|
| 1531 |
+
"funding": [
|
| 1532 |
+
{
|
| 1533 |
+
"type": "opencollective",
|
| 1534 |
+
"url": "https://opencollective.com/postcss/"
|
| 1535 |
+
},
|
| 1536 |
+
{
|
| 1537 |
+
"type": "tidelift",
|
| 1538 |
+
"url": "https://tidelift.com/funding/github/npm/postcss"
|
| 1539 |
+
},
|
| 1540 |
+
{
|
| 1541 |
+
"type": "github",
|
| 1542 |
+
"url": "https://github.com/sponsors/ai"
|
| 1543 |
+
}
|
| 1544 |
+
],
|
| 1545 |
+
"license": "MIT",
|
| 1546 |
+
"dependencies": {
|
| 1547 |
+
"nanoid": "^3.3.12",
|
| 1548 |
+
"picocolors": "^1.1.1",
|
| 1549 |
+
"source-map-js": "^1.2.1"
|
| 1550 |
+
},
|
| 1551 |
+
"engines": {
|
| 1552 |
+
"node": "^10 || ^12 || >=14"
|
| 1553 |
+
}
|
| 1554 |
+
},
|
| 1555 |
+
"node_modules/punycode": {
|
| 1556 |
+
"version": "2.3.1",
|
| 1557 |
+
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
| 1558 |
+
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
| 1559 |
+
"dev": true,
|
| 1560 |
+
"license": "MIT",
|
| 1561 |
+
"engines": {
|
| 1562 |
+
"node": ">=6"
|
| 1563 |
+
}
|
| 1564 |
+
},
|
| 1565 |
+
"node_modules/require-from-string": {
|
| 1566 |
+
"version": "2.0.2",
|
| 1567 |
+
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
| 1568 |
+
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
| 1569 |
+
"dev": true,
|
| 1570 |
+
"license": "MIT",
|
| 1571 |
+
"engines": {
|
| 1572 |
+
"node": ">=0.10.0"
|
| 1573 |
+
}
|
| 1574 |
+
},
|
| 1575 |
+
"node_modules/rollup": {
|
| 1576 |
+
"version": "4.61.0",
|
| 1577 |
+
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz",
|
| 1578 |
+
"integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==",
|
| 1579 |
+
"dev": true,
|
| 1580 |
+
"license": "MIT",
|
| 1581 |
+
"dependencies": {
|
| 1582 |
+
"@types/estree": "1.0.9"
|
| 1583 |
+
},
|
| 1584 |
+
"bin": {
|
| 1585 |
+
"rollup": "dist/bin/rollup"
|
| 1586 |
+
},
|
| 1587 |
+
"engines": {
|
| 1588 |
+
"node": ">=18.0.0",
|
| 1589 |
+
"npm": ">=8.0.0"
|
| 1590 |
+
},
|
| 1591 |
+
"optionalDependencies": {
|
| 1592 |
+
"@rollup/rollup-android-arm-eabi": "4.61.0",
|
| 1593 |
+
"@rollup/rollup-android-arm64": "4.61.0",
|
| 1594 |
+
"@rollup/rollup-darwin-arm64": "4.61.0",
|
| 1595 |
+
"@rollup/rollup-darwin-x64": "4.61.0",
|
| 1596 |
+
"@rollup/rollup-freebsd-arm64": "4.61.0",
|
| 1597 |
+
"@rollup/rollup-freebsd-x64": "4.61.0",
|
| 1598 |
+
"@rollup/rollup-linux-arm-gnueabihf": "4.61.0",
|
| 1599 |
+
"@rollup/rollup-linux-arm-musleabihf": "4.61.0",
|
| 1600 |
+
"@rollup/rollup-linux-arm64-gnu": "4.61.0",
|
| 1601 |
+
"@rollup/rollup-linux-arm64-musl": "4.61.0",
|
| 1602 |
+
"@rollup/rollup-linux-loong64-gnu": "4.61.0",
|
| 1603 |
+
"@rollup/rollup-linux-loong64-musl": "4.61.0",
|
| 1604 |
+
"@rollup/rollup-linux-ppc64-gnu": "4.61.0",
|
| 1605 |
+
"@rollup/rollup-linux-ppc64-musl": "4.61.0",
|
| 1606 |
+
"@rollup/rollup-linux-riscv64-gnu": "4.61.0",
|
| 1607 |
+
"@rollup/rollup-linux-riscv64-musl": "4.61.0",
|
| 1608 |
+
"@rollup/rollup-linux-s390x-gnu": "4.61.0",
|
| 1609 |
+
"@rollup/rollup-linux-x64-gnu": "4.61.0",
|
| 1610 |
+
"@rollup/rollup-linux-x64-musl": "4.61.0",
|
| 1611 |
+
"@rollup/rollup-openbsd-x64": "4.61.0",
|
| 1612 |
+
"@rollup/rollup-openharmony-arm64": "4.61.0",
|
| 1613 |
+
"@rollup/rollup-win32-arm64-msvc": "4.61.0",
|
| 1614 |
+
"@rollup/rollup-win32-ia32-msvc": "4.61.0",
|
| 1615 |
+
"@rollup/rollup-win32-x64-gnu": "4.61.0",
|
| 1616 |
+
"@rollup/rollup-win32-x64-msvc": "4.61.0",
|
| 1617 |
+
"fsevents": "~2.3.2"
|
| 1618 |
+
}
|
| 1619 |
+
},
|
| 1620 |
+
"node_modules/saxes": {
|
| 1621 |
+
"version": "6.0.0",
|
| 1622 |
+
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
| 1623 |
+
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
| 1624 |
+
"dev": true,
|
| 1625 |
+
"license": "ISC",
|
| 1626 |
+
"dependencies": {
|
| 1627 |
+
"xmlchars": "^2.2.0"
|
| 1628 |
+
},
|
| 1629 |
+
"engines": {
|
| 1630 |
+
"node": ">=v12.22.7"
|
| 1631 |
+
}
|
| 1632 |
+
},
|
| 1633 |
+
"node_modules/siginfo": {
|
| 1634 |
+
"version": "2.0.0",
|
| 1635 |
+
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
| 1636 |
+
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
| 1637 |
+
"dev": true,
|
| 1638 |
+
"license": "ISC"
|
| 1639 |
+
},
|
| 1640 |
+
"node_modules/source-map-js": {
|
| 1641 |
+
"version": "1.2.1",
|
| 1642 |
+
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
| 1643 |
+
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
| 1644 |
+
"dev": true,
|
| 1645 |
+
"license": "BSD-3-Clause",
|
| 1646 |
+
"engines": {
|
| 1647 |
+
"node": ">=0.10.0"
|
| 1648 |
+
}
|
| 1649 |
+
},
|
| 1650 |
+
"node_modules/stackback": {
|
| 1651 |
+
"version": "0.0.2",
|
| 1652 |
+
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
| 1653 |
+
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
| 1654 |
+
"dev": true,
|
| 1655 |
+
"license": "MIT"
|
| 1656 |
+
},
|
| 1657 |
+
"node_modules/std-env": {
|
| 1658 |
+
"version": "3.10.0",
|
| 1659 |
+
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
| 1660 |
+
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
| 1661 |
+
"dev": true,
|
| 1662 |
+
"license": "MIT"
|
| 1663 |
+
},
|
| 1664 |
+
"node_modules/symbol-tree": {
|
| 1665 |
+
"version": "3.2.4",
|
| 1666 |
+
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
| 1667 |
+
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
| 1668 |
+
"dev": true,
|
| 1669 |
+
"license": "MIT"
|
| 1670 |
+
},
|
| 1671 |
+
"node_modules/tinybench": {
|
| 1672 |
+
"version": "2.9.0",
|
| 1673 |
+
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
| 1674 |
+
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
| 1675 |
+
"dev": true,
|
| 1676 |
+
"license": "MIT"
|
| 1677 |
+
},
|
| 1678 |
+
"node_modules/tinyexec": {
|
| 1679 |
+
"version": "0.3.2",
|
| 1680 |
+
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
| 1681 |
+
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
| 1682 |
+
"dev": true,
|
| 1683 |
+
"license": "MIT"
|
| 1684 |
+
},
|
| 1685 |
+
"node_modules/tinypool": {
|
| 1686 |
+
"version": "1.1.1",
|
| 1687 |
+
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
| 1688 |
+
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
| 1689 |
+
"dev": true,
|
| 1690 |
+
"license": "MIT",
|
| 1691 |
+
"engines": {
|
| 1692 |
+
"node": "^18.0.0 || >=20.0.0"
|
| 1693 |
+
}
|
| 1694 |
+
},
|
| 1695 |
+
"node_modules/tinyrainbow": {
|
| 1696 |
+
"version": "1.2.0",
|
| 1697 |
+
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
|
| 1698 |
+
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
|
| 1699 |
+
"dev": true,
|
| 1700 |
+
"license": "MIT",
|
| 1701 |
+
"engines": {
|
| 1702 |
+
"node": ">=14.0.0"
|
| 1703 |
+
}
|
| 1704 |
+
},
|
| 1705 |
+
"node_modules/tinyspy": {
|
| 1706 |
+
"version": "3.0.2",
|
| 1707 |
+
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
|
| 1708 |
+
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
|
| 1709 |
+
"dev": true,
|
| 1710 |
+
"license": "MIT",
|
| 1711 |
+
"engines": {
|
| 1712 |
+
"node": ">=14.0.0"
|
| 1713 |
+
}
|
| 1714 |
+
},
|
| 1715 |
+
"node_modules/tldts": {
|
| 1716 |
+
"version": "7.4.2",
|
| 1717 |
+
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz",
|
| 1718 |
+
"integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==",
|
| 1719 |
+
"dev": true,
|
| 1720 |
+
"license": "MIT",
|
| 1721 |
+
"dependencies": {
|
| 1722 |
+
"tldts-core": "^7.4.2"
|
| 1723 |
+
},
|
| 1724 |
+
"bin": {
|
| 1725 |
+
"tldts": "bin/cli.js"
|
| 1726 |
+
}
|
| 1727 |
+
},
|
| 1728 |
+
"node_modules/tldts-core": {
|
| 1729 |
+
"version": "7.4.2",
|
| 1730 |
+
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz",
|
| 1731 |
+
"integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==",
|
| 1732 |
+
"dev": true,
|
| 1733 |
+
"license": "MIT"
|
| 1734 |
+
},
|
| 1735 |
+
"node_modules/tough-cookie": {
|
| 1736 |
+
"version": "6.0.1",
|
| 1737 |
+
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
| 1738 |
+
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
| 1739 |
+
"dev": true,
|
| 1740 |
+
"license": "BSD-3-Clause",
|
| 1741 |
+
"dependencies": {
|
| 1742 |
+
"tldts": "^7.0.5"
|
| 1743 |
+
},
|
| 1744 |
+
"engines": {
|
| 1745 |
+
"node": ">=16"
|
| 1746 |
+
}
|
| 1747 |
+
},
|
| 1748 |
+
"node_modules/tr46": {
|
| 1749 |
+
"version": "6.0.0",
|
| 1750 |
+
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
| 1751 |
+
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
| 1752 |
+
"dev": true,
|
| 1753 |
+
"license": "MIT",
|
| 1754 |
+
"dependencies": {
|
| 1755 |
+
"punycode": "^2.3.1"
|
| 1756 |
+
},
|
| 1757 |
+
"engines": {
|
| 1758 |
+
"node": ">=20"
|
| 1759 |
+
}
|
| 1760 |
+
},
|
| 1761 |
+
"node_modules/undici": {
|
| 1762 |
+
"version": "7.27.0",
|
| 1763 |
+
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz",
|
| 1764 |
+
"integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==",
|
| 1765 |
+
"dev": true,
|
| 1766 |
+
"license": "MIT",
|
| 1767 |
+
"engines": {
|
| 1768 |
+
"node": ">=20.18.1"
|
| 1769 |
+
}
|
| 1770 |
+
},
|
| 1771 |
+
"node_modules/vite": {
|
| 1772 |
+
"version": "5.4.21",
|
| 1773 |
+
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
| 1774 |
+
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
| 1775 |
+
"dev": true,
|
| 1776 |
+
"license": "MIT",
|
| 1777 |
+
"dependencies": {
|
| 1778 |
+
"esbuild": "^0.21.3",
|
| 1779 |
+
"postcss": "^8.4.43",
|
| 1780 |
+
"rollup": "^4.20.0"
|
| 1781 |
+
},
|
| 1782 |
+
"bin": {
|
| 1783 |
+
"vite": "bin/vite.js"
|
| 1784 |
+
},
|
| 1785 |
+
"engines": {
|
| 1786 |
+
"node": "^18.0.0 || >=20.0.0"
|
| 1787 |
+
},
|
| 1788 |
+
"funding": {
|
| 1789 |
+
"url": "https://github.com/vitejs/vite?sponsor=1"
|
| 1790 |
+
},
|
| 1791 |
+
"optionalDependencies": {
|
| 1792 |
+
"fsevents": "~2.3.3"
|
| 1793 |
+
},
|
| 1794 |
+
"peerDependencies": {
|
| 1795 |
+
"@types/node": "^18.0.0 || >=20.0.0",
|
| 1796 |
+
"less": "*",
|
| 1797 |
+
"lightningcss": "^1.21.0",
|
| 1798 |
+
"sass": "*",
|
| 1799 |
+
"sass-embedded": "*",
|
| 1800 |
+
"stylus": "*",
|
| 1801 |
+
"sugarss": "*",
|
| 1802 |
+
"terser": "^5.4.0"
|
| 1803 |
+
},
|
| 1804 |
+
"peerDependenciesMeta": {
|
| 1805 |
+
"@types/node": {
|
| 1806 |
+
"optional": true
|
| 1807 |
+
},
|
| 1808 |
+
"less": {
|
| 1809 |
+
"optional": true
|
| 1810 |
+
},
|
| 1811 |
+
"lightningcss": {
|
| 1812 |
+
"optional": true
|
| 1813 |
+
},
|
| 1814 |
+
"sass": {
|
| 1815 |
+
"optional": true
|
| 1816 |
+
},
|
| 1817 |
+
"sass-embedded": {
|
| 1818 |
+
"optional": true
|
| 1819 |
+
},
|
| 1820 |
+
"stylus": {
|
| 1821 |
+
"optional": true
|
| 1822 |
+
},
|
| 1823 |
+
"sugarss": {
|
| 1824 |
+
"optional": true
|
| 1825 |
+
},
|
| 1826 |
+
"terser": {
|
| 1827 |
+
"optional": true
|
| 1828 |
+
}
|
| 1829 |
+
}
|
| 1830 |
+
},
|
| 1831 |
+
"node_modules/vite-node": {
|
| 1832 |
+
"version": "2.1.9",
|
| 1833 |
+
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
|
| 1834 |
+
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
|
| 1835 |
+
"dev": true,
|
| 1836 |
+
"license": "MIT",
|
| 1837 |
+
"dependencies": {
|
| 1838 |
+
"cac": "^6.7.14",
|
| 1839 |
+
"debug": "^4.3.7",
|
| 1840 |
+
"es-module-lexer": "^1.5.4",
|
| 1841 |
+
"pathe": "^1.1.2",
|
| 1842 |
+
"vite": "^5.0.0"
|
| 1843 |
+
},
|
| 1844 |
+
"bin": {
|
| 1845 |
+
"vite-node": "vite-node.mjs"
|
| 1846 |
+
},
|
| 1847 |
+
"engines": {
|
| 1848 |
+
"node": "^18.0.0 || >=20.0.0"
|
| 1849 |
+
},
|
| 1850 |
+
"funding": {
|
| 1851 |
+
"url": "https://opencollective.com/vitest"
|
| 1852 |
+
}
|
| 1853 |
+
},
|
| 1854 |
+
"node_modules/vite/node_modules/fsevents": {
|
| 1855 |
+
"version": "2.3.3",
|
| 1856 |
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
| 1857 |
+
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
| 1858 |
+
"dev": true,
|
| 1859 |
+
"hasInstallScript": true,
|
| 1860 |
+
"license": "MIT",
|
| 1861 |
+
"optional": true,
|
| 1862 |
+
"os": [
|
| 1863 |
+
"darwin"
|
| 1864 |
+
],
|
| 1865 |
+
"engines": {
|
| 1866 |
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
| 1867 |
+
}
|
| 1868 |
+
},
|
| 1869 |
+
"node_modules/vitest": {
|
| 1870 |
+
"version": "2.1.9",
|
| 1871 |
+
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
|
| 1872 |
+
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
|
| 1873 |
+
"dev": true,
|
| 1874 |
+
"license": "MIT",
|
| 1875 |
+
"dependencies": {
|
| 1876 |
+
"@vitest/expect": "2.1.9",
|
| 1877 |
+
"@vitest/mocker": "2.1.9",
|
| 1878 |
+
"@vitest/pretty-format": "^2.1.9",
|
| 1879 |
+
"@vitest/runner": "2.1.9",
|
| 1880 |
+
"@vitest/snapshot": "2.1.9",
|
| 1881 |
+
"@vitest/spy": "2.1.9",
|
| 1882 |
+
"@vitest/utils": "2.1.9",
|
| 1883 |
+
"chai": "^5.1.2",
|
| 1884 |
+
"debug": "^4.3.7",
|
| 1885 |
+
"expect-type": "^1.1.0",
|
| 1886 |
+
"magic-string": "^0.30.12",
|
| 1887 |
+
"pathe": "^1.1.2",
|
| 1888 |
+
"std-env": "^3.8.0",
|
| 1889 |
+
"tinybench": "^2.9.0",
|
| 1890 |
+
"tinyexec": "^0.3.1",
|
| 1891 |
+
"tinypool": "^1.0.1",
|
| 1892 |
+
"tinyrainbow": "^1.2.0",
|
| 1893 |
+
"vite": "^5.0.0",
|
| 1894 |
+
"vite-node": "2.1.9",
|
| 1895 |
+
"why-is-node-running": "^2.3.0"
|
| 1896 |
+
},
|
| 1897 |
+
"bin": {
|
| 1898 |
+
"vitest": "vitest.mjs"
|
| 1899 |
+
},
|
| 1900 |
+
"engines": {
|
| 1901 |
+
"node": "^18.0.0 || >=20.0.0"
|
| 1902 |
+
},
|
| 1903 |
+
"funding": {
|
| 1904 |
+
"url": "https://opencollective.com/vitest"
|
| 1905 |
+
},
|
| 1906 |
+
"peerDependencies": {
|
| 1907 |
+
"@edge-runtime/vm": "*",
|
| 1908 |
+
"@types/node": "^18.0.0 || >=20.0.0",
|
| 1909 |
+
"@vitest/browser": "2.1.9",
|
| 1910 |
+
"@vitest/ui": "2.1.9",
|
| 1911 |
+
"happy-dom": "*",
|
| 1912 |
+
"jsdom": "*"
|
| 1913 |
+
},
|
| 1914 |
+
"peerDependenciesMeta": {
|
| 1915 |
+
"@edge-runtime/vm": {
|
| 1916 |
+
"optional": true
|
| 1917 |
+
},
|
| 1918 |
+
"@types/node": {
|
| 1919 |
+
"optional": true
|
| 1920 |
+
},
|
| 1921 |
+
"@vitest/browser": {
|
| 1922 |
+
"optional": true
|
| 1923 |
+
},
|
| 1924 |
+
"@vitest/ui": {
|
| 1925 |
+
"optional": true
|
| 1926 |
+
},
|
| 1927 |
+
"happy-dom": {
|
| 1928 |
+
"optional": true
|
| 1929 |
+
},
|
| 1930 |
+
"jsdom": {
|
| 1931 |
+
"optional": true
|
| 1932 |
+
}
|
| 1933 |
+
}
|
| 1934 |
+
},
|
| 1935 |
+
"node_modules/w3c-xmlserializer": {
|
| 1936 |
+
"version": "5.0.0",
|
| 1937 |
+
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
| 1938 |
+
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
| 1939 |
+
"dev": true,
|
| 1940 |
+
"license": "MIT",
|
| 1941 |
+
"dependencies": {
|
| 1942 |
+
"xml-name-validator": "^5.0.0"
|
| 1943 |
+
},
|
| 1944 |
+
"engines": {
|
| 1945 |
+
"node": ">=18"
|
| 1946 |
+
}
|
| 1947 |
+
},
|
| 1948 |
+
"node_modules/webidl-conversions": {
|
| 1949 |
+
"version": "8.0.1",
|
| 1950 |
+
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
| 1951 |
+
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
| 1952 |
+
"dev": true,
|
| 1953 |
+
"license": "BSD-2-Clause",
|
| 1954 |
+
"engines": {
|
| 1955 |
+
"node": ">=20"
|
| 1956 |
+
}
|
| 1957 |
+
},
|
| 1958 |
+
"node_modules/whatwg-mimetype": {
|
| 1959 |
+
"version": "5.0.0",
|
| 1960 |
+
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
| 1961 |
+
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
| 1962 |
+
"dev": true,
|
| 1963 |
+
"license": "MIT",
|
| 1964 |
+
"engines": {
|
| 1965 |
+
"node": ">=20"
|
| 1966 |
+
}
|
| 1967 |
+
},
|
| 1968 |
+
"node_modules/whatwg-url": {
|
| 1969 |
+
"version": "16.0.1",
|
| 1970 |
+
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
| 1971 |
+
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
| 1972 |
+
"dev": true,
|
| 1973 |
+
"license": "MIT",
|
| 1974 |
+
"dependencies": {
|
| 1975 |
+
"@exodus/bytes": "^1.11.0",
|
| 1976 |
+
"tr46": "^6.0.0",
|
| 1977 |
+
"webidl-conversions": "^8.0.1"
|
| 1978 |
+
},
|
| 1979 |
+
"engines": {
|
| 1980 |
+
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
| 1981 |
+
}
|
| 1982 |
+
},
|
| 1983 |
+
"node_modules/why-is-node-running": {
|
| 1984 |
+
"version": "2.3.0",
|
| 1985 |
+
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
| 1986 |
+
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
| 1987 |
+
"dev": true,
|
| 1988 |
+
"license": "MIT",
|
| 1989 |
+
"dependencies": {
|
| 1990 |
+
"siginfo": "^2.0.0",
|
| 1991 |
+
"stackback": "0.0.2"
|
| 1992 |
+
},
|
| 1993 |
+
"bin": {
|
| 1994 |
+
"why-is-node-running": "cli.js"
|
| 1995 |
+
},
|
| 1996 |
+
"engines": {
|
| 1997 |
+
"node": ">=8"
|
| 1998 |
+
}
|
| 1999 |
+
},
|
| 2000 |
+
"node_modules/xml-name-validator": {
|
| 2001 |
+
"version": "5.0.0",
|
| 2002 |
+
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
| 2003 |
+
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
| 2004 |
+
"dev": true,
|
| 2005 |
+
"license": "Apache-2.0",
|
| 2006 |
+
"engines": {
|
| 2007 |
+
"node": ">=18"
|
| 2008 |
+
}
|
| 2009 |
+
},
|
| 2010 |
+
"node_modules/xmlchars": {
|
| 2011 |
+
"version": "2.2.0",
|
| 2012 |
+
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
| 2013 |
+
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
| 2014 |
+
"dev": true,
|
| 2015 |
+
"license": "MIT"
|
| 2016 |
+
}
|
| 2017 |
+
}
|
| 2018 |
+
}
|
package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "reachy-mini-morse-code",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"description": "Talk to Reachy Mini in Morse code — antenna taps & beeps, heard through the mic.",
|
| 7 |
+
"scripts": {
|
| 8 |
+
"test": "vitest run",
|
| 9 |
+
"test:watch": "vitest",
|
| 10 |
+
"test:e2e": "playwright test"
|
| 11 |
+
},
|
| 12 |
+
"devDependencies": {
|
| 13 |
+
"@playwright/test": "^1.48.0",
|
| 14 |
+
"jsdom": "^29.1.1",
|
| 15 |
+
"vitest": "^2.1.0"
|
| 16 |
+
}
|
| 17 |
+
}
|
plan.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Reachy Mini Morse Code — plan
|
| 2 |
+
|
| 3 |
+
## Goal
|
| 4 |
+
|
| 5 |
+
A shareable browser app (HF Space, JS, bare-HTML + CDN host shell, mirroring
|
| 6 |
+
`marionette-js`) that lets a Reachy Mini **communicate in Morse code**:
|
| 7 |
+
|
| 8 |
+
- The **robot** transmits by *hitting its antennas together* — each hit makes
|
| 9 |
+
an audible click.
|
| 10 |
+
- A **phone / laptop** transmits by *beeping* through its speaker.
|
| 11 |
+
- Either device (or a second robot) **listens** through a microphone, detects
|
| 12 |
+
the impulses, and decodes them back to text.
|
| 13 |
+
- Two Reachy Minis face-to-face can therefore talk to each other.
|
| 14 |
+
|
| 15 |
+
## Why an "impulse" wire code (the key design decision)
|
| 16 |
+
|
| 17 |
+
An antenna hit is **impulsive**: it produces one short click. You cannot make a
|
| 18 |
+
click "longer" the way a tone can be held, so we **cannot** use the classic
|
| 19 |
+
Morse discriminator (dot = short tone, dash = long tone) on the robot channel.
|
| 20 |
+
|
| 21 |
+
To keep *every* channel interoperable through *one* detector (robot-clicks,
|
| 22 |
+
phone-beeps, robot↔robot), the on-the-wire code is built from **onsets only**
|
| 23 |
+
(the instant each sound starts), and dot vs dash is carried by **rhythm**:
|
| 24 |
+
|
| 25 |
+
- **dot** = 1 impulse
|
| 26 |
+
- **dash** = 2 impulses spaced by `dahGapMs` (a quick double-tap)
|
| 27 |
+
- elements within a letter separated by `elemGapMs`
|
| 28 |
+
- letters separated by `letterGapMs`
|
| 29 |
+
- words separated by `wordGapMs`
|
| 30 |
+
|
| 31 |
+
with `dahGapMs < elemGapMs < letterGapMs < wordGapMs` (well separated so the
|
| 32 |
+
decoder can bin each inter-onset interval unambiguously). This is still real
|
| 33 |
+
Morse semantically — the learn-chart shows the standard `.-` patterns — it is
|
| 34 |
+
just transmitted as an impulse rhythm instead of tone length. The user
|
| 35 |
+
explicitly framed Morse as "an equivalence between letters and rhythm", which
|
| 36 |
+
is exactly this.
|
| 37 |
+
|
| 38 |
+
Beeps use the *same* impulse code (short clicks) so a beeping phone and a
|
| 39 |
+
tapping robot are mutually intelligible. (An "audible long-tone" cosmetic mode
|
| 40 |
+
can be added later; it is not the machine-decodable wire format.)
|
| 41 |
+
|
| 42 |
+
All timings live in one `timing.js` config with a single `unitMs` speed knob,
|
| 43 |
+
so they can be **calibrated on real hardware** (click sharpness, room
|
| 44 |
+
reverb, mic latency) without touching logic.
|
| 45 |
+
|
| 46 |
+
## Detection algorithm (ported from `marionette/tests/audio_analysis.py`)
|
| 47 |
+
|
| 48 |
+
`detect_transient_onsets`, proven in the Marionette audio-sync tests:
|
| 49 |
+
|
| 50 |
+
1. High-pass filter (~2 kHz) to isolate sharp clicks from room rumble / voice.
|
| 51 |
+
2. Short-term energy in 5 ms windows.
|
| 52 |
+
3. Spectral flux = positive energy differences.
|
| 53 |
+
4. Normalise, peak-pick with a min-separation guard.
|
| 54 |
+
|
| 55 |
+
Ported to the Web Audio API (`BiquadFilter` highpass + per-frame energy/flux in
|
| 56 |
+
an `AudioWorklet`/`ScriptProcessor`) for live mic decoding, and to a pure-JS
|
| 57 |
+
offline function reused verbatim in unit tests against synthetic audio.
|
| 58 |
+
|
| 59 |
+
## Architecture (bare-HTML + CDN host shell, like marionette-js)
|
| 60 |
+
|
| 61 |
+
```
|
| 62 |
+
index.html #root (host shell mount) + #app (in-iframe surface), CDN SDK pin
|
| 63 |
+
main.js dispatcher: standalone mountHost() | embed connectToHost()
|
| 64 |
+
style.css mobile-first, OKLCH palette, dark/light from handle.theme
|
| 65 |
+
lib/
|
| 66 |
+
morse.js text <-> morse string ('.- ...') tables + encode/decode [pure, tested]
|
| 67 |
+
timing.js unit/gap config + WPM-ish speed presets [pure, tested]
|
| 68 |
+
wire.js morse <-> timed impulse schedule (emit) and onset-times
|
| 69 |
+
-> morse decode (gap binning) [pure, tested]
|
| 70 |
+
detector.js offline + streaming transient-onset detector (Web Audio) [DSP core tested]
|
| 71 |
+
synth.js Web Audio click/beep emitter (plays a wire schedule)
|
| 72 |
+
robot-tapper.js drives antennas to clap on schedule (setAntennasDeg)
|
| 73 |
+
mic.js getUserMedia -> streaming detector -> onset times
|
| 74 |
+
viz.js canvas waveform + live onset markers
|
| 75 |
+
animation-helpers.js re-export SDK /animation (safelyReturnToPose) [stubbed in tests]
|
| 76 |
+
views/
|
| 77 |
+
composer.js type text -> choose emitter (robot antennas | this device) -> send
|
| 78 |
+
listener.js live mic visualization + decoded text
|
| 79 |
+
learn.js Morse chart (letter <-> .- <-> rhythm), tap-to-hear
|
| 80 |
+
topbar.js / settings.js speed + detector threshold + theme
|
| 81 |
+
tests/unit/ morse, wire, detector (synthetic round-trip) — vitest
|
| 82 |
+
public/icon.svg
|
| 83 |
+
README.md HF Space frontmatter (sdk: static, hf_oauth, tags)
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
## Robot antenna "clap" (from `marionette/tests/test_antenna_collision.py`)
|
| 87 |
+
|
| 88 |
+
Right antenna held, left antenna slams in to contact and back — low-PID
|
| 89 |
+
antennas make this safe and audible. In the JS SDK we drive it in real time
|
| 90 |
+
with `setAntennasDeg(right, left)` on a timed schedule from `wire.js`. Exact
|
| 91 |
+
contact angles + hold time get **calibrated on hardware**; defaults seeded from
|
| 92 |
+
the Marionette recipe (right ≈ −39°, left rest 0° → slam ≈ +40°).
|
| 93 |
+
|
| 94 |
+
## What needs hardware (deferred — will ping Rémi)
|
| 95 |
+
|
| 96 |
+
1. **Mic + speaker calibration**: emit the impulse schedule from the Mac
|
| 97 |
+
speaker, record with the Mac mic, confirm the detector recovers the onsets
|
| 98 |
+
through-air and tune `unitMs` / thresholds / highpass. (Noise — needs Rémi.)
|
| 99 |
+
2. **Robot antenna-click test**: confirm the clap is audible + detectable and
|
| 100 |
+
tune the contact angles / hold. (Needs the robot connected.)
|
| 101 |
+
|
| 102 |
+
Everything else (codec, detector DSP, full UI, synth, robot-tapper wiring) is
|
| 103 |
+
built and validated in software first (synthetic-audio round-trip, no
|
| 104 |
+
mic/speaker/robot).
|
| 105 |
+
|
| 106 |
+
## Open questions (defaulted; will confirm with Rémi, non-blocking)
|
| 107 |
+
|
| 108 |
+
- App/Space name: `morse-code` / "Reachy Mini Morse Code". Deploy to
|
| 109 |
+
`RemiFabre/<name>` (user is logged in as RemiFabre, org pollen-robotics).
|
| 110 |
+
- Default speed: ~`unitMs = 120 ms` (slow & robust) — final value from calibration.
|
| 111 |
+
- Long-tone cosmetic beep mode: out of scope for v1 (impulse code only).
|
playwright.config.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, devices } from "@playwright/test";
|
| 2 |
+
|
| 3 |
+
// Smoke test only: serve the static folder and confirm the app's module
|
| 4 |
+
// graph (including the CDN host-shell + SDK imports) loads in a real browser
|
| 5 |
+
// without fatal errors. The full embed flow needs a live robot.
|
| 6 |
+
export default defineConfig({
|
| 7 |
+
testDir: "./tests/e2e",
|
| 8 |
+
timeout: 45_000,
|
| 9 |
+
use: {
|
| 10 |
+
baseURL: "http://localhost:4321",
|
| 11 |
+
...devices["Desktop Chrome"],
|
| 12 |
+
},
|
| 13 |
+
webServer: {
|
| 14 |
+
command: "python3 -m http.server 4321",
|
| 15 |
+
port: 4321,
|
| 16 |
+
reuseExistingServer: true,
|
| 17 |
+
timeout: 20_000,
|
| 18 |
+
},
|
| 19 |
+
});
|
public/icon.svg
ADDED
|
|
style.css
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Morse Code · Reachy Mini — mobile-first, dark/light themed. */
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--bg: #0d1117;
|
| 5 |
+
--bg-soft: #161b22;
|
| 6 |
+
--panel: #1b222c;
|
| 7 |
+
--panel-2: #232c38;
|
| 8 |
+
--text: #e8edf3;
|
| 9 |
+
--muted: #93a1b3;
|
| 10 |
+
--line: #2b3442;
|
| 11 |
+
--accent: #5b8cff;
|
| 12 |
+
--accent-2: #8a6cff;
|
| 13 |
+
--danger: #ff5a78;
|
| 14 |
+
--good: #36d399;
|
| 15 |
+
--scope-fill: rgba(91, 140, 255, 0.45);
|
| 16 |
+
--scope-mark: rgba(255, 90, 120, 0.95);
|
| 17 |
+
--radius: 16px;
|
| 18 |
+
--shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
:root[data-theme="light"] {
|
| 22 |
+
--bg: #f4f6fb;
|
| 23 |
+
--bg-soft: #ffffff;
|
| 24 |
+
--panel: #ffffff;
|
| 25 |
+
--panel-2: #eef2f9;
|
| 26 |
+
--text: #131923;
|
| 27 |
+
--muted: #5d6b7e;
|
| 28 |
+
--line: #dde3ee;
|
| 29 |
+
--accent: #2f6bff;
|
| 30 |
+
--accent-2: #7a4dff;
|
| 31 |
+
--scope-fill: rgba(47, 107, 255, 0.30);
|
| 32 |
+
--scope-mark: rgba(214, 40, 79, 0.95);
|
| 33 |
+
--shadow: 0 10px 30px rgba(20, 30, 60, 0.12);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
* { box-sizing: border-box; }
|
| 37 |
+
|
| 38 |
+
html, body {
|
| 39 |
+
margin: 0;
|
| 40 |
+
padding: 0;
|
| 41 |
+
background: var(--bg);
|
| 42 |
+
color: var(--text);
|
| 43 |
+
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
| 44 |
+
-webkit-font-smoothing: antialiased;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
#app {
|
| 48 |
+
min-height: 100dvh;
|
| 49 |
+
display: flex;
|
| 50 |
+
flex-direction: column;
|
| 51 |
+
max-width: 720px;
|
| 52 |
+
margin: 0 auto;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/* ─── App bar ─────────────────────────────────────────────── */
|
| 56 |
+
.appbar {
|
| 57 |
+
position: sticky;
|
| 58 |
+
top: 0;
|
| 59 |
+
z-index: 10;
|
| 60 |
+
display: flex;
|
| 61 |
+
align-items: center;
|
| 62 |
+
justify-content: space-between;
|
| 63 |
+
padding: 14px 18px calc(14px + env(safe-area-inset-top));
|
| 64 |
+
padding-top: max(14px, env(safe-area-inset-top));
|
| 65 |
+
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
| 66 |
+
backdrop-filter: blur(12px);
|
| 67 |
+
border-bottom: 1px solid var(--line);
|
| 68 |
+
}
|
| 69 |
+
.brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 19px; letter-spacing: -0.02em; }
|
| 70 |
+
.brand-logo { font-size: 22px; }
|
| 71 |
+
|
| 72 |
+
.icon-btn {
|
| 73 |
+
appearance: none;
|
| 74 |
+
background: var(--panel);
|
| 75 |
+
border: 1px solid var(--line);
|
| 76 |
+
color: var(--text);
|
| 77 |
+
width: 40px; height: 40px;
|
| 78 |
+
border-radius: 12px;
|
| 79 |
+
font-size: 18px;
|
| 80 |
+
cursor: pointer;
|
| 81 |
+
}
|
| 82 |
+
.icon-btn:active { transform: scale(0.95); }
|
| 83 |
+
|
| 84 |
+
/* ─── View host + tabbar ──────────────────────────────────── */
|
| 85 |
+
.view-host { flex: 1; padding: 16px 16px 96px; }
|
| 86 |
+
.view { display: flex; flex-direction: column; gap: 16px; }
|
| 87 |
+
.view h2 { margin: 4px 0 0; font-size: 24px; letter-spacing: -0.02em; }
|
| 88 |
+
.view h3 { margin: 6px 0; font-size: 15px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
|
| 89 |
+
.muted { color: var(--muted); }
|
| 90 |
+
|
| 91 |
+
.tabbar {
|
| 92 |
+
position: fixed;
|
| 93 |
+
left: 0; right: 0; bottom: 0;
|
| 94 |
+
z-index: 10;
|
| 95 |
+
display: flex;
|
| 96 |
+
justify-content: center;
|
| 97 |
+
gap: 6px;
|
| 98 |
+
padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
|
| 99 |
+
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
| 100 |
+
backdrop-filter: blur(12px);
|
| 101 |
+
border-top: 1px solid var(--line);
|
| 102 |
+
}
|
| 103 |
+
.tab {
|
| 104 |
+
flex: 1 1 0;
|
| 105 |
+
max-width: 160px;
|
| 106 |
+
appearance: none;
|
| 107 |
+
background: transparent;
|
| 108 |
+
border: 0;
|
| 109 |
+
color: var(--muted);
|
| 110 |
+
display: flex;
|
| 111 |
+
flex-direction: column;
|
| 112 |
+
align-items: center;
|
| 113 |
+
gap: 3px;
|
| 114 |
+
padding: 6px;
|
| 115 |
+
border-radius: 12px;
|
| 116 |
+
font-size: 12px;
|
| 117 |
+
font-weight: 600;
|
| 118 |
+
cursor: pointer;
|
| 119 |
+
}
|
| 120 |
+
.tab-ico { font-size: 20px; }
|
| 121 |
+
.tab.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
| 122 |
+
|
| 123 |
+
/* ─── Fields & controls ───────────────────────────────────── */
|
| 124 |
+
.field { display: flex; flex-direction: column; gap: 8px; }
|
| 125 |
+
.field-label { font-size: 13px; font-weight: 600; color: var(--muted); }
|
| 126 |
+
.row { display: flex; gap: 10px; align-items: center; }
|
| 127 |
+
.row.between { justify-content: space-between; }
|
| 128 |
+
.send-row { margin-top: 4px; }
|
| 129 |
+
|
| 130 |
+
.compose-input {
|
| 131 |
+
width: 100%;
|
| 132 |
+
resize: vertical;
|
| 133 |
+
min-height: 84px;
|
| 134 |
+
padding: 14px;
|
| 135 |
+
font-size: 18px;
|
| 136 |
+
font-family: inherit;
|
| 137 |
+
color: var(--text);
|
| 138 |
+
background: var(--panel);
|
| 139 |
+
border: 1px solid var(--line);
|
| 140 |
+
border-radius: var(--radius);
|
| 141 |
+
outline: none;
|
| 142 |
+
}
|
| 143 |
+
.compose-input:focus { border-color: var(--accent); }
|
| 144 |
+
|
| 145 |
+
.btn {
|
| 146 |
+
appearance: none;
|
| 147 |
+
border: 1px solid var(--line);
|
| 148 |
+
background: var(--panel);
|
| 149 |
+
color: var(--text);
|
| 150 |
+
padding: 12px 18px;
|
| 151 |
+
border-radius: 14px;
|
| 152 |
+
font-size: 15px;
|
| 153 |
+
font-weight: 700;
|
| 154 |
+
cursor: pointer;
|
| 155 |
+
}
|
| 156 |
+
.btn:active { transform: scale(0.98); }
|
| 157 |
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 158 |
+
.btn.primary { background: linear-gradient(135deg, var(--accent), var(--accent-2)); border: 0; color: #fff; }
|
| 159 |
+
.btn.ghost { background: transparent; }
|
| 160 |
+
.btn.big { flex: 1; padding: 16px; font-size: 17px; }
|
| 161 |
+
.btn.listening { background: linear-gradient(135deg, var(--danger), #ff9a5a); }
|
| 162 |
+
|
| 163 |
+
.segmented { display: flex; gap: 6px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 5px; }
|
| 164 |
+
.seg {
|
| 165 |
+
flex: 1;
|
| 166 |
+
appearance: none;
|
| 167 |
+
background: transparent;
|
| 168 |
+
border: 0;
|
| 169 |
+
color: var(--muted);
|
| 170 |
+
padding: 10px 8px;
|
| 171 |
+
border-radius: 10px;
|
| 172 |
+
font-size: 14px;
|
| 173 |
+
font-weight: 600;
|
| 174 |
+
cursor: pointer;
|
| 175 |
+
}
|
| 176 |
+
.seg.active { background: var(--panel-2); color: var(--text); box-shadow: var(--shadow); }
|
| 177 |
+
|
| 178 |
+
.range { width: 100%; accent-color: var(--accent); }
|
| 179 |
+
.range-val { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--text); }
|
| 180 |
+
|
| 181 |
+
.status { font-size: 13px; min-height: 18px; }
|
| 182 |
+
.warn { color: var(--danger); font-size: 14px; font-weight: 600; }
|
| 183 |
+
|
| 184 |
+
/* ─── Morse chips ─────────────────────────────────────────── */
|
| 185 |
+
.morse-out {
|
| 186 |
+
min-height: 48px;
|
| 187 |
+
display: flex;
|
| 188 |
+
flex-wrap: wrap;
|
| 189 |
+
align-items: center;
|
| 190 |
+
gap: 6px 12px;
|
| 191 |
+
padding: 14px;
|
| 192 |
+
background: var(--panel);
|
| 193 |
+
border: 1px solid var(--line);
|
| 194 |
+
border-radius: var(--radius);
|
| 195 |
+
}
|
| 196 |
+
.pattern { display: inline-flex; align-items: center; gap: 4px; }
|
| 197 |
+
.pattern .dot, .pattern .dash {
|
| 198 |
+
display: inline-block;
|
| 199 |
+
height: 12px;
|
| 200 |
+
border-radius: 6px;
|
| 201 |
+
background: var(--accent);
|
| 202 |
+
}
|
| 203 |
+
.pattern .dot { width: 12px; }
|
| 204 |
+
.pattern .dash { width: 30px; background: var(--accent-2); }
|
| 205 |
+
.wordsep { color: var(--muted); font-weight: 800; padding: 0 2px; }
|
| 206 |
+
|
| 207 |
+
/* ─── Listen ──────────────────────────────────────────────── */
|
| 208 |
+
.scope {
|
| 209 |
+
width: 100%;
|
| 210 |
+
height: 130px;
|
| 211 |
+
background: var(--panel);
|
| 212 |
+
border: 1px solid var(--line);
|
| 213 |
+
border-radius: var(--radius);
|
| 214 |
+
display: block;
|
| 215 |
+
}
|
| 216 |
+
.decoded-text {
|
| 217 |
+
font-family: "JetBrains Mono", ui-monospace, monospace;
|
| 218 |
+
font-size: 26px;
|
| 219 |
+
font-weight: 700;
|
| 220 |
+
letter-spacing: 0.04em;
|
| 221 |
+
min-height: 34px;
|
| 222 |
+
word-break: break-word;
|
| 223 |
+
}
|
| 224 |
+
.decoded-morse { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 14px; word-break: break-all; }
|
| 225 |
+
.transcript { display: flex; flex-direction: column; gap: 8px; }
|
| 226 |
+
.line {
|
| 227 |
+
display: flex; flex-direction: column; gap: 2px;
|
| 228 |
+
padding: 10px 12px;
|
| 229 |
+
background: var(--panel);
|
| 230 |
+
border: 1px solid var(--line);
|
| 231 |
+
border-radius: 12px;
|
| 232 |
+
}
|
| 233 |
+
.line-text { font-family: "JetBrains Mono", monospace; font-weight: 700; font-size: 16px; }
|
| 234 |
+
.line-morse { font-family: "JetBrains Mono", monospace; font-size: 12px; }
|
| 235 |
+
|
| 236 |
+
/* ─── Learn chart ─────────────────────────────────────────── */
|
| 237 |
+
.chart-section { display: flex; flex-direction: column; gap: 8px; }
|
| 238 |
+
.chart-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; }
|
| 239 |
+
.chart-cell {
|
| 240 |
+
appearance: none;
|
| 241 |
+
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
| 242 |
+
padding: 12px 8px;
|
| 243 |
+
background: var(--panel);
|
| 244 |
+
border: 1px solid var(--line);
|
| 245 |
+
border-radius: 14px;
|
| 246 |
+
color: var(--text);
|
| 247 |
+
cursor: pointer;
|
| 248 |
+
}
|
| 249 |
+
.chart-cell:active { transform: scale(0.96); }
|
| 250 |
+
.cell-char { font-size: 22px; font-weight: 800; }
|
| 251 |
+
.chart-cell.ping { animation: ping 0.4s ease; border-color: var(--accent); }
|
| 252 |
+
@keyframes ping { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent); } 100% { box-shadow: 0 0 0 14px transparent; } }
|
| 253 |
+
.legend { display: flex; gap: 18px; flex-wrap: wrap; color: var(--muted); font-size: 14px; }
|
| 254 |
+
.legend-item { display: inline-flex; align-items: center; gap: 8px; }
|
| 255 |
+
|
| 256 |
+
/* ─── Settings sheet / overlay ────────────────────────────── */
|
| 257 |
+
.overlay {
|
| 258 |
+
position: fixed; inset: 0; z-index: 50;
|
| 259 |
+
background: rgba(0, 0, 0, 0.5);
|
| 260 |
+
display: flex; align-items: flex-end; justify-content: center;
|
| 261 |
+
}
|
| 262 |
+
.sheet {
|
| 263 |
+
width: 100%;
|
| 264 |
+
max-width: 720px;
|
| 265 |
+
max-height: 88dvh;
|
| 266 |
+
overflow-y: auto;
|
| 267 |
+
background: var(--bg-soft);
|
| 268 |
+
border-radius: 22px 22px 0 0;
|
| 269 |
+
box-shadow: var(--shadow);
|
| 270 |
+
padding: 16px 18px calc(28px + env(safe-area-inset-bottom));
|
| 271 |
+
}
|
| 272 |
+
.sheet-head { display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; background: var(--bg-soft); padding-bottom: 8px; }
|
| 273 |
+
.settings-body { display: flex; flex-direction: column; gap: 14px; }
|
| 274 |
+
.version { text-align: center; font-size: 12px; margin-top: 6px; }
|
| 275 |
+
|
| 276 |
+
/* ─── Toast ───────────────────────────────────────────────── */
|
| 277 |
+
.toast {
|
| 278 |
+
position: fixed; left: 50%; bottom: 110px; transform: translateX(-50%) translateY(20px);
|
| 279 |
+
background: var(--panel-2); color: var(--text);
|
| 280 |
+
border: 1px solid var(--line);
|
| 281 |
+
padding: 12px 18px; border-radius: 12px;
|
| 282 |
+
box-shadow: var(--shadow);
|
| 283 |
+
opacity: 0; pointer-events: none; transition: all 0.25s ease;
|
| 284 |
+
z-index: 100; max-width: 90vw; text-align: center;
|
| 285 |
+
}
|
| 286 |
+
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
| 287 |
+
|
| 288 |
+
@media (min-width: 560px) {
|
| 289 |
+
.tab-label { font-size: 13px; }
|
| 290 |
+
}
|
tests/e2e/smoke.spec.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test, expect } from "@playwright/test";
|
| 2 |
+
|
| 3 |
+
// Fatal = the module graph failed to load (bad import URL, syntax error,
|
| 4 |
+
// missing global). We tolerate runtime warnings the host shell may emit
|
| 5 |
+
// before sign-in, but not these.
|
| 6 |
+
const FATAL = /(SyntaxError|ReferenceError|is not defined|Failed to fetch dynamically imported module|Unexpected token)/;
|
| 7 |
+
|
| 8 |
+
test("standalone page loads the module graph (host shell mounts, no fatal errors)", async ({ page }) => {
|
| 9 |
+
const fatals = [];
|
| 10 |
+
page.on("pageerror", (e) => { if (FATAL.test(String(e))) fatals.push(String(e)); });
|
| 11 |
+
page.on("console", (m) => { if (m.type() === "error" && FATAL.test(m.text())) fatals.push(m.text()); });
|
| 12 |
+
|
| 13 |
+
await page.goto("/");
|
| 14 |
+
|
| 15 |
+
// mountHost() renders the host shell (sign-in / picker) into #root.
|
| 16 |
+
// We only assert the module graph executed far enough to populate it.
|
| 17 |
+
await expect.poll(
|
| 18 |
+
async () => page.locator("#root").evaluate((n) => n.childElementCount),
|
| 19 |
+
{ timeout: 30_000 },
|
| 20 |
+
).toBeGreaterThan(0);
|
| 21 |
+
|
| 22 |
+
expect(fatals, `fatal load errors:\n${fatals.join("\n")}`).toEqual([]);
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
test("embedded boot path imports cleanly (no module-load error)", async ({ page }) => {
|
| 26 |
+
const fatals = [];
|
| 27 |
+
page.on("pageerror", (e) => { if (FATAL.test(String(e))) fatals.push(String(e)); });
|
| 28 |
+
|
| 29 |
+
// ?embedded=1 runs bootEmbed(): connectToHost() will not complete without
|
| 30 |
+
// a host, but importing it + our whole module graph must not throw.
|
| 31 |
+
await page.goto("/?embedded=1");
|
| 32 |
+
await page.waitForTimeout(4000);
|
| 33 |
+
expect(fatals, `fatal load errors:\n${fatals.join("\n")}`).toEqual([]);
|
| 34 |
+
});
|
tests/stubs/animation-helpers.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Test stub for lib/animation-helpers.js (the SDK /animation re-export).
|
| 2 |
+
// Unit tests never drive the robot; these are inert placeholders.
|
| 3 |
+
export const INIT_POSE = {};
|
| 4 |
+
export const INIT_ANTENNAS_RAD = [0, 0];
|
| 5 |
+
export const DEFAULT_SCALED_DURATION_PRESET = {};
|
| 6 |
+
export function safelyReturnToPose() {}
|
| 7 |
+
export function scaledDuration() {
|
| 8 |
+
return { duration: 1 };
|
| 9 |
+
}
|
| 10 |
+
export function installShutdownHandler() {}
|
tests/unit/detector.test.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from "vitest";
|
| 2 |
+
import { detectTransientOnsets, highpass, energyFlux } from "../../lib/detector.js";
|
| 3 |
+
import { textToSchedule, decodeOnsets } from "../../lib/wire.js";
|
| 4 |
+
import { makeTiming } from "../../lib/timing.js";
|
| 5 |
+
|
| 6 |
+
const SR = 48000;
|
| 7 |
+
const T = makeTiming(120);
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Render an onset schedule (ms) into a synthetic mono buffer.
|
| 11 |
+
* Each impulse is a short decaying ~4 kHz burst (sharp click, energy well
|
| 12 |
+
* above the 2 kHz highpass). Optionally add a low 60 Hz hum + deterministic
|
| 13 |
+
* pseudo-noise to prove the highpass + flux stages are doing real work.
|
| 14 |
+
*/
|
| 15 |
+
function renderClicks(onsetsMs, { leadMs = 60, tailMs = 300, hum = 0, noise = 0 } = {}) {
|
| 16 |
+
// Lead-in silence: a real listener is already recording before the
|
| 17 |
+
// sender starts, so the very first click has a low-energy window before
|
| 18 |
+
// it for the rising-edge detector to key off.
|
| 19 |
+
const durMs = leadMs + (onsetsMs.length ? onsetsMs[onsetsMs.length - 1] : 0) + tailMs;
|
| 20 |
+
const n = Math.ceil((durMs / 1000) * SR);
|
| 21 |
+
const buf = new Float32Array(n);
|
| 22 |
+
const clickLen = Math.round(0.01 * SR); // 10 ms
|
| 23 |
+
const tau = 0.0015; // 1.5 ms decay
|
| 24 |
+
for (const t0 of onsetsMs) {
|
| 25 |
+
const start = Math.round(((t0 + leadMs) / 1000) * SR);
|
| 26 |
+
for (let j = 0; j < clickLen && start + j < n; j++) {
|
| 27 |
+
const tt = j / SR;
|
| 28 |
+
buf[start + j] += 0.6 * Math.exp(-tt / tau) * Math.sin(2 * Math.PI * 4000 * tt);
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
if (hum || noise) {
|
| 32 |
+
for (let i = 0; i < n; i++) {
|
| 33 |
+
const tt = i / SR;
|
| 34 |
+
if (hum) buf[i] += hum * Math.sin(2 * Math.PI * 60 * tt);
|
| 35 |
+
// deterministic low-level "noise": sum of a few incommensurate tones
|
| 36 |
+
if (noise) {
|
| 37 |
+
buf[i] += noise * (
|
| 38 |
+
Math.sin(2 * Math.PI * 137 * tt) +
|
| 39 |
+
Math.sin(2 * Math.PI * 311 * tt)
|
| 40 |
+
) * 0.5;
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
return buf;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
describe("highpass + energyFlux primitives", () => {
|
| 48 |
+
it("highpass removes a strong 60 Hz hum", () => {
|
| 49 |
+
const n = SR; // 1 s
|
| 50 |
+
const hum = new Float32Array(n);
|
| 51 |
+
for (let i = 0; i < n; i++) hum[i] = Math.sin(2 * Math.PI * 60 * (i / SR));
|
| 52 |
+
const filtered = highpass(hum, SR, 2000);
|
| 53 |
+
let rmsIn = 0, rmsOut = 0;
|
| 54 |
+
for (let i = 0; i < n; i++) { rmsIn += hum[i] ** 2; rmsOut += filtered[i] ** 2; }
|
| 55 |
+
expect(Math.sqrt(rmsOut / n)).toBeLessThan(Math.sqrt(rmsIn / n) * 0.05);
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
it("energyFlux returns positive spikes around clicks", () => {
|
| 59 |
+
const buf = renderClicks([100, 500]);
|
| 60 |
+
const { flux } = energyFlux(highpass(buf, SR, 2000), SR, 5);
|
| 61 |
+
let maxFlux = 0;
|
| 62 |
+
for (const f of flux) if (f > maxFlux) maxFlux = f;
|
| 63 |
+
expect(maxFlux).toBeGreaterThan(0);
|
| 64 |
+
});
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
describe("detectTransientOnsets — count + timing", () => {
|
| 68 |
+
it("finds all 12 impulses of SOS within 25 ms", () => {
|
| 69 |
+
const { onsets } = textToSchedule("SOS", T);
|
| 70 |
+
const detected = detectTransientOnsets(renderClicks(onsets), SR);
|
| 71 |
+
expect(detected.length).toBe(onsets.length);
|
| 72 |
+
// Compare spacing relative to the first onset (absorbs the constant
|
| 73 |
+
// lead-in offset + any fixed detection latency).
|
| 74 |
+
for (let i = 0; i < onsets.length; i++) {
|
| 75 |
+
const exp = onsets[i] - onsets[0];
|
| 76 |
+
const got = detected[i] - detected[0];
|
| 77 |
+
expect(Math.abs(got - exp)).toBeLessThan(25);
|
| 78 |
+
}
|
| 79 |
+
});
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
describe("end-to-end: text -> clicks -> detect -> decode", () => {
|
| 83 |
+
const phrases = ["SOS", "HI", "HELLO WORLD", "CQ CQ"];
|
| 84 |
+
for (const text of phrases) {
|
| 85 |
+
it(`recovers "${text}" from clean synthetic audio`, () => {
|
| 86 |
+
const { onsets } = textToSchedule(text, T);
|
| 87 |
+
const detected = detectTransientOnsets(renderClicks(onsets), SR);
|
| 88 |
+
expect(decodeOnsets(detected, T).text).toBe(text);
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
it(`recovers "${text}" through 60 Hz hum + tonal noise`, () => {
|
| 92 |
+
const { onsets } = textToSchedule(text, T);
|
| 93 |
+
const buf = renderClicks(onsets, { hum: 0.15, noise: 0.03 });
|
| 94 |
+
const detected = detectTransientOnsets(buf, SR);
|
| 95 |
+
expect(decodeOnsets(detected, T).text).toBe(text);
|
| 96 |
+
});
|
| 97 |
+
}
|
| 98 |
+
});
|
tests/unit/morse.test.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
textToMorse,
|
| 4 |
+
morseToText,
|
| 5 |
+
morseToTokens,
|
| 6 |
+
isEncodable,
|
| 7 |
+
} from "../../lib/morse.js";
|
| 8 |
+
|
| 9 |
+
describe("textToMorse", () => {
|
| 10 |
+
it("encodes SOS", () => {
|
| 11 |
+
expect(textToMorse("SOS").morse).toBe("... --- ...");
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
it("is case-insensitive", () => {
|
| 15 |
+
expect(textToMorse("sos").morse).toBe("... --- ...");
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
it("separates words with /", () => {
|
| 19 |
+
expect(textToMorse("HI YOU").morse).toBe(".... .. / -.-- --- ..-");
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
it("encodes digits and punctuation", () => {
|
| 23 |
+
expect(textToMorse("E1?").morse).toBe(". .---- ..--..");
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
it("reports skipped (non-encodable) characters", () => {
|
| 27 |
+
const r = textToMorse("Aé😀");
|
| 28 |
+
expect(r.morse).toBe(".-");
|
| 29 |
+
expect(r.skipped).toContain("É");
|
| 30 |
+
});
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
describe("morseToText", () => {
|
| 34 |
+
it("decodes SOS", () => {
|
| 35 |
+
expect(morseToText("... --- ...")).toBe("SOS");
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
it("decodes multi-word with /", () => {
|
| 39 |
+
expect(morseToText(".... .. / -.-- --- ..-")).toBe("HI YOU");
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
it("round-trips a sentence", () => {
|
| 43 |
+
const text = "HELLO WORLD 123";
|
| 44 |
+
expect(morseToText(textToMorse(text).morse)).toBe(text);
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
it("maps unknown pattern to ?", () => {
|
| 48 |
+
expect(morseToText("........")).toBe("?");
|
| 49 |
+
});
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
describe("morseToTokens", () => {
|
| 53 |
+
it("flattens A (.-) into dot, elemGap, dah", () => {
|
| 54 |
+
expect(morseToTokens(".-")).toEqual(["dot", "elemGap", "dah"]);
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
it("inserts letterGap between letters and wordGap between words", () => {
|
| 58 |
+
// "E E / E" -> dot, letterGap, dot, wordGap, dot
|
| 59 |
+
expect(morseToTokens(". . / .")).toEqual([
|
| 60 |
+
"dot", "letterGap", "dot", "wordGap", "dot",
|
| 61 |
+
]);
|
| 62 |
+
});
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
describe("isEncodable", () => {
|
| 66 |
+
it("accepts letters/digits, rejects emoji", () => {
|
| 67 |
+
expect(isEncodable("a")).toBe(true);
|
| 68 |
+
expect(isEncodable("7")).toBe(true);
|
| 69 |
+
expect(isEncodable("😀")).toBe(false);
|
| 70 |
+
});
|
| 71 |
+
});
|
tests/unit/views.test.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// @vitest-environment jsdom
|
| 2 |
+
import { describe, it, expect, vi } from "vitest";
|
| 3 |
+
import { makeTiming, SPEED_PRESETS } from "../../lib/timing.js";
|
| 4 |
+
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 {
|
| 21 |
+
reachy: null,
|
| 22 |
+
state,
|
| 23 |
+
timing: () => makeTiming(state.unitMs),
|
| 24 |
+
setSpeed: (p) => { state.speed = p; if (SPEED_PRESETS[p]) state.unitMs = SPEED_PRESETS[p]; },
|
| 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,
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
describe("Compose view", () => {
|
| 35 |
+
it("renders Morse chips for the default message", () => {
|
| 36 |
+
const ctx = makeCtx();
|
| 37 |
+
const { node } = createCompose(ctx);
|
| 38 |
+
const input = node.querySelector(".compose-input");
|
| 39 |
+
expect(input.value).toBe("SOS");
|
| 40 |
+
// SOS = 3 + 3 + 3 = 9 symbol chips
|
| 41 |
+
expect(node.querySelectorAll(".morse-out .pattern").length).toBe(3);
|
| 42 |
+
expect(node.querySelectorAll(".morse-out .dot, .morse-out .dash").length).toBe(9);
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it("updates preview on input and warns on non-encodable chars", () => {
|
| 46 |
+
const ctx = makeCtx();
|
| 47 |
+
const { node } = createCompose(ctx);
|
| 48 |
+
const input = node.querySelector(".compose-input");
|
| 49 |
+
input.value = "Aé";
|
| 50 |
+
input.dispatchEvent(new window.Event("input"));
|
| 51 |
+
expect(node.querySelector(".warn").hidden).toBe(false);
|
| 52 |
+
// 'A' -> .- = 2 chips
|
| 53 |
+
expect(node.querySelectorAll(".morse-out .dot, .morse-out .dash").length).toBe(2);
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
it("plays via synth when emitter is device", () => {
|
| 57 |
+
const ctx = makeCtx();
|
| 58 |
+
const { node } = createCompose(ctx);
|
| 59 |
+
node.querySelector(".btn.primary").click();
|
| 60 |
+
expect(ctx._synth.play).toHaveBeenCalled();
|
| 61 |
+
});
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
describe("Learn view", () => {
|
| 65 |
+
it("renders a tile per letter, digit and punctuation, and plays on tap", () => {
|
| 66 |
+
const ctx = makeCtx();
|
| 67 |
+
const { node } = createLearn(ctx);
|
| 68 |
+
const cells = node.querySelectorAll(".chart-cell");
|
| 69 |
+
expect(cells.length).toBe(26 + 10 + 12);
|
| 70 |
+
cells[0].click(); // 'A'
|
| 71 |
+
expect(ctx._synth.play).toHaveBeenCalled();
|
| 72 |
+
});
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
describe("Settings view", () => {
|
| 76 |
+
it("renders calibration sliders and mutates config", () => {
|
| 77 |
+
const ctx = makeCtx();
|
| 78 |
+
const { node } = createSettings(ctx);
|
| 79 |
+
const ranges = node.querySelectorAll("input.range");
|
| 80 |
+
expect(ranges.length).toBeGreaterThanOrEqual(8);
|
| 81 |
+
// first range is the speed unit
|
| 82 |
+
ranges[0].value = "200";
|
| 83 |
+
ranges[0].dispatchEvent(new window.Event("input"));
|
| 84 |
+
expect(ctx.state.unitMs).toBe(200);
|
| 85 |
+
});
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
describe("Listen view", () => {
|
| 89 |
+
it("builds without a mic and shows placeholder decode", () => {
|
| 90 |
+
const ctx = makeCtx();
|
| 91 |
+
const { node } = createListen(ctx);
|
| 92 |
+
expect(node.querySelector(".scope")).toBeTruthy();
|
| 93 |
+
expect(node.querySelector(".decoded-text").textContent).toBe("…");
|
| 94 |
+
expect(node.querySelector(".btn.primary").textContent).toMatch(/Start listening/);
|
| 95 |
+
});
|
| 96 |
+
});
|
tests/unit/wire.test.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
tokensToOnsets,
|
| 4 |
+
textToSchedule,
|
| 5 |
+
onsetsToMorse,
|
| 6 |
+
decodeOnsets,
|
| 7 |
+
} from "../../lib/wire.js";
|
| 8 |
+
import { morseToTokens } from "../../lib/morse.js";
|
| 9 |
+
import { makeTiming } from "../../lib/timing.js";
|
| 10 |
+
|
| 11 |
+
const T = makeTiming(120); // dah=240 elem=480 letter=960 word=1680
|
| 12 |
+
|
| 13 |
+
describe("tokensToOnsets", () => {
|
| 14 |
+
it("dot = 1 impulse", () => {
|
| 15 |
+
expect(tokensToOnsets(["dot"], T)).toEqual([0]);
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
it("dash = 2 impulses spaced by dahGap", () => {
|
| 19 |
+
expect(tokensToOnsets(["dah"], T)).toEqual([0, T.dahGapMs]);
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
it("A (.-) spaces the dash element after elemGap from the dot", () => {
|
| 23 |
+
// dot@0, elemGap, dah@(elem), dah2@(elem+dah)
|
| 24 |
+
expect(tokensToOnsets(morseToTokens(".-"), T)).toEqual([
|
| 25 |
+
0,
|
| 26 |
+
T.elemGapMs,
|
| 27 |
+
T.elemGapMs + T.dahGapMs,
|
| 28 |
+
]);
|
| 29 |
+
});
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
describe("onsetsToMorse round-trip (ideal timing)", () => {
|
| 33 |
+
const samples = ["SOS", "HELLO WORLD", "HI YOU", "CQ CQ", "E T 1 9", "ABCDE"];
|
| 34 |
+
for (const text of samples) {
|
| 35 |
+
it(`recovers "${text}"`, () => {
|
| 36 |
+
const { morse, onsets } = textToSchedule(text, T);
|
| 37 |
+
expect(onsetsToMorse(onsets, T)).toBe(morse);
|
| 38 |
+
expect(decodeOnsets(onsets, T).text).toBe(text);
|
| 39 |
+
});
|
| 40 |
+
}
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
describe("onsetsToMorse with timing jitter", () => {
|
| 44 |
+
// Deterministic pseudo-jitter (no Math.random): perturb each onset by a
|
| 45 |
+
// bounded sawtooth. Should still decode because gap classes are well
|
| 46 |
+
// separated (geometric midpoints).
|
| 47 |
+
function jitter(onsets, maxMs) {
|
| 48 |
+
return onsets.map((t, i) => t + (((i * 37) % 11) - 5) / 5 * maxMs);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
it("decodes SOS with +-30ms jitter", () => {
|
| 52 |
+
const { onsets, text } = { ...textToSchedule("SOS", T) };
|
| 53 |
+
const jittered = jitter(onsets, 30);
|
| 54 |
+
expect(decodeOnsets(jittered, T).text).toBe("SOS");
|
| 55 |
+
expect(text).toBeUndefined; // sanity: textToSchedule has no .text
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
it("decodes a sentence with +-40ms jitter", () => {
|
| 59 |
+
const { onsets } = textToSchedule("HELLO WORLD", T);
|
| 60 |
+
expect(decodeOnsets(jitter(onsets, 40), T).text).toBe("HELLO WORLD");
|
| 61 |
+
});
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
describe("edge cases", () => {
|
| 65 |
+
it("empty onsets -> empty", () => {
|
| 66 |
+
expect(onsetsToMorse([], T)).toBe("");
|
| 67 |
+
expect(decodeOnsets([], T)).toEqual({ morse: "", text: "" });
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
it("single impulse -> E", () => {
|
| 71 |
+
expect(decodeOnsets([0], T).text).toBe("E");
|
| 72 |
+
});
|
| 73 |
+
});
|
views/compose.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Compose view: type a message, see its Morse, pick who transmits (the robot's
|
| 3 |
+
* antennas or this device's speaker), and send it.
|
| 4 |
+
*/
|
| 5 |
+
import { el, clear, morsePattern } from "./dom.js";
|
| 6 |
+
import { textToMorse, morseToTokens } from "../lib/morse.js";
|
| 7 |
+
import { tokensToOnsets } from "../lib/wire.js";
|
| 8 |
+
|
| 9 |
+
export function createCompose(ctx) {
|
| 10 |
+
const root = el("section.view#view-compose");
|
| 11 |
+
|
| 12 |
+
const input = el("textarea.compose-input", {
|
| 13 |
+
placeholder: "Type a message… e.g. SOS",
|
| 14 |
+
rows: 3,
|
| 15 |
+
value: "SOS",
|
| 16 |
+
oninput: refresh,
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
const morseOut = el("div.morse-out");
|
| 20 |
+
const warn = el("div.warn", { hidden: true });
|
| 21 |
+
const duration = el("span.muted");
|
| 22 |
+
|
| 23 |
+
const emitter = segmented(
|
| 24 |
+
"Who sends it?",
|
| 25 |
+
[
|
| 26 |
+
{ value: "robot", label: "🤖 Robot antennas" },
|
| 27 |
+
{ value: "device", label: "📱 This device" },
|
| 28 |
+
],
|
| 29 |
+
ctx.state.emitter,
|
| 30 |
+
(v) => { ctx.state.emitter = v; refresh(); },
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
const speed = segmented(
|
| 34 |
+
"Speed",
|
| 35 |
+
[
|
| 36 |
+
{ value: "relaxed", label: "Relaxed" },
|
| 37 |
+
{ value: "normal", label: "Normal" },
|
| 38 |
+
{ value: "brisk", label: "Brisk" },
|
| 39 |
+
],
|
| 40 |
+
ctx.state.speed,
|
| 41 |
+
(v) => { ctx.setSpeed(v); refresh(); },
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
const sendBtn = el("button.btn.primary.big", { onclick: send }, "Send ▶");
|
| 45 |
+
const stopBtn = el("button.btn.ghost", { onclick: stop, hidden: true }, "Stop ◼");
|
| 46 |
+
const status = el("div.status muted");
|
| 47 |
+
|
| 48 |
+
let cancelFn = null;
|
| 49 |
+
|
| 50 |
+
function currentSchedule() {
|
| 51 |
+
const { morse, skipped } = textToMorse(input.value);
|
| 52 |
+
const onsets = tokensToOnsets(morseToTokens(morse), ctx.timing());
|
| 53 |
+
return { morse, skipped, onsets };
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function refresh() {
|
| 57 |
+
const { morse, skipped, onsets } = currentSchedule();
|
| 58 |
+
clear(morseOut);
|
| 59 |
+
if (!morse) {
|
| 60 |
+
morseOut.append(el("span.muted", {}, "Your Morse will appear here."));
|
| 61 |
+
} else {
|
| 62 |
+
morse.split(" / ").forEach((word, wi) => {
|
| 63 |
+
if (wi > 0) morseOut.append(el("span.wordsep", {}, "/"));
|
| 64 |
+
word.split(" ").forEach((p) => morseOut.append(morsePattern(p)));
|
| 65 |
+
});
|
| 66 |
+
}
|
| 67 |
+
if (skipped.length) {
|
| 68 |
+
warn.hidden = false;
|
| 69 |
+
warn.textContent = `Can't send in Morse: ${[...new Set(skipped)].join(" ")}`;
|
| 70 |
+
} else {
|
| 71 |
+
warn.hidden = true;
|
| 72 |
+
}
|
| 73 |
+
const secs = onsets.length ? (onsets[onsets.length - 1] / 1000 + 0.3) : 0;
|
| 74 |
+
duration.textContent = onsets.length ? `${onsets.length} taps · ~${secs.toFixed(1)}s` : "";
|
| 75 |
+
// reflect speed selection if changed elsewhere
|
| 76 |
+
speed.setValue(ctx.state.speed);
|
| 77 |
+
emitter.setValue(ctx.state.emitter);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
async function send() {
|
| 81 |
+
const { onsets } = currentSchedule();
|
| 82 |
+
if (!onsets.length) return;
|
| 83 |
+
setSending(true);
|
| 84 |
+
const controller = new AbortController();
|
| 85 |
+
const highlight = (i) => flashTap(i, onsets.length);
|
| 86 |
+
|
| 87 |
+
try {
|
| 88 |
+
if (ctx.state.emitter === "robot") {
|
| 89 |
+
if (!ctx.reachy) { ctx.toast("No robot connected."); return; }
|
| 90 |
+
cancelFn = () => controller.abort();
|
| 91 |
+
await ctx.tapper().tap(onsets, { onTap: highlight, signal: controller.signal });
|
| 92 |
+
} else {
|
| 93 |
+
await new Promise((resolve) => {
|
| 94 |
+
const cancel = ctx.synth().play(onsets, {
|
| 95 |
+
onBlip: highlight,
|
| 96 |
+
onDone: resolve,
|
| 97 |
+
clickMs: ctx.timing().clickMs,
|
| 98 |
+
});
|
| 99 |
+
cancelFn = () => { cancel(); resolve(); };
|
| 100 |
+
});
|
| 101 |
+
}
|
| 102 |
+
} catch (e) {
|
| 103 |
+
ctx.toast(`Send failed: ${e?.message || e}`);
|
| 104 |
+
} finally {
|
| 105 |
+
setSending(false);
|
| 106 |
+
cancelFn = null;
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
function stop() {
|
| 111 |
+
cancelFn?.();
|
| 112 |
+
ctx.synth().stop();
|
| 113 |
+
ctx.tapper()?.stop?.();
|
| 114 |
+
setSending(false);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
function setSending(on) {
|
| 118 |
+
sendBtn.hidden = on;
|
| 119 |
+
stopBtn.hidden = !on;
|
| 120 |
+
input.disabled = on;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function flashTap(i, total) {
|
| 124 |
+
status.textContent = `Transmitting… ${i + 1}/${total}`;
|
| 125 |
+
if (i + 1 >= total) setTimeout(() => { status.textContent = "Sent ✓"; }, 200);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
root.append(
|
| 129 |
+
el("h2", {}, "Compose"),
|
| 130 |
+
el("label.field-label", {}, "Message"),
|
| 131 |
+
input,
|
| 132 |
+
el("div.row.between", {}, [el("span.muted", {}, "Morse preview"), duration]),
|
| 133 |
+
morseOut,
|
| 134 |
+
warn,
|
| 135 |
+
emitter.node,
|
| 136 |
+
speed.node,
|
| 137 |
+
el("div.row.send-row", {}, [sendBtn, stopBtn]),
|
| 138 |
+
status,
|
| 139 |
+
);
|
| 140 |
+
|
| 141 |
+
refresh();
|
| 142 |
+
return { node: root, onShow: refresh };
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
function segmented(label, options, value, onChange) {
|
| 146 |
+
const btns = new Map();
|
| 147 |
+
const group = el("div.segmented");
|
| 148 |
+
options.forEach((o) => {
|
| 149 |
+
const b = el("button.seg", {
|
| 150 |
+
onclick: () => { setValue(o.value); onChange(o.value); },
|
| 151 |
+
}, o.label);
|
| 152 |
+
btns.set(o.value, b);
|
| 153 |
+
group.append(b);
|
| 154 |
+
});
|
| 155 |
+
function setValue(v) {
|
| 156 |
+
value = v;
|
| 157 |
+
btns.forEach((b, k) => b.classList.toggle("active", k === v));
|
| 158 |
+
}
|
| 159 |
+
setValue(value);
|
| 160 |
+
return {
|
| 161 |
+
node: el("div.field", {}, [el("label.field-label", {}, label), group]),
|
| 162 |
+
setValue,
|
| 163 |
+
};
|
| 164 |
+
}
|
views/dom.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Tiny DOM helpers (no framework). */
|
| 2 |
+
|
| 3 |
+
/** el("button.cls#id", {onclick, ...attrs}, [children|string]) */
|
| 4 |
+
export function el(spec, attrs = {}, children = []) {
|
| 5 |
+
const m = spec.match(/^([a-z0-9]+)?(#[\w-]+)?((?:\.[\w-]+)*)$/i) || [];
|
| 6 |
+
const tag = m[1] || "div";
|
| 7 |
+
const node = document.createElement(tag);
|
| 8 |
+
if (m[2]) node.id = m[2].slice(1);
|
| 9 |
+
if (m[3]) node.className = m[3].split(".").filter(Boolean).join(" ");
|
| 10 |
+
for (const [k, v] of Object.entries(attrs)) {
|
| 11 |
+
if (v == null || v === false) continue;
|
| 12 |
+
if (k.startsWith("on") && typeof v === "function") {
|
| 13 |
+
node.addEventListener(k.slice(2).toLowerCase(), v);
|
| 14 |
+
} else if (k === "class") {
|
| 15 |
+
node.className = node.className ? `${node.className} ${v}` : v;
|
| 16 |
+
} else if (k === "html") {
|
| 17 |
+
node.innerHTML = v;
|
| 18 |
+
} else if (k in node && k !== "list") {
|
| 19 |
+
try { node[k] = v; } catch { node.setAttribute(k, v); }
|
| 20 |
+
} else {
|
| 21 |
+
node.setAttribute(k, v);
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
const kids = Array.isArray(children) ? children : [children];
|
| 25 |
+
for (const c of kids) {
|
| 26 |
+
if (c == null || c === false) continue;
|
| 27 |
+
node.append(c.nodeType ? c : document.createTextNode(String(c)));
|
| 28 |
+
}
|
| 29 |
+
return node;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export function clear(node) {
|
| 33 |
+
while (node.firstChild) node.removeChild(node.firstChild);
|
| 34 |
+
return node;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/** Render a morse pattern string ('.-') as styled dot/dash chips. */
|
| 38 |
+
export function morsePattern(pattern) {
|
| 39 |
+
const wrap = el("span.pattern");
|
| 40 |
+
for (const ch of pattern) {
|
| 41 |
+
wrap.append(el(`span.${ch === "." ? "dot" : "dash"}`));
|
| 42 |
+
}
|
| 43 |
+
return wrap;
|
| 44 |
+
}
|
views/learn.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Learn view: the letter ↔ rhythm chart. Tap any entry to hear (or have the
|
| 3 |
+
* robot tap) its rhythm — the whole point is that a letter *is* a rhythm.
|
| 4 |
+
*/
|
| 5 |
+
import { el, clear, morsePattern } from "./dom.js";
|
| 6 |
+
import { CHAR_TO_MORSE, morseToTokens } from "../lib/morse.js";
|
| 7 |
+
import { tokensToOnsets } from "../lib/wire.js";
|
| 8 |
+
|
| 9 |
+
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
| 10 |
+
const DIGITS = "0123456789".split("");
|
| 11 |
+
const PUNCT = [".", ",", "?", "/", "@", "=", "+", "-", "!", "'", "(", ")"];
|
| 12 |
+
|
| 13 |
+
export function createLearn(ctx) {
|
| 14 |
+
const root = el("section.view#view-learn");
|
| 15 |
+
|
| 16 |
+
function playChar(ch) {
|
| 17 |
+
const pattern = CHAR_TO_MORSE[ch];
|
| 18 |
+
if (!pattern) return;
|
| 19 |
+
const onsets = tokensToOnsets(morseToTokens(pattern), ctx.timing());
|
| 20 |
+
if (ctx.state.emitter === "robot" && ctx.reachy) {
|
| 21 |
+
ctx.tapper().tap(onsets);
|
| 22 |
+
} else {
|
| 23 |
+
ctx.synth().play(onsets, { clickMs: ctx.timing().clickMs });
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function grid(title, chars) {
|
| 28 |
+
const g = el("div.chart-grid");
|
| 29 |
+
chars.forEach((ch) => {
|
| 30 |
+
const cell = el("button.chart-cell", { onclick: () => {
|
| 31 |
+
playChar(ch);
|
| 32 |
+
cell.classList.remove("ping"); void cell.offsetWidth; cell.classList.add("ping");
|
| 33 |
+
} }, [
|
| 34 |
+
el("span.cell-char", {}, ch),
|
| 35 |
+
morsePattern(CHAR_TO_MORSE[ch]),
|
| 36 |
+
]);
|
| 37 |
+
g.append(cell);
|
| 38 |
+
});
|
| 39 |
+
return el("div.chart-section", {}, [el("h3", {}, title), g]);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const emitterNote = el("div.status muted");
|
| 43 |
+
function refresh() {
|
| 44 |
+
emitterNote.textContent = ctx.state.emitter === "robot" && ctx.reachy
|
| 45 |
+
? "Tapping plays on the robot's antennas."
|
| 46 |
+
: "Tapping plays a beep on this device. (Switch sender in Compose.)";
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
root.append(
|
| 50 |
+
el("h2", {}, "Learn Morse"),
|
| 51 |
+
el("p.muted", {}, "A letter is just a rhythm of short (·) and long (—) beats. Tap any tile to feel it."),
|
| 52 |
+
emitterNote,
|
| 53 |
+
grid("Letters", LETTERS),
|
| 54 |
+
grid("Numbers", DIGITS),
|
| 55 |
+
grid("Punctuation", PUNCT),
|
| 56 |
+
el("div.legend", {}, [
|
| 57 |
+
el("span.legend-item", {}, [morsePattern("."), " dot — 1 tap"]),
|
| 58 |
+
el("span.legend-item", {}, [morsePattern("-"), " dash — quick double-tap"]),
|
| 59 |
+
]),
|
| 60 |
+
);
|
| 61 |
+
|
| 62 |
+
refresh();
|
| 63 |
+
return { node: root, onShow: refresh };
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
void clear;
|
views/listen.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Listen view: turn on the mic, watch the live scope, and decode incoming
|
| 3 |
+
* clicks/beeps to Morse + text in real time. Works for a tapping robot, a
|
| 4 |
+
* beeping phone, or a second Reachy Mini across the table.
|
| 5 |
+
*/
|
| 6 |
+
import { el, clear } from "./dom.js";
|
| 7 |
+
import { decodeOnsets } from "../lib/wire.js";
|
| 8 |
+
import { Scope } from "../lib/viz.js";
|
| 9 |
+
|
| 10 |
+
export function createListen(ctx) {
|
| 11 |
+
const root = el("section.view#view-listen");
|
| 12 |
+
|
| 13 |
+
const canvas = el("canvas.scope");
|
| 14 |
+
const scope = new Scope(canvas);
|
| 15 |
+
|
| 16 |
+
const liveText = el("div.decoded-text", {}, "—");
|
| 17 |
+
const liveMorse = el("div.decoded-morse muted");
|
| 18 |
+
const transcript = el("div.transcript");
|
| 19 |
+
const startBtn = el("button.btn.primary.big", { onclick: toggle }, "🎙 Start listening");
|
| 20 |
+
const clearBtn = el("button.btn.ghost", { onclick: reset }, "Clear");
|
| 21 |
+
const hint = el("div.status muted", {}, "Point the mic at the robot, then start.");
|
| 22 |
+
|
| 23 |
+
let mic = null;
|
| 24 |
+
let onsets = [];
|
| 25 |
+
let lastOnsetMs = 0;
|
| 26 |
+
let idleTimer = null;
|
| 27 |
+
|
| 28 |
+
function timing() { return ctx.timing(); }
|
| 29 |
+
|
| 30 |
+
async function toggle() {
|
| 31 |
+
if (mic) return stopMic();
|
| 32 |
+
try {
|
| 33 |
+
mic = ctx.makeMic({
|
| 34 |
+
onOnset: (t) => { onOnset(t); },
|
| 35 |
+
onLevel: (lvl) => scope.pushLevel(lvl),
|
| 36 |
+
});
|
| 37 |
+
await mic.start();
|
| 38 |
+
scope.start();
|
| 39 |
+
startBtn.textContent = "◼ Stop listening";
|
| 40 |
+
startBtn.classList.add("listening");
|
| 41 |
+
hint.textContent = "Listening… every detected hit drops a red marker.";
|
| 42 |
+
idleTimer = setInterval(checkIdle, 250);
|
| 43 |
+
} catch (e) {
|
| 44 |
+
ctx.toast(`Mic error: ${e?.message || e}`);
|
| 45 |
+
mic = null;
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
async function stopMic() {
|
| 50 |
+
if (idleTimer) { clearInterval(idleTimer); idleTimer = null; }
|
| 51 |
+
scope.stop();
|
| 52 |
+
if (mic) { await mic.stop(); mic = null; }
|
| 53 |
+
startBtn.textContent = "🎙 Start listening";
|
| 54 |
+
startBtn.classList.remove("listening");
|
| 55 |
+
hint.textContent = "Stopped. Decoded text is kept below.";
|
| 56 |
+
finalize();
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function onOnset(timeMs) {
|
| 60 |
+
onsets.push(timeMs);
|
| 61 |
+
lastOnsetMs = timeMs;
|
| 62 |
+
scope.markOnset();
|
| 63 |
+
renderLive();
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function renderLive() {
|
| 67 |
+
const { morse, text } = decodeOnsets(onsets, timing());
|
| 68 |
+
liveText.textContent = text || "…";
|
| 69 |
+
liveMorse.textContent = morse;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// After a long silence, commit the current decode as a finished line.
|
| 73 |
+
function checkIdle() {
|
| 74 |
+
if (!mic || onsets.length === 0) return;
|
| 75 |
+
const idle = mic.now() - lastOnsetMs;
|
| 76 |
+
if (idle > timing().wordGapMs * 2.5) finalize();
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function finalize() {
|
| 80 |
+
if (onsets.length === 0) return;
|
| 81 |
+
const { morse, text } = decodeOnsets(onsets, timing());
|
| 82 |
+
if (text) {
|
| 83 |
+
transcript.prepend(el("div.line", {}, [
|
| 84 |
+
el("span.line-text", {}, text),
|
| 85 |
+
el("span.line-morse muted", {}, morse),
|
| 86 |
+
]));
|
| 87 |
+
}
|
| 88 |
+
onsets = [];
|
| 89 |
+
renderLive();
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function reset() {
|
| 93 |
+
onsets = [];
|
| 94 |
+
clear(transcript);
|
| 95 |
+
renderLive();
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
renderLive();
|
| 99 |
+
root.append(
|
| 100 |
+
el("h2", {}, "Listen"),
|
| 101 |
+
canvas,
|
| 102 |
+
el("div.row.between", {}, [startBtn, clearBtn]),
|
| 103 |
+
hint,
|
| 104 |
+
el("label.field-label", {}, "Decoding now"),
|
| 105 |
+
liveText,
|
| 106 |
+
liveMorse,
|
| 107 |
+
el("label.field-label", {}, "Transcript"),
|
| 108 |
+
transcript,
|
| 109 |
+
);
|
| 110 |
+
|
| 111 |
+
return {
|
| 112 |
+
node: root,
|
| 113 |
+
onShow: () => {},
|
| 114 |
+
onHide: () => { if (mic) stopMic(); },
|
| 115 |
+
};
|
| 116 |
+
}
|
views/settings.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Settings sheet: speed, beep tone, mic sensitivity, and the robot clap
|
| 3 |
+
* calibration knobs (contact angle / hold / latency lead). These are exactly
|
| 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 |
+
|
| 11 |
+
export function createSettings(ctx) {
|
| 12 |
+
const c = ctx.state;
|
| 13 |
+
|
| 14 |
+
function rangeRow(label, get, set, { min, max, step, fmt = (v) => v }) {
|
| 15 |
+
const out = el("span.range-val", {}, fmt(get()));
|
| 16 |
+
const slider = el("input.range", {
|
| 17 |
+
type: "range", min, max, step, value: get(),
|
| 18 |
+
oninput: (e) => { set(parseFloat(e.target.value)); out.textContent = fmt(get()); },
|
| 19 |
+
});
|
| 20 |
+
return el("div.field", {}, [
|
| 21 |
+
el("div.row.between", {}, [el("label.field-label", {}, label), out]),
|
| 22 |
+
slider,
|
| 23 |
+
]);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const body = el("div.settings-body", {}, [
|
| 27 |
+
el("h3", {}, "Transmission"),
|
| 28 |
+
rangeRow("Speed (unit)", () => c.unitMs, (v) => { c.unitMs = v; c.speed = "custom"; },
|
| 29 |
+
{ min: 60, max: 240, step: 10, fmt: (v) => `${v} ms` }),
|
| 30 |
+
rangeRow("Beep tone", () => c.beepFreq, (v) => { c.beepFreq = v; },
|
| 31 |
+
{ min: 600, max: 4000, step: 50, fmt: (v) => `${v} Hz` }),
|
| 32 |
+
|
| 33 |
+
el("h3", {}, "Microphone / detection"),
|
| 34 |
+
rangeRow("High-pass", () => c.detector.highpassHz, (v) => { c.detector.highpassHz = v; },
|
| 35 |
+
{ min: 500, max: 4000, step: 100, fmt: (v) => `${v} Hz` }),
|
| 36 |
+
rangeRow("Sensitivity", () => c.detector.thresholdFactor,
|
| 37 |
+
(v) => { c.detector.thresholdFactor = v; },
|
| 38 |
+
{ min: 1.5, max: 10, step: 0.5, fmt: (v) => `${v}×` }),
|
| 39 |
+
|
| 40 |
+
el("h3", {}, "Robot clap (calibration)"),
|
| 41 |
+
rangeRow("Contact angle", () => c.tap.collisionDeg, (v) => { c.tap.collisionDeg = v; },
|
| 42 |
+
{ min: 10, max: 55, step: 1, fmt: (v) => `${v}°` }),
|
| 43 |
+
rangeRow("Right antenna", () => c.tap.rightRestDeg, (v) => { c.tap.rightRestDeg = v; },
|
| 44 |
+
{ min: -55, max: 0, step: 1, fmt: (v) => `${v}°` }),
|
| 45 |
+
rangeRow("Hold", () => c.tap.holdMs, (v) => { c.tap.holdMs = v; },
|
| 46 |
+
{ min: 20, max: 150, step: 10, fmt: (v) => `${v} ms` }),
|
| 47 |
+
rangeRow("Latency lead", () => c.tap.leadMs, (v) => { c.tap.leadMs = v; },
|
| 48 |
+
{ min: -50, max: 250, step: 10, fmt: (v) => `${v} ms` }),
|
| 49 |
+
el("div.row", {}, [
|
| 50 |
+
el("button.btn.ghost", { onclick: () => testBeep() }, "Test beep"),
|
| 51 |
+
el("button.btn.ghost", { onclick: () => testClap(), disabled: !ctx.reachy },
|
| 52 |
+
ctx.reachy ? "Test clap" : "Test clap (no robot)"),
|
| 53 |
+
]),
|
| 54 |
+
el("div.gap-preview muted"),
|
| 55 |
+
el("div.version muted", {}, APP_VERSION),
|
| 56 |
+
]);
|
| 57 |
+
|
| 58 |
+
function testBeep() {
|
| 59 |
+
ctx.synth().play([0, makeTiming(c.unitMs).dahGapMs], { clickMs: makeTiming(c.unitMs).clickMs });
|
| 60 |
+
}
|
| 61 |
+
function testClap() {
|
| 62 |
+
if (ctx.reachy) ctx.tapper().testClap();
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
return { node: body };
|
| 66 |
+
}
|
vitest.config.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "vitest/config";
|
| 2 |
+
import { fileURLToPath } from "node:url";
|
| 3 |
+
|
| 4 |
+
// Stub the SDK animation wrapper in unit tests: Node's ESM loader can't
|
| 5 |
+
// resolve `https:` imports, and the unit suite never touches the live SDK.
|
| 6 |
+
// Production loads the real wrapper from jsDelivr.
|
| 7 |
+
const animationStub = fileURLToPath(
|
| 8 |
+
new URL("./tests/stubs/animation-helpers.js", import.meta.url),
|
| 9 |
+
);
|
| 10 |
+
|
| 11 |
+
export default defineConfig({
|
| 12 |
+
test: {
|
| 13 |
+
include: ["tests/unit/**/*.test.js"],
|
| 14 |
+
environment: "node",
|
| 15 |
+
globals: false,
|
| 16 |
+
alias: {
|
| 17 |
+
"./animation-helpers.js": animationStub,
|
| 18 |
+
},
|
| 19 |
+
},
|
| 20 |
+
});
|