diff --git a/.github/ISSUE_TEMPLATE/guide_stuck.yml b/.github/ISSUE_TEMPLATE/guide_stuck.yml index 9249797d9..ff4ae7ccb 100644 --- a/.github/ISSUE_TEMPLATE/guide_stuck.yml +++ b/.github/ISSUE_TEMPLATE/guide_stuck.yml @@ -13,7 +13,7 @@ body: id: where attributes: label: Where in the guide - description: The guide (Build, Play or Make), the chapter and the step. Filled in for you if you came from the guide. + description: The guide (Build, Play, Make or Audio), the chapter and the step. Filled in for you if you came from the guide. placeholder: "Play · 01 Flash, step 5 — Hold BOOT, tap RST, let go." validations: required: true diff --git a/.gitignore b/.gitignore index e6ccd81c7..1302a653c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ out/ # Local staging for a release upload (bins duplicated from the flasher folder). firmware/release/ firmware/patternflow/patternflow_secrets.h +firmware/patternflow/patternflow_secrets.h.shelf-aside # KiCad 자동 생성/백업 *.kicad_prl diff --git a/AGENTS.md b/AGENTS.md index 4181b5dfd..0f2472b8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ It is standalone (not Eurorack), powered from a 5 V power bank through a screw t - `docs/` — contracts and long-form docs: `EDITIONS.md` (the firmware seam — read it before touching firmware), `rest-api.md` / `osc-spec.md` / `midi-spec.md` / `audio-ws-spec.md` (wire contracts integrations are built against), `pfst-v2-spec.md` (show files), `panel-compatibility.md`, `RELEASING.md`, `SERVICES.md` (production server ops), the assembly map (`docs/assembly/`), the manifesto, investigations, and media (build-guide images in `docs/build-guide/`). Root-level guides: `BUILD_GUIDE.md` (the **v3.0 board**), `BUILD_GUIDE_v2.md` (legacy v2.x board), `PATTERN_GUIDE.md` (community → deck → Pattern Lab → device), `FEATURE_GUIDE.md` (writing a firmware feature or edition), `AUDIO_GUIDE.md` (sound into the panel). The live roadmap is patternflow.work/journal and the `/roadmap` page. - `firmware/` — PlatformIO/Arduino code for the ESP32-S3. Main sketch folder: `firmware/patternflow/` with `patternflow.ino`, `config.h` (pin mappings, brightness, limits, LED calibration), `net_config.h` (Wi-Fi / Improv / OTA / self-update defaults and the lane scale — core only; a feature's own tunables live in `features//_config.h`, and settings TUNE a feature, they never add one; per-device secrets in gitignored `patternflow_secrets.h`), and `pattern_registry.h` (function-pointer pattern table; compiles in Origin alone — the rest of the curated set ships as the Basics pack). Shared engine code lives in `firmware/patternflow/src/` (`core_display.h` HUB75 driver, `core_encoders.h`, canvas/color/math/noise helpers, Wi-Fi/OTA/web-console modules, the hotspot in `core_hotspot.h`) — core only: OSC, audio, MQTT, weather, shows, MIDI and the clock all live in `features/`. `firmware/patternflow/abi/` is the frozen host ⇄ module contract. Curated patterns live in `firmware/patternflow/presets/` (`preset_origin.h`, `preset_wave_saw.h`, dated presets), each using its own pattern namespace; user patterns are `.pfm` modules uploaded to the device over Wi-Fi, built from `firmware/modules/` via `firmware/toolchain/build_module.py` (the old `custom1.h`–`custom3.h` root slots are gone). Features attach through `firmware/patternflow/features/` (named `addons/` until 2026-08-30; legacy spellings are shimmed for out-of-tree bundles — see docs/EDITIONS.md) — a directory and a descriptor of function pointers, with the core naming none of them (see its README). `firmware/bundles/` names firmwares built from the same tree (the shelf editions `audio` and `performance`, plus `clock` and `midi`, compositions the tree keeps buildable but does not publish; the default build has no bundle): two files each, saying which features compile in and what the build calls itself; `firmware/bundles/build.sh` builds the default or a named one, `shelf.sh` stages a publishable image. Device console pages are plain HTML in `firmware/patternflow/console/`, spliced into `*_index.h` by `firmware/toolchain/console_pages.py`. Tooling lives in `firmware/toolchain/` (repo-level scripts) and `firmware/patternflow/toolchain/` (PlatformIO `extra_scripts`, which must stay sketch-relative). Also: `firmware/encoder_test/` (standalone encoder diagnostic) and `firmware/CUSTOM_PATTERNS.md` (pattern authoring guide). - `hardware/` — Hardware designs. `case/` (Blender source in `source/`, STLs by printer bed size, `legacy_v2/` for the v2.x board), `pcb/` (KiCad 10.0 source, Gerbers per revision, schematic PDF), `bom/` (`bom_v3.9.csv` — **the BOM source of truth**; the guide's table is derived from it. `bom_v3.0.csv` is kept for the previous board). -- `web/` — Next.js site at patternflow.work: landing page (`/`, `/pattern`, `/build`, `/inside` are tabs of one view), the interactive guide (`/guide` is a hub over `/guide/build`, `/guide/play` and `/guide/make`, each with a `/ko` twin; one 3D stage in `components/guide/`, the firmware simulator in `lib/guide/`), Pattern Lab (`/pattern-lab`), community (`/community/**`, SQLite + Drizzle + Better Auth, only on the Pi deployment), browser flasher (`/flash`), the edition shelf (`/editions`; `/variants` redirects there), the feature catalogue (`/features`, reels per feature), device update handoff (`/update`), journal, roadmap. `lib/pattern/` is the pattern runtime and annotations, `lib/lab/` the Pattern Lab, `lib/community/` the community, `lib/ai/` the Gemini client. Architecture doc: `web/ARCHITECTURE.md`. The JS presets in `web/src/lib/presets/` are the source of truth for firmware preset headers. +- `web/` — Next.js site at patternflow.work: landing page (`/`, `/pattern`, `/build`, `/inside` are tabs of one view), the interactive guide (`/guide` is a hub over `/guide/build`, `/guide/play`, `/guide/make` and `/guide/audio`, each with a `/ko` twin; one 3D stage in `components/guide/`, the firmware simulator in `lib/guide/`), Pattern Lab (`/pattern-lab`), community (`/community/**`, SQLite + Drizzle + Better Auth, only on the Pi deployment), browser flasher (`/flash`), the edition shelf (`/editions`; `/variants` redirects there), the feature catalogue (`/features`, reels per feature), device update handoff (`/update`), journal, roadmap. `lib/pattern/` is the pattern runtime and annotations, `lib/lab/` the Pattern Lab, `lib/community/` the community, `lib/ai/` the Gemini client. Architecture doc: `web/ARCHITECTURE.md`. The JS presets in `web/src/lib/presets/` are the source of truth for firmware preset headers. - `tools/` — desktop-side helpers: `patternflow-audio-extension` (the browser audio-react extension — also the authoring source the device's `/audio-in` console page is assembled from), `patternflow-audio-android` (phone capture app), `rtpmidi-probe`. - `integrations/` — host-software integrations, each with its own README. `integrations/ableton/` is the Max for Live bridge (knobs → Live parameters over OSC). The Home Assistant integration left this repository on 2026-09-03; its author maintains it separately. Integrations are built against the contracts in `docs/` (`rest-api.md`, `osc-spec.md`, `midi-spec.md`), not against the firmware source. `docs/rest-api.md` also has the table for choosing between HTTP, OSC and MQTT, and the rules that make the device's single-connection web server easy to knock over. diff --git a/AUDIO_GUIDE.md b/AUDIO_GUIDE.md index 7893e9270..6460a30e4 100644 --- a/AUDIO_GUIDE.md +++ b/AUDIO_GUIDE.md @@ -6,12 +6,15 @@ know where knob values come from. There are two main ways in, and this guide covers both: the **Chrome extension** (any browser tab becomes the source) and the **on-board -microphone** (a $5 part and four solder joints, and the panel hears the +microphone** (a $5 part and five thin leads, and the panel hears the room with no computer involved). A phone app exists too — it's covered briefly at the end — and if you work in a DAW, the panel is also a **MIDI device** ([its own section](#midi--the-panel-as-a-midi-port)) and speaks OSC ([here](#osc--ableton-today-anything-tomorrow)). +The same ground step by step, on a 3D Patternflow in the browser: +[patternflow.work/guide/audio](https://patternflow.work/guide/audio). + Like the [Feature Guide](FEATURE_GUIDE.md), this page ends with a section written for AI coding agents; everything above it is for people. @@ -92,7 +95,7 @@ until you solder one on and switch it on. | Part | What to look for | Notes | | --- | --- | --- | | PDM MEMS microphone breakout | **Adafruit PDM MEMS Microphone Breakout #3492** (MP34DT01-M), or any clone whose pin row reads **3V · GND · SEL · CLK · DAT** | ~$5. This is the one in the photo. | -| Hookup wire | 4 thin leads, ~10 cm | Shorter is better — see routing note below. | +| Hookup wire | 5 thin leads, ~10 cm | Shorter is better — see routing note below. | **Don't buy these instead:** an **INMP441** or other standard I²S mic (it needs three signal pins — this board has exactly two free), or an analog @@ -100,7 +103,7 @@ electret module (no ADC pin is free at all). It must be **PDM**. ### Wiring -Four leads, five pads: +Five leads, four places on the DevKit (GND takes two): ``` mic breakout ESP32-S3 DevKit @@ -111,11 +114,11 @@ Four leads, five pads: │ CLK ───────┼──┼───────────► TX (GPIO 43) │ DAT ───────┼──┼───────────► RX (GPIO 44) └──────────────┘ │ - └──────────► GND ← SEL goes to ground + └──────────► G (GND) ← SEL goes to ground: the same GND pin ``` -- **Don't forget SEL → GND** — jumper it to the breakout's own GND pad so it - shares the same lead. It selects the LEFT channel, the slot a mono read +- **Don't forget SEL → GND** — it is a lead of its own, soldered to the same + GND pin as the GND lead. It selects the LEFT channel, the slot a mono read uses; tied high, the mic reads silence while looking perfectly healthy. - The DevKit silkscreen says **TX / RX**, not 43/44 — those are the ones. They're free because the console talks over native USB, and they are the @@ -127,8 +130,8 @@ the main board: 1. **Pull the DevKit out** of its sockets (straight up). 2. Solder the thin wires on the **top side**, where the header pins poke - through the module — four spots: TX, RX, 3V3, GND (that's what the photo - shows). + through the module — five wires on four spots: TX, RX, 3V3, and GND + twice (that's what the photo shows). 3. Route the wires out the top and plug the DevKit back in. Fully reversible — to undo it, just remove the wires. Two cautions: while @@ -154,7 +157,7 @@ phone app*, is the other input: the same switch as `AUD` on the panel's NETWORK screen.) The same box editor from the extension lives on this page, under -**Microphone mapping**, in the console's own look (light, or dark from the +**Mapping**, in the console's own look (light, or dark from the toggle in the header) — same boxes K1 to K4, same curves, same Preview. Its level axis is the microphone's, about −45 to +2 dB, and its frequencies stop at 8 kHz. Changes save to the panel as you make them; mic settings are stored @@ -236,6 +239,11 @@ microphone on any lane it's driving. The mic takes whatever's left. So you can leave the mic on and still grab a knob whenever you want — it comes back to the music a few seconds after you let go. +A value set from the console's knob sliders, by MIDI CC 20–23 or by +`POST /api/params` outranks all of them: that knob stays where it was put, and +sound does not move it, until that encoder is turned. The Audio page's line +does not show this. + The extension keeps driving its knobs through a silent or paused tab, so with both on, the microphone does not take over during a pause. Press **Stop** in the extension to give the knobs to the room, or **Mute** a knob in the diff --git a/CHANGELOG.md b/CHANGELOG.md index f76f81c2e..d715bef29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,21 @@ All notable changes to Patternflow will be documented in this file, newest first - **SELECT shows patterns that are already in memory live.** A preset or a parked module comes on as the knob reaches it and runs behind the overlay instead of showing its thumbnail, as long as the pattern being left can be kept too. A pattern that has to be loaded still waits for the knob to rest. - **A module's data now goes to PSRAM first, as its code already did.** Only a module that holds no internal RAM at all is kept, so the console and Wi-Fi give up nothing for parked patterns. Measured A-B-B-A on two boards: 13 of 16 module runs within noise (-1.1..+0.8%), Burgers +2.6% (38.9 -> 39.9 ms a frame), Wave Cascade +2.2%, Breakout Arcade +0.85%; in exchange 4-6 KB more internal heap with a module running, up to 9.9 KB. - **Parked patterns give their memory back.** Least recently used first, whenever a new load would otherwise start with less than 4.5 MB of PSRAM free (the most one pattern may allocate, plus 512 KB), and whenever a running pattern's own allocation would leave less than 512 KB. A pattern installed, replaced or deleted - from the console, or by a feature that installs patterns - is never resumed from the old file. Checked on two boards with 12-13 modules parked: internal heap stayed flat, an install emptied the table and PSRAM came back by exactly the parked bytes (683 KB), and with five 1.5 MB test modules PSRAM never fell under about 3.1 MB free, with no refused load. `/api/status` gains `load.resumed`, `load.resumeUs` and `moduleMemory.resident`. -- **What this takes away:** a pattern that wants a fresh start every time it is picked cannot have one. `setup()` runs once per load, as `firmware/CUSTOM_PATTERNS.md` now says. -- **The console is light, and its six core pages look like one thing.** It opens light; dark is still the toggle in the header, and a choice made there is remembered. Console, Patterns, Status, Wi-Fi, Knobs and Update now share one set of parts - buttons, chips, list rows, fields, notices, section titles - from a base block every page opens with (`console/README.md`, "The look"), with one accent, the LED orange, kept for what is live: the current tab, the playing pattern, the panel being on. The home page leads with the panel itself: what is playing with its arrows, on/sleep, brightness and the four knobs, with the device's facts under them in the same card; the arrow keys step through patterns as the arrow buttons do. Which edition the panel runs has a card of its own instead of being one more row among the facts: its name, its versions, the feature pages it carries as links, the update notice when there is one, and a button to the other editions (or a line saying the panel's own hotspot has no internet). The numbered index of every page with a paragraph each is now a list of one-line links. The header no longer scrolls sideways on a phone: Console and Patterns, the build's own feature pages, then Wi-Fi, Knobs, Update and Status, wrapped onto rows. Long explanations are a line, with the rest behind a disclosure where it is needed. Windows showed every small label in Courier New because no font in the monospace list was a Windows one; the list now includes Cascadia Mono and Consolas. The Knobs page has an *All* row that sets direction and edges per click for the four at once (it reads *Mixed* while they differ). The Status page shows the crash record (`crash` in `/api/status`) when there is one, with a button to clear it. Feature pages are not edited and take the light palette from the header. Home is 8.9 KB gzipped (was 10.9), Knobs grew by 0.15 KB for its All row, the others are the same or slightly smaller, and the header script is 9.6 KB. On a panel on the home network a page still opens in about 0.1 s the first time and in under 0.01 s after. Every request, control and message the pages had is still there; checked on a panel by stepping through patterns, sleeping and waking it, dimming it, dragging a knob and playing a pattern from the list. The guide's live console demo is regenerated to match. -- **The Audio page is a console page now: it says who has the knobs, and it saves only what you changed** (Audio edition). `/audio-in` was the one page the light console left behind: its own dark palette, 12 px type, square corners, a second brand line. It opens light like the rest now, with the console's buttons, chips and list rows, and it does not scroll sideways on a phone (checked at 390 and 320 px). Above the editor is a **Sources** list. *Microphone* has a chip that reads `off`, `starting`, `listening`, `not detected` (nothing on the data pin; the page says to check the DAT lead) or `not answering`, with the input gain beside it; a board with no microphone fitted used to look the same as a healthy one in a silent room. *Browser extension and phone app* is the Audio-React switch, the same one as `AUD` on the NETWORK screen, with `off`, `waiting`, `connected` or `phone app`, shown only on a build that has that path. The line at the top of the page says which of them has the knobs. Under it the mapping editor is the extension's, with what that gained in this release (see Tools): the knobs are K1 to K4 with their tabs under the plot, the level axis is in dB, and Preview takes the plot's place. On this page the line in each box, the cursor on the curve and the readout now show where the firmware has the level (`outputs` and `levelsN` from the poll); the page used to work that out a second time on its own dB scale, which is not the scale the firmware maps a hand-set window on. A panel that has heard nothing draws its stored windows dashed, where it drew four boxes flat on the axis. *Reset K1* asks the firmware for that knob's default: the page's own table of defaults was the extension's, and put a 5-16 kHz band on an axis that ends at 8. *Reset mapping* is pressed twice and says what it resets, in place of a `confirm()` dialog, which the phone app's WebView is not set up to show. Saving: nothing is written until the panel's mapping has been read. A read that failed used to leave the editor on the extension's defaults with nothing on screen to say so, and the next touch saved four default bands over the panel's own. An edit now sends the fields that changed, one request at a time - an Attack nudge is `attack=0.611`, a box's top edge is that band's `inMin` and `inMax` - where every edit posted the whole mapping, 41 arguments, and two open tabs wrote their stale copies over each other's edits. A save the panel did not take is said so, tried once more, and kept for the next change; an edit made just before leaving the page is still sent. Polling: the levels poll rides the console's shared lane (`PF.poll`) as every other page's requests do, so it waits behind an upload in another tab and slows itself on a slow link. Measured on the mock, about 9 polls a second with the microphone on, one every 1.5 s with it off (it had been 9.2 a second there too, for numbers that cannot change), one a second while the panel is not answering, and 0.6 a second on a link as slow as the hotspot. Weight: 17.4 KB gzipped as the panel sends it, where it was 19.9, because comments and indentation are dropped as the page is assembled; CI fails the page above 17.5 KB. `console_pages.py build` assembles the page as its first step, so there is one build command where there were two, and `console_serve.py` serves it assembled from its sources on every request against a port of the firmware's audio handlers, with `/mock?audio=` states for music, a quiet room, no microphone, a stalled one, the phone app and a read that never answers. Checked on that mock in Chromium, light and dark, with no console errors in any state, and on a panel with the microphone: the mapping stored on it survived the update, the page read before it wrote, one slider step posted one field (`attack=0.645`) and held across a reload, and frame time and microphone drops were unchanged with the page open. Not yet on a phone or in the phone app's WebView. +- **What this takes away:** a pattern that wants a fresh start every time it is picked cannot have one. `setup()` runs once per load, as `firmware/CUSTOM_PATTERNS.md` now says. A restart of the panel, or any install, delete or reorder on the Patterns page, is what starts one again. +- **The console is light, and its six core pages look like one thing.** It opens light; dark is still the toggle in the header, and a choice made there is remembered. Console, Patterns, Status, Wi-Fi, Knobs and Update now share one set of parts - buttons, chips, list rows, fields, notices, section titles - from a base block every page opens with (`console/README.md`, "The look"), with one accent, the LED orange, kept for what is live: the current tab, the playing pattern, the panel being on. The home page leads with the panel itself: what is playing with its arrows, on/sleep, brightness and the four knobs, with the device's facts under them in the same card; the arrow keys step through patterns as the arrow buttons do. Which edition the panel runs has a card of its own instead of being one more row among the facts: its name, its versions, the feature pages it carries as links, the update notice when there is one, and a button to the other editions (or a line saying the panel's own hotspot has no internet). The numbered index of every page with a paragraph each is now a list of one-line links. The header no longer scrolls sideways on a phone: Console and Patterns, the build's own feature pages, then Wi-Fi, Knobs, Update and Status, wrapped onto rows. Long explanations are a line, with the rest behind a disclosure where it is needed. Windows showed every small label in Courier New because no font in the monospace list was a Windows one; the list now includes Cascadia Mono and Consolas. The Knobs page has an *All* row that sets direction and edges per click for the four at once (it reads *Mixed* while they differ). The Status page shows the crash record (`crash` in `/api/status`) when there is one, with a button to clear it. Feature pages are not edited and take the light palette from the header. Home is 8.9 KB gzipped (was 10.9), Knobs grew by 0.15 KB for its All row, the others are within 40 bytes of what they were, and the header script is 9.6 KB. On a panel on the home network a page still opens in about 0.1 s the first time and in under 0.01 s after. Every request, control and message the pages had is still there; checked on a panel by stepping through patterns, sleeping and waking it, dimming it, dragging a knob and playing a pattern from the list. The guide's live console demo is regenerated to match. +- **The Audio page is a console page now: it says who has the knobs, and it saves only what you changed** (Audio edition). `/audio-in` was the one page the light console left behind: its own dark palette, 12 px type, square corners, a second brand line. It opens light like the rest now, with the console's buttons, chips and list rows, and it does not scroll sideways on a phone (checked at 390 and 320 px). Above the editor is a **Sources** list. *Microphone* has a chip that reads `off`, `starting`, `listening`, `not detected` (nothing on the data pin; the page says to check the DAT lead) or `not answering`, with the input gain beside it; a board with no microphone fitted used to look the same as a healthy one in a silent room. *Browser extension and phone app* is the Audio-React switch, the same one as `AUD` on the NETWORK screen, with `off`, `waiting`, `connected` or `phone app`, shown only on a build that has that path. The line at the top of the page says which of them has the knobs. It does not know about a knob pinned from the home page's sliders, MIDI CC 20-23 or `/api/params`; that knob stays pinned until its encoder is turned. Under it the mapping editor is the extension's, with what that gained in this release (see Tools): the knobs are K1 to K4 with their tabs under the plot, the level axis is in dB, and Preview takes the plot's place. On this page the line in each box, the cursor on the curve and the readout now show where the firmware has the level (`outputs` and `levelsN` from the poll); the page used to work that out a second time on its own dB scale, which is not the scale the firmware maps a hand-set window on. A panel that has heard nothing draws its stored windows dashed, where it drew four boxes flat on the axis. *Reset K1* asks the firmware for that knob's default: the page's own table of defaults was the extension's, and put a 5-16 kHz band on an axis that ends at 8. *Reset mapping* is pressed twice and says what it resets, in place of a `confirm()` dialog, which the phone app's WebView is not set up to show. Saving: nothing is written until the panel's mapping has been read. A read that failed used to leave the editor on the extension's defaults with nothing on screen to say so, and the next touch saved four default bands over the panel's own. An edit now sends the fields that changed, one request at a time - an Attack nudge is `attack=0.611`, a box's top edge is that band's `inMin` and `inMax` - where every edit posted the whole mapping, 41 arguments, and two open tabs wrote their stale copies over each other's edits. A save the panel did not take is said so, tried once more, and kept for the next change; an edit made just before leaving the page is still sent. Polling: the levels poll rides the console's shared lane (`PF.poll`) as every other page's requests do, so it waits behind an upload in another tab and slows itself on a slow link. Measured on the mock, about 9 polls a second with the microphone on, one every 1.5 s with it off (it had been 9.2 a second there too, for numbers that cannot change), one a second while the panel is not answering, and 0.6 a second on a link as slow as the hotspot. Weight: 17.2 KB gzipped as the panel sends it, where it was 19.9, because comments and indentation are dropped as the page is assembled; CI fails the page above 17.5 KB. `console_pages.py build` assembles the page as its first step, so there is one build command where there were two, and `console_serve.py` serves it assembled from its sources on every request against a port of the firmware's audio handlers, with `/mock?audio=` states for music, a quiet room, no microphone, a stalled one, the phone app and a read that never answers. Checked on that mock in Chromium, light and dark, with no console errors in any state, and on a panel with the microphone: the mapping stored on it survived the update, the page read before it wrote, one slider step posted one field (`attack=0.645`) and held across a reload, and frame time and microphone drops were unchanged with the page open. Not yet on a phone or in the phone app's WebView. +- **The Audio edition transmits at the core's Wi-Fi power** (19.5 dBm). It had been left pinned at 17 dBm when the core's 13 dBm cap was retired in 3.10.5, so it was the one build with less than full power. - **A microphone that is not delivering no longer moves the knobs, and the microphone API takes one field at a time** (Audio edition). When the PDM driver did not start, or the microphone stopped returning samples, the analysis carried on over its built-in three-tone test signal and those tones reached the knobs, while `source` said `synth (mic stalled)`. The microphone now drives the knobs only while it is returning samples. `POST /api/audio-in` applies a band when any one of its suffixed fields is present; before, a request carrying only `inMax2` was answered `{"ok":true}` and changed nothing, because only `hzMin`, `lut`, `knob`, `muted` or `outMin` counted as naming a band. A `band` outside 0..3 is refused with `400` before anything else in the request is applied. `POST /api/audio-in/reset` takes `band=N` and resets that band and its curve alone, naming it in the answer; a full reset now includes `attack`, and still leaves the microphone switch and auto range alone. `hzRange` is printed to two decimals (`31.25`, the bound a band is really held to). The poll reply is built in one allocation where it grew through about forty, ten times a second. Nothing is removed or renamed: the phone app reads the same configuration it did, and `d=` stays in the WebSocket protocol for earlier extensions. Static RAM is what it was (161,216 bytes of DRAM, the pinned figure). `docs/rest-api.md` describes the microphone endpoints field by field, including what had been there unwritten (both spellings of a band field, `smoothing`, `attack`, `meta`, `lut`, `env`, the phone's `frame` post, every `source` string, and that a body sent without the form `Content-Type` is not read, which turns a `band=2` reset into a reset of everything). `docs/audio-ws-spec.md` 1.1 states the 500 ms lane release that version 1 said did not exist, and which senders hold a value through it: the extension does, the Android capture app does not yet. Checked on a host by running the old and the new handlers on the same requests (78 assertions), by building every composition, and by the one-field post above on a panel. +### Web + +- **The guide has a fourth part: Audio** (`/guide/audio`, Korean at `/guide/audio/ko`). Getting the Audio edition onto the board first, then three sections in no order: sound from a browser tab, the on-board microphone, and MIDI with a DAW (network MIDI, Ableton Live as the worked example). The browser section runs the extension's own mapping editor on its demo source, and the boxes drawn there turn the knobs of the 3D board; a hand on a knob takes it for five seconds, as on the real one. The rest is captures of the real pages and the two photos of the microphone. OSC, the phone app and the Director's `.mid` export are links at the end. The hub (`/guide`) has four cards. + +### Docs + +- **The microphone is five leads on four places, and the docs say so.** `AUDIO_GUIDE.md`, the Audio card on `/editions` and `firmware/bundles/README.md` said four wires; GND and SEL both go to GND, which makes five. `AUDIO_GUIDE.md` also names the Audio page's editor by its heading, **Mapping**. +- **The MIDI console page points at Live's current settings page** (Settings → Link, Tempo & MIDI, not Preferences → MIDI) and no longer mentions a MIDI row on the board's NETWORK screen: that screen shows the first two switchable features, and on the Audio edition those are OSC and AUD. `docs/midi-spec.md` says the same. + ### Tools - **The audio extension holds the knobs through silence, and Live means the panel is being driven** (`tools/patternflow-audio-extension`, 0.2.0). The panel hands a knob back to its encoder 500 ms after the last message that set it, and the extension did not send a frame equal to the last one. So half a second into a silent tab, a paused track or a Gate curve resting on one step, the knobs were let go while the popup said Live: measured, not one message in ten seconds of silence. It now sends an unchanged frame again about every 210 ms (five a second, no gap longer than 223 ms), and the knobs rest where each band rests until Stop. A knob turned by hand during a pause goes back there when its five-second hold ends, and a microphone that is also switched on does not get the knobs during a pause. Owner's decision. Muting a knob hands it back at once (`off=N`), where it waited for the panel's timeout. *Live* used to mean the socket was open, which it also is on a panel whose Audio-React switch is off and which ignores every frame; the extension now asks the panel once when the socket opens (`GET /api/audio`), and off reads *Aud off* with a **Turn on** button. A socket that will not open is explained, where every failure read "WebSocket error": no panel at that address, a panel that does not run the Audio edition, or something that answers and refuses the socket. A silent tab reads "No sound from this tab yet." The popup has one button, Start until a tab is captured and Stop while one is; *Release knobs* is gone (Stop does that), and *Test without audio* holds each knob where its slider is put. The address is taken on Enter, on leaving the field and on Start, where it reconnected on every keystroke (typing `192.168.0.42` tried `192.168.0.4` on the way), and it has a storage key of its own, so an editor tab left open no longer writes an old address back. Closing the captured tab ends the capture and says so, and a restarted service worker no longer shows Idle over a running capture. The frequency labels under the popup's spectrum sit where their frequencies are ("1k" had been over 317 Hz). In the mapping editor the bands are K1 to K4 everywhere, the knob tabs sit directly under the plot, the level axis and the curve's readout are in dB, and the hints are plain sentences ("This band still runs its old boost curve", on a band nobody had touched, is gone). A new install starts with Auto range on and the Smooth curve, as the panel does and as `AUDIO_GUIDE.md` already said; a mapping that is already stored keeps what it has. Switching Auto range off goes back to the windows set by hand, where it replaced all four with wherever the boxes were; dragging a top or bottom edge is what takes them over. Preview puts its scope where the plot is, so the sliders, the curve and the output range it demonstrates stay in reach, where it was a dialog over them. The editor holds together at half a screen's width, and on a touch screen a swipe that starts on an empty part of the plot scrolls the page while one on a box drags it. One model (`mapping.js`) holds the defaults and the level-to-knob arithmetic for the capture, the popup, the editor and the panel's page, where the first three each had a copy. The extension keeps its cream skin. After pulling, press Reload on `chrome://extensions`; the card should read 0.2.0. Reload keeps the stored mapping, so a copy you already had opens as it was, which unless you had changed them is Auto range off and no curve: for the new defaults, press Reset on each of the four knobs in the editor and switch Auto range on, or remove the extension and load it again (which also forgets the panel's address). Checked with the unpacked extension in headless Chromium against a loopback socket and a stand-in for the panel's HTTP side (44 popup checks, and the wire in each state), and against a panel: a tone in the captured tab swung K1 between 0.30 and 0.85, four seconds of silence left the lanes held at their rest value where they used to be released after half a second, switching Audio-React off on the panel turned the popup to *Aud off*, and Stop handed the knobs back to the microphone. Touch was tried only in emulation, and the panel requests only in the Chromium Playwright ships, not in a current Chrome with its local-network permission prompt. diff --git a/README.md b/README.md index 15f71806b..330a44536 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ **Patternflow is an open-source LED synthesizer.** You play it with four knobs. 8,192 pixels across a 128 × 64 matrix respond the instant you turn one; nothing is pre-rendered, every frame is computed live on the device. A pattern is just a small file, so every Patternflow plays every pattern anyone makes. -**Start with [the guide](https://patternflow.work/guide).** It builds one with you, shows you how to play it, and walks you through making patterns, each step on a 3D Patternflow you can turn in the browser: **[Build](https://patternflow.work/guide/build)** · **[Play](https://patternflow.work/guide/play)** · **[Make](https://patternflow.work/guide/make)**. +**Start with [the guide](https://patternflow.work/guide).** It builds one with you, shows you how to play it, walks you through making patterns, and connects sound and MIDI, each step on a 3D Patternflow you can turn in the browser: **[Build](https://patternflow.work/guide/build)** · **[Play](https://patternflow.work/guide/play)** · **[Make](https://patternflow.work/guide/make)** · **[Audio](https://patternflow.work/guide/audio)**. ## Where it began @@ -189,7 +189,7 @@ Patternflow is built around a standalone ESP32-S3 driving a HUB75 RGB LED matrix | `integrations/` | Host-software bridges: Ableton Live / Max for Live (OSC knob mapping) | | `.github/` | Issue and PR templates, and the CI that runs on every pull request (web, every firmware edition, doc links, console pages) | -**Guide (interactive):** [Start here](https://patternflow.work/guide) · [Build it](https://patternflow.work/guide/build) · [Play it](https://patternflow.work/guide/play) · [Make patterns](https://patternflow.work/guide/make): each step shown on a 3D Patternflow you can turn, in English and Korean +**Guide (interactive):** [Start here](https://patternflow.work/guide) · [Build it](https://patternflow.work/guide/build) · [Play it](https://patternflow.work/guide/play) · [Make patterns](https://patternflow.work/guide/make) · [Sound and MIDI](https://patternflow.work/guide/audio): each step shown on a 3D Patternflow you can turn, in English and Korean **Build:** [Full Build Guide](BUILD_GUIDE.md) · [Assembly Map](docs/assembly/README.md) · [Panel Compatibility](docs/panel-compatibility.md) · [Hardware files](hardware/README.md) **Play:** [Pattern Guide](PATTERN_GUIDE.md) · [Custom Patterns](firmware/CUSTOM_PATTERNS.md) · [Audio Guide](AUDIO_GUIDE.md) · [MIDI in Ableton](docs/midi-ableton.md) **Extend:** [Feature Guide](FEATURE_GUIDE.md) · [Editions](docs/EDITIONS.md) · [Firmware build](firmware/README.md) · [Web architecture](web/ARCHITECTURE.md) · [HTTP API](docs/rest-api.md) · [OSC Spec](docs/osc-spec.md) · [MIDI Spec](docs/midi-spec.md) · [Director → MIDI](docs/director-midi.md) diff --git a/SUPPORT.md b/SUPPORT.md index 889478be9..b5f6e1121 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -4,7 +4,7 @@ One table for "where do I ask". Issues are for things that change this repositor | I want to… | Go to | | :--- | :--- | -| Learn to build, play or make patterns for one | [The guide](https://patternflow.work/guide) — each step on a 3D Patternflow in the browser. A step that didn't get you through has a **Stuck here?** link, which opens an issue with that step filled in | +| Learn to build, play, make patterns for one or give it sound and MIDI | [The guide](https://patternflow.work/guide) — each step on a 3D Patternflow in the browser. A step that didn't get you through has a **Stuck here?** link, which opens an issue with that step filled in | | Get unstuck building, soldering or flashing | [Discord → hardware-help](https://discord.com/channels/1497757947827327067/1499907910707187833) — photos and quick back-and-forth | | Show a pattern I made, or get feedback on one | Publish it from the [Pattern Lab](https://patternflow.work/pattern-lab) to the [Community](https://community.patternflow.work/community), then [Discord → patterns](https://discord.com/channels/1497757947827327067/1499908302962819236) | | Put my finished build on the [build map](https://patternflow.work/inside) | [Share your build](https://github.com/engmung/Patternflow/issues/new?template=share_build.yml) — or post it in Discord | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 39511e1ac..fb438b7ef 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -73,7 +73,7 @@ What the two commands do, step by step - and the way to do it by hand. ``` then point `web/public/flash/manifest.json` at the new folder. An edition that also moved gets its own `shelf.sh vA.B.C` and a card update in `web/src/app/editions/editions-data.ts`. The shelf retires the previous folder of the same name — older images stay on their tags. Try-out images (`clock-v0.1.5`, `midi-v0.1.0`, the ones the features page installs) are not part of a release and stay as they are; a fresh one is its own `shelf.sh vA.B.C` and a row update in `web/src/app/features/features-data.ts`, done when somebody wants a newer one, not because the core moved. -7. Run the web checks from `web/`: `npm run lint && npm run typecheck && npm run check:ci && npm run build`. +7. Regenerate the guide's live console demo, which carries the version, the core image's build id and the manifest version: `python firmware/toolchain/console_demo.py` (neither `release.py` nor CI does this; without it the demo on `/guide/play` keeps presenting the previous release), and confirm `python firmware/toolchain/editor_demo.py --check`. Then run the web checks from `web/`: `npm run lint && npm run typecheck && npm run check:ci && npm run build`. 8. Commit and tag: ```bash @@ -84,6 +84,7 @@ What the two commands do, step by step - and the way to do it by hand. 9. Create the GitHub Release from the tag. Publishing it triggers **Firmware release assets**, which attaches the four flash images from the tag plus a generated `FLASHING.md` with offsets and hashes. 10. Confirm it went green before announcing; re-run it with `workflow_dispatch` if it did not fire. +11. Redeploy the community host (the Pi: pull `main`, build, restart the web and worker units) once `main` has deployed. The Basics pack card and the pattern builds are served from there, so until it is redeployed it hands out the previous pack and the previous build settings. Do it before announcing anything that says "install the pack again". ## Branches diff --git a/docs/REPOSITORY.md b/docs/REPOSITORY.md index 39449c36e..a889d1229 100644 --- a/docs/REPOSITORY.md +++ b/docs/REPOSITORY.md @@ -8,7 +8,7 @@ A map for people. (AI coding agents get the same map with more rules in [`../AGE | :--- | :--- | :--- | | `firmware/` | The ESP32-S3 firmware. `patternflow/` is the sketch: `patternflow.ino`, `config.h` (pins, brightness, limits, calibration), `net_config.h` (Wi-Fi, OTA, update defaults), `src/` (the engine: HUB75 driver, encoders, canvas, colour, noise, Wi-Fi, web console — the **core**, compiled unchanged into every published firmware), `features/` (everything optional: OSC, audio, MQTT, MIDI, shows, weather, clock — attached through hooks the core exposes), `presets/` (the curated patterns), `abi/` (the frozen contract a `.pfm` module is compiled against), `console/` (the device's web pages as plain HTML). `bundles/` names the editions (which features compile in) and holds `build.sh`. `toolchain/` is the repo-level scripts: build a module, make the Basics pack, and the `check_*.py` that CI runs. `modules/` is the module toolchain's working folder, not a place patterns are contributed to | [`firmware/README.md`](../firmware/README.md) for building; [`EDITIONS.md`](EDITIONS.md) before touching anything | | `hardware/` | `pcb/` (KiCad source, Gerbers per revision, schematic PDF), `case/` (STLs by printer bed size, knob plates, the Blender source, legacy v2 cases, and `remixes/` for community variants), `bom/` (the CSV that is the BOM source of truth) | [`hardware/README.md`](../hardware/README.md) | -| `web/` | The Next.js site at patternflow.work: landing page, the interactive guide (`/guide`, with `/guide/build`, `/guide/play` and `/guide/make`), Live Editor, Pattern Lab, the community (only on the community host), browser flasher, edition shelf, device update handoff, journal, roadmap. The JS presets in `src/lib/presets/` are the source of truth for the firmware's preset headers. The breadboard build guide is a React page here, `src/app/build/breadboard/` | [`web/README.md`](../web/README.md), then [`web/ARCHITECTURE.md`](../web/ARCHITECTURE.md) | +| `web/` | The Next.js site at patternflow.work: landing page, the interactive guide (`/guide`, with `/guide/build`, `/guide/play`, `/guide/make` and `/guide/audio`), Live Editor, Pattern Lab, the community (only on the community host), browser flasher, edition shelf, device update handoff, journal, roadmap. The JS presets in `src/lib/presets/` are the source of truth for the firmware's preset headers. The breadboard build guide is a React page here, `src/app/build/breadboard/` | [`web/README.md`](../web/README.md), then [`web/ARCHITECTURE.md`](../web/ARCHITECTURE.md) | | `docs/` | Contracts other software is built against, how the firmware is put together, walk-throughs, the assembly map, project records and media | [`README.md`](README.md) | | `tools/` | Clients Patternflow ships that run on their own: the audio-react browser extension (also the authoring source of the device's `/audio-in` page), the Android capture app, an RTP-MIDI probe | [`tools/README.md`](../tools/README.md) | | `integrations/` | Bridges that run inside or beside someone else's software (Ableton / Max for Live today) and talk to the panel only through the contracts in `docs/` | [`integrations/README.md`](../integrations/README.md) | @@ -26,7 +26,7 @@ The rule for where a document lives: **a guide somebody follows start to finish | Make a pattern | [`PATTERN_GUIDE.md`](../PATTERN_GUIDE.md) — the Pattern Lab, the community, and the device. No local toolchain involved. The same loop as a tutorial: [patternflow.work/guide/make](https://patternflow.work/guide/make). [`firmware/CUSTOM_PATTERNS.md`](../firmware/CUSTOM_PATTERNS.md) is the older, more hands-on route | | Write a firmware feature or cut an edition | [`FEATURE_GUIDE.md`](../FEATURE_GUIDE.md) → [`EDITIONS.md`](EDITIONS.md) → [`features/README.md`](../firmware/patternflow/features/README.md) → [`bundles/README.md`](../firmware/bundles/README.md) | | Control the panel from other software | [`rest-api.md`](rest-api.md) has the table for choosing between HTTP, OSC, MIDI and MQTT; then the spec for the one you chose | -| Put sound into it | [`AUDIO_GUIDE.md`](../AUDIO_GUIDE.md) | +| Put sound into it | [`AUDIO_GUIDE.md`](../AUDIO_GUIDE.md). The browser tab, the microphone and MIDI with a DAW, step by step: [patternflow.work/guide/audio](https://patternflow.work/guide/audio) | | Work on the site | [`web/README.md`](../web/README.md) → [`web/ARCHITECTURE.md`](../web/ARCHITECTURE.md) | | Contribute anything | [`CONTRIBUTING.md`](../CONTRIBUTING.md) — one table of where each kind of contribution goes | | Cut a release, run the servers | [`RELEASING.md`](RELEASING.md), [`SERVICES.md`](SERVICES.md) | diff --git a/docs/midi-spec.md b/docs/midi-spec.md index cc2ee64fe..68e0124d7 100644 --- a/docs/midi-spec.md +++ b/docs/midi-spec.md @@ -72,7 +72,9 @@ carries "midi": {"runtime": true, "channel": 1, "outDiv": [1,1,4,1], "outMul": [1,2,1,1], "outMode": "abs", "host": "192.168.0.23", "rtpPeers": 1, "rtpPeer": "MacBook", "rx": 812, "tx": 40} ``` -`runtime` is the device's own switch (the `MIDI` row on the NETWORK screen); +`runtime` is the device's own switch (**MIDI on this panel** on the console's +MIDI page; the NETWORK screen shows the first two switchable features, so +on the Audio edition, where `OSC` and `AUD` come first, MIDI has no row there); off means the port stays open and everything is dropped, the same convention as `OSC` and `AUD`. diff --git a/firmware/bundles/README.md b/firmware/bundles/README.md index 36493f08a..73546715b 100644 --- a/firmware/bundles/README.md +++ b/firmware/bundles/README.md @@ -48,7 +48,7 @@ A bundle earns its place when it carries something the default **cannot**: | | why not in the default | |---|---| -| **audio** | an on-board microphone that needs four wires soldered to the DevKit, a Wi-Fi transmit power that is not the conformance-tested one, and MIDI over the network | +| **audio** | an on-board microphone that needs five leads soldered to the DevKit, and MIDI over the network | | **performance** | the show player, MQTT in every role, weather — Simone Majocchi's performance-director firmware, pinned so a show behaves the same at the next gig | | **clock** | the time cut out of the running pattern, in a choice of faces — a panel on a shelf that tells the time, with nothing else attached. **Not on the shelf since 2026-09-15:** the clock is a feature; this bundle is the composition CI keeps compiling | | **midi** | the DevKit's USB port as a MIDI device, beside RTP-MIDI — a wire into a DAW with no driver and no session. It needs the port on the S3's USB-OTG stack, a build flag the other images cannot carry (that flag is what makes the port their serial console), so this one builds in its own PlatformIO env. Data only; the panel is still powered from `J4`. **Not on the shelf:** the composition that proves the OTG build | diff --git a/firmware/bundles/audio/overrides.h b/firmware/bundles/audio/overrides.h index d4eca5c67..73c12b47d 100644 --- a/firmware/bundles/audio/overrides.h +++ b/firmware/bundles/audio/overrides.h @@ -12,28 +12,10 @@ // ── Wi-Fi transmit power ──────────────────────────────────────────────── // -// Core ships 13 dBm and that is NOT a conservative default — it is a -// conformance fix. The comment where it is set says why: the ESP32's own -// default of 19.5 dBm, plus the WROOM-1 antenna's gain, can sit at or over -// the EU's 20 dBm EIRP limit. -// -// This firmware is for performing: a venue, a booth, a room full of -// competing access points, twenty metres of it between the panel and -// whatever laptop is running Ableton. 13 dBm is chosen for a desk. -// -// 17 dBm is +4 dB, so roughly 2.5x the uplink power, and it is the step the -// core's own comment names as the first one to try. Note that only the -// device→router direction changes: the RSSI on /status is the downlink and -// will not move, so do not use it to judge whether this helped. -// -// **19.5 dBm is deliberately not used here.** It is the maximum the radio -// offers and it is the value an outside report once proposed; the -// investigation that looked at it declined, on the grounds above. Raising -// this to WIFI_POWER_19_5dBm is one word away and whoever does it owns the -// EIRP question that comes with it — including that a firmware published by -// the same person who made the hardware does not read as third-party to -// anybody checking. -#define PF_WIFI_TX_POWER WIFI_POWER_17dBm +// Nothing is set here: this edition transmits at the core's power +// (net_config.h, 19.5 dBm since 3.10.5). It used to pin 17 dBm, which was a +// raise while the core shipped 13 and became a cut when the core went to full +// power; from v0.7.0 it follows the core. // ── What this firmware calls itself ───────────────────────────────────── // diff --git a/firmware/bundles/shelf.sh b/firmware/bundles/shelf.sh index fe03581b1..b57d163a1 100755 --- a/firmware/bundles/shelf.sh +++ b/firmware/bundles/shelf.sh @@ -45,6 +45,31 @@ OUT="web/public/flash/bin/$NAME-$VERSION" CORE_PKG="$HOME/.platformio/packages/framework-arduinoespressif32" BOOT_APP0="$CORE_PKG/tools/partitions/boot_app0.bin" +# A run that was killed outright (a Ctrl-C is covered by the trap further down) +# leaves the credentials at .shelf-aside. Put them back before anything else: +# otherwise this run would skip its control build, and the file would stay +# under a name that is easy to commit by accident. +if [ -f "$SECRETS.shelf-aside" ]; then + if [ -f "$SECRETS" ]; then + echo "both $SECRETS and $SECRETS.shelf-aside exist. Keep the right one and run again." >&2 + exit 1 + fi + mv "$SECRETS.shelf-aside" "$SECRETS" + echo "restored $SECRETS from an interrupted run" +fi + +# A killed build.sh leaves its composition files in features/, and a +# default build would then compile them in: a "core" image carrying an +# edition's features, with every check green. A shelf image is built from the +# bundle alone, so refuse rather than guess whose files these are. +for leftover in features_local.h addons_local.h overrides.h; do + if [ -f "$SKETCH/features/$leftover" ]; then + echo "$SKETCH/features/$leftover exists (a local composition, or left by an interrupted build)." >&2 + echo "Remove it and run again: a shelf image must be built from the bundle alone." >&2 + exit 1 + fi +done + # A version-stamped path is never overwritten: the CDN and every browser that # has seen it will keep serving the old bytes under the same URL, so a # corrected image published over an existing one reaches nobody. diff --git a/firmware/patternflow/console/midi.html b/firmware/patternflow/console/midi.html index 9a0cb18d0..08f51d1b3 100644 --- a/firmware/patternflow/console/midi.html +++ b/firmware/patternflow/console/midi.html @@ -149,14 +149,14 @@

Knobs → MIDI

K3CC 26
-
K4CC 27
-
-

Live positions as the DAW sees them. They start at 64 on boot and stop at the ends; the DAW's takeover mode (Live: Preferences → MIDI → Takeover Mode) smooths the pickup.

+

Live positions as the DAW sees them. They start at 64 on boot and stop at the ends; the DAW's takeover mode (Live: Settings → Link, Tempo & MIDI → Takeover Mode) smooths the pickup.

Switch

-

Same switch as the MIDI row on the panel's NETWORK screen. Off keeps the port open and drops everything.

+

Off keeps the port open and drops everything.

@@ -168,7 +168,7 @@

The map

Note 60–63Button 1–4 (on = press, held while held)Button 1–4 pressed / released Prog ChangeSelect pattern by indexPattern changed -

Ableton: in Preferences → MIDI, switch Track and Remote on for the port's Input and Track on for its Output. Then Ctrl/Cmd-M, click a parameter, turn a knob.

+

Ableton: in Settings → Link, Tempo & MIDI, switch Track and Remote on for the port's Input and Track on for its Output. Then Ctrl/Cmd-M, click a parameter, turn a knob.

@@ -216,7 +216,7 @@

The map

$('st').textContent=!d.runtime?'off':(con?('connected · '+(d.rtpPeer||'')):'listening'); $('st').className='sub '+(con&&d.runtime?'ok':''); var lead=$('lead'); - if(!d.runtime){lead.className='lead off';$('lead-t').textContent='MIDI is switched off';$('lead-p').textContent='Turn it on below, or the MIDI row on the panel.';} + if(!d.runtime){lead.className='lead off';$('lead-t').textContent='MIDI is switched off';$('lead-p').textContent='Turn it on below.';} else if(con){lead.className='lead on';$('lead-t').textContent='Connected to '+(d.rtpPeer||'a computer'); $('lead-p').textContent='The panel is a MIDI port there now.'+(d.host?' It will reconnect on its own.':'');} else{lead.className='lead';$('lead-t').textContent=d.host?('Calling '+d.host+'…'):'Listening'; diff --git a/firmware/patternflow/console/update.html b/firmware/patternflow/console/update.html index 2b367fba4..65b40f627 100644 --- a/firmware/patternflow/console/update.html +++ b/firmware/patternflow/console/update.html @@ -72,7 +72,7 @@

Linked build

-

No file? patternflow.work/update sends the +

No file? patternflow.work/update sends the latest release straight back to this page. Your own arduino-cli export works too. Keep the panel powered: it checks, flashes and restarts by itself.

@@ -202,6 +202,8 @@

Linked build

// The running build; and a late answer (the chrome keeps probing). PF.status(function(s){ build=s.build; + // An edition's image comes from its own card; /update on the site sends the standard firmware. + if(s.variant&&s.variant!=='core'){var a=$('src');a.href='https://patternflow.work/editions#'+encodeURIComponent(s.variant);a.textContent='patternflow.work/editions'} if(phase!=='lost')return; if(!job.from||s.build!==job.from||job.to===job.from)done(s); else{phase='';PF.dirty=false;bar.style.display='none';say('The panel is back, but still on its old build — the update did not take. Try again.','err');startPoll();render()} diff --git a/firmware/patternflow/features/audio_in/core_audio_in_map.h b/firmware/patternflow/features/audio_in/core_audio_in_map.h index 2cb26cd3f..c2529b1e3 100644 --- a/firmware/patternflow/features/audio_in/core_audio_in_map.h +++ b/firmware/patternflow/features/audio_in/core_audio_in_map.h @@ -263,7 +263,7 @@ inline void clampRange(Band& b) { // analysis. Off means the feature costs a panel nothing but the code size. // // Default off, and this is the switch people will actually use. The mic is -// four wires to a breakout rather than a part on the board, so a panel that +// five leads to a breakout rather than a part on the board, so a panel that // installs the Audio edition for OSC and the Chrome extension should not be // running an analysis over a floating pin. Turning it on is one tick on // /audio-in and it persists. diff --git a/firmware/patternflow/features/midi/core_midi_http.h b/firmware/patternflow/features/midi/core_midi_http.h index 67a4d0539..611264b4c 100644 --- a/firmware/patternflow/features/midi/core_midi_http.h +++ b/firmware/patternflow/features/midi/core_midi_http.h @@ -3,7 +3,8 @@ // // GET /midi the page // GET /api/midi channel, outbound sensitivity, host, session -// POST /api/midi?on=0|1 the MIDI row on the NETWORK screen, from here +// POST /api/midi?on=0|1 MIDI on or off (the NETWORK screen's MIDI row, +// on a build where that screen shows one) // POST /api/midi?outDiv=N detents per outbound step, 1..16, persisted // POST /api/midi?outMul=N steps per detent, 1..8 (the other direction) // ...&knob=1..4 apply to one knob; absent = all four diff --git a/firmware/patternflow/features/midi/midi_index.h b/firmware/patternflow/features/midi/midi_index.h index 1e1dda7d9..2e3961dcf 100644 --- a/firmware/patternflow/features/midi/midi_index.h +++ b/firmware/patternflow/features/midi/midi_index.h @@ -159,14 +159,14 @@ html[data-theme=light]{--cream:#F4EFE6;--cream2:#FFFCFA;--bg:#F4EFE6;--panel:#FF
K3CC 26
-
K4CC 27
-
-

Live positions as the DAW sees them. They start at 64 on boot and stop at the ends; the DAW's takeover mode (Live: Preferences → MIDI → Takeover Mode) smooths the pickup.

+

Live positions as the DAW sees them. They start at 64 on boot and stop at the ends; the DAW's takeover mode (Live: Settings → Link, Tempo & MIDI → Takeover Mode) smooths the pickup.

Switch

-

Same switch as the MIDI row on the panel's NETWORK screen. Off keeps the port open and drops everything.

+

Off keeps the port open and drops everything.

@@ -178,7 +178,7 @@ html[data-theme=light]{--cream:#F4EFE6;--cream2:#FFFCFA;--bg:#F4EFE6;--panel:#FF Note 60–63Button 1–4 (on = press, held while held)Button 1–4 pressed / released Prog ChangeSelect pattern by indexPattern changed -

Ableton: in Preferences → MIDI, switch Track and Remote on for the port's Input and Track on for its Output. Then Ctrl/Cmd-M, click a parameter, turn a knob.

+

Ableton: in Settings → Link, Tempo & MIDI, switch Track and Remote on for the port's Input and Track on for its Output. Then Ctrl/Cmd-M, click a parameter, turn a knob.

@@ -226,7 +226,7 @@ function paint(d){ $('st').textContent=!d.runtime?'off':(con?('connected · '+(d.rtpPeer||'')):'listening'); $('st').className='sub '+(con&&d.runtime?'ok':''); var lead=$('lead'); - if(!d.runtime){lead.className='lead off';$('lead-t').textContent='MIDI is switched off';$('lead-p').textContent='Turn it on below, or the MIDI row on the panel.';} + if(!d.runtime){lead.className='lead off';$('lead-t').textContent='MIDI is switched off';$('lead-p').textContent='Turn it on below.';} else if(con){lead.className='lead on';$('lead-t').textContent='Connected to '+(d.rtpPeer||'a computer'); $('lead-p').textContent='The panel is a MIDI port there now.'+(d.host?' It will reconnect on its own.':'');} else{lead.className='lead';$('lead-t').textContent=d.host?('Calling '+d.host+'…'):'Listening'; @@ -297,7 +297,7 @@ PF.status(function(s){ )HTML"; // GENERATED by firmware/toolchain/console_pages.py build — do not edit. -// gzip -9 of MIDI_INDEX_HTML (19039 bytes → 7056 bytes), served with Content-Encoding: gzip. +// gzip -9 of MIDI_INDEX_HTML (18973 bytes → 7026 bytes), served with Content-Encoding: gzip. static const uint8_t MIDI_INDEX_HTML_GZ[] PROGMEM = { 0x1f,0x8b,0x08,0x00,0x00,0x00,0x00,0x00,0x02,0xff,0xc5,0x5c,0xcd,0x72,0xdb,0xc8, 0x76,0xde,0xeb,0x29,0xda,0xf4,0x1d,0x81,0x18,0x83,0x20,0x41,0x4b,0xb2,0x0c,0x0a, @@ -553,193 +553,192 @@ static const uint8_t MIDI_INDEX_HTML_GZ[] PROGMEM = { 0x29,0x1b,0x55,0x89,0xb4,0xa4,0x0c,0x49,0x68,0x89,0xb2,0x66,0x29,0x25,0x5f,0xcc, 0x18,0xc6,0xdd,0xc2,0xc2,0xc3,0x42,0xd1,0xb9,0x30,0xd2,0x11,0x32,0x5e,0x8d,0x16, 0xa9,0x8e,0x47,0xcd,0x34,0x8e,0x52,0xd8,0xa0,0xa2,0x60,0x61,0x78,0x78,0x25,0xa9, - 0x2e,0x2c,0x68,0x33,0x20,0xda,0x34,0x99,0x2f,0xde,0x16,0x72,0x2c,0x0b,0xe4,0x2b, - 0xb9,0x02,0x13,0xfc,0xe5,0x7d,0xd5,0xfb,0x0d,0x7a,0xdb,0x42,0x3f,0xad,0xa5,0x19, - 0xca,0xe3,0xd1,0x15,0x00,0x25,0x23,0xaf,0x2f,0x41,0x94,0xcb,0x9b,0x58,0x8d,0xa6, - 0x35,0x34,0xd1,0xfb,0xa1,0x3a,0x39,0x23,0xe3,0xb4,0xbe,0x94,0x99,0x28,0x4b,0x6b, - 0x76,0x08,0xe9,0x53,0xf6,0x67,0xe0,0xd1,0xd8,0x30,0xed,0xf4,0xe4,0xec,0x37,0xa3, - 0xf5,0xcb,0x70,0x06,0x0c,0xc0,0xbc,0x56,0x22,0x47,0x26,0xd5,0xa8,0x6a,0x78,0x2a, - 0xe8,0xf9,0xc9,0xac,0x51,0x2b,0x85,0x24,0x7f,0x78,0xf1,0xfe,0xe7,0x1f,0xdf,0xbd, - 0x16,0xe5,0xa8,0x90,0x32,0x75,0xc5,0x8f,0xe3,0xb1,0xc1,0x43,0xdc,0x2b,0x83,0x6a, - 0x08,0xe1,0xb3,0x46,0x34,0x5e,0x67,0x50,0xcf,0xb8,0xe6,0xeb,0x62,0x23,0x8c,0x05, - 0x30,0x51,0xcb,0x8d,0x6b,0x7c,0xc6,0x98,0x15,0xb6,0xe9,0x6a,0x7a,0xfa,0x06,0x20, - 0x23,0x9c,0x00,0x9b,0xe1,0x3b,0x5d,0xbf,0x4a,0x45,0x9b,0xa0,0x08,0x6b,0x4e,0x1f, - 0x5c,0xd4,0xf7,0x7e,0x84,0x7c,0xdb,0x1a,0xb3,0xd1,0x5d,0xea,0x66,0x6e,0x76,0x41, - 0xad,0x41,0x36,0x62,0xab,0x27,0x3c,0xd7,0x87,0x4f,0xe2,0x92,0x9a,0x18,0x08,0x7a, - 0x68,0x3b,0x70,0x44,0x75,0x5c,0x58,0x63,0x3e,0x9e,0x2d,0x06,0x32,0x8e,0xc4,0x3c, - 0x55,0x71,0x02,0x38,0x38,0xa5,0x35,0xf3,0x63,0x0a,0x1a,0x15,0x91,0x71,0xeb,0xad, - 0xc7,0x45,0x8c,0xdd,0x8a,0xca,0x0a,0xab,0x02,0xfa,0xee,0x2c,0x8e,0x0c,0xc2,0x17, - 0x37,0x05,0x74,0xc4,0xe2,0x2b,0x09,0x08,0x99,0xc9,0x01,0x07,0xf5,0xf7,0x9d,0x9c, - 0x1e,0x10,0xa7,0x4f,0x76,0x72,0x5a,0x1d,0x7c,0xae,0xe0,0xda,0x8e,0x6e,0xcc,0x26, - 0xed,0xcb,0x62,0xad,0x5d,0x76,0x0b,0x80,0xe6,0x92,0x34,0x37,0xcc,0x68,0x23,0xbb, - 0x73,0xee,0x1f,0xe8,0x64,0xe1,0x88,0x64,0x70,0xb4,0x92,0xd3,0x73,0x0d,0x3d,0x35, - 0xe1,0x36,0xbe,0x05,0x22,0xa7,0x2d,0x87,0x23,0xa6,0x54,0x9e,0xb9,0x99,0xc6,0x89, - 0xe4,0xaf,0xf6,0xee,0x21,0xdc,0x19,0x72,0xec,0x12,0xef,0x92,0x1e,0x85,0xbc,0x63, - 0xf6,0xb7,0x45,0x36,0x11,0xb4,0x1f,0x99,0xc8,0x9a,0xd2,0x25,0x86,0x60,0xb7,0x95, - 0xeb,0x9d,0x13,0xed,0x91,0xe2,0x34,0x92,0x9f,0xea,0xfb,0x66,0x4b,0x45,0x6f,0x0d, - 0x60,0xd8,0x3a,0x65,0xfc,0xad,0xec,0x6b,0xd3,0x31,0x0c,0xd2,0xf6,0x49,0x40,0xbb, - 0xe2,0x84,0x53,0x79,0x0d,0xdc,0xe5,0x7d,0x11,0x8e,0xae,0xd8,0x5f,0xc8,0x00,0x18, - 0x5d,0xcf,0x32,0xc5,0xbb,0x07,0xf2,0x1e,0x02,0xee,0x95,0x6f,0xc0,0x00,0x5e,0xb1, - 0xd7,0x9b,0x9e,0xab,0xa1,0xa6,0x23,0x3d,0x24,0x03,0xab,0x45,0x17,0xb6,0x9c,0x54, - 0x9c,0xab,0x22,0xe9,0x9e,0xcf,0xa2,0xce,0x1b,0x20,0x66,0xe0,0xe9,0x2b,0x7a,0x40, - 0x25,0x2c,0xe0,0xb7,0x8a,0x40,0x34,0x3f,0xcb,0x1c,0x6a,0x53,0xdb,0xf6,0x2e,0x5d, - 0x60,0x3d,0x7d,0xf7,0xfe,0x6d,0x87,0xa3,0x49,0xfb,0x2c,0xcf,0x13,0x49,0x5f,0x6d, - 0x02,0x0e,0x27,0xb0,0x59,0xac,0x2d,0x68,0x4d,0x95,0xca,0x4b,0xbf,0xdb,0x9d,0x60, - 0x23,0x3b,0x1f,0xba,0xd8,0xb1,0x76,0x65,0x3a,0x99,0xcd,0xd3,0x49,0xb7,0xb1,0x25, - 0xed,0x0e,0x93,0x6c,0xd8,0x9d,0x85,0x71,0xda,0x8d,0xb2,0x51,0xd9,0x85,0x0d,0xc7, - 0x9d,0xd0,0x6c,0x49,0x66,0x51,0xeb,0xb4,0xa4,0xcd,0xb6,0xa3,0x37,0x0b,0xc3,0x5b, - 0x03,0x45,0xc2,0xd3,0x3f,0x6a,0xaa,0x32,0x97,0x23,0x9e,0x47,0xef,0xaa,0x53,0x05, - 0xe1,0x29,0x9a,0x80,0x6a,0x18,0xbc,0xd0,0x3d,0x13,0xe8,0xaa,0x17,0x3c,0xf6,0xea, - 0xe7,0xb8,0xff,0xd4,0x8e,0xeb,0x87,0xc7,0x41,0x71,0x3e,0x03,0x5c,0x72,0x81,0xf0, - 0x5f,0x24,0x92,0xbe,0x3e,0xbf,0x7d,0x15,0xa1,0xc7,0x72,0x8f,0x9e,0x84,0xa7,0xb3, - 0x9f,0x80,0x5f,0x45,0xa0,0x80,0x71,0x41,0x6f,0x72,0x04,0xe3,0x30,0x29,0xa5,0x13, - 0x15,0xe1,0x64,0x82,0x40,0x66,0x2e,0x69,0x03,0xc1,0x9b,0x1a,0x19,0x99,0x96,0x79, - 0x39,0xfc,0x99,0x9e,0xe4,0xaf,0x1a,0x06,0x7b,0xdd,0x2e,0x47,0x00,0x8e,0x50,0xa6, - 0x5c,0x59,0xd6,0x4f,0x1d,0xc1,0x1c,0xe8,0x51,0xa3,0x79,0xca,0x55,0x12,0x21,0xa3, - 0x58,0xe9,0xac,0xa5,0x4f,0xad,0xc4,0x4d,0x8c,0xed,0x08,0x0c,0x92,0x1c,0x23,0x0b, - 0x23,0x22,0x56,0x3f,0xb0,0x84,0xf5,0x67,0xfc,0x48,0xff,0x38,0x2e,0x66,0x37,0x61, - 0x21,0x8d,0xa3,0x61,0x7b,0x49,0x15,0x33,0xa6,0xec,0xae,0x04,0x00,0xdd,0xf0,0x4a, - 0xda,0xd7,0xf6,0x62,0xb5,0xac,0xb7,0x2f,0x5d,0x7e,0x53,0x25,0xb8,0x5e,0x12,0xf1, - 0x33,0x4c,0x54,0xce,0x13,0x45,0x3c,0x96,0x61,0x1c,0x89,0x21,0x76,0x6b,0x91,0x14, - 0xb5,0xc0,0xb3,0x44,0x87,0xb6,0x51,0x38,0x27,0x8f,0x8d,0x95,0x2f,0xca,0x04,0xfc, - 0x21,0xf1,0x88,0xb6,0xa9,0xe7,0xd8,0x0e,0x91,0xba,0xe2,0x26,0x86,0x88,0x36,0xd5, - 0x30,0x32,0xdd,0x85,0x9d,0x06,0x1b,0xc8,0x77,0x12,0xf1,0xf4,0x5a,0xd2,0xe3,0x5a, - 0x6a,0xca,0xf4,0xf5,0x01,0x1c,0xd7,0x3b,0xe8,0x91,0x2b,0x02,0xb7,0x51,0x83,0x7f, - 0x40,0x03,0xd5,0x66,0xc3,0x2f,0x9d,0xec,0xea,0xbd,0xfc,0xa4,0x1c,0x9a,0xd9,0x5e, - 0xc0,0x79,0x49,0x69,0x54,0xe1,0x0d,0x48,0x1c,0x3f,0xbd,0xfb,0xfe,0x52,0x86,0xc5, - 0x68,0xfa,0x96,0x3b,0xb7,0x6d,0x47,0x26,0xc1,0x9f,0xda,0xd4,0xf9,0xf3,0x67,0x0b, - 0x5c,0x58,0xf6,0x00,0x63,0xe0,0x6c,0x6d,0x1a,0x47,0x27,0xb3,0xda,0xa1,0x4a,0x9b, - 0x68,0xf0,0x4b,0x16,0x57,0x8e,0x6e,0xf9,0x70,0xf5,0x91,0x3b,0xaf,0xbd,0x75,0xa0, - 0x5f,0x06,0x22,0x8b,0xb4,0x9c,0x05,0xdc,0x70,0x9a,0x45,0xbe,0xf5,0xf6,0xc7,0xcb, - 0xf7,0x96,0x43,0x04,0x7c,0xfa,0x58,0xee,0x78,0x2f,0xc1,0x10,0x59,0xbd,0x65,0xb0, - 0xfd,0x62,0x8c,0xae,0x0b,0x8d,0xdb,0x91,0x9b,0x5d,0x05,0x81,0x36,0x21,0x7b,0x01, - 0x25,0x95,0xe1,0x2d,0x1a,0x25,0xbd,0xca,0x80,0x45,0x8c,0x43,0x28,0x3a,0xb2,0x1c, - 0x0b,0x0d,0x96,0x43,0x27,0xf5,0x95,0x65,0x2f,0x99,0x42,0x4e,0xe7,0x08,0xa0,0x37, - 0x00,0x29,0x2d,0x2b,0xdb,0xd0,0x30,0x92,0xb3,0xb2,0xab,0xf5,0x71,0xb4,0x48,0x30, - 0x34,0x22,0xdb,0x6d,0xbe,0xb0,0x61,0x86,0x59,0x69,0x66,0xf4,0xb3,0x2a,0x6b,0x69, - 0x40,0xb0,0xe2,0x61,0x09,0x49,0x2d,0x1b,0xf6,0x06,0x8b,0xfe,0x7b,0xd2,0xd9,0x50, - 0xa5,0xec,0x47,0x46,0x6b,0xbc,0xc8,0x3f,0xb5,0x2d,0x5d,0xe2,0xb5,0x6c,0x57,0xef, - 0xa6,0xe8,0x82,0x98,0xc0,0x84,0xf4,0x7a,0x13,0x8f,0x62,0x95,0x2f,0xcc,0xa1,0x7e, - 0xa9,0x96,0x15,0x85,0x1d,0x82,0x23,0x99,0xed,0xef,0x93,0xd8,0x1e,0x54,0x62,0xab, - 0x0d,0x5e,0x5f,0x6a,0xf6,0x60,0x98,0x3f,0xc2,0xe1,0x78,0xf7,0xe5,0x9b,0xdd,0x16, - 0xd5,0x37,0xf4,0xfe,0xa9,0xc2,0x3a,0x74,0x22,0xe8,0xd4,0x7b,0xaa,0xba,0xfe,0x61, - 0xee,0xf2,0x09,0x16,0xdb,0xb8,0xe7,0x7b,0x75,0x02,0x8d,0xa3,0x28,0x91,0xae,0xa0, - 0x17,0x42,0x80,0x3e,0x55,0x71,0x4b,0x3e,0xf4,0x61,0x06,0x67,0x8a,0xf3,0x24,0x26, - 0xc3,0x46,0x64,0x8a,0xcb,0xac,0xf8,0x48,0x4f,0x19,0x26,0xb7,0xec,0xf8,0xb1,0x46, - 0x47,0xe2,0x54,0x78,0x2e,0x87,0x9e,0xef,0xce,0x5e,0xfd,0x70,0x19,0x7c,0xf8,0x70, - 0xec,0x78,0x1f,0x9d,0x0f,0x07,0xfc,0xf9,0x98,0x3f,0xfb,0xfc,0xe9,0x99,0xcf,0x3e, - 0x7f,0x3e,0xe6,0xcf,0x03,0xfe,0x3c,0xe2,0xcf,0x63,0xdd,0x47,0xdf,0xf6,0x8e,0x3e, - 0x7e,0x1c,0xac,0x34,0x32,0x81,0x55,0xbc,0xa2,0xdc,0xd8,0x06,0x57,0x0e,0xb8,0x61, - 0x55,0x54,0x7e,0x10,0x07,0xbd,0x41,0x7c,0xc2,0xf3,0xbb,0xba,0x3e,0x3e,0x88,0x1f, - 0x3d,0xb2,0x21,0x58,0x6e,0xfb,0x10,0x7f,0xfc,0xd0,0xfb,0x08,0x9b,0xc4,0xd8,0xfd, - 0xfd,0xba,0xc9,0xa3,0x26,0x22,0x65,0x0c,0x29,0x6e,0x78,0x0b,0x7a,0x9e,0x7a,0xcf, - 0x7a,0x7e,0x93,0x66,0xc7,0x5b,0x33,0x12,0x62,0x89,0x54,0xda,0x9e,0xd4,0xee,0x31, - 0xc1,0x34,0x18,0xd6,0xb6,0xfe,0x71,0xde,0xeb,0x45,0x4f,0xac,0x47,0xd4,0x60,0xfb, - 0xed,0x09,0xe6,0xe2,0x76,0xaf,0x4b,0x6d,0x1e,0xda,0x2c,0xde,0x12,0x5b,0x36,0xab, - 0xb5,0xdf,0xab,0x15,0x16,0x72,0x56,0x1c,0x08,0x82,0x69,0x5a,0xc5,0x92,0x80,0x59, - 0x46,0x7f,0x1a,0x11,0x05,0xe0,0x56,0xcf,0xed,0x00,0xdc,0xd5,0x71,0x04,0xda,0x7e, - 0x8f,0xd1,0x41,0xbf,0xf7,0x2d,0xcd,0xdc,0xa5,0xa9,0x06,0xe6,0x9e,0x0a,0x1a,0x0c, - 0x3f,0xb2,0x04,0xb1,0x38,0x7c,0x22,0xac,0x47,0x6d,0x7e,0x27,0x8b,0x9f,0x56,0x69, - 0x1b,0x02,0xdf,0x7a,0x3d,0xbb,0x8b,0x7f,0x8f,0xac,0x2e,0x71,0x63,0x11,0x0d,0x08, - 0x93,0xa6,0x52,0x8f,0x82,0x3b,0xc6,0x82,0xe3,0xee,0xd6,0x78,0x0d,0x29,0xad,0x86, - 0x64,0xd5,0xba,0xa7,0xf1,0x33,0xa9,0x08,0x74,0x5b,0xf9,0xed,0x9f,0xe6,0x40,0xde, - 0x1a,0x20,0x65,0xc5,0x59,0x92,0xb4,0xad,0x5d,0x4f,0x80,0x92,0x00,0x6b,0x62,0x61, - 0x51,0x50,0x86,0x30,0x94,0xce,0x8a,0x22,0xbc,0x75,0xe3,0x92,0xff,0xa2,0xfd,0xd9, - 0xb5,0xff,0xe1,0xda,0xe1,0xff,0x3e,0x36,0x06,0x55,0xf1,0x86,0x64,0xc8,0xc9,0x33, - 0xaa,0x24,0x86,0xa4,0x11,0x44,0x6e,0xa1,0xf2,0xb7,0x12,0x2c,0x9e,0xf6,0x06,0xda, - 0xf9,0xd9,0xf1,0x9b,0xaf,0x35,0x3e,0x40,0x27,0x82,0xd1,0x33,0xf9,0xcc,0xca,0xc6, - 0x63,0xcb,0x6f,0x63,0x24,0x94,0x5d,0x9f,0xe5,0x10,0x86,0x80,0xac,0x6a,0x5a,0x88, - 0x82,0x96,0x6d,0xd3,0xdb,0x96,0xd0,0x70,0x8a,0x94,0xac,0xa3,0x7a,0x45,0x9b,0xb1, - 0xdc,0x0f,0xf4,0xbe,0x95,0x55,0xce,0x87,0x34,0x12,0x94,0x28,0x40,0xac,0x66,0xb9, - 0xb2,0x7c,0x4b,0x0f,0xe2,0x9c,0x2f,0xc3,0x08,0x59,0xc2,0xa2,0xbf,0xba,0x95,0x5e, - 0x23,0xab,0xfb,0xdb,0x0b,0x7e,0x83,0xa1,0x41,0x96,0xdf,0x9e,0x20,0x56,0x07,0x66, - 0x54,0x67,0x73,0x4d,0x16,0x43,0x2e,0xca,0xa4,0x9c,0xf4,0xe4,0x46,0xf7,0x7c,0xb3, - 0xfb,0x7b,0x76,0x20,0x0e,0x42,0x5c,0xde,0xe7,0x54,0x48,0xb1,0x85,0xe9,0x6c,0x6e, - 0xc5,0x5c,0x6b,0x40,0xc1,0x5e,0x22,0xb6,0x11,0xab,0x58,0xde,0x5d,0x3c,0xa6,0x5f, - 0x60,0xf1,0xbc,0x96,0x2f,0xdc,0x63,0x43,0xbe,0xab,0x43,0x1c,0x2d,0x10,0x96,0xee, - 0x1d,0xac,0xd7,0xa5,0xf1,0x98,0xdc,0x8f,0x19,0xe6,0xad,0x20,0x55,0x50,0x25,0x10, - 0xcc,0x8d,0xcb,0xc4,0x29,0x8c,0x3f,0xb3,0xc4,0x2b,0xa5,0x91,0x4d,0x7d,0x84,0x23, - 0xb2,0xfa,0xf1,0x70,0x57,0xab,0xa5,0x5a,0xdb,0xce,0x35,0xdd,0xbd,0x20,0x33,0x45, - 0xdb,0x3a,0x0f,0x93,0x84,0x2a,0xe9,0xd6,0x23,0xdd,0xf4,0xc8,0xfa,0xf5,0xaf,0xff, - 0x69,0xc1,0x60,0xbe,0xaf,0x0d,0xe6,0xcb,0x6b,0xaa,0x98,0x7d,0x45,0xc7,0x43,0x44, - 0x49,0x9f,0x4d,0x21,0xcc,0x94,0x2e,0xf6,0x20,0xf4,0x48,0x17,0x23,0xa1,0xea,0xd8, - 0xa9,0x2b,0x7e,0x90,0xea,0x26,0x2b,0xae,0x44,0xa5,0xf6,0x7a,0x1b,0xac,0x0f,0x9a, - 0xca,0xe6,0x49,0x13,0xad,0xf2,0xbc,0x79,0x50,0x74,0x4b,0x35,0xe7,0x4a,0xde,0xac, - 0x79,0x4a,0xa0,0x2c,0x94,0xb5,0x33,0x1f,0x26,0xb8,0x3a,0x4c,0x63,0x31,0xd2,0xeb, - 0xb6,0x74,0xce,0xa0,0x2d,0x82,0xec,0xb6,0x06,0x78,0xf6,0x76,0xa6,0xd5,0x0b,0x23, - 0xff,0x31,0x0e,0x63,0xce,0x1e,0x37,0x75,0xba,0x75,0x12,0xc9,0x1a,0x04,0x9f,0x50, - 0x60,0x9b,0xc4,0x8a,0xaf,0x8f,0x2c,0xbb,0xf6,0xa2,0x35,0x4a,0x7c,0xfa,0xee,0x9a, - 0xf3,0xfb,0xc0,0x0c,0x43,0x57,0x7a,0x48,0xab,0x9a,0x97,0xd6,0xb5,0x31,0x29,0x26, - 0x88,0x73,0xb0,0xd6,0xb1,0x10,0xf7,0x7c,0x9e,0x90,0xcc,0xe8,0xf3,0xe7,0xc3,0x5e, - 0xef,0x80,0x42,0x21,0x85,0x01,0xd1,0xd8,0x27,0x54,0xb4,0x68,0x77,0xb7,0xa5,0xc0, - 0x91,0x3e,0xb9,0x42,0x86,0xea,0x3d,0xb3,0x20,0x23,0x44,0x15,0x74,0x64,0x93,0x30, - 0xb7,0x2a,0xc6,0x47,0xa9,0xda,0xe6,0xa4,0xf8,0xf4,0xf9,0x33,0xc7,0xdf,0x2a,0xf4, - 0x28,0x6e,0xa8,0x22,0x06,0x22,0x39,0xe6,0xc8,0xe6,0x8a,0x8a,0x44,0x40,0x1d,0x16, - 0x30,0x7a,0xc5,0x0e,0x1f,0x2d,0xad,0x87,0xa1,0xd1,0x14,0x34,0xd0,0x0a,0xe9,0xc1, - 0x23,0x1b,0x42,0xe3,0x23,0x9a,0xbb,0xfa,0xa2,0x23,0x75,0xaf,0x27,0x45,0x4e,0x2d, - 0x03,0x8a,0xd1,0x7a,0xe6,0x79,0xf2,0xf9,0xb3,0x67,0x53,0x2e,0x6f,0xb4,0x5e,0xc4, - 0xd7,0xd4,0x5a,0x0d,0x29,0x93,0xa0,0xce,0x10,0x4d,0xe0,0x5b,0x22,0xe1,0x97,0x27, - 0x07,0x83,0x12,0x59,0xbe,0x06,0x9e,0x0f,0xaa,0x1d,0x8e,0x5d,0x26,0x1f,0xca,0x8f, - 0xc6,0x64,0xd6,0x90,0x43,0x89,0x76,0x9e,0x11,0x7f,0x4d,0x5c,0x20,0x72,0x93,0x40, - 0x83,0x02,0xfd,0x66,0x7b,0xbb,0x31,0xdc,0xfe,0xa8,0x7b,0xe9,0x26,0x00,0x6b,0xc8, - 0xf7,0x07,0x3a,0xf4,0x59,0x4b,0x4f,0xc8,0x4d,0x9b,0xd6,0xb0,0x9e,0xa0,0x19,0xa2, - 0xee,0xad,0x73,0xb9,0xbf,0xff,0xa0,0xb1,0x07,0x33,0xcb,0xe0,0xc5,0x91,0x1c,0x89, - 0x59,0x97,0xdd,0x76,0x85,0x15,0x67,0xce,0x6a,0x1f,0x38,0xd3,0x70,0xa6,0x44,0x9a, - 0x47,0x6a,0xa0,0x15,0xc5,0x06,0xce,0x50,0xd3,0xb2,0x11,0xf4,0x30,0x07,0x69,0x48, - 0x9f,0x14,0x05,0x44,0xbc,0xe2,0x86,0xb7,0x10,0x30,0x84,0xbb,0x53,0xee,0x43,0xde, - 0x03,0xe9,0xe7,0xfd,0xd6,0xb7,0x1e,0x1a,0x72,0x1d,0x30,0xce,0x5a,0xb1,0x7e,0x1d, - 0x68,0x35,0xbe,0xcd,0xca,0xcf,0x9f,0x3f,0x1c,0x1d,0x38,0xd5,0xff,0x1f,0x6d,0xf0, - 0xa7,0x79,0xba,0x22,0x56,0x37,0xe5,0x17,0xd7,0x7e,0xa7,0xdf,0x27,0x26,0x03,0x6a, - 0xb7,0xaf,0xbb,0x00,0x15,0x00,0x13,0x64,0xc9,0xdf,0x50,0x00,0xec,0x7d,0x63,0x7d, - 0x81,0x88,0x7b,0xbd,0xa1,0x04,0xa2,0x72,0xed,0x5b,0x7f,0xfb,0x2f,0x0d,0x61,0x96, - 0xda,0x68,0xab,0xba,0xed,0x96,0xab,0x57,0x56,0xdb,0x74,0x74,0x32,0xe0,0x5a,0x78, - 0x0f,0x56,0x49,0x95,0x50,0x4c,0xc3,0x59,0xb2,0x94,0xeb,0x1b,0xc1,0xfa,0xf6,0x83, - 0x77,0x03,0x14,0x55,0xcc,0x76,0xc0,0x38,0x9c,0x6f,0xd1,0xa0,0xa5,0x63,0x69,0xe9, - 0x96,0x52,0x87,0xc5,0xb8,0xd0,0xa7,0x33,0xd8,0x97,0x5c,0xf1,0x56,0xcf,0x5e,0x0e, - 0xf6,0x1a,0x4e,0xf6,0x1b,0xe7,0xa0,0x41,0xeb,0x73,0x14,0x6b,0x07,0xa7,0x9b,0xd3, - 0x18,0x43,0xc1,0x2c,0x5c,0x6d,0x6a,0x4e,0xd3,0xac,0x15,0xa8,0x62,0x2e,0x69,0x6f, - 0x46,0x53,0x56,0x82,0xd1,0x2f,0xe1,0x5f,0xaf,0x7c,0x15,0x26,0xa8,0xfd,0x67,0xb0, - 0xc3,0xbd,0xae,0xe1,0x54,0xcc,0xab,0x81,0xcb,0x0b,0x1d,0x0d,0x7c,0xba,0x5c,0xfa, - 0x0b,0x1d,0x05,0x7c,0xc2,0xae,0x60,0xbf,0x3e,0xd3,0xa4,0xd7,0xec,0x38,0xcf,0x37, - 0x60,0x6c,0xb5,0x82,0x25,0x56,0xa0,0xe1,0x5e,0x5e,0x64,0x2a,0x23,0x7c,0x58,0xbd, - 0xe0,0xce,0xbf,0xe7,0xd0,0xae,0x19,0x6b,0xfc,0x66,0x43,0xc2,0x76,0x0b,0x30,0x92, - 0xa5,0x0c,0x2b,0x9b,0x0b,0xae,0x6b,0x25,0xbc,0xda,0x2a,0xc0,0x6c,0x79,0x53,0xb5, - 0x6e,0x96,0x85,0x5e,0xef,0x6f,0x65,0x83,0x7e,0xe9,0xc0,0x44,0xaa,0xeb,0xa5,0xbd, - 0xac,0xa6,0x22,0x24,0x6a,0x6b,0x68,0xca,0x5f,0x97,0x83,0x8a,0xd7,0x6d,0xed,0xac, - 0x17,0x76,0x76,0x49,0x7c,0xc5,0x5f,0x15,0xcf,0x78,0xb7,0x10,0x7c,0x5d,0x01,0xf5, - 0xda,0x1f,0x6c,0x2f,0x3e,0xe7,0xb0,0x10,0x34,0xe7,0x98,0x48,0x75,0xa6,0x54,0x11, - 0x0f,0x91,0x73,0xdb,0x96,0x3e,0x61,0x84,0x81,0x3d,0xf2,0x34,0x1d,0x5d,0x07,0x71, - 0xb6,0x05,0xf9,0xac,0xd6,0x33,0xdc,0xef,0xb5,0xf5,0x48,0x93,0xd6,0xdb,0x94,0xbe, - 0xf7,0xb4,0x7f,0x87,0xda,0xe1,0x84,0x36,0xdb,0x2e,0x7b,0xe9,0x2e,0xd9,0x18,0x9f, - 0x48,0xfd,0xa6,0xb9,0x3e,0xb3,0x3c,0x4c,0xd3,0x83,0x77,0xac,0xb7,0x9a,0x43,0x0b, - 0xdc,0xd3,0xdf,0x80,0x78,0x1d,0x2b,0xab,0xa6,0x6a,0xa2,0x91,0x1d,0x26,0x53,0x6f, - 0xd9,0xc9,0x64,0x4c,0x7f,0x7e,0xf8,0x8c,0xbb,0xeb,0x87,0xae,0x82,0xe6,0x2f,0x86, - 0x48,0x58,0x09,0x02,0x7c,0xaa,0x2e,0xe4,0x38,0xc4,0x56,0x1b,0x19,0x4e,0xff,0x94, - 0xc5,0x16,0xec,0x71,0x21,0xd1,0x99,0x4e,0x80,0x75,0x65,0x82,0x39,0xdf,0x8c,0x7f, - 0x74,0xd7,0xb2,0x9d,0xa9,0x33,0x7d,0x66,0xe9,0xaa,0x1c,0x3d,0x98,0x11,0x1b,0x14, - 0x88,0x75,0xf1,0x73,0x55,0x32,0xaa,0xd6,0xb3,0xc2,0x3c,0x3b,0x62,0x8b,0xb1,0xc2, - 0xfd,0x7d,0xfa,0x24,0x10,0x64,0xaf,0x4d,0xee,0x54,0xcd,0xce,0xae,0xa9,0xcc,0x04, - 0xf5,0x73,0x5c,0xbb,0xa7,0x58,0x27,0x68,0x41,0xda,0x86,0x41,0x26,0x46,0x8f,0xc5, - 0x99,0x07,0x4a,0x9a,0x4f,0xc6,0x31,0x6d,0xae,0x85,0x34,0xaa,0x8d,0x56,0x49,0xbf, - 0xb8,0x23,0x7d,0x2a,0x95,0xc7,0x7c,0x38,0x90,0xeb,0x2a,0x9f,0xae,0x2b,0x52,0x47, - 0x15,0x0e,0x09,0xd9,0xea,0x27,0x0b,0x9d,0x0a,0x8b,0x12,0xa5,0xea,0x59,0x2c,0x7a, - 0x04,0x65,0x1a,0xe7,0x0e,0x57,0x2d,0xcd,0xc9,0xd5,0x44,0x3a,0xf7,0x79,0xf0,0xde, - 0xdd,0x43,0x28,0xe6,0x5f,0x20,0x69,0x56,0xd8,0x3c,0x40,0x3f,0x67,0xb3,0xd4,0x63, - 0x57,0x3b,0x4e,0xb2,0x5d,0xcc,0xfe,0xd3,0xe5,0x73,0x5d,0xe0,0x96,0x9f,0x62,0x7a, - 0x28,0x85,0x0b,0x2c,0x31,0x15,0xc6,0xf9,0x17,0x4a,0x30,0x71,0x06,0x34,0x8b,0x5e, - 0x7a,0x57,0x62,0x2a,0xb0,0x3f,0xbe,0xff,0x8e,0x0e,0x13,0x47,0x57,0x8e,0x39,0x3f, - 0xa4,0x5f,0xf6,0x20,0x72,0xcc,0x6c,0x99,0xe9,0xda,0x64,0x48,0xb5,0x9d,0xf8,0xa7, - 0x72,0x28,0xf8,0x31,0x54,0x57,0xbc,0x96,0x32,0x37,0x9d,0x81,0xdd,0x65,0x39,0xd5, - 0xd3,0x51,0x4d,0x0a,0xb2,0xa3,0x7a,0x38,0x63,0x66,0xc9,0x44,0x27,0x19,0x2f,0xcb, - 0xfc,0x88,0x4b,0xf3,0x47,0x55,0x0c,0x78,0x98,0x07,0xa5,0x6b,0x26,0xa8,0x51,0x34, - 0x70,0x82,0xab,0x65,0x1c,0x3c,0x98,0x57,0x7b,0xd1,0xb9,0xa9,0xb1,0xd4,0xd7,0x75, - 0xa9,0xd9,0x5e,0x34,0xca,0xce,0x1c,0x6d,0x31,0x63,0xe3,0x07,0x65,0xda,0x10,0x61, - 0xcf,0x5e,0xd6,0x45,0x8c,0xb9,0x3b,0xcb,0x90,0x81,0xc9,0x5d,0x37,0xf7,0xd7,0xf3, - 0x15,0xea,0x85,0xf8,0xaa,0x36,0x55,0xb5,0xd1,0x2f,0xb5,0x50,0x6e,0x5f,0x3d,0x4b, - 0x67,0xb6,0xa4,0xbc,0xec,0xaa,0xc6,0x41,0x03,0x2e,0x8a,0x2c,0xcf,0xc1,0x1a,0xd7, - 0x3a,0x98,0x4e,0xa3,0x15,0x94,0x22,0xfd,0x6d,0xb5,0x07,0x19,0x6e,0xed,0xe5,0xd4, - 0xc6,0xbd,0x15,0x40,0x6e,0x2c,0x40,0x6f,0xe3,0x75,0x10,0x6b,0xfc,0xde,0x4a,0x57, - 0x3f,0xf0,0xdb,0xd5,0x3f,0x27,0xf5,0xbf,0xe2,0xf5,0x68,0xdf,0x5f,0x4a,0x00,0x00, + 0x2e,0x2c,0x68,0x33,0x20,0xda,0x34,0x99,0x4f,0xd8,0x90,0xc0,0xa3,0x46,0x12,0xdf, + 0x23,0x19,0x20,0x45,0x61,0x3b,0x91,0x89,0xfd,0x70,0x96,0x0f,0x34,0x7e,0xa4,0x5b, + 0xef,0xab,0xc1,0x6f,0x30,0xd8,0x16,0xfa,0xe1,0x2d,0xcd,0x5f,0x1e,0x8f,0xae,0x80, + 0x2f,0x19,0x88,0x7d,0x09,0xb1,0x5c,0xde,0xc4,0x6a,0x34,0xad,0x91,0x8a,0xde,0x1e, + 0xd5,0xb9,0x1a,0x09,0xa8,0xf5,0xa5,0x44,0x45,0x49,0x5b,0xb3,0x43,0xc0,0x9f,0xc0, + 0x00,0xe3,0x90,0xc6,0xfe,0x69,0xa7,0x63,0x67,0xbf,0x19,0xbc,0xff,0x38,0x1e,0x1b, + 0x70,0xc3,0x8b,0xcb,0x20,0x67,0x82,0xeb,0x2c,0x5e,0x0d,0xbe,0x19,0xa1,0x33,0x48, + 0xf9,0xfa,0xa2,0x09,0x30,0x01,0x19,0xd4,0xab,0xe6,0x82,0x9d,0xb1,0x4c,0x85,0x3d, + 0xb7,0x9a,0x9e,0xbe,0x01,0x62,0x08,0x27,0x00,0x5a,0xf8,0x4e,0xd7,0xaf,0x52,0xd1, + 0x26,0x5c,0xc1,0x72,0xd7,0xa7,0x10,0xf5,0xbd,0x1f,0x21,0x9d,0xb6,0x06,0x60,0x74, + 0x97,0xba,0x99,0x9b,0x5d,0x50,0x6b,0x90,0x8d,0xd8,0x84,0x09,0x9c,0xf5,0xe1,0x60, + 0xb8,0xa4,0x26,0x46,0x75,0x1e,0xda,0x0e,0x1c,0x51,0x9d,0xfd,0xd5,0x00,0x8e,0x67, + 0x8b,0x01,0x73,0x23,0x31,0x4f,0x55,0x9c,0x00,0xdb,0x4d,0x69,0xcd,0xfc,0xcc,0x81, + 0x86,0x38,0x64,0xa9,0x7a,0x1f,0x71,0x11,0x63,0xeb,0xa1,0xb2,0xc2,0xaa,0x50,0xbb, + 0x3b,0x8b,0x23,0x03,0xd7,0xc5,0x4d,0x01,0x09,0xb3,0xf8,0x4a,0x42,0x35,0x66,0x72, + 0x60,0x3b,0xfd,0x7d,0x27,0xa7,0x07,0xc4,0xe9,0x93,0x9d,0x9c,0x56,0xa7,0x98,0x2b, + 0xec,0xb5,0xa3,0x1b,0xb3,0x49,0x9b,0xac,0x58,0x97,0xb5,0xd9,0xc6,0x81,0x80,0x4b, + 0xd2,0xdc,0x30,0xa3,0x5d,0xe9,0xce,0xb9,0x7f,0xa0,0x63,0x82,0x23,0x92,0xc1,0xd1, + 0x4a,0x4e,0xcf,0x35,0x8e,0xd4,0x84,0xdb,0xf8,0x16,0x88,0x9c,0xf6,0x0f,0x8e,0x98, + 0x52,0xad,0xe5,0x66,0x1a,0x27,0x92,0xbf,0xda,0xbb,0x87,0x70,0x67,0xc8,0xb1,0x4b, + 0xbc,0x4b,0x7a,0xae,0xf1,0x8e,0xd9,0xdf,0x16,0xd9,0x44,0xd0,0xe6,0x62,0x22,0x6b, + 0x4a,0x97,0x18,0x82,0xad,0x53,0xae,0xb7,0x41,0xb4,0xe1,0x89,0xd3,0x48,0x7e,0xaa, + 0xef,0x9b,0xfd,0x11,0xbd,0x02,0x80,0x61,0xeb,0x94,0xf1,0xb7,0xb2,0xaf,0x4d,0xb3, + 0x36,0xb0,0xd9,0x27,0x01,0xdd,0xc3,0xe9,0x1d,0x51,0xb2,0xbb,0x02,0x54,0x9f,0xbe, + 0x2f,0xc2,0xd1,0x15,0xc1,0x49,0xf6,0x01,0x46,0xce,0xb3,0x4c,0xf1,0xce,0x80,0x9c, + 0x91,0x40,0x79,0xe5,0x2a,0xb0,0x87,0x57,0xec,0xc2,0xa6,0xe7,0x6a,0xa8,0xe9,0x48, + 0x0f,0xc0,0xc0,0x88,0xd1,0x85,0x0d,0x29,0x15,0xe7,0xaa,0x48,0xba,0xe7,0xb3,0xa8, + 0xf3,0x06,0x68,0x18,0x58,0xf9,0x8a,0x1e,0x3e,0x09,0x8b,0x70,0x86,0x6c,0x0e,0x80, + 0xcc,0xcf,0x29,0x87,0xda,0xf2,0xb6,0x9d,0x4d,0x17,0x4f,0x4f,0xdf,0xbd,0x7f,0xdb, + 0xe1,0xd0,0xd0,0x3e,0xcb,0xf3,0x44,0xd2,0x57,0x9b,0x40,0xc1,0x09,0x4c,0xb8,0x90, + 0xe3,0xa0,0x35,0x55,0x2a,0x2f,0xfd,0x6e,0x77,0x82,0x4d,0xea,0x7c,0xe8,0x62,0x37, + 0xda,0x95,0xe9,0x64,0x36,0x4f,0x27,0xdd,0xc6,0x76,0xb3,0x3b,0x4c,0xb2,0x61,0x77, + 0x16,0xc6,0x69,0x37,0xca,0x46,0x65,0x17,0x26,0x1d,0x77,0x42,0xb3,0xdd,0x98,0x45, + 0xad,0xd3,0x92,0x36,0xd2,0x8e,0xde,0x08,0x0c,0x6f,0x0d,0xcc,0x08,0x4f,0xff,0xa8, + 0xa9,0xca,0x5c,0x8e,0x78,0x1e,0xbd,0x63,0x4e,0x15,0x84,0xa7,0x68,0x02,0xaa,0x4f, + 0xf0,0x42,0xf7,0x4c,0xd4,0xaa,0x5e,0xde,0xd8,0xab,0x9f,0xd1,0xfe,0x53,0x3b,0xae, + 0x1f,0x0c,0x07,0xc5,0xf9,0x0c,0x50,0xc8,0x05,0x7a,0x7f,0x91,0x48,0xfa,0xfa,0xfc, + 0xf6,0x55,0x84,0x1e,0xcb,0x3d,0x7a,0xca,0x9d,0xce,0x75,0x02,0x7e,0xcd,0x80,0xe2, + 0xc7,0x05,0xbd,0xa5,0x11,0x8c,0xc3,0xa4,0x94,0x4e,0x54,0x84,0x93,0x09,0x2c,0xc3, + 0x5c,0xd2,0xe6,0x80,0x37,0x2c,0x32,0x32,0x2d,0xf3,0x72,0xf8,0x33,0x3d,0xa5,0x5f, + 0x35,0x0c,0xf6,0xba,0x5d,0x0e,0x08,0x1c,0xb0,0x4c,0x29,0xb2,0xac,0x9f,0x28,0x82, + 0x39,0xd0,0x63,0x44,0xf3,0x94,0x2b,0x20,0x42,0x46,0xb1,0xd2,0x19,0x49,0x9f,0x48, + 0x89,0x9b,0x18,0x5b,0x0d,0xd8,0x27,0xf9,0x49,0x16,0x46,0x44,0xac,0x7e,0x18,0x09, + 0xeb,0xcf,0xf8,0x71,0xfd,0x71,0x5c,0xcc,0x6e,0xc2,0x42,0x1a,0xbf,0xc3,0xd6,0x91, + 0xaa,0x61,0x4c,0xd9,0x5d,0x09,0x00,0xba,0xe1,0x95,0xb4,0xaf,0xed,0xc5,0x6a,0x59, + 0x6f,0x5f,0xba,0xfc,0x16,0x4a,0x70,0xbd,0x24,0xe2,0x67,0x98,0xa8,0x9c,0x27,0x8a, + 0x78,0x2c,0xc3,0x38,0x12,0x43,0xec,0xc4,0x22,0x29,0x6a,0x81,0x67,0x89,0x8e,0x74, + 0xa3,0x70,0x4e,0x0e,0x1c,0x2b,0x5f,0x94,0x09,0xf8,0x43,0x16,0x11,0x6d,0x53,0xab, + 0xb1,0x1d,0x22,0x75,0xc5,0x4d,0x0c,0xff,0x6c,0xaa,0x4f,0x64,0xba,0x0b,0x3b,0x0d, + 0x36,0x87,0xef,0x24,0xc2,0xeb,0xb5,0xa4,0x47,0xb1,0xd4,0x94,0xe9,0xeb,0xc3,0x35, + 0xae,0x65,0xd0,0xe3,0x54,0x04,0x5c,0xa3,0x06,0xff,0x48,0xfb,0xaa,0xcd,0x86,0x5f, + 0x3a,0xd9,0xd5,0x7b,0xf9,0x49,0x39,0x34,0xb3,0xbd,0x80,0x2f,0x93,0xd2,0xa8,0x7a, + 0x1b,0x90,0x38,0x7e,0x7a,0xf7,0xfd,0xa5,0x0c,0x8b,0xd1,0xf4,0x2d,0x77,0x6e,0xdb, + 0x8e,0x4c,0x82,0x3f,0xb5,0xa9,0xf3,0xe7,0xcf,0x16,0xb8,0xb0,0xec,0x01,0xc6,0xc0, + 0xd9,0xda,0x34,0x8e,0x4e,0x5d,0xb5,0x43,0x95,0x36,0xd1,0xe0,0x17,0x28,0xae,0x1c, + 0xdd,0xf2,0xe1,0xea,0x23,0x77,0x5e,0x7b,0xa3,0x40,0xbf,0xe8,0x43,0x16,0x69,0x39, + 0x0b,0xb8,0xe1,0x34,0x8b,0x7c,0xeb,0xed,0x8f,0x97,0xef,0x2d,0x87,0x08,0xf8,0xf4, + 0xb1,0xdc,0xf1,0xce,0x81,0x21,0xb2,0x7a,0x83,0x60,0xfb,0xa5,0x17,0x5d,0xf3,0x19, + 0xb7,0x23,0x37,0xbb,0x0a,0x02,0x6d,0x42,0xf6,0x02,0x4a,0x2a,0xc3,0x5b,0x34,0x4a, + 0x7a,0x4d,0x01,0x8b,0x18,0x87,0x50,0x74,0x64,0x39,0x16,0x1a,0x2c,0x87,0x4e,0xe1, + 0x2b,0xcb,0x5e,0x32,0x85,0x9c,0xce,0x08,0x40,0x6f,0x00,0x52,0x5a,0x56,0xb6,0xa1, + 0x61,0x24,0x67,0x65,0x57,0xeb,0xe3,0x68,0x91,0x60,0x68,0x44,0xb6,0xdb,0x7c,0x19, + 0xc3,0x0c,0xb3,0xd2,0xcc,0xe8,0x67,0x55,0xb2,0xe2,0x2c,0xdb,0xe0,0x61,0x09,0x49, + 0x2d,0x1b,0xf6,0x06,0x8b,0xfe,0x7b,0xd2,0xd9,0x50,0xa5,0xec,0x47,0x46,0x6b,0xbc, + 0xc8,0x3f,0xb5,0x2d,0x5d,0xbe,0xb5,0x6c,0x57,0xef,0x94,0xe8,0x82,0x98,0xc0,0x84, + 0xf4,0xea,0x12,0x8f,0x62,0x95,0x2f,0xcc,0x81,0x7d,0xa9,0x96,0x15,0x85,0x1d,0x82, + 0x23,0x99,0xed,0xef,0x93,0xd8,0x1e,0x54,0x62,0xab,0x0d,0x5e,0x5f,0x6a,0xf6,0x60, + 0x98,0x3f,0xc2,0xe1,0x78,0x67,0xe5,0x9b,0x9d,0x14,0xd5,0x2e,0xf4,0xde,0x48,0x43, + 0x27,0x29,0xe8,0xb4,0xcf,0xa9,0xf7,0x4b,0x75,0x6d,0xc3,0xdc,0xe5,0xd3,0x29,0xb6, + 0x71,0xcf,0xf7,0xea,0x7c,0x1a,0x47,0x51,0x22,0x5d,0x41,0x2f,0x7b,0x00,0x59,0xaa, + 0xe2,0x96,0x7c,0xe8,0xc3,0x0c,0xce,0x14,0xe7,0x49,0x4c,0x86,0x8d,0xc8,0x14,0x97, + 0x59,0xf1,0x91,0x9e,0x20,0x4c,0x6e,0xd9,0xf1,0x63,0x0d,0x96,0xc4,0xa9,0xf0,0x5c, + 0x0e,0x3d,0xdf,0x9d,0xbd,0xfa,0xe1,0x32,0xf8,0xf0,0xe1,0xd8,0xf1,0x3e,0x3a,0x1f, + 0x0e,0xf8,0xf3,0x31,0x7f,0xf6,0xf9,0xd3,0x33,0x9f,0x7d,0xfe,0x7c,0xcc,0x9f,0x07, + 0xfc,0x79,0xc4,0x9f,0xc7,0xba,0x8f,0xbe,0xed,0x1d,0x7d,0xfc,0x38,0x58,0x69,0x64, + 0x02,0xab,0x78,0x45,0xa9,0xb2,0x0d,0xae,0x1c,0x70,0xc3,0xaa,0xa8,0xfc,0x20,0x0e, + 0x7a,0x83,0xf8,0x84,0xe7,0x77,0x75,0xed,0x7b,0x10,0x3f,0x7a,0x64,0x43,0xb0,0xdc, + 0xf6,0x21,0xfe,0xf8,0xa1,0xf7,0x11,0x36,0x89,0xb1,0xfb,0xfb,0x75,0x93,0x47,0x4d, + 0x44,0xca,0x18,0x52,0xdc,0xf0,0x16,0xf4,0x3c,0xf5,0x9e,0xf5,0xfc,0x26,0xcd,0x8e, + 0xb7,0x66,0x24,0xc4,0x12,0xa9,0xb4,0x3d,0xa9,0xdd,0x63,0x82,0x69,0x30,0xac,0x6d, + 0xfd,0xe3,0xbc,0xd7,0x8b,0x9e,0x58,0x8f,0xa8,0xc1,0xf6,0xdb,0x13,0xcc,0xc5,0xed, + 0x5e,0x97,0xda,0x3c,0xb4,0x59,0xbc,0xdd,0xb5,0x6c,0x56,0x6b,0xbf,0x57,0x2b,0x2c, + 0xe4,0xac,0x38,0x10,0x84,0xda,0xb4,0x8a,0x25,0xe1,0xb4,0x8c,0xfe,0x34,0x22,0x4a, + 0x91,0xdd,0xe8,0xb9,0x1d,0x60,0xbd,0x3a,0x8e,0x40,0xdb,0xef,0x31,0x3a,0xe8,0xf7, + 0xbe,0xa5,0x99,0xbb,0x34,0xd5,0xc0,0xdc,0x53,0x41,0x83,0xe1,0x47,0x96,0x20,0x16, + 0x87,0x4f,0x84,0xf5,0xa8,0xcd,0xef,0x5b,0xf1,0x93,0x28,0x6d,0x43,0xe0,0x5b,0xaf, + 0x67,0x77,0xf1,0xef,0x91,0xd5,0x25,0x6e,0x2c,0xa2,0x01,0x61,0xd2,0x54,0xea,0x51, + 0x70,0xc7,0x58,0x70,0xdc,0xdd,0x1a,0xaf,0x11,0xa6,0xd5,0x90,0xac,0x5a,0xf7,0x34, + 0x7e,0xde,0x14,0x81,0x6e,0x2b,0xbf,0xfd,0xd3,0x1c,0x40,0x5c,0xe3,0xa5,0xac,0x38, + 0x4b,0x92,0xb6,0xb5,0xeb,0xe9,0x4e,0x12,0x60,0x4d,0x2c,0x2c,0x0a,0xca,0x10,0x86, + 0xd2,0x59,0x51,0x84,0xb7,0x6e,0x5c,0xf2,0x5f,0xb4,0x3f,0xbb,0xf6,0x3f,0x5c,0x3b, + 0xfc,0xdf,0xc7,0xc6,0xa0,0x2a,0xde,0x90,0x0c,0x39,0x79,0x46,0x95,0xc4,0x90,0x34, + 0x82,0xc8,0x2d,0x54,0xfe,0x56,0x82,0xc5,0xd3,0xde,0x40,0x3b,0x3f,0x3b,0x7e,0xf3, + 0x95,0xc5,0x07,0xe8,0x44,0xa8,0x7a,0x26,0x9f,0x59,0xd9,0x78,0x6c,0xf9,0x6d,0x8c, + 0x84,0xb2,0xeb,0x73,0x1a,0xc2,0x10,0x90,0x55,0x4d,0x0b,0x51,0xd0,0xb2,0x6d,0x7a, + 0x93,0x12,0x1a,0x4e,0x91,0x92,0x75,0x54,0xaf,0x68,0x33,0xb4,0xfb,0x81,0xde,0xa5, + 0xb2,0xca,0xf9,0x90,0x46,0x82,0x12,0x05,0x88,0xd5,0x2c,0x57,0x96,0x6f,0xe9,0x41, + 0x9c,0xf3,0x65,0x18,0x21,0x4b,0x58,0xf4,0x57,0xb7,0xd2,0x2b,0x62,0x75,0x7f,0x7b, + 0xc1,0x6f,0x27,0x34,0xc8,0xf2,0x9b,0x11,0xc4,0xea,0xc0,0x8c,0xea,0x6c,0xae,0xc9, + 0x62,0xc8,0x45,0x99,0x94,0x93,0x9e,0xdc,0xe8,0x9e,0x6f,0x76,0x7f,0xcf,0x0e,0xc4, + 0x41,0x48,0x97,0xee,0xad,0x01,0x85,0x73,0x89,0xe8,0x45,0xcc,0x60,0x01,0x77,0x71, + 0x91,0x7e,0x81,0x89,0xf3,0x5a,0x82,0x70,0x80,0x0d,0x09,0xae,0x8e,0x60,0xf4,0x92, + 0x59,0x7e,0x77,0x30,0x57,0x17,0xb6,0x63,0x72,0x30,0x5e,0x1a,0xef,0xfd,0xa8,0xfe, + 0x29,0x81,0x51,0xc0,0x2e,0x11,0xa7,0x40,0xfd,0xcc,0x12,0xaf,0x94,0xc6,0x2e,0xf5, + 0x01,0x8c,0xc8,0xea,0x87,0xbb,0x5d,0x2d,0xf8,0x6a,0x6d,0x3b,0xd7,0x74,0xf7,0x82, + 0xcc,0x14,0x6d,0xeb,0x3c,0x4c,0x12,0xaa,0x83,0x5b,0x8f,0x74,0xd3,0x23,0xeb,0xd7, + 0xbf,0xfe,0xa7,0x05,0x93,0xf8,0xbe,0x36,0x89,0x2f,0xaf,0xa9,0x62,0xf6,0x15,0x1d, + 0xee,0x10,0x25,0x7d,0xb2,0x84,0x40,0x52,0xba,0xd8,0x74,0xd0,0x03,0x59,0x8c,0x75, + 0xaa,0x43,0xa3,0xae,0xf8,0x41,0xaa,0x9b,0xac,0xb8,0x12,0x95,0x62,0xeb,0x7d,0xaf, + 0x3e,0x26,0x2a,0x9b,0xe7,0x44,0xb4,0xca,0xf3,0xe6,0x31,0xcf,0x2d,0x55,0x8c,0x2b, + 0x79,0x33,0xcc,0xa1,0x14,0xc9,0x42,0x59,0x3b,0xb1,0x61,0x82,0xab,0xa3,0x30,0x16, + 0x23,0xbd,0x2c,0x4b,0xa7,0x04,0xda,0x22,0xc8,0x32,0x6b,0x08,0x67,0x6f,0xe7,0x52, + 0xbd,0x30,0xf2,0x10,0xe3,0x12,0xe6,0xe4,0x70,0x53,0xa7,0x5b,0xe7,0x88,0xac,0x41, + 0xf0,0x09,0x05,0xb6,0x49,0xac,0xf8,0xfa,0xc8,0xb2,0x6b,0x3f,0x59,0xa3,0xc4,0x67, + 0xe7,0xae,0x39,0x7d,0x0f,0xcc,0x30,0x74,0xa5,0x47,0xac,0xaa,0x79,0x69,0x5d,0x1b, + 0x93,0x62,0x82,0x38,0x07,0x6b,0x1d,0x0b,0x91,0xcd,0xe7,0x09,0xc9,0x8c,0x3e,0x7f, + 0x3e,0xec,0xf5,0x0e,0x28,0xd8,0x91,0xa3,0x8b,0xc6,0x4e,0xa0,0xa2,0x45,0xdb,0xb9, + 0x2d,0x05,0x8e,0xf4,0xb9,0x13,0x72,0x50,0xef,0x99,0x05,0x19,0x21,0x6e,0xa0,0x23, + 0x9b,0x84,0xb9,0x55,0x31,0x3e,0x4a,0xd5,0x36,0x27,0xc5,0xa7,0xcf,0x9f,0x39,0xc2, + 0x56,0xc1,0x45,0x71,0x43,0x15,0x13,0x10,0xab,0x31,0x47,0x36,0x57,0x54,0xd3,0x01, + 0xae,0xb0,0x80,0xc2,0x2b,0x76,0xf8,0x60,0x68,0x3d,0xd0,0x8c,0xa6,0xa0,0x81,0x56, + 0x48,0x0f,0x1e,0xd9,0x10,0x1a,0x1f,0xb0,0xdc,0xd5,0x17,0x1d,0xa9,0x7b,0x3d,0x29, + 0xb2,0x66,0x19,0x50,0x14,0xd6,0x33,0xcf,0x93,0xcf,0x9f,0x3d,0x9b,0xb2,0x75,0xa3, + 0xf5,0x22,0xbe,0xa6,0xd6,0x6a,0x48,0x99,0x04,0x75,0x0e,0x68,0x42,0xdb,0x12,0x29, + 0xbd,0x3c,0x39,0x18,0x94,0xc8,0xe3,0x35,0xb4,0x7c,0x50,0xed,0x61,0xec,0x32,0xf9, + 0x50,0x7e,0x34,0x26,0xb3,0x86,0x0d,0x4a,0xb4,0xf3,0x8c,0xf8,0x6b,0xe2,0x02,0x91, + 0x9b,0x04,0x3a,0xed,0xeb,0xf7,0xd2,0xdb,0x8d,0xe1,0xf6,0x47,0xdd,0x4b,0x37,0x01, + 0x3a,0x43,0xbe,0x3f,0xd0,0x91,0xcd,0x5a,0x02,0x42,0xf6,0xd9,0xb4,0x86,0xf5,0x14, + 0xcc,0x20,0x74,0x6f,0x9d,0xcb,0xfd,0xfd,0x07,0x8d,0x5d,0x96,0x59,0x06,0x2f,0x8e, + 0xe4,0x48,0xcc,0xba,0xec,0xb6,0x2b,0x34,0x38,0x73,0x56,0x3b,0xbd,0x99,0x06,0x2c, + 0x25,0x12,0x39,0x82,0x3f,0xad,0x28,0x36,0x80,0x85,0x9a,0x96,0x8d,0xa0,0x87,0x39, + 0x48,0x43,0xfa,0x9c,0x27,0x20,0xe2,0x15,0x37,0xbc,0x49,0x80,0x21,0xdc,0x9d,0x54, + 0x1f,0xf2,0x2e,0x47,0x3f,0xad,0xb7,0xbe,0xb9,0xd0,0xa0,0xea,0x80,0x91,0xd4,0x8a, + 0xf5,0xeb,0x40,0xab,0xf1,0x6d,0x56,0x7e,0xfe,0xfc,0xe1,0xe8,0xc0,0xa9,0xfe,0xff, + 0x68,0x83,0x3f,0xcd,0xd3,0x15,0xb1,0xba,0x29,0xbf,0xb8,0xf6,0x3b,0xfd,0x36,0x30, + 0x19,0x50,0xbb,0x7d,0xdd,0x05,0x6c,0x00,0x5c,0x20,0x4b,0xfe,0x86,0x02,0x60,0xef, + 0x1b,0xeb,0x0b,0x44,0xdc,0xeb,0x0d,0x25,0x10,0x95,0x6b,0xdf,0xfa,0xdb,0x7f,0x69, + 0x90,0xb2,0xd4,0x46,0x5b,0x55,0x5d,0xb7,0x5c,0xbd,0xb2,0xda,0xa6,0xa3,0x93,0x01, + 0xd7,0xc2,0x7b,0xb0,0x4a,0x9b,0x84,0x53,0x1a,0xce,0x92,0xa5,0x5c,0xc1,0x08,0xd6, + 0x37,0x18,0x8c,0xf7,0x29,0xaa,0x18,0xc0,0x6f,0x1c,0xce,0xb7,0x68,0xd0,0xd2,0xb1, + 0xb4,0x74,0x4b,0xa9,0xc3,0x62,0x5c,0xe8,0xb3,0x15,0xec,0x3c,0xae,0x78,0x33,0x67, + 0x2f,0x07,0x7b,0x0d,0x27,0xfb,0x8d,0x73,0xd0,0xa0,0xf5,0x39,0x8a,0xb5,0x63,0xcf, + 0xcd,0x69,0x8c,0xa1,0x60,0x16,0x2e,0x2f,0x35,0xa7,0x69,0x56,0x03,0x54,0x31,0x97, + 0xb4,0xfb,0xa2,0x29,0x2b,0xc1,0xe8,0x57,0xe8,0xaf,0x57,0xbe,0x0a,0x13,0xd4,0xfe, + 0x33,0xd8,0xe1,0x5e,0xd7,0x70,0x2a,0xe6,0xd5,0x00,0xe2,0x85,0x8e,0x06,0x3e,0x5d, + 0x2e,0xfd,0x85,0x8e,0x02,0x3e,0xa1,0x53,0xb0,0x5f,0x9f,0x48,0xd2,0x4b,0x72,0x9c, + 0xe7,0x1b,0x40,0xb5,0x5a,0xc1,0x12,0x2b,0xd0,0x80,0x2e,0x2f,0x32,0x95,0x11,0x02, + 0xac,0x5e,0x4f,0xe7,0x5f,0x63,0x68,0xd7,0x8c,0x35,0x7e,0x71,0x21,0x61,0xbb,0x95, + 0x09,0x56,0xcc,0xc0,0xb1,0xb9,0xe0,0xba,0x1a,0xc2,0xab,0xad,0x02,0xcc,0x96,0x37, + 0x55,0xeb,0x66,0x59,0xe8,0xf5,0xfe,0x56,0x36,0xe8,0x77,0x0a,0x4c,0xa4,0xba,0x5e, + 0xda,0xcb,0x6a,0x2a,0xc2,0x9a,0xb6,0x06,0x9f,0xfc,0x75,0x39,0xa8,0x78,0xdd,0xd6, + 0xce,0x7a,0xe9,0x66,0x97,0xc4,0x57,0xfc,0x55,0xf1,0x8c,0xf7,0x03,0xc1,0xd7,0x15, + 0x50,0xaf,0xfd,0xc1,0xf6,0xe2,0x73,0x0e,0x0b,0x41,0x73,0x8e,0x89,0x54,0x67,0x4a, + 0x15,0xf1,0x10,0x39,0xb7,0x6d,0xe9,0xf3,0x41,0x18,0xd8,0x23,0x4f,0xd3,0xd1,0x95, + 0x0e,0x67,0x5b,0x90,0xcf,0x6a,0x3d,0xc3,0xfd,0x5e,0x5b,0x8f,0x34,0x69,0xbd,0x11, + 0xe9,0x7b,0x4f,0xfb,0x77,0xa8,0x1d,0x4e,0x68,0xb3,0xed,0xb2,0x97,0xee,0x92,0x8d, + 0xf1,0x89,0xd4,0x6f,0x9a,0xeb,0x33,0xcb,0xc3,0x34,0x3d,0x78,0xc7,0x7a,0xab,0x39, + 0x63,0xc0,0x3d,0xfd,0x0d,0x98,0xd6,0xb1,0xb2,0x6a,0xaa,0x26,0x1a,0xd9,0x61,0x32, + 0xf5,0xa6,0x9c,0x4c,0xc6,0xf4,0xe7,0x47,0xc7,0xb8,0xbb,0x7e,0x64,0x2a,0x68,0xfe, + 0xde,0x87,0x84,0x95,0x20,0xc0,0xa7,0xea,0x42,0x8e,0x43,0x6c,0xa6,0x91,0xe1,0xf4, + 0x0f,0x51,0x6c,0xc1,0x1e,0x17,0x12,0x9d,0xe9,0x04,0x58,0xd7,0x1e,0x98,0xf3,0xcd, + 0xf8,0x47,0x77,0x2d,0xdb,0x99,0x3a,0xd3,0x67,0x96,0xae,0xbb,0xd1,0x63,0x15,0xb1, + 0x41,0x81,0x58,0x17,0x3f,0x15,0x25,0xa3,0x6a,0x3d,0x2b,0xcc,0xb3,0x23,0xb6,0x18, + 0x2b,0xdc,0xdf,0xa7,0x4f,0x02,0x41,0xf6,0xda,0xe4,0x4e,0xd5,0xec,0xec,0x9a,0xca, + 0x4c,0x50,0x3f,0x85,0xb5,0x7b,0x8a,0x75,0x82,0x16,0xa4,0x6d,0x18,0x64,0x62,0xf4, + 0x50,0x9b,0x79,0x1c,0xa4,0xf9,0x5c,0x1b,0xd3,0xe6,0x6a,0x47,0xa3,0x9e,0x68,0x95, + 0xf4,0x7b,0x39,0xd2,0xa7,0xda,0x78,0xcc,0xa7,0x01,0xb9,0xae,0xe3,0xe9,0xca,0x21, + 0x75,0x54,0xe1,0x90,0x90,0xad,0x7e,0x2e,0xd0,0xa9,0xb0,0x28,0x51,0xaa,0x9e,0xa4, + 0xa2,0x07,0x48,0xa6,0x71,0xee,0x70,0x5d,0xd2,0x1c,0x34,0x4d,0xa4,0x73,0x9f,0xc7, + 0xe6,0xdd,0x3d,0x84,0x62,0xfe,0xfd,0x90,0x66,0x0d,0xcd,0x03,0xf4,0x73,0x36,0x8b, + 0x39,0x76,0xb5,0xa7,0x24,0xdb,0xc5,0xec,0x3f,0x5d,0x3e,0xd7,0x25,0x6c,0xf9,0x29, + 0xa6,0x47,0x4a,0xb8,0x84,0x12,0x53,0xe9,0x9b,0x7f,0x5f,0x04,0x13,0x67,0x40,0xb3, + 0xe8,0xa5,0x77,0x25,0xa6,0xc6,0xfa,0xe3,0xfb,0xef,0xe8,0x28,0x70,0x74,0xe5,0x98, + 0xd3,0x3f,0xfa,0x5d,0x0e,0x22,0xc7,0xcc,0x96,0x99,0xae,0x3e,0x86,0x54,0xbd,0x89, + 0x7f,0x2a,0x87,0x82,0x1f,0x22,0x75,0xc5,0x6b,0x29,0x73,0xd3,0x19,0xd8,0x5d,0x96, + 0x53,0x3d,0x1d,0x55,0x9d,0x20,0x3b,0xaa,0x78,0x33,0x66,0x96,0x4c,0x74,0x92,0xf1, + 0xb2,0xcc,0x4f,0xb0,0x34,0x7f,0x12,0xc5,0x80,0x87,0x79,0x50,0xba,0x66,0x82,0x1a, + 0x45,0x03,0x27,0xb8,0x5a,0xc6,0xc1,0x83,0x79,0xb5,0xdb,0x9c,0x9b,0x2a,0x4a,0x7d, + 0x5d,0x17,0x93,0xed,0x45,0xa3,0xb0,0xcc,0xd1,0x16,0x33,0x36,0x7e,0x0e,0xa6,0x0d, + 0x11,0xf6,0xec,0x65,0x5d,0xa6,0x98,0xbb,0xb3,0x0c,0x19,0x98,0xdc,0x75,0x73,0x07, + 0x3d,0x5f,0xa1,0x5e,0x88,0xaf,0x6a,0x53,0x55,0x1b,0xfd,0xce,0x0a,0xe5,0xf6,0xd5, + 0x93,0x70,0xa6,0xea,0xc5,0xcb,0xae,0xaa,0x18,0x34,0xe0,0xa2,0xc8,0xf2,0x1c,0xac, + 0x71,0x35,0x83,0xe9,0x34,0x5a,0x41,0x29,0xd2,0xdf,0x56,0x7b,0x90,0xe1,0xd6,0x5e, + 0x4e,0x6d,0xdc,0x5b,0x01,0xe4,0xc6,0x02,0xf4,0x46,0x5d,0x07,0xb1,0xc6,0xaf,0xa5, + 0x74,0xf5,0xe3,0xba,0x5d,0xfd,0x63,0x50,0xff,0x0b,0x75,0x03,0x36,0xe6,0x1d,0x4a, + 0x00,0x00, }; constexpr size_t MIDI_INDEX_HTML_GZ_LEN = sizeof(MIDI_INDEX_HTML_GZ); // diff --git a/firmware/patternflow/src/web_update_index.h b/firmware/patternflow/src/web_update_index.h index f61bb9078..131f88337 100644 --- a/firmware/patternflow/src/web_update_index.h +++ b/firmware/patternflow/src/web_update_index.h @@ -97,7 +97,7 @@ h1{flex:1;margin:0;font-size:22px;font-weight:600;letter-spacing:-.01em}
-

No file? patternflow.work/update sends the +

No file? patternflow.work/update sends the latest release straight back to this page. Your own arduino-cli export works too. Keep the panel powered: it checks, flashes and restarts by itself.

@@ -227,6 +227,8 @@ function done(s){ // The running build; and a late answer (the chrome keeps probing). PF.status(function(s){ build=s.build; + // An edition's image comes from its own card; /update on the site sends the standard firmware. + if(s.variant&&s.variant!=='core'){var a=$('src');a.href='https://patternflow.work/editions#'+encodeURIComponent(s.variant);a.textContent='patternflow.work/editions'} if(phase!=='lost')return; if(!job.from||s.build!==job.from||job.to===job.from)done(s); else{phase='';PF.dirty=false;bar.style.display='none';say('The panel is back, but still on its old build — the update did not take. Try again.','err');startPoll();render()} @@ -274,408 +276,414 @@ startPoll();render(); )HTML"; // GENERATED by firmware/toolchain/console_pages.py build — do not edit. -// gzip -9 of WEB_UPDATE_HTML (14965 bytes → 6385 bytes), served with Content-Encoding: gzip. +// gzip -9 of WEB_UPDATE_HTML (15240 bytes → 6487 bytes), served with Content-Encoding: gzip. const uint8_t WEB_UPDATE_HTML_GZ[] PROGMEM = { - 0x1f,0x8b,0x08,0x00,0x00,0x00,0x00,0x00,0x02,0xff,0xad,0x5b,0xeb,0x72,0xdb,0xc8, - 0x95,0xfe,0xaf,0xa7,0x68,0xd1,0x89,0x00,0x8c,0x41,0xf0,0x62,0xc9,0x96,0x41,0x41, - 0x8a,0xae,0x33,0x89,0xaf,0x65,0xd9,0x3b,0xbb,0xe3,0x38,0x29,0x90,0x68,0x92,0x18, - 0x81,0x00,0x02,0x80,0x92,0x18,0x8a,0x55,0xf9,0xb5,0x0f,0xb0,0x95,0x67,0xd8,0x07, - 0x9b,0x27,0xd9,0xef,0x9c,0x6e,0x5c,0x48,0xd1,0x8e,0x53,0xb5,0x2e,0x17,0x49,0x00, - 0xdd,0xa7,0x4f,0x9f,0xcb,0x77,0x2e,0x0d,0x1d,0xed,0x06,0xc9,0xa8,0x58,0xa4,0x52, - 0x4c,0x8b,0x59,0x74,0xbc,0x73,0x44,0x5f,0x22,0xf2,0xe3,0x89,0xd7,0x92,0x71,0x8b, - 0x6e,0x48,0x3f,0xc0,0xd7,0x4c,0x16,0xbe,0x18,0x4d,0xfd,0x2c,0x97,0x85,0xd7,0x9a, - 0x17,0xe3,0xf6,0x21,0x3d,0xcd,0x47,0x59,0x98,0x16,0x22,0xcf,0x46,0x5e,0xab,0x93, - 0x8e,0xdb,0xa3,0x24,0xce,0x93,0x48,0x3a,0xbf,0xe6,0x27,0x53,0x4f,0x8e,0xc7,0xe3, - 0xe0,0xa0,0x7b,0xd0,0x3a,0x3e,0xea,0xa8,0x81,0xc7,0x7a,0xc2,0x71,0x38,0x36,0x77, - 0xef,0xc2,0x38,0x48,0xee,0x9c,0xf7,0x57,0x96,0x39,0x9e,0xc7,0xa3,0x22,0x4c,0x62, - 0xd3,0x5a,0xde,0xfa,0x99,0xf8,0xd9,0x53,0xcf,0xec,0x4f,0x9e,0xd1,0xf1,0xd3,0xb0, - 0x93,0x17,0x7e,0x31,0xcf,0x0d,0xfb,0xb5,0xf7,0xf9,0x8b,0xfd,0xc6,0xeb,0xcb,0x97, - 0xf6,0xb5,0xfd,0x8b,0xfd,0xde,0xfb,0x19,0xd3,0xbd,0x25,0x3d,0x96,0xae,0x11,0x85, - 0xb7,0xd2,0xb0,0x83,0x30,0x2b,0x16,0xee,0x6e,0xcf,0xbe,0x75,0xe3,0x79,0x14,0xd9, - 0x13,0x59,0xb8,0x3f,0xda,0x61,0x3c,0x8e,0xc2,0xc9,0xb4,0x70,0xdf,0xce,0x67,0x43, - 0x99,0xd9,0xd3,0x24,0x0a,0xdc,0x77,0xc3,0x5f,0xe5,0xa8,0xb0,0x73,0x7f,0xe1,0x56, - 0x1c,0x14,0xf6,0x8d,0x2d,0xad,0xa5,0xdc,0xdb,0x33,0xa5,0x53,0xc8,0xfb,0xe2,0x3c, - 0x89,0x0b,0x19,0x17,0x5e,0x61,0xad,0xec,0xe1,0x3c,0x6f,0x0c,0x1d,0xda,0xa9,0xb5, - 0x2c,0xaf,0xc4,0x18,0xcc,0x0f,0x9d,0x20,0xcc,0xfd,0x61,0x24,0x03,0x6f,0xb7,0xb7, - 0x6a,0x5e,0x75,0x07,0xa9,0x53,0x4c,0x65,0x6c,0x8e,0xed,0xb1,0x35,0xc8,0x64,0x31, - 0xcf,0x62,0x91,0xae,0xec,0x34,0x89,0xa2,0x9a,0x62,0x6e,0xcf,0xec,0x91,0x9d,0x28, - 0x29,0x14,0xf6,0xd8,0x9e,0x78,0xbd,0x41,0xb5,0xc2,0x07,0xac,0x30,0xd9,0xdb,0xdb, - 0x1d,0x83,0xb7,0xb1,0xd7,0xb3,0x47,0x91,0xf4,0xb3,0x8f,0xe1,0x4c,0x26,0xf3,0xc2, - 0x2c,0x2c,0xdb,0xcc,0x9d,0x91,0x1f,0x45,0x27,0xb9,0x69,0xb9,0x3f,0x9a,0xb9,0x65, - 0xe9,0x15,0x4b,0xea,0x81,0xb5,0x1c,0x99,0x81,0x4d,0x52,0xc1,0x5e,0xaa,0xdb,0x92, - 0x6e,0xb3,0xa8,0xa4,0xb5,0xd2,0x73,0x5e,0xd9,0xaf,0x2c,0x6b,0x55,0xad,0xfc,0x0a, - 0x2b,0x8f,0xbd,0xee,0x60,0x63,0xc5,0x01,0xb8,0x31,0x0b,0x0f,0x26,0x51,0xde,0xfb, - 0x60,0xcf,0x30,0x2f,0xd9,0xdb,0x4b,0x9c,0x71,0x98,0xe5,0x85,0xe7,0x41,0x10,0x27, - 0x98,0xee,0x82,0x79,0xbd,0x6d,0xa8,0x2b,0x49,0xdd,0x86,0xca,0x27,0x8f,0x29,0xaf, - 0x6c,0x10,0xad,0xc7,0xc4,0xd6,0x72,0xe6,0xc5,0x83,0xf1,0xc3,0x03,0x48,0xad,0xec, - 0x38,0xb9,0x73,0x3f,0xac,0x30,0x86,0xcd,0xa2,0x1e,0x36,0xc2,0x4e,0xf6,0xf6,0x5e, - 0x3b,0xe9,0x3c,0x9f,0xe2,0x62,0x70,0x7d,0x82,0xcb,0x91,0x79,0x6d,0xb9,0xbf,0x9c, - 0xfc,0xe2,0x60,0x16,0xf8,0xf8,0xc5,0x7b,0xef,0x90,0xd0,0xcd,0x4f,0xf6,0x1b,0xfb, - 0x02,0xc4,0xee,0xfc,0x62,0x34,0xbd,0xde,0xa0,0x34,0xb3,0x96,0xef,0x1d,0x45,0x1e, - 0x7c,0xff,0xe2,0x80,0x1b,0xf3,0x8d,0xf7,0xc6,0x2f,0xa6,0xce,0x2c,0xc4,0x63,0xfb, - 0x8d,0xc5,0x53,0xc3,0xe2,0x2a,0xc9,0x2e,0xe4,0x6d,0x38,0x92,0xf5,0x64,0xe8,0x2f, - 0xf1,0x92,0x87,0x87,0xe5,0xaa,0x54,0x74,0x2c,0xef,0xc4,0xfb,0x2c,0x99,0x85,0xb9, - 0xac,0xd5,0xb1,0xb0,0x63,0xa5,0xe8,0x69,0xcd,0x52,0x5f,0x3e,0xab,0x15,0x93,0x5b, - 0xcb,0x1c,0xea,0x36,0x13,0x67,0x38,0x0f,0xa3,0x60,0x6f,0x2f,0x57,0x3f,0x3c,0x4f, - 0xdf,0xb1,0x20,0xfe,0xa9,0x43,0xe2,0x34,0x2d,0x7b,0x41,0x1a,0x5f,0x59,0x83,0x86, - 0x36,0x1a,0x32,0x2e,0x87,0x0d,0x62,0xf3,0x32,0xcb,0x92,0xcc,0x34,0x0a,0x35,0xc8, - 0xa0,0x8d,0x24,0x8e,0xbe,0x7a,0x78,0x78,0x29,0xf7,0x41,0x65,0xb5,0xaa,0xcd,0xee, - 0x47,0x73,0x4e,0x26,0xa9,0xb7,0x32,0x96,0x10,0x17,0xdf,0xd9,0xb0,0xae,0xcc,0x5a, - 0x92,0x57,0x67,0x4e,0x72,0x63,0x15,0xd3,0x2c,0xb9,0x13,0x6a,0xa1,0x4c,0xcb,0xb1, - 0xb2,0x7a,0xb6,0x0e,0xf2,0xac,0x93,0x8c,0xbf,0xa0,0x94,0x0c,0x80,0x41,0x6c,0xae, - 0x1a,0x36,0x77,0x41,0xfb,0x07,0x45,0x7c,0x5e,0xc3,0xcf,0xd3,0xb1,0xd2,0x91,0x97, - 0x0f,0x5e,0x3b,0xe3,0x24,0xbb,0xf4,0xc1,0x46,0xb5,0xf8,0x18,0xf6,0x89,0x91,0xc4, - 0xf8,0xca,0x32,0xad,0x0a,0x6f,0x34,0x70,0xc5,0xfe,0x4c,0x7a,0xad,0xdb,0x50,0xde, - 0xa5,0x49,0x56,0xb4,0xc4,0x48,0xfb,0x74,0xeb,0x2e,0x0c,0x8a,0xa9,0x17,0xb0,0x02, - 0xdb,0x7c,0x01,0xa4,0x08,0x8b,0xd0,0x8f,0xda,0x39,0x7c,0x49,0x7a,0x3d,0x42,0xb9, - 0x22,0x2c,0x22,0x79,0xfc,0xde,0x2f,0x0a,0x99,0x01,0x47,0xb0,0xb5,0xdf,0xfe,0xf1, - 0x4f,0x71,0x15,0x66,0xb3,0x3b,0x3f,0x93,0x62,0x9e,0x06,0xc0,0x9f,0xa3,0x8e,0x1a, - 0x06,0x50,0x2c,0x16,0xf4,0xdd,0xf9,0x41,0x9c,0x2b,0x38,0x14,0x43,0x3f,0x97,0xae, - 0x80,0xbc,0x44,0x0e,0x4e,0xc4,0x30,0x4a,0x46,0x37,0x22,0x49,0x65,0x9c,0x0b,0x79, - 0x2b,0xb3,0x05,0x18,0x02,0x9d,0xd4,0x9f,0x48,0x47,0xbc,0x26,0x98,0x12,0x61,0x2c, - 0xdc,0x2c,0x49,0x0a,0x61,0xd2,0xac,0x11,0xe4,0x39,0x93,0x46,0xbe,0x23,0x04,0x9e, - 0x10,0x68,0xc9,0x40,0x30,0x9e,0x61,0x52,0x24,0xc1,0x97,0x20,0x40,0xcb,0x69,0x89, - 0x5c,0x2f,0x72,0xeb,0x47,0x73,0x99,0xbb,0xe2,0x46,0xca,0x94,0x97,0x2e,0xee,0x12, - 0x22,0x9b,0x17,0x32,0xb5,0x6c,0xa2,0x14,0xf8,0xd9,0x8d,0x98,0xc7,0x81,0xcc,0x18, - 0xfd,0x3f,0x63,0x17,0x7e,0x1b,0x23,0x21,0x2b,0x7a,0xf4,0xc5,0x11,0x6f,0x13,0x71, - 0x27,0x87,0x62,0x0c,0x71,0xe5,0x8a,0xff,0x69,0x52,0xe4,0x29,0xd8,0x9a,0xfa,0xb9, - 0x88,0x89,0x1e,0x89,0x44,0x16,0x8e,0xf8,0xa1,0xb3,0xc3,0x0c,0x2f,0x47,0x49,0x94, - 0x64,0x90,0x1e,0xd1,0x71,0x99,0xc7,0x41,0xbb,0x3d,0xca,0xa4,0x3f,0x73,0x9f,0x5c, - 0xed,0x5f,0x5e,0x5d,0x3e,0xc7,0x75,0xea,0xc7,0x32,0xc2,0xf5,0xd5,0xd5,0xf9,0xd5, - 0x29,0xae,0xc3,0xf8,0xc6,0x7d,0xd2,0x3b,0xed,0x1d,0xf6,0xf6,0x71,0x35,0x9b,0x63, - 0x7f,0xee,0x93,0xe7,0xfd,0x83,0xf3,0x83,0x1e,0xae,0xc7,0x3e,0xd6,0x71,0x9f,0xbc, - 0xb8,0x78,0xf1,0xfc,0xf9,0x21,0xae,0xb3,0x79,0x24,0xdd,0x27,0x17,0x17,0x17,0x07, - 0xe7,0xcf,0xf5,0x65,0x3b,0x4f,0xc6,0x18,0x72,0x79,0x78,0xd9,0xbb,0x20,0x12,0x93, - 0x69,0x92,0xd3,0xf5,0xb3,0x8b,0xf3,0xf3,0x4b,0x5c,0x47,0x44,0xf0,0xea,0xea,0xe0, - 0xbc,0x4f,0x57,0x43,0x1f,0x57,0x67,0x87,0xcf,0xce,0x7b,0xe7,0xb8,0x4a,0xb0,0x76, - 0xff,0xec,0xc5,0xe9,0xfe,0x19,0x2e,0xa0,0xd1,0xd8,0x7d,0x72,0x78,0xf0,0xbc,0xdb, - 0xbd,0x1c,0xec,0xb4,0xdb,0xb9,0x1f,0xe7,0x6e,0xbe,0x80,0xd4,0x66,0xed,0x79,0x68, - 0xb7,0xfd,0x34,0xa5,0xd5,0xf8,0x86,0x6d,0x5c,0xcb,0x49,0x22,0xc5,0xa7,0x3f,0x1a, - 0xf6,0x87,0x64,0x98,0x14,0x89,0x4d,0xc3,0xdb,0xb9,0xcc,0xc2,0x31,0xed,0x23,0x89, - 0x13,0x77,0x1e,0xf2,0x77,0x9e,0xfa,0x23,0x69,0x1b,0xe7,0x3e,0xec,0x2a,0x08,0x7d, - 0xf1,0x06,0xf7,0x0c,0x10,0xb8,0xd2,0xbf,0xde,0xc8,0x38,0x4a,0x6c,0x65,0x30,0x7e, - 0x6e,0x1b,0x8a,0x9e,0x7e,0x58,0x11,0x58,0xed,0x6c,0xd5,0xd4,0xba,0xd0,0xe9,0x56, - 0x2d,0xf3,0xee,0x79,0xf7,0xac,0xfb,0xb2,0x96,0x79,0xef,0x59,0xaf,0xd7,0xeb,0x96, - 0x32,0xbf,0xbc,0xb8,0x7c,0x71,0x71,0x56,0xcb,0xfc,0xf4,0xd9,0xcb,0xb3,0xc3,0xd3, - 0x86,0xcc,0x4f,0x5f,0x3c,0x7b,0xbe,0x5f,0xc9,0xbc,0x7f,0xd6,0x7f,0xd1,0xef,0xad, - 0xcb,0xbc,0x77,0xd5,0x3b,0xef,0x1d,0xd6,0x32,0xef,0x9f,0xf6,0x9f,0xf7,0xbb,0xa5, - 0x94,0xaf,0xae,0x5e,0x9c,0x1e,0x1c,0x68,0x29,0x1f,0xbc,0x38,0x3b,0x7c,0x71,0x55, - 0x49,0xf9,0xe2,0xe5,0x69,0xf7,0xd9,0xd5,0x6a,0xe7,0x87,0xe5,0x30,0xb9,0x6f,0xe7, - 0xe1,0xdf,0xc3,0x78,0xe2,0x0e,0x93,0x0c,0x16,0xd9,0xc6,0x9d,0xd5,0xe7,0x69,0x18, - 0x04,0x32,0xfe,0xb2,0x44,0x1c,0x4d,0x23,0x44,0xe9,0x38,0x89,0xe5,0x6e,0x38,0x23, - 0x27,0xf6,0xe3,0x62,0xb5,0x33,0x4c,0x82,0xc5,0x72,0xe6,0x67,0x93,0x30,0x76,0xbb, - 0x83,0xa1,0x3f,0xba,0x99,0x64,0x09,0x2c,0xda,0x05,0xac,0x9a,0x5a,0x02,0xd6,0x80, - 0x85,0xa3,0x6f,0x61,0xd3,0xd6,0x80,0xec,0xd9,0xed,0x1d,0xa4,0xf7,0x9d,0x9e,0x73, - 0x20,0xd4,0x03,0x52,0x9c,0x35,0x68,0xc3,0xdc,0x6f,0xc2,0xa2,0x4d,0x23,0xda,0xf9, - 0x0c,0xf6,0x3c,0x25,0x9e,0xb0,0x18,0x20,0x21,0x84,0x17,0x07,0xab,0x1d,0x5f,0x49, - 0xdb,0x0d,0xe3,0x29,0xf4,0x5c,0xac,0xdc,0x71,0x32,0x9a,0xe7,0xed,0xdb,0x30,0x0f, - 0x11,0xeb,0x97,0x40,0xd1,0x28,0x8c,0xa5,0xdb,0x4f,0xef,0x05,0x74,0x19,0x06,0x7a, - 0x01,0x58,0xa0,0x35,0xd0,0x0f,0xdb,0xc9,0x78,0x4c,0x01,0x0e,0x63,0x56,0x3b,0xce, - 0x5d,0xe6,0xa7,0xd8,0xc5,0xbd,0x02,0x20,0xf7,0x45,0xbf,0x9b,0xde,0x0f,0xca,0x5d, - 0x09,0x7f,0x5e,0x24,0x83,0xd4,0x0f,0x02,0x62,0x84,0x1e,0x89,0xde,0x73,0x7c,0xec, - 0x1f,0xd2,0xdc,0x69,0xbf,0xda,0xbe,0xe8,0x0a,0xdc,0x1f,0x28,0xce,0xc3,0xbf,0x4b, - 0xb7,0xf7,0xac,0xbc,0xbc,0x93,0x9c,0xfa,0xc0,0xa4,0xd7,0x64,0xc1,0x2a,0xb7,0xc0, - 0xc0,0xb0,0x88,0x2b,0x11,0x87,0x31,0x33,0x38,0x8e,0xe4,0xfd,0x00,0x5b,0x9e,0xc4, - 0xed,0x10,0x96,0x9e,0xbb,0x23,0x49,0xae,0x3e,0xf8,0x75,0x9e,0x17,0xe1,0x78,0xd1, - 0xd6,0x08,0x5a,0xde,0x9e,0xf8,0xa9,0x4b,0xab,0x23,0x38,0xb6,0xa7,0x6a,0xb5,0x7d, - 0xda,0x45,0xc9,0x77,0x97,0x99,0x1e,0x28,0xdd,0xba,0xbd,0x0d,0xd1,0x90,0x31,0x59, - 0xfa,0x61,0x3b,0x83,0x7b,0x20,0x14,0x1f,0xd2,0xf0,0x4d,0x85,0xb2,0x09,0x7f,0x4d, - 0xa1,0x07,0x5d,0xac,0xb2,0x0f,0xd2,0x4d,0x85,0x52,0x80,0x69,0x07,0x12,0xf8,0xea, - 0x53,0xa0,0x60,0x0b,0x1a,0x8c,0xe6,0x59,0x0e,0x02,0x69,0xc2,0xf0,0x35,0x28,0x32, - 0x0c,0x0d,0xf9,0xb1,0xe6,0x81,0x17,0x10,0x4e,0xef,0x20,0xb7,0x6b,0x1e,0xd4,0x75, - 0xfd,0x68,0xb5,0xf3,0x87,0x99,0x84,0x2b,0x9b,0xd3,0x04,0x18,0xee,0xf2,0xa7,0xb5, - 0x24,0x61,0xaa,0xdf,0xcb,0x26,0xb1,0x06,0xb7,0x2b,0x1a,0xe2,0xa4,0x59,0x58,0x0e, - 0xdb,0xdc,0x25,0x5b,0xca,0x96,0xc9,0x7c,0x5f,0xdd,0x78,0x82,0xd4,0x7a,0x35,0x86, - 0x79,0x02,0xbb,0x7d,0x4d,0xe7,0xd1,0x3a,0x4a,0xb3,0x6e,0x99,0x84,0x2e,0x13,0x20, - 0x48,0x88,0xdc,0xd8,0xd9,0x3f,0x28,0x45,0x10,0xc8,0xb1,0x3f,0x8f,0x8a,0x6d,0xab, - 0xb1,0x52,0x14,0x09,0x62,0xf6,0x31,0x9b,0x2c,0xf9,0xaf,0xec,0x71,0x4d,0x45,0xca, - 0x0d,0x41,0x6a,0x96,0x4f,0x96,0xca,0xf7,0xfa,0x95,0x9a,0x08,0xda,0xd6,0x87,0x33, - 0xf2,0x40,0x4a,0x28,0x27,0x92,0x9b,0xb5,0x5d,0x25,0x37,0xea,0xb6,0xcc,0xd6,0x77, - 0x0b,0xa0,0x21,0xf2,0xa3,0x69,0x98,0x6e,0xda,0x31,0x87,0xda,0x86,0x19,0x1e,0xfe, - 0x7b,0x56,0xf8,0xf2,0xa5,0xf6,0x21,0xb7,0x87,0xe1,0x1d,0x76,0xbf,0xaf,0xf1,0xad, - 0xdc,0x69,0x70,0x37,0x85,0xc3,0xb4,0x19,0xad,0x61,0x6d,0xe4,0xdb,0x60,0x2d,0x4e, - 0x0a,0x64,0x18,0x15,0x73,0xeb,0x5c,0xb1,0x34,0xc8,0x72,0xbf,0x8b,0xb3,0x48,0x02, - 0x74,0x9f,0x6d,0x03,0x97,0xef,0x77,0xa0,0x06,0x48,0xd0,0xb2,0xdb,0x9c,0x64,0xa5, - 0x79,0x16,0xc3,0x65,0x13,0x43,0xe0,0x64,0x5b,0x77,0xa8,0x6c,0xb1,0xda,0xe0,0xf7, - 0xc2,0x07,0x13,0x69,0x0f,0x65,0x71,0x27,0x65,0xcc,0x28,0xc2,0x38,0xa1,0x30,0xad, - 0x4d,0x75,0x02,0xc3,0x88,0xde,0x19,0x5d,0x6f,0x97,0xcd,0x77,0x5b,0xd5,0x4e,0xe9, - 0x34,0xdf,0x0b,0x78,0x9b,0x88,0xb6,0x45,0xe3,0x5b,0xc5,0xb7,0x43,0x85,0xf3,0xb7, - 0x24,0x42,0x39,0x21,0xad,0x3c,0xa0,0x27,0x6d,0x92,0xa2,0x4b,0x1f,0x2c,0x04,0x42, - 0x31,0xda,0x4d,0x29,0x08,0xa4,0x02,0x45,0x32,0x63,0xf4,0x07,0xe1,0xde,0x92,0xa6, - 0xb8,0xbd,0x2a,0x46,0x34,0xf4,0xd9,0xef,0x6f,0x01,0x7d,0xce,0x10,0x33,0xd6,0x18, - 0xd9,0x5b,0xdb,0xe9,0xf6,0xe4,0x4c,0x59,0xa5,0x2c,0xe3,0x07,0xcc,0x05,0x11,0xa4, - 0xbb,0x19,0x3f,0xb6,0xc6,0x8b,0x20,0x4b,0xd2,0xf5,0x8d,0xf1,0x1e,0x50,0x79,0x4b, - 0x4e,0xc7,0x5d,0xcc,0x9a,0xcf,0xe2,0x7f,0x33,0x7e,0xec,0xaf,0xc7,0x8f,0xde,0x5a, - 0x00,0xe9,0x77,0xd7,0xdd,0x23,0xf0,0xf3,0xa9,0xfc,0x96,0xe7,0xf6,0xba,0xdf,0xb0, - 0x7f,0xd6,0x18,0x73,0x57,0x32,0xb0,0x11,0x12,0xb6,0x68,0x79,0xdd,0x67,0xb4,0x14, - 0x9c,0x6f,0x22,0xfd,0xd7,0x32,0x92,0x15,0xd5,0x06,0xbf,0xfd,0xf3,0x1f,0xf8,0x2f, - 0x3e,0x71,0xf1,0x50,0x5e,0x21,0x8b,0x66,0x18,0x43,0x11,0xb5,0x8d,0x68,0xb2,0x81, - 0xac,0x0c,0x88,0x3c,0x9e,0x12,0xaa,0xe5,0xb7,0x63,0x46,0x33,0x02,0xf1,0x1c,0xc0, - 0xe6,0x72,0x1b,0xf0,0xf2,0x43,0xc2,0xd8,0x2d,0xf4,0x08,0x6a,0x07,0x5b,0xb0,0x57, - 0x03,0x5c,0xc3,0x71,0xc9,0x91,0x2b,0x10,0xe1,0x14,0xa3,0xf9,0x90,0x33,0x9f,0x27, - 0x53,0x3f,0x0e,0x90,0x0a,0x5d,0xa1,0xba,0x59,0xde,0x61,0xb1,0xf6,0x10,0xf2,0xb9, - 0x71,0xf9,0x13,0xfa,0x89,0x30,0x84,0x6d,0x6d,0x83,0xec,0x37,0x23,0xf6,0x8a,0xa7, - 0x6c,0x81,0xad,0x47,0x52,0x20,0x2d,0x04,0xe1,0x4c,0x15,0x47,0x54,0xbb,0xd9,0x28, - 0x69,0x0a,0xbe,0x04,0x03,0x28,0x48,0x75,0x71,0x14,0x16,0xa4,0x17,0x26,0x5b,0x75, - 0x74,0x96,0xdb,0x03,0x28,0x97,0x7e,0x2e,0xe3,0xd3,0x6a,0x7d,0x02,0x18,0x6a,0xc4, - 0xdf,0xcd,0x87,0x1c,0x1a,0xb7,0xf9,0xda,0x93,0xa1,0x9f,0xad,0xa5,0xbf,0x83,0x12, - 0x8c,0xd6,0x61,0x52,0xa5,0x57,0x9b,0xe6,0xc6,0x19,0x39,0x51,0x19,0x87,0x51,0xb4, - 0x2c,0x1d,0xab,0xdb,0xfd,0xfd,0x40,0xa5,0x9a,0x5b,0x72,0x66,0x36,0x98,0x86,0x7c, - 0x79,0x20,0x04,0x9b,0x0b,0x02,0x2b,0x3f,0x03,0x31,0xe2,0xb5,0xe1,0xa8,0x8d,0x74, - 0xb5,0xd2,0xed,0x66,0x84,0xd9,0xb2,0x35,0x22,0xf3,0xf5,0x60,0xfe,0x24,0xc0,0x5e, - 0x97,0xeb,0xf0,0xc7,0x16,0xc5,0x0f,0xde,0x10,0x07,0x1a,0xfc,0x60,0x7c,0x94,0x29, - 0x8c,0xc7,0xc4,0x9f,0x78,0x92,0xd2,0x4e,0x9b,0x92,0xde,0x39,0xea,0xa8,0x82,0xfc, - 0xa8,0xc3,0xbd,0xcc,0x23,0x2a,0x1a,0x8e,0x77,0x76,0x8e,0x66,0x30,0x77,0x31,0x42, - 0xb1,0x95,0x7b,0x2d,0x42,0x5e,0x54,0xfa,0x42,0x1c,0x29,0xd8,0x3e,0x3e,0x9a,0xf6, - 0x8e,0x3f,0xe9,0xba,0x1e,0x3f,0x8f,0x80,0x9d,0xd5,0x60,0xf2,0x8f,0x96,0x08,0x03, - 0xaf,0x45,0x8b,0xb5,0x8e,0xf7,0xa6,0x32,0x8a,0xc2,0x74,0x80,0x85,0x30,0x4a,0xaf, - 0x03,0x1a,0x44,0x2e,0x2d,0x27,0x11,0xd6,0xb6,0x8e,0x2f,0xc8,0x34,0x7d,0x31,0x2e, - 0x5b,0x07,0xce,0x10,0x5b,0x60,0x8b,0x63,0x64,0x12,0xe3,0x88,0x70,0x2d,0x87,0xc9, - 0x21,0x38,0x8c,0x05,0x81,0x0b,0x3f,0x45,0x89,0x0d,0xff,0xb8,0x11,0xf0,0x17,0xd8, - 0x66,0x5e,0xf8,0x59,0x91,0x0b,0x18,0xa8,0x7a,0x76,0x27,0xb8,0x1d,0xe4,0x1c,0x75, - 0x52,0x6c,0x0c,0xab,0xee,0xb6,0xdb,0xe2,0x23,0x1e,0xf1,0x6d,0xee,0x2b,0x08,0x72, - 0xb5,0xbc,0xb9,0xf4,0xa7,0x0f,0xaf,0x15,0xfd,0x5b,0x54,0xa3,0x27,0xd4,0xc1,0xd5, - 0x8c,0x4c,0x21,0x60,0x10,0xc5,0x23,0x6a,0x12,0xd0,0xbf,0x22,0x99,0x8f,0x88,0x2b, - 0x9a,0x1d,0x61,0x44,0x38,0xba,0x91,0x99,0x23,0xda,0x6d,0xde,0x62,0x10,0xde,0x36, - 0x36,0x09,0x6f,0x57,0xb2,0xd1,0xbe,0xdd,0x12,0xaa,0x88,0x3b,0x66,0x62,0x47,0xd3, - 0xfe,0xf1,0x6b,0x38,0x1f,0xf9,0x03,0xf1,0x06,0x59,0xf5,0xf5,0x93,0x06,0x19,0x58, - 0xc6,0x1a,0x0d,0xc2,0x07,0x6a,0x22,0x63,0x88,0x1e,0x3c,0x9c,0xc3,0x22,0xe2,0xe6, - 0x98,0x1f,0x93,0x56,0x39,0x1d,0x58,0x23,0x90,0xb1,0xb6,0x04,0x35,0xb6,0x71,0xc9, - 0x63,0x5b,0xa2,0x74,0xb6,0xe3,0x2b,0x6a,0x61,0x89,0x3d,0x7f,0x96,0x0e,0x94,0xc0, - 0x8f,0x3a,0x6a,0x0c,0x6f,0x47,0xad,0xf2,0x8d,0x8d,0xf9,0xd9,0xec,0x27,0x00,0x65, - 0xb5,0xb1,0x8f,0xd3,0x30,0xd7,0x92,0x8e,0xa5,0x24,0x21,0x67,0xf0,0x8e,0x89,0xe0, - 0x06,0xa8,0x23,0xde,0xc5,0xb5,0x7e,0x5d,0x6e,0xd3,0x80,0xfd,0xe3,0x57,0x7d,0x2c, - 0x7a,0x2c,0xf6,0x32,0x3f,0xcb,0x06,0xe2,0xed,0xe5,0xc7,0x9f,0xdf,0x7d,0x78,0x65, - 0xd3,0x48,0x0c,0xa7,0x2e,0x19,0x8d,0xd9,0x6f,0x8e,0xc1,0x8d,0x4f,0xef,0x2f,0x4e, - 0x3f,0x5e,0xd2,0x4d,0x67,0x83,0x4d,0xe2,0x8b,0x10,0xa5,0x92,0x01,0x63,0x60,0xb9, - 0xe3,0x96,0xc8,0x92,0xa8,0x21,0x89,0xc2,0x87,0xd9,0x05,0xf2,0xde,0x6b,0x75,0x5b, - 0xe0,0x36,0xf4,0xdb,0x55,0xa3,0xba,0x55,0x64,0x73,0xd9,0x2a,0xa5,0x5c,0xda,0x2b, - 0x99,0xa9,0x40,0xb5,0x2b,0x69,0x6d,0xf5,0xac,0xe9,0x10,0x95,0xbe,0xee,0xa6,0x8b, - 0xd6,0xf1,0xf9,0x54,0x8e,0x6e,0x48,0x00,0xd5,0xb6,0x37,0x1c,0xa4,0x96,0xb2,0x36, - 0x55,0x7f,0x34,0x92,0x69,0xe1,0xb5,0x7e,0xe8,0xfc,0xd0,0x52,0x18,0xdc,0xa2,0x15, - 0x5b,0xae,0x38,0x8d,0xb1,0x11,0xe4,0x7a,0x33,0x3f,0xcd,0x81,0xc6,0x37,0x48,0x37, - 0x63,0x81,0xe0,0x2d,0x01,0x4f,0x49,0x9c,0xc3,0x32,0x85,0x5f,0x5a,0x29,0x17,0x63, - 0xed,0x24,0x8e,0x16,0xda,0x42,0x85,0x39,0xf2,0x67,0x32,0xf3,0x3b,0x13,0x84,0x12, - 0x99,0x2d,0x2c,0x9b,0xfd,0x07,0x76,0x93,0x27,0x33,0x29,0x54,0xd7,0x2f,0x17,0x6f, - 0xdf,0x69,0x06,0x4a,0x42,0x41,0x22,0xf3,0xaa,0x59,0x37,0xc0,0x1c,0xac,0x98,0x46, - 0x21,0x10,0x05,0xbf,0x17,0x6d,0x3e,0x2e,0x51,0x33,0x04,0x34,0x7f,0x37,0xf5,0x0b, - 0xf8,0x64,0x14,0x42,0x80,0x0b,0x31,0xcc,0xb0,0xf3,0xbc,0xa4,0x34,0x57,0x9d,0x37, - 0xf6,0x9a,0x99,0x1f,0xc3,0x0f,0xe1,0x36,0xe4,0x98,0x7f,0xba,0x16,0x23,0x12,0x53, - 0x5e,0x6f,0x86,0x79,0x23,0xc4,0x14,0x61,0x9c,0x17,0x40,0x90,0xca,0xc1,0xc2,0x38, - 0x9d,0x17,0x2c,0x5f,0x22,0x54,0x9a,0xb5,0xfa,0xdd,0x14,0x9d,0x36,0xc8,0xda,0xd0, - 0x9b,0xd6,0x81,0x38,0x02,0x17,0x2a,0xaf,0xc6,0x0c,0x5c,0x4a,0x0b,0xb5,0x2e,0xca, - 0xa7,0xac,0x4f,0xb6,0x0b,0x3a,0x52,0x01,0xcc,0x21,0x9e,0x11,0x7a,0xad,0x8f,0xdc, - 0xe2,0x1a,0x84,0xcd,0x95,0x5f,0x00,0xfb,0xca,0x7b,0xc0,0x6b,0x9a,0x9d,0x1e,0x37, - 0x1d,0x97,0xfa,0x9f,0xff,0xc2,0x67,0x8f,0xdf,0x61,0x8c,0xd0,0x87,0x4a,0x95,0x97, - 0x36,0x6c,0x7f,0x03,0x5e,0xdf,0x26,0x2c,0xeb,0x13,0x71,0xe4,0x8b,0x69,0x26,0xc7, - 0x40,0x87,0xa2,0x48,0x73,0xb7,0xd3,0x49,0xeb,0xe6,0xad,0x43,0x48,0xda,0x51,0x4d, - 0xdb,0xd6,0xf1,0x57,0x1e,0x1c,0x75,0xfc,0x63,0x91,0xcb,0x58,0xf5,0x54,0xb1,0x54, - 0x84,0x9b,0x39,0x2b,0x1a,0xa9,0x01,0x4c,0x03,0x21,0x92,0xfb,0xaf,0x14,0x40,0xc9, - 0x0c,0x0b,0x42,0x01,0xd5,0xc1,0xfd,0xaf,0x64,0x9e,0x09,0x32,0x54,0x3f,0x0b,0xe6, - 0x61,0x9c,0xb4,0x47,0x51,0x48,0x26,0x94,0x64,0x85,0xa0,0x25,0xc8,0x6c,0x13,0x47, - 0xbc,0x2a,0x1b,0xb3,0xec,0x23,0x58,0x22,0x4d,0xee,0xe0,0x62,0x81,0x4b,0x09,0x87, - 0xb2,0x0e,0xbb,0x0a,0x08,0x6b,0xc0,0x3f,0x5c,0xe8,0x00,0x51,0x23,0xbe,0x2a,0x6b, - 0x8e,0xab,0x7d,0x77,0xe0,0x87,0xa5,0xd8,0xfc,0xe3,0x7f,0x29,0x0e,0xc2,0x84,0x6c, - 0x42,0x47,0x7b,0x7f,0x1d,0x46,0x7e,0x8c,0x6b,0x6c,0x94,0xa4,0x4a,0x4a,0x92,0xd9, - 0x63,0x31,0x31,0xd5,0x8e,0x5e,0x15,0x21,0x96,0x82,0x29,0x05,0xd5,0xb2,0xef,0x5e, - 0xb5,0xf1,0x7f,0x67,0x86,0xd5,0xd9,0x41,0x90,0x8c,0xe6,0x33,0xe4,0xdb,0x0e,0x56, - 0xba,0x8c,0x24,0xfd,0x3c,0x5b,0xfc,0x31,0xc0,0x88,0xd5,0x0e,0x1d,0x84,0x10,0x68, - 0x79,0xbf,0x33,0x0d,0xfa,0x36,0x2c,0x9b,0x54,0xf9,0xc7,0x98,0x6e,0xd0,0x2f,0xdc, - 0xa0,0x70,0x4b,0x97,0xf4,0x8d,0x4b,0x98,0x34,0x5d,0xe1,0x4b,0x0d,0x8e,0xf4,0x50, - 0x7a,0x06,0x13,0xa6,0x2b,0x7c,0xe1,0x62,0x92,0xd0,0xef,0x2a,0x50,0x18,0xd6,0x80, - 0x97,0xcb,0x0b,0x8f,0x8f,0xbd,0xd8,0x55,0x3b,0x1d,0xd6,0x05,0xc4,0x5d,0x08,0x6d, - 0x03,0xfa,0xd8,0x91,0x9d,0x59,0x1d,0xbc,0xe8,0xe1,0x7a,0x6c,0x38,0xa3,0xc0,0x9a, - 0xcd,0xe3,0x98,0xd0,0x0e,0xd0,0x04,0x75,0x21,0x52,0x89,0xc6,0x91,0x25,0xcf,0x4d, - 0xa7,0xb0,0x18,0xcf,0x30,0x6c,0xbd,0x8e,0x61,0x88,0x07,0x75,0x8e,0x82,0xef,0x79, - 0x1a,0x25,0x7e,0x80,0x1f,0x74,0x98,0x84,0xaf,0x08,0x19,0x1b,0xcf,0xfa,0x35,0x19, - 0xd6,0xec,0xf1,0x8a,0x30,0x30,0x36,0x07,0x57,0x2c,0x69,0x1d,0x57,0x50,0x2c,0x61, - 0xc6,0x10,0x38,0x12,0xb7,0x82,0x1a,0x23,0x2f,0xef,0xde,0xaa,0x31,0x08,0xe5,0x84, - 0x30,0x2b,0xc5,0x4d,0x02,0x41,0x31,0x61,0xda,0x2b,0xff,0x1a,0xec,0xd4,0xfa,0xca, - 0x65,0xf1,0x1e,0x22,0x34,0x0b,0x7b,0x64,0x2d,0x49,0xd0,0xeb,0x87,0xa0,0x03,0xbe, - 0xc5,0x4e,0xf7,0x96,0x0e,0x55,0x0c,0x4a,0x86,0x84,0xf1,0xd4,0x1c,0x3d,0x3c,0x18, - 0x06,0xd5,0xd7,0x15,0x21,0x7f,0x41,0x87,0xa9,0xd6,0xf2,0xfd,0x95,0xa3,0x7f,0x63, - 0x48,0x18,0x8f,0xa9,0x9f,0x9d,0x4f,0x30,0x74,0x07,0xbb,0xfa,0x79,0xba,0xa0,0x73, - 0x04,0x46,0xc8,0x11,0x90,0x76,0xc2,0x67,0x14,0x90,0xa5,0x30,0x21,0x26,0x8f,0x5d, - 0xc1,0x8f,0x2d,0x9d,0x9a,0x28,0x58,0x87,0x1c,0xf2,0xe9,0xbc,0xb0,0xcb,0x2c,0x9d, - 0xf0,0xa4,0x70,0xea,0x95,0x11,0x85,0x4c,0x6b,0x89,0xcd,0x96,0xc7,0xac,0x2c,0x7f, - 0xcf,0x33,0x58,0xe6,0xc6,0x89,0x71,0x01,0xe7,0x24,0xa9,0x97,0x41,0x8a,0xa5,0xf5, - 0xdb,0x3f,0xfe,0xd7,0x70,0xab,0xa1,0x4a,0x2d,0x18,0x7b,0x45,0x12,0xc7,0x40,0x7a, - 0xcc,0xf2,0xab,0xc7,0x90,0xc6,0x30,0xe2,0x83,0x72,0x4b,0x3d,0xa6,0x7e,0x4c,0x9a, - 0xc4,0xe3,0x9f,0x31,0x8a,0xd3,0x81,0x24,0xab,0xbd,0xbd,0xa6,0xb6,0x9b,0x17,0x27, - 0xc6,0xe3,0x90,0xc9,0xa4,0x90,0x3e,0xd0,0x89,0xf3,0x89,0x71,0x86,0x4f,0x3e,0x73, - 0x82,0x84,0x94,0x6d,0x92,0x10,0xfc,0x08,0x02,0x08,0x16,0xa5,0x05,0xd6,0x04,0x1d, - 0x64,0x20,0x32,0x38,0x31,0x5e,0x27,0x23,0xca,0xb4,0x78,0x62,0x36,0x23,0x59,0x26, - 0x71,0x33,0xd9,0xa4,0x04,0x45,0x98,0xfe,0x10,0x69,0xa0,0x65,0xb8,0x86,0x31,0xd8, - 0x69,0xe8,0x2f,0x93,0x54,0xfc,0x28,0x41,0x92,0xb7,0xdc,0x79,0x2c,0x57,0x3b,0xf7, - 0xf2,0x62,0x80,0x7b,0x74,0xd8,0xc7,0x7b,0xb5,0x4a,0x93,0xd9,0xcd,0x4f,0x0c,0xc5, - 0xb7,0x66,0x9b,0x3e,0xe9,0x4a,0xb3,0xc3,0xdc,0x62,0x9d,0x88,0xd9,0x32,0x6c,0x1a, - 0x5f,0x0f,0xa6,0x0a,0xb6,0x31,0x38,0xb9,0xc1,0x48,0xd4,0x02,0xe4,0xac,0x02,0x28, - 0x62,0xe8,0xb4,0xcb,0xb0,0x1c,0x15,0x5f,0xbc,0x5d,0x93,0x4e,0x47,0xf5,0x04,0xfe, - 0x45,0x84,0xf0,0x43,0x71,0x45,0xd3,0xb8,0xc8,0x62,0x63,0x7d,0x1d,0x42,0x2c,0x45, - 0x32,0x99,0x44,0x12,0xf8,0xa2,0x03,0x24,0x58,0xd8,0xbd,0xab,0x07,0x62,0x1f,0xa7, - 0x45,0x91,0x85,0x08,0x33,0x92,0xd6,0x6b,0xe4,0x45,0x86,0x7d,0x77,0x62,0x50,0x6a, - 0x04,0xa6,0xc6,0x7e,0x94,0x4b,0xc5,0x96,0x42,0xa8,0xfa,0x98,0x7f,0x92,0x34,0x8e, - 0xfc,0x77,0xef,0x34,0xe7,0x90,0x1b,0xb8,0x6e,0xba,0xd1,0x1d,0x3c,0x01,0xe6,0x80, - 0xb8,0xa0,0x82,0xc7,0x68,0x9a,0x24,0x08,0x2a,0x2a,0xbf,0x66,0x35,0x90,0x6f,0xbc, - 0x43,0x1e,0x8e,0xd8,0x17,0xab,0xa6,0x8a,0x40,0x9a,0xe1,0x0b,0x3a,0xa7,0x75,0xf1, - 0x4d,0x5e,0x0c,0x00,0x83,0xd8,0xc9,0x5f,0xd4,0xbb,0x0d,0x30,0x7c,0xa9,0xf4,0xdb, - 0x73,0xfa,0xe2,0xcd,0x99,0x78,0xff,0xee,0xfa,0x23,0x11,0x62,0x10,0x0f,0x80,0x30, - 0x48,0x37,0xe2,0x64,0x3e,0x99,0xd2,0x9a,0xa8,0xf6,0x23,0xf2,0x1b,0xce,0x47,0x98, - 0x1c,0x9d,0x14,0x72,0xe9,0x01,0xdd,0xff,0x6d,0x4e,0xd1,0xce,0x24,0x88,0xb0,0x90, - 0x2e,0xc1,0xf0,0x88,0x90,0x06,0x2b,0x72,0x54,0x86,0x2b,0xb2,0xe9,0xb0,0x18,0x08, - 0xb8,0x38,0x27,0xb6,0x9c,0xbe,0x2a,0x32,0x0a,0x4c,0xb1,0x83,0xbf,0xcd,0x43,0xd9, - 0xf4,0xce,0xf2,0x54,0xbd,0xe1,0xa0,0x0c,0x44,0xea,0x60,0xd9,0x58,0xc7,0x5e,0xc3, - 0x5e,0x8e,0x7c,0xc4,0x42,0xd7,0x40,0x18,0xcd,0x8b,0x24,0x93,0xc6,0xea,0x7b,0x0f, - 0x9e,0x8d,0x9f,0x3e,0x7e,0x7c,0x0f,0x68,0x7a,0x74,0x00,0x5d,0x9f,0x36,0xaf,0x19, - 0x3c,0x7b,0xf1,0x7b,0x3a,0x87,0x67,0xde,0x40,0x93,0xa4,0x62,0xd1,0x47,0x79,0x78, - 0xbe,0xa3,0xe1,0x13,0xfb,0xe5,0x03,0x7b,0x45,0xd8,0xee,0x77,0xbb,0xdd,0xb5,0x63, - 0x7b,0x75,0x6c,0x8d,0x4d,0xe5,0x83,0xd2,0x8d,0x56,0x6a,0xb5,0x7a,0x39,0x8e,0xf0, - 0x74,0x70,0x5d,0xcb,0x61,0xb7,0xf3,0x67,0xca,0x7b,0x7f,0xd7,0x09,0x1d,0x4a,0x36, - 0xcc,0xb1,0x43,0x07,0xd7,0xd6,0x89,0xf1,0x91,0x72,0xcc,0x30,0xe7,0xd4,0x58,0x67, - 0xe3,0x64,0x29,0x8e,0x76,0xf9,0xb1,0x43,0x79,0xe3,0xf1,0xb3,0x1f,0x7a,0xdd,0xfe, - 0x3e,0x7f,0x60,0x4a,0x92,0x88,0x61,0x58,0xc3,0xce,0x33,0x32,0x08,0x3f,0x4d,0xe1, - 0xfb,0xc0,0x2a,0xe2,0x80,0x34,0x0f,0x9a,0x51,0x92,0xdc,0x50,0x71,0x7f,0x43,0xf6, - 0x87,0x74,0x79,0x02,0x4b,0x19,0x23,0x28,0xb4,0x39,0xd6,0xa8,0x20,0x37,0x28,0x35, - 0x4f,0x94,0x88,0x08,0xb3,0x50,0xe6,0xa9,0x6b,0x5c,0x1c,0x41,0x18,0x9a,0x85,0xeb, - 0x79,0x0e,0xc4,0x0e,0x93,0x79,0x8e,0xc4,0x38,0x9f,0x21,0x09,0x67,0x6e,0x9a,0xe7, - 0xe8,0x65,0x35,0xea,0x88,0x9f,0xb3,0x84,0x6b,0x26,0x64,0x6f,0x25,0x14,0x91,0xc5, - 0x7d,0xdc,0x88,0x69,0x2a,0x10,0x5c,0xbe,0xbe,0x12,0xd7,0x3f,0x9d,0xb6,0xfb,0x07, - 0xcf,0x85,0xcc,0x53,0xa4,0x53,0x91,0x40,0x8e,0x9c,0x93,0x8f,0x74,0xef,0x87,0x5d, - 0x5b,0x63,0xdb,0x98,0x32,0xb1,0xe1,0x02,0xc2,0x24,0x5a,0x26,0x8a,0xdc,0x0e,0x9d, - 0xb4,0xff,0x55,0x95,0xcb,0x53,0xcb,0x11,0xa7,0x60,0xea,0xef,0x32,0x4b,0x72,0xc4, - 0x9a,0x39,0xf6,0x83,0xa2,0x50,0x06,0xba,0x0e,0x53,0xf5,0x06,0x79,0x33,0xd7,0x3e, - 0x0d,0x1b,0x26,0x8e,0xce,0x88,0xc6,0xba,0xfe,0xb6,0xbf,0xe6,0xc1,0x23,0x14,0x86, - 0x66,0x1e,0x0d,0xb9,0xc2,0xec,0x0f,0xdc,0x22,0x50,0x26,0x85,0xf9,0x4e,0xc2,0x01, - 0xc9,0xdb,0x78,0x11,0x6a,0xc8,0xe3,0x3f,0x01,0xf6,0x0e,0x4f,0xb3,0x0c,0x41,0x34, - 0x73,0x90,0x04,0xce,0xa3,0xc2,0xb2,0xa7,0x94,0x50,0x84,0x03,0x08,0xd4,0x0c,0xbd, - 0xee,0x20,0x3c,0x1a,0x3a,0x91,0x8c,0x27,0xc5,0x74,0x10,0x3e,0x7d,0x6a,0x4d,0x9f, - 0x7a,0xe6,0xf0,0x73,0xf8,0xe5,0xa8,0xf7,0xfc,0xc4,0xe8,0x92,0x40,0xad,0xa7,0x74, - 0x0d,0x08,0xbc,0x2e,0xa8,0x44,0x31,0x7b,0xcf,0xad,0xc1,0xc2,0x9c,0xea,0x49,0x88, - 0x57,0x87,0x7b,0x7b,0x9d,0xcf,0x7f,0xe9,0x7e,0xe9,0x28,0xdb,0x9b,0x5a,0x27,0x53, - 0x57,0xbd,0x2a,0x54,0xf3,0x28,0xc9,0xb1,0x9a,0x4c,0x2e,0xcc,0xf5,0x21,0x04,0xf1, - 0xa7,0x39,0xf3,0x7a,0x36,0x1f,0x8f,0xb1,0x43,0x58,0x05,0x50,0x4e,0x9a,0xbd,0x17, - 0xcf,0xed,0xde,0x61,0xd7,0xe2,0x1d,0x6f,0xfa,0x02,0x25,0xda,0xdf,0x23,0x4a,0x7a, - 0x63,0xa6,0x12,0xe6,0x3d,0x0b,0xe7,0x3f,0xdf,0xbc,0xfe,0x09,0x49,0xed,0x07,0x85, - 0x58,0x88,0x4f,0xe3,0x40,0x09,0x39,0xc9,0x66,0x17,0x7e,0xe1,0x97,0x22,0xae,0xd6, - 0x8a,0x13,0xb3,0xb0,0x96,0xe5,0x6b,0x30,0x85,0x65,0xa9,0xb4,0xe8,0xde,0x21,0x94, - 0x34,0x0d,0xc2,0x4c,0xc3,0x2e,0x61,0xe8,0x84,0x4c,0xda,0x33,0x9e,0x2a,0xdb,0xd6, - 0xa4,0xee,0xcb,0xb7,0x65,0xbc,0x67,0x5d,0xfa,0x57,0xde,0x55,0x3e,0x02,0x31,0xa5, - 0x59,0x32,0x81,0x9e,0x72,0xaf,0xf9,0x8e,0x15,0x30,0x41,0x6a,0x69,0x9f,0x27,0x33, - 0x58,0x2b,0x19,0x96,0x45,0xe9,0xaa,0xc3,0xfd,0x29,0x47,0xbd,0x8b,0xc2,0xef,0x18, - 0x71,0x43,0x8e,0x86,0x83,0x9e,0x0c,0xe0,0x4e,0xdd,0x8e,0x84,0xea,0x0a,0x3f,0xb2, - 0x9e,0x1a,0xbf,0x37,0x56,0xe5,0x8a,0x8f,0xad,0x46,0x57,0x9e,0x58,0xec,0x5e,0xc3, - 0x1e,0x54,0x0b,0x97,0xb4,0xb4,0x5c,0x17,0xa5,0x3c,0x94,0x0c,0x8b,0x32,0xd3,0xe1, - 0x70,0x00,0xcf,0x37,0x8d,0xa7,0xe5,0xc4,0xa7,0x86,0x65,0x94,0x63,0x8b,0x6c,0xb1, - 0x2c,0xbc,0x3f,0x5d,0xbf,0x7b,0xeb,0xa4,0xf4,0x0e,0x20,0xa8,0x63,0x83,0x29,0x2a, - 0x0c,0xf9,0x11,0x71,0xcd,0x72,0xd8,0x32,0x1e,0x1e,0x8a,0xd5,0x88,0x5e,0xa7,0xa2, - 0xfd,0xae,0xf4,0x54,0xc8,0xdb,0xb8,0x62,0xe2,0x2e,0xc0,0xb8,0xd0,0xcb,0x37,0xb6, - 0xf0,0xc8,0xa8,0x68,0xc6,0x79,0x1d,0xf7,0x28,0x8f,0x12,0xc1,0x9c,0xac,0x56,0xc3, - 0x90,0x8a,0x59,0xaa,0x7e,0xd7,0x41,0x10,0x39,0x90,0x0a,0x5d,0x14,0x86,0xaa,0x1e, - 0xd7,0x3c,0x8e,0xa0,0x06,0x4a,0x7c,0xe8,0xdc,0x01,0xb6,0x35,0xa4,0x82,0x25,0x10, - 0x6d,0x08,0x88,0xe2,0x66,0x90,0xcc,0x87,0xc8,0x23,0xb1,0x3b,0xe1,0x4f,0x50,0xc1, - 0x38,0x86,0xd5,0x60,0xac,0xd4,0xf2,0x06,0x6b,0x9f,0x34,0x14,0xe2,0x69,0x20,0xf0, - 0x1c,0xdc,0x94,0xf3,0x11,0xd1,0x11,0xc7,0x33,0x55,0x0f,0xaa,0x26,0x01,0x96,0xe7, - 0x43,0x05,0x5b,0x00,0xfa,0x14,0xa4,0x72,0xd7,0xed,0xd3,0xf5,0x59,0xbd,0xda,0x38, - 0x70,0x80,0xaa,0xe4,0x04,0x46,0xc9,0xbb,0x61,0x8f,0x6d,0x8d,0xff,0x25,0x47,0xca, - 0x4b,0x82,0xad,0x1e,0xa4,0x04,0xa3,0x7d,0x08,0x58,0xc7,0xb9,0x1a,0xd5,0x99,0xfa, - 0xb5,0x23,0x7a,0x43,0x92,0xe3,0xf6,0x08,0x65,0x10,0xaa,0x96,0xbb,0x29,0x65,0xdc, - 0xeb,0x49,0x35,0x39,0x80,0x4e,0xf6,0x90,0x78,0x79,0x65,0x80,0x7a,0x78,0x60,0x62, - 0x3a,0xe7,0xa3,0xf6,0xec,0x92,0xf2,0x79,0xfc,0xb0,0x0d,0xe8,0xce,0x28,0xe3,0x2a, - 0x29,0x9c,0x8a,0x16,0x55,0x9c,0x30,0xc4,0x3e,0x3c,0x20,0x52,0xde,0xda,0x28,0x4e, - 0xb8,0xe0,0xb8,0x45,0x46,0xbb,0xb7,0x87,0x44,0xac,0x2c,0x48,0x38,0xa0,0xaa,0xea, - 0x48,0x27,0xdc,0x55,0xbc,0x1c,0x94,0x59,0xa5,0x31,0xd6,0x19,0x38,0xbc,0x92,0x73, - 0x44,0xe6,0x44,0x97,0x60,0x0a,0x0d,0x69,0x7b,0x0b,0xdd,0x0f,0xd5,0x55,0xb8,0x4b, - 0x55,0x85,0x5e,0x86,0x6a,0x59,0xd0,0xc6,0x2c,0x70,0xc3,0xaf,0x87,0x7a,0x94,0xca, - 0x95,0xd1,0xbc,0x11,0xdc,0xa9,0xf6,0x84,0x41,0xd6,0x39,0x66,0x39,0x0e,0x35,0xa6, - 0xf6,0x52,0xdd,0x8b,0xf7,0x0c,0x3e,0xc3,0x35,0x06,0x8f,0x1c,0xd8,0xe8,0xfe,0x9e, - 0xdd,0x86,0x64,0xa4,0x6d,0x85,0x2c,0x97,0x30,0x84,0xb4,0xf9,0xd4,0x20,0x1f,0x33, - 0x15,0xa0,0x74,0x7a,0xdd,0xfd,0xc3,0x83,0x17,0xcf,0x91,0xd2,0x24,0x57,0xe1,0xbd, - 0x0c,0xcc,0x3e,0x1c,0x1c,0x91,0xda,0xaa,0xda,0x03,0xd8,0x0f,0x90,0x82,0x75,0xc3, - 0x4d,0x80,0xa2,0xb6,0x7c,0xdd,0x2f,0x70,0x94,0x44,0x48,0x6d,0xa9,0xa7,0x31,0xd3, - 0x41,0xb0,0x35,0x3f,0x37,0xc3,0x14,0x57,0x7b,0x7b,0x7b,0xf4,0xe9,0x28,0x1f,0x6d, - 0x98,0xf5,0xca,0xfa,0xb2,0x25,0xab,0x82,0x32,0xc1,0x97,0x97,0x7d,0xee,0x7e,0x29, - 0x53,0x27,0x0d,0xd4,0x2b,0x5e,0x51,0x67,0x7d,0x66,0x6a,0x2f,0x27,0x73,0x3f,0x0b, - 0x5c,0x92,0x56,0x99,0x9e,0xe9,0x66,0xc5,0xda,0x6b,0xa6,0x6b,0xd5,0xf0,0xa0,0x52, - 0x06,0x27,0xd4,0x83,0x2d,0x22,0xa6,0xf3,0x0e,0x0d,0x41,0x24,0x4d,0xe9,0xcc,0xe0, - 0x4e,0x50,0x6e,0x69,0x77,0x8d,0x8c,0xad,0xb6,0x9b,0xda,0x39,0x74,0xfa,0xa0,0x8a, - 0x1d,0xe5,0xfb,0x39,0xa7,0xc6,0xc8,0x13,0xc6,0x7c,0x08,0x1c,0xe7,0x90,0x20,0xb4, - 0xa3,0x00,0x85,0x5f,0x9b,0x53,0x45,0xbd,0xf2,0x66,0x33,0xc9,0xca,0xc4,0x57,0xb7, - 0x1e,0xab,0x8a,0xdb,0x2a,0xdf,0x80,0xab,0xba,0xef,0xe4,0xf4,0x65,0x52,0xec,0xaa, - 0x5f,0x00,0x07,0x6d,0xa5,0xb4,0xb6,0xd3,0xac,0xad,0x98,0xf5,0xba,0xb8,0xf2,0xe3, - 0x85,0xb7,0x4b,0xf2,0x26,0xdf,0x79,0x78,0xd0,0x92,0xf7,0xbc,0xf2,0x56,0xc3,0x51, - 0xb8,0xea,0xdc,0xe2,0x26,0x59,0x55,0x86,0xae,0x39,0xca,0x63,0xfb,0xa4,0xf3,0x9f, - 0xda,0x42,0xb9,0xbe,0x95,0x1a,0x52,0x95,0xa8,0xc2,0x5c,0xd4,0xc4,0x1e,0x9d,0x32, - 0x50,0x85,0xc7,0x94,0xbf,0x96,0xed,0x34,0x5e,0x40,0x5d,0xd8,0xd8,0xd8,0xc9,0x3e, - 0x62,0xa4,0xdb,0xb5,0x1e,0x25,0xee,0xda,0x20,0xb4,0x69,0xc1,0x1c,0xd6,0xde,0xa7, - 0x35,0x97,0x2a,0xe1,0x23,0x0a,0x84,0x1e,0x6e,0x29,0x0b,0x5b,0xe3,0xb2,0xdb,0xa3, - 0xbc,0xbb,0xbb,0xd2,0x2a,0x57,0xc4,0xc9,0x7d,0xed,0xcd,0x15,0xb4,0xe4,0xb8,0x20, - 0xdf,0x22,0xb9,0x38,0xd1,0x96,0x60,0x94,0x86,0x55,0x99,0x9c,0xf1,0xb6,0x7c,0xa6, - 0x12,0xe9,0xbb,0x44,0xcc,0xc2,0x18,0xf5,0x61,0xee,0x94,0x79,0x6c,0xae,0x5a,0x80, - 0xdc,0x84,0xc8,0x45,0x3e,0xca,0xa4,0x8c,0x4f,0x48,0x9a,0x74,0x83,0xad,0xc4,0x67, - 0x51,0xf9,0x41,0x40,0x39,0x81,0x0b,0x1f,0x92,0x8d,0x22,0xdc,0xe4,0xc2,0xe9,0x55, - 0x5f,0xfc,0xf6,0xdf,0xff,0x53,0x9e,0x03,0x58,0x14,0x2b,0x28,0x28,0x51,0xbb,0x6e, - 0xa3,0x5b,0x07,0xd4,0xf1,0xa3,0x8e,0x23,0x2e,0xe8,0xe5,0x4d,0x0c,0xab,0x35,0xe5, - 0xea,0xf0,0x42,0x86,0xa7,0x23,0x0c,0xe5,0xc3,0x8f,0x1a,0x9c,0x3c,0xca,0x69,0xee, - 0x75,0xa3,0x10,0x22,0x19,0x52,0xfd,0x52,0x03,0xe2,0x1b,0x6a,0xa3,0xad,0x55,0xb0, - 0xa6,0x32,0xd1,0xbd,0xbd,0x47,0xa6,0x4a,0x0d,0x11,0xd5,0xb5,0x0c,0xc4,0x2d,0x32, - 0x4f,0x75,0x8a,0xc6,0xee,0x71,0x8b,0x2c,0x34,0x2f,0xc1,0x9f,0x65,0xfc,0x94,0xe9, - 0xdc,0x2a,0x32,0xb7,0xbb,0x9e,0x57,0x3d,0x3e,0x01,0x4e,0x52,0xc1,0x7a,0x6b,0x3c, - 0xe5,0x67,0x94,0x8d,0x50,0x1a,0xab,0xa7,0x61,0x1c,0x15,0xe8,0x31,0xc5,0x93,0xf2, - 0x27,0xa6,0x1b,0x94,0xde,0x1b,0x98,0x4b,0x3d,0x0f,0xe3,0x69,0xf5,0x08,0xa0,0x6a, - 0x34,0x26,0xfd,0x87,0x5a,0x83,0x5b,0x56,0x9c,0x1b,0x1b,0x8e,0xa1,0xf1,0x9f,0x80, - 0x16,0x7b,0x4d,0x62,0x2e,0xce,0x9b,0xb1,0x9f,0x24,0xcf,0x75,0x53,0x26,0x01,0x4f, - 0x30,0x50,0xa3,0x73,0x72,0xeb,0xd1,0x22,0x0a,0x11,0x56,0x5b,0x23,0x88,0x02,0xb7, - 0xed,0x21,0x44,0xe1,0x1b,0xdb,0x99,0x52,0x44,0x85,0x8d,0xdb,0x91,0xad,0xc6,0xb4, - 0xb2,0xe9,0xc8,0x2b,0x0f,0x38,0x30,0xf8,0xdc,0xa2,0x2e,0xad,0xb5,0xf1,0xda,0xb0, - 0x2e,0xc8,0x91,0x94,0xa2,0x68,0x9b,0x58,0xce,0x0e,0x35,0xe4,0x54,0x11,0xde,0xac, - 0x59,0x77,0xca,0x26,0xa7,0xde,0x8f,0x8e,0xf7,0xcc,0xd2,0x6e,0xd9,0xce,0xd2,0x69, - 0x64,0xd9,0xff,0xa9,0x11,0x4b,0x4f,0xda,0xad,0xed,0x60,0x0b,0x8a,0x59,0xda,0xb2, - 0x68,0xba,0x84,0x58,0x96,0xff,0x76,0x2c,0x60,0x59,0xad,0x21,0x15,0x39,0x9f,0x0d, - 0xce,0x0b,0x9d,0x08,0x6a,0x3f,0xac,0x80,0x9a,0x2d,0x81,0x84,0xa1,0xfb,0x66,0x41, - 0x18,0xa8,0x53,0x7e,0xff,0x46,0x36,0x32,0xb7,0xda,0x1f,0xb6,0x89,0x7e,0xb5,0x43, - 0x2e,0xb2,0xc3,0x5d,0x22,0xf8,0xf1,0xe5,0x2d,0x5c,0x80,0x3a,0x4a,0xd4,0x1b,0x37, - 0x0d,0xb6,0x14,0xa3,0x89,0x3a,0xfc,0xe7,0x2d,0x94,0x34,0x59,0xba,0x43,0xc4,0x43, - 0x54,0xc7,0xe1,0x2b,0x44,0x6e,0xe4,0x22,0x40,0x84,0x31,0xec,0x47,0x35,0x03,0x9e, - 0x50,0x3b,0xf1,0x92,0x5e,0x4c,0x31,0x1e,0x1e,0xaa,0x1b,0xc2,0xb0,0x96,0xd2,0x49, - 0x33,0x49,0x84,0x2e,0xd4,0x7b,0x08,0xe0,0xf9,0xab,0x6b,0xd3,0xe2,0x30,0x1f,0xfe, - 0x4b,0x0b,0xaa,0x71,0xf3,0xa4,0x7e,0x23,0x9d,0xfb,0xaf,0x2a,0xe8,0x29,0x31,0x22, - 0x0d,0x45,0xd1,0xac,0x29,0x6c,0xd9,0x32,0x67,0x91,0x6b,0x7b,0xa6,0x30,0x36,0xf6, - 0xf4,0x0c,0xfa,0xca,0x29,0x6f,0xd0,0xd7,0xfc,0x32,0x3a,0x29,0x1a,0xdc,0x8d,0xad, - 0x2a,0x59,0x25,0x96,0x3e,0x1b,0x41,0xe6,0x4f,0x08,0xb1,0xa0,0x02,0xfa,0xc9,0x2f, - 0xe4,0x18,0x5f,0x1e,0xbf,0xe4,0x2f,0x6f,0xad,0xe5,0x76,0xe9,0xc9,0xdb,0x35,0xb1, - 0x7d,0x53,0x2a,0x1b,0x3d,0x41,0xd0,0x32,0x0d,0x7e,0x8b,0xc7,0xa0,0x3f,0x1f,0xa8, - 0x18,0x82,0x94,0xe8,0xaf,0x89,0xd4,0x59,0xc4,0xff,0x33,0x33,0x1b,0x2c,0x64,0x72, - 0x86,0xf5,0x37,0xb8,0xf8,0x8a,0x99,0x30,0x3b,0x6b,0xf4,0x95,0xdc,0xe1,0x29,0x28, - 0x76,0x3f,0xd2,0x7b,0x1b,0xa8,0xba,0x6b,0xf9,0x93,0xbc,0xf7,0xf6,0xf4,0xd6,0xd7, - 0xe4,0x4e,0xb6,0xc0,0xc7,0xfe,0x42,0x1f,0x8c,0xa8,0xc3,0x8b,0x62,0xfd,0xb5,0x01, - 0xb3,0x75,0x2d,0xe9,0xcc,0x94,0x02,0xca,0xcf,0x61,0xfb,0x2a,0x6c,0x71,0x2f,0x5e, - 0x67,0xd9,0xea,0x14,0x43,0x75,0x57,0x68,0xa2,0x6a,0x0b,0xa9,0x77,0x17,0x08,0x93, - 0xc6,0x7c,0x1e,0x1e,0xd2,0x9f,0x33,0x14,0x0d,0x6b,0xd3,0x25,0x27,0x22,0xd3,0x94, - 0xdd,0xb3,0x3c,0x00,0x50,0xa5,0x88,0xbd,0xc3,0xf5,0x4b,0x38,0x9a,0xaa,0x73,0xda, - 0xf2,0x0c,0x58,0xbd,0x73,0x9f,0x53,0x15,0x43,0x0e,0x9c,0x4f,0xa9,0x7f,0x44,0x19, - 0x5a,0x36,0x0c,0x8b,0xcc,0x87,0x27,0xf3,0xae,0x1d,0xdd,0x33,0x52,0x65,0x14,0xd1, - 0x42,0x35,0x0d,0xc7,0x47,0x5d,0x76,0x2b,0xc1,0xf6,0x7c,0x08,0x7f,0x10,0xe7,0xef, - 0x3e,0x5c,0xf3,0x6c,0x79,0xef,0x8f,0x8a,0x68,0xa1,0x8f,0x57,0x68,0x3b,0x8e,0x3a, - 0x1c,0xca,0x46,0x9f,0xb2,0x48,0x1f,0x8f,0x50,0xf9,0xab,0x33,0xb5,0x5c,0xb5,0x64, - 0x3e,0xbc,0xbe,0x86,0x1b,0x8d,0xa6,0xef,0xfd,0xcc,0x9f,0xe5,0x66,0x15,0x1b,0x72, - 0xbe,0x6b,0xd1,0x09,0x97,0x69,0x80,0x86,0x51,0xd6,0x4d,0xf9,0xde,0x5e,0xe7,0x2f, - 0x7c,0xfc,0x76,0xe2,0xfe,0xb9,0xf3,0xe7,0x4e,0xd9,0xe3,0xcb,0x2d,0x9d,0xa4,0xe8, - 0x05,0x73,0x95,0x7c,0xd4,0xe7,0x55,0xf4,0xf2,0xc3,0x46,0xf0,0xcd,0xab,0x10,0xb4, - 0x41,0xd2,0x36,0xca,0xe4,0xa5,0x9e,0xbf,0x25,0x14,0x01,0xce,0x1a,0x65,0xfa,0x24, - 0xf9,0x2e,0x4c,0xd3,0x90,0xaf,0xd8,0xd4,0x35,0x61,0x23,0x1c,0x68,0x28,0x57,0x47, - 0x2c,0xdb,0xaa,0x38,0x7a,0xb0,0x99,0x9c,0x32,0x9c,0x6f,0x3d,0x8d,0x51,0xc6,0x88, - 0xe0,0xca,0xcb,0x39,0x08,0x02,0x21,0x04,0xda,0x31,0xac,0xcf,0xfd,0x2f,0x08,0xe6, - 0x8e,0xa3,0xab,0x1e,0xd5,0x35,0x56,0xa3,0xb6,0x94,0x30,0x3b,0xba,0x19,0xd2,0xec, - 0x0e,0x93,0xfe,0xbe,0xd6,0x21,0x6e,0x26,0xa4,0x99,0x83,0x12,0x6f,0x68,0xae,0xa5, - 0x97,0xf5,0xdf,0xfe,0x6d,0x56,0x33,0x3b,0xea,0xbc,0x9f,0x1d,0xac,0x6c,0xf2,0x99, - 0x9f,0x87,0x5f,0xec,0xaa,0xa2,0x27,0xe7,0x30,0xec,0x25,0x1d,0x70,0xbb,0x06,0xfd, - 0x19,0x49,0xa8,0x6c,0xa6,0x93,0x8c,0x0a,0x59,0xb4,0xf3,0x82,0xde,0x0d,0x34,0x56, - 0xba,0x43,0xf6,0x8d,0xc2,0xa9,0x59,0xea,0x68,0x19,0x9e,0x27,0xf3,0x48,0xc5,0xb5, - 0x40,0x4b,0xb3,0x21,0x4a,0xd4,0x9b,0x55,0xe9,0x84,0x34,0x0a,0xb9,0x63,0x73,0x0c, - 0xfb,0x6c,0x59,0x3a,0xeb,0xca,0x92,0x7c,0x97,0xdf,0x20,0x81,0xeb,0x0e,0x25,0x35, - 0x6a,0xab,0x5e,0xef,0x66,0xda,0x48,0x48,0xb2,0x3d,0x5b,0x69,0xfc,0x11,0x56,0x87, - 0xdf,0xb9,0x3a,0xea,0xa8,0x3f,0x32,0xfd,0x3f,0x5c,0x93,0x0a,0xb9,0x75,0x3a,0x00, - 0x00, + 0x1f,0x8b,0x08,0x00,0x00,0x00,0x00,0x00,0x02,0xff,0xad,0x5b,0xe9,0x72,0xdb,0x48, + 0x92,0xfe,0xaf,0xa7,0x28,0xd1,0x33,0x02,0xd0,0x06,0x41,0x52,0xb6,0x6c,0x19,0x14, + 0xa4,0x95,0x75,0x74,0xf7,0xf8,0x0c,0xcb,0xde,0xde,0x6d,0x8f,0x67,0x02,0x24,0x0a, + 0x24,0x5a,0x20,0x80,0x01,0x40,0x49,0x1c,0x8a,0x11,0xf3,0x6b,0x1f,0x60,0x63,0x9e, + 0x61,0x1f,0xac,0x9f,0x64,0xbf,0xcc,0x2a,0x1c,0xa4,0x68,0xaf,0x27,0x62,0x1d,0x0e, + 0x91,0x00,0xaa,0xb2,0xb2,0xf2,0xf8,0xf2,0x28,0xf0,0x68,0x37,0x48,0xc7,0xe5,0x22, + 0x93,0x62,0x5a,0xce,0xe2,0xe3,0x9d,0x23,0xfa,0x10,0xb1,0x9f,0x4c,0xbc,0x8e,0x4c, + 0x3a,0x74,0x43,0xfa,0x01,0x3e,0x66,0xb2,0xf4,0xc5,0x78,0xea,0xe7,0x85,0x2c,0xbd, + 0xce,0xbc,0x0c,0xbb,0x87,0xf4,0xb4,0x18,0xe7,0x51,0x56,0x8a,0x22,0x1f,0x7b,0x9d, + 0x5e,0x16,0x76,0xc7,0x69,0x52,0xa4,0xb1,0x74,0x7e,0x2b,0x4e,0xa6,0x9e,0x0c,0xc3, + 0x30,0x38,0xe8,0x1f,0x74,0x8e,0x8f,0x7a,0x6a,0xe0,0xb1,0x9e,0x70,0x1c,0x85,0xe6, + 0xee,0x6d,0x94,0x04,0xe9,0xad,0xf3,0xfe,0xd2,0x32,0xc3,0x79,0x32,0x2e,0xa3,0x34, + 0x31,0xad,0xe5,0x8d,0x9f,0x8b,0x5f,0x3c,0xf5,0xcc,0xfe,0xe4,0x19,0x3d,0x3f,0x8b, + 0x7a,0x45,0xe9,0x97,0xf3,0xc2,0xb0,0x5f,0x7b,0x9f,0xbf,0xd8,0x6f,0xbc,0x7d,0xf9, + 0xc2,0xbe,0xb2,0x7f,0xb5,0xdf,0x7b,0xbf,0x60,0xba,0xb7,0xa4,0xc7,0xd2,0x35,0xe2, + 0xe8,0x46,0x1a,0x76,0x10,0xe5,0xe5,0xc2,0xdd,0x1d,0xd8,0x37,0x6e,0x32,0x8f,0x63, + 0x7b,0x22,0x4b,0xf7,0x47,0x3b,0x4a,0xc2,0x38,0x9a,0x4c,0x4b,0xf7,0xed,0x7c,0x36, + 0x92,0xb9,0x3d,0x4d,0xe3,0xc0,0x7d,0x37,0xfa,0x4d,0x8e,0x4b,0xbb,0xf0,0x17,0x6e, + 0xcd,0x41,0x69,0x5f,0xdb,0xd2,0x5a,0xca,0xbd,0x3d,0x53,0x3a,0xa5,0xbc,0x2b,0xcf, + 0xd2,0xa4,0x94,0x49,0xe9,0x95,0xd6,0xca,0x1e,0xcd,0x8b,0xd6,0xd0,0x91,0x9d,0x59, + 0xcb,0xea,0x4a,0x84,0x60,0x7e,0xe4,0x04,0x51,0xe1,0x8f,0x62,0x19,0x78,0xbb,0x83, + 0x55,0xfb,0xaa,0x3f,0xcc,0x9c,0x72,0x2a,0x13,0x33,0xb4,0x43,0x6b,0x98,0xcb,0x72, + 0x9e,0x27,0x22,0x5b,0xd9,0x59,0x1a,0xc7,0x0d,0xc5,0xc2,0x9e,0xd9,0x63,0x3b,0x55, + 0x52,0x28,0xed,0xd0,0x9e,0x78,0x83,0x61,0xbd,0xc2,0x07,0xac,0x30,0xd9,0xdb,0xdb, + 0x0d,0xc1,0x5b,0xe8,0x0d,0xec,0x71,0x2c,0xfd,0xfc,0x63,0x34,0x93,0xe9,0xbc,0x34, + 0x4b,0xcb,0x36,0x0b,0x67,0xec,0xc7,0xf1,0x49,0x61,0x5a,0xee,0x8f,0x66,0x61,0x59, + 0x7a,0xc5,0x8a,0x7a,0x60,0x2d,0xc7,0x66,0x60,0x93,0x54,0xb0,0x97,0xfa,0xb6,0xa4, + 0xdb,0x2c,0x2a,0x69,0xad,0xf4,0x9c,0x57,0xf6,0x2b,0xcb,0x5a,0xd5,0x2b,0xbf,0xc2, + 0xca,0xa1,0xd7,0x1f,0x6e,0xac,0x38,0x04,0x37,0x66,0xe9,0xc1,0x24,0xaa,0x7b,0x1f, + 0xec,0x19,0xe6,0xa5,0x7b,0x7b,0xa9,0x13,0x46,0x79,0x51,0x7a,0x1e,0x04,0x71,0x82, + 0xe9,0x2e,0x98,0xd7,0xdb,0x86,0xba,0xd2,0xcc,0x6d,0xa9,0x7c,0xf2,0x90,0xf2,0xca, + 0x06,0xd1,0x66,0x4c,0x62,0x2d,0x67,0x5e,0x32,0x0c,0xef,0xef,0x41,0x6a,0x65,0x27, + 0xe9,0xad,0xfb,0x61,0x85,0x31,0x6c,0x16,0xcd,0xb0,0x31,0x76,0xb2,0xb7,0xf7,0xda, + 0xc9,0xe6,0xc5,0x14,0x17,0xc3,0xab,0x13,0x5c,0x8e,0xcd,0x2b,0xcb,0xfd,0xf5,0xe4, + 0x57,0x07,0xb3,0xc0,0xc7,0xaf,0xde,0x7b,0x87,0x84,0x6e,0x7e,0xb2,0xdf,0xd8,0xe7, + 0x20,0x76,0xeb,0x97,0xe3,0xe9,0xd5,0x06,0xa5,0x99,0xb5,0x7c,0xef,0x28,0xf2,0xe0, + 0xfb,0x57,0x07,0xdc,0x98,0x6f,0xbc,0x37,0x7e,0x39,0x75,0x66,0x11,0x1e,0xdb,0x6f, + 0x2c,0x9e,0x1a,0x95,0x97,0x69,0x7e,0x2e,0x6f,0xa2,0xb1,0x6c,0x26,0x43,0x7f,0xa9, + 0x97,0xde,0xdf,0x2f,0x57,0x95,0xa2,0x13,0x79,0x2b,0xde,0xe7,0xe9,0x2c,0x2a,0x64, + 0xa3,0x8e,0x85,0x9d,0x28,0x45,0x4f,0x1b,0x96,0xf6,0xe5,0x93,0x46,0x31,0x85,0xb5, + 0x2c,0xa0,0x6e,0x33,0x75,0x46,0xf3,0x28,0x0e,0xf6,0xf6,0x0a,0xf5,0xc5,0xf3,0xf4, + 0x1d,0x0b,0xe2,0x9f,0x3a,0x24,0x4e,0xd3,0xb2,0x17,0xa4,0xf1,0x95,0x35,0x6c,0x69, + 0xa3,0x25,0xe3,0x6a,0xd8,0x30,0x31,0x2f,0xf2,0x3c,0xcd,0x4d,0xa3,0x54,0x83,0x0c, + 0xda,0x48,0xea,0xe8,0xab,0xfb,0xfb,0x17,0xf2,0x29,0xa8,0xac,0x56,0x8d,0xd9,0xfd, + 0x68,0xce,0xc9,0x24,0xf5,0x56,0x42,0x09,0x71,0xf1,0x9d,0x0d,0xeb,0xca,0xad,0x25, + 0x79,0x75,0xee,0xa4,0xd7,0x56,0x39,0xcd,0xd3,0x5b,0xa1,0x16,0xca,0xb5,0x1c,0x6b, + 0xab,0x67,0xeb,0x20,0xcf,0x3a,0xc9,0xf9,0x03,0x4a,0xc9,0x01,0x18,0xc4,0xe6,0xaa, + 0x65,0x73,0xe7,0xb4,0x7f,0x50,0xc4,0xdf,0x2b,0xf8,0x79,0x16,0x2a,0x1d,0x79,0xc5, + 0xf0,0xb5,0x13,0xa6,0xf9,0x85,0x0f,0x36,0xea,0xc5,0x43,0xd8,0x27,0x46,0x12,0xe3, + 0x2b,0xcb,0xb4,0x6a,0xbc,0xd1,0xc0,0x95,0xf8,0x33,0xe9,0x75,0x6e,0x22,0x79,0x9b, + 0xa5,0x79,0xd9,0x11,0x63,0xed,0xd3,0x9d,0xdb,0x28,0x28,0xa7,0x5e,0xc0,0x0a,0xec, + 0xf2,0x05,0x90,0x22,0x2a,0x23,0x3f,0xee,0x16,0xf0,0x25,0xe9,0x0d,0x08,0xe5,0xca, + 0xa8,0x8c,0xe5,0xf1,0x7b,0xbf,0x2c,0x65,0x0e,0x1c,0xc1,0xd6,0x7e,0xff,0xc7,0x3f, + 0xc5,0x65,0x94,0xcf,0x6e,0xfd,0x5c,0x8a,0x79,0x16,0x00,0x7f,0x8e,0x7a,0x6a,0x18, + 0x40,0xb1,0x5c,0xd0,0x67,0xef,0x07,0x71,0xa6,0xe0,0x50,0x8c,0xfc,0x42,0xba,0x02, + 0xf2,0x12,0x05,0x38,0x11,0xa3,0x38,0x1d,0x5f,0x8b,0x34,0x93,0x49,0x21,0xe4,0x8d, + 0xcc,0x17,0x60,0x08,0x74,0x32,0x7f,0x22,0x1d,0xf1,0x9a,0x60,0x4a,0x44,0x89,0x70, + 0xf3,0x34,0x2d,0x85,0x49,0xb3,0xc6,0x90,0xe7,0x4c,0x1a,0xc5,0x8e,0x10,0x78,0x42, + 0xa0,0x25,0x03,0xc1,0x78,0x86,0x49,0xb1,0x04,0x5f,0x82,0x00,0xad,0xa0,0x25,0x0a, + 0xbd,0xc8,0x8d,0x1f,0xcf,0x65,0xe1,0x8a,0x6b,0x29,0x33,0x5e,0xba,0xbc,0x4d,0x89, + 0x6c,0x51,0xca,0xcc,0xb2,0x89,0x52,0xe0,0xe7,0xd7,0x62,0x9e,0x04,0x32,0x67,0xf4, + 0xff,0x8c,0x5d,0xf8,0x5d,0x8c,0x84,0xac,0xe8,0xd1,0x17,0x47,0xbc,0x4d,0xc5,0xad, + 0x1c,0x89,0x10,0xe2,0x2a,0x14,0xff,0xd3,0xb4,0x2c,0x32,0xb0,0x35,0xf5,0x0b,0x91, + 0x10,0x3d,0x12,0x89,0x2c,0x1d,0xf1,0x43,0x6f,0x87,0x19,0x5e,0x8e,0xd3,0x38,0xcd, + 0x21,0x3d,0xa2,0xe3,0x32,0x8f,0xc3,0x6e,0x77,0x9c,0x4b,0x7f,0xe6,0x3e,0xba,0x7c, + 0x7a,0x71,0x79,0xf1,0x0c,0xd7,0x99,0x9f,0xc8,0x18,0xd7,0x97,0x97,0x67,0x97,0xa7, + 0xb8,0x8e,0x92,0x6b,0xf7,0xd1,0xe0,0x74,0x70,0x38,0x78,0x8a,0xab,0xd9,0x1c,0xfb, + 0x73,0x1f,0x3d,0xdb,0x3f,0x38,0x3b,0x18,0xe0,0x3a,0xf4,0xb1,0x8e,0xfb,0xe8,0xf9, + 0xf9,0xf3,0x67,0xcf,0x0e,0x71,0x9d,0xcf,0x63,0xe9,0x3e,0x3a,0x3f,0x3f,0x3f,0x38, + 0x7b,0xa6,0x2f,0xbb,0x45,0x1a,0x62,0xc8,0xc5,0xe1,0xc5,0xe0,0x9c,0x48,0x4c,0xa6, + 0x69,0x41,0xd7,0x4f,0xce,0xcf,0xce,0x2e,0x70,0x1d,0x13,0xc1,0xcb,0xcb,0x83,0xb3, + 0x7d,0xba,0x1a,0xf9,0xb8,0x7a,0x79,0xf8,0xe4,0x6c,0x70,0x86,0xab,0x14,0x6b,0xef, + 0xbf,0x7c,0x7e,0xfa,0xf4,0x25,0x2e,0xa0,0xd1,0xc4,0x7d,0x74,0x78,0xf0,0xac,0xdf, + 0xbf,0x18,0xee,0x74,0xbb,0x85,0x9f,0x14,0x6e,0xb1,0x80,0xd4,0x66,0xdd,0x79,0x64, + 0x77,0xfd,0x2c,0xa3,0xd5,0xf8,0x86,0x6d,0x5c,0xc9,0x49,0x2a,0xc5,0xa7,0x9f,0x0d, + 0xfb,0x43,0x3a,0x4a,0xcb,0xd4,0xa6,0xe1,0xdd,0x42,0xe6,0x51,0x48,0xfb,0x48,0x93, + 0xd4,0x9d,0x47,0xfc,0x59,0x64,0xfe,0x58,0xda,0xc6,0x99,0x0f,0xbb,0x0a,0x22,0x5f, + 0xbc,0xc1,0x3d,0x03,0x04,0x2e,0xf5,0xb7,0x37,0x32,0x89,0x53,0x5b,0x19,0x8c,0x5f, + 0xd8,0x86,0xa2,0xa7,0x1f,0xd6,0x04,0x56,0x3b,0x5b,0x35,0xb5,0x2e,0x74,0xba,0xd5, + 0xc8,0xbc,0x7f,0xd6,0x7f,0xd9,0x7f,0xd1,0xc8,0x7c,0xf0,0x64,0x30,0x18,0xf4,0x2b, + 0x99,0x5f,0x9c,0x5f,0x3c,0x3f,0x7f,0xd9,0xc8,0xfc,0xf4,0xc9,0x8b,0x97,0x87,0xa7, + 0x2d,0x99,0x9f,0x3e,0x7f,0xf2,0xec,0x69,0x2d,0xf3,0xfd,0x97,0xfb,0xcf,0xf7,0x07, + 0xeb,0x32,0x1f,0x5c,0x0e,0xce,0x06,0x87,0x8d,0xcc,0xf7,0x4f,0xf7,0x9f,0xed,0xf7, + 0x2b,0x29,0x5f,0x5e,0x3e,0x3f,0x3d,0x38,0xd0,0x52,0x3e,0x78,0xfe,0xf2,0xf0,0xf9, + 0x65,0x2d,0xe5,0xf3,0x17,0xa7,0xfd,0x27,0x97,0xab,0x9d,0x1f,0x96,0xa3,0xf4,0xae, + 0x5b,0x44,0x7f,0x8f,0x92,0x89,0x3b,0x4a,0x73,0x58,0x64,0x17,0x77,0x56,0x9f,0xa7, + 0x51,0x10,0xc8,0xe4,0xcb,0x12,0x71,0x34,0x8b,0x11,0xa5,0x93,0x34,0x91,0xbb,0xd1, + 0x8c,0x9c,0xd8,0x4f,0xca,0xd5,0xce,0x28,0x0d,0x16,0xcb,0x99,0x9f,0x4f,0xa2,0xc4, + 0xed,0x0f,0x47,0xfe,0xf8,0x7a,0x92,0xa7,0xb0,0x68,0x17,0xb0,0x6a,0x6a,0x09,0x58, + 0x43,0x16,0x8e,0xbe,0x85,0x4d,0x5b,0x43,0xb2,0x67,0x77,0x70,0x90,0xdd,0xf5,0x06, + 0xce,0x81,0x50,0x0f,0x48,0x71,0xd6,0xb0,0x0b,0x73,0xbf,0x8e,0xca,0x2e,0x8d,0xe8, + 0x16,0x33,0xd8,0xf3,0x94,0x78,0xc2,0x62,0x80,0x84,0x08,0x5e,0x1c,0xac,0x76,0x7c, + 0x25,0x6d,0x37,0x4a,0xa6,0xd0,0x73,0xb9,0x72,0xc3,0x74,0x3c,0x2f,0xba,0x37,0x51, + 0x11,0x21,0xd6,0x2f,0x81,0xa2,0x71,0x94,0x48,0x77,0x3f,0xbb,0x13,0xd0,0x65,0x14, + 0xe8,0x05,0x60,0x81,0xd6,0x50,0x3f,0xec,0xa6,0x61,0x48,0x01,0x0e,0x63,0x56,0x3b, + 0xce,0x6d,0xee,0x67,0xd8,0xc5,0x9d,0x02,0x20,0xf7,0xf9,0x7e,0x3f,0xbb,0x1b,0x56, + 0xbb,0x12,0xfe,0xbc,0x4c,0x87,0x99,0x1f,0x04,0xc4,0x08,0x3d,0x12,0x83,0x67,0xf8, + 0xf3,0xf4,0x90,0xe6,0x4e,0xf7,0xeb,0xed,0x8b,0xbe,0xc0,0xfd,0xa1,0xe2,0x3c,0xfa, + 0xbb,0x74,0x07,0x4f,0xaa,0xcb,0x5b,0xc9,0xa9,0x0f,0x4c,0x7a,0x4d,0x16,0xac,0x72, + 0x0b,0x0c,0x8c,0xca,0xa4,0x16,0x71,0x94,0x30,0x83,0x61,0x2c,0xef,0x86,0xd8,0xf2, + 0x24,0xe9,0x46,0xb0,0xf4,0xc2,0x1d,0x4b,0x72,0xf5,0xe1,0x6f,0xf3,0xa2,0x8c,0xc2, + 0x45,0x57,0x23,0x68,0x75,0x7b,0xe2,0x67,0x2e,0xad,0x8e,0xe0,0xd8,0x9d,0xaa,0xd5, + 0x9e,0xd2,0x2e,0x2a,0xbe,0xfb,0xcc,0xf4,0x50,0xe9,0xd6,0x1d,0x6c,0x88,0x86,0x8c, + 0xc9,0xd2,0x0f,0xbb,0x39,0xdc,0x03,0xa1,0xf8,0x90,0x86,0x6f,0x2a,0x94,0x4d,0xf8, + 0x6b,0x0a,0x3d,0xe8,0x63,0x95,0xa7,0x20,0xdd,0x56,0x28,0x05,0x98,0x6e,0x20,0x81, + 0xaf,0x3e,0x05,0x0a,0xb6,0xa0,0xe1,0x78,0x9e,0x17,0x20,0x90,0xa5,0x0c,0x5f,0xc3, + 0x32,0xc7,0xd0,0x88,0x1f,0x6b,0x1e,0x78,0x01,0xe1,0x0c,0x0e,0x0a,0xbb,0xe1,0x41, + 0x5d,0x37,0x8f,0x56,0x3b,0xff,0x36,0x93,0x70,0x65,0x73,0x9a,0x02,0xc3,0x5d,0xfe, + 0x6b,0x2d,0x49,0x98,0xea,0xfb,0xb2,0x4d,0xac,0xc5,0xed,0x8a,0x86,0x38,0x59,0x1e, + 0x55,0xc3,0x36,0x77,0xc9,0x96,0xb2,0x65,0x32,0xdf,0x57,0x37,0x1e,0x21,0xb5,0x5e, + 0x85,0x30,0x4f,0x60,0xb7,0xaf,0xe9,0x3c,0x58,0x47,0x69,0xd6,0xad,0x92,0xd0,0x65, + 0x0a,0x04,0x89,0x90,0x1b,0x3b,0x4f,0x0f,0x2a,0x11,0x04,0x32,0xf4,0xe7,0x71,0xb9, + 0x6d,0x35,0x56,0x8a,0x22,0x41,0xcc,0x3e,0x64,0x93,0x25,0xff,0x95,0x3d,0xae,0xa9, + 0x48,0xb9,0x21,0x48,0xcd,0x8a,0xc9,0x52,0xf9,0xde,0x7e,0xad,0x26,0x82,0xb6,0xf5, + 0xe1,0x8c,0x3c,0x90,0x12,0xca,0x89,0xf4,0x7a,0x6d,0x57,0xe9,0xb5,0xba,0x2d,0xf3, + 0xf5,0xdd,0x02,0x68,0x88,0xfc,0x78,0x1a,0x65,0x9b,0x76,0xcc,0xa1,0xb6,0x65,0x86, + 0x87,0xff,0x9a,0x15,0xbe,0x78,0xa1,0x7d,0xc8,0x1d,0x60,0x78,0x8f,0xdd,0xef,0x6b, + 0x7c,0x2b,0x77,0x1a,0xde,0x4e,0xe1,0x30,0x5d,0x46,0x6b,0x58,0x1b,0xf9,0x36,0x58, + 0x4b,0xd2,0x12,0x19,0x46,0xcd,0xdc,0x3a,0x57,0x2c,0x0d,0xb2,0xdc,0xef,0xe2,0x2c, + 0x96,0x00,0xdd,0x27,0xdb,0xc0,0xe5,0xfb,0x1d,0xa8,0x05,0x12,0xb4,0xec,0x36,0x27, + 0x59,0x69,0x9e,0xc5,0x68,0xd9,0xc6,0x10,0x38,0xd9,0xd6,0x1d,0x2a,0x5b,0xac,0x37, + 0xf8,0xbd,0xf0,0xc1,0x44,0xba,0x23,0x59,0xde,0x4a,0x99,0x30,0x8a,0x30,0x4e,0x28, + 0x4c,0xeb,0x52,0x9d,0xc0,0x30,0xa2,0x77,0x46,0xd7,0xdb,0x65,0xf3,0xdd,0x56,0xb5, + 0x53,0x39,0xcd,0xf7,0x02,0xde,0x26,0xa2,0x6d,0xd1,0xf8,0x56,0xf1,0xed,0x50,0xe1, + 0xfc,0x2d,0x89,0x50,0x4e,0x48,0x2b,0x0f,0xe9,0x49,0x97,0xa4,0xe8,0xd2,0x1f,0x16, + 0x02,0xa1,0x18,0xed,0xa6,0x12,0x04,0x52,0x81,0x32,0x9d,0x31,0xfa,0x83,0xf0,0x60, + 0x49,0x53,0xdc,0x41,0x1d,0x23,0x5a,0xfa,0xdc,0xdf,0xdf,0x02,0xfa,0x9c,0x21,0xe6, + 0xac,0x31,0xb2,0xb7,0xae,0xd3,0x1f,0xc8,0x99,0xb2,0x4a,0x59,0xc5,0x0f,0x98,0x0b, + 0x22,0x48,0x7f,0x33,0x7e,0x6c,0x8d,0x17,0x41,0x9e,0x66,0xeb,0x1b,0xe3,0x3d,0xa0, + 0xf2,0x96,0x9c,0x8e,0xbb,0x98,0x35,0x9f,0x25,0xff,0x62,0xfc,0x78,0xba,0x1e,0x3f, + 0x06,0x6b,0x01,0x64,0xbf,0xbf,0xee,0x1e,0x81,0x5f,0x4c,0xe5,0xb7,0x3c,0x77,0xd0, + 0xff,0x86,0xfd,0xb3,0xc6,0x98,0xbb,0x8a,0x81,0x8d,0x90,0xb0,0x45,0xcb,0xeb,0x3e, + 0xa3,0xa5,0xe0,0x7c,0x13,0xe9,0xbf,0x96,0x91,0xac,0xa8,0x36,0xf8,0xfd,0x9f,0xff, + 0xc0,0x7f,0xf1,0x89,0x8b,0x87,0xea,0x0a,0x59,0x34,0xc3,0x18,0x8a,0xa8,0x6d,0x44, + 0xd3,0x0d,0x64,0x65,0x40,0xe4,0xf1,0x94,0x50,0x2d,0xbf,0x1d,0x33,0xda,0x11,0x88, + 0xe7,0x00,0x36,0x97,0xdb,0x80,0x97,0x1f,0x12,0xc6,0x6e,0xa1,0x47,0x50,0x3b,0xdc, + 0x82,0xbd,0x1a,0xe0,0x5a,0x8e,0x4b,0x8e,0x5c,0x83,0x08,0xa7,0x18,0xed,0x87,0x9c, + 0xf9,0x3c,0x9a,0xfa,0x49,0x80,0x54,0xe8,0x12,0xd5,0xcd,0xf2,0x16,0x8b,0x75,0x47, + 0x90,0xcf,0xb5,0xcb,0x7f,0xa1,0x9f,0x18,0x43,0xd8,0xd6,0x36,0xc8,0x7e,0x33,0x62, + 0xaf,0x78,0xca,0x16,0xd8,0x7a,0x20,0x05,0xd2,0x42,0x10,0xcd,0x54,0x71,0x44,0xb5, + 0x9b,0x8d,0x92,0xa6,0xe4,0x4b,0x30,0x80,0x82,0x54,0x17,0x47,0x51,0x49,0x7a,0x61, + 0xb2,0x75,0x47,0x67,0xb9,0x3d,0x80,0x72,0xe9,0xe7,0x32,0x3e,0xad,0xd6,0x27,0x80, + 0xa1,0x56,0xfc,0xdd,0x7c,0xc8,0xa1,0x71,0x9b,0xaf,0x3d,0x1a,0xf9,0xf9,0x5a,0xfa, + 0x3b,0xac,0xc0,0x68,0x1d,0x26,0x55,0x7a,0xb5,0x69,0x6e,0x9c,0x91,0x13,0x95,0x30, + 0x8a,0xe3,0x65,0xe5,0x58,0xfd,0xfe,0x1f,0x87,0x2a,0xd5,0xdc,0x92,0x33,0xb3,0xc1, + 0xb4,0xe4,0xcb,0x03,0x21,0xd8,0x42,0x10,0x58,0xf9,0x39,0x88,0x11,0xaf,0x2d,0x47, + 0x6d,0xa5,0xab,0xb5,0x6e,0x37,0x23,0xcc,0x96,0xad,0x11,0x99,0xaf,0x07,0xf3,0x47, + 0x01,0xf6,0xba,0x5c,0x87,0x3f,0xb6,0x28,0x7e,0xf0,0x86,0x38,0xd0,0xe0,0x07,0xe3, + 0xa3,0x4c,0x21,0x0c,0x89,0x3f,0xf1,0x28,0xa3,0x9d,0xb6,0x25,0xbd,0x73,0xd4,0x53, + 0x05,0xf9,0x51,0x8f,0x7b,0x99,0x47,0x54,0x34,0x1c,0xef,0xec,0x1c,0xcd,0x60,0xee, + 0x62,0x8c,0x62,0xab,0xf0,0x3a,0x84,0xbc,0xa8,0xf4,0x85,0x38,0x52,0xb0,0x7d,0x7c, + 0x34,0x1d,0x1c,0x7f,0xd2,0x75,0x3d,0xbe,0x1e,0x01,0x3b,0xeb,0xc1,0xe4,0x1f,0x1d, + 0x11,0x05,0x5e,0x87,0x16,0xeb,0x1c,0xef,0x4d,0x65,0x1c,0x47,0xd9,0x10,0x0b,0x61, + 0x94,0x5e,0x07,0x34,0x88,0x5c,0x56,0x4d,0x22,0xac,0xed,0x1c,0x9f,0x93,0x69,0xfa, + 0x22,0xac,0x5a,0x07,0xce,0x08,0x5b,0x60,0x8b,0x63,0x64,0x12,0x61,0x4c,0xb8,0x56, + 0xc0,0xe4,0x10,0x1c,0x42,0x41,0xe0,0xc2,0x4f,0x51,0x62,0xc3,0x3f,0xae,0x05,0xfc, + 0x05,0xb6,0x59,0x94,0x7e,0x5e,0x16,0x02,0x06,0xaa,0x9e,0xdd,0x0a,0x6e,0x07,0x39, + 0x47,0xbd,0x0c,0x1b,0xc3,0xaa,0xbb,0xdd,0xae,0xf8,0x88,0x47,0x7c,0x9b,0xfb,0x0a, + 0x82,0x5c,0xad,0x68,0x2f,0xfd,0xe9,0xc3,0x6b,0x45,0xff,0x06,0xd5,0xe8,0x09,0x75, + 0x70,0x35,0x23,0x53,0x08,0x18,0x44,0xf1,0x88,0x9a,0x04,0xf4,0xaf,0x4c,0xe7,0x63, + 0xe2,0x8a,0x66,0xc7,0x18,0x11,0x8d,0xaf,0x65,0xee,0x88,0x6e,0x97,0xb7,0x18,0x44, + 0x37,0xad,0x4d,0xc2,0xdb,0x95,0x6c,0xb4,0x6f,0x77,0x84,0x2a,0xe2,0x8e,0x99,0xd8, + 0xd1,0x74,0xff,0xf8,0x35,0x9c,0x8f,0xfc,0x81,0x78,0x83,0xac,0xf6,0xf5,0x93,0x16, + 0x19,0x58,0xc6,0x1a,0x0d,0xc2,0x07,0x6a,0x22,0x63,0x88,0x1e,0x3c,0x9a,0xc3,0x22, + 0x92,0xf6,0x98,0x1f,0xd3,0x4e,0x35,0x1d,0x58,0x23,0x90,0xb1,0x76,0x04,0x35,0xb6, + 0x71,0xc9,0x63,0x3b,0xa2,0x72,0xb6,0xe3,0x4b,0x6a,0x61,0x89,0x3d,0x7f,0x96,0x0d, + 0x95,0xc0,0x8f,0x7a,0x6a,0x0c,0x6f,0x47,0xad,0xf2,0x8d,0x8d,0xf9,0xf9,0xec,0x27, + 0x00,0x65,0xbd,0xb1,0x8f,0xd3,0xa8,0xd0,0x92,0x4e,0xa4,0x24,0x21,0xe7,0xf0,0x8e, + 0x89,0xe0,0x06,0xa8,0x23,0xde,0x25,0x8d,0x7e,0x5d,0x6e,0xd3,0x80,0xfd,0xe3,0x57, + 0xfb,0x58,0xf4,0x58,0xec,0xe5,0x7e,0x9e,0x0f,0xc5,0xdb,0x8b,0x8f,0xbf,0xbc,0xfb, + 0xf0,0xca,0xa6,0x91,0x18,0x4e,0x5d,0x32,0x1a,0xf3,0xb4,0x3d,0x06,0x37,0x3e,0xbd, + 0x3f,0x3f,0xfd,0x78,0x41,0x37,0x9d,0x0d,0x36,0x89,0x2f,0x42,0x94,0x5a,0x06,0x8c, + 0x81,0xd5,0x8e,0x3b,0x22,0x4f,0xe3,0x96,0x24,0x4a,0x1f,0x66,0x17,0xc8,0x3b,0xaf, + 0xd3,0xef,0x80,0xdb,0xc8,0xef,0xd6,0x8d,0xea,0x4e,0x99,0xcf,0x65,0xa7,0x92,0x72, + 0x65,0xaf,0x64,0xa6,0x02,0xd5,0xae,0xa4,0xb5,0xd5,0xb3,0xb6,0x43,0xd4,0xfa,0xba, + 0x9d,0x2e,0x3a,0xc7,0x67,0x53,0x39,0xbe,0x26,0x01,0xd4,0xdb,0xde,0x70,0x90,0x46, + 0xca,0xda,0x54,0xfd,0xf1,0x58,0x66,0xa5,0xd7,0xf9,0xa1,0xf7,0x43,0x47,0x61,0x70, + 0x87,0x56,0xec,0xb8,0xe2,0x34,0xc1,0x46,0x90,0xeb,0xcd,0xfc,0xac,0x00,0x1a,0x5f, + 0x23,0xdd,0x4c,0x04,0x82,0xb7,0x04,0x3c,0xa5,0x49,0x01,0xcb,0x14,0x7e,0x65,0xa5, + 0x5c,0x8c,0x75,0xd3,0x24,0x5e,0x68,0x0b,0x15,0xe6,0xd8,0x9f,0xc9,0xdc,0xef,0x4d, + 0x10,0x4a,0x64,0xbe,0xb0,0x6c,0xf6,0x1f,0xd8,0x4d,0x91,0xce,0xa4,0x50,0x5d,0xbf, + 0x42,0xbc,0x7d,0xa7,0x19,0xa8,0x08,0x05,0xa9,0x2c,0xea,0x66,0xdd,0x10,0x73,0xb0, + 0x62,0x16,0x47,0x40,0x14,0x7c,0x5f,0x74,0xf9,0xb8,0x44,0xcd,0x10,0xd0,0xfc,0xed, + 0xd4,0x2f,0xe1,0x93,0x71,0x04,0x01,0x2e,0xc4,0x28,0xc7,0xce,0x8b,0x8a,0xd2,0x5c, + 0x75,0xde,0xd8,0x6b,0x66,0x7e,0x02,0x3f,0x84,0xdb,0x90,0x63,0xfe,0xe9,0x4a,0x8c, + 0x49,0x4c,0x45,0xb3,0x19,0xe6,0x8d,0x10,0x53,0x44,0x49,0x51,0x02,0x41,0x6a,0x07, + 0x8b,0x92,0x6c,0x5e,0xb2,0x7c,0x89,0x50,0x65,0xd6,0xea,0x7b,0x5b,0x74,0xda,0x20, + 0x1b,0x43,0x6f,0x5b,0x07,0xe2,0x08,0x5c,0xa8,0xba,0x0a,0x19,0xb8,0x94,0x16,0x1a, + 0x5d,0x54,0x4f,0x59,0x9f,0x6c,0x17,0x74,0xa4,0x02,0x98,0x43,0x3c,0x23,0xf4,0x5a, + 0x1f,0xb9,0xc5,0x35,0x08,0x9b,0x6b,0xbf,0x00,0xf6,0x55,0xf7,0x80,0xd7,0x34,0x3b, + 0x3b,0x6e,0x3b,0x2e,0xf5,0x3f,0xff,0x0f,0x9f,0x3d,0x7e,0x87,0x31,0x42,0x1f,0x2a, + 0xd5,0x5e,0xda,0xb2,0xfd,0x0d,0x78,0x7d,0x9b,0xb2,0xac,0x4f,0xc4,0x91,0xcf,0x4b, + 0x00,0xd5,0xc0,0x4e,0x2e,0x43,0xc0,0x44,0x59,0x66,0x85,0xdb,0xeb,0x65,0x4d,0x17, + 0xd7,0x21,0x48,0xed,0xa9,0xee,0x6d,0xe7,0xf8,0x2b,0x0f,0x8e,0x7a,0xfe,0xb1,0x28, + 0x64,0xa2,0x9a,0xab,0x58,0x33,0xc6,0xcd,0x82,0x35,0x8e,0x1c,0x01,0x36,0x82,0x58, + 0xc9,0x8d,0x58,0x8a,0xa4,0x64,0x8f,0x25,0xc1,0x81,0x6a,0xe5,0xfe,0x67,0x3a,0xcf, + 0x05,0x59,0xac,0x9f,0x07,0xf3,0x28,0x49,0xbb,0xe3,0x38,0x22,0x5b,0x4a,0xf3,0x52, + 0xd0,0x12,0x64,0xbf,0xa9,0x23,0x5e,0x55,0x1d,0x5a,0x76,0x16,0x2c,0x91,0xa5,0xb7, + 0xf0,0xb5,0xc0,0xa5,0xcc,0x43,0x99,0x89,0x5d,0x47,0x86,0xb5,0x08,0x30,0x5a,0xe8, + 0x48,0xd1,0x40,0xbf,0xaa,0x6f,0x8e,0x21,0x00,0xb5,0xef,0x1e,0x1c,0xb2,0x92,0x9f, + 0xdf,0xdc,0xfe,0x9a,0x38,0x08,0x1c,0xf2,0x09,0x9d,0xf1,0xfd,0x75,0x14,0xfb,0x09, + 0xae,0xb1,0x51,0x12,0x2f,0x69,0x4b,0xe6,0x0f,0xc5,0xc4,0x54,0x7b,0x7a,0x55,0xc4, + 0x5a,0x8a,0xaa,0x14,0x5d,0xab,0x06,0x7c,0xdd,0xcf,0xff,0x83,0x19,0xd5,0x87,0x08, + 0x41,0x3a,0x9e,0xcf,0x90,0x78,0x3b,0x58,0xe9,0x22,0x96,0xf4,0xf5,0xe5,0xe2,0xe7, + 0x00,0x23,0x56,0x3b,0x74,0x22,0x42,0xe8,0xe5,0xfd,0xc1,0x34,0xe8,0xd3,0xb0,0x6c, + 0xd2,0xe9,0xcf,0x09,0xdd,0xa0,0x6f,0xb8,0x41,0x71,0x97,0x2e,0xe9,0x13,0x97,0xb0, + 0x6d,0xba,0xc2,0x87,0x1a,0x1c,0xeb,0xa1,0xf4,0x0c,0xb6,0x4c,0x57,0xf8,0xc0,0xc5, + 0x24,0xa5,0xef,0x75,0xc4,0x30,0xac,0x21,0x2f,0x57,0x94,0x1e,0x9f,0x7f,0xb1,0xcf, + 0xf6,0x7a,0xac,0x0b,0x88,0xbb,0x14,0xda,0x06,0xf4,0xf9,0x23,0x7b,0xb5,0x3a,0x81, + 0xd1,0xc3,0xf5,0xd8,0x68,0x46,0x11,0x36,0x9f,0x27,0x09,0xc1,0x1e,0x30,0x0a,0xea, + 0x42,0xc8,0x12,0xad,0xb3,0x4b,0x9e,0x9b,0x4d,0x61,0x31,0x9e,0x61,0xd8,0x7a,0x1d, + 0xc3,0x10,0xf7,0xea,0x40,0x05,0x9f,0xf3,0x2c,0x4e,0xfd,0x00,0x5f,0xe8,0x54,0x09, + 0x1f,0x31,0x52,0x37,0x9e,0xf5,0x5b,0x3a,0x6a,0xd8,0xe3,0x15,0x61,0x60,0x6c,0x0e, + 0xae,0x58,0xd2,0x3a,0xae,0xa0,0xa0,0xc2,0x8c,0x21,0x82,0xa4,0x6e,0x8d,0x39,0x46, + 0x51,0xdd,0xbd,0x51,0x63,0x10,0xd3,0x09,0x6a,0x56,0x8a,0x9b,0x14,0x82,0x62,0xc2, + 0xb4,0x57,0xfe,0x36,0xdc,0x69,0xf4,0x55,0xc8,0xf2,0x3d,0x44,0x68,0x96,0xf6,0xd8, + 0x5a,0x92,0xa0,0xd7,0x4f,0x43,0x87,0x7c,0x8b,0xbd,0xef,0x2d,0x9d,0xae,0x18,0x94, + 0x15,0x09,0xe3,0xb1,0x39,0xbe,0xbf,0x37,0x0c,0x2a,0xb4,0x6b,0x42,0xfe,0x82,0x4e, + 0x55,0xad,0xe5,0xfb,0x4b,0x47,0x7f,0xc7,0x90,0x28,0x09,0xa9,0xb1,0x5d,0x4c,0x30, + 0x74,0x07,0xbb,0xfa,0x65,0xba,0xa0,0x03,0x05,0x86,0xca,0x31,0x20,0x77,0xc2,0x87, + 0x15,0x90,0xa5,0x30,0x21,0x26,0x8f,0x5d,0xc1,0x4f,0x2c,0x9d,0xa3,0x28,0x7c,0x87, + 0x1c,0x8a,0xe9,0xbc,0xb4,0xab,0x74,0x9d,0x80,0xa5,0x74,0x9a,0x95,0x11,0x8e,0x4c, + 0x6b,0x89,0xcd,0x56,0xe7,0xad,0x2c,0x7f,0xcf,0x33,0x58,0xe6,0xc6,0x89,0x71,0x0e, + 0xe7,0x24,0xa9,0x57,0xd1,0x8a,0xa5,0xf5,0xfb,0x3f,0xfe,0xc7,0x70,0xeb,0xa1,0x4a, + 0x2d,0x18,0x7b,0x49,0x12,0xc7,0x40,0x7a,0xcc,0xf2,0x6b,0xc6,0x90,0xc6,0x30,0xe2, + 0x83,0x72,0x4b,0x3d,0xa6,0x79,0x4c,0x9a,0xc4,0xe3,0x5f,0x30,0x8a,0xf3,0x82,0x34, + 0x6f,0xbc,0xbd,0xa1,0xb6,0x5b,0x94,0x27,0xc6,0xc3,0xd8,0xc9,0xa4,0x90,0x47,0xd0, + 0xd1,0xf3,0x89,0xf1,0x12,0x7f,0xf9,0xf0,0x09,0x12,0x52,0xb6,0x49,0x42,0xf0,0x63, + 0x08,0x20,0x58,0x54,0x16,0xd8,0x10,0x74,0x90,0x8a,0xc8,0xe0,0xc4,0x78,0x9d,0x8e, + 0x29,0xe5,0xe2,0x89,0xf9,0x8c,0x64,0x99,0x26,0xed,0xac,0x93,0x32,0x15,0x61,0xfa, + 0x23,0xe4,0x83,0x96,0xe1,0x1a,0xc6,0x70,0xa7,0xa5,0xbf,0x5c,0x52,0x15,0xa4,0x04, + 0x49,0xde,0x72,0xeb,0xb1,0x5c,0xed,0xc2,0x2b,0xca,0x21,0xee,0xd1,0xa9,0x1f,0xef, + 0xd5,0xaa,0x4c,0x66,0xb7,0x38,0x31,0x14,0xdf,0x9a,0x6d,0xfa,0x4b,0x57,0x9a,0x1d, + 0xe6,0x16,0xeb,0xc4,0xcc,0x96,0x61,0xd3,0xf8,0x66,0x30,0x95,0xb2,0xad,0xc1,0xe9, + 0x35,0x46,0xa2,0x28,0x20,0x67,0x15,0x40,0x11,0x43,0xe7,0x5f,0x86,0xe5,0xa8,0x40, + 0xe3,0xed,0x9a,0x74,0x4c,0xaa,0x27,0xf0,0x37,0x22,0x84,0x2f,0x8a,0x2b,0x9a,0xc6, + 0xd5,0x16,0x1b,0xeb,0xeb,0x08,0x62,0x29,0xd3,0xc9,0x24,0x96,0xc0,0x17,0x1d,0x29, + 0xc1,0xc2,0xee,0x6d,0x33,0x10,0xfb,0x38,0x2d,0xcb,0x3c,0x42,0xbc,0x91,0xb4,0x5e, + 0x2b,0x41,0x32,0xec,0xdb,0x13,0x83,0x72,0x24,0x30,0x15,0xfa,0x71,0x21,0x15,0x5b, + 0x0a,0xa1,0x9a,0xf3,0xfe,0x49,0xda,0x3a,0xfb,0xdf,0xbd,0xd5,0x9c,0x43,0x6e,0xe0, + 0xba,0xed,0x46,0xb7,0xf0,0x04,0x98,0x03,0xe2,0x82,0x0a,0x1e,0xe3,0x69,0x9a,0x22, + 0xa8,0xa8,0x44,0x9b,0xd5,0x40,0xbe,0xf1,0x0e,0x09,0x39,0x82,0x60,0xa2,0xba,0x2b, + 0x02,0xf9,0x86,0x2f,0xe8,0xc0,0xd6,0xc5,0x27,0x79,0x31,0x00,0x0c,0x62,0x27,0x7f, + 0x51,0x2f,0x39,0xc0,0xf0,0xa5,0xd2,0xef,0xc0,0xd9,0x17,0x6f,0x5e,0x8a,0xf7,0xef, + 0xae,0x3e,0x12,0x21,0x06,0xf1,0x00,0x08,0x83,0xbc,0x23,0x49,0xe7,0x93,0x29,0xad, + 0x89,0xb2,0x3f,0x26,0xbf,0xe1,0xc4,0x84,0xc9,0xd1,0x91,0x21,0xd7,0x20,0xd0,0xfd, + 0xdf,0xe6,0x14,0xed,0x4c,0x82,0x08,0x0b,0x79,0x13,0x0c,0x8f,0x08,0x69,0xb0,0x22, + 0x47,0x65,0xb8,0x22,0x9b,0x8e,0xca,0xa1,0x80,0x8b,0x73,0x86,0xcb,0x79,0xac,0x22, + 0xa3,0xc0,0x14,0x3b,0xf8,0xdb,0x3c,0x92,0x6d,0xef,0xac,0x8e,0xd7,0x5b,0x0e,0xca, + 0x40,0xa4,0x4e,0x98,0x8d,0x75,0xec,0x35,0xec,0xe5,0xd8,0x47,0x2c,0x74,0x0d,0x84, + 0xd1,0xa2,0x4c,0x73,0x69,0xac,0xbe,0xf7,0x04,0xda,0xf8,0xe9,0xe3,0xc7,0xf7,0x80, + 0xa6,0x07,0x27,0xd1,0xcd,0xb1,0xf3,0x9a,0xc1,0xb3,0x17,0xbf,0xa7,0x03,0x79,0xe6, + 0x0d,0x34,0x49,0x2a,0x16,0xfd,0xa9,0x4e,0xd1,0x77,0x34,0x7c,0x62,0xbf,0x7c,0x72, + 0xaf,0x08,0xdb,0xfb,0xfd,0x7e,0x7f,0xed,0xfc,0x5e,0x9d,0x5f,0x63,0x53,0xc5,0xb0, + 0x72,0xa3,0x95,0x5a,0xad,0x59,0x8e,0x23,0x3c,0x9d,0x60,0x37,0x72,0xd8,0xed,0xfd, + 0x99,0x12,0xe0,0x3f,0xf4,0x22,0x87,0x92,0x0d,0x33,0x74,0xe8,0x04,0xdb,0x3a,0x31, + 0x3e,0x52,0xb2,0x19,0x15,0x9c,0x23,0xeb,0xb4,0x9c,0x2c,0xc5,0xd1,0x2e,0x1f,0x3a, + 0x94,0x40,0x1e,0x3f,0xf9,0x61,0xd0,0xdf,0x7f,0xca,0x7f,0x30,0x25,0x4d,0xc5,0x28, + 0x6a,0x60,0xe7,0x09,0x19,0x84,0x9f,0x65,0xf0,0x7d,0x60,0x15,0x71,0x40,0x9a,0x07, + 0xcd,0x38,0x4d,0xaf,0xa9,0xca,0xbf,0x26,0xfb,0x43,0xde,0x3c,0x81,0xa5,0x84,0x08, + 0x0a,0x5d,0x8e,0x35,0x2a,0xc8,0x0d,0x2b,0xcd,0x13,0x25,0x22,0xc2,0x2c,0x54,0x09, + 0xeb,0x1a,0x17,0x47,0x10,0x86,0x66,0xe1,0x6a,0x5e,0x00,0xb1,0xa3,0x74,0x5e,0x20, + 0x43,0x2e,0x66,0xc8,0xc6,0x99,0x9b,0xf6,0x81,0x7a,0x55,0x96,0x3a,0xe2,0x97,0x3c, + 0xe5,0xe2,0x09,0x69,0x5c,0x05,0x45,0x64,0x71,0x1f,0x37,0x62,0x9a,0x0a,0x04,0x17, + 0xaf,0x2f,0xc5,0xd5,0x4f,0xa7,0xdd,0xfd,0x83,0x67,0x42,0x16,0x19,0xd2,0xa9,0x58, + 0x20,0x59,0x2e,0xc8,0x47,0xfa,0x77,0xa3,0xbe,0xad,0xb1,0x2d,0xa4,0x4c,0x6c,0xb4, + 0x80,0x30,0x89,0x96,0x89,0xbc,0xb0,0x47,0x47,0xee,0x7f,0x55,0x75,0xf3,0xd4,0x72, + 0xc4,0x29,0x98,0xfa,0xbb,0xcc,0xd3,0x02,0xb1,0x66,0x8e,0xfd,0xa0,0x3a,0x94,0x81, + 0x2e,0xc8,0x54,0xe1,0x41,0xde,0xcc,0x45,0x50,0xcb,0x86,0x89,0xa3,0x97,0x44,0x63, + 0x5d,0x7f,0xdb,0xdf,0xf7,0xe0,0x11,0x0a,0x43,0x73,0x8f,0x86,0x5c,0x62,0xf6,0x07, + 0xee,0x15,0x28,0x93,0xc2,0x7c,0x27,0xe5,0x80,0xe4,0x6d,0xbc,0x11,0x35,0xe2,0xf1, + 0x9f,0x00,0x7b,0x87,0xa7,0x79,0x8e,0x20,0x9a,0x3b,0x48,0x02,0xe7,0x71,0x69,0xd9, + 0x53,0x4a,0x28,0xa2,0x21,0x04,0x6a,0x46,0x5e,0x7f,0x18,0x1d,0x8d,0x9c,0x58,0x26, + 0x93,0x72,0x3a,0x8c,0x1e,0x3f,0xb6,0xa6,0x8f,0x3d,0x73,0xf4,0x39,0xfa,0x72,0x34, + 0x78,0x76,0x62,0xf4,0x49,0xa0,0xd6,0x63,0xba,0x06,0x04,0x5e,0x95,0x54,0xab,0x98, + 0x83,0x67,0xd6,0x70,0x61,0x4e,0xf5,0x24,0xc4,0xab,0xc3,0xbd,0xbd,0xde,0xe7,0xbf, + 0xf4,0xbf,0xf4,0x94,0xed,0x4d,0xad,0x93,0xa9,0xab,0xde,0x19,0x6a,0x78,0x94,0xe4, + 0x58,0x6d,0x26,0x17,0xe6,0xfa,0x10,0x82,0xf8,0xd3,0x82,0x79,0x7d,0x39,0x0f,0x43, + 0xec,0x10,0x56,0x01,0x94,0x93,0xe6,0xe0,0xf9,0x33,0x7b,0x70,0xd8,0xb7,0x78,0xc7, + 0x9b,0xbe,0x40,0x89,0xf6,0xf7,0x88,0x92,0x5e,0x9d,0xa9,0x85,0x79,0xc7,0xc2,0xf9, + 0x8f,0x37,0xaf,0x7f,0x42,0x52,0xfb,0x41,0x21,0x16,0xe2,0x53,0x18,0x28,0x21,0xa7, + 0xf9,0xec,0xdc,0x2f,0xfd,0x4a,0xc4,0xf5,0x5a,0x49,0x6a,0x96,0xd6,0xb2,0x7a,0x1f, + 0xa6,0xb4,0x2c,0x95,0x16,0xdd,0x39,0x84,0x92,0xa6,0x41,0x98,0x69,0xd8,0x15,0x0c, + 0x9d,0x90,0x49,0x7b,0xc6,0x63,0x65,0xdb,0x9a,0xd4,0x5d,0xf5,0xda,0x8c,0xf7,0xa4, + 0x4f,0xff,0xaa,0xbb,0xca,0x47,0x20,0xa6,0x2c,0x4f,0x27,0xd0,0x53,0xe1,0xb5,0x5f, + 0xb6,0x02,0x26,0x48,0x2d,0xed,0xb3,0x74,0x06,0x6b,0x25,0xc3,0xb2,0x28,0x5d,0x75, + 0xb8,0x51,0xe5,0xa8,0x97,0x52,0xf8,0x65,0x23,0xee,0xcc,0xd1,0x70,0xd0,0x93,0x01, + 0xdc,0xa9,0xdf,0x93,0x50,0x5d,0xe9,0xc7,0xd6,0x63,0xe3,0x8f,0xc6,0xaa,0x5a,0xf1, + 0xa1,0xd5,0xe8,0x12,0x14,0x8b,0xdd,0x69,0xd8,0x83,0x6a,0xe1,0x92,0x96,0x96,0xeb, + 0xa2,0x92,0x87,0x92,0x61,0x59,0x65,0x3a,0x1c,0x0e,0xe0,0xf9,0xa6,0xf1,0xb8,0x9a, + 0xf8,0xd8,0xb0,0x8c,0x6a,0x6c,0x99,0x2f,0x96,0xa5,0xf7,0xa7,0xab,0x77,0x6f,0x9d, + 0x8c,0x5e,0x06,0x04,0x75,0x6c,0x30,0x43,0x85,0x21,0x3f,0x22,0xae,0x59,0x0e,0x5b, + 0xc6,0xfd,0x7d,0xb9,0x1a,0xd3,0x7b,0x55,0xb4,0xdf,0x95,0x9e,0x0a,0x79,0x1b,0x97, + 0x4c,0xdc,0x05,0x18,0x97,0x7a,0xf9,0xd6,0x16,0x1e,0x18,0x15,0xcd,0x38,0x6b,0xe2, + 0x1e,0xe5,0x51,0x22,0x98,0x93,0xd5,0x6a,0x18,0x52,0x31,0x4b,0x15,0xf2,0x3a,0x08, + 0x22,0x07,0x52,0xa1,0x8b,0xc2,0x50,0xdd,0xec,0x9a,0x27,0x31,0xd4,0x40,0x89,0x0f, + 0x1d,0x40,0xc0,0xb6,0x46,0x54,0xb0,0x04,0xa2,0x0b,0x01,0x51,0xdc,0x0c,0xd2,0xf9, + 0x08,0x79,0x24,0x76,0x27,0xfc,0x09,0x2a,0x18,0xc7,0xb0,0x5a,0x8c,0x55,0x5a,0xde, + 0x60,0xed,0x93,0x86,0x42,0x3c,0x0d,0x04,0x9e,0x83,0x9b,0x6a,0x3e,0x22,0x3a,0xe2, + 0x78,0xae,0xea,0x41,0xd5,0x2d,0xc0,0xf2,0x7c,0xba,0x60,0x0b,0x40,0x9f,0x82,0x54, + 0x6e,0xbf,0x7d,0xba,0x7a,0xd9,0xac,0x16,0x06,0x0e,0x50,0x95,0x9c,0xc0,0xa8,0x78, + 0x37,0xec,0xd0,0xd6,0xf8,0x5f,0x71,0xa4,0xbc,0x24,0xd8,0xea,0x41,0x4a,0x30,0xda, + 0x87,0x80,0x75,0x9c,0xab,0x51,0x9d,0xa9,0xdf,0x3f,0xa2,0x57,0x25,0x39,0x6e,0x8f, + 0x51,0x06,0xa1,0x6a,0xb9,0x9d,0x52,0xc6,0xbd,0x9e,0x54,0x93,0x03,0xe8,0x64,0x0f, + 0x89,0x97,0x57,0x05,0xa8,0xfb,0x7b,0x26,0xa6,0x73,0x3e,0xea,0xd3,0x2e,0x29,0x9f, + 0xc7,0x17,0xdb,0x80,0xee,0x8c,0x2a,0xae,0x92,0xc2,0xa9,0x68,0x51,0xc5,0x09,0x43, + 0xec,0xfd,0x3d,0x22,0xe5,0x8d,0x8d,0xe2,0x84,0x0b,0x8e,0x1b,0x64,0xb4,0x7b,0x7b, + 0x48,0xc4,0xaa,0x82,0x84,0x03,0xaa,0xaa,0x8e,0x74,0xc2,0x5d,0xc7,0xcb,0x61,0x95, + 0x55,0x1a,0xa1,0xce,0xc0,0xe1,0x95,0x9c,0x23,0x32,0x27,0xba,0x04,0x53,0x68,0x48, + 0xdb,0x5b,0xe8,0xc6,0xa8,0xae,0xc2,0x5d,0xaa,0x2a,0xf4,0x32,0x54,0xcb,0x82,0x36, + 0x66,0x81,0x1b,0x7e,0x4f,0xd4,0xa3,0x54,0xae,0x8a,0xe6,0xad,0xe0,0x4e,0xb5,0x27, + 0x0c,0xb2,0xc9,0x31,0xab,0x71,0xa8,0x31,0xb5,0x97,0xea,0xa6,0xbc,0x67,0xf0,0x61, + 0xae,0x31,0x7c,0xe0,0xc0,0x46,0xff,0x8f,0xec,0x36,0x24,0x23,0x6d,0x2b,0x64,0xb9, + 0x84,0x21,0xa4,0xcd,0xc7,0x06,0xf9,0x98,0xa9,0x00,0xa5,0x37,0xe8,0x3f,0x3d,0x3c, + 0x78,0xfe,0x0c,0x29,0x4d,0x7a,0x19,0xdd,0xc9,0xc0,0xdc,0x87,0x83,0x23,0x52,0x5b, + 0x75,0x7b,0x00,0xfb,0x01,0x52,0xb0,0x6e,0xb8,0x09,0x50,0x36,0x96,0xaf,0xfb,0x05, + 0x8e,0x92,0x08,0xa9,0x2d,0xf3,0x34,0x66,0x3a,0x08,0xb6,0xe6,0xe7,0x76,0x98,0xe2, + 0x6a,0x6f,0x6f,0x8f,0xfe,0x3a,0xca,0x47,0x5b,0x66,0xbd,0xb2,0xbe,0x6c,0xc9,0xaa, + 0xa0,0x4c,0xf0,0xe5,0xe5,0x9f,0xfb,0x5f,0xaa,0xd4,0x49,0x03,0xf5,0x8a,0x57,0xd4, + 0x59,0x9f,0x99,0xd9,0xcb,0xc9,0xdc,0xcf,0x03,0x97,0xa4,0x55,0xa5,0x67,0xba,0x59, + 0xb1,0xf6,0xbe,0xe9,0x5a,0x35,0x3c,0xac,0x95,0xc1,0x09,0xf5,0x70,0x8b,0x88,0xe9, + 0xe0,0x43,0x43,0x10,0x49,0x53,0x3a,0x33,0xb8,0x13,0x94,0x5b,0xd9,0x5d,0x2b,0x63, + 0x6b,0xec,0xa6,0x71,0x0e,0x9d,0x3e,0xa8,0x62,0x47,0xf9,0x7e,0xc1,0xa9,0x31,0xf2, + 0x84,0x90,0x4f,0x83,0x93,0x02,0x12,0x84,0x76,0x14,0xa0,0xf0,0xfb,0x73,0xaa,0xa8, + 0x57,0xde,0x6c,0xa6,0x79,0x95,0xf8,0xea,0x1e,0x64,0x5d,0x71,0x5b,0xd5,0xab,0x70, + 0x75,0x1b,0x9e,0x9c,0xbe,0x4a,0x8a,0x5d,0xf5,0x0d,0xe0,0xa0,0xad,0x94,0xd6,0x76, + 0xda,0xb5,0x15,0xb3,0xde,0x14,0x57,0x7e,0xb2,0xf0,0x76,0x49,0xde,0xe4,0x3b,0xf7, + 0xf7,0x5a,0xf2,0x9e,0x57,0xdd,0x6a,0x39,0x0a,0x57,0x9d,0x5b,0xdc,0x24,0xaf,0xcb, + 0xd0,0x35,0x47,0x79,0x68,0x9f,0x74,0x10,0xd4,0x58,0x28,0xd7,0xb7,0x52,0x43,0xaa, + 0x12,0x55,0x54,0x88,0x86,0xd8,0x83,0xe3,0x06,0xaa,0xf0,0x98,0xf2,0xd7,0xb2,0x9d, + 0xd6,0x9b,0xa8,0x0b,0x1b,0x1b,0x3b,0x79,0x8a,0x18,0xe9,0xf6,0xad,0x07,0x89,0xbb, + 0x36,0x08,0x6d,0x5a,0x30,0x87,0xb5,0x17,0x6b,0xcd,0xa5,0x4a,0xf8,0x88,0x02,0xa1, + 0x87,0x5b,0xc9,0xc2,0xd6,0xb8,0xec,0x0e,0x28,0xef,0xee,0xaf,0xb4,0xca,0x15,0x71, + 0x72,0x5f,0x7b,0x73,0x05,0x2d,0x39,0x2e,0xc8,0xb7,0x48,0x2e,0x49,0xb5,0x25,0x18, + 0x95,0x61,0xd5,0x26,0x67,0xbc,0xad,0x9e,0xa9,0x44,0xfa,0x36,0x15,0xb3,0x28,0x41, + 0x7d,0x58,0x38,0x55,0x1e,0x5b,0xa8,0x16,0x20,0x37,0x21,0x0a,0x51,0x8c,0x73,0x29, + 0x93,0x13,0x92,0x26,0xdd,0x60,0x2b,0xf1,0x59,0x54,0x7e,0x10,0x50,0x4e,0xe0,0xc2, + 0x87,0x64,0xab,0x08,0x37,0xb9,0x70,0x7a,0xb5,0x2f,0x7e,0xff,0xaf,0xff,0xae,0x0e, + 0x04,0x2c,0x8a,0x15,0x14,0x94,0xa8,0x5d,0xb7,0xd1,0xad,0x03,0xea,0xf8,0x71,0xcf, + 0x11,0xe7,0xf4,0x16,0x27,0x86,0x35,0x9a,0x72,0x75,0x78,0x21,0xc3,0xd3,0x11,0x86, + 0xf2,0xe1,0x07,0x0d,0x4e,0x1e,0xe5,0xb4,0xf7,0xba,0x51,0x08,0x91,0x0c,0xa9,0x7e, + 0x69,0x00,0xf1,0x0d,0xb5,0xd1,0xd6,0x2a,0x58,0x53,0x99,0xe8,0xde,0xde,0x03,0x53, + 0xa5,0x86,0x88,0xea,0x5a,0x06,0xe2,0x06,0x99,0xa7,0x3a,0x4e,0x63,0xf7,0xb8,0x41, + 0x16,0x5a,0x54,0xe0,0xcf,0x32,0x7e,0xcc,0x74,0x6e,0x14,0x99,0x9b,0x5d,0xcf,0xab, + 0x1f,0x9f,0x00,0x27,0xa9,0x60,0xbd,0x31,0x1e,0xf3,0x33,0xca,0x46,0x28,0x8d,0xd5, + 0xd3,0x30,0x8e,0x0a,0xf4,0x84,0xe2,0x49,0xf5,0x15,0xd3,0x0d,0x4a,0xef,0x0d,0xcc, + 0xa5,0x9e,0x87,0xf1,0xb8,0x7e,0x04,0x50,0x35,0x5a,0x93,0xfe,0x5d,0xad,0xc1,0x2d, + 0x2b,0xce,0x8d,0x0d,0xc7,0xd0,0xf8,0x4f,0x40,0x8b,0xbd,0xa6,0x09,0x17,0xe7,0xed, + 0xd8,0x4f,0x92,0xe7,0xba,0x29,0x97,0x80,0x27,0x18,0xa8,0xd1,0x3b,0xb9,0xf1,0x68, + 0x11,0x85,0x08,0xab,0xad,0x11,0x44,0x81,0xdb,0xf6,0x10,0xa2,0xf0,0x8d,0xed,0x4c, + 0x29,0xa2,0xc6,0xc6,0xed,0xc8,0xd6,0x60,0x5a,0xd5,0x74,0xe4,0x95,0x87,0x1c,0x18, + 0x7c,0x6e,0x51,0x57,0xd6,0xda,0x7a,0x7f,0x58,0x17,0xe4,0x48,0x4a,0x51,0xb4,0x4d, + 0x2c,0x67,0x87,0x1a,0x72,0xaa,0x08,0x6f,0xd7,0xac,0x3b,0x55,0x93,0x53,0xef,0x47, + 0x47,0xd9,0xd3,0x44,0xc8,0x80,0xeb,0x45,0x94,0x60,0x0a,0x1c,0xc7,0x20,0x5a,0xa8, + 0x56,0x27,0x67,0x5c,0xc0,0xc5,0x31,0xe0,0x7f,0x58,0xf5,0x4d,0x2b,0xc8,0x28,0x22, + 0x7c,0xaf,0x7b,0xe8,0x14,0xa4,0x93,0x00,0xe3,0x9a,0xa2,0x4f,0x65,0x14,0xdf,0x54, + 0xa5,0xaa,0x83,0x7c,0xea,0xdf,0xa2,0x7a,0x83,0x94,0x7c,0x87,0x3b,0xd9,0xc6,0x57, + 0x1b,0xfb,0x9a,0xdb,0xe2,0x91,0xf1,0x58,0x26,0xe3,0x34,0x90,0x9f,0x3e,0xfc,0x4c, + 0x09,0x37,0x64,0x9d,0x94,0xcd,0x62,0x44,0xa9,0x6d,0xd0,0xc6,0x57,0x09,0x19,0x2b, + 0xdd,0x0d,0x20,0xe5,0xec,0x56,0x8d,0x3d,0x9d,0x50,0x57,0x9d,0xb0,0x06,0xbb,0xb5, + 0xf8,0x76,0x1b,0x8f,0xd8,0x82,0xe7,0x96,0xf6,0x31,0x9a,0x2e,0x61,0x20,0xcb,0x7f, + 0x39,0x2a,0xb2,0xd5,0xac,0x61,0x36,0xc1,0x90,0x0d,0x1d,0x96,0x3a,0x25,0xd6,0x88, + 0x54,0x87,0x2c,0xf6,0x09,0x52,0x84,0xd6,0x52,0x10,0x05,0xea,0xc5,0x07,0xff,0x5a, + 0xb6,0x72,0xd8,0x06,0x19,0xb6,0x19,0xe1,0x6a,0x87,0xc0,0x62,0x87,0xfb,0x65,0x40, + 0xb4,0x8b,0x1b,0xc8,0x8e,0x7a,0x6b,0x74,0x4a,0x60,0x1a,0xec,0x33,0x46,0x1b,0x7f, + 0xf9,0x17,0x3f,0x94,0x3e,0x5a,0xba,0x57,0xc6,0x43,0x54,0xef,0xe5,0x2b,0x44,0xae, + 0xe5,0x22,0x80,0x4d,0x19,0xf6,0x83,0xea,0x09,0x4f,0xa8,0xb1,0x7a,0x41,0xef,0xea, + 0x18,0xf7,0xf7,0xf5,0x0d,0x01,0x33,0x91,0x4e,0x96,0x4b,0x22,0x74,0xae,0x5e,0xcd, + 0x00,0xcf,0x5f,0x5d,0x9b,0x16,0x87,0x69,0xf3,0x8f,0x4f,0xa8,0xda,0x2f,0xd2,0xe6, + 0x25,0x7d,0xee,0x44,0xab,0xf0,0xaf,0xc4,0x08,0x6b,0x95,0xc5,0x8e,0xa6,0xb0,0x65, + 0xcb,0x9c,0x4f,0xaf,0xed,0x99,0x2c,0x36,0xf4,0xf4,0x0c,0xfa,0x28,0x28,0x83,0xd2, + 0xd7,0xfc,0x7e,0x3e,0x29,0x1a,0xdc,0x85,0x56,0x9d,0xb6,0x13,0x4b,0x9f,0x8d,0x20, + 0xf7,0x27,0x84,0xdd,0x50,0x01,0x7d,0xe5,0x77,0x94,0x8c,0x2f,0x0f,0x7f,0xf7,0x20, + 0x6f,0xac,0xe5,0x76,0xe9,0xc9,0x9b,0x35,0xb1,0x7d,0x53,0x2a,0x1b,0xdd,0x51,0xd0, + 0x32,0x0d,0x7e,0xb1,0xc9,0xa0,0x5f,0x54,0xd4,0x0c,0x41,0x4a,0xf4,0x03,0x2b,0x75, + 0x2a,0xf3,0xff,0xcc,0xcc,0x06,0x0b,0xb9,0x9c,0x61,0xfd,0x0d,0x2e,0xbe,0x62,0x26, + 0xcc,0xce,0x1a,0x7d,0x25,0x77,0x78,0x0a,0xca,0xfe,0x8f,0xf4,0x2a,0x4b,0x28,0xf3, + 0x46,0xfe,0x24,0xef,0xbd,0x3d,0xbd,0xf5,0x35,0xb9,0x93,0x2d,0xf0,0x9b,0x10,0x42, + 0x1f,0x11,0x29,0x6c,0x2b,0xd7,0xdf,0xa4,0x30,0x3b,0x57,0x92,0x8e,0x91,0x29,0xb4, + 0xfe,0x12,0x75,0x2f,0xa3,0x0e,0x9f,0x4a,0xe8,0x7a,0x43,0x9d,0xe7,0xa8,0x3e,0x13, + 0x4d,0x54,0x0d,0x32,0xf5,0x3a,0x07,0xa1,0x73,0xc8,0xaf,0x08,0x44,0xf4,0x0b,0x8f, + 0xb2,0x65,0x6d,0xba,0xf8,0x06,0xf6,0x4c,0xd9,0x3d,0xab,0xa3,0x10,0x55,0x94,0xd9, + 0x3b,0x5c,0xc9,0x45,0xe3,0xa9,0x3a,0xba,0xae,0x8e,0xc5,0xd5,0xcf,0x10,0x0a,0xaa, + 0xe7,0xc8,0x81,0x8b,0x29,0x75,0xd2,0x28,0x57,0xcd,0x47,0x51,0x99,0xfb,0xf0,0x64, + 0xde,0xb5,0xa3,0xbb,0x67,0x0a,0x6d,0x89,0x56,0x8e,0xb4,0x89,0x20,0x39,0xbf,0x01, + 0x80,0x67,0xf3,0x11,0xfc,0x41,0x9c,0xbd,0xfb,0x70,0xc5,0xb3,0xe5,0x9d,0x3f,0x2e, + 0xe3,0x85,0x3e,0x68,0xa2,0xed,0x38,0xea,0x98,0x2c,0x1f,0x7f,0xca,0x63,0x7d,0x50, + 0x44,0x8d,0x00,0x9d,0xb3,0x16,0xaa,0x39,0xf5,0xe1,0xf5,0x15,0xdc,0x68,0x3c,0x7d, + 0xef,0xe7,0xfe,0xac,0x30,0xeb,0x28,0x59,0xf0,0x5d,0x8b,0xce,0xfa,0x2a,0xec,0xd6, + 0x78,0xbf,0xb7,0xd7,0xfb,0x0b,0xc3,0xf7,0x89,0xfb,0xe7,0xde,0x9f,0x7b,0x55,0xb7, + 0xb3,0xb0,0x74,0xba,0xa6,0x17,0x2c,0x54,0x1a,0xd6,0x9c,0xdc,0xd1,0xfb,0x20,0x1b, + 0x69,0x48,0x51,0x07,0xe3,0x0d,0x92,0xb6,0x51,0xa5,0x71,0xcd,0xfc,0x2d,0x41,0x19, + 0x70,0xd6,0x6a,0x58,0x4c,0xd2,0xef,0xc2,0x34,0x0d,0xf9,0x8a,0x4d,0x5d,0x1d,0xb7, + 0xc2,0x81,0x86,0x72,0x75,0xd8,0xb4,0xad,0x9e,0xa5,0x07,0x9b,0x69,0x3a,0xc3,0xf9, + 0xd6,0x73,0x29,0x65,0x8c,0x48,0x33,0x78,0x39,0x07,0x41,0x20,0x82,0x40,0x7b,0x86, + 0xf5,0x79,0xff,0x0b,0xd2,0x1a,0xc7,0xd1,0xf5,0x9f,0xea,0x9f,0xab,0x51,0x5b,0x8a, + 0xb9,0x1d,0xdd,0x16,0x6a,0xf7,0xc9,0x49,0x7f,0x5f,0xeb,0x95,0xb7,0x53,0xf3,0xdc, + 0x41,0xb1,0x3b,0x32,0xd7,0x12,0xed,0xe6,0xe7,0x90,0x9b,0x75,0xdd,0x8e,0x7a,0x05, + 0x82,0x1d,0xac,0x6a,0x77,0x9a,0x9f,0x47,0x5f,0xec,0xba,0xb7,0x41,0xce,0x61,0xd8, + 0x4b,0x3a,0xf3,0x77,0x0d,0xfa,0x65,0x4d,0xa4,0x6c,0xa6,0x97,0x8e,0x4b,0x59,0x76, + 0x8b,0x92,0x5e,0x97,0x34,0x56,0xba,0x57,0xf8,0x8d,0x12,0xb2,0x5d,0xf4,0x69,0x19, + 0x9e,0xa5,0xf3,0x58,0xc5,0xb5,0x40,0x4b,0xb3,0x25,0x4a,0x54,0xde,0x75,0x11,0x89, + 0x84,0x12,0x59,0x74,0x7b,0x0c,0xfb,0x6c,0xd5,0x44,0xd0,0x35,0x36,0xf9,0x2e,0xbf, + 0x54,0x03,0xd7,0x1d,0x49,0x6a,0x59,0xd7,0x5d,0xef,0xcd,0x04,0x9a,0x90,0x64,0x7b, + 0xde,0xd6,0xfa,0x5d,0x5a,0x8f,0x5f,0x43,0x3b,0xea,0xa9,0xdf,0xdd,0xfe,0x2f,0xa8, + 0x7d,0xe7,0xd9,0x88,0x3b,0x00,0x00, }; constexpr size_t WEB_UPDATE_HTML_GZ_LEN = sizeof(WEB_UPDATE_HTML_GZ); // diff --git a/firmware/toolchain/editor_demo.py b/firmware/toolchain/editor_demo.py new file mode 100644 index 000000000..852d57aed --- /dev/null +++ b/firmware/toolchain/editor_demo.py @@ -0,0 +1,334 @@ +"""The audio extension's mapping editor as static files, for the Audio guide. + + python firmware/toolchain/editor_demo.py # write web/public/guide/audio-editor/ + python firmware/toolchain/editor_demo.py --check # is what is there current? + +The guide at /guide/audio shows the extension's real editor - the page the +popup's "Open editor" opens, not a screenshot - in a window on the page, and +what it maps drives the 3D board beside it (web/src/components/guide/ +EditorWindow.tsx is the other end). This writes what that window loads: + + index.html tools/patternflow-audio-extension/editor.html, with one + tag added: bridge.js, between the adapter and the editor + editor.css ) + mapping.js ) the extension's files, byte for byte (line ends + editor-adapter.js ) apart) + editor.js ) + bridge.js BRIDGE_JS below: the guide's end of the editor + +Nothing of the extension is rewritten. A page that is not an extension has no +chrome.storage, so editor-adapter.js runs its own demo source (a synthesized +spectrum; the bar says "demo source"), and bridge.js stands between that +adapter and the editor - its header comment says what it does and why. The +extension's folder is not touched: this only reads it. + +Rerun it after any change to those four files, to editor.html or to BRIDGE_JS; +`--check` exits 1 when the folder is stale. The folder is this script's alone: +it is emptied and rewritten each run. + +bridge.js maps levels to knobs the way the capture does (offscreen.js tick()), +with PFMap's own functions, but the few lines that join them are written +again there. CHAIN below is those lines as offscreen.js has them: if one is +gone the capture's chain has changed, this script stops, and BRIDGE_JS is to +be brought back in step before the list is. +""" + +import argparse +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.normpath(os.path.join(HERE, "..", "..")) +EXT = os.path.join(REPO, "tools", "patternflow-audio-extension") +OUT = os.path.join(REPO, "web", "public", "guide", "audio-editor") + +# Copied as they are. +VERBATIM = ["editor.css", "mapping.js", "editor-adapter.js", "editor.js"] + +# The one edit to editor.html. +ADAPTER_TAG = '' +BRIDGE_TAG = '' + +# What bridge.js follows, as offscreen.js writes it (see the docstring). +CHAIN = [ + "const TICK_MS = 33;", + "const REPORT_MS = 120;", + "const a = raw > levels[i] ? config.attack : config.smoothing;", + "levels[i] = a * raw + (1 - a) * levels[i];", + "if (auto) PFMap.trackEnvelope(envelopes[i], levels[i]);", + "const pos = PFMap.position(levels[i], bands[i], auto ? envelopes[i] : null);", + "const output = PFMap.output(bands[i], pos);", + "if (!bands[i].muted) lanes[bands[i].knob] = output;", + "if (running) config = PFMap.normalizeConfig(message.config, PFMap.SOURCE);", +] +# And the two things it leans on in the demo adapter. +ADAPTER = [ + "onFrame(fn) { frameFn = fn; demoFrame(); setInterval(demoFrame, 120); }", + "e && e.hi - e.lo > PFMap.ENV_MIN_SPAN + 1e-6 ? e : null);", +] + + +def read(name): + with open(os.path.join(EXT, name), encoding="utf-8", newline="") as f: + return f.read().replace("\r\n", "\n") + + +def render(): + """{file name: bytes} - everything the folder should hold.""" + for name, lines in (("offscreen.js", CHAIN), ("editor-adapter.js", ADAPTER)): + text = read(name) + for line in lines: + if line not in text: + raise SystemExit( + "%s no longer has the line\n %s\nbridge.js (BRIDGE_JS in " + "editor_demo.py) follows it: bring the bridge in step, then " + "this list." % (name, line)) + html = read("editor.html") + if html.count(ADAPTER_TAG) != 1: + raise SystemExit("editor.html: expected one %s" % ADAPTER_TAG) + html = html.replace(ADAPTER_TAG, ADAPTER_TAG + "\n" + BRIDGE_TAG) + head = "\n" + if not html.startswith(head): + raise SystemExit("editor.html: does not start with its doctype") + html = (head + "\n" + + html[len(head):]) + files = {"index.html": html, "bridge.js": BRIDGE_JS} + for name in VERBATIM: + files[name] = read(name) + return {n: t.encode("utf-8") for n, t in files.items()} + + +def main(): + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + ap = argparse.ArgumentParser(description="Write the Audio guide's editor demo.") + ap.add_argument("--check", action="store_true", help="exit 1 if the folder is stale") + args = ap.parse_args() + files = render() + total = sum(len(b) for b in files.values()) + if args.check: + have = set(os.listdir(OUT)) if os.path.isdir(OUT) else set() + # A Windows checkout has the folder's files with CRLF line ends; what + # is written here, and what git stores, has LF. + def on_disk(n): + with open(os.path.join(OUT, n), "rb") as f: + return f.read().replace(b"\r\n", b"\n") + stale = sorted(n for n, b in files.items() if n not in have or on_disk(n) != b) + stale += sorted(have - set(files)) + if stale: + print("editor demo is stale: %s\nrun: python firmware/toolchain/editor_demo.py" + % ", ".join(stale)) + sys.exit(1) + print("editor demo current (%d files, %d bytes)" % (len(files), total)) + return + os.makedirs(OUT, exist_ok=True) + for n in os.listdir(OUT): + p = os.path.join(OUT, n) + if os.path.isfile(p): + os.remove(p) + for n, b in sorted(files.items()): + with open(os.path.join(OUT, n), "wb") as f: + f.write(b) + print("wrote %d files, %d bytes -> %s" % (len(files), total, os.path.relpath(OUT, REPO))) + for n, b in sorted(files.items()): + print(" %-18s %7d" % (n, len(b))) + + +# ── the guide's end of the editor ────────────────────────────────────────── + +BRIDGE_JS = r'''/* Patternflow Audio guide: between the editor and its demo source. + * GENERATED by firmware/toolchain/editor_demo.py -- edit BRIDGE_JS there, rerun. + * + * Loaded after editor-adapter.js and before editor.js. Outside the extension + * the adapter is its demo: a synthesized spectrum, the mapping in memory, and + * each band's output straight off its level. That is a preview of the editor. + * The guide shows the editor driving a board, so five things are added here, + * around window.PFAdapter and without a change to any of the extension's + * files: + * + * the capture's chain The knob values are worked out as the capture works + * them out (offscreen.js tick()): every 33 ms, the + * level glided by Attack and Damping, auto range's + * envelope at its real rate, PFMap.position and + * PFMap.output, and a muted band sends nothing. The + * editor is handed these values too, so its cursor + * and readout are what the board was told. + * to the board {type:'pf-audio', outputs:[v|null x4]} to the page + * that holds this frame, each tick: v is 0..1 as the + * extension's a= message carries it, null is a lane + * left alone (its '-'). + * one mapping per tab The mapping is kept in sessionStorage, so the + * miniature in the card and the window lifted from + * it are one editor: an edit in either is the other's + * at once (the storage event), and survives a reload. + * Esc {type:'pf-audio', action:'escape'} when Esc is + * pressed and the editor's Preview is not what it + * closes. + * its scroll its own A wheel or a finger that runs past the editor's end + * stops there. Without this the page that holds the + * frame scrolls on, and the guide's step changes + * under the window. + * + * The source stays the adapter's: its spectrum, its levels per band, its + * "demo source" chip. Nothing leaves this folder. + */ +(function () { + 'use strict'; + + var source = window.PFAdapter; + if (!source || typeof PFMap === 'undefined') return; + + var KEY = 'pf-audio-editor:mapping'; + var TICK_MS = 33; // offscreen.js TICK_MS + var REPORT_MS = 120; // offscreen.js REPORT_MS + var CAPS = source.caps(); + + var host = window.parent !== window ? window.parent : null; + function tell(message) { + message.type = 'pf-audio'; + if (host) host.postMessage(message, window.location.origin); + } + + // ── the mapping ───────────────────────────────────────────────────────── + + function stored() { + try { + var text = window.sessionStorage.getItem(KEY); + var raw = text ? JSON.parse(text) : null; + return raw && Array.isArray(raw.bands) ? raw : null; + } catch (e) { + return null; + } + } + function keep(config) { + try { window.sessionStorage.setItem(KEY, JSON.stringify(config)); } catch (e) { /* private mode */ } + } + // A copy made whole, as the capture takes one out of a 'config' message. + function whole(raw) { + return PFMap.normalizeConfig(raw ? JSON.parse(JSON.stringify(raw)) : null, CAPS); + } + + var config = whole(stored()); // what is being mapped with + var configFn = null; + + // The mapping changed somewhere else (the other frame of this tab). + function adopt(raw) { + if (!raw) return; + config = whole(raw); + source.saveConfig(whole(raw)); // the demo source listens where the boxes are + if (configFn) configFn(raw); + } + window.addEventListener('storage', function (e) { + if (e.key === KEY && e.storageArea === window.sessionStorage) adopt(stored()); + }); + + // ── the chain (offscreen.js tick()) ───────────────────────────────────── + + var levels = [0, 0, 0, 0]; + var envelopes = [0, 1, 2, 3].map(PFMap.newEnvelope); + var peakLevels = [-1, -1, -1, -1]; + var peakPos = [0, 0, 0, 0]; + var peakOutputs = [0, 0, 0, 0]; + var heard = null; // the demo source's last frame + var frameFn = null; + var lastReport = 0; + + // editor-adapter.js learned(): an envelope at its minimum span is not one. + function learned(e) { + return e.hi - e.lo > PFMap.ENV_MIN_SPAN + 1e-6 ? { lo: e.lo, hi: e.hi } : null; + } + + function tick() { + if (!heard || !heard.levels || heard.levels.length < 4) return; + var bands = config.bands; + var auto = config.autoRange; + var i; + + for (i = 0; i < 4; i++) { + var raw = PFMap.clamp01(heard.levels[i]); + var a = raw > levels[i] ? config.attack : config.smoothing; + levels[i] = a * raw + (1 - a) * levels[i]; + if (auto) PFMap.trackEnvelope(envelopes[i], levels[i]); + } + + var lanes = [null, null, null, null]; + for (i = 0; i < 4; i++) { + var pos = PFMap.position(levels[i], bands[i], auto ? envelopes[i] : null); + var output = PFMap.output(bands[i], pos); + if (!bands[i].muted) lanes[bands[i].knob] = output; + if (levels[i] > peakLevels[i]) { + peakLevels[i] = levels[i]; + peakPos[i] = pos; + peakOutputs[i] = output; + } + } + tell({ outputs: lanes }); + + var now = performance.now(); + if (frameFn && now - lastReport > REPORT_MS) { + lastReport = now; + frameFn({ + running: true, connected: false, demo: true, + levels: peakLevels.slice(), + pos: peakPos.slice(), + outputs: peakOutputs.slice(), + spectrum: heard.spectrum, + env: auto ? envelopes.map(learned) : [], + autoRange: auto + }); + peakLevels = [-1, -1, -1, -1]; + } + } + + // ── the adapter the editor talks to ───────────────────────────────────── + + window.PFAdapter = { + caps: function () { return CAPS; }, + // What this tab has stored, or nothing (a first run), as the extension's + // own adapter answers from chrome.storage. + loadConfig: function () { + var raw = stored(); + if (raw) source.saveConfig(whole(raw)); + return Promise.resolve(raw); + }, + saveConfig: function (next) { + keep(next); + config = whole(next); + return source.saveConfig(next); + }, + resetBand: function (index, current) { + return source.resetBand(index, current).then(function (next) { + keep(next); + config = whole(next); + return next; + }); + }, + onConfig: function (fn) { configFn = fn; }, + onFrame: function (fn) { + frameFn = fn; + source.onFrame(function (state) { heard = state; }); + tick(); + setInterval(tick, TICK_MS); + } + }; + + document.addEventListener('keydown', function (e) { + if (e.key !== 'Escape') return; + var preview = document.getElementById('pv'); + if (preview && !preview.hidden) return; // the editor closes its Preview + tell({ action: 'escape' }); + }); + + // This page scrolls at its root; what is left of a scroll at its end is + // not handed on to the page that holds the frame. + document.documentElement.style.overscrollBehavior = 'contain'; +})(); +''' + + +if __name__ == "__main__": + main() diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 9a5dd851a..05e000012 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -17,6 +17,10 @@ const eslintConfig = defineConfig([ // fflate vendored beside them. `console_demo.py --check` keeps it in step // with the firmware; lint rules for hand-written code don't apply. "public/guide/console-demo/**", + // The Audio guide's editor is generated the same way: the audio + // extension's own editor files plus a bridge to the page, by + // firmware/toolchain/editor_demo.py. + "public/guide/audio-editor/**", ]), // Layering: lib/ is the bottom. It is imported by app/ and components/ and // must not import from them — a type it needs from a component belongs in diff --git a/web/public/guide/audio-editor/bridge.js b/web/public/guide/audio-editor/bridge.js new file mode 100644 index 000000000..974a6750b --- /dev/null +++ b/web/public/guide/audio-editor/bridge.js @@ -0,0 +1,187 @@ +/* Patternflow Audio guide: between the editor and its demo source. + * GENERATED by firmware/toolchain/editor_demo.py -- edit BRIDGE_JS there, rerun. + * + * Loaded after editor-adapter.js and before editor.js. Outside the extension + * the adapter is its demo: a synthesized spectrum, the mapping in memory, and + * each band's output straight off its level. That is a preview of the editor. + * The guide shows the editor driving a board, so five things are added here, + * around window.PFAdapter and without a change to any of the extension's + * files: + * + * the capture's chain The knob values are worked out as the capture works + * them out (offscreen.js tick()): every 33 ms, the + * level glided by Attack and Damping, auto range's + * envelope at its real rate, PFMap.position and + * PFMap.output, and a muted band sends nothing. The + * editor is handed these values too, so its cursor + * and readout are what the board was told. + * to the board {type:'pf-audio', outputs:[v|null x4]} to the page + * that holds this frame, each tick: v is 0..1 as the + * extension's a= message carries it, null is a lane + * left alone (its '-'). + * one mapping per tab The mapping is kept in sessionStorage, so the + * miniature in the card and the window lifted from + * it are one editor: an edit in either is the other's + * at once (the storage event), and survives a reload. + * Esc {type:'pf-audio', action:'escape'} when Esc is + * pressed and the editor's Preview is not what it + * closes. + * its scroll its own A wheel or a finger that runs past the editor's end + * stops there. Without this the page that holds the + * frame scrolls on, and the guide's step changes + * under the window. + * + * The source stays the adapter's: its spectrum, its levels per band, its + * "demo source" chip. Nothing leaves this folder. + */ +(function () { + 'use strict'; + + var source = window.PFAdapter; + if (!source || typeof PFMap === 'undefined') return; + + var KEY = 'pf-audio-editor:mapping'; + var TICK_MS = 33; // offscreen.js TICK_MS + var REPORT_MS = 120; // offscreen.js REPORT_MS + var CAPS = source.caps(); + + var host = window.parent !== window ? window.parent : null; + function tell(message) { + message.type = 'pf-audio'; + if (host) host.postMessage(message, window.location.origin); + } + + // ── the mapping ───────────────────────────────────────────────────────── + + function stored() { + try { + var text = window.sessionStorage.getItem(KEY); + var raw = text ? JSON.parse(text) : null; + return raw && Array.isArray(raw.bands) ? raw : null; + } catch (e) { + return null; + } + } + function keep(config) { + try { window.sessionStorage.setItem(KEY, JSON.stringify(config)); } catch (e) { /* private mode */ } + } + // A copy made whole, as the capture takes one out of a 'config' message. + function whole(raw) { + return PFMap.normalizeConfig(raw ? JSON.parse(JSON.stringify(raw)) : null, CAPS); + } + + var config = whole(stored()); // what is being mapped with + var configFn = null; + + // The mapping changed somewhere else (the other frame of this tab). + function adopt(raw) { + if (!raw) return; + config = whole(raw); + source.saveConfig(whole(raw)); // the demo source listens where the boxes are + if (configFn) configFn(raw); + } + window.addEventListener('storage', function (e) { + if (e.key === KEY && e.storageArea === window.sessionStorage) adopt(stored()); + }); + + // ── the chain (offscreen.js tick()) ───────────────────────────────────── + + var levels = [0, 0, 0, 0]; + var envelopes = [0, 1, 2, 3].map(PFMap.newEnvelope); + var peakLevels = [-1, -1, -1, -1]; + var peakPos = [0, 0, 0, 0]; + var peakOutputs = [0, 0, 0, 0]; + var heard = null; // the demo source's last frame + var frameFn = null; + var lastReport = 0; + + // editor-adapter.js learned(): an envelope at its minimum span is not one. + function learned(e) { + return e.hi - e.lo > PFMap.ENV_MIN_SPAN + 1e-6 ? { lo: e.lo, hi: e.hi } : null; + } + + function tick() { + if (!heard || !heard.levels || heard.levels.length < 4) return; + var bands = config.bands; + var auto = config.autoRange; + var i; + + for (i = 0; i < 4; i++) { + var raw = PFMap.clamp01(heard.levels[i]); + var a = raw > levels[i] ? config.attack : config.smoothing; + levels[i] = a * raw + (1 - a) * levels[i]; + if (auto) PFMap.trackEnvelope(envelopes[i], levels[i]); + } + + var lanes = [null, null, null, null]; + for (i = 0; i < 4; i++) { + var pos = PFMap.position(levels[i], bands[i], auto ? envelopes[i] : null); + var output = PFMap.output(bands[i], pos); + if (!bands[i].muted) lanes[bands[i].knob] = output; + if (levels[i] > peakLevels[i]) { + peakLevels[i] = levels[i]; + peakPos[i] = pos; + peakOutputs[i] = output; + } + } + tell({ outputs: lanes }); + + var now = performance.now(); + if (frameFn && now - lastReport > REPORT_MS) { + lastReport = now; + frameFn({ + running: true, connected: false, demo: true, + levels: peakLevels.slice(), + pos: peakPos.slice(), + outputs: peakOutputs.slice(), + spectrum: heard.spectrum, + env: auto ? envelopes.map(learned) : [], + autoRange: auto + }); + peakLevels = [-1, -1, -1, -1]; + } + } + + // ── the adapter the editor talks to ───────────────────────────────────── + + window.PFAdapter = { + caps: function () { return CAPS; }, + // What this tab has stored, or nothing (a first run), as the extension's + // own adapter answers from chrome.storage. + loadConfig: function () { + var raw = stored(); + if (raw) source.saveConfig(whole(raw)); + return Promise.resolve(raw); + }, + saveConfig: function (next) { + keep(next); + config = whole(next); + return source.saveConfig(next); + }, + resetBand: function (index, current) { + return source.resetBand(index, current).then(function (next) { + keep(next); + config = whole(next); + return next; + }); + }, + onConfig: function (fn) { configFn = fn; }, + onFrame: function (fn) { + frameFn = fn; + source.onFrame(function (state) { heard = state; }); + tick(); + setInterval(tick, TICK_MS); + } + }; + + document.addEventListener('keydown', function (e) { + if (e.key !== 'Escape') return; + var preview = document.getElementById('pv'); + if (preview && !preview.hidden) return; // the editor closes its Preview + tell({ action: 'escape' }); + }); + + // This page scrolls at its root; what is left of a scroll at its end is + // not handed on to the page that holds the frame. + document.documentElement.style.overscrollBehavior = 'contain'; +})(); diff --git a/web/public/guide/audio-editor/editor-adapter.js b/web/public/guide/audio-editor/editor-adapter.js new file mode 100644 index 000000000..32cce65f2 --- /dev/null +++ b/web/public/guide/audio-editor/editor-adapter.js @@ -0,0 +1,203 @@ +// Patternflow Audio — editor adapter, extension flavor. +// +// The editor module (editor.js) knows nothing about Chrome, the panel, or +// where audio comes from: it talks to exactly window.PFAdapter. The panel's +// /audio-in page is the same editor over another adapter +// (firmware/patternflow/console/_audio_in_adapter.js, which speaks +// fetch('/api/audio-in')). That boundary is the whole architecture; do not +// let editor.js grow a chrome.* call. The contract both adapters keep is +// written at the top of editor.js. +// +// This file is also the extension's SOURCE BAR: the
in editor.html +// (what is feeding the editor, where it is going, Stop). The editor never +// looks in there; every state that arrives is drawn here first and then +// handed on. +// +// Without chrome.* (opened as a plain file, or from a static server) it runs +// a demo: a synthesized music-ish spectrum mapped by the same model, the +// mapping kept in memory. That is a preview of the editor, not of the +// product: nothing persists and nothing is captured. + +(function () { + const hasChrome = typeof chrome !== 'undefined' && chrome.storage && chrome.runtime; + const $ = (id) => document.getElementById(id); + + // What this source is: tab audio through an AnalyserNode. The numbers are + // the model's (PFMap.SOURCE), the two edge rules are its minimum window, and + // a band is never narrower than the 10 Hz the model keeps on load. + const CAPS = { + hzMin: PFMap.SOURCE.hzMin, + hzMax: PFMap.SOURCE.hzMax, + db: PFMap.SOURCE.db, + levelMax: 1, + hzGap: 10, + top: (lo) => lo + PFMap.MIN_WINDOW, + bottom: (hi) => hi - PFMap.MIN_WINDOW + }; + + // ── the source bar ──────────────────────────────────────────────────── + // + // The chip is the capture's own phase (offscreen.js phase()), in the same + // word the popup prints for it, so the two cannot disagree about whether + // the panel is being driven: a socket that is open to a panel whose + // Audio-React switch is off is not "live". Beside it, one sentence when + // there is something to do or something wrong: + // error the capture's own sentence, and both it and the chip in red + // aud off what is off and where its switch is (Turn on is the popup's) + // idle why the last capture ended, if it ended by itself (the tab + // was closed), then how to start one + // otherwise nothing: a capture or the popup's test connection is up, + // and "start a capture" is not what to say beside it + const WORDS = { connecting: 'connecting', off: 'aud off', live: 'live', test: 'test', error: 'error' }; + + function drawBar(state, idleHint) { + const chip = $('sourceChip'); + const hint = $('captureHint'); + const bad = state.phase === 'error'; + chip.textContent = state.demo ? 'demo source' : WORDS[state.phase] || 'idle'; + chip.className = state.phase === 'live' ? 'chip okChip' : bad ? 'chip badChip' : 'chip'; + // Where the capture is being sent, as the socket has it. Nothing is + // connected: no chip, rather than an address nothing is using. + $('hostChip').textContent = state.host || ''; + $('hostChip').hidden = !state.host; + hint.textContent = state.demo ? '' + : state.phase === 'off' ? 'Connected, but Audio-React is off on the panel. Turn it on in the extension popup.' + : state.error || (state.running || state.manual ? '' : (state.note ? state.note + ' ' : '') + idleHint); + hint.classList.toggle('badText', bad); + $('stopBtn').disabled = !state.running || !!state.demo; + } + + // An envelope that is at its minimum span has learned nothing: the band has + // been silent, or has sat at one level, for as long as an envelope + // remembers. The editor is told there is none (see `env` in its contract), + // so it draws the band's own window, and a drag that takes the four windows + // over by hand leaves that one as it was. Seeded from such an envelope it + // became a 4 dB sliver at the floor, and the first sound pegged that knob at + // its top. (A band that has heard a single click does have an envelope, and + // is seeded from it: this guards silence, not thinness.) + const learned = (env) => (env || []).map((e) => + e && e.hi - e.lo > PFMap.ENV_MIN_SPAN + 1e-6 ? e : null); + + if (hasChrome) { + const HINT = 'Start capture from the extension popup, on the tab that is playing.'; + let frameFn = null; + + const take = (state) => { + state = state || {}; + drawBar(state, HINT); + if (frameFn) frameFn({ ...state, env: learned(state.env) }); + }; + const ask = () => chrome.runtime.sendMessage({ type: 'status' }, (response) => { + void chrome.runtime.lastError; + if (response && response.state) take(response.state); + }); + + chrome.runtime.onMessage.addListener((message) => { + if (message.type === 'state') take(message.state); + }); + $('stopBtn').addEventListener('click', () => { + chrome.runtime.sendMessage({ type: 'stop' }, () => void chrome.runtime.lastError); + }); + + window.PFAdapter = { + caps() { + return CAPS; + }, + loadConfig() { + return new Promise((resolve) => + chrome.storage.local.get(PFMap.CONFIG_KEY, (stored) => + resolve(stored[PFMap.CONFIG_KEY] || null))); + }, + // Stored, then told to the capture: that message is how a running + // capture gets the edit (nothing listens to storage). + saveConfig(config) { + return new Promise((resolve) => + chrome.storage.local.set({ [PFMap.CONFIG_KEY]: config }, () => { + chrome.runtime.sendMessage({ type: 'config', config }, () => { + void chrome.runtime.lastError; // background may be asleep; storage holds + resolve(); + }); + })); + }, + resetBand(index, config) { + config.bands[index] = PFMap.defaultBand(index, CAPS); + return this.saveConfig(config).then(() => config); + }, + // The editor is up: one state now, without waiting for the next push. + onFrame(fn) { + frameFn = fn; + ask(); + } + }; + ask(); + return; + } + + // ── demo adapter ────────────────────────────────────────────────────── + const HINT = 'Demo mode — open this page from the extension for live audio.'; + let demoConfig = PFMap.normalizeConfig(null, CAPS); + let frameFn = null; + let t = 0; + + function demoSpectrum() { + t += 0.08; + const values = []; + for (let i = 0; i < 64; i++) { + const frac = i / 63; + const hz = 20 * Math.pow(1000, frac); + const lg = Math.log10(hz); + let v = 0.05; + const beat = 0.5 + 0.5 * Math.max(0, Math.sin(t * 2.2)); + v += (0.45 + 0.35 * beat) * Math.exp(-Math.pow(lg - Math.log10(70), 2) / 0.05); + v += 0.32 * (0.7 + 0.3 * Math.sin(t * 1.1 + 1)) * Math.exp(-Math.pow(lg - Math.log10(900), 2) / 0.16); + v += 0.2 * (0.6 + 0.4 * Math.sin(t * 3.1 + 2)) * Math.exp(-Math.pow(lg - Math.log10(3500), 2) / 0.06); + v += 0.16 * Math.max(0, Math.sin(t * 4.4)) * Math.exp(-Math.pow(lg - Math.log10(9500), 2) / 0.035); + if (hz > 12000) v *= Math.max(0.25, 1 - (hz - 12000) / 16000); + v += (Math.sin(i * 7.3 + t * 5) * 0.02); + values.push(Math.max(0.02, Math.min(0.95, v))); + } + return values; + } + + // The same envelope and the same level-to-knob chain the capture runs + // (offscreen.js), so the demo's cursor and readout are the model's too. + const envelopes = [0, 1, 2, 3].map(() => PFMap.newEnvelope()); + + function demoFrame() { + const spectrum = demoSpectrum(); + const auto = demoConfig.autoRange; + const levels = [], pos = [], outputs = []; + demoConfig.bands.forEach((band, i) => { + const lo = Math.log10(band.hzMin / 20) / 3 * 64; + const hi = Math.log10(band.hzMax / 20) / 3 * 64; + let sum = 0, n = 0; + for (let k = Math.max(0, Math.floor(lo)); k <= Math.min(63, Math.ceil(hi)); k++) { sum += spectrum[k]; n++; } + levels[i] = n ? sum / n : 0; + PFMap.trackEnvelope(envelopes[i], levels[i], 0.01); + pos[i] = PFMap.position(levels[i], band, auto ? envelopes[i] : null); + outputs[i] = PFMap.output(band, pos[i]); + }); + const state = { + running: true, connected: false, demo: true, + levels, pos, outputs, spectrum, + env: auto ? learned(envelopes.map((e) => ({ lo: e.lo, hi: e.hi }))) : [], + autoRange: auto + }; + drawBar(state, HINT); + if (frameFn) frameFn(state); + } + + window.PFAdapter = { + caps() { return CAPS; }, + // Nothing is stored: the editor starts as a first run does. + loadConfig() { return Promise.resolve(null); }, + saveConfig(config) { demoConfig = config; return Promise.resolve(); }, + resetBand(index, config) { + config.bands[index] = PFMap.defaultBand(index, CAPS); + demoConfig = config; + return Promise.resolve(config); + }, + onFrame(fn) { frameFn = fn; demoFrame(); setInterval(demoFrame, 120); } + }; + drawBar({ demo: true }, HINT); +})(); diff --git a/web/public/guide/audio-editor/editor.css b/web/public/guide/audio-editor/editor.css new file mode 100644 index 000000000..da75d6c44 --- /dev/null +++ b/web/public/guide/audio-editor/editor.css @@ -0,0 +1,333 @@ +/* Patternflow Audio — mapping editor. + + Two parts. Down to the line that says EXTENSION ONLY these are the editor's + own rules, the same on every surface: the panel's /audio-in page takes + exactly that part (firmware/toolchain/build_audio_in_page.py stops reading + at the line) and brings its own colours and its own bar. Below the line is + what only the extension's page has: the cream instrument palette and the + source bar. Every colour above the line is a variable, so that works. + + Three things the part above the line leaves to the surface, because a + console page has them already and would carry them twice: the page itself + (body: margin, background, type), --mono, and that the hidden attribute + hides ([hidden] { display: none !important }: three of the editor's parts + are flex boxes that come and go by that attribute, and a display of their + own would otherwise keep them on screen). + + The layout rules are here too, and not in either surface's own sheet: the + editor is used in a full tab, in half a screen beside the tab that is + playing, and on a phone, and it has to hold in all three wherever it is + shown. Three steps: everything in a row; at 1100 px the output range drops + under the curve; at 700 px each row of controls becomes a column. */ +* { box-sizing: border-box; } + +.page { + max-width: 1280px; + margin: 0 auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.mono { font-family: var(--mono); } +.dimText { color: var(--muted); } +.hint { font-size: 12px; color: var(--muted); } +.hint:empty { display: none; } +.spacer { flex-grow: 1; } + +.ghostBtn { + font: inherit; font-size: 11px; cursor: pointer; + border: 1px solid var(--rule); border-radius: 2px; + background: var(--field); color: var(--muted); padding: 6px 12px; +} +.ghostBtn[aria-pressed=true] { border-color: var(--led); color: var(--ink); } +.ghostBtn.dan { color: var(--bad); } + +/* ── toolbar / toggles ── */ +/* The hint belongs to the switch and takes what the row has left; the two + glide sliders stay together and drop to a line of their own before the + hint is squeezed. */ +.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; } +#autoHint { flex: 1 1 22em; } +.glide { display: flex; flex-wrap: wrap; gap: 10px 18px; } +.damping { display: flex; align-items: center; gap: 8px; } +.damping input { width: 130px; accent-color: var(--led); } +.damping .mono { font-size: 11px; min-width: 56px; } +.toggleWrap { + font: inherit; color: inherit; cursor: pointer; display: flex; align-items: center; gap: 7px; + border: 1px solid var(--ink); border-radius: 2px; background: var(--field); padding: 5px 11px; +} +.toggleLabel { font-size: 11px; font-weight: 500; } +.toggle { + width: 22px; height: 12px; background: var(--rule); border-radius: 7px; + position: relative; flex: none; pointer-events: none; +} +.dotK { + position: absolute; left: 1px; top: 1px; width: 10px; height: 10px; + background: var(--field); border-radius: 50%; transition: left 0.12s; +} +.toggleWrap.on .toggle { background: var(--ok); } +.toggleWrap.on .dotK { left: 11px; } +/* Mute is the same switch as Auto range, the whole button its target; in its + column it is as wide as its words, not as the column. */ +#muteToggle { align-self: flex-start; } + +/* ── main plot ── */ +/* Clipped: the canvas, and the Preview layer that lies exactly over it, are + square, and a surface may round this box's corners; and a tag has no place + of its own until editor.js gives it one (until then it is below the canvas, + out of the box). Isolated: what is stacked in here (the selected tag over + its neighbours, Preview over them all) is stacked against nothing outside + it. */ +.plotWrap { + position: relative; isolation: isolate; background: var(--field); + border: 1px solid var(--rule); border-radius: 2px; overflow: hidden; +} +/* No touch-action here or on #curve, on purpose. A finger that lands on a box + or a handle drags it and one that misses scrolls the page, and that is + decided per touch, by editor.js, in a touchstart listener. touch-action is + per element: `none` trapped every swipe that began on the plot, and `pan-y` + would hand the browser the vertical drag of a box's top or bottom edge. + + EVERY CANVAS HAS ITS BOX HERE, not from editor.js: the plot, the curve + (#curve) and the three kinds of glyph. A canvas is 300 x 150 until somebody + says otherwise, and editor.js says nothing until the mapping has been read, + which on the panel's own hotspot is seconds after the first paint: sized + there, the page came up with a 150 px plot and 131 px tabs and then jumped. + The plot is as wide as the page and 45% of the window tall, between 240 and + 400 px, so the plot, the knobs and the top of the card share one screen (a + phone gets 260 at the most: see the 700 px step). editor.js only makes the + pixels for the box it finds. */ +#plot { display: block; cursor: default; width: 100%; height: clamp(240px, 45vh, 400px); } +.tag canvas, .chipCard canvas { width: 18px; height: 12px; } +.tag { + position: absolute; display: flex; align-items: center; gap: 6px; + background: var(--cream-2); border: 1px solid var(--rule); border-radius: 2px; + padding: 2px 7px; pointer-events: none; white-space: nowrap; +} +.tag.sel { border-color: var(--led); z-index: 1; } +/* A muted knob's tag says so, in the room its curve glyph took. */ +.tag.mutedTag { opacity: 0.55; } +.mutedTag canvas { display: none; } +.tagText { font-family: var(--mono); font-size: 10px; } + +/* ── preview: the scope, in the plot's place while it runs ── */ +.pv { + position: absolute; inset: 0; z-index: 2; display: flex; flex-direction: column; + background: var(--field); +} +.pvHead { + display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; + gap: 8px 14px; padding: 10px 12px; +} +.pvChips { display: flex; flex-wrap: wrap; gap: 8px; } +.pvSig { + font: inherit; font-size: 11px; cursor: pointer; + border: 1px solid var(--rule); border-radius: 2px; + background: var(--field); color: var(--ink); padding: 5px 11px; +} +.pvSig.on { border-color: var(--led); font-weight: 600; } +/* Sized by the column it is in, never by a style of its own: editor.js reads + the box it was given and draws to that. */ +#pvScope { display: block; flex: 1 1 0; min-height: 0; width: 100%; } +.pvLegend { display: flex; gap: 7px; align-items: center; } +.pvKeyIn, .pvKeyOut { width: 16px; height: 3px; display: inline-block; } +.pvKeyIn { background: var(--faint); } +.pvKeyOut { background: var(--led); } + +/* ── the knobs, as tabs under the plot ── */ +.knobRow { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.chips { display: flex; gap: 8px; flex: 1 1 auto; } +.chipCard { + font: inherit; color: inherit; cursor: pointer; + display: flex; align-items: center; gap: 8px; + background: var(--field); border: 1px solid var(--rule); border-radius: 2px; + padding: 7px 12px; +} +.chipCard.sel { border-color: var(--led); } +.chipCard.sel .chipName { font-weight: 600; } +.chipCard.mutedChip { opacity: 0.55; } +.chipDot { width: 6px; height: 6px; background: var(--led); border-radius: 1px; } +.chipCard .mono { font-size: 11px; } + +/* ── the selected knob's card ── */ +.panel { + display: flex; flex-wrap: wrap; gap: 14px 20px; background: var(--field); + border: 1px solid var(--rule); border-radius: 2px; padding: 16px 18px; +} +.bandHead { flex: 0 0 100%; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; } +.bandName { font-size: 13px; font-weight: 600; white-space: nowrap; } +.col { display: flex; flex-direction: column; gap: 8px; min-width: 0; } +.col.grow { flex: 1 1 0; gap: 12px; } +.curveCol { flex: none; } +.colKicker { font-family: var(--mono); font-size: 10px; letter-spacing: 0.09em; color: var(--muted); } +.vr { width: 1px; background: var(--rule); } +/* The curve, and its shapes beside it while there is room for both. The + curve is 356 px wide where it has that, its row's width where it has not. */ +.curveRow { display: flex; flex-wrap: wrap; gap: 12px 20px; } +#curve { display: block; flex: none; width: 356px; max-width: 100%; height: 218px; } + +.presetsCol { width: 268px; flex: none; display: flex; flex-direction: column; gap: 8px; } +.presetGrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } +.preset { + font: inherit; cursor: pointer; + display: flex; flex-direction: column; align-items: center; gap: 5px; + border: 1px solid var(--rule); border-radius: 2px; background: var(--cream); + padding: 9px 4px 7px; +} +.preset canvas { width: 40px; height: 24px; } +.preset span { font-size: 10px; font-weight: 500; color: var(--ink); } +.preset.on { border-color: var(--led); } +.preset.on span { font-weight: 600; } +.stepsRow { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--muted); } +/* Minus and plus, at 32 px: the size of a small button on the panel's + console, and a finger's. (The extension keeps its instrument's smaller + ones: below the line.) */ +.stepsRow button { + font: inherit; cursor: pointer; width: 32px; height: 32px; padding: 0; line-height: 1; + border: 1px solid var(--rule); border-radius: 2px; background: var(--cream); color: var(--ink); +} + +.field { display: flex; flex-direction: column; gap: 6px; } +.fieldRow { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.fieldLabel { font-size: 11px; color: var(--muted); } + +/* A sideways drag is the track's, an up-and-down one is the page's. */ +.outTrack { position: relative; height: 18px; cursor: pointer; touch-action: pan-y; } +.outRail { position: absolute; left: 5px; right: 5px; top: 50%; height: 2px; margin-top: -1px; background: var(--rule); } +.outFill { position: absolute; top: 50%; height: 3px; margin-top: -1.5px; background: var(--led); } +.outHandle { + position: absolute; top: 50%; width: 9px; height: 10px; margin: -5px 0 0 -4.5px; + background: var(--field); border: 1px solid var(--ink); +} +.outPresets { display: flex; flex-wrap: wrap; gap: 6px; } +.outPreset { + font: inherit; font-size: 10px; cursor: pointer; + border: 1px solid var(--rule); border-radius: 2px; + background: var(--cream); color: var(--muted); padding: 3px 8px; +} + +/* ── the resets, at the foot ── */ +.acts { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } + +/* The editor body has no box of its own: its parts are laid out by .page. + Until editor.js has the mapping it is inert, and looks it. + + And until the FIRST mapping is in, the page says nothing it does not know: + no switch position, no slider thumb, no output handles, and the toolbar's + hint keeps its lines without showing its words. Everything keeps its box, + so nothing moves when the mapping arrives. That state is `inert="inert"`, + as editor.html writes it; a source that makes the editor inert again later + (the panel's adapter, around a reset) sets the property, which leaves the + attribute empty, so what is on screen then stays on screen. */ +#ed { display: contents; } +#ed[inert] > * { opacity: 0.5; } +#ed[inert=inert] :is(.toggle, input, .outHandle, .hint) { visibility: hidden; } + +/* ── narrower than a full tab ── */ +/* Half a screen: the output range goes under the curve, across the card. The + shapes stay beside the curve for as long as both fit, then wrap under it. */ +@media (max-width: 1100px) { + .vr { display: none; } + .curveCol, .col.grow { flex: 1 1 100%; } + .presetsCol { flex: 1 1 200px; width: auto; } +} +/* A phone: a 16 px gutter all round (never edge to edge: the gutter is where + a thumb scrolls from), one control to a row, the knobs four across. */ +@media (max-width: 700px) { + .page { padding: 16px; gap: 12px; } + #plot { max-height: 260px; } + .panel { padding: 14px 12px; } + .glide, .damping { flex: 1 1 100%; } + .damping .fieldLabel { min-width: 56px; } + .damping input { flex: 1; width: auto; min-width: 0; } + .chips { flex-basis: 100%; gap: 6px; } + .chipCard { flex: 1 1 0; min-width: 0; flex-direction: column; gap: 0; padding: 6px 2px; } + .chipDot, .chipCard canvas { display: none; } + #openPreview { margin-left: auto; } +} +/* Touch: what a finger drags is drawn larger, and what it presses gets a hit + area that grows invisibly while the control keeps its size. */ +@media (pointer: coarse) { + .outTrack { height: 36px; } + .outHandle { width: 16px; height: 22px; margin: -11px 0 0 -8px; } + .damping input { min-height: 32px; } + :is(.toggleWrap, .ghostBtn, .pvSig, .chipCard, .outPreset, .stepsRow button) { position: relative; } + :is(.toggleWrap, .ghostBtn, .pvSig, .chipCard, .outPreset, .stepsRow button)::after { + content: ""; + position: absolute; inset: -8px -2px; + } +} + +/* ── EXTENSION ONLY ── */ + +/* The page itself and its type: a surface's own. The panel's /audio-in is a + console page and brings the console's (console/_audio_in.css), and with it + the rule that hidden means hidden. */ +[hidden] { display: none !important; } +/* Keyboard focus, as the popup has it: the browser's own ring is near black, + and on the filled Stop that is black on black. (The console's base block + has the same rule for the panel's page.) */ +:focus-visible { outline: 2px solid var(--led); outline-offset: 2px; } +body { + margin: 0; + background: var(--cream); + color: var(--ink); + font: 12px/1.45 Inter, ui-sans-serif, system-ui, sans-serif; +} + +/* The cream instrument palette, the same as the popup's (popup.css). */ +:root { + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --cream: #f4efe6; + --cream-2: #ede7db; + --ink: #141414; + --muted: #6b655a; + --faint: #a69f90; + --rule: #d9d1c0; + --led: #e8552e; + --ok: #267a43; + --bad: #b43b2d; + --field: #fbf7ef; +} + +/* The instrument's small capitals, on the two headings inside the card, and + its small steppers. */ +.colKicker { text-transform: uppercase; } +.stepsRow button { width: 18px; height: 18px; } + +/* A button under the mouse goes orange here. (On the panel orange is for what + is selected or on, never a hover, so this is not the shared part's to say.) + :where keeps it weaker than a button's own state: a pressed Preview stays + ink, as it did when this rule stood above the pressed one. */ +:where(.ghostBtn, .outPreset):hover { border-color: var(--led); color: var(--led); } + +/* ── the source bar ── */ +header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; } +.brand { display: flex; align-items: center; gap: 9px; } +.led { width: 8px; height: 8px; background: var(--led); border-radius: 1px; } +.title { font-size: 14px; font-weight: 600; white-space: nowrap; } +.kicker { font-family: var(--mono); font-size: 10px; letter-spacing: 0.09em; color: var(--muted); } +.cluster { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } + +.chip { + border: 1px solid var(--rule); border-radius: 2px; background: var(--field); + padding: 5px 10px; font-family: var(--mono); font-size: 11px; color: var(--ink); + white-space: nowrap; +} +.chip.dim { color: var(--muted); } +.okChip { border-color: var(--ok); color: var(--ok); } +/* An error looks like one: the chip, and the sentence beside it. */ +.badChip { border-color: var(--bad); color: var(--bad); } +.badText { color: var(--bad); } + +/* Stop is the popup's Stop: the same filled button, in the instrument's small + capitals. */ +.primaryBtn { + font: inherit; font-family: var(--mono); font-size: 10px; letter-spacing: .1em; + text-transform: uppercase; cursor: pointer; min-height: 32px; + border: 1px solid var(--ink); border-radius: 0; + background: var(--ink); color: var(--cream); padding: 0 14px; +} +.primaryBtn:disabled { opacity: 0.35; cursor: default; } diff --git a/web/public/guide/audio-editor/editor.js b/web/public/guide/audio-editor/editor.js new file mode 100644 index 000000000..6cd2030c5 --- /dev/null +++ b/web/public/guide/audio-editor/editor.js @@ -0,0 +1,1177 @@ +// Patternflow Audio — the mapping editor. +// +// One module, no chrome.* and no fetch. It stands on two things: +// +// PFMap (mapping.js) the model: defaults, what a stored mapping is turned +// into, the curves, the level-to-knob chain. +// window.PFAdapter the SOURCE: the extension's is editor-adapter.js, the +// panel's is console/_audio_in_adapter.js. This file +// moves to the panel verbatim; only the adapter differs. +// +// The model, in one sentence: a band is a BOX drawn on the live spectrum +// (width = the frequencies it listens to, height = the level window it maps), +// and a RESPONSE CURVE that turns position-in-window into position-in-output- +// range. Curves bake to a 33-point table on save; the analysis side — and +// the firmware — only interpolates, and never learns what a bezier is. +// +// THE ADAPTER CONTRACT. The source owns its limits, its axis, its defaults +// and where a level sits in a window; the editor draws what it is told and +// asks rather than assumes. +// +// loadConfig() -> Promise +// { autoRange, smoothing, attack, bands[4] }, each band { hzMin, hzMax, +// inMin, inMax, gain, outMin, outMax, muted, curve, lut }. Levels are +// 0..1 along the source's own level axis. null means NOTHING IS STORED +// (a first run): the editor then starts from PFMap's defaults. A source +// that could not read must not answer null - it keeps the promise open +// until it can; the editor stays inert meanwhile. +// caps() -> asked once, after loadConfig() has settled +// hzMin, hzMax the ends of the frequency axis +// db [bottom, top]: the dB at 0 and at 1 of the level axis +// levelMax the highest a window's top edge is stored, 0..1 +// hzGap the narrowest band the source stores, in Hz +// top(lo) the lowest top edge it stores over a bottom at `lo` +// bottom(hi) the highest bottom edge it stores under a top at `hi` +// Only hzMin and hzMax are required; the rest default to an axis with no +// dB printed on it, the whole axis, and PFMap.MIN_WINDOW. +// saveConfig(config) -> Promise store it and hand it to what is mapping +// resetBand(i, config) -> Promise +// band i back to the SOURCE's default. Answers the whole mapping as it +// now stands (as loadConfig would); the editor takes that and does not +// save it back. +// resetAll(button) optional: the source's own "reset everything", where it +// has one. The editor then shows Reset mapping beside +// the knob's own Reset and calls this with the button +// pressed (for PFConfirm); what comes back goes in +// through onConfig. A source without it has no such +// button. +// onConfig(fn) optional: fn(config) replaces the editor's copy without +// saving it (after a reset the source did by itself) +// onFrame(fn) fn(frame), about ten times a second while there is +// something to show. Called once, when the editor is up. +// levels[4] each band's level, 0..1 on the level axis. Empty or +// missing = nothing is being heard: no level line, no +// cursor. +// spectrum[64] log-spaced over hzMin..hzMax, same axis +// env[4] { lo, hi } per band while auto range has a learned +// envelope; missing, or null for that band, otherwise +// (the stored window is drawn, dashed). "Learned" is +// the source's to say: what a band has while it has +// heard nothing is not an envelope and is not sent, for +// a drag that takes the windows over by hand seeds each +// window from what is sent here. +// pos[4] where each level sits in its window, 0..1, AS THE +// MAPPER COMPUTED IT: the curve's input +// outputs[4] what each knob is being sent +// db optional [bottom, top] when this frame's levels are on +// another axis than caps().db (the phone app's) +// pos and outputs are what make the cursor and the readout true. A frame +// without them (one the source did not map itself) gets the editor's own +// arithmetic: PFMap.position / PFMap.output on the frame's levels. + +'use strict'; + +const A = window.PFAdapter; +const { clamp01, PRESETS, evalCurve, bakeLut } = PFMap; + +// What the source says about itself. Read once the mapping is in (the panel +// learns its frequency range from the same reply), so nothing above init() +// may use it at load. +let CAPS = null; +let DECADES = 1; +let TICKS = []; + +function readCaps() { + const c = A.caps(); + CAPS = { + hzMin: c.hzMin, hzMax: c.hzMax, + db: c.db || null, + levelMax: c.levelMax || 1, + hzGap: c.hzGap || 0, + top: c.top || ((lo) => lo + PFMap.MIN_WINDOW), + bottom: c.bottom || ((hi) => hi - PFMap.MIN_WINDOW) + }; + DECADES = Math.log10(CAPS.hzMax / CAPS.hzMin); + // Inner gridlines only — the endpoints are drawn from CAPS, so the same + // axis code serves the extension's 20–20k and the microphone's 31–8k. + TICKS = [[50, '50'], [100, '100'], [200, '200'], [500, '500'], + [1000, '1k'], [2000, '2k'], [5000, '5k'], [10000, '10k']] + .filter(([hz]) => hz > CAPS.hzMin * 1.15 && hz < CAPS.hzMax * 0.87); +} + +const clampHz = (v) => Math.max(CAPS.hzMin, Math.min(CAPS.hzMax, Number(v) || CAPS.hzMin)); + +// What the editor DRAWS: the curve itself where there is one (a table would +// round a step's corner), the model's answer otherwise. +function bandCurveValue(band, u) { + return band.curve ? evalCurve(band.curve, u) : PFMap.curveValue(band, u); +} + +// ── state ─────────────────────────────────────────────────────────────── + +// The mapping being edited (null until init() has it), and the knob selected. +let cfg = null; +let sel = 0; +let frame = {}; +let saveTimer = 0; + +// Every edit saves itself, 150 ms after the last one of a burst. +function persist() { + clearTimeout(saveTimer); + saveTimer = setTimeout(flush, 150); +} + +// Save now what is waiting to be saved. Also on the way out of the page, so +// an edit made in the last 150 ms is not left behind. +function flush() { + if (!saveTimer) return null; + clearTimeout(saveTimer); + saveTimer = 0; + return A.saveConfig(cfg); +} +window.addEventListener('pagehide', flush); + +// The source's copy replaces the editor's: after a reset. Not saved back. +function applyConfig(raw) { + cfg = PFMap.normalizeConfig(raw, CAPS); + syncAutoToggle(); + syncDamping(); + selectBand(sel); +} + +function touchCurve(band, curve) { + band.curve = curve; + band.lut = bakeLut(curve); +} + +// ── main plot geometry ────────────────────────────────────────────────── + +const $ = (id) => document.getElementById(id); +const plotCanvas = $('plot'); +const pctx = plotCanvas.getContext('2d'); +const M = { l: 48, r: 6, t: 26, b: 40 }; +// The plot area inside those margins, css px. +let PW = 0, PH = 0; + +// A finger, not a mouse: what it has to land on is made larger, here and in +// the stylesheet. EDGE is how far from a box's edge still grabs it, GRIP the +// square drawn on a corner or a curve handle, REACH how far from a curve +// handle still grabs that. +const COARSE = matchMedia('(pointer: coarse)').matches; +const EDGE = COARSE ? 18 : 7; +const GRIP = COARSE ? 11 : 7; +const REACH = COARSE ? 22 : 12; + +// What the toolbar says beside the Auto range switch, in each of its two +// states (syncAutoToggle). The first, as a mouse has it, is also written in +// editor.html in the hint's own place: unseen until the mapping is in, and +// there so that its lines are already taken and the plot under it does not +// move when the words arrive. +const HINT_AUTO = 'Each box follows the loudness of what it hears. ' + + (COARSE ? 'Switch Auto range off to set a height by hand.' : 'Drag a top or bottom edge to take all four over by hand.'); +const HINT_HAND = 'Width: the frequencies a knob listens to. Height: the quiet-to-loud range mapped onto it.'; + +// The plot's box is the stylesheet's (#plot in editor.css: as wide as the +// page, 45% of the window tall between 240 and 400 px, 260 at the most at the +// phone width), so it has its size before this file has run and nothing below +// it moves when the mapping arrives. That is the one rule for its height +// (Preview's scope is the same box). Here the height is only snapped to a +// whole pixel, so the canvas is not resampled, and the pixels are made. +function sizePlot() { + plotCanvas.style.height = ''; + const cssW = plotCanvas.clientWidth, cssH = plotCanvas.clientHeight; + const dpr = window.devicePixelRatio || 1; + plotCanvas.width = Math.round(cssW * dpr); + plotCanvas.height = Math.round(cssH * dpr); + plotCanvas.style.height = cssH + 'px'; + pctx.setTransform(dpr, 0, 0, dpr, 0, 0); + PW = cssW - M.l - M.r; + PH = cssH - M.t - M.b; +} + +const xOf = (hz) => M.l + PW * (Math.log10(clampHz(hz) / CAPS.hzMin) / DECADES); +const hzOf = (x) => clampHz(CAPS.hzMin * Math.pow(10, DECADES * ((x - M.l) / PW))); +const yOf = (v) => M.t + (1 - clamp01(v)) * PH; +const vOf = (y) => clamp01(1 - (y - M.t) / PH); + +// The level axis is the source's own dB: [bottom, top], or null when it gave +// none. The frame may be on another axis than the source's usual one (see the +// contract). Every level the editor prints goes through these, so the axis, +// the knob's heading and the curve's labels are in one unit. +const axisDb = () => frame.db || CAPS.db; +const minus = (n) => String(Math.round(n)).replace('-', '−'); + +// A height on the level axis, 0..1, as dB: '−43'. '' on an axis without dB. +function dbText(v) { + const db = axisDb(); + return db ? minus(db[0] + v * (db[1] - db[0])) : ''; +} + +// A line every 10 dB: [[v, label]]. +function dbLines() { + const db = axisDb(); + const lines = []; + if (!db) return lines; + for (let d = Math.ceil(db[0] / 10) * 10; d <= db[1]; d += 10) { + lines.push([(d - db[0]) / (db[1] - db[0]), minus(d)]); + } + return lines; +} + +// Canvas colors come from the page's CSS variables, read fresh each paint so +// a theme toggle repaints correctly. The extension page is the cream +// instrument; the panel's console wraps the same module in its own tokens. +// Three of them are also painted see-through, which takes the colour apart: +// --led, --rule and --ink have to be written #rrggbb on every surface. +function theme() { + const s = getComputedStyle(document.body); + const v = (name) => s.getPropertyValue(name).trim(); + const alpha = (name) => { + const n = parseInt(v(name).slice(1), 16); + return (a) => `rgba(${n >> 16}, ${n >> 8 & 255}, ${n & 255}, ${a})`; + }; + return { + field: v('--field'), + cream: v('--cream'), + ink: v('--ink'), + muted: v('--muted'), + faint: v('--faint'), + rule: v('--rule'), + led: v('--led'), + ledA: alpha('--led'), + ruleA: alpha('--rule'), + inkA: alpha('--ink') + }; +} + +// The envelope auto range is mapping band `index` through right now, or null: +// auto range is off, the source has not learned one, or what it sent has no +// height (a panel that has never heard anything). +function liveEnv(index) { + const env = cfg.autoRange && frame.env && frame.env[index]; + return env && env.hi > env.lo ? { lo: clamp01(env.lo), hi: clamp01(env.hi) } : null; +} + +// The window a band is drawn with: the breathing envelope while auto range +// has one, the band's own hand-set window otherwise. Auto range without an +// envelope yet shows the stored window (dashed), never a made-up one. +function bandWindow(index) { + const band = cfg.bands[index]; + return liveEnv(index) || { lo: band.inMin, hi: band.inMax }; +} + +// Is anything being heard? Without levels there is no line and no cursor. +const hearing = () => !!(frame.levels && frame.levels.length); + +// Where band `index`'s level sits in its window, 0..1: the mapper's own +// figure when the frame carries it, the model's arithmetic when it does not. +function bandPos(index) { + if (frame.pos && frame.pos.length) return clamp01(frame.pos[index]); + return PFMap.position(frame.levels[index] || 0, cfg.bands[index], liveEnv(index)); +} + +// A window the source would store as it is: the top no higher than it keeps +// one, the two edges no closer than it keeps them. +function fitWindow(band) { + band.inMin = Math.max(0, Math.min(band.inMin, CAPS.bottom(CAPS.levelMax))); + band.inMax = Math.min(CAPS.levelMax, Math.max(band.inMax, CAPS.top(band.inMin))); +} + +function formatHz(hz) { + return hz >= 1000 ? (hz / 1000).toFixed(hz >= 9950 ? 0 : 1).replace(/\.0$/, '') + 'k' : String(Math.round(hz)); +} + +// ── main plot drawing ─────────────────────────────────────────────────── + +// Everything written on a canvas: 11 px, in --muted. (10 px in --faint, as it +// was, is 2.3:1 on the extension's cream; the axis and the one number that +// says what the knob gets were the least legible text on the page.) +const MONO = '11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'; + +function drawPlot() { + const T = theme(); + const W = M.l + PW + M.r, H = M.t + PH + M.b; + pctx.clearRect(0, 0, W, H); + pctx.fillStyle = T.field; + pctx.fillRect(0, 0, W, H); + + // grid + pctx.font = MONO; + pctx.strokeStyle = T.ruleA(0.55); + pctx.fillStyle = T.muted; + pctx.lineWidth = 1; + for (const [v, label] of dbLines()) { + const y = Math.round(yOf(v)) + 0.5; + if (v > 0 && v < 1) { + pctx.beginPath(); pctx.moveTo(M.l, y); pctx.lineTo(M.l + PW, y); pctx.stroke(); + } + pctx.textAlign = 'right'; + pctx.fillText(label, M.l - 8, y + 3); + } + // The two units, once each, in the margin the level labels are in: dB heads + // their column, Hz starts the row of frequencies. + pctx.textAlign = 'right'; + if (axisDb()) pctx.fillText('dB', M.l - 8, M.t - 9); + pctx.fillText('Hz', M.l - 8, M.t + PH + 16); + pctx.textAlign = 'center'; + for (const [hz, label] of TICKS) { + const x = Math.round(xOf(hz)) + 0.5; + pctx.beginPath(); pctx.moveTo(x, M.t); pctx.lineTo(x, M.t + PH); pctx.stroke(); + pctx.fillText(label, x, M.t + PH + 16); + } + pctx.textAlign = 'left'; + pctx.fillText(formatHz(CAPS.hzMin), M.l, M.t + PH + 16); + pctx.textAlign = 'right'; + pctx.fillText(formatHz(CAPS.hzMax), M.l + PW, M.t + PH + 16); + pctx.strokeStyle = T.rule; + pctx.beginPath(); pctx.moveTo(M.l, M.t + PH + 0.5); pctx.lineTo(M.l + PW, M.t + PH + 0.5); pctx.stroke(); + + // spectrum staircase + const spec = frame.spectrum || []; + if (spec.length > 1) { + pctx.fillStyle = T.inkA(0.14); + pctx.beginPath(); + pctx.moveTo(M.l, yOf(spec[0])); + const bw = PW / spec.length; + for (let i = 0; i < spec.length; i++) { + const y = yOf(spec[i]); + pctx.lineTo(M.l + i * bw, y); + pctx.lineTo(M.l + (i + 1) * bw, y); + } + pctx.lineTo(M.l + PW, M.t + PH); + pctx.lineTo(M.l, M.t + PH); + pctx.closePath(); + pctx.fill(); + } + + // boxes + cfg.bands.forEach((band, i) => { + const win = bandWindow(i); + const x0 = xOf(band.hzMin), x1 = xOf(band.hzMax); + const yTop = yOf(win.hi), yBot = yOf(win.lo); + const isSel = i === sel; + const stroke = isSel ? T.led : (band.muted ? T.faint : T.ink); + const alpha = band.muted ? 0.04 : (isSel ? 0.12 : 0.07); + + pctx.fillStyle = T.ledA(alpha); + pctx.fillRect(x0, yTop, x1 - x0, yBot - yTop); + + pctx.strokeStyle = stroke; + pctx.lineWidth = isSel ? 1.5 : 1; + pctx.beginPath(); pctx.moveTo(x0, yTop); pctx.lineTo(x0, yBot); pctx.stroke(); + pctx.beginPath(); pctx.moveTo(x1, yTop); pctx.lineTo(x1, yBot); pctx.stroke(); + if (cfg.autoRange) pctx.setLineDash([5, 4]); + pctx.beginPath(); pctx.moveTo(x0, yTop); pctx.lineTo(x1, yTop); pctx.stroke(); + pctx.beginPath(); pctx.moveTo(x0, yBot); pctx.lineTo(x1, yBot); pctx.stroke(); + pctx.setLineDash([]); + + // The live level, as a line across the box: how far through its window + // the band is, which is what the curve is fed and so what the knob gets. + // It is the mapper's position and not the level's height on the axis: the + // two differ wherever the mapper is not linear in this axis (the panel + // maps linear amplitude on a dB axis, and auto range squelches the floor), + // and a line drawn at the height says "fires" where the knob does not. + // Resting, it sits on the bottom edge; at full scale, on the top. + if (hearing()) { + const ly = Math.max(yTop + 1.5, Math.min(yBot - 1.5, yBot - bandPos(i) * (yBot - yTop))); + pctx.strokeStyle = T.led; + pctx.lineWidth = 2; + pctx.beginPath(); + pctx.moveTo(x0 + 1, ly); + pctx.lineTo(x1 - 1, ly); + pctx.stroke(); + } + + if (isSel) { + pctx.fillStyle = T.field; + pctx.strokeStyle = T.ink; + pctx.lineWidth = 1; + for (const [cx, cy] of [[x0, yTop], [x1, yTop], [x0, yBot], [x1, yBot]]) { + pctx.fillRect(cx - GRIP / 2, cy - GRIP / 2, GRIP, GRIP); + pctx.strokeRect(cx - GRIP / 2, cy - GRIP / 2, GRIP, GRIP); + } + } + }); + + placeTags(); +} + +// Band N drives knob N, and is named after it everywhere: K1..K4. A tag sits +// ON its box's top edge, outside the box: auto range can make a box thinner +// than the tag, which would otherwise cover it and its level line. It starts +// at the box's left edge, and stops at the plot's right one: on a phone the +// highest knob's box is narrower than its tag, which was cut off there. +function placeTags() { + const T = theme(); + cfg.bands.forEach((band, i) => { + const tag = $('tag' + i); + const win = bandWindow(i); + tag.classList.toggle('sel', i === sel); + tag.classList.toggle('mutedTag', band.muted); + tag.querySelector('.tagText').textContent = 'K' + (i + 1) + (band.muted ? ' muted' : ''); + drawGlyph(tag.querySelector('canvas'), band, 18, 12, T.ink); + tag.style.left = Math.round(Math.min(xOf(band.hzMin), M.l + PW + M.r - tag.offsetWidth - 1)) + 'px'; + tag.style.top = Math.round(yOf(win.hi) - 21) + 'px'; + }); +} + +// The tiny curve glyph on tags, tabs and shapes. Its box (w x h css px) is the +// stylesheet's, so a glyph that is not drawn yet already has its size; here +// only the pixels are made. +function drawGlyph(canvas, band, w, h, color) { + const dpr = window.devicePixelRatio || 1; + if (canvas.width !== w * dpr) { + canvas.width = w * dpr; canvas.height = h * dpr; + } + const g = canvas.getContext('2d'); + g.setTransform(dpr, 0, 0, dpr, 0, 0); + g.clearRect(0, 0, w, h); + g.strokeStyle = color; + g.lineWidth = 1.4; + g.lineCap = 'round'; + g.beginPath(); + for (let i = 0; i <= 16; i++) { + const u = i / 16; + const v = bandCurveValue(band, u); + const x = 2 + u * (w - 4); + const y = h - 2 - v * (h - 4); + if (i === 0) g.moveTo(x, y); else g.lineTo(x, y); + } + g.stroke(); +} + +// ── main plot interaction ─────────────────────────────────────────────── + +let drag = null; + +function hitTest(x, y) { + // Selected band's edges win first — reaching for a handle should not + // reselect the neighbour underneath it. + const order = [sel, ...cfg.bands.map((_, i) => i).filter((i) => i !== sel)]; + for (const i of order) { + const band = cfg.bands[i]; + const win = bandWindow(i); + const x0 = xOf(band.hzMin), x1 = xOf(band.hzMax); + const yTop = yOf(win.hi), yBot = yOf(win.lo); + const insideX = x > x0 - EDGE && x < x1 + EDGE; + const insideY = y > yTop - EDGE && y < yBot + EDGE; + if (!insideX || !insideY) continue; + // An edge is grabbed from EDGE px outside it, and from inside too, but + // never from further in than a third of the box: the middle third of + // even the smallest box stays "move", so a fat edge cannot swallow it. + const inX = Math.min(EDGE, (x1 - x0) / 3), inY = Math.min(EDGE, (yBot - yTop) / 3); + const nearL = x <= x0 + inX, nearR = x >= x1 - inX; + // Under a finger, in auto range, a top or bottom edge is not a handle: + // with a finger's EDGE those two zones are a quarter of the plot, and a + // thumb that only meant to scroll the page took all four knobs over (see + // takeOver). There the touch scrolls, or moves the box if it is inside it; + // a height is set by hand after the Auto range switch is put off. + const tall = !(COARSE && cfg.autoRange); + const nearT = tall && y <= yTop + inY, nearB = tall && y >= yBot - inY; + // Corners first — a grab there resizes both axes at once. + if (nearL && nearT) return { band: i, mode: 'tl' }; + if (nearR && nearT) return { band: i, mode: 'tr' }; + if (nearL && nearB) return { band: i, mode: 'bl' }; + if (nearR && nearB) return { band: i, mode: 'br' }; + if (nearL) return { band: i, mode: 'l' }; + if (nearR) return { band: i, mode: 'r' }; + if (nearT) return { band: i, mode: 't' }; + if (nearB) return { band: i, mode: 'b' }; + if (x > x0 && x < x1 && y > yTop && y < yBot) return { band: i, mode: 'move' }; + } + return null; +} + +// Dragging a breathing edge is the gesture that says "I'll take it from +// here": auto range switches off, and every band's window is seeded from the +// envelope it was just breathing at, so nothing jumps. A band with no live +// envelope keeps the window it had (and a source sends none for a band that +// has learned nothing: seeded from silence, a window is a sliver at the floor +// that any sound pegs at its top). This is the ONLY thing that writes an +// envelope into a window: the Auto range switch itself just goes back to the +// hand-set windows, so trying auto costs nothing. With a mouse only: see +// hitTest. +function takeOver() { + cfg.bands.forEach((band, i) => { + const env = liveEnv(i); + if (!env) return; + band.inMin = env.lo; + band.inMax = env.hi; + fitWindow(band); + }); + cfg.autoRange = false; + syncAutoToggle(); +} + +function plotPointer(event) { + const rect = plotCanvas.getBoundingClientRect(); + return { x: event.clientX - rect.left, y: event.clientY - rect.top }; +} + +// A FINGER ON A CANVAS. One that lands on something draggable is a drag, and +// the page must not scroll under it; one that misses is the browser's, so the +// page still scrolls from anywhere else on the plot. That cannot be said with +// touch-action, which is per element and not per touch (`none` made the plot +// a trap no swipe got out of; `pan-y` would give the browser the vertical +// drag of a top or bottom edge, and it takes it with a pointercancel that +// preventDefault on pointerdown does not stop). What does decide it per touch +// is a touchstart listener that is not passive: prevented, that touch never +// scrolls and its pointer events run to the end. `hits(x, y)` says whether +// the point, in the canvas's own px, is on something. +function dragOnHit(canvas, hits) { + canvas.addEventListener('touchstart', (event) => { + const rect = canvas.getBoundingClientRect(); + const touch = event.changedTouches[0]; + if (hits(touch.clientX - rect.left, touch.clientY - rect.top)) event.preventDefault(); + }, { passive: false }); +} +dragOnHit(plotCanvas, hitTest); + +plotCanvas.addEventListener('pointerdown', (event) => { + const { x, y } = plotPointer(event); + const hit = hitTest(x, y); + if (!hit) return; + if (hit.band !== sel) selectBand(hit.band); + // Armed, not moving: a press selects. Nothing is moved, resized or taken + // over until the pointer has travelled SLOP px, so a tap (or the jitter of + // one) changes nothing. + drag = { ...hit, startX: x, startY: y, live: false }; + plotCanvas.setPointerCapture(event.pointerId); + event.preventDefault(); +}); + +const SLOP = 4; + +plotCanvas.addEventListener('pointermove', (event) => { + const { x, y } = plotPointer(event); + if (!drag) { + const hit = hitTest(x, y); + plotCanvas.style.cursor = !hit ? 'default' + : hit.mode === 'move' ? 'move' + : (hit.mode === 'tl' || hit.mode === 'br') ? 'nwse-resize' + : (hit.mode === 'tr' || hit.mode === 'bl') ? 'nesw-resize' + : (hit.mode === 'l' || hit.mode === 'r') ? 'ew-resize' : 'ns-resize'; + return; + } + const band = cfg.bands[drag.band]; + if (!drag.live) { + if (Math.hypot(x - drag.startX, y - drag.startY) < SLOP) return; + // A top or bottom edge of a box in auto range (or a corner, which is one + // too): this drag takes over. ('move' has neither letter in it.) + if (cfg.autoRange && /[tb]/.test(drag.mode)) takeOver(); + const win = bandWindow(drag.band); + Object.assign(drag, { live: true, hzMin: band.hzMin, hzMax: band.hzMax, lo: win.lo, hi: win.hi }); + } + // The narrowest box, as a frequency ratio. + const MINR = 1.12; + + if (drag.mode === 'move') { + // Translate in log-frequency: the box keeps its RATIO width, which is + // what a log axis means by "same size somewhere else". + const shift = (x - drag.startX) / PW * DECADES; + let ratio = Math.pow(10, shift); + ratio = Math.max(CAPS.hzMin / drag.hzMin, Math.min(CAPS.hzMax / drag.hzMax, ratio)); + band.hzMin = drag.hzMin * ratio; + band.hzMax = Math.max(drag.hzMax * ratio, band.hzMin + CAPS.hzGap); + if (!cfg.autoRange) { + const dv = vOf(y) - vOf(drag.startY); + const span = drag.hi - drag.lo; + band.inMin = Math.max(0, Math.min(CAPS.levelMax - span, drag.lo + dv)); + band.inMax = band.inMin + span; + fitWindow(band); + } + } else { + // Edges and corners share the same four moves; a corner just does two. + // Each edge stops where the source would stop it, so what is drawn is + // what is stored and nothing moves on the next load. + if (drag.mode.includes('l')) band.hzMin = Math.max(CAPS.hzMin, Math.min(hzOf(x), band.hzMax / MINR, band.hzMax - CAPS.hzGap)); + if (drag.mode.includes('r')) band.hzMax = Math.min(CAPS.hzMax, Math.max(hzOf(x), band.hzMin * MINR, band.hzMin + CAPS.hzGap)); + if (drag.mode.includes('t')) band.inMax = Math.min(CAPS.levelMax, Math.max(vOf(y), CAPS.top(band.inMin))); + if (drag.mode.includes('b')) band.inMin = Math.max(0, Math.min(vOf(y), CAPS.bottom(band.inMax))); + } + drawPlot(); + drawCurve(); + renderChips(); + renderSettings(); +}); + +const releasePlot = () => { + if (!drag) return; + const moved = drag.live; + drag = null; + if (moved) persist(); +}; +plotCanvas.addEventListener('pointerup', releasePlot); +plotCanvas.addEventListener('pointercancel', releasePlot); + +// ── curve panel ───────────────────────────────────────────────────────── + +const curveCanvas = $('curve'); +const cctx = curveCanvas.getContext('2d'); +const CM = { l: 34, r: 10, t: 12, b: 26 }; +let CW = 0, CH = 0; +let curveDrag = null; + +// The curve's box is the stylesheet's too (#curve: 356 px wide where there is +// room, the width of its row where there is not, 218 tall). The pointer maths +// below go by CW and CH, so any size works. +function sizeCurve() { + const dpr = window.devicePixelRatio || 1; + const cssW = curveCanvas.clientWidth, cssH = 218; + curveCanvas.width = cssW * dpr; curveCanvas.height = cssH * dpr; + cctx.setTransform(dpr, 0, 0, dpr, 0, 0); + CW = cssW - CM.l - CM.r; CH = cssH - CM.t - CM.b; +} + +const cxOf = (u) => CM.l + clamp01(u) * CW; +const cyOf = (v) => CM.t + (1 - clamp01(v)) * CH; + +function drawCurve() { + const T = theme(); + const band = cfg.bands[sel]; + const W = CM.l + CW + CM.r, H = CM.t + CH + CM.b; + cctx.clearRect(0, 0, W, H); + + cctx.fillStyle = T.cream; + cctx.fillRect(CM.l, CM.t, CW, CH); + cctx.strokeStyle = T.rule; + cctx.lineWidth = 1; + cctx.strokeRect(CM.l + 0.5, CM.t + 0.5, CW - 1, CH - 1); + cctx.strokeStyle = T.ruleA(0.5); + cctx.beginPath(); cctx.moveTo(cxOf(0.5), CM.t); cctx.lineTo(cxOf(0.5), CM.t + CH); cctx.stroke(); + cctx.beginPath(); cctx.moveTo(CM.l, cyOf(0.5)); cctx.lineTo(CM.l + CW, cyOf(0.5)); cctx.stroke(); + + // curve + cctx.strokeStyle = T.ink; + cctx.lineWidth = 2; + cctx.lineCap = 'round'; + cctx.beginPath(); + const steps = band.curve && band.curve.type === 'steps'; + const N = steps ? 200 : 64; + for (let i = 0; i <= N; i++) { + const u = i / N; + const v = bandCurveValue(band, u); + const x = cxOf(u), y = cyOf(v); + if (i === 0) cctx.moveTo(x, y); else cctx.lineTo(x, y); + } + cctx.stroke(); + + // bezier handles + if (band.curve && band.curve.type === 'bezier') { + const c = band.curve; + cctx.strokeStyle = T.faint; + cctx.setLineDash([2, 3]); + cctx.beginPath(); cctx.moveTo(cxOf(0), cyOf(c.y0)); cctx.lineTo(cxOf(c.p1x), cyOf(c.p1y)); cctx.stroke(); + cctx.beginPath(); cctx.moveTo(cxOf(1), cyOf(c.y1)); cctx.lineTo(cxOf(c.p2x), cyOf(c.p2y)); cctx.stroke(); + cctx.setLineDash([]); + cctx.fillStyle = T.led; + cctx.strokeStyle = T.ink; + cctx.lineWidth = 1; + for (const [hx, hy] of [[c.p1x, c.p1y], [c.p2x, c.p2y]]) { + cctx.fillRect(cxOf(hx) - GRIP / 2, cyOf(hy) - GRIP / 2, GRIP, GRIP); + cctx.strokeRect(cxOf(hx) - GRIP / 2, cyOf(hy) - GRIP / 2, GRIP, GRIP); + } + } + + // live cursor: where the music is on this curve right now, and (top + // left) what that is: the level heard, in the axis's dB, -> the knob's + // value. The position and the value are the mapper's own figures when the + // frame has them. + const win = bandWindow(sel); + if (hearing()) { + const u = bandPos(sel); + const v = bandCurveValue(band, u); + cctx.strokeStyle = T.led; + cctx.setLineDash([3, 3]); + cctx.lineWidth = 1; + cctx.beginPath(); cctx.moveTo(cxOf(u), CM.t + CH); cctx.lineTo(cxOf(u), cyOf(v)); cctx.stroke(); + cctx.setLineDash([]); + cctx.fillStyle = T.led; + cctx.beginPath(); cctx.arc(cxOf(u), cyOf(v), 4.5, 0, Math.PI * 2); cctx.fill(); + const out = frame.outputs && frame.outputs.length ? frame.outputs[sel] : PFMap.output(band, u); + const heard = dbText(frame.levels[sel] || 0); + cctx.font = MONO; + cctx.fillStyle = T.muted; + cctx.textAlign = 'left'; + cctx.fillText((heard && heard + ' dB ') + '→ ' + Number(out).toFixed(2), CM.l + 6, CM.t + 12); + } + + // labels: along the bottom the window this curve is spread over, quiet end + // to loud end, in the plot's dB (auto range keeps moving it, so it gets the + // word); up the side the two ends of the output range. + const lo = dbText(win.lo); + cctx.font = MONO; + cctx.fillStyle = T.muted; + cctx.textAlign = 'left'; + cctx.fillText(cfg.autoRange ? 'auto range' : lo, CM.l, CM.t + CH + 16); + cctx.textAlign = 'right'; + cctx.fillText(cfg.autoRange || !lo ? '' : dbText(win.hi) + ' dB', CM.l + CW, CM.t + CH + 16); + cctx.fillText(band.outMax.toFixed(2), CM.l - 6, CM.t + 8); + cctx.fillText(band.outMin.toFixed(2), CM.l - 6, CM.t + CH); +} + +// Which handle of the selected knob's curve is at this point: 'p1', 'p2', or +// nothing (only a bezier has handles). +function curveHit(x, y) { + const c = cfg.bands[sel].curve; + if (!c || c.type !== 'bezier') return null; + const near = (hx, hy) => Math.hypot(x - cxOf(hx), y - cyOf(hy)) < REACH; + return near(c.p1x, c.p1y) ? 'p1' : near(c.p2x, c.p2y) ? 'p2' : null; +} +dragOnHit(curveCanvas, curveHit); + +curveCanvas.addEventListener('pointerdown', (event) => { + const rect = curveCanvas.getBoundingClientRect(); + curveDrag = curveHit(event.clientX - rect.left, event.clientY - rect.top); + if (!curveDrag) return; + curveCanvas.setPointerCapture(event.pointerId); + event.preventDefault(); +}); + +curveCanvas.addEventListener('pointermove', (event) => { + if (!curveDrag) return; + const rect = curveCanvas.getBoundingClientRect(); + const u = clamp01((event.clientX - rect.left - CM.l) / CW); + const v = clamp01(1 - (event.clientY - rect.top - CM.t) / CH); + const band = cfg.bands[sel]; + const c = band.curve; + if (curveDrag === 'p1') { c.p1x = u; c.p1y = v; } + else { c.p2x = u; c.p2y = v; } + c.id = 'custom'; + band.lut = bakeLut(c); + drawCurve(); + renderPresetChips(); + placeTags(); +}); + +const releaseCurve = () => { + if (!curveDrag) return; + curveDrag = null; + persist(); + renderChips(); +}; +curveCanvas.addEventListener('pointerup', releaseCurve); +curveCanvas.addEventListener('pointercancel', releaseCurve); + +// ── presets, steps, settings column ───────────────────────────────────── + +function renderPresetChips() { + const band = cfg.bands[sel]; + const id = band.curve ? (band.curve.id || 'custom') : null; + document.querySelectorAll('.preset').forEach((el) => { + el.classList.toggle('on', el.dataset.p === id); + el.setAttribute('aria-pressed', el.dataset.p === id); + }); + // One line under the shapes, and only when it is true: a knob that has no + // shape is on the source's default response (nothing to fix, so nothing is + // asked), and only a bezier has handles to drag. + $('curveHint').textContent = !band.curve ? 'This knob uses the default response.' + : band.curve.type === 'bezier' ? 'Drag the two handles for a custom shape.' : ''; + $('stepsRow').hidden = !(band.curve && band.curve.type === 'steps'); + if (band.curve && band.curve.type === 'steps') $('stepsN').textContent = String(band.curve.n); + document.querySelectorAll('.preset canvas').forEach((canvas) => { + const preset = PRESETS[canvas.parentElement.dataset.p]; + drawGlyph(canvas, { curve: preset, gain: 1 }, 40, 24, theme().ink); + }); +} + +document.querySelectorAll('.preset').forEach((el) => { + el.addEventListener('click', () => { + const preset = PRESETS[el.dataset.p]; + if (!preset) return; + touchCurve(cfg.bands[sel], structuredClone(preset)); + drawCurve(); + renderPresetChips(); + placeTags(); + renderChips(); + persist(); + }); +}); + +$('stepsMinus').addEventListener('click', () => stepsAdjust(-1)); +$('stepsPlus').addEventListener('click', () => stepsAdjust(1)); +function stepsAdjust(delta) { + const band = cfg.bands[sel]; + if (!band.curve || band.curve.type !== 'steps') return; + band.curve.n = Math.max(2, Math.min(8, band.curve.n + delta)); + band.curve.id = band.curve.n === 2 ? 'gate' : 'steps'; + band.lut = bakeLut(band.curve); + drawCurve(); + renderPresetChips(); + placeTags(); + renderChips(); + persist(); +} + +// The selected knob's card: which knob, what it listens to (its frequencies, +// and its window in the plot's dB unless auto range is setting that), its +// output range and its mute. The knob's name is also on its Reset and in +// Preview's legend. +function renderSettings() { + const band = cfg.bands[sel]; + const lo = dbText(band.inMin); + $('bandName').textContent = 'Knob ' + (sel + 1); + $('bandMeta').textContent = formatHz(band.hzMin) + ' – ' + formatHz(band.hzMax) + ' Hz' + + (cfg.autoRange ? ' · auto range' : lo && ' · ' + lo + ' to ' + dbText(band.inMax) + ' dB'); + $('outLabel').textContent = band.outMin.toFixed(2) + ' – ' + band.outMax.toFixed(2); + const w = $('outTrack').clientWidth - 10; + $('outLo').style.left = (5 + band.outMin * w) + 'px'; + $('outHi').style.left = (5 + band.outMax * w) + 'px'; + $('outFill').style.left = (5 + band.outMin * w) + 'px'; + $('outFill').style.width = Math.max(0, (band.outMax - band.outMin) * w) + 'px'; + $('muteToggle').classList.toggle('on', band.muted); + $('muteToggle').setAttribute('aria-pressed', band.muted); + $('resetBand').textContent = 'Reset K' + (sel + 1); + $('pvOut').textContent = 'K' + (sel + 1) + ' output'; +} + +document.querySelectorAll('.outPreset').forEach((el) => { + el.addEventListener('click', () => { + const band = cfg.bands[sel]; + band.outMin = Number(el.dataset.lo); + band.outMax = Number(el.dataset.hi); + renderSettings(); drawCurve(); renderChips(); persist(); + }); +}); + +// The track is dragged sideways and lets the page scroll up and down +// (touch-action: pan-y). A touch that turns out to be a scroll ends in +// pointercancel after it has already moved the handle a little: that puts +// back what the press found and saves nothing. +let outDrag = null; +$('outTrack').addEventListener('pointerdown', (event) => { + const band = cfg.bands[sel]; + const rect = $('outTrack').getBoundingClientRect(); + const v = clamp01((event.clientX - rect.left - 5) / (rect.width - 10)); + outDrag = { lo: Math.abs(v - band.outMin) < Math.abs(v - band.outMax), was: [band.outMin, band.outMax] }; + $('outTrack').setPointerCapture(event.pointerId); + event.preventDefault(); +}); +$('outTrack').addEventListener('pointermove', (event) => { + if (!outDrag) return; + const band = cfg.bands[sel]; + const rect = $('outTrack').getBoundingClientRect(); + const v = clamp01((event.clientX - rect.left - 5) / (rect.width - 10)); + if (outDrag.lo) band.outMin = Math.min(v, band.outMax - PFMap.MIN_OUT); + else band.outMax = Math.max(v, band.outMin + PFMap.MIN_OUT); + renderSettings(); drawCurve(); +}); +$('outTrack').addEventListener('pointerup', () => { + if (outDrag) { outDrag = null; persist(); } +}); +$('outTrack').addEventListener('pointercancel', () => { + if (!outDrag) return; + [cfg.bands[sel].outMin, cfg.bands[sel].outMax] = outDrag.was; + outDrag = null; + renderSettings(); drawCurve(); +}); + +$('muteToggle').addEventListener('click', () => { + const band = cfg.bands[sel]; + band.muted = !band.muted; + renderSettings(); renderChips(); drawPlot(); persist(); +}); + +// Reset band asks the SOURCE for its default: the editor has no table of its +// own, so this and the source's own "reset everything" cannot disagree. +// Whatever is waiting to be saved goes first, so the reset is the last word. +$('resetBand').addEventListener('click', async () => { + await flush(); + const next = await A.resetBand(sel, cfg); + if (next) applyConfig(next); +}); + +// The source's own "reset everything", where it has one (see the contract): +// the button is in the page, hidden, and only such a source brings it out. +if (A.resetAll) { + $('resetMap').hidden = false; + $('resetMap').addEventListener('click', function () { A.resetAll(this); }); +} + +// ── the knobs, as tabs under the plot ─────────────────────────────────── + +function renderChips() { + cfg.bands.forEach((band, i) => { + const chip = $('chip' + i); + chip.classList.toggle('sel', i === sel); + chip.setAttribute('aria-pressed', i === sel); + chip.classList.toggle('mutedChip', band.muted); + chip.querySelector('.chipHz').textContent = formatHz(band.hzMin) + '–' + formatHz(band.hzMax); + drawGlyph(chip.querySelector('canvas'), band, 18, 12, theme().muted); + }); +} + +for (let i = 0; i < 4; i++) { + $('chip' + i).addEventListener('click', () => selectBand(i)); +} + +function selectBand(index) { + sel = Math.max(0, Math.min(3, index)); + drawPlot(); + drawCurve(); + renderPresetChips(); + renderSettings(); + renderChips(); +} + +// ── preview ───────────────────────────────────────────────────────────── +// +// Ableton-style: synthetic test signals through the SELECTED knob's whole +// chain - window, glide ballistics, curve, output range - drawn as a +// scrolling scope. What the knob will do, audible music not required. +// +// The scope takes the plot's place while it runs (#pv lies over it: a test +// signal needs no spectrum), so everything it demonstrates - attack and +// damping above it, the knobs, the curve and the output range below - stays +// live around it. The Preview button, right under it, closes it again; so +// does Escape. + +const pv = $('pv'); +const pvScope = $('pvScope'); +const pvCtx = pvScope.getContext('2d'); +// The scope's box, css px: whatever the layer gives it (see pvSize). +let pvW = 0, pvH = 0; +let pvSig = 'pulse'; +let pvRaf = 0; +let pvTimer = 0; +let pvT = 0; +let pvLast = 0; +let pvLevel = 0; +const PV_SECONDS = 4; +const PV_STEP = 1 / 30; +let pvIn = []; +let pvOut = []; + +function pvSignal(t) { + if (pvSig === 'pulse') { + // A kick every 0.8 s: one sharp burst, then near-silence. The tail the + // output shows comes from the glide, which is the point of the demo. + const phase = t % 0.8; + const spike = phase < 0.06 ? 0.85 : 0; + return clamp01(spike + 0.07 + (Math.random() - 0.5) * 0.03); + } + if (pvSig === 'noisy') { + const spike = (t % 1.7) < 0.06 ? 0.5 : 0; + return clamp01(0.30 + 0.10 * Math.sin(t * 1.3) + (Math.random() - 0.5) * 0.22 + spike); + } + // swell + return clamp01(0.45 + 0.35 * Math.sin(t * Math.PI * 2 * 0.35) + (Math.random() - 0.5) * 0.04); +} + +function pvChain(sig) { + // Mirror the live pipeline: asymmetric glide on the LEVEL, then window, + // curve, output range. + // (Both alphas are already inside 0.05..0.9: the model holds them there on + // load and the two sliders cannot leave it.) + const a = pvLevel < sig ? cfg.attack : cfg.smoothing; + pvLevel += (sig - pvLevel) * a; + const band = cfg.bands[sel]; + // The model's own two functions. In auto range the test signal is taken + // as already sitting in its envelope. + return PFMap.output(band, PFMap.position(pvLevel, band, cfg.autoRange ? { lo: 0, hi: 1 } : null)); +} + +function pvDraw() { + const T = theme(); + const w = pvW, h = pvH; + pvCtx.clearRect(0, 0, w, h); + pvCtx.strokeStyle = T.ruleA(0.5); + pvCtx.lineWidth = 1; + for (const v of [0.25, 0.5, 0.75]) { + const y = Math.round(h - v * h) + 0.5; + pvCtx.beginPath(); pvCtx.moveTo(0, y); pvCtx.lineTo(w, y); pvCtx.stroke(); + } + const trace = (arr, color, width) => { + pvCtx.strokeStyle = color; + pvCtx.lineWidth = width; + pvCtx.beginPath(); + for (let i = 0; i < arr.length; i++) { + const x = (i / (PV_SECONDS / PV_STEP)) * w; + const y = h - clamp01(arr[i]) * h; + if (i === 0) pvCtx.moveTo(x, y); else pvCtx.lineTo(x, y); + } + pvCtx.stroke(); + }; + trace(pvIn, T.faint, 1.2); + trace(pvOut, T.led, 2); +} + +function pvTick(now) { + if (pv.hidden) return; + if (!pvLast) pvLast = now; + // Wall-clock scheduling, not per-frame increments: a throttled tab still + // simulates the RIGHT amount of time when frames do arrive (capped per + // frame so a background stint cannot demand thousands of steps at once). + const target = pvT + (now - pvLast) / 1000; + pvLast = now; + let budget = 60; + while (pvT < target && budget-- > 0) { + pvT += PV_STEP; + const sig = pvSignal(pvT); + pvIn.push(sig); + pvOut.push(pvChain(sig)); + const cap = PV_SECONDS / PV_STEP; + if (pvIn.length > cap) { pvIn.shift(); pvOut.shift(); } + } + if (budget < 0) pvT = target; + pvDraw(); + pvSchedule(); +} + +// rAF when the surface is live, a timer backstop when it is throttled or +// hidden - the preview keeps moving either way. +function pvSchedule() { + pvRaf = requestAnimationFrame(pvTick); + clearTimeout(pvTimer); + pvTimer = setTimeout(() => { + cancelAnimationFrame(pvRaf); + pvTick(performance.now()); + }, 300); +} + +// The scope draws into the box the stylesheet gives it: what is left of the +// plot's box under the row of test signals. No size of its own. +function pvSize() { + const dpr = window.devicePixelRatio || 1; + pvW = pvScope.clientWidth; pvH = pvScope.clientHeight; + pvScope.width = pvW * dpr; pvScope.height = pvH * dpr; + pvCtx.setTransform(dpr, 0, 0, dpr, 0, 0); +} + +// On, off: the one switch. The button says which it is (aria-pressed). +function pvSet(on) { + pv.hidden = !on; + $('openPreview').setAttribute('aria-pressed', on); + cancelAnimationFrame(pvRaf); + clearTimeout(pvTimer); + if (!on) return; + pvSize(); + pvIn = []; pvOut = []; pvT = 0; pvLast = 0; pvLevel = 0; + pvSchedule(); +} + +$('openPreview').addEventListener('click', () => pvSet(pv.hidden)); +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !pv.hidden) pvSet(false); +}); +document.querySelectorAll('.pvSig').forEach((el) => { + el.addEventListener('click', () => { + pvSig = el.dataset.s; + document.querySelectorAll('.pvSig').forEach((x) => x.classList.toggle('on', x === el)); + }); +}); + +// ── auto toggle, status, wiring ───────────────────────────────────────── + +// The switch, and beside it the one thing to know about the boxes in the mode +// it is in. +function syncAutoToggle() { + $('autoToggle').classList.toggle('on', cfg.autoRange); + $('autoToggle').setAttribute('aria-pressed', cfg.autoRange); + $('autoHint').textContent = cfg.autoRange ? HINT_AUTO : HINT_HAND; +} + +// The switch only switches: off goes back to the windows set by hand, which +// auto range never touched. (Dragging an edge is what keeps an envelope.) +$('autoToggle').addEventListener('click', () => { + cfg.autoRange = !cfg.autoRange; + syncAutoToggle(); + drawPlot(); drawCurve(); renderSettings(); + persist(); +}); + +// Damping = the level smoothing the analysis already runs (an EMA on each +// band, before the window and curve). Right means calmer: the slider's 0..1 +// maps to alpha 0.9..0.05, so a gentle word scale replaces a number nobody +// would know the direction of. It smooths the INPUT level, not the output — +// gate and steps curves stay crisp, their trigger just stops flickering. +const DAMP_WORDS = [[0.25, 'tight'], [0.5, 'balanced'], [0.75, 'smooth'], [1.01, 'glassy']]; +const ATK_WORDS = [[0.25, 'snap'], [0.5, 'quick'], [0.75, 'soft'], [1.01, 'lazy']]; + +const dampWord = (v) => DAMP_WORDS.find(([top]) => v < top)[1]; + +function syncDamping() { + const v = clamp01((0.9 - cfg.smoothing) / 0.85); + $('damping').value = v; + $('dampingWord').textContent = dampWord(v); + const k = clamp01((0.9 - cfg.attack) / 0.85); + $('attack').value = k; + $('attackWord').textContent = ATK_WORDS.find(([top]) => k < top)[1]; +} + +$('attack').addEventListener('input', () => { + const v = clamp01($('attack').value); + cfg.attack = 0.9 - v * 0.85; + $('attackWord').textContent = ATK_WORDS.find(([top]) => v < top)[1]; + persist(); +}); + +$('damping').addEventListener('input', () => { + const v = clamp01($('damping').value); + cfg.smoothing = 0.9 - v * 0.85; + $('dampingWord').textContent = dampWord(v); + persist(); +}); + +// Everything drawn is sized from the page, so a window made narrower (half a +// screen beside the music, a phone turned round) is measured again. +window.addEventListener('resize', () => { + if (!cfg) return; + sizePlot(); + sizeCurve(); + if (!pv.hidden) pvSize(); + drawPlot(); + drawCurve(); + renderSettings(); +}); + +// An in-page "are you sure": the first press arms the button and makes it ask, +// a second press within four seconds answers yes. A second press that comes +// within half a second of the first is the same double click or double tap, +// not an answer to a question nobody has had time to read: it is ignored, and +// the button stays armed. window.confirm is not used anywhere on these pages: +// the phone app shows /audio-in in a WebView that has none (it answers "no" +// without showing anything). Use: +// if (!PFConfirm(button, 'Reset everything? Press again')) return; +window.PFConfirm = function (button, ask) { + if (button._armed) { + if (Date.now() - button._at < 500) return false; + clearTimeout(button._armed); + button._armed = 0; + button.textContent = button._label; + return true; + } + button._label = button.textContent; + button.textContent = ask; + button._at = Date.now(); + button._armed = setTimeout(() => { + button._armed = 0; + button.textContent = button._label; + }, 4000); + return false; +}; + +(async function init() { + const stored = await A.loadConfig(); + readCaps(); + cfg = PFMap.normalizeConfig(stored, CAPS); + sizePlot(); + sizeCurve(); + syncAutoToggle(); + syncDamping(); + selectBand(0); + // The attribute, not the property: a browser too old to know `inert` would + // keep the attribute, and with it what the stylesheet hides before the + // first mapping. + $('ed').removeAttribute('inert'); + if (A.onConfig) A.onConfig(applyConfig); + A.onFrame((state) => { + const was = String(axisDb()); + frame = state || {}; + // A frame on another level axis (`db` in the contract) changes the dB the + // card prints, not only the plot's labels. + if (String(axisDb()) !== was) renderSettings(); + drawPlot(); + drawCurve(); + }); +})(); diff --git a/web/public/guide/audio-editor/index.html b/web/public/guide/audio-editor/index.html new file mode 100644 index 000000000..f7fb6b865 --- /dev/null +++ b/web/public/guide/audio-editor/index.html @@ -0,0 +1,185 @@ + + + + + + +Patternflow Audio — Mapping + + + +
+ + +
+
+ + Patternflow Audio + MAPPING +
+
+ + idle + + +
+
+ + + +
+ +
+ + Each box follows the loudness of what it hears. Drag a top or bottom edge to take all four over by hand. +
+
+ + + +
+
+ + + +
+
+
+ + +
+ +
K1
+
K2
+
K3
+
K4
+ +
+ + +
+
+ + + + +
+ +
+ +
+
+ Knob 1 + +
+ +
+ Response curve +
+ +
+
+ + + + + + +
+ + +
+
+
+ +
+ +
+
+
+ Output range + +
+
+
+
+
+
+
+
+ + + + + + +
+
+ + +
+
+ + +
+ + +
+ +
+ + +
+ + + + + + diff --git a/web/public/guide/audio-editor/mapping.js b/web/public/guide/audio-editor/mapping.js new file mode 100644 index 000000000..975fc629b --- /dev/null +++ b/web/public/guide/audio-editor/mapping.js @@ -0,0 +1,236 @@ +// Patternflow Audio — the mapping model. +// +// What a mapping is, written once. Four bands, one per knob. A band listens +// to a range of frequencies and takes the level it hears there through +// +// level -> window -> response curve -> [rests at .. peaks at] +// +// where the window is the band's own (manual) or the envelope the band has +// recently moved in (auto range). offscreen.js runs that chain for the knobs; +// the editor draws it and previews it; both ask this file, so a box on the +// screen and a knob on the panel cannot be told two different things. +// +// It is ONE object on purpose. These are classic scripts sharing a global +// scope, where a second top-level `clamp01` in any file is a SyntaxError that +// kills the page at load and that `node --check` on each file cannot see. +// Everything here is reached as PFMap.something; nothing else is declared. +// +// Two halves. Down to the line that says so, this is the model: no chrome.*, +// no DOM, none of the extension's own numbers. That half also travels to the +// panel, inside its /audio-in page (firmware/toolchain/build_audio_in_page.py +// stops reading at the line), so every byte of it is sent over the panel's +// Wi-Fi: keep it to what the editor needs on both sides. Below the line is +// what only the extension's audio source knows. + +var PFMap = (function () { + 'use strict'; + + const within = (v, lo, hi) => Math.max(lo, Math.min(hi, Number(v) || 0)); + const clamp01 = (v) => within(v, 0, 1); + + // ── response curves ───────────────────────────────────────────────────── + // + // A curve is authored as one of these shapes and BAKED to a 33-point table. + // Whatever maps for real - offscreen.js here, the firmware on the panel - + // only interpolates the table, and never learns what a bezier is. + + const PRESETS = { + smooth: { type: 'bezier', id: 'smooth', y0: 0, y1: 1, p1x: 0.45, p1y: 0.05, p2x: 0.55, p2y: 0.95 }, + sharp: { type: 'bezier', id: 'sharp', y0: 0, y1: 1, p1x: 0.10, p1y: 0.65, p2x: 0.35, p2y: 1.00 }, + fall: { type: 'bezier', id: 'fall', y0: 1, y1: 0, p1x: 0.45, p1y: 0.95, p2x: 0.55, p2y: 0.05 }, + gate: { type: 'steps', id: 'gate', n: 2 }, + steps: { type: 'steps', id: 'steps', n: 3 }, + arch: { type: 'arch', id: 'arch' } + }; + + function evalCurve(curve, u) { + u = clamp01(u); + if (!curve) return u; + if (curve.type === 'steps') { + const n = within(Math.round(curve.n) || 2, 2, 8); + return Math.min(n - 1, Math.floor(u * n)) / (n - 1); + } + if (curve.type === 'arch') return Math.sin(Math.PI * u); + if (curve.type === 'bezier') { + // Cubic from (0, y0) to (1, y1); x(t) is monotone while both control xs + // stay in [0,1], so a short bisection recovers t for any u. + const { y0 = 0, y1 = 1, p1x, p1y, p2x, p2y } = curve; + const bx = (t) => 3 * (1 - t) * (1 - t) * t * p1x + 3 * (1 - t) * t * t * p2x + t * t * t; + const by = (t) => (1 - t) * (1 - t) * (1 - t) * y0 + 3 * (1 - t) * (1 - t) * t * p1y + 3 * (1 - t) * t * t * p2y + t * t * t * y1; + let lo = 0, hi = 1; + for (let i = 0; i < 24; i++) { + const mid = (lo + hi) / 2; + if (bx(mid) < u) lo = mid; else hi = mid; + } + return clamp01(by((lo + hi) / 2)); + } + return u; + } + + function bakeLut(curve) { + const lut = []; + for (let i = 0; i <= 32; i++) lut.push(Number(evalCurve(curve, i / 32).toFixed(4))); + return lut; + } + + // ── level to knob ─────────────────────────────────────────────────────── + + // The narrowest window and the shortest stretch of knob a band may have. + const MIN_WINDOW = 0.02; + const MIN_OUT = 0.05; + + const api = { + clamp01, PRESETS, evalCurve, bakeLut, MIN_WINDOW, MIN_OUT, + + // The response at a position in the window: the band's curve, or, for a + // band saved before there were curves, its boost exponent (above 1x a + // quiet band reaches its top early, below 1x a loud one holds back). + curveValue(band, u) { + return band.curve ? evalCurve(band.curve, u) + : Math.pow(clamp01(u), 1 / within(Number(band.gain) || 1, 0.2, 4)); + }, + + // Where a level sits in the band's window, 0..1: the input of the curve. + // `env` is the band's { lo, hi } envelope while auto range runs, null for + // the band's own inMin..inMax. Auto range maps a fixed slice of the + // envelope: under 0.10 of it is squelched, so the resting noise at the + // floor does not dance, and 0.95 of it is already full scale. + position(level, band, env) { + if (env) { + return clamp01((clamp01((level - env.lo) / Math.max(0.001, env.hi - env.lo)) - 0.10) / (0.95 - 0.10)); + } + const lo = clamp01(band.inMin); + return clamp01((level - lo) / Math.max(MIN_WINDOW, clamp01(band.inMax) - lo)); + }, + + // The knob value for a position in the window, through the curve. + output(band, u) { + const lo = clamp01(band.outMin); + return lo + api.curveValue(band, u) * (clamp01(band.outMax) - lo); + }, + + // The default band: what Reset gives, and what a first run starts with. + defaultBand(index, caps) { + return normalizeBand(null, index, caps); + }, + + // The whole mapping, made whole: { autoRange, smoothing, attack, bands[4] }. + // `caps` is the source's { hzMin, hzMax }. smoothing and attack are the + // two alphas of the level glide: how fast a level falls, how fast it rises. + // + // Nothing stored is a first run, and starts the way the panel does: auto + // range on, four default bands. Something stored is somebody's mapping + // and is not improved behind their back; in particular one saved before + // auto range existed has no `autoRange` and stays manual. + normalizeConfig(raw, caps) { + const stored = raw && Array.isArray(raw.bands) ? raw : { autoRange: true, bands: [] }; + return { + autoRange: stored.autoRange === true, + smoothing: within(Number(stored.smoothing) || 0.35, 0.05, 0.9), + attack: within(Number(stored.attack) || 0.65, 0.05, 0.9), + bands: [0, 1, 2, 3].map((i) => normalizeBand(stored.bands[i], i, caps)) + }; + } + }; + + const HZ = [[60, 250], [250, 2000], [2000, 5000], [5000, 16000]]; + + // lo and hi inside floor..ceiling, in that order, at least `gap` apart. + function pair(lo, hi, floor, ceiling, gap) { + lo = within(lo, floor, ceiling); + hi = within(hi, floor, ceiling); + if (lo > hi) [lo, hi] = [hi, lo]; + if (hi - lo < gap) { + hi = Math.min(ceiling, lo + gap); + lo = Math.min(lo, hi - gap); + } + return [lo, hi]; + } + + // One band, made whole. No band at all is the default: its slice of the + // spectrum, the full window, the Smooth curve already baked. A band that IS + // stored keeps what it has and gains nothing: saved before curves existed + // it has no curve and keeps its boost exponent, and saved when the output + // range was `base` + `range` it is read as that. + function normalizeBand(raw, index, caps) { + const b = raw && typeof raw === 'object' ? raw + : { outMin: 0.30, outMax: 0.85, curve: { ...PRESETS.smooth }, lut: bakeLut(PRESETS.smooth) }; + const base = b.base ?? 0.30; + const [hzMin, hzMax] = pair(b.hzMin ?? HZ[index][0], b.hzMax ?? HZ[index][1], caps.hzMin, caps.hzMax, 10); + const [inMin, inMax] = pair(b.inMin ?? 0, b.inMax ?? 1, 0, 1, MIN_WINDOW); + const [outMin, outMax] = pair(b.outMin ?? base, b.outMax ?? base + (b.range ?? 0.55), 0, 1, MIN_OUT); + return { + hzMin, hzMax, + // Four bands, four knobs, one line between them: band i drives knob i. + knob: index, + muted: b.muted === true, + inMin, inMax, + gain: within(Number(b.gain) || 1, 0.2, 4), + outMin, outMax, + curve: b.curve && typeof b.curve === 'object' ? b.curve : null, + lut: Array.isArray(b.lut) && b.lut.length > 1 ? b.lut : null + }; + } + + return api; +})(); + +// ── EXTENSION ONLY ──────────────────────────────────────────────────────── +// +// What the extension's audio source knows and the panel's does not. + +(function (M) { + 'use strict'; + + // Where the mapping is stored. The panel's address is not in it: that is the + // popup's, under a key of its own, so the editor and the popup never write + // the same value and neither can put the other's old one back. + M.CONFIG_KEY = 'patternflowAudioConfig'; + + // Tab audio through an AnalyserNode: the frequencies worth drawing, and the + // dB the level axis runs between, bottom then top. Levels everywhere in the + // extension are 0..1 along that axis. + M.SOURCE = { hzMin: 20, hzMax: 20000, db: [-80, -10] }; + + M.normalizeDb = function (db) { + return M.clamp01((db - M.SOURCE.db[0]) / (M.SOURCE.db[1] - M.SOURCE.db[0])); + }; + + // The knob gets the band's TABLE where it has one, interpolated the way the + // firmware interpolates it, and not the curve the table was baked from: a + // table rounds a step's corner, and what is sent should be what a panel + // running the same mapping would do. Whatever in the extension asks for a + // knob value gets this: PFMap.output() reaches it, so offscreen.js and the + // editor's Preview do. + const drawn = M.curveValue; + M.curveValue = function (band, u) { + const lut = band.lut; + if (!lut || lut.length < 2) return drawn(band, u); + const at = M.clamp01(u) * (lut.length - 1); + const i = Math.floor(at); + const j = Math.min(lut.length - 1, i + 1); + return M.clamp01(lut[i]) * (1 - (at - i)) + M.clamp01(lut[j]) * (at - i); + }; + + // Auto-range envelope, one per band: the floor and peak the band has + // recently seen, in the same units as its level. Both edges jump to a new + // extreme at once and drift back slowly (ENV_RELEASE per 33 ms tick is about + // 8 s, the panel's own time constant). ENV_MIN_SPAN stops silence from + // collapsing the window into a noise amplifier. There is no microphone gate + // here, unlike the panel: tab audio is digital, and silent means zero. + M.ENV_RELEASE = 0.004; + M.ENV_MIN_SPAN = 0.06; + + M.newEnvelope = function () { + return { lo: 1, hi: 0 }; + }; + + M.trackEnvelope = function (env, level, release) { + const v = M.clamp01(level); + const r = release || M.ENV_RELEASE; + if (v > env.hi) env.hi = v; else env.hi += (v - env.hi) * r; + if (v < env.lo) env.lo = v; else env.lo += (v - env.lo) * r; + if (env.hi < env.lo + M.ENV_MIN_SPAN) env.hi = env.lo + M.ENV_MIN_SPAN; + return env; + }; +})(PFMap); diff --git a/web/public/guide/audio/audio-in-listening.webp b/web/public/guide/audio/audio-in-listening.webp new file mode 100644 index 000000000..7d0f6b512 Binary files /dev/null and b/web/public/guide/audio/audio-in-listening.webp differ diff --git a/web/public/guide/audio/audio-in-mapping.webp b/web/public/guide/audio/audio-in-mapping.webp new file mode 100644 index 000000000..41768973a Binary files /dev/null and b/web/public/guide/audio/audio-in-mapping.webp differ diff --git a/web/public/guide/audio/audio-in-nomic.webp b/web/public/guide/audio/audio-in-nomic.webp new file mode 100644 index 000000000..8c4e62c4a Binary files /dev/null and b/web/public/guide/audio/audio-in-nomic.webp differ diff --git a/web/public/guide/audio/audio-in-off.webp b/web/public/guide/audio/audio-in-off.webp new file mode 100644 index 000000000..dc3f27349 Binary files /dev/null and b/web/public/guide/audio/audio-in-off.webp differ diff --git a/web/public/guide/audio/live-remote.webp b/web/public/guide/audio/live-remote.webp new file mode 100644 index 000000000..f33d27485 Binary files /dev/null and b/web/public/guide/audio/live-remote.webp differ diff --git a/web/public/guide/audio/mic-part.webp b/web/public/guide/audio/mic-part.webp new file mode 100644 index 000000000..f48a445ab Binary files /dev/null and b/web/public/guide/audio/mic-part.webp differ diff --git a/web/public/guide/audio/mic-seated.webp b/web/public/guide/audio/mic-seated.webp new file mode 100644 index 000000000..30349dd1d Binary files /dev/null and b/web/public/guide/audio/mic-seated.webp differ diff --git a/web/public/guide/audio/mic-wiring.webp b/web/public/guide/audio/mic-wiring.webp new file mode 100644 index 000000000..f4a921349 Binary files /dev/null and b/web/public/guide/audio/mic-wiring.webp differ diff --git a/web/public/guide/audio/midi-knobs.webp b/web/public/guide/audio/midi-knobs.webp new file mode 100644 index 000000000..c0ddafd89 Binary files /dev/null and b/web/public/guide/audio/midi-knobs.webp differ diff --git a/web/public/guide/audio/midi-session.webp b/web/public/guide/audio/midi-session.webp new file mode 100644 index 000000000..77c5ee973 Binary files /dev/null and b/web/public/guide/audio/midi-session.webp differ diff --git a/web/public/guide/audio/popup-audoff.webp b/web/public/guide/audio/popup-audoff.webp new file mode 100644 index 000000000..41b2e80c7 Binary files /dev/null and b/web/public/guide/audio/popup-audoff.webp differ diff --git a/web/public/guide/audio/popup-idle.webp b/web/public/guide/audio/popup-idle.webp new file mode 100644 index 000000000..8c4dfcacc Binary files /dev/null and b/web/public/guide/audio/popup-idle.webp differ diff --git a/web/public/guide/audio/popup-live.webp b/web/public/guide/audio/popup-live.webp new file mode 100644 index 000000000..490416c48 Binary files /dev/null and b/web/public/guide/audio/popup-live.webp differ diff --git a/web/public/guide/audio/popup-nosound.webp b/web/public/guide/audio/popup-nosound.webp new file mode 100644 index 000000000..0def11ca7 Binary files /dev/null and b/web/public/guide/audio/popup-nosound.webp differ diff --git a/web/public/guide/audio/rtpmidi-1.webp b/web/public/guide/audio/rtpmidi-1.webp new file mode 100644 index 000000000..6a4d4d429 Binary files /dev/null and b/web/public/guide/audio/rtpmidi-1.webp differ diff --git a/web/public/guide/audio/rtpmidi-2.webp b/web/public/guide/audio/rtpmidi-2.webp new file mode 100644 index 000000000..a6593b931 Binary files /dev/null and b/web/public/guide/audio/rtpmidi-2.webp differ diff --git a/web/public/guide/audio/rtpmidi-3.webp b/web/public/guide/audio/rtpmidi-3.webp new file mode 100644 index 000000000..95543473f Binary files /dev/null and b/web/public/guide/audio/rtpmidi-3.webp differ diff --git a/web/public/guide/audio/rtpmidi-4.webp b/web/public/guide/audio/rtpmidi-4.webp new file mode 100644 index 000000000..89416e407 Binary files /dev/null and b/web/public/guide/audio/rtpmidi-4.webp differ diff --git a/web/public/guide/console-demo/update.html b/web/public/guide/console-demo/update.html index 5dc2b5655..3c8b2e94c 100644 --- a/web/public/guide/console-demo/update.html +++ b/web/public/guide/console-demo/update.html @@ -72,7 +72,7 @@

Linked build

-

No file? patternflow.work/update sends the +

No file? patternflow.work/update sends the latest release straight back to this page. Your own arduino-cli export works too. Keep the panel powered: it checks, flashes and restarts by itself.

@@ -202,6 +202,8 @@

Linked build

// The running build; and a late answer (the chrome keeps probing). PF.status(function(s){ build=s.build; + // An edition's image comes from its own card; /update on the site sends the standard firmware. + if(s.variant&&s.variant!=='core'){var a=$('src');a.href='https://patternflow.work/editions#'+encodeURIComponent(s.variant);a.textContent='patternflow.work/editions'} if(phase!=='lost')return; if(!job.from||s.build!==job.from||job.to===job.from)done(s); else{phase='';PF.dirty=false;bar.style.display='none';say('The panel is back, but still on its old build — the update did not take. Try again.','err');startPoll();render()} diff --git a/web/public/llms.txt b/web/public/llms.txt index b984c2fe1..7536b4f6c 100644 --- a/web/public/llms.txt +++ b/web/public/llms.txt @@ -25,6 +25,7 @@ It is not a single luminous object but an open system. The hardware files, firmw - [Build](https://patternflow.work/guide/build): Assembling one from the PCB, the printed case and the parts list, step by step in 3D. - [Play](https://patternflow.work/guide/play): The first hour with a finished board — flashing the ESP32, the four knobs, installing patterns, the device console. - [Make](https://patternflow.work/guide/make): A hands-on tutorial for the community and the Pattern Lab, from a prompt to a pattern on the device. + - [Audio](https://patternflow.work/guide/audio): The Audio edition — sound from a browser tab (the extension and its mapping editor), the on-board PDM microphone, and the board as a network MIDI device with a DAW (Ableton Live as the example). - [Build your own](https://patternflow.work/build): Build the device from scratch — firmware, PCB, 3D-printed enclosure, browser flasher, and the full build guide. - [Inside](https://patternflow.work/inside): How Patternflow works — the electronics, encoders, and pattern architecture. - [Journal](https://patternflow.work/journal): Long-form notes and the story behind each step (English and Korean). diff --git a/web/src/app/editions/editions-data.ts b/web/src/app/editions/editions-data.ts index 8bd042ae6..46f22a09a 100644 --- a/web/src/app/editions/editions-data.ts +++ b/web/src/app/editions/editions-data.ts @@ -228,10 +228,9 @@ export const EDITIONS: Edition[] = [ 'sensitivity on the console, the session reconnects itself', 'OSC — Ableton, Max and TouchDesigner, both directions', 'Browser and tab audio through the Chrome extension', - 'On-board PDM microphone — four wires to GPIO43/44, no computer needed', + 'On-board PDM microphone — five leads, the two signals on GPIO43/44, no computer needed', 'The mapping editor: bands as boxes on the live spectrum, response ' + 'curves from rise to gate to hand-drawn', - 'Wi-Fi transmit power raised for rooms full of access points', ], // Served from here, so the panel's own /update page can fetch it. Under // /flash/bin, which already sends the CORS header that fetch needs. @@ -242,15 +241,14 @@ export const EDITIONS: Edition[] = [ source: 'https://github.com/engmung/Patternflow/tree/main/firmware/bundles/audio', note: 'Six of seven people asked for on-board sound and it was the most ' + - 'wanted thing in the survey by a wide margin — but it needs four wires ' + - 'soldered to the DevKit and the radio setting here is not the ' + - 'conformance-tested one, so neither belongs in the firmware everybody ' + + 'wanted thing in the survey by a wide margin — but it needs five leads ' + + 'soldered to the DevKit, so it does not belong in the firmware everybody ' + 'gets. It is also where that work happens, which is why it carries ' + 'nothing else: no sequences, no weather, no MQTT. Take this to ' + 'experiment with sound, not to run a room. When the microphone is a ' + 'part on the board, on-board audio moves into the default. ' + 'Installing this without the microphone is safe: the panel can tell a ' + - 'missing mic from a quiet room, says so on the Mic page, and lets ' + + 'missing mic from a quiet room, says so on the Audio page, and lets ' + 'nothing drive the knobs until you turn it on.', }, ]; diff --git a/web/src/app/guide/audio/ko/page.tsx b/web/src/app/guide/audio/ko/page.tsx new file mode 100644 index 000000000..34f691a37 --- /dev/null +++ b/web/src/app/guide/audio/ko/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; +import GuideExperience from "@/components/guide/GuideExperience"; +import { guideMetadata } from "@/components/guide/meta"; + +// Audio: the Audio edition, then a browser tab, the microphone, MIDI and a DAW. + +export const metadata: Metadata = guideMetadata("audio", "ko"); + +export const dynamic = "force-static"; + +export default function GuideAudioPageKo() { + return ; +} diff --git a/web/src/app/guide/audio/page.tsx b/web/src/app/guide/audio/page.tsx new file mode 100644 index 000000000..eb349953c --- /dev/null +++ b/web/src/app/guide/audio/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; +import GuideExperience from "@/components/guide/GuideExperience"; +import { guideMetadata } from "@/components/guide/meta"; + +// Audio: the Audio edition, then a browser tab, the microphone, MIDI and a DAW. + +export const metadata: Metadata = guideMetadata("audio", "en"); + +export const dynamic = "force-static"; + +export default function GuideAudioPage() { + return ; +} diff --git a/web/src/app/guide/ko/page.tsx b/web/src/app/guide/ko/page.tsx index d365505c0..9eac0efea 100644 --- a/web/src/app/guide/ko/page.tsx +++ b/web/src/app/guide/ko/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import GuideHub from "@/components/guide/GuideHub"; import { guideMetadata } from "@/components/guide/meta"; -// The guide's front door: pick Build, Play or Make by where your Patternflow +// The guide's front door: pick Build, Play, Make or Audio by where your Patternflow // is (GuideHub). /guide used to be the Play guide; its old anchors // (/guide#flash …) are sent on to /guide/play (components/guide/legacy.ts). diff --git a/web/src/app/guide/layout.tsx b/web/src/app/guide/layout.tsx index 3a48a1193..9a21d8084 100644 --- a/web/src/app/guide/layout.tsx +++ b/web/src/app/guide/layout.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import GuideWorld from "@/components/guide/world/GuideWorld"; -// Everything under /guide — the hub, Build, Play and Make, in both languages +// Everything under /guide — the hub, Build, Play, Make and Audio, in both languages // — is one world: this layout mounts the guide's stage once (GuideWorld), and // it stays up while the reader moves between the pages. A layout is not // rendered again on a navigation inside it, so the 3D canvas, the camera, the diff --git a/web/src/app/guide/page.tsx b/web/src/app/guide/page.tsx index 52505f1fc..bb7203369 100644 --- a/web/src/app/guide/page.tsx +++ b/web/src/app/guide/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import GuideHub from "@/components/guide/GuideHub"; import { guideMetadata } from "@/components/guide/meta"; -// The guide's front door: pick Build, Play or Make by where your Patternflow +// The guide's front door: pick Build, Play, Make or Audio by where your Patternflow // is (GuideHub). /guide used to be the Play guide; its old anchors // (/guide#flash …) are sent on to /guide/play (components/guide/legacy.ts). diff --git a/web/src/app/sitemap.ts b/web/src/app/sitemap.ts index 295495f93..5ff658f3e 100644 --- a/web/src/app/sitemap.ts +++ b/web/src/app/sitemap.ts @@ -13,9 +13,10 @@ export default function sitemap(): MetadataRoute.Sitemap { { url: `${siteUrl}/build`, lastModified: now, changeFrequency: "monthly", priority: 0.8 }, { url: `${siteUrl}/inside`, lastModified: now, changeFrequency: "monthly", priority: 0.7 }, { url: `${siteUrl}/roadmap`, lastModified: now, changeFrequency: "weekly", priority: 0.6 }, - // The guide: the hub, then its three guides in order — Build (soldering + // The guide: the hub, then its four guides in order — Build (soldering // one from bare parts), Play (01 Flash to 04 Console), Make (the - // community and Pattern Lab). + // community and Pattern Lab), Audio (the Audio edition: a browser tab, + // the microphone, MIDI and a DAW). { url: `${siteUrl}/guide`, lastModified: now, changeFrequency: "monthly", priority: 0.8 }, { url: `${siteUrl}/guide/ko`, lastModified: now, changeFrequency: "monthly", priority: 0.7 }, { url: `${siteUrl}/guide/build`, lastModified: now, changeFrequency: "monthly", priority: 0.7 }, @@ -24,6 +25,8 @@ export default function sitemap(): MetadataRoute.Sitemap { { url: `${siteUrl}/guide/play/ko`, lastModified: now, changeFrequency: "monthly", priority: 0.7 }, { url: `${siteUrl}/guide/make`, lastModified: now, changeFrequency: "monthly", priority: 0.7 }, { url: `${siteUrl}/guide/make/ko`, lastModified: now, changeFrequency: "monthly", priority: 0.6 }, + { url: `${siteUrl}/guide/audio`, lastModified: now, changeFrequency: "monthly", priority: 0.7 }, + { url: `${siteUrl}/guide/audio/ko`, lastModified: now, changeFrequency: "monthly", priority: 0.6 }, // The shelf of firmwares built on core. A device running an edition links // here from its own console, so this needs to be findable from outside too. // (/variants only redirects here.) diff --git a/web/src/app/update/UpdateClient.tsx b/web/src/app/update/UpdateClient.tsx index e819fc972..9fe4ebc56 100644 --- a/web/src/app/update/UpdateClient.tsx +++ b/web/src/app/update/UpdateClient.tsx @@ -104,6 +104,11 @@ export default function UpdateClient() { Android cannot resolve .local — use the IP from the device’s NETWORK screen (hold K2).

+

+ This installs the standard firmware. Running an edition (Audio, Performance)? Update + it from its card on the shelf — this button would + replace it. +

The set a board arrives with. Install it on a device with nothing on it yet, or after - a wipe — no account, no build queue. + a wipe — no account, no build queue. Needs firmware 3.5.1 or later; update an older + board first. diff --git a/web/src/components/guide/AudioShots.module.css b/web/src/components/guide/AudioShots.module.css new file mode 100644 index 000000000..ebe565d92 --- /dev/null +++ b/web/src/components/guide/AudioShots.module.css @@ -0,0 +1,472 @@ +/* The Audio guide's captures and photos inside a guide card (AudioShots.tsx). + Three looks. A "crop" is a piece of a page — the console, the extension's + popup, Live's settings — and its frame takes that page's own ground, so a + crop that stops short reads as more of the same page and not as a hole in + the card. A "window" (rtpMIDI) is a whole window with a rim of dark + desktop round it, at the card's width. A "photo" fills its frame. Tokens + come from .page in Guide.module.css; the fallbacks keep it legible + anywhere else. + Shaped after FlasherShots, LabShots and CommunityShots. */ + +.shots { + margin: 18px 0 4px; + padding: 0; +} + +.frame { + position: relative; + width: 100%; + /* Its shape is the set's largest frame (--ar: AudioShots.tsx). */ + aspect-ratio: var(--ar); + margin: 0 auto; + border-radius: 12px; + border: 1px solid var(--g-hair-2, rgba(239, 233, 221, 0.18)); + background: var(--ground, #0b0b0c); + overflow: hidden; + isolation: isolate; +} +/* The dimmed desktop a window stands on: a rim of it, no more. The window + has a program's worth of small type and wants the width. */ +.shots[data-look="window"] .frame { + border-radius: 10px; + border-color: var(--g-hair, rgba(239, 233, 221, 0.1)); + background: + radial-gradient(120% 100% at 50% 40%, rgba(239, 233, 221, 0.075), rgba(239, 233, 221, 0.03) 75%), + #171614; +} + +/* On a wide screen the frame may take a little of the card's padding — the + screens are small type — and is never taller than its share of the + viewport (--cap: AudioShots.tsx LOOKS). */ +@media (min-width: 900px) { + .shots { + margin-inline: -10px; + } + .frame { + width: min(100%, var(--cap, 100%)); + } + .caption { + padding-inline: 10px; + } +} +@media (max-width: 899px) { + .frame { + width: min(100%, var(--cap-narrow, 100%)); + } +} +/* A laptop's short screen (under 880 px of page): everything in the card + sets closer (Guide.module.css), and the frame draws a little smaller + (--cap-short: AudioShots.tsx LOOKS). */ +@media (min-width: 900px) and (max-height: 879px) { + .shots { + margin-top: 12px; + } + .frame { + width: min(100%, var(--cap-short, 100%)); + } +} + +/* One frame of the set: the image and, over it, the ring. Every frame is + drawn at the same scale and hangs from the top of the frame; a window + stands in its middle. */ +.shot { + position: absolute; + top: 0; + left: 50%; + transform: translate(-50%, 0); + opacity: 0; + pointer-events: none; + user-select: none; + /* The next frame comes up over the last one, which only goes once it is + covered: never a frame with nothing in it. */ + z-index: 1; + transition: opacity 0.12s linear 0.34s; +} +.shot[data-on="1"] { + z-index: 2; + opacity: 1; + transition: opacity 0.34s ease; +} +.shot img { + display: block; + width: 100%; + height: 100%; + max-width: none; +} +/* A photo fills its frame, whatever shape the frame has taken. */ +.shots[data-look="photo"] .shot { + height: 100%; +} +.shots[data-look="photo"] .shot img { + object-fit: cover; +} +.shots[data-look="window"] .shot { + top: 50%; + transform: translate(-50%, -50%) scale(0.985); + filter: drop-shadow(0 10px 26px rgba(0, 0, 0, 0.55)); + transition: + opacity 0.12s linear 0.3s, + transform 0s linear 0.42s; +} +.shots[data-look="window"] .shot[data-on="1"] { + transform: translate(-50%, -50%); + transition: + opacity 0.3s ease, + transform 0.3s cubic-bezier(0.2, 0.7, 0.2, 1); +} +/* A Windows window's own corners. */ +.shots[data-look="window"] .shot img { + border-radius: 7px; +} + +/* The control a frame is about. Remounted per frame: it arrives just after + the frame does, then breathes. The captures are light pages, so the ring + keeps the guide's fixed orange — the panel's own colour can be one that + does not read on cream. */ +.ring { + position: absolute; + z-index: 3; + box-sizing: border-box; + border: 2px solid var(--g-led-fixed, #ff6a3d); + border-radius: 7px; + pointer-events: none; + box-shadow: + 0 0 0 3px rgb(255 106 61 / 0.2), + 0 0 16px rgb(255 106 61 / 0.32); + animation: + ringIn 0.42s cubic-bezier(0.2, 0.7, 0.2, 1) 0.22s both, + ringBreathe 1.9s ease-in-out 0.7s infinite; +} +@keyframes ringIn { + from { + opacity: 0; + transform: scale(1.18); + } + to { + opacity: 1; + transform: scale(1); + } +} +@keyframes ringBreathe { + 0%, + 100% { + box-shadow: + 0 0 0 3px rgb(255 106 61 / 0.2), + 0 0 16px rgb(255 106 61 / 0.32); + } + 50% { + box-shadow: + 0 0 0 6px rgb(255 106 61 / 0.08), + 0 0 24px rgb(255 106 61 / 0.45); + } +} + +.caption { + margin-top: 12px; +} + +/* A set of more than one frame: its dots, and beside them the words of the + frame that is up — one row, so the caption costs the card as little + height as it can. */ +.bar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 7px; +} +.dots { + display: flex; + align-items: center; + gap: 6px; + flex: none; +} +.dot { + position: relative; + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--g-hair-2, rgba(239, 233, 221, 0.18)); + border-radius: 999px; + background: transparent; + color: var(--g-ink-3, rgba(239, 233, 221, 0.52)); + font: 600 10.5px/1 var(--pf-mono, ui-monospace, monospace); + cursor: pointer; + transition: + background 0.2s, + border-color 0.2s, + color 0.2s; +} +/* A bigger hit area than the dot itself, without spreading the row. */ +.dot::before { + content: ""; + position: absolute; + inset: -6px -3px; +} +.dot[data-done="1"] { + border-color: rgb(var(--g-led-rgb, 255 106 61) / 0.35); + color: var(--g-ink-2, rgba(239, 233, 221, 0.78)); +} +.dot[data-on="1"] { + border-color: var(--g-led, #ff6a3d); + background: var(--g-led, #ff6a3d); + color: #150a06; +} +.dot:hover:not([data-on="1"]) { + border-color: var(--g-ink-3, rgba(239, 233, 221, 0.52)); + color: var(--g-ink, #efe9dd); +} +.dot:focus-visible { + outline: 2px solid var(--g-led, #ff6a3d); + outline-offset: 2px; +} + +/* The words of each frame share one cell, as the sentences under them do: + nothing in the caption moves when the frame changes. */ +.heads { + display: grid; + flex: 1; + min-width: 0; +} +.head { + grid-area: 1 / 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 13px; + line-height: 1.5; + font-weight: 600; + color: var(--g-ink, #efe9dd); + opacity: 0; + visibility: hidden; + transition: + opacity 0.25s ease, + visibility 0s linear 0.25s; +} +.head[data-on="1"] { + opacity: 1; + visibility: visible; + transition: + opacity 0.3s ease 0.12s, + visibility 0s; +} + +/* Every caption sits in the same grid cell, so the block is as tall as the + longest one and nothing below it jumps when the frame changes. */ +.texts { + display: grid; +} +.text { + text-wrap: pretty; + grid-area: 1 / 1; + margin: 0; + font-size: 13px; + line-height: 1.5; + color: var(--g-ink-2, rgba(239, 233, 221, 0.78)); + opacity: 0; + visibility: hidden; + transition: + opacity 0.25s ease, + visibility 0s linear 0.25s; +} +.text[data-on="1"] { + opacity: 1; + visibility: visible; + transition: + opacity 0.3s ease 0.12s, + visibility 0s; +} +/* A single frame: the screen's own words run in at the head of its sentence. */ +.text b { + margin-right: 0.5em; + font-weight: 600; + color: var(--g-ink, #efe9dd); +} + +/* ── mic · 4: an upright photo, its caption beside it ───────────────────── */ +.shots[data-beside] { + display: grid; + align-items: center; + column-gap: 16px; +} +.shots[data-beside="caption"] { + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1fr); +} +.shots[data-beside="caption"] .caption { + margin-top: 0; + padding-inline: 0; +} +@media (min-width: 900px) { + .shots[data-beside] { + padding-inline: 0 10px; + } +} +/* A card at its full width has the room to give the photo the larger half: + the caption still sets in lines long enough to read (a narrower card's, + or a phone's, would break inside "USB-C"). */ +@media (min-width: 1100px) { + .shots[data-beside="caption"] { + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); + } +} + +/* ── mic · 3: the photo a reader solders by ─────────────────────────────── + It takes the card's whole width, at any size, and hangs from its foot: + the pins the leads land on. Where the screen has the room the frame is + the whole capture, the breakout's row of pads at its top. Where it has + not, the frame is shorter and what goes is the top of the picture, never + its scale: the silkscreen by the pins is what has to be read. */ +.shots[data-foot] .frame { + width: 100%; +} +.shots[data-foot] .shot img { + object-position: 50% 100%; +} +/* A wide screen under 900 px tall, or one whose card is narrower than its + full 440 px (the words run to more lines): from under the breakout down + (--ar-short), and less of the leads still as the screen gets shorter. + The card holds under the header while its block passes (Guide.module.css, + Audio's dwell cards), so the frame may take what the rest of the card + leaves of the screen (--rest: AudioShots.tsx). */ +@media (min-width: 900px) and (max-height: 899px), (min-width: 900px) and (max-width: 1099px) { + .shots[data-foot] .frame { + aspect-ratio: var(--ar-short); + max-height: max(150px, calc(100vh - var(--rest, 570px))); + } +} +/* A phone: the rows of pins and the leads' ends (--ar-narrow). The card is + already most of the screen. */ +@media (max-width: 899px) { + .shots[data-foot] .frame { + aspect-ratio: var(--ar-narrow); + } +} + +/* The leads under it, pad to pin: the pads across the top, in the order + they stand on the breakout, and under each the pin it goes to. These are + names a reader solders by, digits and all: the body face, never the pixel + one. A place two leads share is one cell under both — five leads, four + places. */ +.leads { + width: 100%; + margin-top: 10px; + border-collapse: collapse; + font-family: var(--pf-sans, system-ui, sans-serif); + font-size: 14px; + line-height: 1.2; + font-variant-numeric: tabular-nums; + text-align: center; + color: var(--g-ink, #efe9dd); +} +.leads th, +.leads td { + padding: 0; + vertical-align: baseline; +} +/* What each row is: as wide as its word and no wider; the five leads share + what is left. */ +.leads th[scope="row"] { + width: 1%; + padding-right: 12px; + text-align: left; + white-space: nowrap; + font-size: 11.5px; + font-weight: 500; + color: var(--g-ink-3, rgba(239, 233, 221, 0.52)); +} +.leads th[scope="col"] { + width: 19.8%; + font-weight: 500; + color: var(--g-ink-2, rgba(239, 233, 221, 0.78)); +} +.leads tbody th, +.leads tbody td { + padding-top: 24px; +} +.leads tbody td { + position: relative; + font-weight: 600; +} +.leads tbody td::before { + content: "↓"; + position: absolute; + top: 9px; + left: 0; + right: 0; + font-size: 12px; + line-height: 1; + font-weight: 400; + color: var(--g-ink-3, rgba(239, 233, 221, 0.52)); +} +/* Two leads, one place: a bracket under the pair of pads, from the middle + of one to the middle of the other. */ +.leads tbody td[data-shared]::after { + content: ""; + position: absolute; + top: 3px; + left: 25%; + right: 25%; + height: 4px; + border: 1px solid var(--g-ink-3, rgba(239, 233, 221, 0.52)); + border-top: 0; + border-radius: 0 0 3px 3px; +} +/* On a wide screen the frame takes a little of the card's padding; the + table keeps to the column the caption and the card's words are in. */ +@media (min-width: 900px) { + .leads { + width: calc(100% - 20px); + margin-inline: 10px; + } +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* The short laptop screen again, after the rules it tightens. */ +@media (min-width: 900px) and (max-height: 879px) { + .caption { + margin-top: 9px; + } + .bar { + margin-bottom: 5px; + } +} +@media (max-width: 899px) { + .caption { + margin-top: 10px; + } + .text, + .head { + font-size: 12.5px; + line-height: 1.45; + } +} + +@media (prefers-reduced-motion: reduce) { + .shot, + .shot[data-on="1"], + .shots[data-look="window"] .shot, + .shots[data-look="window"] .shot[data-on="1"], + .text, + .text[data-on="1"], + .head, + .head[data-on="1"], + .dot { + transition: none; + } + .shots[data-look="window"] .shot { + transform: translate(-50%, -50%); + } + .ring { + animation: none; + } +} diff --git a/web/src/components/guide/AudioShots.tsx b/web/src/components/guide/AudioShots.tsx new file mode 100644 index 000000000..4242d2cd9 --- /dev/null +++ b/web/src/components/guide/AudioShots.tsx @@ -0,0 +1,715 @@ +"use client"; + +// The Audio guide's real screens and photos, a set a step (copy.ts +// AudioShotSet; a step's copy names one as extra: "audioShots:"): the +// extension's popup, the two microphone photos, the console's Audio and MIDI +// pages, rtpMIDI and Live. Nothing is redrawn. Every image in /guide/audio/ +// is a capture of the real thing or a photo that already existed: +// +// popup-* the extension's popup against a real board, cropped to its +// top (the corner badge, the address, the line under it) +// audio-in-* the console's /audio-in from the mock (console_serve.py, +// /mock?audio=fresh|music|nomic), with the pages an Audio +// board has, at a phone's width, 2x +// midi-* the console's /midi from the mock, its fixture set for the +// shot: the board at the guide's address (deviceSim SIM_IP), +// the computer at the page's own example, factory sensitivity +// rtpmidi-* docs/images/midi/rtpmidi-1..4, whole +// live-remote docs/images/midi/ableton-5-remote.png, cropped to the ports +// mic-* docs/images/audio-guide/pdm-mic-wiring.jpg (framed on the +// breakout; and from the breakout's row of pads down to the +// pins the leads land on, at the photo's own pixels) and the +// journal's photo of the wired module seated in a board +// +// The crops are tight on purpose: the cards these stand in are full, and a +// crop keeps the capture's own words at a size that can be read. If a page +// changes, re-shoot it rather than editing the image: the sizes and rings +// below (GEO) are what the capture script prints. +// +// A set with more than one frame is stepped the way FlasherShots is: on its +// own only while its step is on stage (useGuideStore: page "audio", the +// set's scene, step `step`) and the frame is on screen, never under the +// pointer or keyboard focus, and not at all for reduced motion. The bold +// words of a caption are the screen's own, as it writes them (English in +// both languages); the sentence is this file's, in both. + +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { AudioShotSet } from "./copy"; +import { useGuideStore, type GuideLang } from "./store"; +import styles from "./AudioShots.module.css"; + +/** Where a ring sits: [left, top, width, height] as fractions of the image. */ +type Ring = [number, number, number, number]; + +/** An image in /guide/audio/: its size in CSS px as captured, and the control it is about. */ +type Geo = { w: number; h: number; rings: Ring[] }; + +// The capture script's own numbers; re-run it, don't edit by hand. +const GEO = { + "popup-idle": { w: 460, h: 180, rings: [[0.0239, 0.5167, 0.9522, 0.2556]] }, + "popup-live": { w: 460, h: 180, rings: [[0.7761, 0.0611, 0.2, 0.2056]] }, + "popup-audoff": { w: 460, h: 220, rings: [[0.0239, 0.7364, 0.187, 0.1909]] }, + "popup-nosound": { w: 460, h: 180, rings: [[0.0239, 0.7611, 0.3283, 0.1389]] }, + "rtpmidi-1": { w: 612, h: 405, rings: [] }, + "rtpmidi-2": { w: 611, h: 403, rings: [] }, + "rtpmidi-3": { w: 611, h: 405, rings: [] }, + "rtpmidi-4": { w: 610, h: 403, rings: [] }, + "live-remote": { w: 491, h: 180, rings: [] }, + "mic-part": { w: 640, h: 480, rings: [] }, + "mic-wiring": { w: 1000, h: 805, rings: [] }, + "mic-seated": { w: 900, h: 1200, rings: [] }, + "audio-in-off": { w: 440, h: 289, rings: [[0.0273, 0.673, 0.3115, 0.1384]] }, + "audio-in-listening": { + w: 440, + h: 264, + rings: [ + [0.3433, 0.7557, 0.1909, 0.1136], + [0.5071, 0.4242, 0.4656, 0.0833], + ], + }, + "audio-in-nomic": { w: 440, h: 202, rings: [[0.3433, 0.5569, 0.2349, 0.1485]] }, + "audio-in-mapping": { w: 520, h: 282, rings: [] }, + "midi-knobs": { w: 520, h: 324, rings: [] }, + "midi-session": { w: 520, h: 297, rings: [[0.1504, 0.7555, 0.4094, 0.1347]] }, +} satisfies Record; + +type Shot = { + /** The frame's short name: its dot's label, and AUDIO_SHOT_FRAMES. */ + name: string; + /** web/public/guide/audio/.webp */ + file: keyof typeof GEO; + /** The screen's own words, quoted exactly (English in both languages). A photo has none. */ + title?: string; + en: string; + ko: string; + /** What the picture shows, for a reader who cannot see it. */ + alt: Record; + /** How long this frame stays up before the next one (ms). */ + ms?: number; +}; + +/** + * How a set is drawn. "crop" is a piece of a page: it fills the frame from + * the top, and the frame takes the page's own ground, so a shorter crop reads + * as more of the same page. "window" is a whole window at the card's width, + * a rim of dark desktop round it. "photo" fills its frame. + */ +type Look = "crop" | "window" | "photo"; + +type ShotSet = { + /** The chapter the set's step is in (scenes/audio.ts). */ + scene: "browser" | "mic" | "midi"; + look: Look; + /** A crop's ground: the colour of the page it was cut from. */ + ground?: string; + /** What stands beside the picture instead of under it: the caption. */ + beside?: "caption"; + /** What stands between the picture and its caption: the lead table. */ + under?: "leads"; + /** + * A photo that takes the card's whole width and hangs from its foot. On a + * screen with no room for all of it the frame keeps this many of the + * capture's rows, counted from the foot, and what goes is the top: `short` + * on a wide screen under 900 px tall (and fewer still where `rest` leaves + * fewer), `narrow` on a phone. + */ + foot?: { short: number; narrow: number }; + /** More of the screen's height than its look allows (LOOKS), for a set whose card has the room. */ + vh?: number; + /** + * For a set whose card holds under the header while its block passes + * (scenes/audio.ts dwell; Guide.module.css stops it 72 px down): what the + * rest of a short wide screen comes to, in px. The header, the card's + * words in English at their short-screen size, the set's own caption (and + * table) and a margin. The frame may take what that leaves of the screen + * where it is more than its look's share (a `foot` photo takes just that), + * so the card's foot stays on screen and the picture is as large as that + * allows. Measured, not derived: if the step's copy grows by a paragraph, + * measure again. + */ + rest?: number; + /** What the set is, for a screen reader. */ + label: Record; + shots: Shot[]; +}; + +// The console's and the popup's page colour; Live's settings pane. +const CREAM = "#f4efe6"; +const LIVE_GREY = "#4f4f4f"; + +const SETS: Record = { + // browser · 2 — the extension's popup before and after Start + popup: { + scene: "browser", + look: "crop", + ground: CREAM, + label: { en: "The extension's popup, before and after Start", ko: "확장 프로그램 팝업, Start 전과 후" }, + shots: [ + { + name: "Idle", + file: "popup-idle", + title: "Idle", + en: "Before Start. The address goes under Device.", + ko: "Start 전이에요. 주소는 Device 아래에 넣어요.", + alt: { + en: "The top of the extension's popup: the corner badge reads Idle, and patternflow.local is in the Device field beside a Start button.", + ko: "확장 프로그램 팝업의 윗부분. 구석 배지는 Idle, Device 칸엔 patternflow.local, 그 옆에 Start 버튼이 있다.", + }, + }, + { + name: "Live", + file: "popup-live", + title: "Live", + en: "Start is now Stop; the line under it names the tab.", + ko: "Start가 Stop이 되고, 그 아래 줄에 탭 이름이 나와요.", + alt: { + en: "The top of the extension's popup while capturing: the corner badge reads Live, the button reads Stop, and the line under the address begins with Capturing.", + ko: "캡처 중인 확장 프로그램 팝업의 윗부분. 구석 배지는 Live, 버튼은 Stop, 주소 아래 줄은 Capturing으로 시작한다.", + }, + }, + ], + }, + // browser · 3 — why it is not Live + popupStates: { + scene: "browser", + look: "crop", + ground: CREAM, + label: { en: "The extension's popup when the board is not moving", ko: "보드가 움직이지 않을 때의 확장 프로그램 팝업" }, + shots: [ + { + name: "Aud off", + file: "popup-audoff", + title: "Aud off", + en: "The board is ignoring the sound. Turn on brings it back.", + ko: "보드가 소리를 무시하는 중이에요. Turn on으로 다시 켜요.", + alt: { + en: "The top of the extension's popup: the corner badge reads Aud off, the line under the address says Audio-React is off, and a Turn on button sits under it.", + ko: "확장 프로그램 팝업의 윗부분. 구석 배지는 Aud off, 주소 아래 줄은 Audio-React가 꺼져 있다고 하고, 그 아래 Turn on 버튼이 있다.", + }, + }, + { + name: "No sound", + file: "popup-nosound", + title: "No sound from this tab yet.", + en: "Still Live: the tab has been silent for two seconds.", + ko: "여전히 Live예요. 탭이 2초 동안 조용했어요.", + alt: { + en: "The top of the extension's popup: the corner badge reads Live, and the line under the address reads No sound from this tab yet.", + ko: "확장 프로그램 팝업의 윗부분. 구석 배지는 Live, 주소 아래 줄은 No sound from this tab yet.", + }, + }, + ], + }, + // mic · 1 — the breakout (the wiring photo, framed on the part) + micPart: { + scene: "mic", + look: "photo", + label: { en: "The microphone breakout", ko: "마이크 브레이크아웃" }, + shots: [ + { + name: "The breakout", + file: "mic-part", + en: "The breakout's silkscreen: PDM Mic, and a pad each for 3V, GND, SEL, CLK and DAT.", + ko: "브레이크아웃의 실크 인쇄예요. PDM Mic, 그리고 3V, GND, SEL, CLK, DAT 패드가 하나씩.", + alt: { + en: "A small blue breakout board marked PDM Mic, with five soldered pads labelled 3V, GND, SEL, CLK and DAT and a black lead on each.", + ko: "PDM Mic이라고 적힌 작은 파란 브레이크아웃 보드. 3V, GND, SEL, CLK, DAT 패드 다섯 개에 검은 선이 하나씩 납땜돼 있다.", + }, + }, + ], + }, + // mic · 3 — the wiring photo at the card's width, the five-lead table under + // it. This is the picture a reader solders by, so it is drawn as large as + // the card allows and hangs from its foot, where the leads land: all of it + // (the breakout's row of pads, the leads, the pins) where the screen has the + // room; from under the breakout (the capture's last 655 rows) on a shorter + // one; the pin rows alone (its last 400) on a phone. + micWiring: { + scene: "mic", + look: "photo", + under: "leads", + foot: { short: 655, narrow: 360 }, + rest: 570, + label: { en: "The five leads, from the microphone to the ESP32", ko: "마이크에서 ESP32로 가는 선 다섯 가닥" }, + shots: [ + { + name: "Five leads, four places", + file: "mic-wiring", + en: "In the photo, 3V3 is the top pin on the left. GND, TX and RX are the top three on the right; GND holds two leads.", + ko: "사진에서 3V3은 왼쪽 줄 맨 위 핀이에요. GND, TX, RX는 오른쪽 줄 위에서 셋이고, GND엔 선이 둘 붙어요.", + alt: { + en: "Five black leads run from the microphone breakout's pads down to the ESP32 module's header pins: one to 3V3 at the top of the left pin row, two to GND, and one each to TX and RX at the top of the right row.", + ko: "마이크 브레이크아웃의 패드에서 ESP32 모듈의 헤더 핀으로 검은 선 다섯 가닥이 내려온다. 하나는 왼쪽 핀 줄 맨 위 3V3로, 둘은 GND로, TX와 RX로 하나씩 오른쪽 줄 위쪽에 닿는다.", + }, + }, + ], + }, + // mic · 4 — the wired module seated in a board + micSeated: { + scene: "mic", + look: "photo", + beside: "caption", + label: { en: "The wired ESP32 back in the board", ko: "선을 단 ESP32를 보드에 다시 꽂은 모습" }, + shots: [ + { + name: "Seated", + file: "mic-seated", + en: "The ESP32 back in its sockets, the microphone above it on its leads. The two USB-C ports at the bottom are the ESP32's own; UART is the right one.", + ko: "ESP32를 소켓에 다시 꽂았어요. 마이크는 선에 달린 채 그 위에 있어요. 아래 USB-C 포트 둘은 ESP32의 것이고, 오른쪽이 UART예요.", + alt: { + en: "The back of a Patternflow board in its case: the ESP32 module seated in its sockets, the microphone breakout above it on five short leads, and the module's two USB-C ports at the bottom, both empty.", + ko: "케이스 안 패턴플로우 보드의 뒷면. ESP32 모듈이 소켓에 꽂혀 있고, 그 위에 마이크 브레이크아웃이 짧은 선 다섯 가닥으로 달려 있다. 모듈 아래쪽 USB-C 포트 둘은 비어 있다.", + }, + }, + ], + }, + // mic · 5 — the console's Audio page: Microphone off, then listening + audioIn: { + scene: "mic", + look: "crop", + ground: CREAM, + // The step's words are few and the crop is tall (the header's two rows of + // pages are in it): on a short laptop screen it would be the smallest. + vh: 36, + label: { en: "The console's Audio page: the Microphone switch", ko: "콘솔 Audio 페이지의 Microphone 스위치" }, + shots: [ + { + name: "Off", + file: "audio-in-off", + title: "Microphone", + en: "Under Sources. Off, the chip beside it reads off.", + ko: "Sources 아래에 있어요. 꺼져 있으면 옆의 칩이 off예요.", + alt: { + en: "The console's Audio page: under Sources the Microphone switch is off, the chip beside it reads off, and Input gain is at 8.0.", + ko: "콘솔의 Audio 페이지. Sources 아래 Microphone 스위치가 꺼져 있고, 옆의 칩은 off, Input gain은 8.0이다.", + }, + }, + { + name: "Listening", + file: "audio-in-listening", + title: "listening", + en: "On, and the top of the page says who has the knobs.", + ko: "켜면, 페이지 위쪽 줄이 누가 노브를 쥐었는지 알려 줘요.", + alt: { + en: "The console's Audio page: the Microphone switch is on, the chip beside it reads listening, and the line at the top right reads the microphone has the knobs.", + ko: "콘솔의 Audio 페이지. Microphone 스위치가 켜져 있고, 옆의 칩은 listening, 오른쪽 위 줄은 the microphone has the knobs.", + }, + }, + ], + }, + // mic · 6 — the Mapping half of the Audio page: its plot + audioInMap: { + scene: "mic", + look: "crop", + ground: CREAM, + label: { en: "The console's Audio page: Mapping", ko: "콘솔 Audio 페이지의 Mapping" }, + shots: [ + { + name: "Mapping", + file: "audio-in-mapping", + title: "Mapping", + en: "K1 to K4, boxed on the spectrum the microphone hears.", + ko: "마이크가 듣는 스펙트럼 위에 K1부터 K4까지 상자가 놓여 있어요.", + alt: { + en: "The Mapping plot on the console's Audio page: a spectrum from 31 Hz to 8 kHz, level in dB, with four boxes on it, K1 to K4 from left to right.", + ko: "콘솔 Audio 페이지의 Mapping 그래프. 31 Hz부터 8 kHz까지, 세로는 dB인 스펙트럼 위에 왼쪽부터 K1–K4 상자 네 개가 있다.", + }, + }, + ], + }, + // mic · 7 — the chip reads "not detected" + audioInNoMic: { + scene: "mic", + look: "crop", + ground: CREAM, + label: { en: "The console's Audio page with no microphone found", ko: "마이크를 찾지 못한 콘솔 Audio 페이지" }, + shots: [ + { + name: "Not detected", + file: "audio-in-nomic", + title: "not detected", + en: "Under the slider, the page's own reason: the data pin (RX) is idle, check the DAT lead.", + ko: "슬라이더 아래에 페이지가 이유를 적어 줘요. 데이터 핀(RX)이 조용하니 DAT 선을 보라고요.", + alt: { + en: "The console's Audio page: the Microphone switch is on, the chip beside it reads not detected, and the line under the Input gain slider reads No microphone found: the data pin (RX) is idle. Check the DAT lead.", + ko: "콘솔의 Audio 페이지. Microphone 스위치는 켜져 있지만 옆의 칩은 not detected이고, Input gain 슬라이더 아래 줄은 No microphone found: the data pin (RX) is idle. Check the DAT lead.", + }, + }, + ], + }, + // midi · 1 — rtpMIDI: new session, enable, connect, connected + rtpmidi: { + scene: "midi", + look: "window", + rest: 598, + label: { en: "rtpMIDI, screen by screen", ko: "rtpMIDI 화면 순서" }, + shots: [ + { + name: "New session", + file: "rtpmidi-1", + title: "My Sessions", + en: "Press +. patternflow is already listed in Directory.", + ko: "+를 누르세요. Directory엔 patternflow가 이미 있어요.", + alt: { + en: "The rtpMIDI window: My Sessions is empty with a + button under it, and Directory lists patternflow.", + ko: "rtpMIDI 창. My Sessions는 비어 있고 아래에 + 버튼이 있다. Directory엔 patternflow가 있다.", + }, + }, + { + name: "Enabled", + file: "rtpmidi-2", + title: "Enabled", + en: "Tick it on the new session; leave the port at 5004.", + ko: "새 세션에서 체크하고, 포트는 5004 그대로 두세요.", + alt: { + en: "The rtpMIDI window: a session named after the computer is listed under My Sessions, the Enabled box is still empty, and the port reads 5004.", + ko: "rtpMIDI 창. My Sessions에 컴퓨터 이름으로 된 세션이 있고, Enabled 칸은 아직 비어 있으며, 포트는 5004다.", + }, + }, + { + name: "Connect", + file: "rtpmidi-3", + title: "Connect", + en: "Select patternflow in Directory and press Connect.", + ko: "Directory에서 patternflow를 고르고 Connect.", + alt: { + en: "The rtpMIDI window: the session is enabled, patternflow is selected in Directory, and the Connect button under it is marked.", + ko: "rtpMIDI 창. 세션이 Enabled 상태이고, Directory에서 patternflow가 선택돼 있으며, 그 아래 Connect 버튼에 표시가 있다.", + }, + }, + { + name: "Connected", + file: "rtpmidi-4", + title: "Participants", + en: "Patternflow is listed. The window can be closed now.", + ko: "Patternflow가 올라왔어요. 이제 창은 닫아도 돼요.", + alt: { + en: "The rtpMIDI window: Patternflow is listed under Participants with a latency of 0 ms.", + ko: "rtpMIDI 창. Participants에 Patternflow가 지연 시간 0 ms로 올라와 있다.", + }, + ms: 4200, + }, + ], + }, + // midi · 2 — Live's Link, Tempo & MIDI: Remote ticked on the session's input + liveRemote: { + scene: "midi", + look: "crop", + ground: LIVE_GREY, + label: { en: "Ableton Live's MIDI ports", ko: "Ableton Live의 MIDI 포트" }, + shots: [ + { + name: "Remote", + file: "live-remote", + title: "Input Ports · Remote", + en: "Ticked on the row named after your computer, in Live's Link, Tempo & MIDI settings.", + ko: "Live의 Link, Tempo & MIDI 설정에서, 내 컴퓨터 이름으로 된 줄에 체크돼 있어요.", + alt: { + en: "The MIDI ports in Live's Link, Tempo & MIDI settings: under Input Ports, the row named after the computer has its Remote box ticked; Track, Sync and MPE are empty.", + ko: "Live의 Link, Tempo & MIDI 설정에 있는 MIDI 포트. Input Ports에서 컴퓨터 이름으로 된 줄의 Remote 칸이 체크돼 있고 Track, Sync, MPE는 비어 있다.", + }, + }, + ], + }, + // midi · 4 — the console's MIDI page: Knobs → MIDI (the value mode, Sensitivity) + midiPage: { + scene: "midi", + look: "crop", + ground: CREAM, + label: { en: "The console's MIDI page: Knobs → MIDI", ko: "콘솔 MIDI 페이지의 Knobs → MIDI" }, + shots: [ + { + name: "Knobs → MIDI", + file: "midi-knobs", + title: "Knobs → MIDI", + en: "The value mode on top, Sensitivity under it.", + ko: "위는 값을 보내는 방식, 아래는 Sensitivity예요.", + alt: { + en: "The Knobs → MIDI section of the console's MIDI page: Knob value is selected beside Relative steps, and under them Sensitivity has a slider each for K1 to K4, all at 1 : 1, with move all four together ticked.", + ko: "콘솔 MIDI 페이지의 Knobs → MIDI 섹션. Relative steps 옆의 Knob value가 선택돼 있고, 아래 Sensitivity엔 K1–K4 슬라이더가 모두 1 : 1이며 move all four together가 체크돼 있다.", + }, + }, + ], + }, + // midi · 5 — the MIDI page's Session card: Use this computer + midiSession: { + scene: "midi", + look: "crop", + ground: CREAM, + label: { en: "The console's MIDI page: Session", ko: "콘솔 MIDI 페이지의 Session" }, + shots: [ + { + name: "Session", + file: "midi-session", + title: "Use this computer", + en: "Pressed: this computer's address is in Computer to invite, and the page says saved — inviting.", + ko: "누르면 이 컴퓨터의 주소가 Computer to invite에 들어가고 saved — inviting이 떠요.", + alt: { + en: "The Session section of the console's MIDI page: Connected to a computer, an address in the Computer to invite field, the buttons Save, Use this computer and Clear, and under them the words saved — inviting.", + ko: "콘솔 MIDI 페이지의 Session 섹션. 컴퓨터에 연결돼 있고, Computer to invite 칸에 주소가 있으며, Save, Use this computer, Clear 버튼 아래에 saved — inviting이 떠 있다.", + }, + }, + ], + }, +}; + +/** How many frames each set has, in the order shown. */ +export const AUDIO_SHOT_FRAMES = Object.fromEntries( + (Object.keys(SETS) as AudioShotSet[]).map((set) => [set, SETS[set].shots.map((s) => s.name)]), +) as Record; + +/** + * mic · 3's table: each lead from the microphone's pad to its place on the + * ESP32 module. Five leads, four places — GND and SEL share GND. + */ +export const MIC_LEADS: [string, string][] = [ + ["3V", "3V3"], + ["GND", "GND"], + ["SEL", "GND"], + ["CLK", "TX"], + ["DAT", "RX"], +]; + +const UI = { + en: { step: "Screen", leads: "Each lead, from the microphone's pad to the pin on the ESP32", from: "Microphone", to: "ESP32" }, + ko: { step: "화면", leads: "마이크 패드에서 ESP32 핀까지, 선 하나씩", from: "마이크", to: "ESP32" }, +} as const; + +const BASE = "/guide/audio/"; +const STEP_MS = 3600; +const LAST_HOLD_MS = 1200; + +// Per look: the space around a shot inside the frame (the capture's CSS px), +// and how much of the screen's height the frame may take, wide and narrow — +// on a phone the card sits under the stage and has to leave it room. `short` +// is what a wide screen under 880 px tall leaves of `vh`. +// +// A window is a whole program's worth of small type, so it gets the card's +// width: 31vh is what lets rtpMIDI's 612 px reach it on a 900 px screen, and +// the thin rim (pad) is all the desktop it stands on. On a short screen its +// card is the page's fullest, and the frame stays as tall as it was at 27vh. +const LOOKS: Record = { + crop: { pad: 0, vh: 30, vhNarrow: 27, short: 0.86 }, + window: { pad: 3, vh: 31, vhNarrow: 27, short: 0.75 }, + photo: { pad: 0, vh: 32, vhNarrow: 30, short: 0.86 }, +}; + +function subscribeReducedMotion(cb: () => void) { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + mq.addEventListener("change", cb); + return () => mq.removeEventListener("change", cb); +} +const getReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches; +// On the server nothing moves; the client decides after hydration. +const getReducedMotionServer = () => true; + +/** + * The lead table, lying under the photo: the microphone's pads across the + * top, in the order they stand on the breakout, and under each the pin it + * goes to. A place two leads share is one cell under both. + */ +function Leads({ lang }: { lang: GuideLang }) { + const ui = UI[lang]; + return ( + + + + + + {MIC_LEADS.map(([pad]) => ( + + ))} + + + + + + {MIC_LEADS.map(([pad, pin], i) => { + if (i > 0 && MIC_LEADS[i - 1][1] === pin) return null; + let span = 1; + while (MIC_LEADS[i + span]?.[1] === pin) span += 1; + return ( + + ); + })} + + +
{ui.leads}
{ui.from} + {pad} +
{ui.to} 1 ? span : undefined} data-shared={span > 1 ? "" : undefined}> + {pin} +
+ ); +} + +export default function AudioShots({ set, lang, step }: { set: AudioShotSet; lang: GuideLang; step: number }) { + const def = SETS[set]; + const { shots, scene } = def; + const n = shots.length; + const ui = UI[lang]; + const look = LOOKS[def.look]; + + const rootRef = useRef(null); + const [index, setIndex] = useState(0); + const [visible, setVisible] = useState(false); + const [hovered, setHovered] = useState(false); + const [focused, setFocused] = useState(false); + const reduced = useSyncExternalStore(subscribeReducedMotion, getReducedMotion, getReducedMotionServer); + + // This step on stage. Leaving it puts the set back to its first frame, so + // a reader who comes back sees it from the start. + const onStage = useGuideStore((s) => s.page === "audio" && s.scene === scene && s.step === step); + const [wasOnStage, setWasOnStage] = useState(onStage); + if (wasOnStage !== onStage) { + setWasOnStage(onStage); + if (!onStage) setIndex(0); + } + + const active = index % n; + const running = n > 1 && onStage && visible && !hovered && !focused && !reduced; + + useEffect(() => { + const el = rootRef.current; + if (!el || n < 2) return; + const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting && entry.intersectionRatio >= 0.35), { + threshold: [0, 0.35], + }); + io.observe(el); + return () => io.disconnect(); + }, [n]); + + // One timeout per frame, so a click restarts the clock on the new one. + useEffect(() => { + if (!running) return; + const ms = (shots[active].ms ?? STEP_MS) + (active === n - 1 ? LAST_HOLD_MS : 0); + const t = window.setTimeout(() => setIndex((i) => (i + 1) % n), ms); + return () => window.clearTimeout(t); + }, [running, active, n, shots]); + + const onBlur = useCallback((e: React.FocusEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); + }, []); + + // The frame is sized for the largest frame of the set, and every frame is + // drawn at the same scale: a short crop stays short, as it is on the page. + const unitW = Math.max(...shots.map((s) => GEO[s.file].w)) + look.pad * 2; + const unitH = Math.max(...shots.map((s) => GEO[s.file].h)) + look.pad * 2; + // A short wide screen: the look's share of its height, or what the rest of + // a held card leaves of it (`rest`), whichever is more. + const shortShare = `${((unitW / unitH) * (def.vh ?? look.vh) * look.short).toFixed(2)}vh`; + const capShort = def.rest ? `max(${shortShare}, calc((100vh - ${def.rest}px) * ${(unitW / unitH).toFixed(4)}))` : shortShare; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onFocus={() => setFocused(true)} + onBlur={onBlur} + > +
+ {shots.map((s, i) => { + const geo: Geo = GEO[s.file]; + const on = i === active; + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {on + {/* The control this frame is about. Remounted per frame: it + arrives just after the frame does. */} + {on && + geo.rings.map(([x, y, w, h], r) => ( +
+ ); + })} +
+ + {def.under === "leads" && } + +
+ {/* More than one frame: the dots, and beside them the words of the + frame that is up. */} + {n > 1 && ( +
+
+ {shots.map((s, i) => ( + + ))} +
+
+ {shots.map((s, i) => ( + + {s.title} + + ))} +
+
+ )} + {/* Every caption in one grid cell: the block is as tall as the longest. */} +
+ {shots.map((s, i) => ( +

+ {n === 1 && s.title && {s.title}} + {s[lang]} +

+ ))} +
+
+
+ ); +} diff --git a/web/src/components/guide/EditorWindow.module.css b/web/src/components/guide/EditorWindow.module.css new file mode 100644 index 000000000..b1f2ea105 --- /dev/null +++ b/web/src/components/guide/EditorWindow.module.css @@ -0,0 +1,389 @@ +/* The Audio guide's editor window (EditorWindow.tsx): a browser the guide's + colours, around the extension's own editor page. The shell follows + ConsoleWindow.module.css (desktop half) and is a copy on purpose — that + sheet is Play's. Tokens are local because the lifted window is portalled to + , outside the guide's .page where --g-* live. */ + +.root, +.layer { + --ew-ink: #efe9dd; + --ew-ink-2: rgba(239, 233, 221, 0.72); + --ew-ink-3: rgba(239, 233, 221, 0.46); + --ew-hair-2: rgba(239, 233, 221, 0.18); + --ew-led: #ff6a3d; + /* The editor's own ground (editor.css --cream), so a frame that has not + painted yet is already the right colour. */ + --ew-page: #f4efe6; + --ew-chrome: #1b1a18; + --ew-chrome-2: #131210; + --ew-field: #0f0e0d; + --ew-shadow: 0 22px 60px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.35); +} + +.root { + margin: 18px 0 4px; +} + +.caption { + margin-top: 10px; + font-size: 12.5px; + line-height: 1.45; + color: var(--ew-ink-3); +} + +/* ── the miniature: one button, the whole window ── */ + +.opener { + appearance: none; + display: block; + width: 100%; + margin: 0; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + border-radius: 12px; + transition: + opacity 0.22s ease, + transform 0.35s cubic-bezier(0.2, 0.7, 0.2, 1); +} +.opener:focus-visible { + outline: 2px solid var(--ew-led); + outline-offset: 4px; +} +/* It has left the card while it floats. */ +.opener[data-lifted="1"] { + opacity: 0; +} +@media (hover: hover) and (prefers-reduced-motion: no-preference) { + .opener:hover { + transform: translateY(-2px); + } +} + +.frame { + display: block; + border: 0; + background: var(--ew-page); + color-scheme: light; +} + +.mini { + position: relative; + display: block; + overflow: hidden; + background: var(--ew-page); +} +.mini .frame { + position: absolute; + left: 0; + top: 0; + transform-origin: 0 0; + pointer-events: none; +} +/* A phone, or a short screen: the card and the board both have to fit, so the + miniature shows the editor down to its plot. Lifted, it shows all of it. */ +@media (max-width: 899px), (max-height: 879px) { + .opener .mini { + aspect-ratio: 760 / 400 !important; + } +} + +/* ── tab strip, toolbar, page ── */ + +.window { + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--ew-hair-2); + border-radius: 11px; + background: var(--ew-chrome); + box-shadow: var(--ew-shadow); + transition: border-color 0.25s; +} +.opener:hover .window, +.opener:focus-visible .window { + border-color: rgba(255, 106, 61, 0.45); +} + +.bar { + display: block; + flex: none; + font-family: var(--pf-sans, system-ui, sans-serif); +} +.tabs { + display: flex; + align-items: flex-end; + height: 28px; + padding: 0 8px 0 10px; + background: var(--ew-chrome-2); +} +.lights { + display: flex; + gap: 5px; + align-self: center; + margin-right: 12px; +} +.lights i { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(239, 233, 221, 0.16); +} +.tab { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + max-width: 220px; + height: 22px; + padding: 0 14px 0 10px; + border-radius: 7px 7px 0 0; + background: var(--ew-chrome); + font-size: 11px; + color: var(--ew-ink-2); +} +/* The extension's mark: the square LED of its own source bar. */ +.favicon { + flex: none; + width: 7px; + height: 7px; + border-radius: 1px; + background: #e8552e; +} +.tabTitle { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.barEnd { + display: flex; + align-items: center; + align-self: center; + margin-left: auto; + padding-left: 8px; +} +.toolbar { + display: flex; + align-items: center; + gap: 10px; + height: 30px; + padding: 0 10px; + border-bottom: 1px solid rgba(0, 0, 0, 0.5); + background: var(--ew-chrome); +} +.navIcons { + display: flex; + gap: 6px; + color: var(--ew-ink-3); +} +.navIcons svg { + width: 13px; + height: 13px; +} +.navIcons svg:nth-child(2) { + opacity: 0.45; +} +.address { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + height: 21px; + padding: 0 10px; + border-radius: 999px; + background: var(--ew-field); + font-family: var(--pf-mono, ui-monospace, monospace); + font-size: 10.5px; + letter-spacing: 0.01em; + color: var(--ew-ink); +} +.url { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.scheme { + color: var(--ew-ink-3); +} + +.openChip { + display: inline-flex; + align-items: center; + gap: 4px; + height: 18px; + padding: 0 7px 0 8px; + border: 1px solid var(--ew-hair-2); + border-radius: 999px; + font-family: var(--pf-mono, ui-monospace, monospace); + font-size: 9.5px; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; + color: var(--ew-ink-2); + transition: + color 0.2s, + border-color 0.2s, + background 0.2s; +} +.openChip svg { + width: 10px; + height: 10px; +} +.opener:hover .openChip, +.opener:focus-visible .openChip { + color: var(--ew-led); + border-color: var(--ew-led); + background: rgba(255, 106, 61, 0.1); +} + +/* ── lifted ── */ + +/* The layer itself takes no pointer: the window does, and the guard, which is + the page that must not be touched while the window is up. On a wide screen + the guard is cut (its clip-path, EditorWindow.tsx) so the stage left of the + window is still under the pointer: the board there is to be played. */ +.layer { + position: fixed; + inset: 0; + z-index: 1000; + pointer-events: none; +} +.guard { + position: absolute; + inset: 0; + pointer-events: auto; +} +.backdrop { + position: absolute; + inset: 0; + /* The board and the readouts at its knobs, on the left of a wide stage, + stay in the light: the window is there to be used beside them. */ + background: linear-gradient(90deg, rgba(8, 7, 6, 0.05) 0%, rgba(8, 7, 6, 0.14) 48%, rgba(8, 7, 6, 0.8) 62%, rgba(8, 7, 6, 0.86) 100%); + opacity: 0; + transition: opacity 0.4s ease; +} +.layer[data-narrow="1"] .backdrop { + background: rgba(8, 7, 6, 0.84); +} +.layer[data-shown="1"] .backdrop { + opacity: 1; +} + +.lift { + position: absolute; + pointer-events: auto; + transform-origin: 0 0; + transition: + transform 0.44s cubic-bezier(0.22, 0.8, 0.2, 1), + opacity 0.26s ease; +} +.layer[data-shown="1"] .lift { + transition: + transform 0.44s cubic-bezier(0.22, 0.8, 0.2, 1), + opacity 0.18s ease; +} + +.big.window { + width: 100%; + height: 100%; + border-radius: 12px; + border-color: rgba(239, 233, 221, 0.22); + box-shadow: + 0 40px 120px rgba(0, 0, 0, 0.62), + 0 4px 16px rgba(0, 0, 0, 0.4); +} +.big .tabs { + height: 36px; + padding: 0 10px 0 14px; +} +.big .lights { + gap: 7px; + margin-right: 16px; +} +.big .lights i { + width: 11px; + height: 11px; +} +.big .tab { + gap: 8px; + max-width: 280px; + height: 28px; + padding: 0 18px 0 12px; + border-radius: 9px 9px 0 0; + font-size: 12.5px; +} +.big .favicon { + width: 8px; + height: 8px; +} +.big .toolbar { + height: 40px; + gap: 14px; + padding: 0 12px; +} +.big .navIcons { + gap: 10px; +} +.big .navIcons svg { + width: 16px; + height: 16px; +} +.big .address { + height: 28px; + padding: 0 14px; + font-size: 13px; +} +.viewport { + position: relative; + flex: 1; + min-height: 0; + background: var(--ew-page); +} +/* While a press that began on the stage is down (a knob being turned), the + frame takes no pointer: the stage hears the moves and the release. */ +.layer[data-away="1"] .frame { + pointer-events: none; +} + +.close { + appearance: none; + display: grid; + place-items: center; + width: 28px; + height: 28px; + margin: 0; + padding: 0; + border: 1px solid var(--ew-hair-2); + border-radius: 50%; + background: rgba(239, 233, 221, 0.04); + color: var(--ew-ink-2); + cursor: pointer; + transition: + color 0.2s, + border-color 0.2s, + background 0.2s; +} +.close svg { + width: 14px; + height: 14px; +} +.close:hover { + color: var(--ew-ink); + border-color: var(--ew-led); + background: rgba(255, 106, 61, 0.14); +} +.close:focus-visible { + outline: 2px solid var(--ew-led); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .lift, + .layer[data-shown="1"] .lift { + transition: opacity 0.18s ease; + } +} diff --git a/web/src/components/guide/EditorWindow.tsx b/web/src/components/guide/EditorWindow.tsx new file mode 100644 index 000000000..9e3fe498c --- /dev/null +++ b/web/src/components/guide/EditorWindow.tsx @@ -0,0 +1,448 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"; +import { createPortal } from "react-dom"; +import { getSim, useGuideStore, type GuideLang } from "./store"; +import styles from "./EditorWindow.module.css"; + +// Audio · A browser tab, "Each knob is a box on the spectrum." — the +// extension's real mapping editor (tools/patternflow-audio-extension), on its +// own demo source, in a browser window in the card; what it maps drives the +// board on the stage. +// +// The window holds public/guide/audio-editor/ (written by +// firmware/toolchain/editor_demo.py): the extension's editor files as they +// are, plus bridge.js, whose header comment documents this file's other end. +// Each tick the frame posts {type:"pf-audio", outputs:[v|null ×4]} — what the +// extension would send the board, 0…1 per knob, null for a muted band — and +// this hands them on: getSim().setLane("editor", knob, v). The board takes +// them only on the step that declares `lanes: "editor"` (scenes/audio.ts), so +// nothing here knows which step is on stage; a lane no longer written lets go +// by itself, as a real board's does. +// +// In the card the window is a live miniature and takes no input (the card +// scrolls with the story). A click lifts it: a full-size window over the +// page, beside the board, with a frame of its own, until Esc or the close +// button. Beside it the board is still the reader's: the card says to take a +// knob, so the stage left of the window takes the pointer as it does with no +// window up, and the rest of the page (the top bar, what shows round the +// window) does not. On a phone the window covers the screen, and a click +// beside it closes it too. The two frames are one editor: the mapping is kept +// per tab (bridge.js), so an edit made in the window is the miniature's too, +// and goes on driving the board after the window is closed — on a phone, +// where the window covers the board, that is when the reader sees it. +// +// The window's shell (bar, lift, placement, focus) follows ConsoleWindow's, +// and is a copy on purpose: that file is Play's console, wired to its own +// demo and load queue, and stays as it is. +// +// Used by a step whose copy has extra: "editorLive" (copy/audio.ts, browser +// step 4). Its words are this file's own, as LabWindow's are. The editor's +// own text is the extension's, and English on both pages. + +type Words = { label: string; open: string; close: string; caption: string }; + +export const EDITOR_WINDOW_WORDS: Record = { + en: { + label: "The mapping editor", + open: "Open", + close: "Close", + caption: "The editor on its own demo source. Opened from the extension, it shows what your tab is playing.", + }, + ko: { + label: "매핑 에디터", + open: "열기", + close: "닫기", + caption: "에디터가 자체 데모 신호로 돌고 있어요. 확장 프로그램에서 열면 내 탭에서 나는 소리가 보여요. 에디터 안의 글자는 영어 그대로예요.", + }, +}; + +const SRC = "/guide/audio-editor/index.html"; +// The tab and the address the extension's page has. The part between is the +// extension's id, which is each installation's own. +const TITLE = "Patternflow Audio — Mapping"; +const ADDRESS = ["chrome-extension://…", "/editor.html"]; +// The miniature's viewport in CSS pixels, scaled into the card: the editor +// down to its four knob tabs — the source bar, the toolbar and the plot. +const VIEW = { w: 760, h: 480 }; +const LIFT_MS = 440; + +type Msg = { type?: string; action?: string; outputs?: unknown }; + +// ── browser chrome ────────────────────────────────────────────────────────── + +function Bar({ title, end }: { title: string; end: ReactNode }) { + return ( + + + + +