From 4f8d8501a4e25944db5a789a3b2973c7f9201b96 Mon Sep 17 00:00:00 2001 From: engmung <122682380+engmung@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:07:14 +0900 Subject: [PATCH] firmware, tools: the audio path reworked - /audio-in as a console page, partial saves, an extension that holds and says what is happening Audio edition and the audio extension only; the firmware core, the console chrome and the other editions are untouched. /audio-in - The page is a console page: light by default, the console's parts, a Sources list that says who has the knobs (microphone listening, off, not fitted; extension or phone app), knobs named K1..K4, dB and Hz on the axes. - It reads the panel's mapping before it can write, and an edit posts only the field that changed, one request in flight. A failed read can no longer be followed by an edit that overwrites four bands. - The cursor, level line and readout show what the knob gets. - Levels ride the console's PF.poll lane instead of a private loop. - The builder only assembles (the adapter, CSS and bar are real files), strips comments, and --check gates the stamped page at 17.5 KB gzip: 17.2 KB now, 19.9 before. features/audio_in, features/audio - POST /api/audio-in applies a band's field when any of that band's fields is present; reset takes band=N; reset-all includes attack. - A microphone that is not available cannot drive the knobs; nan arguments are refused. The d= message and every documented field stay. tools/patternflow-audio-extension (0.2.0) - One mapping model (mapping.js) shared by popup, offscreen and editor. - The steady value is resent, so a silent or paused tab holds the knobs at rest instead of releasing the lanes after 500 ms. - Live means the panel is being driven: Audio-React off, a silent tab, a closed tab, a wrong address and a panel without the Audio edition each say so. New installs start in Auto range with the Smooth curve. - The editor works down to a half-screen window and a phone, Preview no longer covers what it demonstrates. console_serve.py answers the audio endpoints with switchable states (/mock?audio=music|silence|off|fresh|nomic|stalled|phone|ext|noread) and assembles /audio-in live. Checked: build.sh all, the footprint pins, the console and source checks; on a panel with the microphone (mapping kept across the update, one-field save, unchanged frame time) and with the real extension against it (hold through silence, Aud off, Stop). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/console-sync.yml | 11 +- AUDIO_GUIDE.md | 185 +- CHANGELOG.md | 6 + docs/audio-ws-spec.md | 83 +- docs/rest-api.md | 96 +- firmware/bundles/audio/features_local.h | 11 +- firmware/patternflow/console/README.md | 74 +- firmware/patternflow/console/_audio_in.css | 83 + .../patternflow/console/_audio_in_adapter.js | 450 ++ .../patternflow/console/_audio_in_bar.html | 58 + firmware/patternflow/console/audio-in.html | 2923 +++++---- firmware/patternflow/features/README.md | 6 +- .../patternflow/features/audio/audio_config.h | 14 +- .../features/audio/core_audio_ws.h | 40 +- .../features/audio/feature_audio.h | 11 +- .../features/audio_in/audio_in_index.h | 5211 ++++++++--------- .../features/audio_in/core_audio_fft.h | 38 +- .../features/audio_in/core_audio_in_http.h | 210 +- .../features/audio_in/core_audio_in_map.h | 130 +- .../features/audio_in/core_audio_pdm.h | 2 + .../features/audio_in/feature_audio_in.h | 55 +- firmware/toolchain/README.md | 2 +- firmware/toolchain/build_audio_in_page.py | 897 +-- firmware/toolchain/console_pages.py | 38 + firmware/toolchain/console_serve.py | 922 ++- tools/README.md | 6 +- tools/patternflow-audio-extension/README.md | 239 +- .../patternflow-audio-extension/background.js | 172 +- .../editor-adapter.js | 188 +- tools/patternflow-audio-extension/editor.css | 371 +- tools/patternflow-audio-extension/editor.html | 200 +- tools/patternflow-audio-extension/editor.js | 857 +-- .../patternflow-audio-extension/manifest.json | 4 +- tools/patternflow-audio-extension/mapping.js | 236 + .../offscreen.html | 1 + .../patternflow-audio-extension/offscreen.js | 715 ++- tools/patternflow-audio-extension/popup.css | 542 +- tools/patternflow-audio-extension/popup.html | 46 +- tools/patternflow-audio-extension/popup.js | 480 +- 39 files changed, 8871 insertions(+), 6742 deletions(-) create mode 100644 firmware/patternflow/console/_audio_in.css create mode 100644 firmware/patternflow/console/_audio_in_adapter.js create mode 100644 firmware/patternflow/console/_audio_in_bar.html create mode 100644 tools/patternflow-audio-extension/mapping.js diff --git a/.github/workflows/console-sync.yml b/.github/workflows/console-sync.yml index 68da1dcc5..c71f48789 100644 --- a/.github/workflows/console-sync.yml +++ b/.github/workflows/console-sync.yml @@ -49,8 +49,11 @@ jobs: python-version: "3.x" - name: Headers match console/*.html run: python firmware/toolchain/console_pages.py check - # audio-in.html is itself generated, from the browser extension's mapping - # editor. The step above proves the header matches the page; this proves - # the page matches the editor it was assembled from. - - name: audio-in.html matches the extension's editor + # audio-in.html is itself generated: from the browser extension's mapping + # editor and the console/_audio_in* files. The step above proves the + # header matches the page; this proves the page is what those sources + # assemble to, and that it is inside its size budget (BUDGET in the + # script: the stamped page, gzip -9, as the panel sends it). The step + # above reports both too; this one keeps them a line of their own. + - name: audio-in.html matches its sources and is inside its size budget run: python firmware/toolchain/build_audio_in_page.py --check diff --git a/AUDIO_GUIDE.md b/AUDIO_GUIDE.md index f0448d779..7893e9270 100644 --- a/AUDIO_GUIDE.md +++ b/AUDIO_GUIDE.md @@ -41,24 +41,42 @@ locally, and streams four knob lanes to the panel over your LAN. **Install:** it's not on the Web Store — load it straight from this repo. `chrome://extensions` → enable *Developer mode* → *Load unpacked* → pick [`tools/patternflow-audio-extension/`](tools/patternflow-audio-extension/). -(Details and screenshots in its [README](tools/patternflow-audio-extension/README.md).) - -**Use:** open a tab with sound → click the extension → **Start** (it captures -the active tab) → enter your panel's address (`patternflow.local` or its IP) -→ the knobs move with the music. (Make sure **Audio-React (AUD)** is turned on -at the top of the console's **Audio** page or via the panel's NETWORK screen.) +After pulling a newer copy, press *Reload* on its card there. Reload keeps the +mapping you already had, so the starting point described below is a new +install's. (Every control is described in its +[README](tools/patternflow-audio-extension/README.md), and so is how to get +that starting point on an older mapping.) + +**Use:** open a tab with sound → click the extension → enter your panel's +address (`patternflow.local` or its IP) → **Start** (it captures the active +tab). The corner of the popup says **Live** once the panel is being driven, +and the knobs move with the music. If it says **Aud off**, the panel's +Audio-React switch is off and the panel is ignoring what arrives: press +**Turn on** in the popup. (The same switch is the `AUD` row of the panel's +NETWORK screen and the *Browser extension and phone app* row on the console's +**Audio** page.) If it says **Error**, the line under the address says what +is wrong: no panel at that address, or a panel that does not run the Audio +edition. + +While the tab is silent or paused the extension holds the knobs, each where +its band rests, until you press **Stop**. A knob you turn by hand during a +pause goes back there about five seconds after you let go. **Then open the editor** (*Mapping editor ↗* in the popup) — this is where it gets -good. Each of the four knobs is a **box drawn on the live spectrum**: the -box's width is the frequencies it listens to, its height the loudness window -it maps. Drag a box over the bass and knob 1 becomes a bass knob. Inside -each box a **response curve** (presets or a hand-dragged bezier) shapes how -it moves, an **output range** decides how far, and **attack/damping** set -the ballistics — fast rise, slow fall. Boxes start in **auto**, breathing -with the music; grab an edge to take manual control. A **preview** popup -shows how a test signal would ride your settings before you commit. - -Boxes map to knobs 1:1 and that's fixed on purpose — box 2 *is* knob 2. +good. Each of the four knobs, **K1** to **K4**, is a **box drawn on the live +spectrum**: the box's width is the frequencies it listens to, its height the +loudness window it maps. Drag K1's box over the bass and knob 1 becomes a +bass knob. Pick a knob with the tabs under the plot, and its card has a +**response curve** (presets, or a bezier you drag by its two handles) that +shapes how it moves and an **output range** that decides how far; **Attack** +and **Damping**, above the plot, set the ballistics for all four — fast rise, +slow fall. A new install starts in **Auto range** with the Smooth curve: the +boxes follow the loudness of the music. Drag a box's top or bottom edge to +take the windows over by hand; switching Auto range off goes back to the +windows you set. **Preview** puts a scope where the plot is and shows how a +test signal would ride the selected knob's settings while you change them. + +Boxes map to knobs 1:1 and that's fixed on purpose — K2 *is* knob 2. ## The on-board microphone @@ -125,22 +143,37 @@ Stick the mic wherever sound reaches it. Done. ### Turn it on -Console → **Audio** page (`/audio-in`) → flip **Microphone** on. (**Audio-React (AUD)** beside it is the other input, the extension and the phone app.) That's the -whole switch: on means listening and driving the knobs, off releases the -hardware completely. The **gain** slider (1–16, default 8) is there if your -room runs quiet — PDM mics on this chip are famously low-amplitude, and gain -is the official answer. - -The same box editor from the extension lives on this page, dark-themed — -same boxes, same curves. Mic settings are stored **on the panel** and -survive reboots and firmware updates; *Reset all* puts everything back. +Console → **Audio** page (`/audio-in`) → under **Sources**, flip +**Microphone** on. That's the whole switch: on means listening and driving +the knobs, off releases the hardware completely. The chip beside it reads +`listening` once samples are arriving, and the line at the top of the page +says who has the knobs. The **Input gain** slider (1–16, default 8) is there +if your room runs quiet — PDM mics on this chip are famously low-amplitude, +and gain is the official answer. (The row under it, *Browser extension and +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 +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 +**on the panel** and survive reboots and firmware updates. *Reset K1* puts the +selected knob back to the firmware's default. *Reset mapping* asks to be +pressed twice and then resets all four knobs, their curves, attack, damping +and input gain; it leaves the Microphone switch and Auto range as they are. ### If something's off - **Silence, but everything looks healthy** → SEL isn't grounded. This is - the classic one. -- **Mic not detected** → the firmware notices an absent/miswired mic and - falls back to a synthetic test source, and says so on `/api/status`. + the classic one, and nothing can detect it: a mic with SEL tied high reads + as a quiet room. +- **The chip says `not detected`** → the firmware sees nothing on the data + pin: no microphone, or no DAT lead (DAT goes to RX). Nothing is driven. +- **The chip says `not answering`** → the microphone was there and has + stopped returning samples, or its driver did not start. The knobs are left + alone in this state too. `source` in `GET /api/audio-in` and in + `/api/status` has the firmware's own word for each of these. - **The top band barely moves at normal volume** → physics, not a fault: at room levels the highest octaves carry almost no energy. The auto range is tuned around this. @@ -203,9 +236,17 @@ 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. +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 +extension's editor to give the microphone just that one. The phone app does +not hold yet: half a second into a pause its knobs go back to the encoders, +or to the microphone if that is on. + One thing to know: the **extension keeps its mapping in the browser**, while the **mic and the phone app share the config stored on the panel**. Same -editor everywhere, two homes for the settings. +editor everywhere, two homes for the settings. The top of the console's Audio +page says which source has the knobs right now. --- @@ -223,8 +264,8 @@ You were pointed here to work on Patternflow's audio path. The map: | `feature_audio_in.h` | Descriptor, the sampling/analysis task, where levels enter the knob pipeline. | | `core_audio_pdm.h` | PDM mic driver (GPIO 43/44). Its header comment is the authoritative wiring + driver-choice record. | | `core_audio_fft.h` | FFT and spectrum buckets. | -| `core_audio_in_map.h` | Bands, curve LUTs (33-point, interpolate-only), attack/damping glide, auto-range, NVS persistence. | -| `core_audio_in_http.h` | `/audio-in` (editor page), `GET/POST /api/audio-in` (whole config), `POST /api/audio-in/reset`, the `?levels=1` live poll, and the frame endpoint the phone posts spectra to. | +| `core_audio_in_map.h` | Bands, curve LUTs (33-point, interpolate-only), attack/damping glide, auto-range, the defaults a reset restores, NVS persistence. Manual mode maps linearly in amplitude between a band's two edges; the page's dB axis is a conversion at the page's edge. | +| `core_audio_in_http.h` | `/audio-in` (editor page), `GET /api/audio-in` (the configuration, read once), `POST /api/audio-in` (any subset: a request changes only the fields it carries), `POST /api/audio-in/reset` (everything, or one band with `band=N`), the `?levels=1` live poll, and the frame endpoint the phone posts spectra to. The contract is [`docs/rest-api.md`](docs/rest-api.md#microphone-audio-edition); the phone app is a second client of it, so fields are added, never renamed. | | `audio_in_index.h` | **Generated** — never edit (chain below). | **Firmware — MIDI** ([`firmware/patternflow/features/midi/`](firmware/patternflow/features/midi/)): @@ -237,37 +278,83 @@ AppleMIDI library. Contract: [`docs/midi-spec.md`](docs/midi-spec.md). ([`firmware/patternflow/features/audio/`](firmware/patternflow/features/audio/)): `core_audio_ws.h` is a WebSocket server on port 81. The wire contract is [`docs/audio-ws-spec.md`](docs/audio-ws-spec.md) — clients build against the -spec, not the source. Probe `GET /api/status` for `"audio"` in `caps`. +spec, not the source. Probe `GET /api/status` for `"audio"` in `caps`. A lane +is released 500 ms after the last message that set it, so a sender that means +to hold a value resends it; the spec says which senders in this tree do. +`feature_audio.h` has the Audio-React switch and its two routes +(`GET`/`POST /api/audio`). -**The editor is authored once and baked down.** Edit at the top, regenerate -downward; CI (`console-sync.yml`) fails on drift: +**The editor is authored once and assembled for the panel.** The panel's +`/audio-in` page is the extension's editor inside the panel's own frame, and +one command builds it; CI (`console-sync.yml`) fails when the generated files +are not what the sources assemble to, or the page is over its size budget: ``` -tools/patternflow-audio-extension/editor.{js,html,css} ← edit here - → python firmware/toolchain/build_audio_in_page.py (dark theme + device adapter) - → firmware/patternflow/console/audio-in.html - → python firmware/toolchain/console_pages.py build - → firmware/patternflow/features/audio_in/audio_in_index.h +tools/patternflow-audio-extension/ + editor.html the editor's body (between its EDITOR BODY lines) + editor.css, mapping.js down to their EXTENSION ONLY line + editor.js all of it +firmware/patternflow/console/ + _audio_in_bar.html the page around the editor: heading, Sources, footer + _audio_in.css the console's look + _audio_in_adapter.js window.PFAdapter over /api/audio-in + ↓ python firmware/toolchain/console_pages.py build +firmware/patternflow/console/audio-in.html generated +firmware/patternflow/features/audio_in/audio_in_index.h generated ``` +Preview without building: `python firmware/toolchain/console_serve.py` +assembles `/audio-in` from those sources on every request and answers its API +the way the firmware does; `/mock?audio=music`, `silence`, `off`, `fresh`, +`nomic`, `stalled`, `phone`, `ext` and `noread` pick what the panel hears +([`console/README.md`](firmware/patternflow/console/README.md#the-page-that-is-assembled-audio-in)). +The page has a budget, `BUDGET` in `build_audio_in_page.py`: 17,500 bytes +gzipped as the panel sends it. It is the heaviest page in the console and +every byte crosses the panel's Wi-Fi, so whole-line comments and indentation +are dropped as it is assembled and what only the extension needs stays below +the `EXTENSION ONLY` lines. + +The seam between the editor and whatever feeds it is `window.PFAdapter`. The +contract is the comment at the top of `editor.js`: the source owns its +limits, its level axis, its defaults and where a level sits in a window, and +the editor draws what it is told. The page's adapter follows the console's +rules for a one-connection server (`console/README.md`, "Writing a page"): +every request rides `PF.poll`/`PF.status`, nothing is saved until the +panel's mapping has been read, and a save sends only the fields that changed. + **Chrome extension** ([`tools/patternflow-audio-extension/`](tools/patternflow-audio-extension/)): -`editor.js` is the single shared editor module (adapter-isolated — -`editor-adapter.js` binds chrome storage/frames, and includes a synthetic -demo mode when opened outside the extension). `offscreen.js` does FFT → -band mapping → glide → WS send. `popup.*` is the capture console; -`background.js` owns capture lifecycle. Extension config lives in -`chrome.storage`, not on the panel. +`mapping.js` is the mapping model, one object (`PFMap`): defaults, the +curves, level → knob. `editor.js` is the single shared editor module, and +`editor-adapter.js` is its extension adapter (chrome storage, the capture's +frames), the editor's source bar, and a synthetic demo source when the page +is opened outside the extension. `offscreen.js` holds the capture: FFT → +`PFMap` → glide → WS send, the resend that holds a steady value, and the +state every page prints (`phase`). `popup.*` is the capture console and owns +the panel's address; `background.js` relays between the pages and the +offscreen document. The mapping lives in `chrome.storage` +(`patternflowAudioConfig`; the address under `patternflowAudioHost`), not on +the panel. The extension keeps its cream skin; only the panel's page wears +the console's. **Android app** ([`tools/patternflow-audio-android/`](tools/patternflow-audio-android/)): -`Analyzer.kt` mirrors `offscreen.js`'s math; `DeviceLink.kt` speaks the WS +`Analyzer.kt` mirrors the extension's analysis and mapping (`offscreen.js` +and `mapping.js`); `DeviceLink.kt` speaks the WS contract + syncs panel config over `/api/audio-in` + posts monitor frames; `CaptureService.kt` is the playback-capture foreground service; `EditorActivity.kt` is a WebView onto the panel's `/audio-in`. Build recipe -in its README. +in its README. Three things it carries by hand and that a change elsewhere +can leave behind: it sends lanes only when they change, so it does not hold a +steady value (the spec has the fix); it has its own copy of the three bezier +presets, by the names the page stores (`p:smooth`, `p:sharp`, `p:fall`); and +its WebView is set up without the part that shows a `window.confirm` dialog, +which is why the page's Reset mapping is a button pressed twice. Open the +app's editor once before releasing a change to `/audio-in`. Rules of the road: firmware changes follow [FEATURE_GUIDE.md](FEATURE_GUIDE.md) (hooks, checkers, `build.sh all`); protocol changes update the spec in the -same PR; editor changes regenerate the chain above and keep the extension -and console page identical — they are the same file, which is the point. +same PR; API changes extend [`docs/rest-api.md`](docs/rest-api.md) and never +rename what is there; editor changes run `console_pages.py build` so the +panel's page follows — the editor's body is one file for both surfaces, which +is the point. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f32312c4..b6c59addb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ All notable changes to Patternflow will be documented in this file, newest first - **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. +- **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. + +### 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. ## [3.10.5] - 2026-10-02 diff --git a/docs/audio-ws-spec.md b/docs/audio-ws-spec.md index f7f332e05..e0cc5b80c 100644 --- a/docs/audio-ws-spec.md +++ b/docs/audio-ws-spec.md @@ -60,11 +60,90 @@ was learned: after the first send the buffer is never empty, lanes 1..3 dropped in index order, and knob 4 never moved. One `a=` per frame carries everything, in order, at a quarter of the traffic. -Send at your analysis rate (the extension sends per animation frame). There -is no keep-alive requirement. +Send at your analysis rate. The extension analyses on a 33 ms timer, so thirty +messages a second is its ceiling and not its rate: a frame equal to the last +one is not sent again at once, and one is dropped while another is in flight. +Measured against a loopback socket, it sends 23–26 a second on music and 5 a +second while nothing changes (the resend described next). + +## Holding a value — the 500 ms release + +**A lane is handed back to its encoder 500 ms after the last message that +set it.** That is what frees the knobs when a tab is closed or a phone walks +out of range without saying `off`, and it has been in the firmware since the +first version of this protocol. Version 1 of this document said there was no +keep-alive requirement, and that was wrong: a client that skips a frame +because its values did not change loses its lanes half a second into any +steady passage — silence, a paused track, a gate curve resting at one level — +and the next thing it sends starts the pattern's motion from wherever the +encoder had left the knob. + +So a client that means to keep driving **resends its last message at least +every 250 ms**, changed or not. The same goes for `k=`. To let go, stop +sending, or say `off`. + +### What the senders in this tree do + +**The extension holds.** A frame that has not changed goes out again once +180 ms have passed since the last one, which on its 33 ms timer comes to about +210 ms (measured: five messages a second, no gap longer than 223 ms). While +its tab is silent or paused the knobs therefore rest where each band rests, +and stay there until Stop; the sliders of the popup's *Test without audio* are +held the same way. Two things follow from holding. A knob turned by hand +during a pause goes back to its resting value when the five-second hold ends. +And on a panel whose microphone is also switched on, the microphone does not +get those lanes during the pause: it only drives a lane nobody else has. + +A band muted in the extension's editor is a `-` in its slot, and the lane it +had been driving is handed back at once with `off=N` instead of being left to +the timeout; with all four muted it sends four `off=N` and then nothing. Stop +sends `off` and closes the socket. A socket that reopens is told everything +again in its first frame. Everything the extension sets, it sets with `a=`, +the test sliders included (`a=0.800,-,-,-`, a `-` for each knob nobody has +moved). Nothing in this tree sends `k=` or `d=` any more; both stay in the +table above and the firmware takes both, for earlier builds of the extension +and for clients outside this tree. + +**The Android capture app does not hold yet.** +`tools/patternflow-audio-android` still has the change-only skip the +extension had (`sendLanes()` in `DeviceLink.kt`: `if (body == lastBody) +return`). Its lanes are released 500 ms into a pause or a steady passage, and +the next frame it sends starts from wherever the encoders, or the microphone, +left the knobs. For whoever maintains the app: keep the time of the last send +beside `lastBody` and send the same body again when 250 ms have passed. It +already forgets `lastBody` when the socket opens. This was outside the change +that corrected this document, and it is the thing to fix before filming a +track with a quiet passage in it. + +## The switch on the panel + +The device accepts the connection, and every message, whether or not +Audio-React is switched on (the `AUD` row of the panel's NETWORK screen; +`audioRuntime` in `GET /api/audio` and `/api/status`). Switched off, the +messages are parsed and nothing is driven — an open socket is not evidence +that the panel is listening. A client that wants to know reads +`audioRuntime` over HTTP, on port 80 of the same host whatever port its +socket uses ([`rest-api.md`](rest-api.md#audio-react-audio-edition)). + +The extension asks once each time its socket opens (`GET /api/audio`, four +seconds to answer), and once more each time its popup is opened while the +socket is open. `false` is shown as *Aud off* with a **Turn on** button, which is +`POST /api/audio` with `on=1`; no answer, or a `404` from firmware without the +route, is "not known" and nothing is claimed either way. It does not poll: +the panel serves one HTTP connection at a time. ## Version history +- **1.1** (unreleased) — no change on the wire, and nothing for the firmware + to do: old and new firmware behave the same. Written down: a lane is + released 500 ms after its last message, so a steady value has to be resent + (version 1 said no keep-alive was needed); the extension's timer is 33 ms, + not the animation frame, and thirty a second is its ceiling; the socket + accepts messages while Audio-React is switched off and ignores them. The + extension (0.2.0) now keeps to it: it resends an unchanged frame, hands a + muted band's lane back with `off=N`, sends its test sliders as `a=`, and + asks the panel over HTTP whether it is listening. The Android app does not + resend yet. - **1** — first written contract: `a=` / `k=` / `d=` / `off=N` / `off`, lane semantics, the unknown-prefix rule, the one-in-flight pacing rule. Matches firmware 3.8.0 (Audio edition v0.3.1 onward) and the extension as shipped in diff --git a/docs/rest-api.md b/docs/rest-api.md index 05a4ff889..f1bb5439c 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -385,14 +385,91 @@ Scale constants live in the same file and are the contract for anything converti Gated on `"audio-in"` in `caps`; the page is `/audio-in`. Settings persist in NVS. The live shaping UI polls the same endpoints documented here — there is -nothing it can do that a script cannot. +nothing it can do that a script cannot. The phone capture app is a second +client of them: it reads the configuration, and posts what it hears so the +page has something to draw on a panel with no microphone. + +Every level here — `inMin`, `inMax`, `levels`, `env`, `spectrum` — is **linear +amplitude**, not decibels, and not clamped: a loud pure tone reads well above +1. The page draws a dB axis and converts at its own edge. | Endpoint | Meaning | |---|---| -| `GET /api/audio-in` | Full state: `micOn`, `micGain` (input gain, 1..16, applied to mic samples before analysis - raw fields stay unscaled), `autoRange` (each band self-normalizes against its tracked floor/peak envelope; on by default), `source` (`"off"`, `"pdm"`, `"pdm (no mic - data pin idle)"`, `"synth"`), raw `rawPeak`/`rawDc`, per-band config (`hzMin`/`hzMax`/`inMin`/`inMax`/`gain`/`outMin`/`outMax`/`knob`/`muted`), `hzRange`, and a 40-bucket log `spec`. | -| `GET /api/audio-in?levels=1` | The polling shape: `levels`, `levelsN` (the level normalized inside the band's tracked range - what the mapping consumes in auto mode), `outputs`, `spectrum`, `dropped` and nothing configurable — the page owns the config after reading it once. | -| `POST /api/audio-in` | `mic=0/1` switches the whole feature (installs/releases the I2S driver). `micGain=1..16` sets the input gain; `auto=0/1` switches per-band auto-ranging. `band=N` plus any subset of the band fields updates one band; partial updates are the point, so a dragged handle cannot clobber the other three. | -| `POST /api/audio-in/reset` | Back to the measured defaults. | +| `GET /api/audio-in` | The configuration, read once: `source` (below), `micOn`, `micGain` (input gain, 1..16, applied to mic samples before analysis - raw fields stay unscaled), `autoRange` (each band self-normalizes against its tracked floor/peak envelope; on by default), `smoothing` and `attack` (the two glide speeds, 0.05..0.9: falls and rises), `bands` (four, below), `hzRange` (`[31.25,8000.00]`, the bounds a band is held to; two decimals since 1.5). Also carried, for scripts and for diagnosing a microphone: `rawPeak`/`rawDc` (the last window before gain), `windows` (read since boot), `level` and `out` (four raw band levels and what they map to) and `spec`, a 64-bucket log spectrum. | +| `GET /api/audio-in?levels=1` | The polling shape, no configuration in it: `source`, `ext`, `rawPeak`, `rawDc`, `dropped`, `levels` (four damped band levels), `levelsN` (the level normalized inside the band's tracked range - what the mapping consumes in auto mode; 0 while that band's gate is closed), `outputs` (what each band maps to, 0..1), `env` (four `{lo,hi}`, the tracked range — present only in auto mode or with `ext`), and `spectrum` (64 log-spaced buckets across `hzRange`). A poll counts as someone watching for 2.5 s, which the phone app reads to decide how often to post; add `idle=1` to a poll that should not count. | +| `POST /api/audio-in` | Any subset of the settings; a request changes only the fields it carries, and answers `{"ok":true}`. `mic=0/1` switches the whole feature (installs/releases the I2S driver). `micGain=1..16`, `auto=0/1`, `smoothing` and `attack` (0.05..0.9). Band fields in either spelling below. `frame=…` is the phone's monitor frame (76 numbers: 4 levels; 4 × lo,hi; 64 buckets): nothing else in that request is read, nothing is saved, and the answer adds `watch` — `{"ok":true,"watch":true}` while a page is watching, `false` when none is. | +| `POST /api/audio-in/reset` | The firmware's measured defaults. With no argument: all four bands and their curves, `smoothing`, `attack` (since 1.5) and `micGain` — not the microphone switch, not `autoRange`. With `band=N` (0..3, since 1.5): that band and its curve only, and the answer names it, `{"ok":true,"band":N}`. Any other `band` is `400` and resets nothing. Firmware before 1.5 ignores `band` and resets everything; the missing `band` in its answer is how to tell. | + +**Band fields.** Each of `bands[0..3]` has `hzMin`/`hzMax` (what it listens +to), `inMin`/`inMax` (the level window mapped onto the knob in manual mode), +`gain` (the response exponent, 0.2..4, used while the band has no curve +table), `outMin`/`outMax` (where the knob rests and peaks, 0..1 each; +`outMax` below `outMin` is a knob that falls as it gets louder), `knob` +(0..3; two bands may name one knob and the lower band wins it), `muted`, +`meta` (up to 31 bytes the editor stores to describe its curve; echoed +verbatim, never parsed) and `lutSet` (read-only: the band runs a stored curve +table instead of `gain`). + +To write them there are two spellings, and both take any subset: + +- `band=N` plus the bare names (`band=2&inMax=0.5`) — one band per request. + A `band` outside 0..3 is `400 {"ok":false,"error":"band must be 0-3"}` and + nothing in the request is applied. +- the names with the band appended (`inMax2=0.5&hzMin0=62&hzMax0=375`) — any + number of bands per request. Since 1.5 a band is applied when **any** of + its fields is present; before, only a request carrying that band's `hzMin`, + `lut`, `knob`, `muted` or `outMin` was, and anything else was answered + `{"ok":true}` and ignored. + +`lut` (write-only) is the curve table: exactly 33 integers 0..255, commas +only. An empty `lut` clears the table and the band goes back to `gain`; a +value that is not exactly 33 integers is ignored and the answer is still +`{"ok":true}`. The firmware does not tie `meta` to `lut` — a client that +names a curve sends its table in the same request. + +Two pairs are settled together after a request's fields are in, whichever +half arrived: `hzMin`/`hzMax` are held to `hzRange`, put in order and kept at +least one analysis bin (31.25 Hz) apart; `inMin`/`inMax` are held to 0..1 with +`inMax` at least 0.01 above `inMin`. One half sent alone can therefore move +the other — send both halves of a pair when either changes. The bin's width is +kept only where the range has room for it: a low edge within 31.25 Hz of the +top is stored as sent, and from about 7984 Hz up the band covers no analysis +bin, so it reads 0 and its knob rests at `outMin`. + +A value that is not a finite number — `nan`, `inf`, one too large for a +32-bit float — is ignored: the field keeps what it had, and the answer is +still `{"ok":true}`. A `frame` with one in it is dropped whole, like a frame +of the wrong length. Before 1.5 such a value was stored and printed back as +`nan`, which is not JSON: the configuration could not be parsed again until +the field was overwritten or reset. One that is already stored is replaced at +boot — a band holding one goes back to its defaults (its curve is kept), and +so do `micGain`, `smoothing` and `attack`. Text that does not start with a +number is still read as 0. + +**Send the form `Content-Type`.** Like every route on this server, these read +a request body as arguments only when it is +`application/x-www-form-urlencoded` (or multipart). `curl -d` sends that by +itself; a browser's `fetch` with a string body does not (it says +`text/plain`, and the body arrives as one argument named `plain`). The +settings POST then changes nothing and answers `{"ok":true}`. The reset is +the one that bites: `band=2` in a body the server did not read is a reset +with no `band`, which resets all four. Put the header on, or put `band` in +the query string, and check that the answer names the band. + +**`source`** says what the input is, and the strings are the contract: + +| `source` | | +|---|---| +| `off` | The switch is off. The analysis is parked and the poll's numbers are whatever the last window left — not zeroed. | +| `pdm` | The microphone is delivering samples. The only state in which it drives the knobs. | +| `pdm (no mic - data pin idle)` | The driver is running but the data pin sits at a rail: no microphone, or no data wire. Nothing is driven. | +| `synth` | The switch is on and the driver is not running — it failed to install, or has not yet (the first quarter second after `mic=1`). | +| `synth (mic stalled)` | The driver is running and has stopped returning samples. | +| `phone` | `?levels=1` only, with `ext:true`: the switch is off and the phone app posted a frame in the last three seconds. `levels`, `env` and `spectrum` are then the phone's, already normalized 0..1 on its own scale; `levelsN` and `outputs` are still the microphone's and describe nothing on screen. | + +In the two `synth` states the analysis runs on a built-in three-tone test +signal, which is what the poll then reports. Since 1.5 that signal no longer +reaches the knobs. `micDropped` in `/api/status`'s `audioIn` block counts capture hops discarded because analysis fell behind; flat is healthy, climbing means overload. @@ -400,8 +477,11 @@ because analysis fell behind; flat is healthy, climbing means overload. ### Audio-React (Audio edition) Gated on `"audio"` in `caps`. The switch that lets the browser extension and -the phone capture drive the knobs — the same one the panel's NETWORK screen -toggles — from the console's `/audio-in` bar. +the phone capture drive the knobs. It is one switch with three handles: the +`AUD` row of the panel's NETWORK screen, the *Browser extension and phone +app* row under Sources on the console's `/audio-in` page, and these two +routes. The browser extension reads it once each time its socket opens and +offers to turn it on ([`audio-ws-spec.md`](audio-ws-spec.md#the-switch-on-the-panel)). | Endpoint | Meaning | |---|---| @@ -535,7 +615,7 @@ In short: HTTP is the management and state transport, OSC and MIDI are the low-l ## Version history -- **1.5** (unreleased) — the hotspot (`hotspot` in status, `GET`/`POST /api/hotspot`); status gains `network` and `thumbs` diagnostics, `fsError` (why storage is not mounted) and `flashId`; a failed `POST /api/patterns/format` returns the reason as its `error`; `POST /api/wifi/reconnect` reconnects without a reboot; core name registration retries partial failures and preserves feature-owned services; the MIDI edition adds a `midiUsb` block to status ([`midi-spec.md`](midi-spec.md)); `GET`/`POST /api/knobs` and the `/knobs` page (encoder direction and edges per click, per knob, persisted); `PUT /update` takes a raw image, and an upload survives a stall of up to two minutes; `GET /api/wifi` gains `join` (what became of the last network asked for) and `GET /api/hotspot` gains `seen` (the names in range); a network added on the hotspot with no station link is tried at once; console pages are cached by build (`?v=` immutable, bare URLs `no-cache` with an `ETag` and `304`, another build's `v` redirected with `302`), the chrome by its CRC (`?h=`), only for a `Host` that can only be the panel; `GET /favicon.ico` answers `204`; status gains `build`, `viaHotspot` and `busy`; `load` gains `code` (where the module's code runs from); pattern names in `GET /api/patterns` and `/api/patterns/select` are JSON-escaped, so a quote or backslash in a title no longer makes the reply unparseable; a multipart request that stops mid-form is dropped - the connection is closed with no reply - instead of spinning the server into its watchdog, and leaves none of its fields behind for later requests; `moduleMemory` gains `codePolicy`; status gains `loopStackMin`; the hotspot password is JSON-escaped in `/api/hotspot`; a `POST` that is not multipart to `/api/patterns` or `/update` is answered instead of crashing the panel, a multipart boundary longer than 70 characters is refused, and a raw `PUT` sees its own query string; status gains `crash` (the core dump's summary, and the pattern and phase the reset interrupted) and `DELETE /api/crash` clears it; status gains `loopAgeMs`, `loopStalled` and `loopSyncGaveUp`, and a route that needs the render loop answers `503` (`render loop is not answering`) when a pattern never returns from `draw()`, where it used to take the whole server down with it; a module that has run stays loaded when another pattern takes over and resumes when picked again - `load` gains `resumed` and `resumeUs`, `moduleMemory` gains `resident`, and a module's data sections go to PSRAM first. +- **1.5** (unreleased) — the hotspot (`hotspot` in status, `GET`/`POST /api/hotspot`); status gains `network` and `thumbs` diagnostics, `fsError` (why storage is not mounted) and `flashId`; a failed `POST /api/patterns/format` returns the reason as its `error`; `POST /api/wifi/reconnect` reconnects without a reboot; core name registration retries partial failures and preserves feature-owned services; the MIDI edition adds a `midiUsb` block to status ([`midi-spec.md`](midi-spec.md)); `GET`/`POST /api/knobs` and the `/knobs` page (encoder direction and edges per click, per knob, persisted); `PUT /update` takes a raw image, and an upload survives a stall of up to two minutes; `GET /api/wifi` gains `join` (what became of the last network asked for) and `GET /api/hotspot` gains `seen` (the names in range); a network added on the hotspot with no station link is tried at once; console pages are cached by build (`?v=` immutable, bare URLs `no-cache` with an `ETag` and `304`, another build's `v` redirected with `302`), the chrome by its CRC (`?h=`), only for a `Host` that can only be the panel; `GET /favicon.ico` answers `204`; status gains `build`, `viaHotspot` and `busy`; `load` gains `code` (where the module's code runs from); pattern names in `GET /api/patterns` and `/api/patterns/select` are JSON-escaped, so a quote or backslash in a title no longer makes the reply unparseable; a multipart request that stops mid-form is dropped - the connection is closed with no reply - instead of spinning the server into its watchdog, and leaves none of its fields behind for later requests; `moduleMemory` gains `codePolicy`; status gains `loopStackMin`; the hotspot password is JSON-escaped in `/api/hotspot`; a `POST` that is not multipart to `/api/patterns` or `/update` is answered instead of crashing the panel, a multipart boundary longer than 70 characters is refused, and a raw `PUT` sees its own query string; status gains `crash` (the core dump's summary, and the pattern and phase the reset interrupted) and `DELETE /api/crash` clears it; status gains `loopAgeMs`, `loopStalled` and `loopSyncGaveUp`, and a route that needs the render loop answers `503` (`render loop is not answering`) when a pattern never returns from `draw()`, where it used to take the whole server down with it; a module that has run stays loaded when another pattern takes over and resumes when picked again - `load` gains `resumed` and `resumeUs`, `moduleMemory` gains `resident`, and a module's data sections go to PSRAM first; `POST /api/audio-in` applies a band when any of its suffixed fields is present (it took one of five before, and answered `ok` to the rest without changing anything), and refuses a bad `band` before applying anything, with `ok:false` in the body; `POST /api/audio-in/reset` takes `band=N` and names the band in its answer, and a full reset now includes `attack`; `hzRange` is printed to two decimals; a microphone that failed to start or stopped answering (`source` beginning `synth`) no longer drives the knobs from the test signal; `/api/audio-in` ignores a value that is not a finite number (`nan`, `inf`) where it used to store it and print `nan` back, which no JSON parser reads, and a band whose low edge is in the top analysis bin reads 0 instead of putting `nan` into the poll and into `audioIn` in status; the microphone section documents what was already there - `smoothing`, `attack`, `meta`, `lutSet`, `lut`, the suffixed field names, `env`, `ext`, the `frame` post, `synth (mic stalled)`, that `spec` has 64 buckets, not 40, and that a body sent without the form `Content-Type` is not read as arguments. - **1.4** (2026-09-06) — `GET /api/patterns/file` gains `ext=thumb`; `GET /api/display` takes `brightness` and status reports it; status gains `resetReason` and `load.internal`/`load.psram`; console pages are served gzip-compressed (`Content-Encoding: gzip`); the page sender no longer truncates on a slow link; the server no longer trips the Core-0 watchdog on a request that stalls mid-header. - **1.3** (2026-09-04) — `GET`/`POST /api/clock` (Utility edition) and the `clock` block in status; `caps` gains `"clock"`. - **1.2** (2026-09-03) — the server is serviced on Core 0 (the one-connection rule stands; the render-pays rule is history); status gains `httpCore`, `netStackMin`, `loopSyncServed`/`loopSyncMaxUs`; `POST /api/params` documents `d1`..`d4` and how a held value reaches a legacy pattern; `GET /api/patterns/select` gains `step`; `GET`/`POST /api/audio` (Audio-React) are documented; `featureNav`'s microphone label is *Audio*. diff --git a/firmware/bundles/audio/features_local.h b/firmware/bundles/audio/features_local.h index 39bcc843f..8f36d9dda 100644 --- a/firmware/bundles/audio/features_local.h +++ b/firmware/bundles/audio/features_local.h @@ -12,13 +12,16 @@ // directions over UDP. Was core until it was pointed out that // "needs no infrastructure" is not a test anyone can apply // evenly — OSC is sound integration and belongs here. -// audio The browser path: /audio in the device console, plus the -// websocket the Chrome extension speaks. Four FFT bands off a -// tab or a mic, driving the four knobs. +// audio The browser path: the websocket on port 81 that the Chrome +// extension and the phone app speak, and the switch that lets +// them drive (AUD on the NETWORK screen, /api/audio). The client +// analyses its own sound into four bands and sends the four knob +// values; this feature serves no page. // audio_in On-board sound. A PDM microphone on the panel itself, so the // thing works with no computer in the room at all. Six of seven // people asked for this and it was the single most-wanted item -// in the survey — more than every other option combined. +// in the survey — more than every other option combined. Its +// page is /audio-in, where both inputs are switched. // // Deliberately absent: the show player, weather, MQTT. This firmware is for // people who point sound at a panel. If you want sequences or a broker, that diff --git a/firmware/patternflow/console/README.md b/firmware/patternflow/console/README.md index 502a80e6f..2b60139d1 100644 --- a/firmware/patternflow/console/README.md +++ b/firmware/patternflow/console/README.md @@ -19,8 +19,9 @@ whenever you save a page, so a tab left open reloads itself onto your edit the way a real one does after a firmware update. To see the console the way a phone on the panel's hotspot does, `--slow` (gzip, 0.4 s round trips, 5 KB/s, one request at a time); `/mock?hotspot=1` and `/mock?busy=update` -set what `/api/status` says; `--chrome404` runs every page on its fallback. -The script's docstring has the rest. +set what `/api/status` says; `/mock?audio=…` sets what the panel hears +(below); `--chrome404` runs every page on its fallback. The script's +docstring has the rest. When you are done: @@ -49,11 +50,9 @@ CI checks the two stay in sync. | `clock.html` | `/clock` | `features/clock/core_clock_http.h` | `features/clock/clock_index.h` | The list `console_pages.py` splices is `PAGES` at the top of that script — add a -row there when you add a page. `audio-in.html` is itself generated, from the -browser extension's mapping editor, by `firmware/toolchain/build_audio_in_page.py`; -edit the extension, run that, then `console_pages.py build`. CI runs -`build_audio_in_page.py --check`, so a page that was not rebaked after an -editor change fails there rather than shipping stale. +row there when you add a page. A file here whose name starts with an +underscore is a source, not a page. `audio-in.html` is the one page you do +not edit: it is assembled from other files (below). Pages under `src/` belong to the core and may not name a feature — not in a nav row, not in a sentence (see `docs/EDITIONS.md`). Pages under `features/` @@ -68,6 +67,63 @@ is editable the same way. The chrome draws itself inside a shadow root page, so do not style or query its insides. What a page may use is `window.PF`, below. +## The page that is assembled: `/audio-in` + +`audio-in.html` is generated. Edit what it is made from, and +`console_pages.py build` assembles it before it bakes the headers (the step +is `firmware/toolchain/build_audio_in_page.py`; its docstring has the detail). +`console_serve.py` does not read the generated file: it assembles the page +from these sources on every request, with the same code, so here too it is +save and refresh. A source the build would refuse shows as the build's own +message instead of a page. + +| source | what it is | +| ------ | ---------- | +| `tools/patternflow-audio-extension/editor.html`, `editor.css`, `editor.js`, `mapping.js` | the mapping editor and its model, shared with the browser extension: one copy, so the two cannot drift. From `editor.html` only what is between its two `EDITOR BODY` lines comes here (the extension's own source bar stays behind); from `editor.css` and `mapping.js`, what is above their `EXTENSION ONLY` line | +| `_audio_in_bar.html` | the panel's frame around the editor: the page heading, the Sources list (the microphone with its gain, the browser path's switch) and the footer, with a line saying `` where the editor's body goes | +| `_audio_in.css` | the panel's styles, applied after the editor's: the console's base block (the editor brings layout and no colours), then the editor's parts dressed as the console's | +| `_audio_in_adapter.js` | `window.PFAdapter`, the editor's one way out, spoken over `/api/audio-in`. The contract it keeps is written at the top of `editor.js` | + +Comment those files as much as they need. Whole-line comments, indentation +and blank lines are dropped as the page is assembled, which is a quarter of +its weight on the wire. Nothing parses the JavaScript to do it, so the build +refuses the few things that would make it unsafe and names the line; the one +you might meet is a template literal that runs across lines. Line numbers in +the browser's console are the generated page's. + +It is the heaviest page in the console, so its size is pinned: `BUDGET` in +`build_audio_in_page.py`, in bytes of the stamped page gzipped as the panel +sends it. `console_pages.py build` prints how much is spare. CI fails when the +page is over it, and when `audio-in.html` is not what its sources assemble to +(`build_audio_in_page.py --check`; `console_pages.py check` says so too). + +The page is an editor, so on the mock it has a panel that behaves. Its +endpoints (`/api/audio-in`, `/api/audio-in/reset`, `/api/audio`) are the one +part of `console_serve.py` that is a port of the firmware's handlers and not +a fixture: a request changes only the fields it carries, the two pairs settle +as they do on a panel, a curve table that is not 33 numbers is ignored with +`{"ok":true}`, a POST without the form `Content-Type` has no arguments at +all, and replies have the handler's field order and decimals. The sound is +made up; what is done with it is not. What the panel hears is a state you +pick, and each address lands on `/audio-in`: + +| `/mock?audio=` | the panel | +| -------------- | --------- | +| `music` | microphone on, a room with music in it (the default) | +| `silence` | on, a quiet room: every gate shut, every knob at rest | +| `off` | switched off after music: the poll's numbers are the last window's, not zeros | +| `fresh` | nobody has configured it: off, nothing heard, the factory mapping | +| `nomic` | on, nothing on the data pin: `source` is `pdm (no mic - data pin idle)` | +| `stalled` | on, the microphone stopped answering: `synth (mic stalled)`, and the numbers are the test tones | +| `phone` | off, the phone app posting monitor frames: `phone`, `ext:true`, its own units | +| `ext` | off, the browser extension connected: `audioClients` is 1 | +| `noread` | the configuration read gets no reply; the poll and every POST still answer | + +The switches on the page work from any of them. A build can also have the +microphone without the browser path: `/mock?caps=patterns,params,sleep,audio-in`, +where `/api/audio` is a 404. When a handler in `features/audio_in/` or +`features/audio/` changes, the port in `console_serve.py` changes with it. + ## The look The console is light unless someone picks dark. The choice is the toggle in @@ -202,3 +258,7 @@ Anything that lives in C++ — whether an upload actually parses, what the panel does when a show plays, whether a Wi-Fi switch really reconnects — is not modelled. A page that works against the mock still has to be tried on a real device before you believe it. + +The one exception is `/audio-in`'s endpoints, which are ported from the +firmware (above). Even there the microphone is invented: what a real one +does in a real room is still only on a panel. diff --git a/firmware/patternflow/console/_audio_in.css b/firmware/patternflow/console/_audio_in.css new file mode 100644 index 000000000..2d7d897b6 --- /dev/null +++ b/firmware/patternflow/console/_audio_in.css @@ -0,0 +1,83 @@ +/* /audio-in, the panel's half: its styles. A source, not a page: + firmware/toolchain/build_audio_in_page.py puts this after the editor's own + rules (tools/patternflow-audio-extension/editor.css, down to its EXTENSION + ONLY line) in console/audio-in.html. Edit it here, then run + console_pages.py build. Comments and indentation do not travel. + + The editor brings layout and no colour: every colour it uses is a + variable, and this file is where the panel gives them values. So the page + is a console page: the console's base block (README, "The look"), then the + editor's parts dressed as the console's. The extension keeps its own skin + below the EXTENSION ONLY line of its sheet; nothing here reaches it. */ + +/* Console base: the same block opens every core page. Light in :root (the chrome's + injected light palette holds these same values: keep the two in step), + dark under html[data-theme=dark]. No web fonts: the hotspot has no internet. + Two names are the editor's own and stand on the console's: its plot and + cards are panels, its tags the ghost grey. They are set here and not in + the chrome, which is the core's. The canvases read all of them fresh on + every paint, and --led, --rule and --ink are taken apart for see-through + paint, so those three stay written #rrggbb. */ +:root{color-scheme:light;--cream:#F4EFE6;--panel:#FFFCFA;--ink:#1A1814;--muted:#625C51;--faint:#7D7668;--rule:#DDD5C6;--rule-soft:#E8E1D4;--ghost:#E3DCCE;--led:#FF5C2E;--bad:#B83C1C;--ok:#2B7A4B;--warn:#85600E; +--sans:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;--mono:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,'Roboto Mono',monospace;--field:var(--panel);--cream-2:var(--ghost)} +html[data-theme=dark]{color-scheme:dark;--cream:#0C0B09;--panel:#131110;--ink:#EDE7DB;--muted:#A39B8A;--faint:#7A7364;--rule:#2B2721;--rule-soft:#1F1C18;--ghost:#2A2620;--bad:#FF7A55;--ok:#57B87F;--warn:#D9A03F} +[hidden]{display:none!important} +body{margin:0;background:var(--cream);color:var(--ink);font:15px/1.5 var(--sans);-webkit-font-smoothing:antialiased} +a{color:inherit}:focus-visible{outline:2px solid var(--led);outline-offset:2px} +.wrap{max-width:1080px;margin:0 auto;padding:20px 16px 48px} +h2{margin:14px 0 0;font-size:13px;font-weight:600;color:var(--muted)} +.msg,.sub{font:12px var(--mono);color:var(--faint)}.pf-ok{color:var(--ok)}.pf-err{color:var(--bad)} +.chip{display:inline-block;padding:0 8px;border:1px solid var(--rule);border-radius:99px;font:11px/20px var(--mono);color:var(--muted);white-space:nowrap} +.chip.ok{border-color:var(--ok);color:var(--ok)}.chip.pf-err{border-color:var(--bad);color:var(--bad)}.chip:empty{display:none} +footer{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-top:26px;border-top:1px solid var(--rule);font:12px var(--mono);color:var(--faint)} +footer a{display:inline-flex;align-items:center;min-height:40px;color:var(--muted);text-decoration:none} +header{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 12px} +h1{flex:1;margin:0;font-size:22px;font-weight:600;letter-spacing:-.01em} +p{margin:0} +.list{margin:0;padding:0;list-style:none;border-top:1px solid var(--rule)} +.list>li{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;min-height:48px;padding:6px 0;border-bottom:1px solid var(--rule-soft)} +/* The three sliders are the browser's own, in ink. The console's drawn range + (home.html's Range block and its fill()) is 290 bytes on the wire that this + page's budget does not have; the look differs by a thumb. */ +input[type=range]{accent-color:var(--ink)} + +/* ── Audio ── */ +/* The editor's parts, as the console's: its buttons, tabs and switches are + the small button (.btn.sm), its cards and plot the card, its two headings + the section title, its hints the note (and the + frame's own notes are its .hint, so there is one rule for both). Type is the console's 12 to 15 px + in the sans; mono stays on numbers. Orange is for what is selected or on, + never a hover. */ +.ghostBtn,.pvSig,.toggleWrap{padding:0 12px} +.ghostBtn,.pvSig,.chipCard,.toggleWrap{min-height:32px;border-color:var(--rule);border-radius:8px;background:var(--panel);color:var(--ink);font:500 13px var(--sans);transition:border-color .15s} +.ghostBtn:hover,.outPreset:hover{border-color:var(--ink);color:var(--ink)} +.ghostBtn.dan,.ghostBtn.dan:hover{color:var(--bad)}.ghostBtn.dan:hover{border-color:var(--bad)} +:disabled{opacity:.45;cursor:default} +.toggleWrap{gap:8px;padding-left:8px} +.toggleLabel,.hint,.fieldLabel{font-size:13px}.fieldLabel{font-weight:500} +.toggle{width:28px;height:16px;border-radius:8px;background:var(--faint)} +.dotK{left:2px;top:2px;width:12px;height:12px} +.toggleWrap.on .toggle{background:var(--led)}.toggleWrap.on .dotK{left:14px} +.plotWrap,.panel{border-radius:10px} +.tag,.preset,.outPreset,.stepsRow button{border-radius:8px} +.mono,.tagText,.chipCard .mono,.damping .mono,.preset span,.outPreset,.stepsRow{font-size:12px} +.bandName{font-size:15px} +.colKicker{font:600 13px var(--sans);letter-spacing:0} +.outFill{background:var(--ink)} +/* The extension's knob tabs each carry an orange dot. Here orange marks what + is selected or on, and the selected tab already has its orange border. */ +.chipDot{display:none} +/* Until the editor has its mapping its canvases are the browser's 300 px + default, wider than a phone's column. */ +canvas{max-width:100%} +/* The message slot beside "Mapping": what a save or a read came to. */ +h2 .msg{margin-left:8px;font-weight:400} +/* The sources. A row is its switch, its state, and what belongs to it. (The + microphone's note takes a line of its own inside its row: the frame gives + it flex-basis 100% in its style attribute.) */ +.list .hint{flex:1 1 16em} +/* The chrome says when the panel is unreachable; the page only dims what it + can no longer vouch for: the plot, the sources, and the header's sentence + about who has the knobs. (The adapter dims the plot the same way while the + microphone it would draw is not there.) */ +.pf-offline .plotWrap,.pf-offline .list,.pf-offline .sub{opacity:.5} diff --git a/firmware/patternflow/console/_audio_in_adapter.js b/firmware/patternflow/console/_audio_in_adapter.js new file mode 100644 index 000000000..8540875d8 --- /dev/null +++ b/firmware/patternflow/console/_audio_in_adapter.js @@ -0,0 +1,450 @@ +// /audio-in, the panel's half: window.PFAdapter. A source, not a page: +// firmware/toolchain/build_audio_in_page.py puts it in console/audio-in.html +// where the extension's editor.html loads editor-adapter.js. Edit it here, +// then run console_pages.py build. Whole-line comments and indentation do not +// travel; a template literal has to stay on one line (the build says so). +// Every byte that is not a comment is sent over the panel's Wi-Fi, and the +// page has a budget (BUDGET in the builder): this file is written short. +// +// The editor (editor.js, shared with the extension) reaches the panel only +// through this object; the contract it keeps is written at the top of +// editor.js, and the API it speaks is docs/rest-api.md, "Microphone". Four +// jobs live here: +// +// - scale conversion. The firmware measures, gates and maps in LINEAR +// amplitude (every constant in core_audio_in_map.h was measured on that +// scale and stays); the editor's level axis is dB so boxes drag like +// hearing works. This file converts both ways at the boundary. +// DB_FLOOR/DB_SPAN are the calibration: a quiet room sits about 0.15 up +// the axis, listening-volume peaks about 0.9. The phone app mirrors the +// two numbers, so they do not move. +// +// - reading. Nothing is saved until the panel's own mapping has been read +// (`ack` is null until then): an editor holding defaults must never write +// them over a mapping it failed to fetch. The read is retried until it +// answers. +// +// - saving. ONE path for everything this page writes (the mapping, the +// microphone switch, its gain): `cur` is what the page wants, `ack` what +// the panel last acknowledged, both as the text of each POST field. A +// save sends the fields that differ and nothing else, one request at a +// time, so two tabs do not write each other's stale copy back and an +// Attack nudge is `attack=0.650`, not forty arguments. +// +// - the sources. Who can have the knobs, and how each stands: the +// microphone's state is the `source` of every levels poll, the +// extension's switch and socket count are what the chrome's status +// delivers, kept fresh by the chrome's shared poller (PF.watchStatus). +// The page fetches neither by itself. +// +// Every request rides the console's lane (README, "Writing a page"): the +// levels and the read are PF.poll, never a timer of this file's own. +(function () { + var DB_FLOOR = -45, DB_SPAN = 47; + var $ = (id) => document.getElementById(id); + var clamp01 = PFMap.clamp01; + var dbn = (x) => clamp01((20 * Math.log10(Math.max(Number(x) || 0, 1e-4)) - DB_FLOOR) / DB_SPAN); + var lin = (v) => Math.pow(10, (clamp01(v) * DB_SPAN + DB_FLOOR) / 20); + var say = (text, kind) => PF.say(text, kind || 'info', $('barMsg')); + + // A curve's name, as the panel stores it beside the table (`meta`, 31 + // bytes, never parsed by the firmware): a, s:N, p:preset, b: six + // hundredths. The shapes are the model's own (PFMap.PRESETS), not copied. + function encodeMeta(curve) { + var c = curve || 0; + return c.type === 'steps' ? 's:' + c.n + : c.type === 'arch' ? 'a' + : c.type !== 'bezier' ? '' + : (PFMap.PRESETS[c.id] || 0).type === 'bezier' ? 'p:' + c.id + : 'b:' + [c.y0, c.y1, c.p1x, c.p1y, c.p2x, c.p2y].map((v) => Math.round(clamp01(v) * 100)); + } + function decodeMeta(m) { + var q = m.slice(2).split(','), n = parseInt(q[0], 10) || 2, p = PFMap.PRESETS[q[0]] || 0; + q = q.map((v) => (parseInt(v, 10) || 0) / 100); + return m === 'a' ? { type: 'arch', id: 'arch' } + : /^s:/.test(m) ? { type: 'steps', id: n === 2 ? 'gate' : 'steps', n: Math.max(2, Math.min(8, n)) } + : /^p:/.test(m) && p.type === 'bezier' ? Object.assign({}, p) + : /^b:/.test(m) && q.length === 6 ? { type: 'bezier', id: 'custom', y0: q[0], y1: q[1], p1x: q[2], p1y: q[3], p2x: q[4], p2y: q[5] } + : null; + } + + // ── what the panel has, and what the page wants ─────────────────────── + // + // Field name -> the text a POST carries for it. `ack` is null until the + // panel's configuration has been read; while it is, nothing saves. + var ack = null, cur = {}, luts = []; + var hz = [31.25, 8000]; + var frameFn, cfgFn, levels; + // One write to the panel at a time, in the order they were asked for. + var tail = Promise.resolve(); + // `tries` counts a save's failures, `told` is the failure the slot is showing. + var tries = 0, told = '', leaving = false; + // The sources: the last poll's `source` and `ext`, when the microphone + // switch last moved, the browser path's three facts from the status + // (`hasAud` is undefined until one has come), and how many switches of that + // path this page has on their way to the panel. + var source = '', ext = false, micAt = 0; + var hasAud, aud = false, clients = 0, busy = 0; + + // A band's numbers and the decimals each is sent with (the firmware's own + // print precision). The first six are the three pairs, each beside its mate. + var FIELDS = [['hzMin', 1], ['hzMax', 1], ['inMin', 5], ['inMax', 5], ['outMin', 3], ['outMax', 3], ['gain', 3]]; + + // The editor's mapping as POST fields. The acknowledged copy is made with + // this too, from the mapping as the editor will hold it, so the first edit + // sends that edit and not every band the round trip through dB moved by a + // digit. Beside each curve's name, its table, baked if the editor has none. + function ser(cfg, to) { + to.auto = cfg.autoRange ? '1' : '0'; + to.smoothing = cfg.smoothing.toFixed(3); + to.attack = cfg.attack.toFixed(3); + cfg.bands.forEach((b, i) => { + FIELDS.forEach((f) => { to[f[0] + i] = (f[1] > 4 ? lin(b[f[0]]) : b[f[0]]).toFixed(f[1]); }); + to['muted' + i] = b.muted ? '1' : '0'; + to['meta' + i] = encodeMeta(b.curve); + luts[i] = (!b.curve ? [] : b.lut && b.lut.length === 33 ? b.lut : PFMap.bakeLut(b.curve)) + .map((v) => Math.round(clamp01(v) * 255)); + }); + } + + // The fields that differ, as a POST body ('' = the panel has everything), + // and in `sent` what each was sent as. A pair goes whole when either half + // moved (hzMin/hzMax, inMin/inMax, outMin/outMax): the range that was + // touched is stored as the page shows it. That matters where the page's + // copy is not the panel's to the digit: the model widens a window narrower + // than its minimum and swaps an output range stored reversed (only another + // API client can make either), and the half that was not dragged would + // otherwise stay as the panel had it, under a page showing something else. + // The price is two tabs on the two edges of ONE box: the later one writes + // the box it shows. A curve's name never goes without its table, nor a + // table without its name. + function diff(sent) { + var out = []; + var put = (k) => { out.push(k + '=' + encodeURIComponent(cur[k])); sent[k] = cur[k]; }; + var one = (k, mate) => { if (cur[k] !== ack[k] || cur[mate] !== ack[mate]) put(k); }; + ['mic', 'auto', 'micGain', 'smoothing', 'attack'].forEach((k) => one(k)); + for (let i = 0; i < 4; i++) { + FIELDS.forEach((f, n) => one(f[0] + i, n < 6 && FIELDS[n ^ 1][0] + i)); + one('muted' + i); + if (cur['meta' + i] !== ack['meta' + i]) { put('meta' + i); out.push('lut' + i + '=' + luts[i]); } + } + return out.join('&'); + } + + // A POST the panel's parser reads (no form Content-Type, no arguments), that + // cannot hang the queue (twelve seconds, where the browser can count them), + // and that outlives the page when it is the last one. + var post = (url, body) => fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body, keepalive: leaving, + signal: AbortSignal.timeout ? AbortSignal.timeout(12000) : undefined + }).then((r) => { + if (!r.ok) throw Error(r.status); + return r.json(); + }); + + // Send what differs. PF.dirty stays up until the panel has acknowledged + // all of it, so the chrome never reloads the page over an unsaved edit. A + // save that failed is tried once more, when the next levels poll shows the + // panel answering; after that it waits for the next change, and says so. + function save() { + return tail = tail.then(() => { + var sent = {}, body = ack && diff(sent); + PF.dirty = !!body; + if (!body) return told && say(told = ''); + return post('/api/audio-in', body).then(() => { + Object.assign(ack, sent); + if (sent.mic) { micAt = Date.now(); levels.set(100); levels.now(); } + if (told) say('saved', 'ok'); + told = ''; + tries = 0; + PF.dirty = !!diff({}); + draw(); + }, () => { + say(told = ++tries < 2 ? 'not saved yet: the panel did not answer. Trying again.' + : 'not saved: the panel did not take it. Change it again to retry.', 'err'); + // The slot is at the top of a long page: giving up is also a toast. + if (tries > 1) PF.say(told, 'err'); + }); + }); + } + function change() { tries = 0; PF.dirty = true; return save(); } + + // On the way out there is no waiting for the queue: whatever differs goes + // now, once, and is taken as sent. editor.js flushes its own waiting save + // from pagehide as well; this listener is registered first, so that save + // finds `leaving` set and goes the same way. + function bye() { + var sent = {}, body = ack && diff(sent); + if (!body) return; + post('/api/audio-in', body).catch(() => {}); + Object.assign(ack, sent); + } + addEventListener('pagehide', () => { leaving = true; bye(); }); + addEventListener('pageshow', () => { leaving = false; }); + + // ── reading ─────────────────────────────────────────────────────────── + + // The panel's configuration reply, as the editor's mapping; and from the + // same reply the acknowledged copy and the device's own controls. A curve + // is taken only where the panel runs its table (lutSet): a name without a + // table is a band running its gain, and is shown as that. + function take(j) { + hz = j.hzRange || hz; + var cfg = PFMap.normalizeConfig({ + autoRange: j.autoRange, smoothing: j.smoothing, attack: j.attack, + bands: j.bands.map((b) => Object.assign({}, b, { + inMin: dbn(b.inMin), inMax: dbn(b.inMax), + curve: b.lutSet ? decodeMeta(b.meta) : null + })) + }, { hzMin: hz[0], hzMax: hz[1] }); + ack = {}; + ser(cfg, ack); + ack.mic = j.micOn ? '1' : '0'; + ack.micGain = j.micGain.toFixed(1); + cur = Object.assign({}, ack); + source = j.source; + PF.dirty = false; + $('micGain').value = ack.micGain; + $('micToggle').disabled = $('micGain').disabled = false; + draw(); + return cfg; + } + + // Read the configuration until the panel answers with one: a reply without + // four bands (an error page, another device) is not one. On the console's + // lane, slower after each miss. + function read() { + ack = null; + say('Reading the panel’s mapping…'); + return new Promise((done) => { + var wait = 2000, rd = PF.poll('/api/audio-in', wait, (j) => { + if (j && (j.bands || 0).length === 4) { + rd.stop(); + say(''); + done(take(j)); + } else { + say('Could not read the panel’s mapping. Trying again.', 'err'); + rd.set(wait = Math.min(wait * 2, 16000)); + } + }); + }); + } + + // The firmware's own defaults: the page carries no table of them. The reply + // has no configuration in it, so the mapping is read again and replaces the + // editor's copy and the acknowledged one before anything saves (a stale + // copy would write the reset away on the next edit); the editor stands + // inert while that is open. `body` is 'band=N' or '' for everything. + function reset(body) { + return tail = tail.then(() => { + $('ed').inert = true; + return post('/api/audio-in/reset', body).then(read, () => { + say('Reset failed: the panel did not take it.', 'err'); + }).then((cfg) => { + $('ed').inert = false; + return cfg; + }); + }); + } + + // ── the levels ──────────────────────────────────────────────────────── + + // One poll, as the editor's frame. Levels are drawn only for a source that + // is heard: the microphone delivering samples ('pdm') or a phone frame + // (`ext`). In every other state the poll's arrays are stale (off), the + // test tones (synth) or a dead pin, and none of them is the room. + function onLevels(j) { + var f = {}, conv; + if (!j) { + // No answer: ask once a second, not ten times; the chrome says why. + levels.set(1000); + frameFn(f); + return; + } + ext = j.ext; + source = j.source; + // The switch as the panel has it (another tab may have moved it), except + // around a change of our own: a poll that left before it would undo it. + if (ack && cur.mic === ack.mic && Date.now() - micAt > 1500) cur.mic = ack.mic = ext || source === 'off' ? '0' : '1'; + // As often as there is something new to draw. The microphone's levels + // move ten times a second; the phone app posts a frame every 250 ms, on + // the one connection this poll also needs. Nothing is drawn from any + // other state (off, no microphone, not answering), so a slow poll is + // enough to see a switch moved elsewhere, and still inside the 2.5 s the + // panel counts a page as watching, which is what keeps the phone app + // sending. For a second and a half after the switch moves the poll stays + // fast, so "starting" settles quickly. + levels.set(ext ? 250 : source === 'pdm' || Date.now() - micAt < 1500 ? 100 : 1500); + if (ext || source === 'pdm') { + conv = ext ? clamp01 : dbn; + f.levels = j.levels.map(conv); + f.spectrum = j.spectrum.map(conv); + // An envelope only once one is learned: a panel that has heard nothing + // sends lo -1.5, hi 0. + f.env = (j.env || []).map((e) => ext || e.lo >= 0 && e.hi > e.lo ? { lo: conv(e.lo), hi: conv(e.hi) } : null); + if (ext) { + // The phone's own axis; levelsN and outputs are the microphone's + // even here, so the editor does this frame's arithmetic itself. + f.db = [-80, -10]; + } else if (ack) { + // Where the mapper has each level: inside its envelope in auto + // range, linearly between the stored edges by hand. Not while a + // reset's re-read is open (`ack` is null then): that frame is drawn + // from its levels alone. + f.outputs = j.outputs; + f.pos = j.levels.map((v, i) => { + var lo = +ack['inMin' + i]; + return clamp01(ack.auto > 0 ? (j.levelsN[i] - 0.10) / 0.85 : (v - lo) / (Math.max(lo + 0.01, ack['inMax' + i]) - lo)); + }); + } + } + draw(); + frameFn(f); + if (tries === 1) save(); + } + + // ── the sources ─────────────────────────────────────────────────────── + + var NOTES = { + 'off': 'The microphone is off. The mapping can still be edited.', + 'not detected': 'No microphone found: the data pin (RX) is idle. Check the DAT lead.', + 'not answering': 'The microphone is not answering.' + }; + function flip(id, on) { + $(id).classList.toggle('on', on); + $(id).setAttribute('aria-pressed', on); + } + // A chip is its word and how it stands: ' ok' (green), ' pf-err' or ''. + function chip(id, text, how) { + $(id).textContent = text; + $(id).className = 'chip' + how; + } + function draw() { + if (!ack) return; + // The microphone, in one word. For a second after the switch goes on the + // firmware has not made up its mind (the first reads say synth, and "no + // microphone" needs the old levels to decay). + var on = cur.mic > 0; + var m = !on ? 'off' + : Date.now() - micAt < 1500 || /^(off|phone)$/.test(source) ? 'starting' + : source === 'pdm' ? 'listening' + : source === 'pdm (no mic - data pin idle)' ? 'not detected' + : 'not answering'; + var bad = /^not/.test(m) ? ' pf-err' : '', note = !ext && NOTES[m] || ''; + flip('micToggle', on); + // A fault is in the error colour, on the chip and on the sentence under + // the switch: it must not look quieter than "listening". + chip('micChip', m, bad || (m === 'listening' ? ' ok' : '')); + $('micGainVal').textContent = cur.micGain; + $('deviceNote').textContent = note; + $('deviceNote').className = 'hint' + bad; + // The plot has nothing to draw from a microphone that is not there. + $('plotWrap').style.opacity = bad ? 0.5 : ''; + // The browser row stands in the page from the first paint, its switch + // disabled and its chip empty, and goes away only when a status says this + // build has no such path: the status comes after the mapping, and a row + // arriving then would push the editor down under a finger. + $('extRow').hidden = hasAud === false; + $('audToggle').disabled = !hasAud; + flip('audToggle', aud); + chip('extChip', !hasAud ? '' : !aud ? 'off' : ext ? 'phone app' : clients ? 'connected' : 'waiting', aud && ext ? ' ok' : ''); + // Who has the knobs, in words. On the panel a sender on the socket + // outranks the microphone lane by lane (feature_audio_in.h drives only a + // lane nobody else has taken), and the page cannot see whether an open + // socket is sending: so while one is open the microphone is not named as + // the owner, listening or not, and the browser path is only ever + // "connected". + $('st').textContent = hasAud && aud && clients && !ext ? 'a browser or phone is connected' + : (m === 'listening' ? 'the microphone' : ext ? 'the phone app' : 'nothing') + ' has the knobs'; + } + // The browser path's switch and how many sockets are open: the chrome's + // status, and kept fresh (its one shared poller, every 5 s), because the + // page says "waiting" and "nothing has the knobs" for as long as it is + // open, and the usual order is this page first and the extension after. + // Subscribed from onFrame, once the mapping is in, and not at load: + // subscribing asks for a status at once, and one ahead of the read only + // makes the editor a round trip later (nothing is drawn from it before the + // mapping is in). The chrome fetches its first status by itself, and on + // its lane that lands right behind the read. A reply that was on its way + // while a switch of this page's own was being sent is older than the + // switch, and is not taken for it. + function onStatus(s) { + hasAud = (s.caps || []).includes('audio'); + if (!busy) aud = s.audioRuntime; + clients = s.audioClients; + draw(); + } + + $('micToggle').onclick = () => { + cur.mic = cur.mic > 0 ? '0' : '1'; + micAt = Date.now(); + draw(); + change(); + }; + // The gain shows as it is dragged and is sent when it is let go. + $('micGain').oninput = () => { + cur.micGain = (+$('micGain').value).toFixed(1); + PF.dirty = true; + draw(); + }; + $('micGain').onchange = change; + $('audToggle').onclick = () => { + var want = aud = !aud; + busy++; + draw(); + tail = tail.then(() => post('/api/audio', 'on=' + (want ? 1 : 0)).then((r) => { + aud = r.audioRuntime; + }, () => { + // A lost request must not leave the switch lying until the next status. + aud = !want; + say('not switched: the panel did not answer.', 'err'); + }).then(() => { + busy--; + draw(); + })); + }; + + // The canvases take their colours from the page's variables on each paint, + // and a paint comes with a frame. With the microphone off a frame is 1.5 s + // away, so a theme change repaints by itself (selectBand is editor.js's: + // the plot, the curve, the shapes and the tab glyphs). + new MutationObserver(() => { if (frameFn) selectBand(sel); }) + .observe(document.documentElement, { attributeFilter: ['data-theme'] }); + + window.PFAdapter = { + loadConfig: read, + // Asked once, after the read: the axis and the limits the firmware holds + // a band to, so nothing the editor stores snaps back. A window is at + // least 0.01 tall in LINEAR amplitude and tops out at linear 1.0; the + // model's own 0.02 of the axis applies as well, whichever is stricter. + caps: () => ({ + hzMin: hz[0], hzMax: hz[1], hzGap: 31.25, + db: [DB_FLOOR, DB_FLOOR + DB_SPAN], + levelMax: dbn(1), + top: (lo) => Math.max(lo + PFMap.MIN_WINDOW, dbn(lin(lo) + 0.01)), + bottom: (hi) => Math.min(hi - PFMap.MIN_WINDOW, dbn(lin(hi) - 0.01)) + }), + saveConfig: (cfg) => { + if (!ack) return; + ser(cfg, cur); + return leaving ? bye() : change(); + }, + onConfig: (fn) => { cfgFn = fn; }, + onFrame: (fn) => { + frameFn = fn; + levels = PF.poll('/api/audio-in?levels=1', 100, onLevels); + PF.status(onStatus); + PF.watchStatus(5000); + }, + resetBand: (i) => reset('band=' + i), + // PFConfirm is editor.js's two-press confirmation: the phone app's + // WebView has no window.confirm. It says exactly what the panel resets. + resetAll: (button) => { + if (PFConfirm(button, 'Press again: resets all four knobs, their curves, attack, damping and input gain')) { + PF.busy(button, reset('').then((cfg) => { if (cfg) cfgFn(cfg); })); + } + } + }; +})(); diff --git a/firmware/patternflow/console/_audio_in_bar.html b/firmware/patternflow/console/_audio_in_bar.html new file mode 100644 index 000000000..3a089a1b2 --- /dev/null +++ b/firmware/patternflow/console/_audio_in_bar.html @@ -0,0 +1,58 @@ + +
+

Audio

+

Sources

+ +

Mapping

+

For the microphone and the phone app. Changes save to the panel as you make them.

+ + +
diff --git a/firmware/patternflow/console/audio-in.html b/firmware/patternflow/console/audio-in.html index b30d521d5..e4c7724a0 100644 --- a/firmware/patternflow/console/audio-in.html +++ b/firmware/patternflow/console/audio-in.html @@ -4,1476 +4,1288 @@ -Patternflow - Mic mapping - +Patternflow - Audio + -
- -
-
- - Patternflow Audio - MAPPING -
-
- - idle - patternflow.local:81 - -
-
- -
- - -
- Input gain - - 8.0 -
- - - - Extension ↗ - -
- - -
- - - -
- Attack - - quick -
-
- Damping - - balanced -
- -
- -
- -
B1·K1
-
B2·K2
-
B3·K3
-
B4·K4
-
- -
-
- RESPONSE - -
- -
- CURVE -
- - - - - - -
- - This band still runs its old boost curve — pick a preset to upgrade it. - Drag the two handles for a custom shape. Any curve saves as a 33-point table. -
- -
- -
-
- Band 1 - 62 – 375 Hz -
- -
-
- Output range - 0.30 – 0.85 -
-
-
-
-
-
-
-
- - - - - - -
-
- -
- Mute band - -
- -
-
- -
-
-
- - - - -
- - - - -
- +
+

Audio

+

Sources

+
    +
  • + + + +
    + + +
    - +

    +
  • +
  • + + +The extension brings its own mapping; the phone app uses the one below. Same switch as AUD on the panel’s NETWORK screen. +Get the extension +
  • +
+

Mapping

+

For the microphone and the phone app. Changes save to the panel as you make them.

+
+
+ +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/firmware/patternflow/features/README.md b/firmware/patternflow/features/README.md index 703c965bc..36126b313 100644 --- a/firmware/patternflow/features/README.md +++ b/firmware/patternflow/features/README.md @@ -83,7 +83,7 @@ each row says which port proved it. | hook | when | proven by | | --- | --- | --- | | `setup()` | boot, before Wi-Fi | show player, weather config, MQTT role | -| `onNetwork()` | Wi-Fi connected, and every reconnect — register HTTP routes here | `/show`, `/weather`, `/mqtt`, `/audio` | +| `onNetwork()` | Wi-Fi connected, and every reconnect — register HTTP routes here | `/show`, `/weather`, `/mqtt`, `/audio-in`, `/api/audio` | | `loop(frame)` | every frame; **must not block** | show cue table, weather polling, MQTT client | | `observeFrame(input, frame)` | the *finished* input frame, for features that mirror rather than produce | MQTT publishing knob values; OSC reporting outward | | `fillInput(input)` | before the pattern sees the frame — drive a lane from a reading | weather, audio bands, MQTT deltas | @@ -177,8 +177,8 @@ Then one line in the bundle that carries it — `firmware/bundles//feat | `show/` | player, HTTP page, night/wake schedule, library pull | The first port, deliberately the hardest. | | `weather/` | readings, HTTP page, corner clock | Grew the interface: `fillInput`, `chromeVisible`. | | `mqtt/` | client, all roles + FlowLocal, HTTP page | Grew it again: `observeFrame`, sleep in both directions, `appendStatus`. | -| `audio/` | FFT bands over a websocket, HTTP page | The one with a server of its own, and a row in the device's own menu. | -| `audio_in/` | the on-board PDM microphone: FFT on a Core-0 task, four bands onto the knob lanes, `/audio-in` | The first feature that did not already exist in the core; four hooks, no core edits. Its console page is assembled from the audio extension's editor (`toolchain/build_audio_in_page.py`). | +| `audio/` | the WebSocket on port 81 that a client capturing sound elsewhere (the browser extension, the phone app) writes the four knob lanes through, and the switch that lets it (`/api/audio`) | The one with a server of its own, and a row in the device's own menu. The analysis and the mapping happen in the client; no page is served from here. `docs/audio-ws-spec.md`. | +| `audio_in/` | the on-board PDM microphone: FFT on a Core-0 task, four bands onto the knob lanes, `/audio-in` and `/api/audio-in` | The first feature that did not already exist in the core; four hooks, no core edits. Its console page is assembled from the audio extension's mapping editor and the panel's own half in `console/_audio_in_*` (`toolchain/build_audio_in_page.py`, run by `console_pages.py build`). | | `osc/` | Max / TouchDesigner / Ableton, both directions | The fifth, and the first that did not fit — see below. | | `midi/` | MIDI in and out over RTP-MIDI (Wi-Fi) and, in the MIDI edition, USB: CC 20–23 absolute, 24–27 relative, notes, Program Change | Transport-agnostic mapping in `core_midi.h`; the network transport beside it (`core_midi_rtp.h`), and the USB one (`core_midi_usb.h`) that compiles in only when the build's USB port is the OTG stack — a build flag, which is why the MIDI edition has its own env (`bundles/midi/env`). `docs/midi-spec.md`. | | `clock/` | the time cut out of the running pattern, `/clock`, `/api/clock` | Settings in its own NVS namespace, a page with a live preview, and the first user of `composeFrame`: huge anti-aliased digits the pattern shows through (typefaces rasterised offline by `toolchain/build_clock_glyphs.py`, several faces), with what fills the digits and what surrounds them chosen separately. Owns the time zone as a POSIX string with its DST rule; the core keeps the time (`src/core_clock.h`), this shows it. | diff --git a/firmware/patternflow/features/audio/audio_config.h b/firmware/patternflow/features/audio/audio_config.h index 3b36ade16..5b7b47941 100644 --- a/firmware/patternflow/features/audio/audio_config.h +++ b/firmware/patternflow/features/audio/audio_config.h @@ -14,15 +14,19 @@ #pragma once #include "../../config.h" -// Hosts a tiny UI on the device. A browser (or the phone app) captures audio, -// runs an FFT, and pushes each band's energy as a normalized 0..1 value over -// WebSocket. The core's input layer turns that into virtual knob deltas, so -// EVERY encoder-driven pattern reacts to audio with no per-pattern code. +// A WebSocket for clients that capture sound where it is playing - the Chrome +// extension, the phone app. The client runs the FFT and the mapping and sends +// each knob a normalized 0..1 value; the core's input layer lerps that into +// the parameter's own range, so EVERY encoder-driven pattern reacts to audio +// with no per-pattern code. No page is served from here. #ifndef PF_AUDIO_ENABLED #define PF_AUDIO_ENABLED 1 #endif // The same port 80 the console lives on (PatternflowHttp::HTTP_PORT); this is -// the audio module's own alias for it and nothing else should need it. +// the audio module's own alias for it and nothing else should need it. Its one +// reader is the "[AUDIO] Ready" line in core_audio_ws.h - which is also the +// string firmware/bundles/build.sh scans an image for to prove this feature +// is in it, so neither goes without the other. #ifndef PF_AUDIO_HTTP_PORT #define PF_AUDIO_HTTP_PORT 80 #endif diff --git a/firmware/patternflow/features/audio/core_audio_ws.h b/firmware/patternflow/features/audio/core_audio_ws.h index e5bb17d96..64406b335 100644 --- a/firmware/patternflow/features/audio/core_audio_ws.h +++ b/firmware/patternflow/features/audio/core_audio_ws.h @@ -1,6 +1,8 @@ // Patternflow - Audio-react WebSocket server (single-threaded) // -// A WebSocket endpoint on port 81, and nothing else. +// Owns: the WebSocket on port 81 and the four lanes it writes. Nothing else - +// no page, no HTTP route (the on/off switch's two routes are in +// feature_audio.h, on the console's server). // // This used to serve a page at /audio too — file, tab and microphone capture // in the browser. It could never work: the device is plain HTTP on a LAN @@ -9,12 +11,13 @@ // could only ever apologise, and a file player is beside the point when the // whole value is being in sync with what is actually playing. // -// Sound reaches a panel two ways now, and neither is this page: the Chrome -// extension, which has an extension context and can capture a tab, and the -// on-board microphone in audio_in, which does not involve a browser at all. (core_web_update.h registers its -// /update routes on this same server.) Browsers connect, send normalized -// 0..1 knob values per frame, and patterns read those through -// InputFrame::knobAudioActive / knobAudioValue. +// Sound reaches a panel two ways now, and neither is that page: a client that +// can capture audio where it is playing (the Chrome extension, which has an +// extension context and can capture a tab; the phone capture app), and the +// on-board microphone in audio_in, which involves no client at all. This +// file is the first kind's way in. A client connects, does its own analysis +// and mapping, and sends each knob a normalized 0..1 value; patterns read +// those through InputFrame::knobAudioActive / knobAudioValue. // // Threading: everything runs in the main Arduino loop via handle(). An // earlier version pushed this into a pinned core-0 FreeRTOS task guarded by @@ -23,13 +26,19 @@ // inline is simpler and robust — the messages are tiny, so the per-frame // cost is negligible. // -// Message protocol: +// Message protocol (the contract is docs/audio-ws-spec.md; this is a copy): // a=F,F,F,F all four lanes at once; '-' leaves a lane alone // k=N,v=F set knob N (0..3) to value F (clamped to 0..1) -// d=N,v=F add normalized delta F to knob N (-1..1) +// d=N,v=F add normalized delta F to knob N (-1..1). Nothing in this tree +// sends it any more; it is in version 1 of the written contract +// and earlier extensions spoke nothing else, so it stays. // off=N release knob N back to encoder control // off release all four knobs // +// A lane holds for AUDIO_TIMEOUT_MS after its last message and is then handed +// back to the encoder. That is what covers a tab closing mid-track - and it +// means a client holding a steady value has to keep saying it. +// // `a=` exists because sending four `k=` messages per frame did not work. A // browser client checks `bufferedAmount` before each send so it cannot // outrun a one-connection server, and after the first send of a frame that @@ -54,17 +63,6 @@ #include "../../src/core_http.h" #endif -#include "../../src/core_send.h" - -// Forward declaration: the home page pauses a loaded pattern module the same -// way the other console pages do (see core_patterns_http.h for why), but this -// header is included first, so only the declaration is available here. The -// definition lands later in the same translation unit. -namespace PatternflowPatternsHttp { -bool noteConsolePageOpened(); -void sendConsoleWakePage(); -} - namespace PatternflowAudio { #if PF_AUDIO_ENABLED @@ -282,6 +280,8 @@ inline void begin() { initialized = true; + // "[AUDIO] Ready" is the literal firmware/bundles/build.sh looks for in an + // image to prove this feature is in it. Reword the rest, not those two words. String ip = WiFi.localIP().toString(); Serial.printf("[AUDIO] Ready — UI http://%s:%d WS ws://%s:%d\n", ip.c_str(), PF_AUDIO_HTTP_PORT, ip.c_str(), PF_AUDIO_WS_PORT); diff --git a/firmware/patternflow/features/audio/feature_audio.h b/firmware/patternflow/features/audio/feature_audio.h index f314e53be..43a836ace 100644 --- a/firmware/patternflow/features/audio/feature_audio.h +++ b/firmware/patternflow/features/audio/feature_audio.h @@ -95,11 +95,12 @@ inline void fillInput(InputFrame& input) { // thing that drives them, and what a source is sending is not what the // pattern ends up seeing. // -// Neither -// was visible from outside the device: a browser could complete a websocket -// handshake, send knob messages, and have every one of them silently dropped -// because the AUD row on the NETWORK screen was off — with nothing anywhere -// saying so. The page reported CONNECTED and the panel did not move. +// Neither used to be visible from outside the device: a browser could +// complete a websocket handshake, send knob messages, and have every one of +// them silently dropped because the AUD row on the NETWORK screen was off — +// with nothing anywhere saying so. The client reported CONNECTED and the +// panel did not move. `audioClients` counts open sockets, which is why it +// cannot answer "is it being driven" on its own: read it with `audioRuntime`. inline void appendStatus(String& json) { json += ",\"audioRuntime\":"; json += PatternflowAudio::isRuntimeEnabled() ? "true" : "false"; diff --git a/firmware/patternflow/features/audio_in/audio_in_index.h b/firmware/patternflow/features/audio_in/audio_in_index.h index 1574cde25..762fd8886 100644 --- a/firmware/patternflow/features/audio_in/audio_in_index.h +++ b/firmware/patternflow/features/audio_in/audio_in_index.h @@ -14,1476 +14,1288 @@ static const char AUDIO_IN_INDEX_HTML[] PROGMEM = R"HTML( -Patternflow - Mic mapping - +Patternflow - Audio + + + +
+

Audio

+

Sources

+
    +
  • + + + +
    + + + +
    +

    +
  • +
  • + + +The extension brings its own mapping; the phone app uses the one below. Same switch as AUD on the panel’s NETWORK screen. +Get the extension +
  • +
+

Mapping

+

For the microphone and the phone app. Changes save to the panel as you make them.

+
+
+ +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 + +
+
+
+
+
+
+
+
+ + + + + + +
+
+ +
+
+
+ + +
+
+ +
+ + )HTML"; // GENERATED by firmware/toolchain/console_pages.py build — do not edit. -// gzip -9 of AUDIO_IN_INDEX_HTML (69547 bytes → 19920 bytes), served with Content-Encoding: gzip. +// gzip -9 of AUDIO_IN_INDEX_HTML (57490 bytes → 17217 bytes), served with Content-Encoding: gzip. static const uint8_t AUDIO_IN_INDEX_HTML_GZ[] PROGMEM = { - 0x1f,0x8b,0x08,0x00,0x00,0x00,0x00,0x00,0x02,0xff,0xd5,0x7d,0xdb,0x76,0x1b,0x47, - 0x92,0xe0,0xbb,0xbe,0x22,0x45,0xf7,0x18,0x80,0x05,0x14,0x01,0xf0,0x22,0x09,0x14, - 0xa5,0xa5,0x28,0xca,0xd2,0x5a,0x17,0x1e,0x91,0x6e,0xcd,0xb4,0x96,0xeb,0x29,0x00, - 0x05,0xa0,0xcc,0x42,0x15,0x5c,0x55,0x20,0x09,0xab,0xb9,0xa7,0x9f,0xe6,0x03,0x66, - 0xe7,0x9c,0x3d,0xfb,0x25,0xf3,0x3e,0x9f,0xd2,0x5f,0xb2,0x71,0xc9,0x6b,0x5d,0x40, - 0x48,0xf6,0xcc,0x76,0x77,0xdb,0x26,0xaa,0x2a,0x33,0x32,0x33,0x32,0x32,0x6e,0x19, - 0x19,0xf9,0xe4,0xfe,0x38,0x19,0xe5,0xab,0x45,0x20,0x66,0xf9,0x3c,0x7a,0x7a,0xef, - 0x09,0xfe,0x11,0x91,0x1f,0x4f,0x0f,0xb7,0x82,0x78,0x0b,0x5f,0x04,0xfe,0x18,0xfe, - 0xcc,0x83,0xdc,0x17,0xa3,0x99,0x9f,0x66,0x41,0x7e,0xb8,0xb5,0xcc,0x27,0x9d,0x47, - 0xf8,0x35,0x1b,0xa5,0xe1,0x22,0x17,0x59,0x3a,0x3a,0xdc,0xda,0x5e,0x4c,0x3a,0xa3, - 0x24,0xce,0x92,0x28,0xf0,0x7e,0xce,0x9e,0xcd,0x0e,0x83,0xc9,0x64,0x32,0xde,0xeb, - 0xee,0x6d,0x3d,0x7d,0xb2,0xcd,0x05,0x9f,0xca,0x0a,0x4f,0xc3,0x49,0xf3,0xfe,0x75, - 0x18,0x8f,0x93,0x6b,0xef,0xf4,0x65,0xab,0x39,0x59,0xc6,0xa3,0x3c,0x4c,0xe2,0x66, - 0xeb,0xf3,0x95,0x9f,0x8a,0x8f,0x87,0xfc,0xad,0xfd,0xe3,0x61,0x63,0xdb,0x5f,0x84, - 0xdb,0x59,0xee,0xe7,0xcb,0xac,0xd1,0x7e,0x73,0xf8,0xe9,0xa2,0xfd,0xf6,0xb0,0x1f, - 0x3c,0x6e,0x9f,0xb5,0xff,0xd4,0x3e,0x3d,0xfc,0x08,0xd5,0x0f,0x3f,0xe3,0xe7,0x60, - 0xd0,0x88,0xc2,0xab,0xa0,0xd1,0x1e,0x87,0x69,0xbe,0x1a,0xdc,0xef,0xb5,0xaf,0x06, - 0xf1,0x32,0x8a,0xda,0xd3,0x20,0x1f,0x7c,0xdf,0x0e,0xe3,0x49,0x14,0x4e,0x67,0xf9, - 0xe0,0xdd,0x72,0x3e,0x0c,0xd2,0xf6,0x2c,0x89,0xc6,0x83,0xf7,0xc3,0x9f,0x83,0x51, - 0xde,0xce,0xfc,0xd5,0x40,0xf7,0x20,0x6f,0x5f,0xb6,0x83,0xd6,0xe7,0xe0,0xdb,0x6f, - 0x9b,0x81,0x97,0x07,0x37,0xf9,0x71,0x12,0xe7,0x41,0x9c,0x1f,0xe6,0xad,0xdb,0xf6, - 0x70,0x99,0x59,0x45,0x87,0xed,0x45,0xeb,0xb3,0x7a,0x12,0x13,0xe8,0xfc,0xd0,0x1b, - 0x87,0x99,0x3f,0x8c,0x82,0xf1,0xe1,0xfd,0xde,0xad,0xfd,0xd4,0x3d,0x58,0x78,0xf9, - 0x2c,0x88,0x9b,0x93,0xf6,0xa4,0x75,0x90,0x06,0xf9,0x32,0x8d,0xc5,0xe2,0xb6,0xbd, - 0x48,0xa2,0xc8,0x40,0xcc,0xda,0xf3,0xf6,0xa8,0x9d,0x30,0x16,0xf2,0xf6,0xa4,0x3d, - 0x3d,0xec,0x1d,0xe8,0x16,0x3e,0x40,0x0b,0xd3,0x6f,0xbf,0xbd,0x3f,0x81,0xbe,0x4d, - 0x0e,0x7b,0xed,0x51,0x14,0xf8,0xe9,0x79,0x38,0x0f,0x92,0x65,0xde,0xcc,0x5b,0xed, - 0x66,0xe6,0x8d,0xfc,0x28,0x7a,0x96,0x35,0x5b,0x83,0xef,0x9b,0x59,0xab,0x25,0x5b, - 0x54,0xd0,0xc7,0xad,0xcf,0xa3,0xe6,0xb8,0x8d,0x58,0x81,0xb1,0xe8,0xd7,0x01,0xbe, - 0x26,0x54,0x05,0xad,0x5b,0x59,0xe7,0x87,0xf6,0x0f,0xad,0xd6,0xad,0x6e,0xf9,0x07, - 0x68,0x79,0x72,0xd8,0x3d,0x28,0xb4,0x78,0x00,0xbd,0x69,0xe6,0x87,0x40,0x12,0xea, - 0xdd,0x87,0xf6,0x1c,0xea,0x25,0xdf,0x7e,0x9b,0x78,0x93,0x30,0xcd,0xf2,0xc3,0x43, - 0x40,0xc4,0x33,0xa8,0x3e,0x80,0xce,0xcb,0x61,0xc3,0x74,0x25,0x8b,0x81,0x35,0xe5, - 0xd3,0x32,0xe4,0xdb,0x36,0x00,0x35,0x65,0xe2,0xd6,0xe7,0xf9,0x61,0x7c,0x30,0xf9, - 0xf3,0x9f,0x01,0xd4,0x6d,0x3b,0x4e,0xae,0x07,0x1f,0x6e,0xa1,0x0c,0x91,0x85,0x29, - 0x36,0x82,0x91,0x7c,0xfb,0xed,0x1b,0x6f,0xb1,0xcc,0x66,0xf0,0x70,0x70,0xf6,0x0c, - 0x1e,0x47,0xcd,0xb3,0xd6,0xe0,0x4f,0xcf,0xfe,0xe4,0x41,0x2d,0xe8,0xc7,0x9f,0x0e, - 0x4f,0x3d,0x44,0x7a,0xf3,0xc7,0xf6,0xdb,0xf6,0x0b,0x00,0x76,0xed,0xe7,0xa3,0xd9, - 0x59,0x01,0xd2,0xbc,0xf5,0xf9,0xd4,0x63,0xf0,0xd0,0xef,0x3f,0x79,0xd0,0x9b,0xe6, - 0xdb,0xc3,0xb7,0x7e,0x3e,0xf3,0xe6,0x21,0x7c,0x6e,0xbf,0x6d,0x51,0xd5,0x30,0x7f, - 0x99,0xa4,0x2f,0x82,0xab,0x70,0x14,0x98,0xca,0x30,0x7f,0xc9,0x61,0xf2,0xe7,0x3f, - 0x7f,0xbe,0x55,0x13,0x1d,0x07,0xd7,0xe2,0x34,0x4d,0xe6,0x61,0x16,0x98,0xe9,0x58, - 0xb5,0x63,0x9e,0xe8,0x99,0xe9,0x52,0x3f,0xd8,0x31,0x13,0x93,0xb5,0x3e,0x67,0x30, - 0xdd,0xcd,0xc4,0x1b,0x2e,0xc3,0x68,0xfc,0xed,0xb7,0x19,0xff,0x38,0x3c,0x94,0x6f, - 0x5a,0x80,0xfe,0x99,0x87,0xe8,0x6c,0xb6,0xda,0x2b,0x9c,0xf1,0xdb,0xd6,0x81,0x35, - 0x1b,0x16,0x8e,0x55,0xb1,0x83,0xb8,0x79,0x92,0xa6,0x49,0xda,0x6c,0xe4,0x5c,0xa8, - 0x81,0x03,0x49,0x3c,0xf9,0xf4,0xe7,0x3f,0x3f,0x0e,0x76,0x01,0xca,0xed,0xad,0x21, - 0xbb,0xef,0x9b,0x4b,0x24,0x49,0x39,0x94,0x49,0x00,0xe8,0xa2,0x37,0x05,0xea,0x4a, - 0x5b,0x9f,0x71,0x55,0xa7,0x5e,0x72,0xd9,0xca,0x67,0x69,0x72,0x2d,0xb8,0xa1,0x54, - 0xe2,0x51,0x53,0x3d,0x51,0x07,0xae,0xac,0x67,0x29,0xfd,0x81,0x49,0x49,0x81,0x61, - 0x60,0x37,0x6f,0x2d,0x9a,0x7b,0x81,0xe3,0x07,0x88,0xf0,0xdf,0x33,0x58,0xe7,0x8b, - 0x09,0xcf,0xd1,0x61,0x76,0xf0,0xc6,0x9b,0x24,0xe9,0x89,0x0f,0xdd,0xd0,0x8d,0x4f, - 0x80,0x3e,0xa1,0x24,0x76,0xfc,0xb6,0xd5,0x6c,0x69,0x7e,0x23,0x19,0x57,0xec,0xcf, - 0x83,0xc3,0xad,0xab,0x30,0xb8,0x5e,0x24,0x69,0xbe,0x25,0x46,0x72,0x4d,0x6f,0x5d, - 0x87,0xe3,0x7c,0x76,0x38,0xa6,0x09,0xec,0xd0,0x03,0x70,0x8a,0x30,0x0f,0xfd,0xa8, - 0x93,0xc1,0x5a,0x0a,0x0e,0x7b,0xc8,0xe5,0xf2,0x30,0x8f,0x82,0xa7,0xa7,0x7e,0x9e, - 0x07,0x29,0xf0,0x11,0x18,0x5a,0x47,0xbc,0x0d,0x47,0x62,0xee,0x2f,0x16,0x61,0x3c, - 0x7d,0xb2,0xcd,0x05,0xee,0x3d,0xb9,0xdf,0xe9,0x88,0xef,0x4f,0xde,0x9d,0x7c,0x38, - 0x3a,0x3f,0x79,0x21,0x86,0x2b,0x01,0x8b,0x60,0x7e,0xed,0xa7,0xc1,0x76,0x9e,0x24, - 0x11,0xb0,0xcf,0x30,0xde,0xa6,0x99,0xfb,0xc9,0x5f,0x8e,0xc3,0xe4,0xa7,0x30,0xfe, - 0x69,0xe1,0x4f,0x03,0x6f,0x01,0x25,0x81,0x3c,0xee,0x09,0xfc,0x1f,0x16,0xcd,0xb6, - 0x17,0xa6,0xb5,0x0e,0x15,0xee,0x00,0xaa,0x82,0x38,0x43,0xd4,0x34,0x83,0x71,0x98, - 0x27,0xa9,0xe8,0x3f,0xee,0x06,0x7b,0x7b,0xfd,0xdd,0xbd,0xfd,0x96,0xc7,0x75,0x4f, - 0xe0,0x83,0x80,0x79,0x11,0xb2,0x04,0xfc,0x4c,0x83,0xb6,0x48,0x83,0x74,0x19,0xd3, - 0x7b,0x6a,0x1d,0xf8,0x1f,0x3e,0xc4,0x42,0xb2,0x6b,0xea,0x44,0x86,0xbd,0xa0,0xcf, - 0x9e,0xe8,0x74,0x90,0xb7,0xe7,0x2b,0x1c,0xd4,0xf6,0x77,0xc2,0x1e,0xf9,0x11,0xf6, - 0x45,0xfc,0xf5,0x2f,0xff,0xa6,0x46,0x2f,0x9b,0xf2,0xc4,0x19,0x20,0x59,0x8c,0xd2, - 0xc0,0x9f,0x8b,0x30,0xce,0xf2,0x74,0x39,0x07,0x14,0x8b,0x05,0x60,0x11,0x6a,0x0b, - 0x3f,0xc3,0x26,0xb1,0x97,0x8b,0x64,0xb1,0x5c,0x88,0x26,0xfd,0xf1,0x46,0x59,0x26, - 0x42,0xfa,0x24,0xb2,0x64,0x99,0x8e,0x02,0x91,0x4c,0xf0,0x29,0x0b,0xc4,0x95,0x1f, - 0x2d,0x83,0xac,0xe5,0x89,0xef,0xb6,0xef,0x0d,0xd2,0x24,0xc9,0xc5,0x67,0xa8,0xde, - 0xe9,0x50,0x13,0x03,0xf1,0xcd,0x64,0x37,0x98,0x04,0xfb,0x07,0xe6,0x5d,0xa7,0x0f, - 0x6f,0x83,0x71,0xf0,0x70,0x3c,0xe4,0xb7,0x61,0x7c,0x09,0x6f,0x7a,0xbb,0xf8,0x7f, - 0x7e,0x33,0x5f,0xe6,0xc1,0x18,0xde,0xed,0x0f,0xf7,0xf7,0xf6,0x7c,0x7e,0x37,0x81, - 0x59,0xc9,0xe1,0x9d,0xbf,0xff,0x78,0xf2,0xb8,0xcb,0xef,0xd2,0x65,0x14,0xc0,0xab, - 0xf1,0xe3,0x71,0x6f,0x24,0x5f,0x45,0x54,0x31,0x78,0x04,0x08,0x0f,0xf8,0x4d,0x82, - 0xd0,0xfb,0xfb,0x0f,0xfd,0xdd,0x1d,0x7e,0x31,0xf4,0xb1,0xc8,0x70,0x77,0x67,0xd8, - 0x1f,0x4b,0xd8,0x61,0x10,0xe1,0xbb,0xc9,0x70,0xf2,0x30,0x98,0xc8,0x3e,0x24,0x71, - 0x32,0x10,0xcb,0x90,0x7e,0x64,0x0b,0x7f,0x04,0x13,0x74,0xf6,0xf2,0x2d,0x3c,0x74, - 0x3e,0x04,0xd3,0x65,0xe4,0xc3,0xec,0xbc,0x0d,0xe2,0x28,0x69,0x8b,0x63,0x9a,0x1f, - 0x3f,0x6b,0x0b,0x5d,0xf6,0xe0,0xde,0xed,0xbd,0x7b,0xdf,0x89,0xcf,0x62,0x98,0xdc, - 0x74,0xb2,0xf0,0x57,0x98,0x81,0x01,0xfc,0x4e,0x61,0x52,0x3b,0xf0,0xea,0x40,0xc0, - 0xe7,0x61,0x32,0x5e,0x11,0xb2,0xe6,0x7e,0x3a,0x0d,0xe3,0x81,0xa0,0x21,0x0c,0xfd, - 0xd1,0xe5,0x34,0x4d,0x96,0x31,0x74,0x08,0xd8,0x4e,0x53,0x62,0xad,0x85,0xdf,0x46, - 0x49,0x94,0xa4,0xea,0x35,0xa0,0x8d,0x5e,0x4e,0x12,0xc4,0x4b,0xaf,0xbf,0xb8,0xd9, - 0xee,0x79,0xbb,0x7b,0xe2,0x35,0x2c,0x1a,0xe8,0x1b,0xf4,0x3c,0xf3,0xe3,0xac,0x93, - 0x05,0x69,0x38,0x69,0x8b,0x6c,0x95,0xe5,0xc1,0xbc,0xb3,0x0c,0xe1,0xa7,0x7e,0x4d, - 0xbd,0xf4,0x90,0xac,0x64,0x3f,0x6e,0x78,0x75,0x21,0xb8,0x47,0xdd,0xc5,0xcd,0x81, - 0xdd,0x39,0xe1,0x2f,0xf3,0x04,0xdf,0x2c,0xfc,0xf1,0x98,0xc6,0xd3,0xdf,0xe5,0x22, - 0x20,0x3f,0x17,0x11,0x48,0x67,0x31,0x89,0x02,0x7a,0x81,0x7f,0x3b,0x20,0xe1,0x03, - 0x5a,0xf6,0x03,0xec,0xf7,0x72,0x1e,0xe3,0x97,0xa9,0xbf,0x00,0xe0,0x54,0x0f,0x9b, - 0x46,0x7c,0x01,0x92,0x70,0x08,0x30,0xc1,0xf3,0x30,0x5a,0xa9,0xd1,0xe1,0x97,0x16, - 0xa2,0x09,0xa4,0xf3,0xfc,0x1c,0x16,0x15,0x14,0x73,0x86,0x4f,0x34,0xc2,0x25,0x66, - 0x40,0x19,0x0a,0x0a,0xe0,0x1a,0x48,0xa2,0xd7,0x83,0x16,0xdc,0xf2,0x44,0x3f,0x5c, - 0x9e,0x66,0x28,0xc5,0x1a,0xd8,0x4f,0x40,0xf6,0x35,0xd4,0xa0,0x29,0x81,0x55,0xf4, - 0xd7,0x7f,0xfb,0x0b,0xfc,0x23,0x50,0xa1,0x82,0x32,0xf2,0x09,0x68,0x5b,0xbe,0xf8, - 0x5c,0x18,0xad,0xf0,0x41,0x61,0x89,0x3b,0x21,0x20,0x37,0x83,0x81,0x06,0x88,0xfc, - 0x03,0xf1,0xf3,0x32,0xcb,0xc3,0xc9,0xaa,0x23,0x39,0xd8,0x40,0x50,0x93,0x9d,0x61, - 0x90,0x5f,0x07,0x41,0x7c,0x20,0xd1,0xd0,0x5f,0x10,0x21,0x78,0xc3,0xd4,0x8f,0xc7, - 0x1b,0x42,0xa6,0x9a,0x8f,0x65,0x45,0x20,0x76,0xa8,0x26,0xa7,0xec,0x11,0xbe,0x9c, - 0x05,0xa4,0x3e,0xf1,0x43,0x99,0x94,0x22,0x42,0x99,0xa4,0xc3,0xd4,0x1f,0x87,0x20, - 0x4d,0x45,0x4f,0x42,0x23,0xf6,0x58,0xc0,0x23,0xce,0x14,0xbf,0xb8,0x96,0x90,0xf7, - 0xbb,0x5d,0x2a,0x7d,0x19,0x8e,0x2e,0x19,0x89,0xb5,0x93,0x67,0x03,0x42,0x6a,0x12, - 0xc4,0x63,0xd2,0x0e,0x22,0x83,0x08,0xa8,0xeb,0x75,0x1f,0x07,0xf3,0x83,0xda,0x89, - 0x1d,0x45,0x80,0xc7,0x8d,0x91,0x4e,0xa8,0x79,0xc4,0x83,0x81,0xba,0xb3,0x70,0x41, - 0x54,0xcd,0xa3,0xa5,0x61,0x02,0xf3,0x8a,0xc2,0xb1,0x6c,0x07,0x99,0x47,0x19,0x19, - 0xfd,0x6a,0xc4,0x11,0x87,0x68,0x39,0xd4,0xbf,0x07,0xf0,0x78,0x58,0x9b,0x61,0xa0, - 0x4c,0x92,0xbc,0x82,0x6f,0xb9,0xaf,0x48,0xe7,0x6b,0x68,0x3c,0xb9,0x3c,0xa6,0x01, - 0xa9,0xfe,0x3a,0xe5,0x40,0x96,0x17,0x40,0xd3,0x1b,0x5a,0xdc,0x69,0x08,0x2b,0x78, - 0xf5,0x3c,0x8f,0x09,0x19,0xcc,0x2c,0xc2,0x18,0x04,0x4e,0x98,0xbb,0xfd,0xa3,0x91, - 0x8f,0x96,0x69,0x86,0x50,0x16,0x49,0x48,0x48,0x5d,0x83,0x3f,0xea,0x7d,0x15,0xfa, - 0x2a,0x99,0x18,0x97,0x76,0xfa,0x28,0xf9,0x9a,0xc1,0xe8,0x3e,0x62,0x54,0xf2,0x06, - 0xab,0xe3,0x03,0xa5,0x9f,0xc3,0xe8,0x13,0x24,0x1d,0x30,0x1c,0x80,0x74,0x76,0xf6, - 0x4c,0x77,0xc7,0xc1,0xc4,0x5f,0x46,0x39,0x61,0x6a,0x3a,0x4b,0xb2,0x7c,0x83,0xf1, - 0xf6,0xbe,0x70,0xbc,0xf5,0xf4,0x52,0x39,0x60,0x49,0x31,0xd5,0x13,0xea,0x0e,0xb9, - 0x2f,0x87,0xac,0x7a,0x3e,0x98,0x25,0x57,0x44,0xf5,0x55,0x73,0xcd,0x4b,0xb8,0xe2, - 0x95,0xc3,0xbe,0x50,0x3b,0x19,0x82,0xd2,0xba,0x0d,0xbf,0xa6,0xd3,0x28,0xc8,0x2c, - 0x4e,0xe6,0xa9,0x8f,0x5f,0xb0,0xac,0x98,0xd0,0x91,0x1b,0xfb,0x73,0x52,0x27,0xbe, - 0x7c,0x49,0xea,0xaa,0x61,0xbc,0x58,0xe6,0x86,0x6f,0xf5,0x76,0x08,0xb6,0x3f,0xc2, - 0x4a,0x9d,0xca,0x81,0xe9,0xaa,0x8e,0xc0,0xb0,0xe7,0x11,0xd4,0x7d,0x25,0xba,0xf6, - 0xf6,0xf1,0x05,0xaa,0xac,0x1d,0xea,0xd3,0x40,0xa4,0xc8,0xb9,0x98,0xc5,0x11,0x32, - 0x3e,0xa6,0xfe,0xa2,0x8a,0x3a,0x8a,0xc4,0xb0,0xf9,0x10,0x1f,0x4a,0x2a,0xf8,0xd2, - 0xa5,0xb2,0x86,0x6e,0x5c,0x3e,0xd3,0x93,0x24,0xc2,0x03,0x78,0xe3,0x0f,0x83,0xa8, - 0x0a,0x0d,0x0e,0xa7,0xde,0x93,0x9c,0x9a,0xeb,0xd0,0x80,0x25,0x8a,0xfa,0x7d,0x5b, - 0x56,0xf4,0x6a,0x7a,0x52,0x4d,0xf0,0x72,0xa8,0x8b,0x24,0x0b,0x59,0xb8,0xa7,0x41, - 0xe4,0xe7,0x60,0xd3,0x5b,0xe8,0x0a,0xe3,0x28,0x8c,0x41,0xdc,0x45,0xc9,0xe8,0xf2, - 0x40,0x63,0xa5,0x5b,0x81,0x61,0x3d,0xca,0xae,0xd2,0x1b,0x06,0x22,0x4e,0xe2,0xc0, - 0x1a,0xac,0xf0,0xc6,0x49,0xfe,0x43,0x5b,0xd8,0x93,0x47,0xaf,0x68,0x44,0xa6,0x1f, - 0xfe,0x10,0x50,0x0e,0xeb,0x0b,0xc5,0xcc,0x24,0x97,0x62,0x0d,0x6d,0x58,0xfe,0xa5, - 0xa8,0xad,0xeb,0x0c,0xbd,0x7b,0xd7,0xf2,0x2d,0x8c,0x7e,0xaf,0xfb,0x0f,0x00,0x14, - 0xe4,0xb5,0x6a,0x15,0xdb,0x02,0x66,0xd4,0xeb,0x67,0x56,0x97,0xb9,0x8f,0x0a,0xef, - 0x6a,0xb4,0x9d,0xe0,0x0a,0x68,0x26,0x93,0x03,0x74,0xc9,0xd1,0x03,0x7b,0xc1,0x54, - 0x28,0x77,0x47,0xf2,0xf3,0x62,0x0d,0x46,0x83,0x1a,0xb0,0x16,0xe4,0x54,0xc8,0xcb, - 0xe6,0x7e,0x14,0x61,0xb1,0xbb,0x01,0x9a,0xb2,0xd5,0x20,0x2d,0xce,0x32,0x07,0x3d, - 0x4a,0x2c,0x22,0xd0,0xf3,0x2d,0x8e,0x82,0xcf,0x7a,0x55,0x55,0x91,0xc6,0x3a,0x89, - 0xfa,0x15,0x0c,0xf7,0xf6,0xde,0x37,0xd4,0x05,0x8b,0x0b,0x49,0x62,0x2b,0x49,0x84, - 0x3c,0x59,0x8e,0x66,0x1d,0x5f,0x6a,0xa2,0x06,0xf5,0xfe,0xb4,0x96,0x7e,0x36,0x5e, - 0xf6,0xfb,0x75,0xd4,0x23,0x0d,0x9d,0xd6,0xc1,0xd7,0x49,0x13,0xa3,0x61,0x43,0x2d, - 0x5c,0x70,0xd5,0x24,0x74,0x8f,0xc7,0xe1,0x65,0xc4,0x0c,0xea,0x85,0x85,0x2c,0x46, - 0xc2,0xe7,0x1c,0xc7,0x6d,0x8b,0xd1,0xbd,0x3d,0x55,0x40,0x6a,0xd9,0x5f,0xa0,0xcf, - 0x39,0x84,0x01,0x98,0xbf,0x0a,0xa0,0xeb,0x31,0xf4,0xc6,0x26,0x0d,0x7a,0xf1,0xb9, - 0x64,0x26,0x30,0x06,0xfb,0xdd,0xbb,0xf4,0xad,0xaf,0x50,0xdf,0x34,0xfa,0x7a,0x24, - 0x5e,0x1f,0x49,0xde,0x09,0x98,0x29,0x8b,0xad,0x1a,0x53,0xc5,0x95,0x5c,0xf0,0xd2, - 0x43,0x33,0xa1,0x64,0x32,0xb8,0x7a,0x3c,0x14,0xfb,0xe1,0xbf,0x40,0x2b,0xbe,0x4a, - 0x2d,0xe1,0xb9,0x96,0x75,0xc3,0x32,0xe1,0x59,0x29,0xaf,0x93,0xea,0x65,0x81,0x6a, - 0x17,0x18,0xf5,0x79,0x76,0x4c,0xb8,0x52,0xe2,0x62,0x5f,0x21,0x82,0xbf,0x7e,0x9f, - 0x86,0x8e,0xb9,0x32,0x85,0xe7,0x03,0xfa,0x6f,0x07,0x16,0x09,0xbc,0xcb,0x83,0x0e, - 0xe3,0x31,0x43,0x1e,0xb0,0x08,0xfc,0xbc,0xb9,0xd3,0x46,0x19,0x0d,0x16,0x66,0xb3, - 0xdb,0x16,0xbd,0x49,0xda,0x6a,0x15,0x70,0xcc,0xa0,0x37,0x11,0xcb,0x65,0x52,0xaa, - 0x9b,0xc4,0xfa,0x95,0xbb,0xb7,0x5e,0x60,0x7f,0x91,0x6d,0x60,0xec,0x73,0x4d,0x78, - 0x60,0xa0,0x89,0x5d,0xb9,0x76,0xef,0x99,0xc1,0xc1,0x14,0xc7,0x05,0xb1,0xdd,0xad, - 0x16,0xdb,0x65,0x3b,0xc1,0xe0,0x48,0xb2,0xf4,0x75,0xcb,0xdd,0x14,0xb4,0x9b,0x2c, - 0x9a,0x70,0x60,0x5b,0x2d,0xb2,0x0f,0x44,0xd6,0x5f,0xa4,0xe9,0xdc,0x61,0xd6,0x58, - 0xa4,0xaa,0x5a,0xf8,0x34,0x0b,0xc7,0xe3,0x20,0xbe,0xb0,0x5b,0xd2,0xac,0x58,0x77, - 0x63,0xb8,0xcc,0xf3,0x24,0xde,0x48,0x33,0x53,0xe4,0xef,0x18,0xbd,0xfc,0x64,0xa9, - 0x14,0x82,0xd4,0x10,0xfd,0xf9,0xf7,0x9e,0xf1,0x6a,0x6b,0xce,0x62,0x89,0x30,0x07, - 0x39,0x74,0x25,0x93,0x04,0x69,0xb3,0xc5,0x21,0x98,0xfc,0xaf,0x02,0xff,0x2e,0xb3, - 0x7f,0xe8,0x03,0x73,0x0f,0x11,0x51,0xce,0x62,0xc1,0xda,0xef,0xd0,0x91,0xe7,0x12, - 0xd3,0x4e,0xb5,0xb5,0x7e,0x3d,0x03,0x68,0xc4,0x5f,0x02,0x44,0xfb,0x35,0x08,0x6a, - 0x82,0x42,0x4c,0xf6,0x0b,0x59,0xe2,0xbe,0xec,0x01,0xd5,0xdd,0x9c,0x76,0x36,0x72, - 0x88,0x3c,0xb2,0x61,0xd7,0xea,0xb8,0x35,0xb4,0x06,0x74,0x14,0x54,0x98,0x25,0xe8, - 0xa9,0x88,0x93,0xe1,0x3a,0x7b,0x70,0x43,0xcb,0xfd,0xcb,0x2d,0xc5,0xcd,0xe8,0x46, - 0x0e,0xc1,0x66,0x1f,0xda,0x10,0x56,0xf4,0x88,0xda,0x98,0xb2,0x6f,0xba,0xc4,0x51, - 0xe4,0xa0,0x06,0xb4,0x7f,0xd3,0x19,0xcd,0x42,0x9a,0xc9,0x8a,0xe2,0x3d,0x0b,0x8c, - 0x45,0xd6,0xa2,0x0b,0xff,0x57,0x82,0x4b,0x01,0x8b,0xfc,0x32,0x2c,0x55,0x87,0x4a, - 0x73,0x4d,0xbb,0x4e,0x3d,0x33,0x92,0x46,0xcf,0x97,0x78,0x02,0x70,0x16,0x93,0x65, - 0x7e,0x9e,0x42,0x25,0xd2,0x9b,0xcb,0x8a,0xa4,0xbb,0xd6,0x4b,0x7c,0xa1,0x46,0xdb, - 0x03,0xa0,0x1f,0xfc,0x30,0x72,0x60,0x16,0xed,0x05,0xc2,0x77,0xca,0xd0,0x77,0xb5, - 0xed,0xe0,0x30,0x98,0xfe,0x1d,0xd2,0x16,0xdb,0x79,0x19,0x46,0x75,0xed,0x10,0xc0, - 0x87,0xde,0x9e,0x0d,0x72,0x67,0x9d,0xa3,0x8e,0x21,0xbe,0x82,0xc5,0x2e,0xed,0xb7, - 0x5a,0xa8,0xbb,0x96,0x89,0xf3,0xf8,0x6b,0x2c,0x9c,0x1a,0xa3,0xd5,0xe5,0x67,0x93, - 0x24,0x41,0x77,0x1c,0xba,0xab,0x1c,0x87,0x02,0xbf,0xf8,0x0a,0x97,0x00,0x56,0x3c, - 0xf6,0xd3,0xf1,0xd7,0x89,0xfd,0xf5,0xf0,0xbf,0x6a,0xd0,0x9b,0xa9,0xe5,0x0f,0x6d, - 0xaf,0x8d,0x1a,0xc3,0x26,0x6a,0xb8,0x2e,0x4b,0x8b,0x5e,0x7a,0xf4,0xca,0xca,0x38, - 0x16,0x7b,0x91,0x58,0x3e,0x92,0x7d,0x7b,0x4a,0xf7,0xbf,0xc2,0xb7,0xab,0x11,0x5d, - 0xeb,0x41,0x81,0x42,0x7e,0x2a,0x75,0xdc,0xa2,0x7e,0x52,0xe3,0x48,0xb7,0x28,0x03, - 0xc8,0x14,0x9d,0x3a,0x52,0x7b,0x14,0x0f,0xf0,0x17,0x6e,0xe4,0xd9,0x54,0x02,0x65, - 0x4e,0xe5,0xf7,0xcf,0x95,0xc6,0x80,0x92,0x2d,0xba,0xe0,0x5d,0x2e,0xbc,0xee,0x7f, - 0xae,0x0b,0x6f,0x1d,0xbb,0x36,0xc4,0x00,0x2b,0x58,0x28,0x13,0x43,0xf7,0xfc,0xab, - 0x5d,0x78,0xde,0xe2,0xea,0x3d,0xd4,0x04,0xd4,0x14,0x16,0xfc,0x24,0xbc,0x09,0x40, - 0xcb,0x0e,0x63,0x84,0x8e,0x3c,0xf8,0x57,0x58,0xa1,0x63,0xf4,0x97,0xec,0x95,0xf6, - 0x8d,0xd2,0xe9,0xd0,0x6f,0xf6,0x41,0xd1,0x56,0xff,0x76,0xbd,0xdd,0xbd,0xd6,0xa6, - 0x2b,0xa8,0x24,0xab,0xe5,0x07,0x52,0x64,0x55,0xef,0xd6,0x6a,0x74,0x8b,0x2b,0xbd, - 0xa8,0xeb,0xb1,0xfa,0x45,0xce,0xb2,0x9d,0xc2,0x0a,0x64,0xf6,0xff,0x25,0x8a,0x4b, - 0x4f,0x4e,0xb3,0x5a,0x51,0xbb,0x44,0x3c,0xd6,0x96,0xd7,0xe3,0xdd,0xab,0x6b,0x39, - 0xc6,0x6a,0xc5,0xec,0x2e,0x1d,0xa6,0x12,0x99,0x8c,0x8d,0x6a,0xfe,0xe8,0xda,0x3f, - 0x57,0x67,0xe1,0xf4,0x6f,0xd1,0x67,0xcd,0xf3,0x51,0xed,0x8e,0xa4,0x4e,0xdf,0x65, - 0x8f,0x54,0xd9,0x1e,0xdf,0x40,0xcd,0x51,0xb2,0xa8,0xb2,0x4c,0xbf,0x9a,0x69,0x53, - 0x7f,0xde,0x04,0xd3,0xa0,0x6a,0x33,0xcd,0x18,0x2f,0xf5,0xd3,0xf4,0x43,0xb0,0x7a, - 0x1d,0xb7,0x05,0xff,0x7a,0xef,0xf8,0xa8,0xf7,0x4b,0x32,0xbb,0xc6,0xd9,0x69,0x00, - 0x55,0xba,0xdd,0xac,0x5d,0x48,0xab,0x95,0x7a,0xf9,0xef,0x38,0x58,0x38,0x0c,0x40, - 0x64,0x97,0x61,0x2c,0x88,0xbd,0x98,0x10,0x82,0x46,0x26,0xf7,0xf4,0xf3,0xe4,0x32, - 0x88,0x6d,0xe1,0x5c,0xb5,0x27,0xdf,0x3d,0xee,0x3e,0xef,0x3e,0x2e,0xee,0xc9,0xf7, - 0x9e,0xf7,0x1e,0xab,0x1d,0x78,0xde,0x93,0x3f,0x79,0x71,0xf2,0xf0,0xc5,0x73,0x77, - 0x4f,0xfe,0xd1,0xd1,0xa3,0xfe,0xc3,0xbe,0xbb,0x27,0xbf,0x77,0xb4,0xb7,0xb7,0xbb, - 0xef,0xec,0xc9,0xf7,0x77,0xfb,0xbd,0xde,0x23,0x7b,0x4f,0xfe,0xe5,0xcb,0xbd,0xe3, - 0xfe,0x89,0xb5,0x27,0xbf,0xf7,0xf0,0xf9,0xa3,0x87,0x2f,0xed,0x3d,0xf9,0x97,0x2f, - 0xf7,0x9f,0xef,0x1d,0xb9,0x7b,0xf2,0xbd,0x9d,0x5e,0xaf,0x47,0x3a,0x2f,0x60,0xe2, - 0x1c,0x06,0x2c,0xd1,0x00,0x23,0x7e,0x83,0xd3,0x21,0x37,0x38,0x44,0x96,0xfb,0x73, - 0x58,0x64,0x18,0x19,0xf7,0x69,0xec,0xe7,0x7e,0x07,0x90,0x33,0x0f,0x0e,0x29,0x9c, - 0xec,0x42,0xe0,0x06,0x2b,0xa2,0x2c,0x0d,0xc7,0x81,0x0e,0x71,0x38,0x7e,0xff,0xee, - 0xec,0xfd,0x9b,0x93,0x0e,0xc6,0x9d,0x10,0x2d,0x85,0xb8,0xcd,0x94,0x89,0x26,0x55, - 0xfd,0x89,0xd8,0xab,0x37,0x6b,0x79,0xd4,0xac,0xc6,0x73,0x7e,0x9d,0x50,0xa4,0x4a, - 0x86,0x31,0x10,0x21,0x08,0xb3,0xe4,0x3a,0x46,0x68,0x71,0x00,0x40,0xa8,0x39,0x19, - 0x13,0x21,0x30,0xaa,0x43,0x70,0x78,0x07,0x7b,0x4c,0xd9,0xe0,0xc2,0xbe,0xe4,0x3e, - 0x58,0x85,0xd0,0xe5,0x95,0x18,0xfb,0xe9,0x25,0xd0,0x90,0xf0,0xb9,0x2e,0x85,0x5e, - 0xe0,0x86,0x7c,0x07,0xcd,0xb6,0xd1,0x0c,0x54,0x5f,0xe0,0x45,0x7e,0x26,0xb6,0x10, - 0x8a,0x1c,0xeb,0x38,0x01,0xe8,0x71,0x92,0xcf,0x60,0x45,0x6e,0x79,0xe2,0xd8,0x8f, - 0xaf,0xfc,0x8c,0x57,0x6d,0x06,0xcb,0x2d,0xc2,0x08,0x90,0x49,0x1a,0x04,0x03,0x84, - 0x26,0x03,0x3f,0x7e,0xce,0x08,0x54,0xa6,0xc3,0x36,0xd4,0x68,0xa1,0x60,0x36,0x13, - 0xb0,0x86,0x03,0xc0,0xcf,0x0a,0x1a,0x87,0x39,0xa5,0x68,0x8e,0x1a,0x54,0x7e,0x2e, - 0x90,0xcd,0xc9,0xa3,0x93,0xfe,0x8b,0x7d,0x77,0xd2,0x5e,0xbe,0x7c,0x79,0xfc,0xf2, - 0x48,0x4e,0x1a,0xc7,0xed,0x08,0xdc,0xdc,0x22,0xd5,0x11,0x23,0x49,0xe6,0x81,0x1f, - 0x43,0xe7,0xa1,0xfd,0x0c,0xc6,0x19,0x70,0xd4,0x0b,0x8d,0xfb,0xf5,0x19,0xfd,0xe6, - 0x4a,0x07,0x56,0xc8,0x09,0x8e,0x85,0xaa,0x23,0xfe,0xce,0x40,0xeb,0x95,0xae,0x25, - 0xa8,0xef,0xe7,0x56,0x1d,0x58,0x4d,0x38,0x4d,0x6f,0xc3,0x51,0x9a,0x2c,0x66,0x20, - 0x92,0x44,0x76,0x1d,0xe6,0x80,0x49,0xac,0x07,0x48,0x83,0x67,0x40,0x3b,0x8c,0xef, - 0x1b,0xec,0x11,0xb2,0xe6,0xb6,0xf8,0x86,0x9b,0x50,0x0f,0x00,0x9c,0x0c,0xc6,0x0a, - 0xd1,0xc6,0x6d,0x3c,0xc7,0xdd,0xb3,0x2f,0x51,0x48,0x7b,0xbb,0xbf,0x4d,0x23,0xd5, - 0x3c,0xbe,0xce,0xd3,0xd9,0xb5,0xf7,0x4e,0xbf,0x7c,0x5f,0x01,0x70,0xf6,0x36,0x9b, - 0x4a,0xdd,0x8f,0x05,0x4d,0x31,0x10,0xe3,0x1b,0x1e,0xfa,0x3b,0xc0,0xe0,0x20,0x98, - 0x2f,0xf2,0x15,0x70,0x4a,0xae,0xc6,0x8f,0xd5,0x9a,0xc0,0x04,0x9a,0x28,0x6e,0x6b, - 0xab,0x46,0xe1,0x23,0xac,0xc8,0xe2,0x57,0x60,0x05,0xf4,0x59,0x2d,0xf7,0x59,0x9a, - 0xcc,0x69,0xce,0x1c,0x4a,0x41,0xa7,0x32,0xd0,0xd1,0x32,0x06,0x3a,0x1c,0xcd,0x90, - 0x92,0x0f,0x0c,0x0d,0x25,0x71,0x04,0x0b,0x2b,0x9c,0x67,0x4c,0x1b,0x21,0xad,0xaa, - 0x91,0x1f,0x43,0xbf,0x44,0x94,0xc4,0x53,0x60,0x9d,0x57,0x68,0x28,0xc2,0x68,0xd3, - 0x36,0xd1,0xc5,0x18,0x68,0x85,0xe3,0x9b,0xb6,0x28,0xfa,0x0d,0x7f,0xcd,0x0d,0x05, - 0x25,0xf1,0x96,0xa0,0x28,0x13,0x9f,0x96,0xfa,0x32,0xf6,0xe3,0xec,0x1a,0x56,0xf7, - 0x58,0x60,0x44,0xa0,0xb8,0x4e,0x96,0xb0,0xae,0x13,0x0c,0xe3,0xba,0x0e,0x61,0x71, - 0x65,0xb3,0xe4,0x5a,0x2f,0x21,0xc2,0xc1,0x64,0x82,0x42,0x42,0xe8,0x8d,0x92,0xb6, - 0x28,0x7d,0xb2,0x28,0xcb,0x31,0x09,0x10,0x17,0xc5,0xc2,0xdf,0x8c,0xfc,0x05,0xf4, - 0x33,0x78,0xc5,0x91,0x2f,0x57,0x61,0x16,0x0e,0xc3,0x88,0x6a,0xb0,0x5e,0x26,0x11, - 0xf8,0x21,0xb9,0x06,0x1c,0xe0,0xc6,0xcc,0x30,0x80,0xb1,0x06,0x38,0xae,0x15,0x31, - 0x42,0x8a,0x12,0x6b,0x5a,0x31,0x68,0xd7,0xc0,0x41,0x22,0x1f,0xc8,0x0e,0x74,0x58, - 0xc4,0x0b,0x30,0x24,0x10,0x7e,0x50,0xc3,0x1f,0x72,0x70,0x97,0xe9,0x20,0xed,0xc5, - 0xd1,0x16,0x32,0x09,0x4b,0xd2,0x6e,0xe0,0x97,0xd1,0xef,0xdb,0x42,0x47,0xcb,0x90, - 0x4e,0x86,0x3d,0x18,0x08,0xe5,0x77,0xb2,0x64,0xbf,0x1d,0x71,0xd4,0xc5,0xad,0x35, - 0x25,0x62,0x31,0xd6,0x48,0xdc,0x0f,0xe7,0x18,0x08,0xe8,0xc7,0xb9,0x45,0x0f,0x33, - 0x28,0x10,0x00,0x27,0x41,0x17,0x18,0x72,0x63,0x7f,0x2a,0x66,0x3e,0xfa,0xd8,0x16, - 0x3e,0xbc,0xc5,0x20,0x42,0xb2,0x62,0x10,0xd1,0xf0,0x99,0xec,0x79,0x18,0xe2,0x14, - 0x64,0xd2,0x28,0x42,0xde,0x43,0xb4,0x40,0x01,0x77,0x22,0xf5,0xf1,0x2f,0x3c,0x00, - 0x65,0xe0,0x58,0x0d,0x17,0xf2,0x0a,0xbb,0x5a,0x1a,0x65,0x36,0x7e,0xff,0x1b,0x48, - 0x8d,0xd0,0x17,0x4d,0x77,0x10,0xb0,0x10,0x5b,0xc4,0x1b,0xd4,0xce,0x47,0x15,0x0a, - 0xe0,0xeb,0x55,0x5a,0xb5,0x60,0xe0,0x43,0x71,0xb3,0x61,0xe8,0x67,0x61,0xa6,0xf0, - 0x73,0xcb,0x2c,0xf5,0x14,0xa9,0x12,0x5e,0x22,0x6d,0xb2,0x96,0xcb,0x42,0x8a,0x79, - 0x04,0x90,0xe5,0x30,0xb9,0x01,0xd6,0x8e,0xe1,0x84,0x38,0x76,0x90,0x1b,0xf4,0xb7, - 0x2d,0x65,0x4a,0xc8,0x0b,0x03,0x31,0x11,0xa1,0x39,0x86,0xc3,0x86,0x99,0x5b,0x80, - 0x64,0x0d,0xe4,0x3e,0xce,0x24,0x44,0x64,0xee,0xec,0x77,0x05,0xf2,0x23,0xe0,0xf6, - 0x01,0x28,0xd4,0xf0,0x0e,0xaa,0xfa,0x6c,0x8b,0x88,0x9d,0xbd,0x7d,0xfc,0x38,0x22, - 0xe9,0x83,0xb0,0xae,0x81,0xa1,0x06,0x4a,0x2d,0x85,0xb9,0xcd,0x67,0x50,0x38,0xcb, - 0x96,0x73,0xa4,0x3b,0x58,0x87,0xa8,0xc4,0xb6,0x81,0xb9,0x21,0x9c,0x39,0xf0,0x60, - 0x60,0xc6,0x40,0x97,0x82,0x42,0x38,0x61,0x09,0x82,0x1c,0x25,0xbc,0x57,0xe0,0xf5, - 0xa1,0x83,0xd6,0x29,0x6d,0xaa,0x6a,0xd6,0xd7,0x97,0xd6,0x38,0xc7,0xa3,0xb9,0x7b, - 0x32,0x42,0x38,0x64,0x6b,0x96,0x9f,0xb5,0x2f,0x65,0xc7,0xb8,0x75,0xe4,0x66,0xb8, - 0xf1,0xa3,0x91,0xad,0xa5,0x78,0x2f,0x13,0x67,0xb7,0xa4,0x51,0x93,0x05,0xe6,0xb6, - 0x56,0xec,0xa2,0x15,0x48,0x61,0xf7,0xce,0xc4,0x62,0x54,0x7e,0x36,0xe0,0x54,0x0c, - 0x84,0x59,0x7c,0xd6,0x2b,0x8b,0x37,0x6b,0xae,0x0c,0x6f,0x71,0x15,0x49,0x16,0x51, - 0x45,0x4a,0x42,0x94,0x62,0x32,0x78,0x07,0xbe,0xa7,0x7d,0x49,0x14,0xf3,0x67,0x47, - 0x57,0x74,0x0b,0xfd,0x96,0xc1,0x74,0x56,0x63,0xf5,0xd2,0xa0,0xb8,0x26,0x6a,0x4d, - 0x35,0xd7,0x8f,0x8f,0x62,0xcd,0xc6,0x49,0xd5,0x16,0x15,0xf7,0x13,0x3f,0xeb,0x1d, - 0xaf,0xd2,0xa4,0xb2,0x74,0xce,0x82,0x68,0x62,0xeb,0xfd,0xb8,0xe2,0x8a,0xe6,0xd9, - 0xda,0x0d,0xad,0xde,0x24,0xc5,0x7f,0xf5,0x5a,0x3c,0x47,0x49,0x82,0x7c,0x01,0x16, - 0x0c,0x08,0xa3,0x4c,0xd0,0xe2,0x0d,0x63,0x62,0xca,0xd1,0x8a,0xc5,0x12,0xda,0x8c, - 0x69,0x12,0x65,0xe2,0x32,0x08,0x16,0xf8,0x26,0x4c,0x69,0x39,0x38,0x04,0x2f,0x57, - 0x0e,0x62,0x03,0x4f,0x70,0x48,0x7a,0x37,0xc2,0x1c,0xa6,0x56,0x85,0x08,0x11,0xe3, - 0x05,0xdb,0xab,0x2d,0xb4,0x6b,0xc7,0xe6,0xc0,0xf0,0xbb,0xb4,0xab,0x52,0xe9,0x4e, - 0xbd,0x75,0x5b,0x18,0x0c,0xfc,0x09,0x45,0x8f,0x9a,0x58,0x24,0xfd,0x86,0xda,0x33, - 0x8f,0xaa,0x59,0xf3,0xc6,0x38,0x3f,0xe4,0x2b,0x84,0x5d,0xe8,0x86,0x53,0x9a,0xfc, - 0xbc,0xe6,0x8d,0x1d,0x8a,0x20,0xdf,0xca,0xf5,0xa9,0x2d,0xee,0xad,0xad,0x83,0x4a, - 0x1f,0xa8,0xf4,0x8a,0x74,0x80,0x70,0x44,0x47,0x9a,0xb7,0xd6,0xd0,0x0a,0x30,0x75, - 0x71,0xda,0x1f,0xee,0x28,0xff,0x17,0x58,0x5b,0x4f,0xb6,0x65,0x38,0xf5,0x93,0x6d, - 0x79,0xa2,0x06,0x83,0x74,0xe1,0xcf,0x38,0xbc,0x02,0xf9,0x01,0xcc,0xec,0x70,0x0b, - 0xf9,0xcf,0xd6,0xd3,0x7b,0x00,0xfd,0x09,0x4b,0xb9,0xa7,0xd4,0x47,0xbb,0x08,0x05, - 0x74,0x6e,0xf1,0x7b,0xf8,0x42,0x7b,0x70,0xf2,0x13,0x70,0x3a,0x3a,0x68,0x03,0xaf, - 0x2a,0xbf,0x53,0x10,0xe6,0xd6,0xd3,0x52,0x2c,0xf7,0x9a,0x2a,0x1c,0x89,0xb9,0xf5, - 0xf4,0xed,0xd1,0xe9,0xe9,0xeb,0x77,0xdf,0xdb,0x25,0x9f,0x6c,0x43,0xaf,0xca,0xfd, - 0x93,0x71,0x95,0x85,0x1e,0x86,0x63,0xf8,0x62,0xf4,0x8a,0x2d,0x55,0x1a,0x15,0x9f, - 0xea,0x3e,0x63,0x0d,0xa3,0x39,0xeb,0x0a,0x48,0x1a,0x5b,0x4f,0xc3,0x71,0x14,0xd4, - 0x55,0x52,0x9a,0xb7,0x53,0x05,0x35,0xb6,0xad,0xa7,0x56,0x44,0xbd,0x07,0x26,0xb5, - 0x1f,0x0d,0x1e,0xf5,0x0a,0x60,0x24,0x45,0x53,0xeb,0xac,0xaa,0x6b,0x38,0x26,0x6a, - 0x70,0x4b,0xa8,0xb0,0xc1,0xa7,0x68,0x2c,0x3c,0xd9,0xe6,0x5a,0x05,0xbc,0xf0,0x3c, - 0xe3,0x1c,0xde,0x73,0x51,0xa4,0x19,0xaf,0x44,0x92,0xdd,0xa8,0xbf,0x1c,0x9f,0x13, - 0x5d,0xe9,0x66,0xcd,0x0a,0xda,0x12,0x78,0x4e,0x0b,0x48,0x80,0x8a,0xc3,0x13,0xce, - 0xe7,0xe1,0xd6,0x49,0x8c,0x5d,0x11,0x43,0x60,0x0d,0x19,0xaa,0x1c,0xfe,0x90,0xd4, - 0x4e,0x73,0x58,0x80,0x0e,0x0f,0xa0,0x85,0xc6,0xc7,0x2a,0x9a,0x47,0x3f,0xbe,0x68, - 0x55,0x53,0x10,0xb7,0x05,0x13,0x62,0xbf,0xc4,0xa8,0x1d,0x3d,0x47,0xeb,0xc8,0xcb, - 0xc4,0x8f,0x6d,0x3d,0x25,0xc2,0xea,0x7c,0xc0,0x36,0xb9,0x41,0x97,0x74,0x1c,0x84, - 0x59,0xc3,0x07,0xbd,0xf8,0x2b,0x87,0x9f,0xc4,0x9d,0x61,0x82,0x9e,0x41,0xa3,0x5a, - 0xff,0x67,0x8f,0xd1,0x98,0x81,0x6b,0x06,0x67,0xcf,0x3b,0x0b,0xc4,0xea,0x6e,0x99, - 0x8d,0xc9,0xad,0xa7,0xaf,0x49,0x62,0x4e,0xc1,0x5a,0x2e,0x74,0x85,0x45,0xa9,0x44, - 0xd4,0xf7,0xf0,0x5d,0xe1,0x04,0x98,0x02,0x70,0x0e,0x14,0xa6,0x87,0x5b,0xbd,0x2d, - 0xd4,0x7d,0xe1,0xef,0xfe,0x96,0x40,0x16,0x79,0xb8,0x05,0x9a,0x7e,0xc5,0x7a,0x94, - 0x20,0xfe,0xe8,0x47,0x1a,0xd9,0xe4,0xc0,0x97,0x41,0xf1,0x5b,0x4f,0x1f,0x79,0xdd, - 0xba,0x05,0xaf,0x81,0x18,0xe5,0x60,0xcd,0x9a,0x36,0xc5,0x59,0x7d,0xd0,0x45,0xd5, - 0x23,0x88,0xaf,0x00,0x17,0x1c,0x1e,0xef,0xa9,0xaa,0x2a,0x8b,0xb3,0x3e,0x50,0x28, - 0xe0,0x13,0x60,0xe8,0xf0,0xf7,0x4b,0xd0,0xb3,0xdf,0x84,0xf1,0xa5,0x06,0xaf,0x04, - 0x0d,0x18,0x58,0x69,0x30,0x81,0x9e,0xe5,0xf9,0x22,0x1b,0x6c,0x6f,0x4f,0xc3,0x7c, - 0xb6,0x1c,0x82,0x36,0x3c,0xdf,0x0e,0xe2,0xe9,0x7c,0x19,0x4f,0xb7,0x2d,0xa6,0xb8, - 0x9d,0x83,0x46,0xba,0x8d,0xb1,0x68,0xdb,0x77,0x1e,0xc4,0xf9,0x06,0xfd,0x1e,0x73, - 0x18,0x7b,0x0e,0x1a,0x01,0x9e,0x8b,0xfc,0x69,0x18,0xf9,0xd8,0x03,0x10,0x83,0x87, - 0x5b,0x71,0x02,0xa6,0x48,0x0c,0x3d,0x16,0xc4,0xfa,0x81,0x7c,0x30,0x72,0x74,0x1c, - 0x8c,0x12,0x30,0x0f,0x50,0xcc,0x90,0xf6,0xa2,0x54,0x03,0xe9,0xec,0x23,0x73,0xdf, - 0xb6,0xf6,0xa5,0x3e,0x81,0xea,0x0b,0x68,0x2b,0x9a,0xee,0x5f,0xc7,0x80,0x2f,0x30, - 0x10,0x8f,0xd9,0x88,0xe5,0x53,0x39,0x27,0xaa,0x63,0x5b,0x4f,0xf5,0x4f,0xf1,0xd7, - 0x7f,0xf9,0x3f,0x4f,0xb6,0xfd,0xf2,0x62,0x23,0x81,0x7a,0x14,0x45,0x15,0xf8,0x72, - 0x56,0xda,0xd3,0x0f,0xb4,0x61,0xa2,0xcf,0x3a,0x19,0xfa,0x96,0x24,0x51,0x64,0x70, - 0x52,0x81,0xab,0x64,0x6f,0x79,0xb2,0xe9,0x02,0xff,0x4f,0xe7,0x50,0x60,0xb9,0xd0, - 0xba,0x59,0xb3,0x7a,0x35,0xe1,0x2a,0x0d,0xf4,0x4e,0x2a,0x5f,0x47,0xaa,0x5f,0xc3, - 0x0b,0x8e,0xf2,0x1c,0x34,0x99,0x5a,0x3e,0xe0,0xd3,0xe7,0x2a,0x36,0xd0,0x55,0x6c, - 0xc0,0x70,0x81,0x6e,0xaf,0x82,0x0d,0x30,0x84,0x8f,0x60,0x74,0xd4,0xb0,0x81,0x5f, - 0x96,0xa1,0xdb,0x81,0x1a,0xc9,0xbf,0xf1,0x90,0x5e,0x70,0xc1,0xda,0x31,0x29,0x40, - 0xbf,0x61,0x50,0x12,0xc4,0x9a,0x51,0x0d,0x7d,0x58,0xa8,0xa3,0x60,0x5c,0x37,0x30, - 0x8b,0x68,0x71,0x11,0x9f,0xf2,0x9e,0xe3,0x5d,0x4b,0x45,0x76,0x84,0xff,0x27,0x17, - 0xea,0xd9,0x2a,0x06,0xa5,0x3c,0x0f,0x47,0x22,0x47,0xe7,0x42,0x06,0x2b,0xdb,0x8f, - 0xd0,0x30,0x4e,0x93,0xe5,0x74,0xc6,0x5e,0xc8,0x20,0x02,0x6b,0x05,0x6d,0x6b,0x94, - 0xdc,0x1d,0x78,0x0e,0x04,0xcf,0x4b,0x5b,0x28,0x33,0x8a,0x5c,0xbb,0x33,0x65,0x43, - 0xcf,0x13,0xf8,0x8f,0x2f,0x30,0x52,0x02,0x34,0x3a,0xee,0x5c,0xd5,0xca,0x94,0x53, - 0x84,0xc3,0x50,0x06,0xaa,0x51,0x68,0xd4,0x0b,0x39,0x64,0xb6,0xb6,0x75,0x59,0x24, - 0x5e,0x7e,0x55,0x9e,0xeb,0xdc,0x87,0xe9,0xc1,0x82,0xf0,0xa3,0x5b,0x58,0x8d,0x32, - 0xdc,0x73,0xeb,0xe9,0xf3,0xde,0x7f,0xfc,0xfb,0x0f,0x4a,0xc1,0x92,0xd0,0x0d,0xcc, - 0x1a,0x2a,0xb2,0x21,0xf7,0x6a,0x21,0xf7,0x01,0x72,0xff,0x37,0x40,0xee,0xd7,0x42, - 0xde,0x01,0xc8,0x3b,0xbf,0x01,0xf2,0x4e,0x2d,0xe4,0x5d,0x80,0xbc,0x7b,0x27,0xe4, - 0xc2,0xc4,0x69,0xc3,0x20,0xc6,0x95,0x53,0xd6,0xb6,0x93,0xa8,0x7a,0xbd,0xe9,0x78, - 0x51,0xe0,0xde,0x27,0x67,0xa7,0xef,0xdf,0x9d,0x9d,0x14,0xd6,0x9b,0x35,0xdb,0x44, - 0x52,0xa5,0xe9,0xd6,0x1d,0x29,0x35,0x29,0x8c,0x99,0x7c,0x67,0xeb,0xc7,0x3f,0x7e, - 0xf8,0x63,0xb1,0x69,0x7b,0x60,0x3a,0xea,0x53,0x43,0x32,0x8b,0xcf,0x29,0x03,0x4a, - 0x37,0xee,0x17,0xc0,0x9a,0xcf,0xe6,0x49,0x92,0xcf,0x8a,0xd2,0xa2,0x8c,0x52,0x6a, - 0xf3,0x8c,0x0a,0x6b,0xd9,0x60,0x73,0xf7,0x4d,0x9a,0x9a,0xf9,0xe9,0x62,0xd3,0x96, - 0xb0,0xec,0xd7,0x36,0x34,0xf1,0x51,0x0e,0x6f,0xd4,0xce,0x4b,0x28,0xfa,0xb5,0xcd, - 0x4c,0x7d,0x54,0xd4,0x36,0x6a,0xe6,0x7b,0x28,0xfa,0xd5,0x68,0x43,0xcb,0x7c,0x53, - 0xb4,0x61,0xd9,0xaf,0x6d,0xc8,0x4f,0x47,0x9b,0x12,0xc2,0x11,0x14,0xad,0x69,0xc6, - 0x5a,0xd6,0x16,0xbf,0x54,0xee,0x05,0xcd,0x2f,0xcd,0x0b,0x76,0xd4,0x5a,0xbd,0x24, - 0xb0,0x99,0x35,0x92,0xd2,0x00,0x34,0xc4,0xb7,0x61,0xbc,0x2c,0xe1,0xe6,0xdb,0x6f, - 0x1e,0x3d,0xec,0xf7,0x0f,0x2a,0xc6,0x6f,0x8c,0x61,0xac,0xfc,0xce,0x91,0x66,0x5b, - 0x4f,0x77,0xee,0x6c,0xee,0x34,0x2a,0xb7,0xf6,0x60,0x3d,0x02,0x74,0x93,0x51,0x30, - 0xf5,0x47,0xab,0x0a,0xb5,0xe7,0x7c,0x16,0x66,0x2c,0xab,0xb2,0x1c,0x63,0xd2,0xd2, - 0x65,0x9c,0xf1,0x7e,0x65,0x84,0xfe,0x61,0xda,0x8d,0x23,0x31,0x85,0xa7,0xc4,0x17, - 0xc0,0x09,0x40,0x54,0xc9,0x38,0x65,0x50,0xba,0x96,0x8b,0x69,0x0a,0x86,0x31,0x54, - 0xf0,0xd6,0x28,0x6b,0xdc,0xd2,0x8b,0xd4,0x9f,0x92,0xd8,0xc3,0xad,0xd1,0x19,0x45, - 0xab,0x65,0x72,0x0b,0x61,0xb4,0x04,0x0b,0x7d,0x2e,0x60,0x89,0x2e,0x02,0x4f,0x1c, - 0xc5,0x2b,0xd9,0x66,0xe6,0x5f,0x05,0xe8,0x1c,0x46,0x1f,0xf3,0x4e,0x87,0x1c,0x5f, - 0x68,0x14,0x47,0x81,0x57,0x21,0xeb,0x4b,0xec,0xed,0x8a,0x94,0xb6,0x7a,0xd6,0x87, - 0x1e,0xb8,0xad,0x2a,0x4e,0xa6,0xa2,0x6e,0xb7,0xaa,0x26,0x4f,0x05,0xd5,0x6e,0xd9, - 0xa5,0xe9,0xc5,0xd3,0xe7,0x88,0xc6,0x5e,0x69,0x1e,0x9d,0xaa,0x6f,0x83,0xdc,0xaf, - 0xd1,0x63,0xf6,0xfb,0x80,0xe4,0xff,0x2d,0x76,0x1e,0xee,0x89,0x57,0xbf,0x16,0xd0, - 0x69,0x0d,0xc3,0xed,0x2b,0x69,0x63,0x76,0x47,0x8b,0xdf,0x3e,0x58,0x83,0x5c,0xab, - 0xcb,0xbd,0xe7,0xe0,0xac,0x92,0x1e,0x5d,0x1c,0x45,0xb2,0xcc,0xb9,0x86,0x4b,0xbf, - 0x5d,0x6f,0xa7,0x4b,0xfd,0xef,0x7a,0x8f,0xf6,0x4a,0x38,0xb0,0xa9,0xd2,0x5a,0x98, - 0xca,0xbd,0xa7,0x61,0xe9,0x17,0x4e,0xdb,0xd6,0x98,0x64,0x8c,0xe6,0xd6,0xd3,0x02, - 0x48,0x17,0x28,0x86,0x57,0xda,0x30,0xe9,0x79,0x7d,0x95,0x37,0x89,0x5d,0x81,0xa3, - 0x29,0xef,0xa8,0xf2,0x2a,0xdc,0xa0,0x4a,0xd5,0xd0,0x4d,0x25,0xb9,0x15,0xe6,0x0e, - 0xd7,0x65,0x94,0xba,0x54,0xd1,0x6b,0x42,0x9c,0x33,0x4a,0x48,0x95,0xa6,0xdf,0xb3, - 0x10,0xd5,0xe9,0xa7,0x5d,0x60,0x41,0xfd,0x5e,0xef,0xa0,0x57,0xe6,0x41,0xbf,0x11, - 0x38,0x79,0x1f,0x14,0x78,0x6f,0xef,0xf7,0x81,0x0f,0x30,0x9d,0xee,0x7b,0x7b,0xbf, - 0x6f,0xff,0xbd,0xfe,0x9e,0x33,0x84,0x87,0x30,0x06,0x78,0xa7,0x46,0xf1,0xf0,0xf7, - 0x1a,0x46,0xcf,0x6d,0x66,0x1f,0x5a,0xe9,0xe9,0x56,0xf6,0x7f,0xa7,0x46,0x76,0x9c, - 0x36,0x1e,0xe1,0x50,0x76,0x54,0x1b,0x8f,0x2a,0x46,0xe2,0x8a,0x83,0xf5,0x5c,0xc4, - 0xe5,0x14,0xb5,0x7c,0xe2,0xed,0x32,0x0f,0x48,0x62,0xac,0x13,0x58,0x18,0x07,0x54, - 0xe9,0x1d,0x10,0xe4,0x64,0x2f,0x09,0xfa,0x35,0x3e,0x80,0x4a,0xf9,0x56,0x31,0x06, - 0x63,0xa7,0x17,0x55,0x80,0x35,0x83,0x2c,0xfa,0x50,0x90,0x85,0x6f,0xe8,0x44,0x61, - 0x14,0xd4,0x4a,0xdf,0x2a,0x33,0x00,0x53,0xca,0x48,0x0b,0x6f,0x20,0xb2,0xbb,0x8d, - 0xc9,0xb3,0x93,0x37,0x27,0xc7,0x94,0x7d,0x86,0xb7,0xb1,0x65,0x26,0x2d,0xd5,0x7d, - 0x16,0x92,0x28,0x74,0xa6,0x11,0x6e,0xc2,0x77,0x4c,0x90,0x0b,0x5a,0x93,0x50,0x1c, - 0x04,0xfa,0x38,0x69,0xab,0x6d,0x7d,0x1f,0x04,0xeb,0x7c,0x99,0x41,0x8b,0xe8,0xa5, - 0xca,0x38,0x35,0x8c,0x6d,0x56,0xaa,0xb0,0x4d,0x63,0x57,0x9a,0x37,0xb6,0xa2,0xe4, - 0xa8,0xfd,0x14,0xc5,0x59,0x29,0x47,0x39,0x44,0xb2,0x52,0x8a,0x2e,0xae,0xce,0x69, - 0x0b,0xa3,0x2c,0x44,0x4f,0x55,0x48,0x30,0xe8,0x1b,0x35,0x02,0xd5,0x9a,0x34,0x68, - 0x3d,0x4a,0xb2,0xe0,0xce,0x29,0xa3,0x52,0x77,0x2b,0x8b,0x66,0x4c,0xb8,0xd9,0xb7, - 0xc6,0x8e,0xa1,0x30,0xcc,0x24,0xae,0x5c,0xa9,0xf8,0x7d,0x19,0x41,0xa7,0x9e,0xa2, - 0xe5,0x24,0xe8,0x77,0x76,0xb7,0x3e,0x8c,0x20,0xeb,0xe0,0xc5,0x49,0x98,0xad,0xb6, - 0x9e,0xbe,0xc3,0x3f,0x22,0x4d,0x92,0xf9,0x6f,0x03,0x97,0x5d,0x07,0x28,0x0f,0xcf, - 0x70,0xdf,0x88,0x7e,0xaf,0xc7,0x8c,0xed,0x4d,0xe0,0xa0,0x8c,0xa2,0x85,0x59,0x44, - 0x1f,0xc7,0x57,0xd6,0x71,0x13,0x19,0xfa,0x68,0xd6,0x78,0x59,0x59,0x64,0x17,0x52, - 0x14,0x5c,0x05,0x51,0xb5,0x3e,0x65,0x83,0x02,0xcd,0x65,0x1d,0x2c,0x5a,0x0b,0x1c, - 0x7a,0x5e,0xa9,0x55,0xd5,0xad,0x56,0x5b,0x5d,0xb4,0xc8,0xa1,0x80,0x68,0xb5,0xc3, - 0xc9,0x6e,0x03,0x7c,0xea,0xae,0xe5,0x6e,0x32,0x6a,0xbf,0xba,0xc3,0xf8,0x91,0x4e, - 0x6c,0xb1,0x4e,0xf5,0xbc,0x57,0x57,0xe8,0xd5,0xaf,0xa2,0xa8,0x3c,0x82,0xee,0xb5, - 0xf3,0x70,0xaf,0xde,0x2b,0x51,0xb5,0x51,0x53,0x3b,0x86,0xde,0xef,0x37,0x86,0xfe, - 0xc6,0x63,0xe8,0xef,0x75,0x61,0x10,0xfd,0xcb,0xdf,0x69,0x0c,0xfd,0xdf,0x6f,0x0c, - 0x3b,0x9b,0x8f,0xe1,0x12,0x86,0xb0,0xf7,0x7b,0x0d,0x61,0xe7,0xf7,0x1b,0xc2,0xee, - 0xc6,0x43,0xd8,0xc3,0x21,0xf4,0xf6,0x37,0x1a,0x83,0x5a,0x2e,0xfa,0xaf,0xca,0xd5, - 0x76,0x6f,0x7b,0x5b,0x66,0xbc,0x02,0xb9,0x33,0xf6,0x17,0x14,0xb1,0x40,0x0e,0x52, - 0xec,0x53,0xb6,0x4c,0x27,0x20,0xad,0x65,0x54,0x59,0x47,0x16,0xc0,0xe8,0xd3,0x69, - 0x78,0xc5,0xa1,0xb7,0x66,0xd7,0xb3,0x8d,0xb0,0xb2,0x05,0x86,0x2c,0x73,0x40,0x33, - 0xa7,0xad,0xe3,0x6c,0x92,0xbc,0x8b,0x13,0xc6,0x0d,0x0c,0xc0,0x05,0xc3,0x12,0x6a, - 0xa5,0xbe,0xf8,0x39,0x19,0x66,0x02,0x53,0x48,0x52,0x74,0xed,0x00,0x00,0x20,0x0c, - 0x81,0xfe,0x58,0x0c,0x23,0xc2,0x50,0x01,0x00,0x84,0xc0,0x39,0x22,0x4a,0xa5,0x76, - 0xc3,0x88,0x53,0xe8,0x5b,0x90,0xb5,0x05,0xfa,0x57,0x32,0x12,0xae,0x73,0x7f,0x91, - 0x61,0xec,0xed,0x9b,0xd7,0xef,0x4e,0x8e,0x3e,0x30,0x20,0x21,0xfc,0xf9,0x22,0x0a, - 0xf3,0x25,0x88,0xdd,0x26,0x87,0xc4,0x62,0xc4,0x31,0x46,0xa1,0x61,0xd1,0x11,0x88, - 0x5b,0x93,0x16,0x0e,0x00,0x78,0x33,0x0a,0x9d,0x93,0xe0,0xc7,0x18,0x48,0x8b,0xc1, - 0x4e,0x0a,0x18,0xf7,0x8a,0xad,0x70,0x90,0xcc,0xad,0x03,0x37,0x68,0x1b,0xe0,0x83, - 0x9e,0xe0,0x47,0xc2,0xbf,0x09,0x29,0xc5,0xda,0xf8,0x79,0x27,0x4e,0x52,0xd0,0xa4, - 0xc2,0x5f,0x01,0x58,0x96,0x70,0x24,0x97,0x82,0x36,0x46,0x4b,0x3b,0x0a,0x2f,0x71, - 0xf4,0x7e,0x8a,0x1e,0xe7,0xeb,0x24,0xbd,0xcc,0x70,0xa8,0x50,0x77,0x12,0x6a,0x04, - 0x80,0x99,0x3f,0x4c,0x72,0xec,0xda,0x0a,0x86,0xca,0x9a,0xc3,0x10,0x03,0x3f,0xfd, - 0x74,0xe5,0x29,0x68,0x2f,0x9e,0xff,0xf4,0xf2,0xcd,0xfb,0xf7,0x1f,0xb6,0xe1,0xc7, - 0xd9,0xe9,0xd1,0x3b,0x8c,0x5f,0x61,0xf7,0x35,0x34,0x3f,0x94,0xdb,0x5f,0xe2,0x97, - 0x65,0x08,0x3a,0x11,0x8a,0x26,0x8c,0x6b,0xc4,0x40,0xc7,0x2c,0xcc,0xc5,0xff,0x42, - 0xb5,0x58,0x2c,0x17,0x0a,0x16,0x56,0xc3,0x31,0xb4,0xa1,0x7b,0x19,0x4c,0x2f,0xf4, - 0xad,0x73,0x85,0x21,0x33,0x81,0x58,0x04,0xfe,0x65,0x86,0x15,0x1e,0xe3,0x44,0xc2, - 0x83,0xe0,0x74,0x77,0x31,0xee,0x77,0x4f,0x80,0x5c,0x67,0x9e,0x35,0x8d,0x6e,0x20, - 0xaf,0x67,0xc7,0xf1,0x2e,0x92,0x6b,0xa0,0x11,0xc4,0x65,0xa8,0x37,0x5f,0x81,0x2c, - 0xa0,0x3d,0x8e,0xf8,0x24,0xd4,0x87,0x1a,0x59,0x19,0x68,0x4e,0x07,0x1c,0xf1,0x98, - 0xfb,0xd1,0x25,0x7a,0x35,0x34,0x99,0x1e,0x9d,0xbe,0x16,0x1c,0x7e,0x14,0x01,0x3e, - 0x74,0x86,0x42,0xd1,0xe4,0xa0,0x1b,0xcc,0xf7,0xa8,0xb0,0x23,0x0e,0x45,0x67,0x77, - 0xaf,0x2d,0x14,0x92,0x0e,0xc5,0xee,0x43,0x4a,0x56,0xa2,0xea,0xc0,0x8a,0x9b,0x2f, - 0xba,0xbd,0xe6,0x55,0x0b,0x43,0x31,0xe1,0x3b,0x27,0x2f,0xc5,0xe7,0x3f,0xff,0x19, - 0x03,0xa6,0x64,0x46,0xc5,0x2b,0xf1,0x44,0x74,0xc5,0x33,0xf8,0x77,0x00,0xbf,0x9f, - 0x8a,0x1e,0xfc,0xee,0xe1,0x6f,0x0e,0xc3,0xd1,0xf0,0xc6,0xc3,0xb8,0x89,0xc1,0x6e, - 0xaa,0x9e,0x82,0xdf,0xec,0x77,0xc5,0x77,0x82,0xb2,0x5b,0x46,0xc9,0xb4,0xd7,0x6d, - 0x72,0xa2,0x4b,0xff,0xa6,0x29,0x1b,0xbc,0xe1,0x06,0xdb,0xa2,0x17,0x74,0x76,0x5b, - 0x2d,0x40,0xa6,0x1a,0x43,0x4b,0x6c,0xab,0xfe,0xb7,0x0a,0xad,0x45,0x61,0xcc,0x3d, - 0x97,0xad,0x11,0x50,0xc0,0x74,0xb3,0x07,0x80,0x9a,0xd6,0xd8,0xbe,0xd3,0x18,0x78, - 0xe0,0xc0,0xed,0x77,0xf9,0xa4,0x02,0x63,0xed,0xf4,0xc3,0xc9,0xd9,0xc9,0xf9,0x4f, - 0xe4,0x51,0x3e,0x03,0x5c,0x70,0x00,0x0f,0x7b,0x83,0x07,0xd0,0x0a,0x32,0xbd,0x81, - 0x68,0x0c,0x83,0x5f,0xc3,0x20,0x6d,0xb4,0x81,0x1f,0xc2,0x13,0x7f,0x86,0xa7,0x55, - 0x77,0x80,0xfd,0x5f,0xf5,0x06,0xa2,0xd7,0x16,0x8b,0xde,0xcd,0x80,0x8e,0x2e,0xe1, - 0x4f,0x0a,0x86,0xed,0xe2,0xcf,0xfe,0x0d,0x1f,0x95,0xc3,0x9f,0xf4,0xf6,0xf1,0x9e, - 0xb8,0x65,0xd5,0x99,0x5c,0xc1,0x03,0x51,0xdb,0x0e,0x7e,0x86,0x87,0xea,0x76,0x70, - 0xc0,0xb2,0x9d,0x7d,0xd3,0xce,0x8e,0x6a,0xa7,0xe7,0x75,0xbb,0xaa,0x1d,0xf4,0x04, - 0x43,0x33,0x75,0xed,0xe0,0x67,0x6c,0x86,0xda,0xe9,0x71,0x3b,0xdd,0xaa,0xf1,0x3c, - 0xae,0x1c,0x4f,0x77,0x8f,0xa6,0xe8,0xf6,0xe0,0x9e,0x3d,0x51,0x41,0x3c,0x4a,0xc6, - 0x01,0x7a,0x99,0x9a,0x64,0x1e,0xb4,0x24,0x6e,0xc3,0x89,0x68,0xde,0x97,0x6f,0xe4, - 0x1c,0x36,0x1a,0x07,0xfa,0x13,0x7d,0xf1,0x28,0x79,0xf0,0xe1,0xe1,0x21,0xe0,0x00, - 0x1d,0x8e,0x0d,0x53,0x34,0x1b,0x34,0x60,0x42,0xb9,0x54,0x5c,0x5f,0x0d,0x9d,0xb8, - 0x56,0x2d,0x7f,0x4d,0x0b,0x12,0x17,0xaa,0x7f,0x76,0xa1,0x70,0x2c,0xbe,0xfd,0xd6, - 0xa5,0x91,0x4f,0xea,0xcb,0x85,0x81,0xbe,0xb0,0xfa,0x14,0x8e,0x0f,0x24,0x18,0xf5, - 0x75,0x48,0x5f,0x65,0xbd,0x15,0xe0,0x55,0xfe,0xea,0xa9,0x5f,0x80,0x67,0xf3,0x73, - 0xa5,0x7f,0xf6,0xcd,0xdb,0xfe,0xea,0x42,0x6b,0x9d,0xb0,0x82,0x16,0x16,0x03,0x28, - 0xad,0x05,0x8a,0x97,0x77,0x17,0x42,0xaf,0x4b,0x34,0xdf,0xf2,0x7e,0x4e,0x60,0xf5, - 0x34,0xda,0x8d,0x16,0xf7,0xf1,0xf6,0x9e,0xdd,0xcf,0x06,0x47,0xa1,0x39,0x4b,0x3b, - 0xd0,0x73,0x38,0x77,0xe6,0x6f,0xae,0x07,0x8f,0x29,0x7e,0x0d,0x6e,0xe7,0x12,0xfb, - 0x06,0xf5,0x9a,0xe4,0x68,0x46,0x24,0xc1,0xd1,0x6f,0x24,0x18,0x5d,0xcf,0xcb,0x22, - 0x60,0xa1,0x98,0x6c,0xa3,0xdf,0x92,0x13,0x3f,0xb0,0xa6,0x04,0x97,0x6a,0x0c,0xcb, - 0x53,0xb3,0x90,0x7e,0x5b,0xe8,0xbc,0xb9,0x8f,0x80,0x12,0x31,0x00,0xf1,0x75,0x9c, - 0x6b,0x40,0xfd,0x16,0xb0,0x95,0x2e,0x31,0x98,0x7e,0xab,0x55,0x98,0x12,0xdd,0x27, - 0x26,0x2e,0xee,0x54,0x4c,0xcd,0xf6,0x81,0xc9,0x35,0x50,0xe2,0x36,0x84,0xf5,0x1d, - 0xcf,0x66,0xab,0xee,0xde,0xae,0xeb,0xf4,0xa2,0xd8,0xe9,0x05,0x74,0xda,0xa5,0x1f, - 0xd3,0xc3,0x8b,0x42,0xb7,0x16,0xd0,0xf6,0x7f,0x3f,0x7b,0xff,0xce,0xa3,0xd1,0x34, - 0xe9,0x67,0x96,0xa3,0xd8,0x0c,0x27,0xab,0xe6,0x02,0x38,0xe4,0xc0,0x42,0xf7,0xda, - 0x7e,0x0c,0x8b,0xfd,0xf8,0x05,0xfa,0x61,0x5a,0xf6,0x32,0x54,0x19,0x88,0x12,0xd6, - 0x90,0x53,0x53,0x63,0xf5,0x4a,0x23,0xb3,0x8b,0x2c,0xb4,0x47,0x87,0xe2,0x34,0x56, - 0xb1,0x0f,0xbf,0x78,0x51,0x10,0x4f,0x41,0x82,0xdf,0x87,0xe6,0xf7,0x2b,0xa8,0xa3, - 0x8c,0x7c,0x97,0x07,0xb1,0xe7,0x5d,0xf2,0xd4,0x5f,0x3e,0x75,0x2f,0x98,0x0d,0xfd, - 0xf2,0xa9,0x77,0x21,0x39,0xd1,0x2f,0x9f,0xfa,0x17,0x92,0x13,0xfd,0xf2,0x69,0xe7, - 0x42,0x72,0xa2,0x5f,0x3e,0xed,0x5e,0x48,0x4e,0xf4,0xcb,0xa7,0xbd,0x0b,0x77,0x96, - 0x0a,0xbd,0xd0,0x5c,0x7f,0x92,0x82,0x50,0x7d,0x89,0x04,0xa5,0x3e,0xe1,0xdb,0x79, - 0x38,0x7a,0x8f,0xef,0x80,0x1d,0x66,0x81,0x7a,0x49,0xa2,0xfc,0x0d,0x2a,0x70,0x85, - 0x0f,0xa0,0x54,0xd9,0xa5,0xe1,0x2d,0xc8,0x71,0x54,0xe1,0xc8,0x56,0x04,0xa5,0x2f, - 0x49,0x16,0x14,0x3e,0x9b,0xa9,0x53,0x57,0x82,0x52,0xf5,0x82,0x9a,0x04,0xf8,0x13, - 0x73,0xd0,0x2d,0xc3,0x39,0xe8,0x07,0x98,0xd5,0x16,0xcf,0x4c,0x25,0x13,0x71,0xfa, - 0x92,0x32,0x36,0x0f,0x18,0x16,0x68,0x25,0xdc,0x51,0xdc,0x75,0xc8,0x60,0x25,0xa2, - 0x22,0x91,0x91,0xca,0x40,0x41,0xff,0xe8,0x86,0x99,0xa1,0x56,0x0b,0x3a,0x66,0xdb, - 0x6c,0xe5,0x53,0xe8,0x4e,0x03,0x0f,0x47,0xc4,0x01,0x03,0xe2,0xe3,0x1e,0xbf,0x2c, - 0x83,0x25,0xee,0x8f,0x88,0x61,0x00,0xd6,0xe8,0x58,0x1e,0x9f,0xe2,0xf8,0x28,0x3a, - 0x16,0xe2,0x89,0xd7,0x39,0x3e,0xa3,0x32,0x96,0xc4,0xa3,0x40,0x05,0x02,0x4f,0xc2, - 0x29,0x1d,0xc5,0x62,0x60,0x33,0x50,0x1e,0x31,0x4f,0x08,0x86,0xc0,0x37,0x7d,0xee, - 0xa0,0x39,0xad,0xe1,0xe7,0x7c,0x20,0x0b,0x21,0x40,0x97,0xc4,0x32,0xce,0x48,0x31, - 0xa4,0xe3,0x64,0xd8,0xc5,0x14,0x94,0x6d,0x75,0xa0,0x0d,0xa0,0xd1,0x69,0xb1,0x89, - 0x3a,0x73,0x86,0x1b,0xd2,0x8d,0x4c,0x9e,0x12,0x88,0x63,0x0e,0xb6,0x86,0x85,0x9c, - 0x45,0x84,0xc6,0xeb,0x19,0x2a,0x8c,0xb4,0x71,0xe3,0x0f,0x11,0x15,0xec,0x09,0x42, - 0xb8,0x0c,0x0d,0xa3,0x2b,0xa9,0x65,0x44,0x0f,0x7c,0x0c,0x3c,0x39,0x57,0xd8,0xcc, - 0x4a,0xcd,0x15,0x68,0xd4,0xd8,0x82,0x7e,0xc2,0xf4,0xeb,0xe6,0x89,0x27,0xe5,0x10, - 0x43,0xe4,0x6d,0x4e,0x08,0x5a,0xef,0x65,0x53,0x2d,0x29,0x37,0xad,0x38,0x56,0x90, - 0x6b,0xc1,0xaa,0xac,0x59,0x25,0xb7,0x0d,0x4b,0xe7,0xbe,0xa2,0x3a,0xf8,0x4d,0x3d, - 0x80,0xbf,0xd4,0x36,0xfc,0x1d,0x27,0x23,0x4a,0x6c,0xec,0xf1,0x90,0xd4,0xfa,0x61, - 0x30,0xb2,0x83,0x79,0xba,0x0c,0xf8,0x05,0x0e,0x09,0xb3,0x84,0xc3,0x4b,0x5c,0x8a, - 0x2c,0xe2,0x2b,0x4c,0x91,0x67,0x4c,0x8a,0x87,0xbd,0x46,0x21,0xad,0xb6,0x68,0xa6, - 0xd6,0x3a,0x57,0x79,0xb2,0x49,0x44,0x14,0xca,0xfd,0x6c,0xd8,0x08,0x20,0x18,0x33, - 0x6e,0x49,0x92,0x1c,0x61,0x80,0x18,0xa6,0x97,0xe6,0xa9,0x23,0x85,0xd7,0x5f,0x2c, - 0x80,0x10,0x23,0x1e,0x71,0x12,0xbb,0xa6,0x83,0x3c,0x76,0xc8,0x80,0x6c,0x9b,0x81, - 0x2c,0x8e,0x8e,0x32,0x04,0xd0,0x48,0xc0,0xf3,0x7b,0xc2,0x27,0x75,0x99,0x69,0xf7, - 0x1a,0x34,0xdf,0x4b,0x7a,0x4d,0x56,0x92,0x81,0xc3,0x9a,0x37,0x40,0x87,0xb5,0xab, - 0x8e,0x31,0xa2,0x55,0x80,0x51,0x6f,0x3e,0xeb,0xde,0xd3,0x40,0x9e,0xb6,0x7b,0x0e, - 0x18,0x0c,0xfc,0x9c,0xd0,0x6c,0x71,0x46,0x1c,0xd3,0xa1,0xf8,0xd9,0xa3,0xbf,0x87, - 0x36,0x9a,0x85,0xe6,0x08,0xf7,0xf1,0x23,0x68,0x02,0x3f,0x7b,0x32,0x9d,0x34,0x72, - 0xb9,0x46,0x32,0x99,0x34,0x54,0x49,0x9b,0x4d,0x40,0x61,0xf5,0x3a,0x5b,0xc5,0xa3, - 0xe7,0x7e,0xda,0xd4,0xbc,0x52,0x5f,0x4f,0x70,0x24,0x2d,0xcd,0x08,0x7d,0xd9,0x99, - 0x17,0xe9,0xaa,0x28,0x80,0xe8,0xe9,0x3f,0xfe,0x9d,0xa1,0x92,0x24,0x52,0x6f,0x4c, - 0x24,0xaa,0x6e,0x1a,0x07,0x81,0xc8,0xd3,0xd5,0xab,0xb9,0x39,0xaa,0xed,0x00,0x09, - 0xb4,0x75,0x9b,0x6f,0x4b,0x9a,0x6c,0x29,0x96,0xd8,0xfc,0xac,0x15,0x8d,0x74,0x19, - 0xc7,0x74,0xa0,0x01,0xa1,0x02,0x8d,0x12,0x32,0xb4,0xcb,0x57,0xad,0x50,0x3c,0x2b, - 0x5b,0x5d,0x80,0x89,0x6f,0x00,0x34,0xe4,0x49,0x96,0x08,0x65,0x3e,0x5d,0xb0,0xc4, - 0xc1,0x0e,0xb7,0x4c,0x59,0x76,0x8a,0x41,0xe1,0x9f,0x3d,0xf9,0x93,0x0b,0x9b,0x12, - 0x41,0x7c,0x45,0xa0,0xe0,0xaf,0x0d,0xc7,0x8c,0x35,0xb0,0xc6,0xfa,0x19,0xb8,0xef, - 0x80,0x90,0xd2,0x0c,0xc0,0xf8,0x00,0x16,0x32,0x0b,0xf5,0xf3,0x2c,0x6c,0x81,0x94, - 0x00,0x5a,0x37,0xc0,0xb3,0x05,0x8c,0x24,0x5d,0xce,0xa9,0x05,0xf5,0xb0,0xa6,0xbb, - 0x18,0xb9,0xf7,0x01,0xb7,0x27,0x07,0x44,0x2e,0xf2,0xbd,0x92,0x88,0xb0,0x8a,0x46, - 0x98,0xfe,0xbf,0xd0,0x39,0x43,0xb5,0x47,0xc3,0x24,0xcd,0x29,0x8f,0xbd,0xca,0x52, - 0x2e,0x4f,0x0d,0xe2,0x21,0x58,0xb9,0x00,0x2c,0x0e,0x28,0xf7,0xa6,0x81,0xa6,0x2f, - 0xe9,0x50,0xbf,0x01,0x34,0x8a,0x30,0x22,0x06,0x18,0x31,0x14,0x50,0x3c,0x0f,0x96, - 0xb1,0x8f,0x47,0xae,0xa7,0x72,0x05,0xc1,0x3f,0x52,0xde,0xf0,0x5a,0xca,0xfd,0x4b, - 0x16,0x03,0xfe,0xe8,0xd2,0x40,0xd2,0x2b,0x39,0xc6,0xc9,0xe4,0x33,0x65,0x67,0xb4, - 0x0f,0x4f,0x47,0xcf,0x00,0xc8,0x8e,0xc8,0xd8,0x6f,0x12,0xfb,0x57,0xe1,0x94,0x8c, - 0x6d,0xf2,0x24,0x8c,0x30,0xe4,0x2d,0x02,0x71,0xe0,0x59,0x64,0x15,0xe0,0x6a,0x01, - 0x05,0x1d,0x85,0x03,0x29,0x24,0x66,0xc8,0xa8,0x98,0x28,0xee,0xb5,0xd3,0xed,0x6a, - 0x7b,0xf3,0x40,0xca,0x6c,0xea,0xce,0x8f,0xe6,0x9c,0xe4,0x00,0x87,0xc3,0x02,0x49, - 0x89,0xc1,0x36,0x1d,0xc5,0x42,0xf9,0x88,0x0c,0x97,0x0f,0x40,0xeb,0x83,0x97,0x33, - 0x3c,0x43,0x18,0xe6,0x2a,0x77,0xbd,0xc5,0x28,0xbb,0xeb,0x09,0xdf,0x10,0xbc,0x14, - 0x05,0x16,0x85,0xcb,0x37,0x8a,0xa4,0x81,0x30,0x99,0x20,0xf1,0x87,0x21,0x9e,0x4f, - 0x17,0x36,0x0d,0x14,0x38,0xa9,0x43,0x01,0xc7,0xac,0x09,0xc0,0x40,0x50,0x0c,0xa3, - 0x40,0xa5,0x34,0xe8,0xc0,0xc1,0x06,0xb6,0x47,0x21,0x83,0x57,0x41,0x51,0x20,0xa2, - 0x7f,0xc4,0x37,0xa0,0x10,0x05,0x2c,0xfe,0x7d,0x29,0x7f,0x78,0x9e,0x33,0x0c,0xf5, - 0x03,0xdc,0x82,0xdc,0xcf,0xd0,0xbd,0xc2,0x52,0x1f,0x50,0x88,0xbe,0x76,0x80,0xa8, - 0xf0,0x63,0x8b,0x3f,0x1b,0x41,0xf7,0x49,0x46,0xc1,0x34,0xde,0x67,0x09,0xa7,0x85, - 0x9b,0x75,0x15,0x04,0x4a,0xc5,0x36,0x61,0x58,0x0f,0x5b,0x1d,0x65,0xd1,0x03,0x27, - 0x8d,0x02,0x47,0x6f,0x89,0x43,0x5e,0xaa,0x2c,0x93,0x89,0xdf,0x4a,0xf9,0x8a,0x2c, - 0x0a,0x2b,0x6b,0x71,0xe8,0x8f,0xc7,0x27,0x98,0x42,0xf2,0x0d,0x39,0x69,0x82,0xb4, - 0xd9,0x30,0x67,0x47,0x47,0x78,0x8e,0x32,0x00,0x8d,0xd1,0xc1,0x31,0xb7,0xa2,0xbb, - 0x2c,0xa1,0xb6,0x38,0x95,0x06,0xf1,0xde,0x32,0x4c,0xb5,0x74,0x4a,0xb0,0xa4,0xa2, - 0xc0,0x5d,0xac,0x92,0xfa,0x96,0xc4,0xdf,0xa4,0x11,0x24,0xce,0xca,0x0e,0x63,0x43, - 0x56,0x83,0x3c,0x19,0x36,0x52,0x5a,0x4a,0xc3,0x3c,0x21,0xb5,0x0d,0xa5,0x2a,0x71, - 0x21,0x0a,0x4c,0x39,0x40,0xcd,0x91,0x6e,0xa8,0x01,0x32,0x41,0x77,0x22,0xd1,0xd0, - 0xd4,0x5f,0x80,0xfe,0x05,0x6b,0xd8,0x3a,0x82,0x8c,0xda,0x5b,0x98,0xb7,0xa5,0xba, - 0x94,0xb8,0x1a,0xa3,0x74,0x44,0x62,0x9c,0x79,0x02,0x6a,0x19,0x93,0x26,0x9f,0x34, - 0x46,0x7a,0x11,0x47,0xd0,0xaf,0x30,0x5a,0x22,0x3b,0x00,0x1d,0x10,0xcf,0xde,0x22, - 0xb3,0x90,0xca,0x6a,0x52,0xf0,0x84,0x39,0x87,0x75,0x41,0x01,0x0e,0xa2,0x09,0xb5, - 0x1e,0x27,0x48,0x81,0x7c,0x64,0x57,0xae,0x52,0x14,0x60,0x30,0x0e,0x64,0x59,0x87, - 0xe8,0x77,0x40,0xb6,0x75,0x2e,0xf1,0xaa,0xf4,0x73,0x43,0x4e,0xfe,0xaa,0x89,0x21, - 0xef,0x6d,0x01,0x3c,0x6e,0x8c,0x38,0x83,0xb1,0xbb,0x2f,0xdb,0x86,0x7e,0x40,0xf8, - 0x9f,0x44,0x01,0xfe,0x7c,0xbe,0x7a,0x3d,0x6e,0x36,0xf8,0x70,0x40,0xa3,0x55,0xf4, - 0x2a,0x2d,0x92,0x2c,0x6f,0xe2,0xd9,0x29,0xb5,0x4a,0xb9,0x3f,0x0f,0x1e,0x30,0x59, - 0x6b,0xf4,0xda,0xea,0x81,0x73,0x75,0x49,0xc1,0x07,0xdc,0xd6,0x8b,0x7d,0x1e,0xe4, - 0xb3,0x04,0x0d,0x9c,0xd3,0xf7,0x67,0xe7,0x0d,0x25,0x42,0xf8,0x14,0x4f,0x86,0x1e, - 0xa6,0x86,0xbc,0x0a,0xa8,0x73,0x0e,0x66,0x51,0x03,0x8d,0xe3,0x05,0x18,0x67,0x23, - 0x62,0xb0,0xdb,0x37,0x9d,0xeb,0xeb,0xeb,0x0e,0xe0,0x6a,0xde,0x59,0xa6,0x11,0xbb, - 0x54,0xc6,0x0d,0xe5,0xd6,0xc1,0x43,0xf6,0x63,0xcc,0x44,0x02,0xff,0xad,0xe1,0x3a, - 0xa9,0xeb,0xd6,0xe0,0x8b,0x54,0x84,0x7d,0x93,0x4a,0xe3,0xd5,0xf9,0xf9,0xa9,0x40, - 0x17,0x85,0xb9,0x53,0xc5,0xaa,0x71,0x27,0x2a,0xed,0xeb,0x8c,0x5a,0x34,0x3d,0x0d, - 0x24,0xca,0x31,0xe0,0xa0,0x91,0x5c,0x36,0xea,0x65,0xa2,0xe9,0x19,0x55,0x42,0x96, - 0x4e,0x15,0x07,0x16,0xbd,0x8e,0xc3,0x31,0xf3,0x7a,0x16,0x5a,0x1e,0x32,0x4e,0x58, - 0xf4,0x28,0xbf,0x58,0xba,0x61,0xbc,0x7d,0x90,0xa7,0x2b,0x0f,0x9b,0x0b,0xd2,0xb4, - 0xb1,0x01,0xff,0x25,0x44,0x74,0x3a,0x92,0xe4,0x90,0xc3,0x69,0x8a,0x6b,0xd9,0x53, - 0x6d,0xb1,0x44,0xc5,0xd7,0xee,0x95,0x35,0x38,0xed,0x34,0x1c,0xf9,0x0b,0x98,0x50, - 0x77,0x6d,0x6b,0x7d,0x64,0xf6,0xeb,0x5b,0x3c,0xdc,0xb9,0xd3,0xf3,0xfa,0x7b,0x6d, - 0x7c,0xf2,0xc1,0x76,0x7d,0xd4,0x45,0x17,0xdd,0x81,0x9a,0x4e,0x56,0x05,0x91,0x26, - 0x50,0xdb,0xab,0x56,0xfa,0x54,0x59,0xeb,0xe0,0x1b,0x14,0x3c,0xaf,0xcc,0x01,0x80, - 0xc8,0x41,0xbf,0x3b,0x9f,0x97,0x4e,0x92,0xb9,0x27,0xe9,0x0f,0x97,0xf7,0x31,0x19, - 0x76,0x85,0xfe,0x1a,0xa9,0xf2,0x8e,0x16,0x7f,0x1a,0xe0,0x55,0x56,0x03,0x3c,0x42, - 0x0d,0x2b,0x1f,0x14,0x0d,0xb2,0xd7,0x30,0x64,0x2e,0x13,0xee,0xf2,0x56,0x09,0x8c, - 0x33,0x65,0x88,0x1a,0x50,0x74,0x00,0x9a,0x63,0xfa,0x70,0x5b,0x02,0xaa,0xb2,0xd7, - 0xbc,0x6c,0x27,0xc3,0x3c,0xd2,0xf1,0x60,0x65,0xcb,0x92,0x2e,0x82,0x34,0x61,0x80, - 0x5d,0xa7,0x61,0x2e,0xb7,0x65,0x60,0x25,0xe6,0x33,0x52,0x63,0x3c,0xf1,0x3e,0x0e, - 0x94,0xa8,0x23,0xf1,0x48,0x7c,0x79,0x60,0x59,0xb0,0xf2,0x6a,0x1a,0xa7,0x63,0x47, - 0x3f,0xbe,0x90,0xb9,0x36,0xcc,0x57,0xea,0x8e,0xe7,0x7a,0x2c,0x6a,0x76,0x79,0x7e, - 0xbb,0x45,0x65,0x19,0x18,0xf7,0x7f,0xf6,0xe8,0xf7,0x81,0xfe,0x84,0x5c,0x11,0xe9, - 0xf2,0x24,0x82,0xef,0xb5,0xab,0x50,0x1e,0xb8,0x6a,0xb4,0x4c,0x45,0xae,0xe4,0x91, - 0x35,0x04,0x55,0xcf,0xc8,0x83,0xd4,0x24,0xf8,0x58,0x14,0xd5,0xda,0x47,0x56,0xf1, - 0xbb,0x40,0xff,0xd1,0x8f,0xdc,0x45,0x6e,0x76,0x0c,0x0a,0x30,0xbd,0x3c,0x79,0x89, - 0x27,0xed,0x9b,0x3d,0x0b,0x7c,0x09,0x79,0x5f,0x8f,0x39,0xdf,0xc6,0x9c,0xd0,0x0e, - 0x98,0xfb,0xf7,0x7d,0x8f,0x20,0x7f,0x40,0x69,0x37,0x0f,0x0e,0xac,0x32,0x25,0xfb, - 0xab,0x8e,0x13,0xa9,0xd6,0x48,0x5b,0xb1,0x4a,0x57,0x00,0x50,0x6b,0xda,0x6a,0x06, - 0x63,0x3d,0x60,0x19,0x92,0x6b,0x46,0x9a,0xa4,0x6d,0xbb,0x17,0xb4,0x5f,0x40,0xaa, - 0x25,0x98,0x17,0xea,0x81,0xdc,0x69,0xe8,0xaf,0xb7,0xc7,0x44,0x07,0x34,0xb0,0x18, - 0xff,0xe2,0x32,0xfb,0x6e,0x19,0x63,0x7c,0x20,0xd9,0xe8,0x47,0xbb,0x0c,0x46,0xb1, - 0xb0,0xe9,0x45,0xbf,0xaa,0x2d,0xa6,0xa1,0x8b,0xcf,0xca,0x91,0xd1,0xe8,0x98,0x77, - 0x0d,0x3d,0xfa,0xa1,0xb9,0x17,0x3d,0xfb,0x37,0xed,0x42,0xe9,0x30,0xa6,0xd2,0xb8, - 0x2b,0x34,0xf4,0xe8,0x01,0xcc,0x2f,0xf8,0x8b,0x55,0xf4,0x4b,0xff,0xa6,0x55,0xac, - 0x87,0x64,0x8b,0x40,0xf1,0x6f,0x1b,0x0d,0x42,0xd9,0x28,0xff,0xe2,0x37,0xdc,0x2c, - 0xff,0x2a,0xd6,0xc7,0xe0,0x0a,0xfc,0x8a,0x7f,0xdb,0x42,0xa6,0x7f,0x1a,0x72,0xe6, - 0xc2,0x62,0x59,0x72,0xab,0x0f,0x6c,0xf7,0x36,0x14,0x84,0x3f,0xd0,0xd3,0x68,0x99, - 0xb3,0x97,0xd5,0xa9,0x72,0x6b,0x13,0xd5,0x6d,0xeb,0x5e,0xe9,0x7d,0x35,0x55,0x29, - 0xe5,0xf7,0xc0,0xf1,0x44,0x1a,0x23,0xa1,0xad,0x74,0x8d,0xa0,0xcc,0x8e,0x47,0x93, - 0x69,0xc1,0x95,0x4c,0x9e,0xb9,0x43,0xf1,0xa9,0x81,0x53,0x7e,0x88,0x12,0x1b,0x0b, - 0x19,0x02,0x90,0xfb,0x73,0x5d,0x0b,0xb5,0x0d,0x4d,0x6d,0x54,0x1e,0x8b,0xeb,0x37, - 0x7a,0xb9,0xee,0xd8,0x15,0x98,0xee,0x74,0x69,0x7e,0xb4,0x8a,0x6a,0xa7,0x35,0x7e, - 0x25,0xe2,0x2a,0xdd,0x9b,0x06,0x94,0x05,0x33,0x6e,0x13,0x17,0x75,0x9d,0x2f,0xd0, - 0x6b,0x10,0x11,0x21,0xf8,0x10,0xfe,0x6d,0x50,0x43,0x92,0xb2,0x2c,0xfe,0x61,0x2d, - 0xb7,0x42,0x5d,0xff,0xa6,0xa2,0xae,0x7f,0x73,0x77,0x5d,0x22,0x45,0xb7,0x2e,0xee, - 0x25,0x2a,0x1a,0xd5,0x00,0xf6,0xd6,0x00,0x28,0x36,0xae,0x01,0x00,0x3d,0xdf,0x0d, - 0x00,0x29,0xbb,0xd8,0x79,0x7c,0x67,0x61,0xb7,0xa6,0x26,0xaf,0x81,0x62,0x5d,0x7e, - 0xbb,0x59,0xed,0x32,0xda,0xf8,0xed,0xdd,0xb5,0x71,0x3d,0x15,0xeb,0xe2,0xbb,0x9a, - 0xe2,0xb4,0xde,0xdc,0xf2,0x4d,0xb9,0x0a,0x35,0x7d,0xd6,0x55,0x85,0x15,0xe8,0xd6, - 0x64,0x85,0xf7,0xc7,0x0f,0xaf,0x8f,0x93,0xf9,0x02,0x94,0x9a,0x38,0x6f,0x5a,0xdb, - 0x8a,0x43,0x8f,0xb7,0x11,0x6d,0x78,0xa8,0xdb,0x1d,0xa5,0xa9,0xbf,0xf2,0xc2,0x8c, - 0xfe,0x42,0x29,0x58,0xd2,0x2d,0x54,0xf3,0xe8,0x97,0xdc,0xa7,0x70,0x59,0x9f,0xdd, - 0x0b,0x28,0x53,0x1c,0x2e,0x56,0x2b,0x6f,0x93,0x54,0xb1,0xce,0xba,0x1d,0xb8,0xfe, - 0xde,0x5e,0xcb,0x65,0x21,0x66,0x2f,0xce,0x96,0x4d,0x05,0x4f,0x92,0xd9,0x1a,0x42, - 0xfb,0x84,0x7b,0xc9,0xf5,0xbe,0xd5,0xf5,0x24,0x13,0x49,0xe2,0x97,0xe8,0xc8,0xb0, - 0x39,0xc8,0x24,0x36,0xbd,0x34,0x3b,0x1e,0x13,0xad,0x63,0x48,0xf3,0xd2,0x86,0x22, - 0x75,0x28,0x79,0x71,0xa5,0xab,0x1c,0x2a,0x6e,0x45,0x09,0x7c,0x5d,0x36,0x47,0xdd, - 0x43,0x85,0xe1,0xb0,0xdb,0x90,0xa6,0xd5,0xad,0x32,0x58,0xd5,0x0d,0x3f,0xae,0x75, - 0xc8,0x6f,0xff,0x36,0xff,0x71,0xcc,0x4d,0x25,0xff,0x25,0x26,0xe5,0xce,0xcf,0xf9, - 0x3a,0x9d,0x4c,0xe7,0x5b,0x50,0x5a,0x19,0x12,0x25,0xbe,0x6c,0x51,0x55,0x8f,0x42, - 0x9e,0xd0,0x31,0x20,0xd3,0x7d,0xc0,0x0a,0x45,0x8b,0x91,0xf4,0x99,0x96,0xf1,0xf0, - 0xe7,0x77,0xe8,0x7d,0xe5,0x36,0x80,0xcc,0xf3,0x3a,0xe8,0xa4,0x5d,0x5a,0xd0,0x29, - 0xb3,0xdc,0x9a,0x06,0xcc,0x21,0x7c,0xbb,0x05,0xac,0x65,0x88,0x0a,0x9f,0x0a,0x9a, - 0x21,0x2b,0xb4,0xcf,0x44,0xa3,0xa1,0x89,0x7a,0x60,0x39,0xc7,0x9f,0x61,0x50,0x43, - 0x72,0xad,0x7c,0x9c,0x7a,0xdb,0xe0,0xaf,0x7f,0xf9,0xbf,0x19,0xe7,0x94,0xb0,0xeb, - 0x35,0x2c,0x73,0x26,0xc4,0xfc,0x85,0x13,0x0a,0xc6,0x75,0x32,0xaa,0xa1,0x75,0xa8, - 0x43,0x78,0x1a,0x66,0xf3,0x8f,0x6c,0xb5,0x35,0x7e,0xa4,0x17,0xef,0xdf,0xca,0x5e, - 0xbf,0x01,0x6b,0x88,0xcc,0xd5,0xb2,0x29,0x24,0x67,0x1b,0x93,0xeb,0x7d,0xc5,0x7c, - 0x43,0x35,0x83,0x2b,0x7e,0xae,0xe8,0x08,0xf9,0x70,0x2b,0x5b,0x37,0x5b,0x3d,0x84, - 0x5a,0xad,0xef,0xd2,0x8f,0xb5,0x2a,0x6a,0x59,0xe9,0x6e,0x3b,0xfc,0xaa,0xda,0x29, - 0xf1,0x7b,0x3a,0x26,0x8c,0x73,0x02,0xc8,0x8f,0xa5,0x00,0x0d,0x43,0x89,0x00,0x5b, - 0x1d,0xaf,0x32,0x07,0xbe,0xc0,0x59,0xb1,0xde,0xb7,0xa0,0xec,0x59,0xa9,0x73,0xa7, - 0x41,0x47,0xa5,0x95,0x44,0x27,0x96,0xb4,0xfe,0xb2,0x04,0xfd,0xeb,0x78,0xc4,0x4c, - 0x19,0x91,0xec,0x43,0x8d,0x02,0x50,0xcd,0xd0,0xd9,0x10,0xa1,0x61,0xea,0x59,0x20, - 0xe5,0x14,0xbf,0xe7,0xa8,0x01,0x1c,0x1a,0xf6,0x5a,0x4f,0xd1,0x35,0x25,0x81,0x33, - 0x33,0xa3,0x39,0xbb,0x76,0x77,0xa0,0xf1,0xa9,0xbc,0x1b,0xdc,0x8d,0x75,0x7e,0x8f, - 0x82,0x77,0xc3,0x95,0x11,0xb7,0x4e,0x38,0xc7,0x26,0x2c,0xe3,0x0b,0xc9,0x50,0x5b, - 0xa9,0x8e,0x89,0x5a,0xa2,0x3b,0x23,0x05,0x68,0xbe,0x15,0x2b,0x70,0x64,0xbe,0xea, - 0xeb,0xdd,0x06,0x6d,0x45,0x1f,0x29,0x8c,0xae,0xa6,0x8f,0xb8,0x50,0xac,0x60,0xb6, - 0x0d,0xe0,0x93,0x81,0xac,0x3b,0xff,0x35,0x66,0xf0,0x55,0x85,0xc5,0x5b,0xe9,0x2a, - 0x2c,0x6c,0x19,0x1b,0x77,0x93,0xfa,0x6c,0xbb,0x3c,0x2b,0xae,0x5a,0x96,0xde,0xe8, - 0xa2,0x5f,0xd4,0x60,0x1c,0x7b,0x48,0x58,0xbf,0x42,0x5a,0x6b,0x8b,0xde,0x5e,0x77, - 0x53,0x84,0xab,0x8c,0x1d,0x5f,0x4c,0x15,0x1c,0xc8,0x85,0x76,0x4b,0x3a,0x6f,0x36, - 0xf8,0x28,0x0a,0x07,0x15,0xa0,0x59,0xd0,0xb6,0x0e,0x85,0x20,0x5d,0x5b,0x21,0x90, - 0x79,0xa2,0xbd,0x45,0xcf,0x40,0x8d,0x71,0xf6,0xba,0x09,0x81,0xb8,0x25,0xd1,0xc4, - 0x05,0xda,0xae,0xf4,0xbd,0x6c,0x53,0x97,0x91,0xa5,0x15,0x18,0xd9,0x7a,0xff,0xe7, - 0xd7,0x78,0x40,0xd1,0x57,0xc6,0x1c,0xcf,0x63,0xaf,0x78,0xd3,0x5a,0x75,0x35,0x26, - 0x1f,0xae,0x6e,0xc6,0x06,0x3b,0xcd,0x36,0x5b,0xd7,0xd6,0xce,0x11,0xff,0x75,0x96, - 0x17,0xbe,0xc4,0x2b,0xa8,0x0f,0x28,0xe7,0x97,0xba,0x85,0x5a,0xfd,0x00,0xfe,0x56, - 0x7d,0x99,0x32,0xb9,0x04,0xdd,0x0b,0x95,0x65,0xe8,0x2a,0x3a,0xcc,0xe6,0xc9,0x78, - 0x19,0x71,0x60,0x2b,0xef,0x01,0x78,0xdf,0xc9,0xa4,0xb3,0x8c,0xf6,0x01,0x4f,0x27, - 0x33,0x4e,0x20,0xc7,0x8c,0xf2,0x6b,0xea,0xc8,0x5d,0x3f,0x4d,0x31,0x44,0x1a,0xa1, - 0xa9,0xa3,0x42,0x25,0xdf,0x68,0xb3,0x14,0x57,0x2d,0xd3,0x13,0xab,0xe4,0xd0,0x98, - 0x30,0x13,0x1e,0x16,0x61,0x90,0xe9,0x60,0x58,0x82,0x0e,0x2d,0x0f,0x01,0xf1,0x73, - 0x8e,0x45,0xbe,0xf6,0x65,0xba,0x53,0x05,0x48,0x0e,0x03,0x41,0xc1,0x30,0x82,0x08, - 0x5d,0x0c,0xb4,0x4f,0x96,0x61,0x66,0x9b,0x18,0x6f,0x8e,0xf1,0xf9,0x34,0x31,0x65, - 0x80,0x7c,0xfe,0xfe,0x1f,0x31,0x18,0xf9,0x3a,0x56,0x61,0x09,0xe4,0x5a,0x55,0x3b, - 0x77,0x08,0xa9,0x49,0x99,0xf9,0x70,0xd5,0xce,0x68,0x1f,0x16,0xc4,0x40,0x3c,0xc2, - 0x5e,0x85,0x4a,0xbf,0x80,0x0e,0x24,0x6d,0x99,0xf1,0x53,0x96,0x23,0x17,0xa6,0x1c, - 0x35,0x67,0x89,0x5c,0x64,0x2d,0x8a,0x12,0xe7,0xfd,0x38,0x95,0x26,0x41,0x50,0xec, - 0x17,0xc7,0xc7,0x20,0xad,0x67,0x3a,0x2b,0x5c,0x87,0x52,0x15,0x72,0xfd,0x18,0x16, - 0x86,0xfd,0x9e,0x77,0xc2,0x3b,0x08,0x8e,0xce,0xc2,0x7a,0xe2,0x78,0x49,0xfb,0x81, - 0x43,0xa4,0x9e,0x3c,0x29,0x1d,0x46,0x16,0xb4,0x3f,0x72,0x25,0x53,0xcb,0xfa,0xb1, - 0x1f,0xad,0x32,0x94,0x72,0x38,0x6f,0x48,0x0e,0x18,0x28,0x03,0xc0,0x30,0x3f,0x20, - 0x3b,0x85,0x75,0xb4,0x39,0x7e,0xa5,0x98,0x64,0xca,0xe8,0xb7,0x48,0xb0,0x48,0xc6, - 0x8e,0x58,0xde,0x06,0x42,0xf6,0x15,0xcb,0xfc,0xb4,0x80,0x58,0x0a,0xdd,0x02,0xd4, - 0x7a,0xf7,0xee,0x35,0x96,0x98,0x41,0x36,0x4f,0xc3,0x51,0x0e,0xea,0xd7,0x3d,0x0a, - 0x3e,0x17,0x47,0x80,0xa0,0x22,0x31,0x1c,0x50,0x28,0xbe,0x49,0x0e,0x8e,0x71,0x45, - 0x7f,0xcb,0x66,0xc0,0x1d,0x46,0x82,0x1c,0xe9,0xab,0x3f,0xfd,0xf4,0xe2,0xe4,0xe5, - 0xd1,0x8f,0x6f,0xce,0x31,0x7c,0xf8,0xd3,0xa7,0x7d,0x8c,0xc2,0xdb,0xc3,0xf0,0xb5, - 0x4f,0xf0,0x07,0xaf,0x33,0xe8,0xf2,0x03,0xfc,0x6d,0xe3,0x65,0x59,0xf4,0xb4,0x47, - 0x4f,0xbd,0x7d,0x7c,0xbc,0x38,0x90,0xa0,0xde,0xfc,0x78,0xfe,0xd3,0xe9,0xfb,0xd7, - 0xef,0x08,0xd2,0xce,0x8e,0x46,0x26,0xc7,0x0f,0xaa,0xe8,0x64,0x13,0x9b,0x4c,0xfb, - 0x05,0xc0,0xfb,0xce,0x54,0x38,0xb2,0x74,0x6b,0xfd,0xe7,0x45,0x2d,0xb3,0x7e,0x67, - 0xa2,0x96,0x75,0x0f,0x54,0xa0,0xf2,0x1d,0x3d,0xf8,0xed,0xf1,0xcc,0xdc,0x03,0x13, - 0xcf,0xac,0x7a,0xf0,0x52,0x85,0x30,0xaf,0xef,0xc1,0xef,0x10,0xe9,0xcc,0x3d,0xc0, - 0x10,0x51,0xb7,0x07,0x98,0xdb,0xa2,0xb2,0x07,0x2a,0x88,0x94,0x7b,0x30,0x95,0xc5, - 0xe2,0x81,0xe8,0x6b,0x84,0x62,0x09,0x17,0xa1,0xaa,0xce,0x7a,0x60,0x56,0x7c,0xea, - 0x8e,0x02,0x86,0xb1,0xb5,0x6e,0xcf,0x8e,0x38,0xf2,0xb6,0x0c,0xcc,0x57,0x1f,0xec, - 0xa0,0x5c,0xcc,0x2c,0xa9,0x49,0x4f,0x3a,0x31,0x80,0xf4,0xd0,0x8f,0x71,0xf8,0xd4, - 0x04,0xe0,0x76,0xad,0x00,0x5c,0x40,0xa4,0x7b,0x86,0x00,0xb5,0x3d,0x0b,0xc0,0xab, - 0x5f,0x2b,0x00,0x1c,0x1f,0x9d,0x9e,0x29,0x8f,0xb2,0x86,0xa4,0x5e,0xfa,0x37,0x05, - 0x90,0xa6,0x34,0xc2,0x96,0xc0,0xf1,0x25,0x40,0x3e,0xf2,0x70,0xeb,0x8d,0x84,0xa3, - 0x15,0xb9,0x4c,0x0a,0x06,0x9e,0xdb,0x6c,0x52,0x62,0x7c,0xd6,0x03,0xb8,0xde,0x27, - 0xdb,0x91,0x7d,0x01,0x10,0xac,0x45,0xfc,0x89,0x4a,0x8b,0x7f,0xb0,0xdf,0x49,0x9f, - 0x11,0xb9,0x3e,0x1d,0x1f,0xb9,0x0d,0x87,0x2d,0x1f,0xf6,0x42,0xeb,0xf1,0x10,0xb0, - 0xb6,0xd8,0x69,0x69,0x8f,0x34,0x47,0x92,0xdc,0xb3,0x3c,0xe5,0x5d,0xed,0x1e,0xef, - 0xb5,0xa5,0x17,0xbc,0x27,0x7d,0x3a,0xd2,0x09,0x8e,0x19,0x0d,0x8c,0x03,0x1c,0x4f, - 0x5b,0xcb,0x4d,0x40,0x9e,0x4f,0xd4,0x05,0x5d,0xc7,0xf5,0x2d,0x5d,0x84,0xa4,0x91, - 0xa1,0xa3,0xed,0x08,0x1d,0x20,0x06,0xb1,0xc5,0x02,0x4e,0x48,0x56,0x02,0x87,0x11, - 0x9e,0xe7,0x95,0x91,0xd7,0xc6,0xd7,0x58,0x13,0xe7,0xe2,0xf3,0x6d,0x8b,0x1d,0xdf, - 0x58,0x87,0x27,0x05,0x6a,0xca,0x99,0x6e,0x9a,0x97,0x74,0x9d,0x16,0x3f,0xf8,0x37, - 0x15,0x25,0xfc,0x1b,0xd2,0x6d,0x50,0x2b,0xb3,0x20,0x3d,0xb5,0x2a,0xb5,0xc4,0x27, - 0xf3,0xa5,0x6d,0x7d,0xc0,0x49,0xfb,0x64,0x1e,0xdb,0x56,0x57,0x2e,0x8a,0x30,0xa1, - 0xed,0x8e,0xdd,0xd5,0x27,0x14,0xcb,0xec,0xf4,0xac,0x92,0xfe,0xac,0x2a,0x0f,0xb0, - 0x8a,0x1e,0x31,0xcd,0x9b,0x1a,0x4f,0xb7,0xd7,0x34,0x2f,0xd5,0x88,0x69,0x3e,0x2b, - 0x4a,0x14,0x47,0xcc,0xe5,0x3a,0x36,0xd8,0x27,0xc8,0x66,0xfa,0x2d,0x17,0x8e,0xbd, - 0xd2,0xac,0xb2,0x0f,0xb8,0xac,0xee,0x18,0xd3,0x4b,0xb1,0x5d,0x7e,0xab,0xba,0xc6, - 0x54,0x54,0x55,0xa6,0xd8,0x39,0x59,0xb2,0xe3,0xc0,0xa6,0xee,0xed,0xb5,0x0a,0xb0, - 0x4a,0xfd,0x93,0xa5,0x1f,0x70,0xe9,0x03,0xf6,0x04,0xbe,0x4c,0x96,0x29,0xef,0x4c, - 0x81,0x5e,0x8f,0xbf,0x71,0xb1,0xc0,0x6f,0x54,0xdb,0x28,0x4d,0xbc,0xbc,0x9a,0x86, - 0x02,0x3d,0x07,0x52,0x7b,0x03,0xb5,0x8d,0x0e,0xe2,0xd1,0xd9,0xd9,0xd0,0x63,0x48, - 0x47,0x14,0x29,0x87,0xc9,0xba,0xc3,0x69,0x8c,0xa6,0x0c,0x1f,0xab,0x0a,0xf8,0xb2, - 0x0a,0x54,0x55,0x82,0xb1,0xba,0x70,0x02,0x33,0xf9,0x62,0x66,0x17,0xce,0x68,0x4c, - 0xa9,0xc5,0x6f,0x16,0x11,0xba,0xdd,0x15,0xda,0x08,0xf4,0x21,0x2f,0x09,0x8d,0x4b, - 0xf6,0x58,0x1f,0x3a,0x0f,0x56,0x74,0x28,0xbd,0x26,0x6b,0xc6,0x3a,0x94,0xd0,0xf5, - 0xec,0x63,0x09,0xbb,0x9a,0x85,0xe9,0xc2,0xc4,0xca,0xe4,0x0e,0x85,0xc6,0x33,0xdb, - 0x49,0xdf,0x7e,0x4b,0x5c,0x39,0x99,0x08,0xeb,0x25,0x87,0x99,0x0e,0x71,0x13,0xbe, - 0xd1,0xb2,0x3f,0x98,0xc0,0x18,0x0d,0x06,0xd6,0x3f,0x02,0x69,0xde,0x2f,0xb8,0xbf, - 0xe5,0x37,0x6a,0x5a,0x3d,0xa8,0x60,0xfd,0x27,0x78,0x38,0x42,0xbf,0xb5,0xa0,0x4a, - 0x2e,0x87,0x5f,0x6a,0x58,0x09,0x6f,0x58,0x21,0x4b,0xb0,0xb9,0x48,0x06,0xca,0x3e, - 0xa1,0xcd,0xed,0x04,0x32,0x0e,0xe8,0x1b,0xfc,0xe1,0x2d,0xa3,0x96,0x78,0x66,0x1e, - 0x04,0x06,0xce,0x1d,0x38,0x8c,0x88,0xb6,0xb9,0xe8,0x1d,0x86,0x09,0x35,0x23,0xb0, - 0xa5,0x42,0x0e,0xb2,0x0a,0xa1,0xcf,0xbb,0xf0,0xe7,0xc1,0x03,0xee,0xb7,0xf4,0xda, - 0xbb,0x1c,0x8e,0x7b,0xf1,0x29,0xbc,0xc0,0x6d,0xa8,0x56,0x99,0x6b,0xd3,0x6e,0xad, - 0xdd,0x29,0x7c,0x43,0x08,0x6a,0x94,0xd2,0xf9,0x4a,0xef,0x98,0xb5,0x85,0x6b,0x57, - 0xd4,0xaf,0x5b,0xee,0x6e,0x2e,0x58,0x21,0x63,0x15,0xd1,0xf7,0x36,0x2b,0xd4,0x71, - 0xbe,0x51,0xc5,0x9d,0x1d,0xae,0xe6,0x6c,0xed,0xda,0x75,0xf4,0x07,0xb9,0x55,0xa7, - 0xf6,0x89,0x2d,0xd2,0x43,0xad,0x4d,0xd3,0x1e,0x28,0x30,0x9a,0xfa,0x6c,0x30,0x54, - 0xad,0xa5,0x76,0x95,0x5b,0x12,0x1a,0xaf,0x49,0xfa,0xa9,0xd8,0xbc,0xac,0x30,0xf7, - 0xe3,0xa5,0x1f,0x9d,0xd0,0x51,0xd6,0x67,0x94,0xa4,0x5c,0x3f,0x0f,0x4a,0x05,0x30, - 0xbc,0xf8,0x33,0x6d,0x8e,0xb2,0xfc,0xb1,0xb4,0x7d,0x22,0x5a,0xa0,0x39,0x5c,0xe5, - 0x7f,0x3f,0x1a,0xbe,0x39,0xcb,0x06,0x53,0x45,0xb6,0x17,0x9f,0x0b,0x6b,0x8b,0x25, - 0x13,0xfd,0xd2,0xe2,0xa3,0x4b,0xbd,0xaa,0x0b,0xa7,0xdb,0x96,0xea,0x7d,0xdd,0xd1, - 0x36,0x9d,0x34,0x1c,0xa8,0x7f,0xdd,0x21,0x27,0x7b,0x73,0x89,0xcf,0xbf,0xb9,0xe7, - 0x9b,0x18,0xc2,0xa5,0xcd,0x8f,0x63,0x60,0xdf,0x3d,0x59,0x13,0x68,0x3a,0x49,0x9b, - 0x4b,0xf1,0x9d,0x88,0x55,0x0d,0xd9,0xc3,0x4b,0x01,0xf6,0x2f,0x15,0xd5,0xe1,0x9b, - 0x77,0x9f,0xa9,0x23,0x98,0x19,0xb4,0x41,0x3f,0x4e,0x5f,0x03,0x5c,0x83,0x82,0x3b, - 0xce,0xd8,0x61,0x04,0xec,0x72,0x18,0x8e,0x38,0xec,0x18,0x75,0xc9,0x15,0x88,0x64, - 0x60,0xcd,0x4d,0xd2,0xc9,0x41,0x56,0xdd,0xe0,0x66,0x06,0x5e,0xbc,0x93,0x00,0x1b, - 0x47,0x01,0xc1,0x87,0x3f,0xe8,0x90,0xb0,0x62,0xe5,0x37,0x99,0x02,0x46,0x37,0x13, - 0x01,0x2f,0xfe,0xd4,0x6d,0xe3,0x71,0x21,0x72,0xe7,0x66,0x33,0x74,0x2d,0x0c,0xc3, - 0x4c,0x46,0xcd,0xa6,0x81,0x8a,0x8d,0xe4,0x78,0xea,0x78,0x25,0x96,0x9e,0x85,0xb7, - 0xcf,0xd0,0x07,0x8e,0x3f,0x5c,0xa1,0xbe,0xcb,0x76,0x09,0xd9,0x03,0x64,0x09,0x90, - 0x0d,0x00,0x24,0x7e,0xc8,0xc4,0x6c,0x63,0x7c,0x88,0x22,0x10,0xfb,0x0b,0xea,0xed, - 0x0e,0xa0,0xa1,0xd9,0xc3,0x08,0xa7,0x96,0xf3,0x2b,0x87,0x7f,0x01,0x1e,0x48,0xc4, - 0x9d,0xd2,0x7b,0xfa,0xd6,0xc7,0x6f,0xea,0x29,0x77,0xc0,0xaf,0x0c,0xf8,0x2a,0xd0, - 0xe6,0x17,0x0c,0xe0,0xc1,0x1d,0x3d,0x58,0xad,0xe9,0xc1,0xca,0xee,0x01,0x82,0xeb, - 0x71,0x37,0x90,0xfd,0x46,0x09,0xe3,0x66,0x86,0x7c,0x58,0xbe,0xaf,0xe2,0xcd,0x7d, - 0xc5,0x9c,0x95,0xd7,0x8d,0xc7,0x30,0x0f,0x51,0x28,0x34,0x01,0xca,0x03,0x81,0xf1, - 0xf7,0xdb,0xa2,0x6f,0x07,0x19,0x0e,0x6f,0x9a,0x50,0xa2,0x05,0xf5,0x61,0x61,0x51, - 0x53,0x73,0xbc,0xd1,0x20,0x00,0x55,0x99,0x5b,0x9c,0xab,0x63,0x95,0xce,0xb9,0x2e, - 0xad,0xc2,0xac,0x9a,0x0e,0xe8,0x96,0x26,0x63,0xb3,0x04,0x6d,0x21,0xc6,0x39,0xe8, - 0x78,0x41,0xb3,0xaf,0x52,0xae,0xe7,0xe2,0x99,0x62,0xb3,0xb6,0xc1,0x58,0x07,0xc8, - 0x46,0x90,0xbb,0x00,0xf1,0x3d,0x81,0xfb,0x23,0xfa,0x94,0x2b,0x61,0x5a,0xc2,0xfb, - 0x99,0xc5,0x4f,0xcc,0x6b,0xaa,0x30,0xa8,0xec,0x5b,0xb1,0xb1,0xcb,0xe0,0xcd,0x32, - 0xb7,0x4f,0xd5,0x32,0x92,0x59,0x84,0xd7,0xcb,0x4d,0xe3,0x5f,0x70,0xe6,0x08,0xf5, - 0x01,0x12,0xa2,0x52,0x5e,0x94,0x98,0x5d,0x88,0xec,0xc1,0x72,0x4e,0x20,0x9f,0x30, - 0xe1,0x0e,0xbb,0xad,0x12,0xba,0x01,0x62,0x41,0x00,0xa0,0x5b,0x35,0xf8,0x3b,0xf6, - 0xf6,0x90,0x38,0x40,0x74,0x8e,0x26,0x18,0xa4,0x5c,0xd4,0x82,0x50,0x6d,0x02,0x24, - 0x60,0x01,0xbc,0x55,0x95,0x0e,0x80,0xe1,0x03,0x9f,0x8e,0x43,0xab,0xea,0xce,0x93, - 0x03,0xed,0x3b,0x8f,0x1f,0xa0,0x54,0xa5,0xa5,0xe8,0x67,0xf9,0x09,0x1d,0xf7,0xe1, - 0x4d,0x00,0xe2,0x80,0x73,0xde,0xbc,0xc2,0xbc,0xde,0x78,0x7f,0x49,0x87,0x2f,0x35, - 0x81,0x96,0xda,0xfa,0x78,0x1d,0xea,0x10,0x7f,0xfd,0x97,0x7f,0x65,0x89,0x4d,0x41, - 0x9c,0xc9,0x64,0xc2,0x7d,0xf6,0xaf,0x02,0x37,0xe0,0xda,0x50,0xdb,0x02,0x83,0xc2, - 0xb3,0x5c,0xfa,0xfd,0x9d,0x6d,0x0c,0x5d,0x8d,0xe6,0xdf,0x06,0x62,0x6d,0x61,0x34, - 0x89,0x6f,0x1d,0x79,0x26,0xc8,0x89,0x22,0x9b,0xd4,0x16,0x85,0x4d,0xd8,0x74,0x19, - 0xb2,0x4d,0xf9,0x16,0x85,0x3b,0xba,0xaf,0xe6,0xc0,0x96,0xe6,0xea,0xae,0x8a,0x02, - 0xf9,0x61,0xe2,0x75,0x3e,0x83,0x38,0x0d,0x92,0x39,0xc6,0x0b,0x8b,0xbf,0x6d,0xaf, - 0xe2,0x1f,0x90,0x5b,0x22,0x47,0x04,0xdc,0xd5,0xed,0xdc,0x84,0x63,0xed,0x65,0xc1, - 0xa1,0xc9,0x5b,0xf5,0x0e,0xc5,0x1f,0x9a,0x0d,0xba,0x4d,0xca,0x7c,0x1d,0xe5,0x28, - 0xa1,0x4c,0x21,0x84,0x44,0xbb,0x58,0x37,0x79,0xb3,0xd1,0x1f,0x9b,0x92,0x6f,0x99, - 0x56,0x07,0x62,0x17,0xd4,0x8d,0x74,0x20,0xf6,0xdb,0x02,0xaf,0x91,0x06,0x3d,0x64, - 0x08,0xef,0xba,0x8a,0x02,0x4f,0x3f,0xb2,0x30,0x38,0x7d,0xc5,0xcc,0x85,0xc3,0x7c, - 0xe9,0x88,0x67,0x1a,0xf8,0x30,0x6f,0x59,0x26,0x16,0x37,0xd6,0xcc,0xe2,0x21,0xd0, - 0x53,0xf8,0xde,0xb4,0xd9,0x15,0xdd,0xf3,0xa5,0xbb,0x8b,0x49,0xa3,0x79,0x7b,0x53, - 0x3a,0x8e,0xb2,0x0c,0x5b,0xc1,0x42,0xde,0x28,0x0a,0x61,0xd0,0x1f,0xd1,0x41,0xef, - 0x14,0xc0,0xe6,0x77,0xf9,0x58,0x0f,0xbf,0x1b,0x2f,0x52,0xe3,0x78,0xe6,0x08,0x86, - 0x53,0x60,0x52,0xd1,0x07,0xdc,0xc2,0x21,0xdb,0x8b,0x6e,0x65,0x35,0x88,0x50,0x4e, - 0x7f,0x5b,0xb5,0xc2,0x86,0xbf,0x43,0x50,0xad,0x42,0x61,0xed,0xfa,0x77,0x4b,0xbf, - 0xaa,0x2e,0x4d,0xe9,0xf9,0x75,0x03,0x04,0xf5,0x01,0xd8,0x17,0x37,0x8d,0xca,0x72, - 0x1a,0x36,0x01,0xb4,0x0a,0xc2,0xdc,0x79,0xb8,0x9c,0x52,0x3f,0xce,0x70,0xbb,0xbd, - 0x09,0x4d,0xb5,0x11,0xf9,0xf0,0x8f,0xfe,0x49,0x6d,0xd3,0xac,0x50,0x3b,0x1d,0xf1, - 0xd6,0x8b,0xe8,0xbf,0x74,0xdb,0x2a,0x4d,0x13,0xc1,0xc5,0x57,0x39,0xfd,0x77,0x48, - 0x2b,0x84,0xb1,0xf6,0xe2,0xe4,0xf8,0xe8,0x05,0x65,0xbf,0xb0,0x92,0x75,0x18,0x4f, - 0x08,0x70,0x7f,0xcb,0xfb,0xa6,0x48,0xe5,0xe6,0xfd,0x04,0x69,0x74,0xf6,0x2b,0x7b, - 0xf5,0xa0,0xbd,0x07,0xd8,0x03,0xd0,0x2c,0x2c,0x20,0xca,0xe3,0x83,0xa5,0x1c,0x28, - 0x98,0xdf,0x83,0x5b,0xd5,0x00,0x67,0xbf,0x32,0xc4,0x1b,0x02,0xa8,0x6a,0x9a,0x3a, - 0x2a,0x97,0x88,0xca,0xf5,0xa1,0x7a,0x0d,0x2d,0x36,0x6f,0x78,0xc8,0x08,0xf6,0xf4, - 0x63,0xcb,0x78,0x1f,0x57,0x0c,0x52,0x7a,0x1e,0x61,0xe8,0x0f,0x58,0xf1,0x31,0xb1, - 0x59,0xa8,0x01,0x9d,0xbe,0x52,0xe5,0xaf,0xb8,0xfc,0xca,0x74,0x01,0x0a,0x61,0x85, - 0xe6,0x8a,0x71,0x47,0x2d,0xbc,0x6a,0xf1,0x56,0xc6,0x6b,0xe0,0xcf,0x29,0x5d,0xbb, - 0x84,0xde,0x8b,0x8c,0x37,0x50,0xd4,0xb6,0x1b,0x18,0x79,0xb4,0x33,0xc3,0xe7,0x51, - 0x79,0xef,0x89,0xb4,0x5d,0x1c,0x51,0x5b,0x9d,0xbd,0xa1,0xe4,0x2e,0x98,0x87,0x06, - 0xe1,0x61,0x0c,0x85,0x3a,0xf2,0xe5,0xe4,0x1d,0x6a,0x64,0xa2,0x4f,0x09,0xa9,0xba, - 0x97,0x7a,0x3f,0xd5,0x3a,0x13,0x90,0x89,0x9d,0x1e,0x7c,0x7d,0x74,0xe9,0xc9,0x61, - 0x9c,0xbf,0x3e,0xfe,0x81,0x77,0x23,0x70,0x07,0xa2,0xb1,0xd7,0x6d,0xe0,0x9e,0x43, - 0x0f,0xb7,0x1c,0x1a,0xf0,0xdf,0x86,0xdc,0x8f,0x80,0xa7,0xbe,0x7c,0xda,0xeb,0x72, - 0x41,0x7c,0x02,0x8a,0xc1,0xb2,0x54,0xf8,0xb2,0x61,0xf6,0x2e,0x1a,0xfd,0xcb,0x86, - 0xd9,0xbb,0x68,0xec,0x5d,0x2a,0xa8,0x12,0x2e,0x3c,0x63,0x96,0x0a,0x6f,0x12,0x46, - 0x60,0xdc,0x36,0x9b,0x9f,0x66,0xbf,0x5e,0x10,0x22,0x67,0xbf,0x8a,0xa7,0xc2,0x99, - 0xc8,0x1e,0x26,0xe3,0x01,0x13,0x13,0xbe,0x3c,0x11,0x16,0xa5,0x7d,0x87,0xce,0xcd, - 0x87,0x7a,0xfa,0x5e,0x3c,0xff,0x09,0x53,0x1d,0xf1,0x50,0x40,0x21,0x6f,0x70,0x67, - 0x31,0x73,0x26,0x3c,0x74,0x7a,0x8f,0xe4,0x23,0x3d,0xed,0xec,0xc9,0xa7,0x3e,0x3d, - 0xee,0xed,0xf0,0x23,0xfe,0x7e,0x08,0xf5,0x2e,0x78,0xce,0xdc,0xbb,0x47,0x0b,0x87, - 0x98,0xf9,0x50,0xfd,0xf1,0xd9,0x99,0xb9,0x6e,0xb4,0x2d,0x8f,0xa8,0xd3,0xa5,0xa3, - 0x78,0x24,0x91,0xcf,0xb7,0xc3,0xfc,0xd1,0x2e,0x1f,0xb9,0xaa,0xf4,0x3d,0xa7,0x69, - 0x20,0x0f,0xbf,0x8f,0x92,0x54,0xe6,0xea,0xe1,0xbb,0x58,0xf5,0xb5,0x19,0x74,0x16, - 0x42,0x9d,0xf9,0xc4,0x2b,0x49,0x11,0x0a,0x66,0x00,0x48,0x89,0xc5,0x1f,0xd8,0x61, - 0x72,0x6a,0x8b,0x14,0x59,0x60,0x66,0xa8,0x85,0x77,0x6d,0xd1,0xda,0xa1,0x63,0x17, - 0x78,0x1d,0x2b,0x5f,0xa2,0xeb,0x59,0x92,0x14,0x7b,0xe5,0x30,0x5b,0x14,0x0c,0xc4, - 0xf5,0xe7,0x0b,0xf4,0x65,0x9d,0x21,0xd3,0x31,0x41,0x12,0x74,0xf2,0xc9,0xb0,0x51, - 0xd4,0x2d,0x9a,0x78,0x76,0xb3,0x4d,0x3b,0x2d,0x43,0xf2,0x19,0xa0,0xf5,0x41,0x92, - 0xe3,0x14,0xe8,0x2e,0x48,0xf3,0x15,0xeb,0xba,0x58,0x8c,0xcc,0x50,0x5d,0xd2,0xcb, - 0xd3,0x70,0xde,0xb4,0xc0,0x45,0xe4,0x11,0xba,0x6a,0x36,0xe8,0x6a,0x5d,0xdc,0xf2, - 0xfe,0x26,0x78,0xb4,0xb7,0xd7,0x0f,0x6c,0x7e,0x3f,0x87,0x22,0xdb,0xff,0xf3,0x9b, - 0x67,0xcd,0x4f,0xdd,0xce,0x63,0xbf,0x33,0xb9,0xf8,0xdc,0xbf,0x6d,0xd5,0x3f,0xfc, - 0x61,0x3b,0xf4,0x82,0x9b,0x60,0xd4,0xc4,0x83,0xa7,0x80,0xf7,0xc8,0x1f,0x05,0xcd, - 0xc6,0x37,0x08,0x5c,0x46,0x46,0xea,0xb6,0x3f,0x4c,0xd1,0xbb,0x37,0x07,0xb5,0xfb, - 0x9f,0xff,0xf0,0xd9,0x24,0x0a,0xa1,0xc4,0x12,0xbd,0xfd,0xd6,0x6d,0x5b,0x38,0xaf, - 0xfb,0xd5,0xaf,0x77,0xe4,0xeb,0x7f,0xc6,0xb8,0xb4,0xfe,0x0e,0x88,0xc4,0x47,0x40, - 0x67,0xbb,0xfb,0x8d,0x92,0x7b,0x49,0x5e,0x2a,0x4b,0xe3,0xa5,0xdf,0x34,0xe2,0xc9, - 0x70,0xf2,0x30,0x98,0x34,0xa4,0xc7,0x45,0xde,0x6c,0x4c,0x65,0xe8,0x37,0x97,0xd9, - 0x0d,0x26,0xc1,0xbe,0x2a,0x43,0x77,0x1a,0x53,0x09,0xf8,0x45,0xdf,0x7b,0xbb,0xf8, - 0x7f,0xf5,0x5d,0x6e,0x28,0x50,0x09,0x8e,0xba,0xc5,0x32,0xfb,0xc3,0xfd,0xbd,0x3d, - 0x5f,0x95,0x91,0x37,0x1e,0x73,0x5f,0xf0,0x37,0x95,0xf1,0xf7,0x1f,0x4f,0x1e,0x77, - 0x55,0x19,0xbe,0x01,0x99,0x8a,0xe0,0x4f,0x2a,0x31,0x7e,0x3c,0xee,0x8d,0x74,0x89, - 0x48,0x45,0xd0,0xc3,0x8f,0xa3,0x01,0x1d,0xc4,0x00,0x6a,0xf8,0x67,0xba,0xb6,0xfd, - 0x0f,0x9f,0x19,0xc5,0x84,0x2f,0xff,0xb6,0xf5,0xcf,0x06,0x68,0xb9,0xa8,0x49,0x28, - 0x52,0xd9,0x5a,0x69,0x22,0xed,0xc4,0x26,0x15,0xb3,0xf2,0x65,0x50,0x60,0xca,0x76, - 0x7f,0x3b,0x94,0xdd,0xb6,0xd8,0xb7,0xa0,0x98,0x11,0xc3,0x24,0xdd,0x35,0xe0,0xe2, - 0x3c,0x7e,0xd5,0x78,0x37,0x05,0xb2,0x76,0xb8,0x9b,0x02,0xa9,0x18,0xad,0xed,0xde, - 0x43,0xe6,0x26,0xa3,0x17,0x64,0xb4,0x05,0xa5,0xa8,0xe3,0x5b,0x4c,0x63,0xbc,0x7e, - 0x54,0xa5,0x3e,0x91,0x69,0xd6,0xdb,0xea,0x7e,0xe9,0x21,0xa6,0x62,0x40,0xc7,0x29, - 0x82,0x01,0x33,0x25,0x88,0xf0,0x86,0x55,0x76,0xf2,0xf8,0xfa,0x16,0x1d,0x4a,0x02, - 0xef,0xb9,0x76,0xf6,0x47,0x6a,0xaf,0x59,0xb7,0x81,0xa5,0x8f,0x25,0xf0,0x46,0x9e, - 0xde,0x0d,0x72,0x4e,0x4a,0xb8,0x3e,0xb7,0x80,0x8c,0x29,0x3e,0x11,0x4e,0xf9,0x05, - 0x40,0x32,0xe9,0x07,0x09,0x86,0x58,0x5c,0x53,0x99,0x5e,0x50,0x40,0xfe,0x54,0x9f, - 0x4d,0xe0,0x27,0xd4,0x69,0x15,0xf2,0x10,0x48,0x45,0x02,0xbe,0x58,0xc9,0x08,0xac, - 0x97,0x9c,0x91,0xe0,0x9e,0x93,0x24,0x07,0x2b,0x76,0xbd,0x1e,0x17,0x06,0x89,0xc7, - 0x05,0x2c,0x23,0x9b,0x8b,0x98,0xdd,0x20,0x2e,0x69,0x6d,0x1b,0x15,0x36,0x01,0x51, - 0x79,0xf4,0x73,0xa9,0x8c,0x59,0x8e,0x09,0x94,0xce,0x7c,0x3c,0x1e,0x38,0x24,0x7c, - 0xe4,0xfc,0x3e,0x5d,0x63,0xe0,0x73,0x81,0xc7,0x8f,0xf7,0x54,0x32,0xb8,0x9e,0x21, - 0x97,0xed,0xff,0xe1,0x75,0xff,0xb0,0x4d,0x14,0x83,0x4a,0xeb,0x25,0xe6,0xaa,0x90, - 0x07,0xce,0x2c,0x25,0x19,0x5a,0xac,0xb7,0xc8,0x50,0x35,0xc2,0x6d,0x9a,0xbf,0xf9, - 0x48,0x8f,0xb7,0xef,0xdf,0xbd,0x07,0x3a,0x69,0xd0,0x9d,0xa7,0xcb,0x90,0x6e,0x9a, - 0xa6,0x54,0xcf,0x6d,0x71,0xf6,0xf2,0x2d,0x3c,0x74,0x3e,0x04,0xd3,0x65,0x84,0x17, - 0xb7,0xbe,0x0d,0xe2,0x28,0x69,0xcb,0xb4,0x93,0x3e,0x50,0xbd,0x2e,0xdb,0x70,0x36, - 0xa9,0x61,0xe8,0x25,0xb3,0xe8,0x9c,0x63,0x8b,0x50,0x7e,0x1b,0x31,0x86,0xaa,0xbd, - 0xd6,0xb1,0x1f,0xa0,0x62,0xdf,0x16,0xaf,0xe8,0x1d,0xea,0xb4,0xa7,0xaf,0xe8,0xdd, - 0x50,0x1b,0x0c,0x64,0xa6,0x7f,0x00,0x0d,0xa4,0xc9,0x86,0xc2,0x47,0x28,0xdd,0xd2, - 0x5f,0x41,0x41,0x8b,0x48,0x01,0x00,0x00,0xe7,0x1e,0x89,0x28,0xe7,0x5b,0xb9,0x22, - 0xef,0xba,0xa1,0xa6,0xab,0xcb,0x25,0x14,0x01,0x89,0x38,0x31,0x66,0x4a,0x9e,0x82, - 0x22,0x62,0x20,0x93,0x0c,0x68,0x62,0x00,0x45,0x6d,0xdb,0x28,0x92,0xf4,0x37,0xd4, - 0xa2,0x3f,0x4a,0x7b,0xa9,0xa7,0x9d,0x58,0x72,0xcb,0xfe,0xaa,0xcd,0xf1,0x0c,0x17, - 0x98,0x28,0x49,0x29,0x86,0xee,0x3a,0x5e,0xb9,0xa6,0x19,0x28,0xff,0xa4,0xe0,0x3f, - 0xa0,0x5b,0xa9,0xf5,0xfa,0xc4,0x54,0x86,0x5d,0x5c,0xc1,0x98,0xdf,0xb0,0x67,0x1c, - 0x95,0xd4,0x85,0x61,0x30,0x0d,0xe3,0x53,0x80,0x81,0x31,0xbb,0xf4,0x06,0x2f,0x45, - 0x3a,0x4f,0x9a,0x80,0xfc,0xb6,0x58,0xa9,0x97,0xd8,0x53,0x7e,0x49,0x33,0x62,0x7d, - 0x61,0x1c,0x34,0x9d,0x88,0x5c,0xfa,0x80,0xc6,0xf6,0x11,0xde,0xc0,0x86,0x24,0x44, - 0x0c,0x52,0x06,0xab,0x6b,0xb4,0x60,0xce,0xd3,0x26,0x8d,0xb1,0x2d,0xed,0x37,0x30, - 0xc2,0xc9,0x5f,0xab,0xfd,0x6b,0x65,0x48,0x7c,0x91,0x5b,0xa3,0x88,0xad,0xd9,0xaf, - 0x36,0xba,0xc8,0x1e,0x70,0x71,0x75,0xe3,0xe2,0x0a,0xac,0x39,0x5a,0xa5,0x36,0xb2, - 0xd6,0x23,0xe4,0xa6,0x4d,0xf6,0x90,0x8b,0x10,0x7e,0x49,0x14,0x59,0x8d,0x90,0xca, - 0xc1,0x5a,0xb5,0x70,0xa7,0x7d,0x7f,0xcd,0x70,0xf1,0x86,0xe3,0x86,0x43,0x4c,0x04, - 0x48,0xf3,0x36,0xcb,0xc4,0x24,0x24,0x56,0x41,0x5e,0x37,0x1d,0x77,0x80,0xc5,0x63, - 0x8b,0xc2,0xcc,0x7a,0x0d,0xec,0xaa,0x75,0xa0,0x3f,0xae,0xa7,0x30,0x0b,0x22,0xcc, - 0x43,0x2d,0xb9,0x55,0x17,0x33,0xa8,0x96,0xf9,0x1d,0x54,0x22,0x9b,0x2c,0xf7,0xc3, - 0x74,0xe4,0x67,0x81,0x31,0x07,0xe0,0x13,0x9e,0x05,0x22,0x29,0xe7,0x26,0xbc,0x51, - 0x02,0x13,0xdf,0xaa,0x7d,0xe2,0xa7,0x66,0xa1,0x54,0xac,0x62,0xd4,0x7c,0x60,0x99, - 0xf7,0x76,0x5b,0x35,0x84,0x63,0xde,0x3a,0xab,0x09,0x68,0x0e,0x1b,0xf9,0xd4,0xbd, - 0x70,0x77,0xb0,0x86,0xd7,0x98,0x34,0xef,0xa3,0xe0,0x11,0xc8,0x3e,0xd4,0x6f,0x39, - 0xd8,0x85,0xaa,0xf6,0x1e,0x90,0x2d,0xe8,0xb6,0xc2,0x0b,0x13,0xae,0x5e,0x42,0x6d, - 0x08,0x06,0xe8,0xf0,0x9a,0x56,0x73,0x6d,0x99,0x26,0x1e,0x0c,0xeb,0xb5,0x0a,0x25, - 0xad,0x85,0x5e,0x3b,0x59,0x36,0x1e,0x4c,0xa1,0xea,0xef,0x23,0xcc,0x12,0x5f,0xc2, - 0x1e,0xa2,0xbd,0x69,0x32,0x11,0xc0,0x14,0x73,0xb6,0xdd,0xaa,0xc3,0x98,0xd2,0x85, - 0x1a,0x92,0x3a,0x6a,0xaf,0xfc,0x6b,0x8a,0x59,0xb0,0x35,0x29,0x07,0xf9,0x37,0xb8, - 0x09,0x86,0xec,0xc0,0x8a,0xe3,0x81,0x25,0xda,0x2b,0xbc,0x95,0xc1,0x22,0x1a,0xc7, - 0xe7,0xc9,0x42,0xa2,0x19,0xe0,0xa3,0x42,0x03,0xb8,0x79,0x9e,0xe4,0xd6,0x3b,0xd0, - 0x7c,0xec,0x1a,0x61,0x76,0x46,0x9e,0xf2,0x90,0x36,0x08,0xb3,0x20,0xb2,0x3f,0x32, - 0x25,0xe3,0x57,0x2a,0xf5,0x0c,0x88,0x0c,0x8d,0xc8,0x81,0x0c,0x75,0x50,0xa7,0x09, - 0xa5,0x00,0x81,0xf7,0x44,0x84,0x0e,0x7c,0x3f,0x5a,0xcc,0x7c,0x37,0x7e,0xe3,0x19, - 0x46,0xa3,0xec,0x22,0x14,0x05,0x16,0x88,0xb6,0x2f,0x28,0xb0,0xef,0x21,0xaf,0x9a, - 0x4a,0xfa,0x46,0xab,0xa7,0x49,0xf0,0x8a,0x93,0x41,0x92,0xf2,0x06,0xb7,0x0c,0x61, - 0xfc,0x84,0xa5,0x8e,0xa0,0x47,0x1c,0x7a,0x87,0xde,0x3a,0x70,0x5d,0xce,0xc0,0x4f, - 0x05,0xaa,0x50,0x02,0x50,0x75,0xb1,0xe7,0xed,0xa1,0xca,0xb5,0x11,0x4b,0x96,0x1d, - 0x29,0x32,0x65,0xd9,0xa1,0x35,0x1c,0xb9,0x1e,0x64,0xaf,0x1a,0x64,0x6f,0x1d,0xc8, - 0x0a,0x75,0x5b,0xb9,0x31,0xdf,0x40,0xfd,0x17,0x7e,0x36,0x6b,0x7e,0x42,0xe3,0xf9, - 0xa2,0xf5,0x5b,0x86,0x55,0xe8,0xda,0x17,0x0d,0xab,0x80,0x92,0x8d,0x86,0x55,0x1e, - 0xc3,0x85,0x9a,0x5c,0x0c,0xaa,0xc6,0x80,0x72,0x8e,0x07,0xa7,0x6b,0xa2,0x66,0x49, - 0x1a,0xfe,0x0a,0x4a,0x92,0x1f,0x71,0x6c,0x93,0x3f,0x4a,0x93,0x2c,0x93,0x89,0xad, - 0x6f,0xb4,0x9b,0x50,0xde,0xa7,0xe1,0x5f,0x05,0x29,0xe6,0xdc,0x52,0xd0,0x28,0x8f, - 0x24,0x56,0x7b,0x7e,0xf2,0xe6,0xfd,0xbb,0xef,0xcf,0x54,0x7e,0x1d,0xa8,0x0a,0x3a, - 0x7e,0x80,0x51,0xfe,0xb9,0xb1,0xbd,0x42,0xbc,0xb4,0x03,0x0c,0xad,0x45,0x18,0xc7, - 0x2a,0xa5,0x77,0xa0,0x40,0x0d,0x93,0x1c,0xaf,0xb4,0x0a,0xc6,0x60,0x4e,0x35,0xf1, - 0x6e,0x11,0x4a,0xb1,0x07,0x42,0x5f,0xc5,0x80,0x63,0x58,0xc0,0x16,0xac,0x57,0x7f, - 0x98,0xf0,0x61,0x27,0x19,0x22,0x9f,0x27,0x0b,0x4f,0xbc,0x20,0xc7,0xa5,0x9f,0x2b, - 0x68,0x94,0x4a,0xc4,0xbf,0xd6,0x71,0xea,0x58,0xfe,0xda,0x47,0x60,0xd8,0xf0,0x32, - 0x47,0x75,0x43,0xd9,0x7c,0xd0,0xd9,0x36,0x9a,0x74,0xa3,0x19,0x3b,0xd1,0x00,0x2f, - 0x43,0x44,0x68,0x6c,0x6f,0xbb,0x33,0xca,0xb4,0xf9,0x25,0x93,0x82,0x68,0x0b,0x8c, - 0x9f,0x91,0x6d,0x73,0x72,0x6a,0xbb,0xe6,0xca,0x8e,0x95,0x20,0x0e,0xf4,0x00,0x97, - 0x8b,0x15,0x34,0x21,0xd7,0x21,0xbd,0x44,0x3e,0x44,0xd0,0xf4,0x79,0xe1,0x4a,0x61, - 0x0d,0x8b,0xbd,0x66,0x45,0xf6,0x37,0x95,0x6e,0x37,0xb8,0x0d,0x0f,0xa4,0x14,0xad, - 0x2a,0x58,0x3e,0xf1,0x88,0xd2,0x47,0x47,0x74,0xf3,0x0a,0x22,0x06,0x50,0x50,0x4f, - 0x6b,0x34,0xf7,0xda,0xc1,0x00,0x53,0x2c,0x49,0x32,0x47,0xc3,0xd6,0x32,0x55,0xea, - 0x8d,0x23,0xcc,0x39,0xbc,0x22,0xa5,0xf1,0xd3,0x27,0xb5,0xf0,0xd0,0x8f,0xaa,0x56, - 0x1b,0xfd,0x96,0xab,0x47,0xbf,0xc7,0xdf,0x17,0xce,0x79,0x7f,0x87,0x41,0x8e,0xd0, - 0x3d,0xbf,0x83,0x73,0x30,0x5a,0xa9,0x5f,0x0f,0xe1,0x1f,0xfb,0x18,0xb8,0xe9,0xfd, - 0x66,0x55,0x6e,0xcd,0x41,0x4f,0x46,0x1a,0xd9,0xa3,0xe7,0xfe,0x94,0xe2,0x80,0x6d, - 0xbb,0xd7,0xfa,0xb0,0xc6,0xca,0xda,0x5c,0x7e,0xe6,0xfe,0x94,0x77,0xad,0xe0,0x07, - 0x1d,0x14,0x77,0xc4,0x4e,0xad,0x74,0x85,0xd2,0x72,0xab,0x07,0x75,0xd8,0xb2,0xf6, - 0x6d,0x89,0xdb,0x96,0xb5,0xff,0x63,0x57,0xcc,0x49,0xc6,0x16,0x2c,0x1c,0x29,0x6e, - 0x91,0xa8,0x7a,0xe5,0x8a,0xe5,0xc3,0xc0,0x20,0x69,0x31,0x18,0x5e,0x49,0xdd,0xd6, - 0xba,0xb2,0x24,0x36,0x01,0x75,0x8d,0xb6,0x25,0x46,0xad,0x1a,0xbf,0x2c,0x83,0x74, - 0x75,0x46,0xf7,0xcc,0xe2,0x31,0x2a,0x4f,0xde,0x4b,0x5a,0x3a,0x27,0xd7,0x78,0xde, - 0xb0,0x34,0xa7,0x07,0x05,0x29,0xde,0xf8,0x8f,0x7f,0x7f,0x4b,0x39,0x2e,0xd5,0xe9, - 0x46,0x34,0x8b,0xbf,0x8f,0x56,0x8b,0x59,0xb3,0xa2,0x11,0xbe,0xfa,0xa1,0xd1,0x6a, - 0xcb,0x53,0x66,0x3d,0x30,0x92,0x7a,0xfd,0xb6,0xa5,0x01,0xdc,0x6a,0x5f,0x03,0x70, - 0x3a,0x75,0x81,0xdf,0x14,0xe1,0xb5,0xc5,0x32,0xc3,0x0b,0x71,0x31,0xc1,0xfe,0x94, - 0x33,0x15,0x4d,0x92,0x04,0x0f,0xc3,0xd0,0xb5,0x2a,0xae,0x61,0xce,0x3d,0xe0,0xe6, - 0x54,0x63,0xa0,0xf1,0xcd,0xda,0xbc,0x79,0x60,0x13,0xd3,0x66,0xdb,0x8e,0x24,0x16, - 0xed,0x6d,0x47,0x8c,0xee,0xbc,0x96,0x9b,0x87,0x3a,0x87,0x94,0xb3,0x2d,0x29,0xbf, - 0x1e,0xa8,0xf7,0x7a,0x97,0x70,0x26,0x3f,0xd8,0x95,0xdc,0x0d,0xc7,0x6b,0x45,0x0b, - 0xee,0x67,0x0b,0x82,0xa1,0x95,0x5b,0x3d,0x12,0x24,0xed,0x51,0xdd,0x1e,0xb1,0x10, - 0xd3,0x0d,0xb7,0x22,0xa7,0x25,0x0f,0x04,0x62,0x4e,0x81,0x70,0x98,0x14,0x21,0x13, - 0x71,0x24,0x57,0xa3,0xe2,0x59,0x53,0x97,0x59,0x79,0xbb,0xe6,0xe5,0x31,0xed,0x1b, - 0x37,0x68,0x0d,0x34,0xf8,0x75,0x81,0x1f,0x57,0x98,0x09,0x00,0x63,0xdf,0x31,0x0f, - 0x78,0xc0,0x4b,0x52,0x41,0xb7,0xf1,0xa3,0xf5,0xf6,0x4a,0xae,0xe1,0x8a,0x98,0x9e, - 0x83,0x82,0x11,0xdd,0x07,0x3c,0x62,0x4c,0x5d,0xf3,0x1a,0x96,0xe0,0xae,0xab,0x14, - 0x13,0x9a,0x3b,0x50,0xa4,0x03,0x10,0xa1,0xc8,0xcc,0x2a,0x42,0x2c,0x9e,0x56,0x61, - 0xb7,0x05,0x03,0x30,0x56,0x35,0xba,0x12,0x28,0xf0,0x69,0x6a,0x59,0xd5,0x2b,0x6d, - 0x13,0x4f,0x2d,0x59,0x51,0xe3,0x51,0xa3,0x33,0x5c,0x3e,0x53,0xf2,0xdf,0xa4,0x27, - 0xed,0xe4,0xc5,0xf7,0x27,0x80,0x9b,0x87,0x1c,0x4d,0x40,0x97,0x84,0x94,0x03,0x4f, - 0x66,0x61,0x7e,0x0e,0xca,0x0a,0x8f,0x9e,0xe6,0x0c,0xc6,0x7a,0x66,0x5f,0x6a,0x0d, - 0x7a,0x13,0x6a,0x35,0x74,0x1b,0x19,0x1e,0x65,0x03,0xc8,0xa8,0x56,0x51,0xe6,0x50, - 0xd4,0x71,0x38,0x6d,0x2a,0x7b,0x9e,0xd5,0x5d,0x20,0x71,0x92,0xab,0xcc,0xd3,0x7c, - 0x41,0xb6,0x4c,0x34,0x06,0xab,0x62,0x88,0x21,0xe9,0xa4,0x17,0xc5,0xe8,0x9b,0xc6, - 0xe3,0x99,0x7a,0x5d,0x24,0xe9,0x98,0x02,0x5b,0x3e,0x65,0xe8,0xb0,0xf0,0x3c,0xcf, - 0x08,0x0c,0x4c,0x0c,0xd2,0xfc,0x49,0x49,0x8a,0xb0,0xa5,0xf7,0x3f,0xe5,0x0b,0x5a, - 0xe6,0xc8,0x69,0x2f,0x0a,0x4e,0x9a,0x10,0xa5,0x2c,0x01,0x76,0x49,0xb2,0xec,0xc8, - 0xbe,0x38,0xf8,0x1b,0xb4,0xdf,0x62,0x3c,0x56,0xf8,0x8f,0x08,0x56,0x3c,0xc5,0x56, - 0x3b,0x3c,0xad,0xa0,0xb9,0xdd,0x80,0x51,0x0e,0x0d,0x3e,0xa0,0x17,0xe5,0x3a,0xff, - 0x84,0x40,0xa1,0x0e,0xb5,0x69,0x6a,0xad,0xa0,0x16,0x35,0x69,0xd7,0xa3,0x70,0x5a, - 0xd5,0x14,0xe6,0xd0,0x96,0x20,0x5a,0x14,0x04,0x1a,0xc6,0x4b,0x27,0x10,0x13,0x73, - 0x3e,0xbf,0x51,0x22,0xd2,0x1f,0x66,0x14,0x17,0x70,0x03,0xeb,0x0b,0x56,0x3f,0xc2, - 0x6c,0x53,0x89,0x0f,0xa5,0x12,0x3d,0x5d,0xa2,0x08,0xed,0xdc,0x2e,0xbb,0x52,0x26, - 0x9d,0x0b,0xef,0x79,0xa9,0x0c,0x9a,0x10,0x2e,0x44,0xba,0x83,0x08,0xe8,0x2a,0xcd, - 0x2c,0x42,0xf5,0xc5,0x34,0xf5,0x87,0x48,0x80,0x29,0xee,0x1d,0x63,0x90,0x8c,0xbc, - 0x06,0xc7,0xbf,0x09,0xe8,0x1a,0x1c,0xcc,0x7a,0xeb,0x99,0xe4,0x1b,0x34,0x3c,0x40, - 0x15,0xf5,0xcc,0xda,0x64,0xc0,0x49,0x1d,0x88,0xb0,0x4d,0x47,0x61,0x31,0x0f,0x59, - 0xe4,0x5c,0xae,0xc0,0x83,0xde,0xa4,0x5e,0x5a,0xaa,0xa7,0xdb,0x7b,0xbe,0xa6,0xde, - 0x70,0x4d,0x7b,0x6b,0xeb,0x55,0xb4,0xb7,0xa6,0x78,0x45,0x2b,0x6b,0x4a,0x97,0x61, - 0xaf,0x1d,0x7a,0xa9,0xf4,0xda,0x8e,0x3b,0xa5,0xe5,0x02,0x30,0x94,0x4f,0xd4,0x2c, - 0x09,0xdc,0xa2,0xec,0x35,0x00,0x91,0xfd,0x37,0x4a,0x5b,0x3c,0xcc,0x12,0x99,0xcb, - 0x7f,0x0f,0xa4,0x32,0xa4,0xac,0xce,0x66,0xef,0x8c,0x8d,0x3a,0x19,0x4e,0x00,0x8c, - 0x10,0x33,0x42,0xf2,0x21,0xe3,0x0c,0xef,0x51,0xda,0x7a,0xdd,0x88,0x22,0x9d,0xef, - 0x19,0xc3,0x1c,0x10,0x10,0x52,0xdb,0xd6,0xc0,0xde,0x64,0xe3,0xf4,0x13,0x01,0x25, - 0x58,0xe1,0x33,0xbf,0xe6,0x18,0xbf,0xbe,0xeb,0x91,0x72,0xbe,0x06,0xc1,0x38,0x18, - 0xeb,0x80,0x09,0x67,0xeb,0x8e,0xec,0xc0,0x8c,0xf2,0x53,0x5b,0x1d,0xf4,0x73,0x0a, - 0x75,0x51,0x47,0x71,0x7e,0x5e,0xce,0x17,0xf6,0x7e,0x1e,0x9e,0xe2,0xf9,0x35,0x38, - 0x82,0xae,0x30,0x2f,0x73,0x75,0xf4,0xb5,0xfb,0x72,0xca,0x35,0xf9,0xcc,0xbc,0xc2, - 0xe8,0x59,0xde,0x95,0x6b,0x19,0x57,0xe6,0x17,0x68,0xf6,0x94,0x80,0x1d,0xb7,0xf4, - 0x2e,0x2c,0xe6,0x13,0x14,0x72,0xf1,0x57,0x1d,0x00,0x0b,0x0c,0x73,0xac,0x38,0xb6, - 0x05,0xc6,0x69,0xe9,0xcc,0x56,0xdb,0xaa,0x3b,0x93,0x67,0x54,0x6e,0xb5,0x25,0x62, - 0x32,0xba,0x59,0x49,0x48,0x31,0x1f,0x00,0x62,0x8a,0x73,0x78,0x94,0xad,0x9c,0x24, - 0x3f,0x4d,0x48,0xf2,0xe3,0x2d,0x60,0x94,0x8e,0x44,0x23,0x12,0x63,0x51,0x4a,0x01, - 0x80,0xcf,0x51,0x7d,0x82,0x49,0x39,0xa6,0xe0,0x3a,0x52,0xd5,0x9c,0xa3,0x32,0xe2, - 0x86,0xce,0xff,0xc7,0xb9,0x0c,0xbf,0xfb,0x47,0xe0,0x6c,0x08,0x88,0xec,0x17,0x90, - 0x0e,0xee,0xd7,0x7f,0x52,0x5f,0xd1,0x48,0xe1,0x9d,0x47,0xab,0xb9,0x8a,0x6c,0xc8, - 0xdc,0x57,0x98,0x73,0x4c,0x4e,0xa4,0xba,0x2c,0x27,0x44,0x05,0xe4,0xa3,0xe8,0xa7, - 0x90,0xfb,0xf2,0xe8,0xcc,0xd6,0xd8,0x8c,0xd2,0x7b,0x3b,0xda,0x82,0x3e,0x88,0x01, - 0x6f,0xed,0xe9,0xc3,0x77,0xf0,0x8a,0xe8,0x41,0x8b,0x65,0xc1,0x7a,0x00,0x1d,0x1c, - 0x52,0x1f,0x5b,0x95,0xbb,0xc6,0x14,0xb1,0x04,0x25,0x70,0xcd,0xf2,0xd1,0x2c,0x5e, - 0xb6,0xa5,0xd7,0x51,0xc5,0x3b,0x73,0xf8,0xa1,0x82,0xe8,0x0f,0x48,0x42,0xe0,0x91, - 0x04,0x29,0x11,0x30,0x4f,0x00,0x46,0xc3,0xd2,0xdd,0x1f,0x85,0xf5,0x8e,0x0b,0x3a, - 0xa3,0xfb,0xe7,0x1c,0xd5,0xbd,0xa4,0x36,0xa8,0xc1,0x58,0x67,0xab,0x4a,0xba,0x83, - 0x33,0x60,0xa9,0x88,0x7d,0x56,0x07,0x81,0x66,0x9c,0x29,0x5a,0x70,0x16,0xc3,0x7f, - 0x1c,0xe0,0x7c,0xd0,0xcf,0x7f,0x1a,0x88,0x55,0xfb,0x9e,0x9d,0x1a,0xd2,0x3a,0xa4, - 0xa9,0xb2,0x43,0x9a,0xc3,0x99,0x32,0xe7,0xec,0x40,0xb0,0x1a,0xc1,0x1b,0xd5,0xac, - 0x66,0xdc,0x93,0x29,0x14,0xed,0x78,0xc8,0x40,0x4d,0xf5,0x31,0xe7,0xb8,0xe5,0x19, - 0xf7,0x24,0xc5,0xbc,0xe6,0xbe,0xca,0x77,0x29,0xfd,0x7d,0xc1,0xe7,0x55,0x69,0x4d, - 0xa0,0x37,0x60,0x13,0xca,0xa3,0xa9,0xfb,0x5a,0xca,0x23,0xe2,0x42,0x74,0xb9,0x9a, - 0x5b,0x0d,0x29,0x56,0x84,0x7b,0x82,0x51,0x9a,0x25,0xa8,0x50,0x22,0x89,0xa2,0x0d, - 0x2c,0x8f,0xdc,0xaa,0xec,0x57,0x03,0x43,0x3d,0x87,0x86,0xd0,0x9e,0xc9,0x1f,0xba, - 0x50,0xd3,0x2d,0x85,0x72,0x1f,0x18,0x9f,0xfb,0x72,0x88,0x94,0x07,0x35,0xe3,0xeb, - 0x0c,0x73,0x1f,0xa1,0x96,0x51,0x0f,0x20,0xad,0x02,0x10,0x49,0x00,0x41,0x76,0x7d, - 0x17,0x80,0xaa,0x0e,0xc8,0xf6,0x03,0x5d,0x19,0x6d,0xfd,0x38,0x53,0x4f,0x76,0xdc, - 0xc3,0xc1,0x7a,0x8a,0x46,0x8c,0x17,0x49,0xfa,0xed,0xeb,0x77,0x1f,0xc8,0x40,0xec, - 0xf5,0x65,0x20,0x72,0xec,0xa7,0x69,0x72,0x8d,0x49,0x9c,0xc8,0x01,0x49,0xde,0x58, - 0x95,0xd0,0x63,0x25,0xe8,0x9a,0xc0,0x7b,0x72,0x0e,0x09,0x60,0x01,0xcb,0xd6,0x19, - 0x25,0xb2,0x74,0x31,0x15,0x06,0x46,0xdb,0x45,0xc9,0xb4,0xa3,0xc1,0x0c,0xb4,0x23, - 0xd7,0xdc,0xf0,0xf3,0xe1,0xe8,0xfc,0xf5,0x7b,0x41,0xf6,0xb7,0x72,0x7b,0x86,0xfa, - 0x84,0x92,0x4c,0x9c,0x01,0x40,0xf8,0xea,0xc4,0x79,0x00,0xa0,0xd1,0x13,0xb1,0xc5, - 0xd7,0x50,0x02,0x26,0x40,0x50,0xce,0x83,0x6b,0x52,0x06,0xd1,0xfe,0xdb,0xb2,0xbd, - 0xa4,0xd9,0x2c,0x24,0xa7,0x11,0xe9,0xab,0xd4,0x6b,0x5e,0x93,0x1c,0xf1,0x8a,0x77, - 0xdd,0x71,0x34,0xac,0x39,0xb0,0x43,0xe3,0x54,0x32,0x48,0xc5,0xcc,0x12,0x18,0x75, - 0xf6,0xcb,0x2e,0xe0,0x1e,0xc8,0x07,0xa0,0xd4,0x46,0xfd,0xe9,0x7c,0xab,0x04,0x1e, - 0x95,0x26,0x58,0x2d,0x5b,0x04,0xaa,0x53,0xe1,0x06,0x0e,0x74,0x92,0x8a,0xb9,0x85, - 0x48,0x4e,0x1a,0x50,0x6e,0x21,0x3e,0x4c,0x57,0x15,0xb8,0xa3,0x3d,0x2f,0xa8,0x21, - 0x5c,0x81,0x85,0xb2,0x42,0x0f,0x18,0xfe,0x30,0xc8,0xf9,0x27,0xed,0x2c,0x54,0x7b, - 0x9e,0xbe,0xe9,0x51,0xa8,0xf0,0x18,0x25,0x6e,0xa9,0xc8,0xc1,0x89,0x93,0xe5,0x00, - 0x2f,0xed,0x04,0x18,0x6d,0x55,0x11,0x64,0xf9,0xf8,0xca,0xa4,0x6f,0x74,0x54,0x03, - 0x03,0xd6,0xd1,0x08,0xa8,0x12,0x02,0xb1,0x12,0xd5,0xb1,0xb5,0xaf,0x89,0xee,0x84, - 0x4c,0x5a,0x24,0xfd,0x91,0xb4,0x18,0x30,0x6d,0x46,0x60,0x02,0x40,0xe9,0xe8,0x34, - 0x92,0x6a,0x76,0x40,0xe7,0x9a,0xb1,0x10,0xeb,0x5d,0xe3,0x04,0x23,0x87,0xaf,0x13, - 0x63,0x32,0x68,0x02,0x87,0x1e,0x8c,0xa2,0xe5,0x38,0xc8,0x9a,0xb0,0x46,0x5b,0x2d, - 0x77,0x92,0xf4,0x10,0x31,0x06,0xbb,0x79,0xd3,0xb2,0x4f,0xda,0xc3,0x4c,0xe3,0x12, - 0xb3,0xbc,0x16,0x55,0x30,0x53,0x1b,0xa6,0xab,0xfb,0x94,0x60,0x72,0x28,0xf7,0xdd, - 0x30,0x73,0x0d,0xb3,0xa4,0x4f,0xf1,0x8c,0xd7,0x9d,0x85,0xaf,0x07,0x39,0xb4,0x41, - 0xba,0x43,0x2f,0x82,0xa4,0x93,0xef,0x1a,0xe4,0x2d,0x8b,0x47,0x19,0xad,0xc3,0x6a, - 0x12,0x7a,0x0a,0xe8,0x76,0x6e,0xfb,0xc5,0x59,0x90,0xe3,0xfe,0x4a,0xa6,0x65,0x91, - 0xd2,0xc0,0xa2,0xc0,0xcf,0xe8,0x0c,0x04,0xae,0x62,0x2d,0x6f,0x2c,0x41,0x62,0x38, - 0xa0,0xe3,0x10,0x11,0xe6,0x04,0xce,0x01,0x26,0xe6,0xd8,0x44,0xb6,0x2d,0x31,0xc9, - 0x8a,0xd5,0x64,0x6b,0xb3,0x6a,0x7c,0x93,0x4b,0xa9,0x6a,0xe9,0x1c,0x2f,0xa7,0xb2, - 0xfa,0x3b,0xcc,0xd5,0x43,0xdd,0xb7,0xcf,0xc9,0xd0,0x0b,0x73,0xfc,0x65,0xc4,0x07, - 0x65,0xac,0x62,0xb5,0x27,0x65,0x8e,0xf5,0x51,0x19,0x4c,0xcd,0x91,0x0e,0x04,0xb2, - 0xd7,0x7c,0x40,0xae,0xe8,0xe1,0x40,0xf4,0xf7,0xd5,0x59,0x99,0x63,0x79,0x56,0xe6, - 0xf8,0x95,0x39,0x16,0x46,0x0d,0xbc,0xa8,0x76,0x7b,0xa1,0x24,0xe0,0x33,0x50,0x5f, - 0xee,0x61,0x76,0x4e,0xce,0xec,0xec,0xed,0xb7,0xd5,0x21,0x99,0x7e,0xef,0x11,0x7d, - 0xb7,0x06,0xe6,0x1c,0x4c,0x51,0x5e,0x66,0xeb,0xbb,0x7b,0x20,0x45,0x7b,0x9b,0xed, - 0x22,0xf5,0x27,0x5c,0x2a,0x8a,0xd5,0x1d,0x70,0x19,0x6d,0x7e,0xc0,0xe5,0xd8,0x3a, - 0xe0,0x72,0xcc,0x11,0x52,0xc7,0x78,0xc4,0x85,0x71,0x2b,0x0f,0xb8,0x1c,0xf3,0x09, - 0x97,0x63,0xf7,0x88,0xcb,0x48,0x1e,0x57,0x59,0xd2,0xca,0x3b,0xe6,0xe0,0x0c,0x73, - 0xba,0x13,0xc6,0x77,0xfc,0x51,0x53,0x81,0x73,0x6a,0xe4,0xb8,0xf6,0xd8,0xc8,0xf1, - 0xab,0x62,0x2c,0x5f,0x79,0xde,0x2a,0x83,0xf9,0x4a,0x7a,0x0d,0x58,0x20,0x17,0x6e, - 0xac,0x9f,0xec,0xe1,0x31,0x62,0xf4,0x58,0x47,0xfb,0xc9,0xbe,0x1c,0xbf,0xe2,0xb7, - 0x43,0x8d,0xbf,0x9a,0x78,0x3f,0xf5,0xd9,0xdd,0x36,0xa4,0x78,0xf3,0x03,0xfb,0x1b, - 0xd5,0x3c,0xa6,0x60,0x14,0x6c,0x02,0xfe,0xfb,0x11,0x09,0xb6,0x65,0xe6,0xa7,0x26, - 0xa2,0x69,0x54,0xb9,0xa9,0x38,0x2a,0x6c,0xe6,0xc9,0xc1,0xd0,0x39,0x0d,0x39,0x04, - 0xfe,0xfd,0x91,0xb7,0x42,0x8f,0x5f,0xe9,0xa3,0xe7,0xa3,0xb5,0x71,0x84,0xa6,0x88, - 0xb3,0xb3,0x3f,0xb2,0xf6,0x5e,0x71,0xa2,0xa9,0x28,0x37,0xa5,0xbe,0x4a,0x4f,0x7a, - 0xe1,0x2b,0xe1,0x52,0x15,0xb1,0x37,0xfb,0xd7,0xb7,0xc1,0x98,0x42,0x32,0x21,0x58, - 0x85,0x36,0xf4,0xd4,0x55,0x14,0x29,0x46,0x68,0xd1,0x32,0xa9,0x1b,0xb6,0xdc,0x08, - 0x19,0x55,0xee,0x43,0xeb,0xb7,0xa5,0xcd,0x90,0x51,0xc5,0xfe,0xb4,0x8a,0xaa,0x41, - 0x55,0xf5,0x50,0xb8,0x59,0x47,0xcc,0x53,0x29,0x35,0x81,0xa9,0xfa,0x8e,0xc2,0x55, - 0xb0,0xfa,0x33,0xcc,0x68,0x06,0x1a,0xfc,0xfe,0x6e,0xed,0x46,0xcb,0xbb,0x35,0xfb, - 0x2c,0xef,0xbe,0x72,0x9b,0x85,0x66,0x0e,0x8f,0x61,0xe3,0xa6,0xca,0x88,0x43,0x3b, - 0x2b,0x36,0x52,0x46,0x6e,0x84,0xa2,0xde,0x4b,0x19,0xb9,0x41,0x8a,0x66,0x3b,0xa5, - 0x72,0x5a,0x64,0x7a,0x3b,0x19,0xb3,0x5e,0x99,0xab,0xa5,0x12,0x6b,0x85,0x2c,0x07, - 0x92,0x9d,0x38,0x18,0x97,0xe3,0xaa,0x9a,0x6f,0x1d,0x0c,0x6b,0x98,0xa2,0x09,0x35, - 0x01,0xb1,0xb2,0xa3,0xc2,0x65,0x36,0x59,0x01,0x2d,0x49,0x7d,0x23,0x6f,0xd5,0x6d, - 0x55,0xad,0x82,0x11,0x5e,0x3b,0x6c,0x4a,0x2d,0x7a,0xab,0x56,0xd5,0x4a,0xd8,0xa8, - 0xb5,0x9e,0xd5,0x5a,0xaf,0xae,0xb5,0xbe,0xdd,0x5a,0x7f,0x6d,0x6b,0xa5,0x20,0x1b, - 0xfd,0xa5,0x14,0x98,0xb5,0x06,0x9f,0x3a,0xf8,0x61,0x54,0x13,0xfa,0xe0,0x04,0xcc, - 0x02,0x55,0xcc,0x54,0xe0,0xc3,0x48,0x5e,0xc9,0x8c,0x58,0xc1,0x20,0x87,0x91,0xbc, - 0x8c,0x99,0x2e,0x62,0xb6,0x02,0x1d,0x46,0x85,0x30,0x07,0x0c,0xa2,0xbd,0x69,0x99, - 0xc8,0x05,0x7c,0x5e,0xb5,0x2a,0xe3,0x17,0x46,0xa5,0x80,0x87,0x8d,0x2b,0xdf,0x5a, - 0xf1,0x7f,0x14,0x75,0xc4,0xce,0x84,0x81,0x60,0xdb,0x91,0x0e,0x07,0x2e,0xb3,0x70, - 0x44,0x29,0xb3,0xf9,0x8a,0x79,0xa9,0xb8,0xe9,0x73,0x19,0xf5,0x0e,0x21,0x15,0x12, - 0xf0,0x25,0xc1,0x39,0x28,0xbe,0x4c,0x78,0x8e,0x59,0xef,0xfa,0x22,0x78,0x06,0xd3, - 0x91,0xfe,0x1f,0xb4,0x59,0xed,0xbc,0x35,0x5d,0x10,0x01,0xec,0x0d,0x32,0x45,0x0a, - 0x87,0xd1,0xd6,0x30,0x89,0xd1,0x9a,0x20,0x9e,0x32,0x31,0xed,0xe8,0x65,0xb4,0x46, - 0x74,0xad,0x27,0xf6,0x65,0x95,0xf0,0xb0,0x69,0x7d,0xa9,0xe8,0xfc,0xaa,0x55,0x2b, - 0x5b,0x2a,0x28,0xbc,0x9e,0xbe,0x6b,0x3a,0xe5,0xa7,0xa3,0x52,0x83,0x6d,0xb1,0x8b, - 0xd4,0xa2,0xcc,0x56,0xca,0xca,0xd2,0x57,0x15,0x54,0xf8,0xa8,0x24,0x1d,0xba,0x6d, - 0x4a,0x37,0xec,0xc6,0xf3,0x8f,0xd6,0xc4,0xea,0x8f,0xea,0xa3,0xb2,0x47,0x4e,0xf8, - 0x74,0xf1,0x72,0x92,0x06,0x10,0x1b,0x3e,0x93,0x03,0x08,0x7e,0x63,0x50,0x08,0xcf, - 0xb7,0x3e,0x6b,0xd2,0x67,0xe4,0x46,0x6d,0x47,0xd7,0x91,0xc1,0xd5,0xa3,0x35,0x81, - 0xdb,0x77,0xb4,0x8c,0x4d,0x32,0x8d,0x95,0x9a,0x92,0xb2,0xba,0xae,0x3d,0x07,0xa6, - 0x51,0xd7,0x8a,0xd7,0x67,0x68,0x68,0x1d,0x3c,0x34,0x2f,0x81,0x3d,0xda,0x0c,0x8c, - 0x75,0x87,0x47,0x25,0x18,0x5b,0x13,0xdb,0x04,0xef,0x4b,0x0b,0x1c,0x6a,0xda,0xe2, - 0xaf,0xff,0xf2,0xaf,0x42,0x5f,0x17,0x53,0x6a,0x1c,0xb3,0x46,0x63,0x74,0x42,0xe5, - 0x00,0x51,0xeb,0xad,0xaa,0x64,0x25,0x00,0xb1,0x10,0x69,0xfa,0xdc,0xeb,0xf3,0x06, - 0x85,0x6d,0x08,0x7c,0xd5,0x2e,0x40,0xad,0xb2,0x4c,0xa6,0xb3,0x25,0x94,0x55,0x7a, - 0x33,0x4b,0x28,0xdf,0x77,0x84,0xb2,0x31,0xb0,0x9d,0x5d,0x91,0x82,0xb5,0x57,0xb7, - 0x2d,0x62,0x54,0x91,0x75,0x9b,0x22,0x85,0xcf,0xf6,0xae,0x88,0x65,0xa0,0x95,0x34, - 0x02,0xb3,0xe1,0x4c,0x67,0xea,0x49,0x16,0x99,0x6c,0x99,0xb3,0xd5,0x22,0xc9,0xc9, - 0xf3,0xa7,0xa4,0x04,0xb6,0xd4,0xd1,0x32,0x02,0xf3,0xe6,0xf4,0xfa,0x0a,0x27,0x08, - 0xa5,0x69,0x4b,0x30,0xf8,0x6e,0x1b,0x9c,0x8d,0x45,0x8f,0x68,0x86,0xf4,0x22,0xbb, - 0x82,0x16,0x71,0xa5,0x0a,0x7d,0x53,0xc1,0x42,0xa2,0x6d,0xe2,0xfd,0x76,0x9f,0xfd, - 0x46,0x84,0x52,0xed,0xb4,0x37,0x39,0xb7,0x5e,0x14,0x3c,0x29,0x5f,0x3f,0xd1,0x8e, - 0xfc,0xaa,0x9d,0x71,0x69,0x88,0x52,0x72,0x83,0x8f,0x05,0x99,0xe5,0xa4,0x0f,0xa8, - 0xdd,0x2a,0x63,0x83,0x95,0x00,0xbc,0xda,0xdc,0x44,0x2c,0x53,0x90,0x4e,0xba,0xc5, - 0x93,0x76,0xc8,0xf3,0x8c,0xf9,0xc8,0x89,0x14,0xa0,0xfc,0xf2,0x80,0xc9,0x01,0x1d, - 0xa6,0x7c,0x1f,0x01,0xfb,0x1d,0x69,0xce,0x4d,0x81,0xbe,0x55,0x60,0xe4,0x51,0xda, - 0xa6,0xc6,0x68,0x99,0xe5,0xc9,0xbc,0x51,0x97,0x71,0x45,0x6d,0x1e,0x29,0xfb,0xd7, - 0x78,0xc3,0x4e,0x29,0x53,0xbb,0xe5,0x24,0x73,0xa3,0x34,0x4b,0xfe,0xb1,0x63,0x99, - 0xe0,0xa5,0xb9,0xc9,0xdc,0x96,0x9c,0x28,0x8e,0xab,0xac,0xe4,0xa1,0xbb,0x3d,0xd8, - 0x8c,0xc6,0x6c,0xe7,0xd9,0xb1,0x4c,0x26,0xb3,0x51,0xc5,0xa2,0xfb,0x4c,0x55,0xb6, - 0xfc,0x67,0x0b,0xc2,0x47,0xd6,0x66,0x5b,0xaa,0x8d,0x79,0x72,0xc8,0x5b,0x88,0x51, - 0x70,0xcb,0xf9,0x7f,0x79,0xb4,0x96,0xe5,0xbe,0xa8,0x98,0xaf,0x4d,0x98,0xb0,0x8c, - 0xeb,0x19,0xbb,0x56,0xe5,0x33,0xdb,0x5a,0x42,0x1a,0xc2,0xcc,0x8a,0x92,0x88,0x30, - 0xc7,0x95,0x9a,0x2e,0x9d,0x7f,0xc0,0x89,0xf1,0x3c,0x02,0x2d,0xa5,0xe1,0x31,0xaa, - 0x1a,0x2d,0xb3,0x6d,0x8f,0x7b,0xb5,0x7a,0xc7,0x3e,0x88,0xea,0x2e,0xba,0x81,0x2f, - 0x63,0x3f,0x07,0xfc,0x03,0xab,0xa1,0x85,0x10,0x8e,0xad,0xed,0xf5,0x3f,0x34,0x1b, - 0x9c,0x61,0x0b,0x6f,0xce,0x04,0xe8,0xb3,0x70,0x3c,0x0e,0xf8,0x16,0x41,0x77,0x41, - 0x41,0x41,0x9a,0xa4,0x0f,0xc9,0xb5,0x53,0xac,0xb9,0xb1,0xf5,0x5c,0x93,0xe5,0x73, - 0x5d,0x0d,0xdd,0xea,0xbb,0x52,0x0c,0xad,0x3c,0x55,0x6b,0xd5,0xe6,0xdb,0x7c,0xee, - 0xc6,0xa1,0x50,0x21,0xb3,0x06,0x95,0xfc,0xa6,0x14,0x00,0x21,0xcb,0x1f,0xaa,0x8c, - 0xe3,0x9f,0x64,0x28,0xe8,0xc2,0x07,0xf2,0x50,0xb9,0x87,0x0c,0x76,0x2f,0x8a,0xe1, - 0xba,0x2a,0x58,0xf6,0xb3,0xca,0x4c,0xcc,0x00,0x55,0x7e,0x33,0x99,0xdb,0x18,0xef, - 0xa7,0xd8,0xc5,0x23,0xee,0xbb,0x6d,0x3b,0xe8,0xd3,0x8e,0xdd,0xfd,0x6a,0xc2,0x80, - 0xc9,0xaf,0xbf,0xc4,0xa2,0x79,0xf7,0x80,0x6d,0xe2,0xb1,0xa3,0x40,0xb8,0xa0,0x1b, - 0x0a,0x62,0xa5,0xab,0x72,0x17,0x06,0x2e,0xef,0x74,0x39,0x42,0x39,0x38,0x3e,0x8e, - 0x92,0x38,0x68,0xda,0x78,0x68,0x59,0x51,0xce,0x16,0xc3,0xac,0x65,0x99,0x05,0xa6, - 0x69,0x4a,0xba,0x65,0x6c,0xce,0x77,0xab,0x98,0xab,0x22,0x27,0x50,0xd8,0x96,0xd9, - 0xda,0x0b,0x3e,0x18,0x37,0x54,0xf8,0x68,0x8c,0x9b,0x49,0xcd,0x0e,0xe5,0xa9,0x55, - 0x10,0x4e,0xa3,0x2f,0x06,0x40,0xf5,0xad,0x6b,0xc0,0xcd,0x97,0x71,0x10,0xe5,0x7e, - 0xeb,0xf7,0x54,0xf2,0xd4,0xfa,0x31,0xf3,0x63,0x2f,0x94,0x35,0x39,0x35,0x9d,0x62, - 0x0f,0x04,0xf7,0xac,0x55,0x00,0x50,0xe0,0x6f,0x1e,0x5f,0xfa,0xd3,0x47,0xc3,0x82, - 0xd2,0xb9,0x8b,0x81,0xed,0x31,0xab,0x10,0x91,0xa6,0xee,0xd7,0xca,0xca,0x8a,0x49, - 0xb7,0xf7,0x85,0x4a,0x9c,0xdc,0xec,0x43,0x6d,0x82,0xe6,0x3f,0xe0,0x75,0xce,0xf1, - 0xf8,0x1d,0x18,0xf5,0x15,0xc1,0xfb,0x58,0x8b,0xcc,0x07,0xcc,0x6d,0xf7,0x40,0x7a, - 0x6a,0x65,0x15,0xbc,0x88,0xaf,0x54,0x45,0x9f,0x9b,0xb5,0x62,0x50,0xd9,0x10,0xf9, - 0xcb,0xff,0x26,0x48,0xa5,0x02,0x98,0xea,0x1b,0x0b,0xbc,0xfa,0x95,0x43,0x0b,0x2a, - 0xae,0xb6,0x6c,0xe0,0x4d,0xc8,0xb6,0x05,0x29,0x9f,0xe9,0x8a,0x3e,0xbd,0xa5,0x57, - 0x30,0x7d,0xa0,0x41,0xfb,0xbb,0x63,0xb0,0xa9,0x61,0x80,0x41,0xf3,0x06,0x6d,0xe1, - 0xd2,0x30,0xac,0x4c,0xd6,0x25,0x8b,0x4a,0x0e,0xc4,0x4a,0x83,0x6d,0x15,0x31,0x92, - 0x31,0x4f,0xf1,0x12,0xd7,0x43,0xd9,0xcc,0x39,0x3e,0xd9,0x49,0x6f,0xae,0xe9,0x96, - 0x20,0xbc,0x60,0xd3,0x4a,0x71,0x86,0xce,0xf0,0xae,0xd5,0x37,0xbc,0x31,0xd7,0x39, - 0x39,0xd2,0xdc,0xb3,0x5b,0xa6,0xed,0x54,0x0c,0x3e,0xdf,0xb3,0x8f,0x7f,0x70,0xdd, - 0x57,0xe1,0xfa,0xba,0xb4,0xe1,0x5e,0x53,0xf7,0x65,0x48,0x77,0x02,0xdd,0xd9,0xf2, - 0x1d,0x35,0x9d,0x0c,0x6b,0x72,0x4b,0xbd,0x3e,0xbf,0x78,0xab,0x02,0x22,0x9e,0x13, - 0xd1,0x17,0x57,0xd5,0x08,0x7d,0xe7,0x78,0xca,0x1d,0x02,0x04,0x5a,0x3a,0xfd,0x1d, - 0x65,0x48,0xed,0xa2,0x2a,0x26,0x65,0x57,0xe9,0x35,0x8d,0x88,0x71,0xa3,0x06,0x75, - 0x36,0xf5,0x72,0xc1,0x59,0xe8,0x70,0x7e,0x6b,0x93,0xd9,0xe1,0x25,0x05,0x1e,0x51, - 0x23,0x14,0xd0,0x5d,0x0f,0x6d,0x39,0xaa,0xb3,0x43,0x9f,0xbf,0xb3,0xa1,0xee,0x18, - 0x61,0x6e,0x43,0x77,0x9a,0x61,0x57,0x9b,0x9a,0x61,0x7b,0x68,0x42,0x35,0xe9,0xcd, - 0xb5,0x5e,0x44,0xbc,0xc4,0xcd,0x58,0x75,0x94,0xf6,0x55,0x91,0xea,0x9e,0xd4,0x7c, - 0x43,0xbe,0x04,0xac,0x27,0x62,0x8e,0x33,0x0b,0x2d,0x2a,0x57,0x63,0xf8,0xed,0xb6, - 0xef,0x86,0xc8,0x5f,0x63,0xfc,0xca,0x21,0x96,0x4d,0xdf,0xbf,0xd9,0x49,0xc1,0x6e, - 0xeb,0x89,0xa1,0xc8,0xb1,0xa4,0xd1,0x2a,0x2c,0x18,0x13,0x9b,0xd1,0x16,0x2e,0xc7, - 0xd0,0xb7,0x0a,0x90,0x09,0x5b,0x75,0x17,0x01,0x06,0x89,0xd4,0xde,0x45,0xb0,0x7e, - 0x15,0xd1,0x9c,0x38,0x66,0xe9,0xfb,0xa5,0x15,0xb5,0x61,0x77,0x1d,0xed,0xeb,0xc2, - 0x52,0xb2,0x16,0x1d,0xde,0xa5,0xb2,0xf1,0xec,0xda,0x66,0x27,0xb4,0xb7,0x39,0x59, - 0x14,0xcd,0x4e,0xae,0x7c,0xaf,0xc8,0x38,0x37,0x61,0x69,0x77,0xd0,0x8c,0x73,0x2f, - 0xc2,0x7d,0xf3,0x54,0x8d,0xd2,0x02,0x2f,0xb2,0xa2,0x67,0x1c,0xcd,0x45,0xf6,0x95, - 0x18,0x32,0x6a,0x1a,0x9b,0x76,0xd5,0xe9,0x1d,0x86,0x75,0x59,0x77,0x96,0xa8,0x4d, - 0x8c,0x2f,0xd0,0xd0,0xa5,0x01,0x20,0x13,0xfa,0xdb,0x9a,0xba,0x15,0x1e,0xac,0xe0, - 0x16,0xfb,0x6f,0x0c,0x7c,0xfb,0x38,0xe1,0xdf,0x63,0xa2,0x7b,0x67,0xd2,0x5c,0x44, - 0xdf,0x1d,0x3f,0x8f,0xa3,0x96,0x71,0x35,0xf0,0xcb,0x39,0x1b,0x0b,0xcf,0x1b,0x9f, - 0x45,0xad,0x29,0x4c,0xa4,0x86,0x3d,0xab,0x3a,0x8d,0x4a,0x75,0x8a,0xc7,0x51,0xf1, - 0x65,0x8d,0x4a,0x6b,0x9d,0x47,0xbd,0x03,0x00,0x68,0xa4,0x9b,0xaa,0xb7,0x52,0xd9, - 0xac,0x52,0x6e,0x4b,0x96,0x72,0x45,0x73,0x75,0x27,0x5b,0x95,0x8d,0x6c,0xc6,0x2b, - 0xad,0xe4,0x75,0x77,0x63,0x7c,0x66,0x39,0x65,0x26,0xe2,0x6e,0xd3,0xcd,0xd0,0x79, - 0xd8,0x2a,0xd8,0x14,0xf6,0x37,0x93,0x77,0x8d,0x53,0x5d,0x57,0xc6,0x4a,0xee,0xa8, - 0x1b,0x86,0xb4,0xbd,0x63,0x22,0xe7,0x36,0xb3,0x7e,0xca,0xe1,0x74,0x65,0x8f,0x5e, - 0xc1,0xb5,0x76,0x15,0x06,0xd7,0x7f,0xaf,0x39,0xc6,0xe5,0x7d,0x86,0x47,0x43,0x98, - 0xcf,0x24,0xee,0x90,0xee,0x3c,0xc0,0x33,0x1f,0x30,0xfb,0x79,0x38,0x12,0x39,0xc6, - 0x33,0xe3,0x6d,0x37,0x7e,0x94,0xe9,0xeb,0x16,0x71,0xab,0xf7,0xec,0xe4,0xcd,0xc9, - 0xf1,0xf9,0xc9,0x0b,0x7d,0x5a,0x67,0x96,0x44,0x74,0x32,0x67,0x34,0xc3,0x03,0xa1, - 0x1d,0x19,0x34,0xd6,0x16,0xd3,0x08,0xaf,0x01,0x1c,0xfa,0x11,0xde,0x66,0x18,0x8e, - 0x32,0x99,0x5e,0x9b,0xee,0x95,0xc2,0xab,0x39,0xf9,0x2c,0x50,0x47,0xa6,0xaa,0xc5, - 0xa8,0x69,0x84,0x92,0x8d,0xd2,0x04,0x6a,0xc4,0x53,0xf8,0x95,0x2c,0x02,0x4f,0x7c, - 0xa4,0xb3,0x09,0xd0,0x30,0xdd,0x9a,0x73,0x0d,0xaa,0xbe,0x18,0x27,0x74,0x79,0x75, - 0x88,0x37,0x10,0xf2,0xce,0x33,0x5e,0x71,0x89,0xa1,0xd2,0x21,0xf0,0x57,0x4f,0xf9, - 0x78,0x17,0x57,0xef,0xaf,0x82,0x34,0xf2,0x57,0x32,0xe9,0xb3,0x7a,0xb4,0x12,0x55, - 0x5f,0x9d,0x61,0x23,0xea,0x3b,0x3d,0xd8,0x5f,0x8f,0x39,0x8f,0x35,0x7f,0xa8,0x08, - 0xcd,0xc3,0xb5,0x00,0x5f,0x43,0xde,0xb5,0x58,0x82,0x6e,0xd0,0x50,0x2f,0x3f,0xf8, - 0x13,0x13,0x7f,0xb7,0xb8,0x52,0x89,0xca,0xcd,0x0b,0xfb,0xe1,0x8d,0x9f,0xe5,0xce, - 0xb3,0xdc,0x06,0xef,0xaa,0xae,0x9c,0xfe,0xf1,0xa7,0xb3,0x93,0xe3,0xf7,0xef,0x5e, - 0x60,0x6a,0xda,0x5d,0xfb,0xed,0xf9,0xc9,0x29,0xee,0x25,0x83,0xde,0xb3,0xa3,0xab, - 0xbf,0x8e,0x65,0x5a,0x7e,0x7e,0x7c,0xaf,0xd3,0xf4,0x5b,0xc7,0x77,0xb0,0xd7,0x30, - 0xb7,0x4d,0x79,0x6e,0x07,0x75,0x0c,0x39,0x92,0x43,0x3d,0x16,0x2b,0x32,0xfd,0x48, - 0x5c,0xc2,0xda,0x95,0xe7,0xb4,0xba,0xde,0x23,0x91,0x0d,0xf8,0xf6,0x4b,0xbc,0x61, - 0x4f,0x0c,0x97,0x69,0x96,0x13,0xe3,0x88,0x69,0x53,0xa9,0x93,0x85,0x78,0x45,0x73, - 0xc0,0xd9,0x68,0x73,0x3f,0x8c,0xec,0x04,0x21,0x72,0xfe,0xf1,0x5e,0x6e,0xce,0x88, - 0x9b,0x99,0x94,0xb8,0x44,0x34,0x26,0x9a,0x9d,0xd3,0xe4,0xd2,0x85,0x93,0xc9,0x44, - 0xe6,0xaa,0x9d,0x27,0x76,0xa0,0xfa,0x62,0x06,0x2a,0x08,0x5a,0xb5,0xe2,0x1f,0xb0, - 0x5f,0x4e,0x52,0x9f,0x45,0x48,0x39,0x7d,0xb8,0x08,0x5d,0x24,0xb5,0x4f,0x39,0x78, - 0x1e,0x61,0x86,0x9b,0xae,0x93,0xff,0xd0,0xbe,0x50,0xca,0x66,0x30,0x0c,0x83,0x54, - 0xb9,0x87,0xc8,0xc2,0x39,0x19,0x02,0xa6,0xb2,0x9f,0x37,0x5b,0xa4,0x18,0xee,0xb5, - 0x28,0x8b,0x70,0x77,0xa7,0xe5,0xdc,0x65,0x62,0x21,0x33,0x4e,0xc2,0x6c,0x55,0x08, - 0xd2,0x51,0x7d,0x6b,0x62,0xbf,0x7b,0xde,0xc3,0x96,0xdd,0xbf,0x8d,0xbb,0x27,0x63, - 0x3b,0xf0,0xba,0x36,0xea,0x63,0xaf,0xab,0x92,0x56,0xe3,0x3d,0x29,0x39,0x25,0x3e, - 0xde,0x69,0xad,0xed,0x77,0xbf,0x4f,0x01,0xe2,0xd0,0x1b,0xd3,0x7f,0x5c,0x86,0xd7, - 0x01,0x5d,0xec,0xb6,0x41,0xfb,0x78,0x8d,0x21,0xb5,0xbe,0xb3,0x57,0x6c,0xdd,0x8a, - 0x09,0xa0,0xc6,0x76,0xf6,0xd6,0x77,0xa6,0xbb,0x5b,0x14,0x04,0xb0,0x06,0x91,0xad, - 0x34,0x81,0x13,0xe9,0x33,0xd9,0x6f,0x43,0xbc,0x45,0xd7,0xdc,0xaa,0xba,0x08,0x17, - 0x01,0xc6,0x43,0x0c,0x80,0x89,0xac,0xe6,0x98,0x6d,0x1f,0x38,0x02,0x73,0x1f,0x99, - 0x59,0xe6,0xcd,0xc9,0x1f,0x4f,0xde,0x48,0xfa,0x94,0xec,0xc9,0x8a,0x87,0x73,0x39, - 0x92,0x39,0x83,0xed,0xd3,0xda,0xe7,0xb5,0xf8,0x04,0x79,0x21,0x0d,0xf7,0xd9,0xfa, - 0x3b,0x90,0xc8,0x8b,0xc4,0xb7,0x1e,0xf1,0x9d,0xe3,0x83,0xbb,0xcb,0x9b,0x4b,0x9d, - 0x58,0xdb,0x93,0x6d,0x3e,0x00,0xf2,0x80,0x56,0x01,0x41,0xf2,0x0d,0x22,0xc9,0xdf, - 0xc0,0xd6,0xc2,0x65,0xbf,0x5c,0x9b,0x87,0xd4,0xd9,0x6d,0x54,0xed,0x75,0x88,0x82, - 0xd0,0x84,0xc2,0x25,0xc2,0xd4,0x60,0x9f,0x15,0x50,0x67,0xaa,0xeb,0x2f,0xa3,0x73, - 0x8b,0xb9,0xd6,0x91,0x15,0x35,0xdf,0x6b,0x57,0x5d,0x54,0x27,0xab,0xd7,0x74,0x8d, - 0x5b,0x40,0xf3,0x4e,0xc5,0xca,0xf3,0x9b,0xe2,0x55,0x20,0xae,0x05,0x56,0x77,0x4f, - 0xca,0x77,0xeb,0x5c,0x42,0x45,0x0a,0xc4,0x94,0x44,0x77,0x07,0xd9,0xa2,0x6b,0x6d, - 0x1f,0x33,0x93,0x53,0xc8,0xe4,0x4e,0x97,0x67,0xf2,0xb8,0x22,0x58,0x56,0xa7,0xa6, - 0xe0,0xcf,0xeb,0xa3,0x4f,0xb9,0xcc,0xba,0x4c,0x95,0x57,0x14,0x43,0xc6,0x69,0xc8, - 0x29,0xcc,0x15,0xd3,0x95,0x5f,0xac,0xcd,0x54,0x39,0x93,0x09,0x22,0x66,0x6e,0xfe, - 0x45,0x6a,0xca,0xcd,0x61,0x45,0xaf,0x64,0x34,0x52,0x57,0xa6,0x9d,0xd4,0x3d,0x82, - 0x77,0xd7,0xf6,0x3b,0x3b,0xea,0xe8,0xd6,0x71,0x49,0x12,0xab,0xf3,0xd3,0x54,0xe6, - 0x32,0x69,0xf3,0xa9,0x25,0x4b,0xa1,0xaf,0x42,0x05,0x15,0xb5,0xbb,0x66,0x63,0xe1, - 0x5a,0x5d,0xce,0x50,0xd5,0xef,0xda,0xd4,0x7d,0xd0,0x85,0x75,0x99,0xfb,0xe8,0x5e, - 0x25,0xba,0x7d,0xc6,0x12,0xb8,0xdb,0x4a,0xce,0x52,0x10,0xf6,0xf5,0x41,0x29,0xd7, - 0xdf,0xcc,0x8a,0xd4,0x86,0x06,0x28,0x77,0x14,0xe0,0xd6,0xbe,0x6f,0x48,0xc7,0x8b, - 0x3a,0x08,0xb5,0x03,0x46,0x1d,0xac,0xde,0x94,0x12,0xfd,0x95,0xf1,0x4b,0xf6,0x26, - 0xa2,0xb6,0x89,0x02,0xbf,0xad,0x02,0xa6,0x40,0x85,0xf7,0xd8,0x21,0xac,0x3e,0x82, - 0xf8,0x6f,0x73,0x6c,0x17,0x66,0x79,0x2e,0x12,0xf7,0x39,0x88,0xf5,0x66,0x9c,0x5c, - 0xdb,0x6a,0x80,0xd4,0x93,0xe4,0x26,0x64,0xf1,0x40,0xeb,0x7d,0xd6,0x57,0x5a,0x46, - 0x6f,0x81,0xea,0xf2,0xc6,0xc5,0x8f,0xa0,0xed,0x75,0x46,0x51,0x02,0xaa,0x42,0x36, - 0x9a,0x05,0xe3,0x25,0x6a,0x72,0x6d,0x52,0xcf,0xc0,0x86,0xed,0xf0,0xf5,0x34,0x61, - 0x3c,0x4a,0x69,0x67,0x2f,0x1b,0x50,0x42,0xfb,0x34,0xc9,0x73,0xcc,0xb6,0x97,0xfb, - 0x43,0x30,0x93,0x43,0x92,0x3b,0x28,0x82,0xc2,0xf9,0x92,0xae,0x85,0x26,0x16,0xfe, - 0xe1,0xf5,0xf7,0xaf,0xce,0x85,0x3f,0x07,0xfa,0x65,0x5d,0x00,0x14,0x2a,0x0c,0x40, - 0x8c,0x39,0x34,0x30,0x03,0xa5,0x50,0x5e,0x10,0x8e,0x49,0x6f,0x16,0x0b,0x80,0xb7, - 0xa0,0x03,0xab,0x78,0xbf,0x37,0x35,0x4b,0x97,0x74,0x61,0xc2,0xf8,0x29,0x2d,0x02, - 0x6c,0x2a,0xa6,0xbd,0x4a,0xec,0x1d,0xa8,0x15,0x7c,0xd1,0x41,0xb2,0xcc,0xe8,0x3e, - 0x40,0x68,0x82,0xe3,0x8f,0x65,0x56,0x84,0x96,0x91,0x0b,0xb9,0x9f,0x4e,0x69,0x13, - 0x0f,0xb5,0x38,0x10,0x67,0x30,0x7c,0xe6,0xd1,0x84,0x15,0xce,0x66,0x2c,0xf9,0xb8, - 0x83,0x1f,0xa4,0xc5,0xe1,0x72,0xcc,0x75,0xf7,0xa9,0x08,0xe7,0x9f,0x6e,0x22,0xa0, - 0x27,0x0a,0x2e,0x6e,0xd9,0x52,0xa9,0x4e,0x07,0x33,0xc7,0xea,0x3c,0x98,0xd8,0xd8, - 0xa1,0xa2,0x45,0x47,0xcf,0x21,0xfd,0x53,0x6b,0x74,0x50,0x50,0xed,0xd1,0x01,0x61, - 0xf0,0x4d,0x4b,0x28,0x3e,0xd5,0xbb,0xf7,0xea,0xfa,0x25,0x5b,0xb6,0x3a,0xbc,0x7b, - 0x44,0x21,0xde,0x55,0x4b,0xc0,0xec,0x50,0x12,0x6c,0x9d,0xad,0x13,0x6a,0xa0,0x6b, - 0x8a,0x5e,0xd2,0x99,0x3f,0x66,0x1e,0xd8,0x94,0x7e,0xbc,0xb5,0xb4,0x23,0x89,0x86, - 0x27,0xbc,0x24,0x88,0x9f,0xd2,0xe0,0x19,0x6d,0xcc,0x70,0xf9,0xf7,0x19,0x93,0x91, - 0x95,0x7e,0x26,0x3d,0x7a,0xc9,0x13,0x4f,0x07,0xd4,0x96,0xe9,0x04,0xd9,0x0b,0xa8, - 0x8a,0xa8,0x0b,0xb4,0x91,0xa0,0x48,0xd5,0xc6,0x89,0xce,0x30,0xe0,0x85,0x8a,0x86, - 0x39,0x2b,0x93,0x8a,0xd4,0x92,0x94,0x32,0x13,0xf0,0x26,0x7b,0x87,0xb5,0x4c,0x69, - 0xcf,0xf1,0xf9,0x4a,0x58,0xa1,0x74,0xfe,0x39,0xc4,0x64,0x19,0xe2,0x1a,0x16,0x83, - 0xa3,0x3b,0xeb,0x4e,0xd1,0xe4,0x28,0x6d,0x9f,0x0e,0x6b,0x66,0xf9,0x51,0x1c,0xce, - 0xf1,0x98,0x4c,0xfc,0x12,0xe9,0xae,0xc9,0x6b,0x8c,0x25,0x85,0x7d,0x9b,0x91,0x34, - 0x0a,0xe4,0x38,0x6b,0x6f,0x32,0xd2,0x79,0x9a,0x46,0x41,0x54,0x82,0x0c,0xed,0xea, - 0x79,0xa5,0x95,0x0c,0x34,0x4f,0xee,0x00,0x54,0xbe,0x81,0xec,0x9a,0x52,0x3e,0xb6, - 0xc1,0x3a,0xe8,0x96,0xd6,0xfe,0xfb,0x45,0x10,0x7f,0xc5,0xa9,0x1f,0x65,0x0f,0xa9, - 0x9d,0x16,0x90,0x7a,0xea,0x40,0x8f,0xfa,0xa4,0x0f,0xdf,0x80,0x20,0x34,0x67,0x79, - 0xd4,0x57,0x77,0xa3,0xa6,0x01,0xf5,0xe9,0x0c,0x8f,0xfb,0x59,0x83,0x68,0x00,0x0c, - 0x75,0x39,0x0d,0xb3,0xc1,0x8d,0x0e,0xef,0x90,0x45,0x77,0x1e,0xe6,0x51,0x85,0x3b, - 0xe6,0x54,0xd9,0xee,0x7f,0xf9,0x37,0x51,0xbd,0xdb,0x68,0x8c,0x28,0xdb,0x80,0xd2, - 0x76,0x9b,0x6d,0xb3,0xb9,0xf6,0x9a,0x10,0x45,0xfe,0x69,0xe7,0x54,0x28,0xd1,0xb3, - 0xad,0xe8,0x62,0x0a,0x56,0x4d,0x51,0x25,0x18,0xea,0x22,0xd8,0x3b,0x28,0xa1,0x86, - 0xc4,0x6e,0xc9,0xe9,0x09,0xd8,0x8d,0xe5,0xd8,0xd7,0xba,0x3d,0x99,0x34,0xd8,0x25, - 0x2c,0x3b,0x76,0x47,0x79,0x2a,0x83,0xc7,0xfe,0x74,0xc7,0xd7,0xb8,0x7f,0x02,0xcb, - 0xb1,0x1d,0x78,0x8a,0x9d,0x1e,0x1e,0x9a,0x61,0xb7,0x0c,0x3e,0x0e,0xc8,0xfb,0xb4, - 0x36,0x40,0x03,0x39,0xdf,0x6f,0xdd,0x5b,0x53,0x66,0xbc,0xb5,0x09,0x96,0x49,0x27, - 0xda,0x97,0x34,0xcd,0x97,0x09,0xdd,0xd4,0x6d,0x1b,0xde,0xd0,0x30,0x31,0x25,0xa4, - 0xb3,0x43,0x66,0xbc,0x4a,0x94,0x25,0x85,0xab,0x50,0xee,0x83,0x7c,0x99,0xa1,0xb2, - 0x94,0xfe,0x7f,0x4e,0x58,0x6f,0xbb,0xe6,0x0a,0x69,0x41,0x94,0xe7,0xcf,0xd7,0xef, - 0xea,0x77,0x4d,0x5d,0x5b,0xe4,0xc0,0x54,0x94,0xc1,0x51,0xee,0x32,0x75,0x4a,0x4b, - 0xdb,0xab,0x41,0x09,0x88,0x65,0x86,0x0a,0xcc,0x47,0x9a,0xb3,0x57,0x2a,0x4d,0x92, - 0x39,0x2d,0x67,0x3a,0xcb,0xea,0x63,0x2e,0x51,0xbc,0xf9,0xc1,0xce,0x45,0x8a,0x88, - 0xc5,0xd4,0x34,0xf2,0x3e,0x3a,0x79,0xa9,0x67,0x43,0x1a,0x69,0x0d,0xf9,0x9a,0x99, - 0x5f,0x66,0xf2,0xa5,0x26,0x37,0x32,0xe9,0x17,0x9e,0x89,0x0e,0x6e,0x7c,0xbc,0x65, - 0x87,0x4f,0xd7,0xe3,0xd5,0x13,0x0d,0xb5,0xb4,0x9c,0xe1,0x6f,0x42,0x72,0x15,0xb6, - 0x59,0x55,0xa2,0x0e,0xb5,0xe7,0x54,0x4c,0xd3,0xa2,0xd8,0x41,0x39,0x4b,0x8b,0xe3, - 0xfe,0xac,0xda,0xae,0x75,0x7d,0x9c,0x15,0x7b,0x23,0x28,0x37,0x9f,0xe7,0xf1,0x06, - 0x03,0x39,0xf2,0xb0,0x2c,0x49,0x19,0x24,0xe2,0x17,0xa0,0x04,0x23,0xa5,0x1e,0xb2, - 0x71,0x4e,0x5c,0x51,0x1b,0xb7,0x7c,0x27,0x20,0x68,0x28,0x2b,0x68,0x4f,0xf8,0x11, - 0xa6,0x68,0x5d,0xd1,0x85,0x1b,0x60,0x1a,0xc4,0xe2,0xe4,0xed,0x11,0x9a,0xeb,0x98, - 0x4b,0x0d,0x41,0xb1,0xa9,0x36,0x0c,0x60,0x65,0x05,0x76,0xea,0x59,0x3a,0xa7,0x4e, - 0x9b,0x25,0x9e,0xf8,0x40,0x12,0x82,0x33,0x1b,0x8c,0xfc,0x08,0x38,0x1d,0xa7,0x49, - 0xc8,0xd0,0xf6,0x4f,0x1b,0x19,0x98,0x36,0x5e,0x0f,0x81,0xd1,0x25,0x21,0x30,0xff, - 0x9c,0xa3,0x19,0xec,0x6e,0xcf,0x63,0x5b,0x9c,0x74,0xc2,0x29,0x0c,0x10,0xaf,0x3d, - 0x4a,0x52,0x50,0x0a,0x01,0x0e,0xdd,0xab,0x84,0xb1,0x30,0x98,0xc8,0x21,0xa6,0xbd, - 0x71,0x50,0xe1,0xf0,0xca,0x22,0x84,0x75,0x4d,0x69,0xdd,0x2e,0x63,0x99,0x11,0x77, - 0x1c,0xa6,0xf2,0xbe,0xd7,0x64,0xe2,0x89,0xd7,0xb9,0x1c,0x2f,0x2b,0xad,0xaf,0xdf, - 0x9d,0xfe,0x78,0xce,0x78,0x60,0x25,0x18,0x5f,0x4a,0x67,0x03,0x10,0x18,0x82,0xc3, - 0x78,0x1e,0x1a,0x14,0x6b,0x9b,0x34,0xb4,0x8c,0xef,0x97,0x1d,0xa5,0x61,0xb6,0x20, - 0xaf,0x45,0x98,0xc2,0x7c,0x87,0xd3,0xa9,0x3a,0x77,0x8f,0x48,0xcf,0xc4,0x04,0xa7, - 0x22,0x40,0xd6,0xa0,0xee,0xe1,0x7a,0x71,0xf4,0xf6,0xf4,0xa7,0x8f,0xef,0x3f,0xbc, - 0xe0,0x1b,0xac,0xe4,0xd5,0x54,0x39,0x1d,0x5a,0x30,0x37,0x57,0x0d,0xfd,0x08,0x65, - 0xc8,0xd8,0xbe,0xdb,0x8a,0x7b,0xcd,0x17,0x6c,0x91,0x81,0xde,0x98,0xe2,0x12,0x5e, - 0xd1,0x3d,0x56,0x0c,0xfc,0xe8,0xfc,0x87,0x32,0xec,0x2c,0xf6,0x17,0x16,0xe8,0x5f, - 0x96,0x48,0x1d,0x36,0xdc,0x64,0x92,0xdb,0x50,0x23,0xff,0x57,0x86,0x69,0x1d,0x64, - 0x05,0xa2,0xf9,0x08,0xb8,0xc7,0xf3,0xaf,0x9f,0x0b,0xd7,0x0a,0xe4,0x98,0xe5,0x1a, - 0x27,0x86,0x6f,0x61,0xd0,0xc3,0x6b,0xc9,0x9b,0x15,0x9e,0xe0,0x42,0xd7,0x69,0xae, - 0xb0,0xa0,0x95,0x51,0x48,0x0d,0xa1,0xb0,0xb5,0x00,0x0b,0x46,0xd2,0xa9,0xa3,0xfb, - 0x38,0x9b,0xcc,0x40,0x22,0xf2,0x20,0x82,0x75,0xe1,0xb6,0xe5,0x17,0x81,0x45,0x32, - 0x66,0x18,0xb0,0x48,0xae,0xd0,0xb7,0x60,0x22,0x26,0xaf,0x0a,0x25,0x70,0x6c,0x25, - 0xde,0x66,0x0d,0xda,0xb8,0x10,0x2e,0xab,0xbb,0xa0,0x2e,0xd1,0x76,0xdb,0xe7,0xb7, - 0xe5,0xe6,0x2f,0xdd,0x02,0x95,0xad,0xeb,0xa9,0xf4,0x26,0xb0,0xaa,0x9a,0x4d,0xc4, - 0x33,0x5f,0x9d,0x76,0x29,0x51,0xfa,0xa9,0x77,0xa1,0x39,0x9b,0x6a,0xa8,0xcc,0x0c, - 0xc2,0x18,0x28,0xb9,0x62,0x47,0xd7,0xc6,0x65,0xa9,0xab,0x26,0xeb,0x14,0xbd,0x76, - 0xef,0xb3,0x2f,0x3b,0xc8,0x18,0x0f,0x57,0x7c,0x55,0xdb,0x5e,0xeb,0x37,0x8d,0xee, - 0xca,0x1e,0x5d,0x25,0xeb,0x33,0xb3,0xfa,0xd5,0xa3,0x2d,0x10,0x86,0x1e,0xae,0x61, - 0x84,0x5f,0x37,0xe2,0x2f,0x20,0xa7,0xe2,0xc0,0x8a,0xb1,0x7a,0xa4,0x57,0xb8,0xa9, - 0xcf,0x22,0xde,0x04,0xc9,0x92,0x65,0x3a,0x0a,0x68,0xd7,0x53,0x87,0x4d,0xf0,0xd9, - 0x3b,0x74,0xbc,0xa3,0x8d,0x07,0x0a,0x52,0x41,0x9b,0xc6,0x2f,0x82,0x2b,0x36,0x0e, - 0x74,0xa0,0xf4,0x3b,0xbe,0x89,0x96,0x77,0x04,0xad,0xf8,0x7f,0x03,0x51,0xde,0x3f, - 0x6b,0x8e,0xf7,0xe9,0x0b,0x68,0x2b,0xdb,0x69,0x1e,0x79,0x7c,0x84,0x0c,0x2b,0xa8, - 0xdf,0x1e,0x5a,0x7d,0x74,0x16,0xb0,0x41,0xbe,0xe0,0xff,0xf8,0x77,0x72,0x23,0xe0, - 0xee,0x50,0x52,0xd3,0x19,0x91,0x5c,0x1e,0xdf,0xd5,0xa7,0xea,0x81,0x8e,0x28,0xde, - 0x06,0xbb,0x0c,0xcd,0xc8,0xce,0xe2,0x4c,0xdf,0x3d,0xe8,0x2a,0x68,0xe1,0x38,0xba, - 0x03,0x5f,0x30,0x1f,0xb3,0x24,0xcb,0x79,0x36,0x2a,0x94,0x23,0xfc,0x88,0xfb,0xc0, - 0x83,0x47,0x3d,0x15,0x27,0xc4,0x5d,0x0c,0x2a,0xf5,0x29,0x17,0xeb,0x78,0x83,0x9e, - 0x9e,0x58,0x75,0x64,0xed,0xc8,0xb3,0x00,0xe8,0xa8,0x74,0xa5,0x0d,0x8c,0xc3,0x0c, - 0xef,0x28,0xa4,0x68,0x0c,0x07,0x18,0xb1,0x8a,0x23,0x4f,0x59,0x23,0x4d,0xba,0x7d, - 0x59,0xaf,0x12,0x75,0x29,0x31,0xdf,0xc9,0x0c,0xed,0x7e,0xbe,0x75,0x49,0xab,0x26, - 0xd5,0x5e,0xcb,0xba,0x79,0x58,0x7f,0xdb,0x70,0x7b,0x57,0x91,0xb8,0x5c,0x00,0xd2, - 0xa4,0x2d,0xaf,0x69,0x99,0x13,0xca,0x5e,0xd4,0xe6,0xee,0xd8,0x83,0x9a,0x24,0x2c, - 0xa5,0x9c,0x2b,0x4d,0x1f,0xa5,0x8a,0xd0,0xcb,0x2c,0x8c,0x43,0xf7,0x8e,0x25,0xc0, - 0x61,0x4a,0x78,0xf3,0xaf,0xfd,0x30,0x47,0xea,0x4d,0xfc,0xb1,0xbc,0x9d,0x58,0x31, - 0x88,0x8a,0x6b,0x9e,0xb9,0x9a,0x5e,0x88,0x47,0xde,0x4f,0x60,0x97,0xbc,0x80,0xf9, - 0xe2,0xef,0x2d,0x51,0x7c,0x43,0x57,0x1d,0x1f,0x94,0x06,0x61,0xe5,0xf8,0xa8,0xd3, - 0x19,0x1d,0xb1,0x58,0x08,0x43,0xe9,0x56,0x22,0x56,0x40,0xeb,0xd2,0xe3,0x61,0x63, - 0x9b,0x0e,0x7c,0x3e,0xd9,0xce,0x40,0x7b,0x59,0xe4,0x4f,0xe1,0x17,0x2a,0x4e,0xf8, - 0x77,0x96,0xcf,0xa3,0xa7,0xf7,0xfe,0x1f,0xa8,0x92,0x75,0xa8,0xab,0x0f,0x01,0x00, + 0x1f,0x8b,0x08,0x00,0x00,0x00,0x00,0x00,0x02,0xff,0xcd,0x7d,0xdb,0x72,0xdb,0x48, + 0x96,0xe0,0xbb,0xbe,0x02,0x96,0x7b,0x4d,0xa0,0x0c,0x52,0x24,0x25,0xca,0x32,0x69, + 0xca,0x2b,0x4b,0x76,0xd9,0x51,0xbe,0x28,0x2c,0x55,0xbb,0xbb,0xb5,0xea,0x1a,0x90, + 0x04,0x45,0x94,0x41,0x80,0x05,0x80,0xba,0x58,0xa5,0x88,0x79,0xda,0xd8,0xe7,0xd9, + 0xa7,0xfd,0x81,0xfd,0x85,0x7d,0xdf,0x4f,0xe9,0x2f,0xd9,0x73,0xc9,0x2b,0x00,0xd2, + 0xb2,0xbb,0x76,0x66,0x22,0xaa,0x2c,0x22,0x2f,0x27,0x33,0x4f,0x9e,0x3c,0xb7,0x3c, + 0x99,0xf9,0xec,0xc1,0x24,0x1d,0x17,0x37,0x8b,0xd0,0x99,0x15,0xf3,0x78,0x7f,0xe3, + 0x19,0xfe,0x71,0xe2,0x20,0xb9,0x18,0x6e,0x86,0xc9,0x26,0x26,0x84,0xc1,0x04,0xfe, + 0xcc,0xc3,0x22,0x70,0xc6,0xb3,0x20,0xcb,0xc3,0x62,0xb8,0xb9,0x2c,0xa6,0xcd,0x3d, + 0xcc,0xcd,0xc7,0x59,0xb4,0x28,0x9c,0x3c,0x1b,0x0f,0x37,0xb7,0x16,0xd3,0xe6,0x38, + 0x4d,0xf2,0x34,0x0e,0x5b,0xbf,0xe6,0xcf,0x67,0xc3,0x70,0x3a,0x9d,0x4e,0x7a,0xed, + 0xde,0xe6,0xfe,0xb3,0x2d,0x2e,0xb8,0x2f,0x2a,0xec,0x47,0x53,0xf7,0xc1,0x55,0x94, + 0x4c,0xd2,0xab,0xd6,0xf1,0x2b,0xcf,0x9d,0x2e,0x93,0x71,0x11,0xa5,0x89,0xeb,0xdd, + 0x5e,0x06,0x99,0xf3,0x69,0xc8,0x79,0xfe,0xcf,0xc3,0xc6,0x56,0xb0,0x88,0xb6,0xf2, + 0x22,0x28,0x96,0x79,0xc3,0x7f,0x3b,0x3c,0x3b,0xf7,0xdf,0x0d,0xbb,0xe1,0x53,0xff, + 0xc4,0xff,0x9b,0x7f,0x3c,0xfc,0x04,0xd5,0x87,0xb7,0x98,0x1d,0xf6,0x1b,0x71,0x74, + 0x19,0x36,0xfc,0x49,0x94,0x15,0x37,0xfd,0x07,0x1d,0xff,0xb2,0x9f,0x2c,0xe3,0xd8, + 0xbf,0x08,0x8b,0xfe,0x8f,0x7e,0x94,0x4c,0xe3,0xe8,0x62,0x56,0xf4,0xdf,0x2f,0xe7, + 0xa3,0x30,0xf3,0x67,0x69,0x3c,0xe9,0x7f,0x18,0xfd,0x1a,0x8e,0x0b,0x3f,0x0f,0x6e, + 0xfa,0xaa,0x07,0x85,0xff,0xd9,0x0f,0xbd,0xdb,0xf0,0xd1,0x23,0x37,0x6c,0x15,0xe1, + 0x75,0x71,0x98,0x26,0x45,0x98,0x14,0xc3,0xc2,0xbb,0xf3,0x47,0xcb,0xdc,0x28,0x3a, + 0xf2,0x17,0xde,0xad,0xfc,0x72,0xa6,0xd0,0xf9,0x51,0x6b,0x12,0xe5,0xc1,0x28,0x0e, + 0x27,0xc3,0x07,0x9d,0x3b,0xf3,0xab,0x3d,0x58,0xb4,0x8a,0x59,0x98,0xb8,0x53,0x7f, + 0xea,0x0d,0xb2,0xb0,0x58,0x66,0x89,0xb3,0xb8,0xf3,0x17,0x69,0x1c,0x6b,0x88,0xb9, + 0x3f,0xf7,0xc7,0x7e,0xca,0x58,0x28,0xfc,0xa9,0x7f,0x31,0xec,0x0c,0x54,0x0b,0x1f, + 0xa1,0x85,0x8b,0x47,0x8f,0x1e,0x4c,0xa1,0x6f,0xd3,0x61,0xc7,0x1f,0xc7,0x61,0x90, + 0x9d,0x46,0xf3,0x30,0x5d,0x16,0x6e,0xe1,0xf9,0x6e,0xde,0x1a,0x07,0x71,0xfc,0x3c, + 0x77,0xbd,0xfe,0x8f,0x6e,0xee,0x79,0xa2,0x45,0x09,0x7d,0xe2,0xdd,0x8e,0xdd,0x89, + 0x8f,0x58,0x81,0xb1,0xa8,0xe4,0x10,0x93,0x09,0x55,0xa1,0x77,0x27,0xea,0xfc,0xe4, + 0xff,0xe4,0x79,0x77,0xaa,0xe5,0x9f,0xa0,0xe5,0xe9,0xb0,0x3d,0x28,0xb5,0x38,0x80, + 0xde,0xb8,0xc5,0x10,0x48,0x42,0xa6,0x7d,0xf4,0xe7,0x50,0x2f,0x7d,0xf4,0x28,0x6d, + 0x4d,0xa3,0x2c,0x2f,0x86,0x43,0x40,0xc4,0x73,0xa8,0xde,0x87,0xce,0x8b,0x61,0xc3, + 0x74,0xa5,0x8b,0xbe,0x31,0xe5,0x17,0x55,0xc8,0x77,0x3e,0x00,0xd5,0x65,0x12,0xef, + 0x76,0x3e,0x4c,0x06,0xd3,0xdf,0x7f,0x07,0x50,0x77,0x7e,0x92,0x5e,0xf5,0x3f,0xde, + 0x41,0x19,0x22,0x0b,0x5d,0x6c,0x0c,0x23,0x79,0xf4,0xe8,0x6d,0x6b,0xb1,0xcc,0x67, + 0xf0,0x31,0x38,0x79,0x0e,0x9f,0x63,0xf7,0xc4,0xeb,0xff,0xed,0xf9,0xdf,0x5a,0x50, + 0x0b,0xfa,0xf1,0xb7,0xe1,0x71,0x0b,0x91,0xee,0xfe,0xec,0xbf,0xf3,0x8f,0x00,0xd8, + 0x55,0x50,0x8c,0x67,0x27,0x25,0x48,0x73,0xef,0xf6,0xb8,0xc5,0xe0,0xa1,0xdf,0x7f, + 0x6b,0x41,0x6f,0xdc,0x77,0xc3,0x77,0x41,0x31,0x6b,0xcd,0x23,0xc8,0xf6,0xdf,0x79, + 0x54,0x35,0x2a,0x5e,0xa5,0xd9,0x51,0x78,0x19,0x8d,0x43,0x5d,0x19,0xe6,0x2f,0x1d, + 0xa6,0xbf,0xff,0x7e,0x7b,0x27,0x27,0x3a,0x09,0xaf,0x9c,0xe3,0x2c,0x9d,0x47,0x79, + 0xa8,0xa7,0xe3,0xc6,0x4f,0x78,0xa2,0x67,0xba,0x4b,0xdd,0x70,0x5b,0x4f,0x4c,0xee, + 0xdd,0xe6,0x30,0xdd,0x6e,0xda,0x1a,0x2d,0xa3,0x78,0xf2,0xe8,0x51,0xce,0x3f,0x86, + 0x43,0x91,0xe2,0x01,0xfa,0x67,0x2d,0x44,0xa7,0xeb,0xf9,0x37,0x38,0xe3,0x77,0xde, + 0xc0,0x98,0x0d,0x03,0xc7,0xb2,0xd8,0x20,0x71,0x5f,0x66,0x59,0x9a,0xb9,0x8d,0x82, + 0x0b,0x35,0x70,0x20,0x69,0x4b,0x7c,0xfd,0xfe,0xfb,0xd3,0x70,0x07,0xa0,0xdc,0xdd, + 0x69,0xb2,0xfb,0xd1,0x5d,0x22,0x49,0x8a,0xa1,0x4c,0x43,0x40,0x17,0xa5,0x94,0xa8, + 0x2b,0xf3,0x6e,0x71,0x55,0x67,0xad,0xf4,0xb3,0x57,0xcc,0xb2,0xf4,0xca,0xe1,0x86, + 0x32,0x81,0x47,0x45,0xf5,0x44,0x1d,0xb8,0xb2,0x9e,0x67,0xf4,0x07,0x26,0x25,0x03, + 0x86,0x81,0xdd,0xbc,0x33,0x68,0xee,0x08,0xc7,0x0f,0x10,0xe1,0xdf,0x13,0x58,0xe7, + 0x8b,0x29,0xcf,0xd1,0x30,0x1f,0xbc,0x6d,0x4d,0xd3,0xec,0x65,0x00,0xdd,0x50,0x8d, + 0x4f,0x81,0x3e,0xa1,0x24,0x76,0xfc,0xce,0x73,0x3d,0xc5,0x6f,0x04,0xe3,0x4a,0x82, + 0x79,0x38,0xdc,0xbc,0x8c,0xc2,0xab,0x45,0x9a,0x15,0x9b,0xce,0x58,0xac,0xe9,0xcd, + 0xab,0x68,0x52,0xcc,0x86,0x13,0x9a,0xc0,0x26,0x7d,0x00,0xa7,0x88,0x8a,0x28,0x88, + 0x9b,0x39,0xac,0xa5,0x70,0xd8,0x41,0x2e,0x57,0x44,0x45,0x1c,0xee,0x1f,0x07,0x45, + 0x11,0x66,0xc0,0x47,0x60,0x68,0x4d,0xe7,0x60,0x39,0x89,0xd2,0x67,0x5b,0x9c,0xb5, + 0xf1,0xec,0x41,0xb3,0xe9,0xfc,0xf8,0xf2,0xfd,0xcb,0x8f,0x07,0xa7,0x2f,0x8f,0x9c, + 0xd1,0x8d,0x43,0x13,0xf4,0x4b,0x80,0xa5,0x7e,0x89,0x92,0x5f,0x16,0xc1,0x45,0xd8, + 0x5a,0xdc,0xf4,0x9d,0x70,0x12,0x15,0x4e,0x54,0xe4,0x4e,0x9e,0x2e,0xb3,0x71,0x98, + 0x3b,0xcd,0x26,0xf2,0xd1,0xe2,0x06,0xc1,0xfc,0x70,0x3b,0x4a,0xaf,0x9b,0x79,0xf4, + 0x25,0x4a,0x2e,0xfa,0xa3,0x34,0x9b,0x84,0x59,0x13,0x52,0xee,0x36,0x5a,0x58,0xff, + 0x76,0x1e,0x5c,0x73,0x27,0xfb,0x9d,0xee,0x5e,0x7b,0x71,0x3d,0x98,0x07,0xd9,0x45, + 0x94,0xf4,0xdb,0x4e,0xb0,0x2c,0xd2,0xc1,0x22,0x98,0x4c,0xb0,0x62,0x77,0x07,0xb2, + 0x80,0xf5,0x2c,0x62,0x64,0x6c,0x71,0x78,0x3d,0xc0,0x7f,0x9a,0xc0,0x18,0x43,0xc2, + 0x56,0x7f,0x9c,0xc6,0xcb,0x79,0x32,0xb8,0x08,0x16,0xfd,0x0e,0x94,0x05,0xf0,0xf3, + 0x34,0x49,0x6f,0xa7,0x80,0x95,0xe6,0x34,0x98,0x47,0xf1,0x4d,0x1f,0xe8,0xd2,0x6d, + 0x36,0x31,0xd9,0x83,0xec,0x49,0x34,0x3f,0x85,0x99,0xba,0x85,0x8a,0x69,0x26,0xf3, + 0x96,0x45,0x38,0xc1,0xcc,0x59,0x94,0x14,0x5c,0x17,0x3a,0x1e,0x42,0xd7,0xa0,0xf5, + 0x95,0x05,0xfb,0xe1,0x7c,0x51,0xdc,0xdc,0xca,0xde,0x25,0x69,0x12,0x42,0x4e,0xbe, + 0x08,0xc6,0x61,0x76,0x4b,0xdd,0xbc,0x00,0xd2,0xe9,0x77,0x20,0xf1,0x62,0x96,0xe6, + 0xc5,0x8b,0x22,0x21,0xd8,0xfd,0x28,0x99,0x85,0x59,0x54,0x0c,0x8c,0x86,0x3a,0xd8, + 0xd0,0x32,0xcb,0xa1,0xa5,0x45,0x0a,0xb0,0xc3,0x6c,0xc0,0x38,0xeb,0x43,0x0e,0xe0, + 0x37,0x8e,0x26,0x0e,0x77,0x21,0x5b,0xc6,0xa1,0x27,0x32,0x9b,0x59,0x30,0x89,0x60, + 0xad,0x63,0x37,0x47,0xc1,0xf8,0x33,0xb4,0xb7,0x4c,0x26,0xa2,0xaf,0xd3,0x28,0x84, + 0x65,0x55,0xd3,0x7d,0x85,0xdc,0x5d,0x00,0x8d,0x43,0x34,0x3a,0x78,0x16,0x64,0x51, + 0xd0,0x5c,0x64,0x61,0x9e,0x03,0xab,0x2f,0xb2,0x65,0x78,0x7e,0x2b,0xda,0x32,0x01, + 0xc5,0x61,0x09,0x72,0x94,0x7c,0xf6,0x0c,0x30,0xad,0x49,0x90,0x58,0x18,0x1e,0x05, + 0x84,0xb6,0x22,0x4d,0xe3,0x51,0x90,0xdd,0x56,0x67,0xf4,0x2a,0x83,0x19,0xc4,0x7f, + 0x06,0x01,0x08,0xb7,0xa4,0x19,0x15,0xe1,0x3c,0xef,0x8f,0x43,0x42,0x05,0xcd,0x6e, + 0x1b,0x7b,0x4b,0x53,0xfc,0x10,0x49,0xe4,0x35,0x4d,0x15,0x54,0xed,0x77,0x9c,0x8e, + 0xd3,0xed,0x86,0x73,0x6c,0x1f,0xf0,0x14,0xae,0x85,0xae,0x41,0xed,0xd1,0xc0,0x27, + 0xc1,0x7c,0x01,0xb8,0xb0,0xeb,0xac,0xe8,0x82,0x55,0xc3,0x89,0x92,0xc5,0xb2,0xb8, + 0x15,0x34,0xbc,0x8d,0x24,0x1c,0x8c,0xb1,0x70,0x05,0x51,0x46,0x1d,0x83,0x38,0xf5, + 0xbc,0x03,0x17,0x16,0x4b,0xa1,0xb7,0x4b,0x2d,0x14,0xe9,0xc5,0x45,0x1c,0x7e,0x82, + 0xee,0xda,0xf4,0xc2,0x80,0xd5,0x97,0x4d,0x2e,0xf7,0x19,0xc0,0x13,0xa4,0x93,0x7a, + 0xb2,0xc2,0x09,0xfc,0x06,0xaa,0x92,0x24,0xd4,0x43,0x4c,0x76,0x8c,0x5e,0xbf,0x0d, + 0x46,0x61,0x5c,0x1e,0x21,0x7d,0x5e,0x85,0xa4,0xb4,0xf4,0xda,0x6d,0x55,0x58,0x60, + 0xaf,0x8b,0x2d,0xcd,0x38,0xbb,0x53,0xdb,0x6a,0x1d,0xd1,0xe3,0x60,0x16,0x69,0x1e, + 0x11,0x1b,0xc8,0xc2,0x38,0x28,0x40,0x67,0xa2,0xe9,0xa6,0xa5,0x38,0x10,0x88,0x69, + 0x86,0x97,0x80,0x80,0x5c,0x2e,0xcf,0x49,0x5a,0xfc,0x74,0xab,0xaa,0x05,0x23,0xc0, + 0x01,0x2c,0x8a,0x41,0x1c,0x4e,0x0b,0xc4,0xc9,0x00,0xe5,0x39,0xfe,0x15,0xf3,0xda, + 0x36,0x7a,0xd6,0x5e,0x83,0x0f,0xbb,0x6b,0xbd,0xf6,0x7f,0x19,0x14,0x59,0x90,0x88, + 0x56,0x10,0xb8,0xd3,0x6e,0x75,0xba,0xb9,0x35,0xb9,0x2d,0x90,0x0c,0x12,0x11,0x15, + 0xb8,0xe9,0x67,0xaf,0x5a,0x98,0x3a,0xcf,0x5d,0x25,0x9c,0x3f,0xc4,0x05,0x7d,0xca, + 0x10,0x78,0xca,0xf3,0x30,0x9e,0x12,0x09,0x34,0x41,0x52,0x65,0x05,0x72,0xdb,0x38, + 0x2d,0x88,0x94,0xaa,0xb8,0x8a,0x60,0xf0,0x01,0x25,0xf1,0xaf,0xf0,0x2b,0xc3,0xbb, + 0x37,0x2f,0x4a,0x2f,0xc3,0x0c,0x65,0x4b,0x7f,0x16,0x4d,0x26,0x61,0x02,0x1d,0xc5, + 0x5e,0xa8,0x35,0x36,0x8a,0xd3,0xf1,0x67,0x49,0xbf,0x93,0x70,0x1a,0x2c,0xe3,0x42, + 0x21,0x1c,0x50,0x27,0x10,0x3e,0x8e,0x61,0xd5,0xb8,0xdd,0x1d,0xc0,0xbb,0xbf,0xd3, + 0xbb,0x9c,0xf9,0x3b,0x6d,0xf8,0x49,0x68,0x09,0x2e,0x9c,0x71,0x90,0x5c,0x06,0xb9, + 0xdf,0x1a,0xcf,0xa2,0xc5,0x61,0x90,0x4d,0x44,0x82,0x5c,0x90,0x7b,0x36,0x49,0x71, + 0xa5,0x9a,0x89,0xbf,0xcf,0xb2,0xd9,0xad,0x9b,0xf8,0x71,0x16,0x06,0xf3,0x66,0xf7, + 0x9b,0x71,0xa3,0x24,0x1b,0x94,0x67,0x12,0xae,0x90,0xe9,0xe0,0x6a,0x06,0x7d,0x68, + 0x92,0x24,0x81,0x04,0xe4,0x5b,0xdc,0x7f,0xd0,0xdd,0xe2,0x95,0xec,0xf8,0x0b,0xac, + 0xe1,0x09,0xb2,0x43,0x51,0x96,0x98,0xfd,0x29,0x0c,0x3a,0x05,0x38,0x11,0xd8,0x11, + 0xed,0x56,0xaf,0x87,0xf2,0x51,0xa4,0x4b,0x84,0x95,0xe4,0x17,0x54,0x25,0xf9,0xb8, + 0x42,0x82,0x9a,0x42,0xab,0x4d,0x78,0x5d,0x5c,0xd6,0xa0,0x35,0x4a,0x50,0xe9,0x6d, + 0xab,0x4e,0x75,0xef,0x23,0xc4,0x57,0x50,0x1f,0xb5,0xf1,0x1a,0x6c,0xb7,0x6f,0x15, + 0x1b,0xbf,0x2e,0xf3,0x22,0x9a,0xde,0x34,0x85,0x8a,0xd4,0x27,0x84,0x36,0x47,0x61, + 0x71,0x15,0x86,0x89,0xe4,0xe8,0x24,0x53,0xd4,0xac,0xb0,0x68,0xe8,0x8a,0x81,0x1d, + 0x02,0x6d,0xe5,0x5f,0x15,0x27,0x7b,0xa2,0xf4,0x49,0x74,0xf1,0x1f,0x2c,0xdf,0x89, + 0x89,0xd7,0xb0,0x66,0xea,0x1b,0x70,0x90,0x95,0xb4,0x63,0x32,0xe8,0x5d,0x64,0xd0, + 0x0f,0xa1,0xca,0x38,0x5d,0x84,0xa5,0x35,0xab,0xe4,0x6d,0x9b,0xa4,0x96,0x58,0x60, + 0x6d,0x63,0xf1,0x52,0x6b,0x6f,0xc3,0x8b,0x30,0x29,0x4d,0x97,0x94,0x3f,0xd5,0x79, + 0xa2,0x2a,0x3f,0x85,0x37,0x6f,0x12,0x9f,0x7f,0x7c,0xd0,0x72,0x75,0x57,0x2f,0xe3, + 0x6d,0x43,0x13,0x8c,0x92,0x38,0x4a,0x60,0x2a,0xb1,0x53,0xba,0x7a,0x95,0x8b,0x4e, + 0x03,0xc0,0xb5,0x27,0x4b,0x20,0xdc,0x4a,0x11,0x21,0xa1,0x3f,0x27,0xe9,0xe8,0x63, + 0x7a,0xf5,0x3d,0x9a,0x09,0xcf,0xff,0xb8,0x4a,0x2b,0x22,0x53,0x63,0x0d,0xf5,0x16, + 0x51,0x14,0x59,0xd6,0x1f,0x2c,0xdd,0xf7,0xbe,0x2a,0x9f,0xbe,0x99,0x49,0x3d,0xd1, + 0xab,0x41,0x76,0x7a,0x1d,0x0f,0x2a,0x15,0x73,0xe8,0xeb,0x3d,0x18,0x2c,0xb7,0x15, + 0x02,0xd3,0x05,0x89,0x1f,0xe1,0x4a,0x2b,0x33,0x2a,0x2c,0x71,0x94,0x4a,0x52,0x30, + 0x28,0xa1,0x96,0x1f,0x13,0x1d,0xdb,0x23,0xe9,0x58,0x1d,0xaf,0x55,0xbe,0xc8,0x1c, + 0x49,0x60,0x44,0x5f,0xd5,0x19,0x81,0x4b,0x38,0xdd,0xf6,0x1f,0x8f,0xe2,0xce,0xae, + 0x56,0x46,0x47,0x41,0x32,0x21,0x46,0x47,0x14,0xd3,0x76,0xda,0x0e,0xc9,0xc3,0xfb, + 0xd2,0xe4,0x28,0x00,0xa4,0xc3,0xba,0xa0,0x0e,0xa3,0x7c,0xd1,0x50,0xf5,0x24,0xf0, + 0xd0,0xb7,0x4b,0x5a,0x19,0xcc,0x49,0xbd,0xd4,0x81,0x29,0xbe,0xbd,0xa7,0xfd,0xb5, + 0x67,0xe9,0xb2,0x6d,0xae,0xdc,0x42,0x1b,0xe8,0xd6,0xe0,0x1b,0x84,0x4c,0x41,0x52, + 0xcb,0xec,0x32,0x3c,0x84,0x06,0x94,0xd2,0xc6,0x75,0x7e,0x8a,0xc6,0x9f,0xd1,0x84, + 0xba,0x97,0x04,0x02,0xd5,0x0d,0x6d,0x59,0xea,0x37,0xe2,0xb3,0xdd,0x6a,0x3f,0x0d, + 0xe7,0xf5,0x56,0xdb,0x65,0x26,0xf9,0xca,0x4a,0x25,0x53,0x76,0xeb,0x6b,0xbc,0x40, + 0x0e,0x83,0x68,0x02,0xf8,0x25,0x55,0xaa,0xe3,0x96,0x2c,0xd0,0xa9,0xd5,0x6d,0xd4, + 0xef,0x07,0x86,0xe5,0x6b,0x28,0x3b,0x5d,0x41,0x02,0x68,0x7a,0x85,0x45,0x8e,0x58, + 0x11,0xca,0xf1,0xae,0x62,0x21,0x04,0xea,0xfe,0x93,0xa1,0xa0,0xfd,0x98,0x45,0x9a, + 0x19,0x5f,0xc0,0xc7,0x00,0xff,0x69,0x02,0xcd,0x2c,0x50,0xeb,0x6b,0x72,0xb5,0x1c, + 0x14,0xc3,0x45,0x18,0x14,0xee,0xb6,0x0f,0xb3,0x08,0xbd,0x74,0xdb,0x7e,0x67,0x9a, + 0x79,0x5e,0x05,0x5e,0x89,0x6f,0xad,0xe1,0x54,0xf5,0xfd,0x5b,0xc1,0xbf,0x7a,0xab, + 0xad,0x93,0x7b,0x0b,0x45,0xd2,0xca,0xb4,0x0c,0x7c,0x0a,0x70,0x76,0x58,0xd1,0x52, + 0xbd,0xb7,0x15,0xc5,0x1d,0x43,0xc3,0xef,0xee,0x98,0xc5,0x80,0xa0,0x92,0xdb,0x12, + 0xad,0x95,0x0c,0x99,0x3a,0x13,0x98,0x6b,0xaf,0x11,0xb6,0x66,0x19,0xa3,0x11,0x8b, + 0x39,0xe6,0x45,0xb8,0xc8,0x2b,0x24,0xb8,0xc6,0xaa,0x2b,0xab,0x1a,0x75,0xd4,0x2f, + 0x81,0x3a,0xa3,0x65,0x51,0xa4,0xc9,0xda,0x59,0x14,0x04,0x6b,0x18,0x66,0xdb,0x26, + 0xd3,0x6a,0x0f,0x48,0xfe,0x4a,0x05,0xfb,0x8f,0x9a,0xb6,0x1a,0x74,0x12,0x73,0xbd, + 0x2f,0x07,0x62,0xf3,0x99,0xaa,0xdc,0x07,0x79,0xf7,0xd2,0x0f,0x25,0xc0,0x5a,0xc3, + 0xb6,0x16,0xcf,0xe9,0xb2,0x38,0xcd,0x60,0x84,0x35,0x16,0x97,0x44,0xd8,0x5e,0x55, + 0x1d,0x2c,0xd2,0xe5,0x78,0xd6,0x0c,0x78,0x4c,0x40,0x15,0xcd,0x1b,0x06,0xf5,0x31, + 0x88,0xe2,0x55,0x06,0x2b,0xae,0x98,0x8c,0x89,0x51,0x98,0xae,0x3d,0x83,0xa3,0x28, + 0xa7,0x5a,0x13,0x73,0x9a,0x6b,0x79,0x1e,0x34,0xf4,0x2a,0x8a,0xeb,0x1a,0x2a,0x41, + 0xdd,0x2e,0x43,0x6d,0xf5,0x56,0x49,0x63,0x06,0xfb,0x1a,0xa4,0x0f,0x98,0xa7,0xab, + 0x01,0x33,0xa9,0x3d,0x2d,0x19,0xda,0xc2,0x1d,0xd8,0x44,0x2d,0x16,0x05,0x61,0x73, + 0xa7,0xbe,0xa1,0xf5,0xc2,0x57,0x10,0x11,0x74,0xe3,0x98,0xf9,0xea,0x57,0x79,0x3a, + 0x93,0x90,0xaa,0xb0,0x52,0xa1,0x6f,0xff,0xf1,0x0a,0x7d,0xcd,0x22,0x28,0x39,0xec, + 0xb6,0x95,0x4c,0x07,0x3a,0xc9,0xbf,0x5b,0x57,0x7d,0x18,0xea,0x15,0x25,0x68,0x3f, + 0xa7,0xd4,0x33,0x58,0xd4,0x59,0x71,0xee,0xec,0x3b,0x3f,0x98,0xca,0x98,0x91,0x37, + 0x14,0x25,0xfa,0x51,0xee,0x0a,0xef,0x84,0x4f,0x8e,0x30,0x5f,0xcf,0xb5,0x4f,0xde, + 0x51,0xef,0xf6,0x32,0xca,0xa3,0x51,0x14,0x23,0x10,0xe9,0x07,0xf8,0xaf,0xf3,0x70, + 0x12,0x05,0x8e,0x6b,0xc8,0xc0,0x0e,0x19,0xf5,0xb7,0x28,0x9b,0x4b,0x96,0xa8,0x54, + 0x0f,0xfc,0x1a,0x3d,0x42,0x9a,0x1a,0x5a,0x5a,0x6a,0x57,0x20,0x02,0x14,0x54,0x45, + 0x0a,0x77,0x5d,0xb3,0x4f,0x44,0xab,0xe4,0x8b,0x36,0x55,0x31,0x43,0x3f,0x61,0xa7, + 0x05,0xd6,0x91,0x2b,0x6a,0xb7,0x6d,0x68,0x8c,0xaa,0xd6,0x8e,0xe1,0x46,0x45,0xff, + 0xa3,0xaf,0x9c,0x8a,0xe5,0xee,0x2a,0x37,0xa0,0xc1,0x51,0x2a,0xde,0x3f,0xdb,0xbf, + 0xc8,0x20,0x8c,0xe1,0x94,0x55,0x2c,0x32,0x3b,0x68,0xf2,0x41,0xfd,0x8b,0x72,0x56, + 0x2a,0x34,0x25,0x6b,0x5b,0xc3,0x36,0xdd,0x04,0x84,0x35,0xcc,0xb4,0x6d,0xf9,0x89, + 0xb5,0x11,0x00,0x2a,0x79,0xd5,0xeb,0x62,0x4f,0xdd,0x43,0xb0,0x1c,0x13,0x58,0x40, + 0xb8,0x4b,0x71,0x2b,0xb8,0x05,0x31,0xac,0xd2,0x7c,0x88,0x95,0x03,0xad,0xe2,0x66, + 0x2c,0x4c,0x87,0x62,0x9d,0x92,0xdb,0xa8,0xe5,0x28,0xd8,0x48,0xd5,0x2c,0xec,0x76, + 0x0d,0x6e,0x81,0x4c,0x99,0xd9,0x45,0x9d,0xaf,0xd6,0x30,0x59,0xb7,0x69,0x3c,0x06, + 0x15,0xa3,0x8b,0xcc,0x57,0xfe,0x6b,0x9f,0x4d,0x66,0x3d,0x4e,0x5f,0xb3,0x04,0xbf, + 0x2c,0x48,0xbd,0x2a,0xa3,0xff,0xc3,0x40,0xf7,0xfb,0xc1,0x14,0x30,0x74,0x2b,0x45, + 0x94,0xb3,0xb9,0x39,0x58,0xe5,0x6d,0xc1,0x31,0x3b,0x4d,0x1c,0x18,0x34,0x9f,0xa5, + 0xa9,0xd8,0xe3,0x68,0xe6,0xe3,0x59,0x38,0x0f,0xfb,0xb4,0x67,0x3c,0x10,0x6c,0xa6, + 0xff,0xf0,0xd5,0xce,0xcb,0x57,0x2f,0x77,0xe1,0x9b,0xa8,0x19,0xbe,0x5f,0xbd,0x3a, + 0x7c,0x75,0x30,0x20,0x9e,0xd9,0x7f,0xd8,0x39,0xe8,0xec,0x75,0x76,0x06,0x82,0x03, + 0xf5,0x1f,0xee,0x76,0x7b,0x87,0xbd,0xce,0x40,0x58,0xd4,0xfd,0x87,0x4f,0x8e,0x9e, + 0xec,0xee,0xee,0x0d,0x98,0xbb,0xf5,0x1f,0x1e,0x1d,0x1d,0xf5,0x0e,0x77,0xc5,0x67, + 0x33,0x4f,0x61,0xaa,0x1f,0xbe,0xdc,0x7b,0xd9,0x39,0x42,0x10,0x34,0x72,0xf8,0xde, + 0x3e,0x3a,0x3c,0x7c,0x39,0x20,0xd9,0x80,0xcd,0xf5,0x0e,0xbb,0xf8,0x35,0x0a,0xe0, + 0xeb,0xc5,0xde,0xf6,0x61,0xe7,0x70,0x80,0x6e,0xcf,0xfe,0xc3,0xee,0x8b,0x27,0x07, + 0x3b,0x2f,0xe0,0xe3,0x2a,0xc8,0x92,0xfe,0xc3,0xbd,0x1e,0xe8,0x44,0x58,0x32,0x0f, + 0x40,0x47,0xcd,0x6f,0x00,0x45,0xf3,0xe6,0x32,0xf2,0x9b,0xc1,0x62,0x81,0x8d,0x51, + 0x82,0xdf,0x38,0x09,0x2f,0xd2,0xd0,0xf9,0xf9,0x4d,0xc3,0xff,0x98,0x8e,0xd2,0x22, + 0xf5,0xb1,0x78,0x33,0x07,0xbe,0x3d,0x1d,0xb0,0xe5,0xd0,0x5f,0x46,0xf4,0x97,0xc4, + 0xbc,0xdf,0x38,0x0c,0xf2,0x71,0x80,0x64,0xf8,0x0e,0xd2,0x1a,0x00,0xe0,0x95,0xf8, + 0xf5,0x2e,0x4c,0xe2,0xd4,0x3f,0xa4,0xed,0xff,0x20,0xf7,0x1b,0x0c,0x4f,0x64,0x2a, + 0x00,0x03,0x21,0x7b,0x04,0xa7,0x26,0x2c,0x7a,0x03,0xe5,0x17,0x14,0xc9,0x34,0x76, + 0x10,0x41,0x18,0x8b,0x70,0x36,0x09,0x8a,0xa0,0x59,0xe0,0x64,0x0c,0x27,0x41,0xf6, + 0xf9,0xdc,0x9e,0x1f,0x4c,0xd2,0xd3,0xd3,0x3e,0x6c,0xbf,0x68,0x3f,0xd5,0xd3,0xd3, + 0xd9,0xee,0x00,0xab,0x94,0xd3,0xf3,0xf2,0xe8,0xe5,0x93,0xa3,0x17,0x7a,0x7a,0x0e, + 0xb6,0x9f,0xbe,0xd8,0x3b,0x30,0xa6,0xe7,0xe0,0xc9,0xf6,0xee,0x8e,0x9a,0x9e,0xee, + 0x8b,0xee,0x93,0x6e,0xc7,0x9e,0x9e,0xce,0xab,0xce,0x61,0x67,0x4f,0x4f,0x4f,0xf7, + 0xa0,0xbb,0xdb,0x6d,0xcb,0x09,0x79,0xf5,0xea,0xc9,0x41,0xaf,0x27,0x26,0xa4,0xf7, + 0xe4,0xc5,0xde,0x93,0x57,0x6a,0x42,0x8e,0x9e,0x1e,0xb4,0xb7,0x5f,0xdd,0x6d,0x9c, + 0x31,0x57,0x3f,0xb7,0x16,0xff,0x83,0x68,0x8e,0xfb,0x92,0x41,0x52,0xdc,0x6d,0x8c, + 0xd2,0xc9,0xcd,0xad,0xdc,0xd7,0xbb,0xb7,0xd2,0x47,0x52,0xb6,0xdf,0x01,0x69,0xbf, + 0x05,0xca,0x85,0x10,0xa2,0x38,0x99,0x80,0xdd,0xab,0x70,0xf4,0x39,0x02,0xa3,0x90, + 0xe4,0xf0,0x1c,0x48,0x7c,0x86,0xfc,0x09,0x1a,0x8b,0x40,0xde,0x81,0xf5,0x3b,0xb9, + 0xdb,0x08,0x6e,0x2d,0x47,0xca,0x5d,0x7f,0x9a,0x8e,0x97,0x79,0x93,0x84,0x11,0x30, + 0x10,0x58,0x6b,0xa8,0xba,0x92,0x79,0x6c,0x4a,0x69,0x72,0x1f,0x88,0xcc,0x66,0x3a, + 0x9d,0xe2,0x82,0x62,0x9e,0x87,0xe2,0xd4,0xdc,0xae,0x6c,0xaf,0xdb,0xae,0x24,0xf7, + 0x21,0xf2,0xcb,0x1d,0x62,0x40,0xb3,0xae,0x1c,0x3f,0x49,0x09,0xe0,0x4c,0x83,0xaf, + 0x18,0xe1,0xb5,0x1a,0xe5,0x3c,0x07,0x7e,0x91,0x2f,0x47,0xac,0x8d,0x90,0xd9,0x69, + 0x1a,0xc4,0x66,0x1d,0xe1,0xf1,0x6a,0x2d,0xa6,0x30,0x75,0xd6,0x16,0x1c,0xee,0x28, + 0x60,0x72,0x98,0x65,0x75,0x5b,0x73,0xc8,0x8e,0x6e,0xeb,0xfc,0x6b,0x5a,0xe9,0x77, + 0xf6,0xbe,0xcd,0x42,0x7b,0xfa,0x54,0x0c,0x90,0x94,0xe5,0x2d,0x42,0xce,0xaa,0x7e, + 0x0b,0x4d,0xa7,0xde,0x07,0x01,0x5d,0x6b,0xc1,0x68,0x6a,0x0c,0x2a,0x18,0xd4,0xa0, + 0x32,0x48,0x2a,0x2f,0x46,0x5a,0x53,0x07,0x07,0x3c,0xa8,0x60,0x80,0x6a,0xd5,0x6e, + 0xe9,0x4e,0x81,0xce,0xc2,0xec,0x0f,0xb0,0x26,0x3a,0xbb,0xb6,0xf2,0xdc,0xdd,0x55, + 0xf8,0x6c,0x8a,0x7d,0xa7,0x1a,0x9c,0xde,0x7b,0xce,0x45,0x47,0x9d,0xa0,0x3c,0x8d, + 0xab,0x7a,0x6c,0xc8,0x43,0x32,0x83,0x6b,0xe6,0x03,0x83,0x1a,0x9a,0x93,0x70,0x9c, + 0x66,0xbc,0x51,0xc4,0x18,0xc1,0x40,0xaa,0x75,0x18,0x51,0x8e,0xa8,0x1a,0x05,0x5b, + 0x6b,0x4b,0xb3,0x8e,0xd4,0x6e,0x14,0x8f,0xd0,0x8b,0x83,0xc4,0x7a,0x79,0x71,0x94, + 0x5c,0x3d,0xcd,0x56,0xbb,0x83,0xdb,0xbe,0x0b,0xc5,0x64,0x80,0x58,0xe2,0x28,0x2f, + 0x34,0xd3,0x31,0x0d,0xd6,0x1c,0x40,0x63,0x6c,0x02,0xfb,0x51,0xbe,0x86,0x77,0x01, + 0x6a,0x3f,0x8e,0xbe,0x47,0xcf,0x26,0xa7,0x5e,0x5b,0x38,0xc4,0x24,0x8e,0xf7,0x0c, + 0x0b,0x1a,0x0b,0xb4,0x07,0x2a,0x2e,0x02,0x84,0xfd,0xbc,0xb6,0x1f,0xc4,0xad,0xa1, + 0x33,0xa4,0xc1,0x9c,0x61,0x78,0xdb,0x30,0x0b,0x92,0x8b,0xf0,0xfc,0xb6,0x66,0xb7, + 0xb9,0xb4,0x0b,0xaf,0x54,0x0d,0x63,0x43,0x59,0x2f,0xe6,0xd2,0xce,0x7f,0x8d,0x5e, + 0x62,0x54,0x2b,0xa9,0x4e,0x83,0x9a,0x65,0x55,0xc7,0x01,0x6a,0x5d,0xd2,0x42,0x54, + 0xd6,0xf3,0xfd,0x5e,0x1b,0x7a,0xb6,0xad,0x88,0x9d,0xf9,0xbe,0xb1,0x97,0x6a,0x36, + 0xec,0xb4,0x3a,0xbd,0xdc,0x18,0x42,0x7f,0x86,0x1b,0x8f,0x86,0x3e,0xc5,0x09,0x75, + 0x3c,0x80,0x1a,0xfc,0x4a,0x00,0x83,0x6f,0x7d,0x09,0x58,0x55,0xbe,0x51,0x53,0x68, + 0x05,0xd3,0x01,0xb5,0x4c,0x46,0xd1,0x29,0xf3,0xaa,0xb5,0xd3,0x2b,0xed,0x84,0xda, + 0x01,0x00,0xd2,0xb1,0x2a,0xe6,0x8d,0xd5,0xe8,0xbd,0xf2,0x86,0x3b,0x9b,0x5c,0xfe, + 0x0a,0x4f,0x05,0xa0,0xf3,0xae,0x92,0xb5,0x66,0x3b,0xde,0xdc,0x3b,0x35,0x58,0xd4, + 0xba,0x39,0x55,0x1b,0x2d,0x7a,0x8b,0xba,0x2b,0x5c,0x12,0x5d,0xbd,0x9b,0xde,0xad, + 0x6e,0xca,0xde,0x6f,0x37,0x9c,0xdc,0x09,0x6b,0x76,0xc3,0x85,0xe3,0x4e,0x6c,0x75, + 0xfb,0xc2,0x42,0x2b,0xed,0x06,0xb4,0xe5,0x36,0xb0,0x2f,0xec,0xc6,0x75,0x9a,0xf7, + 0x6d,0x65,0xd0,0x22,0xb4,0xc8,0x97,0x3b,0xa4,0x7e,0x69,0x67,0xc1,0xb7,0xa3,0x3c, + 0x7c,0xd3,0x8f,0x58,0xdb,0x52,0x29,0xd0,0x68,0x85,0xa7,0xbe,0xc7,0x86,0x97,0xe5, + 0x15,0x47,0x66,0x58,0x5d,0x25,0x65,0x47,0xb8,0xe1,0xd6,0xa9,0x20,0x54,0x90,0xbb, + 0xdc,0x62,0xb1,0x05,0x9e,0xb0,0xe9,0x6c,0x47,0x35,0x2a,0x33,0x0e,0x6a,0x22,0x96, + 0x41,0xb7,0x57,0xe2,0xd2,0x3b,0x6d,0xc9,0x83,0x1d,0x11,0x4c,0xa5,0x4c,0xe0,0x5d, + 0x8a,0xd0,0x41,0xbd,0x64,0x3a,0x45,0xe1,0xe0,0x98,0xf3,0x65,0xa4,0x62,0x65,0x3b, + 0x05,0x35,0x1f,0xb5,0x5e,0x7a,0x77,0x1b,0xcf,0xb6,0x44,0x8c,0xd9,0xb3,0x2d,0x11, + 0xd2,0x8b,0x9a,0x26,0x06,0xc8,0x01,0x15,0x3a,0x63,0x50,0xda,0xf3,0xe1,0x26,0x72, + 0x67,0x07,0xcd,0x7b,0x19,0xf9,0x1b,0x66,0xfb,0xcf,0x66,0x9d,0x7d,0x11,0xec,0x06, + 0xbf,0x9e,0xe1,0xcc,0xc8,0xe2,0xd0,0xc6,0xa6,0x13,0x4d,0xe0,0x47,0x41,0x31,0xbe, + 0x90,0xb5,0xcf,0xe0,0xa1,0x1e,0x00,0xe8,0xee,0x9f,0x70,0x90,0x1b,0x24,0x76,0x21, + 0x61,0x19,0xcb,0x9a,0xd8,0x5f,0x6c,0x23,0x8e,0xb0,0x23,0x44,0x3c,0x04,0x68,0x1e, + 0x8d,0x39,0x34,0x63,0x53,0x96,0xd4,0xf4,0xbb,0xe9,0x48,0x46,0x80,0x01,0x73,0x46, + 0x37,0xb8,0xc8,0xa6,0xdd,0x37,0x24,0x74,0xa3,0x53,0xf4,0xa7,0xae,0x1a,0xad,0xee, + 0xcd,0xfd,0x77,0xd1,0x38,0x4b,0x17,0x33,0x98,0x4a,0x55,0x76,0x8b,0xfb,0x25,0x6b, + 0x89,0xee,0xe1,0xa6,0x9b,0xea,0x1c,0x92,0xc2,0x66,0x3d,0x74,0x0e,0x69,0x33,0x32, + 0x27,0xd1,0xa5,0xea,0x1b,0xd3,0x3c,0x21,0x00,0x9b,0x97,0xe9,0x9a,0xdd,0x6c,0x3a, + 0xd3,0x34,0xa3,0xf6,0x7e,0x84,0xe9,0xd9,0xdc,0x7f,0x83,0x92,0xcc,0xb9,0x80,0xdf, + 0xcf,0xb6,0xa8,0x0a,0x54,0x25,0xe9,0x26,0xbb,0x45,0xc5,0x1c,0x12,0x75,0x9b,0x24, + 0xeb,0x36,0x1d,0x10,0x3f,0xc3,0xcd,0x0e,0xfc,0x0d,0xae,0xe1,0xef,0xee,0xa6,0x83, + 0x6b,0x68,0xb8,0xd9,0x6e,0xf5,0xaa,0x98,0x34,0xa0,0xfc,0x39,0x88,0xd5,0xf8,0x70, + 0x4d,0x3a,0x22,0xf8,0xcf,0x18,0xca,0x16,0x8c,0x05,0xfe,0x2c,0xa8,0x1a,0x07,0x4f, + 0xbe,0x07,0x2d,0x4a,0x55,0x43,0x22,0xc6,0xe6,0x80,0xde,0x60,0x50,0xb6,0x9b,0x05, + 0xc1,0x2c,0x10,0x06,0x4d,0x7d,0x1c,0x11,0x0c,0x00,0x0f,0x6b,0x7b,0xd3,0xa6,0x85, + 0x60,0x39,0xf9,0x8f,0xa1,0x85,0x17,0x59,0x7a,0x05,0x36,0xb0,0x03,0xbd,0x0a,0x41, + 0x84,0x42,0x77,0x80,0xc5,0x38,0x44,0x1c,0x0e,0xd8,0xcf,0xeb,0x08,0x04,0xe3,0xc5, + 0xef,0x49,0x20,0x84,0xa4,0xfd,0xd3,0x59,0x68,0xb4,0x33,0xca,0x80,0x2c,0x72,0x0a, + 0x0f,0x4d,0xaf,0x12,0x98,0xb9,0x05,0xd2,0xc9,0xc0,0x01,0xfb,0x57,0xb7,0xef,0x2c, + 0xf3,0x30,0xa7,0x24,0x4c,0x80,0x1e,0xa7,0x57,0x2d,0xe7,0x04,0x38,0xa0,0x93,0x5f, + 0x45,0xc5,0x78,0xe6,0x04,0xb9,0x73,0xf0,0xf3,0x11,0xe4,0x72,0x3d,0x64,0xeb,0xff, + 0xf8,0xd7,0xff,0x95,0x3b,0xef,0x5f,0x9e,0x7e,0xfa,0xf0,0xf1,0x27,0x27,0x07,0x23, + 0x32,0x4c,0x5a,0x1b,0xcf,0x02,0x67,0x96,0x85,0x53,0xe8,0x4a,0x51,0x2c,0xf2,0xfe, + 0xd6,0xd6,0x45,0x54,0xcc,0x96,0x23,0xe0,0x9b,0xf3,0xad,0x30,0xb9,0x98,0x2f,0x93, + 0x8b,0x2d,0x23,0xe8,0x75,0xab,0x80,0x6a,0x5b,0xc8,0x2e,0xb6,0x30,0x24,0x31,0xdf, + 0x5a,0xe8,0xbc,0x26,0x05,0xba,0x36,0xd5,0x48,0x1e,0x82,0x99,0x3a,0x99,0xc3,0xcc, + 0x15,0xc0,0xf7,0xf0,0xc4,0xc0,0x2f,0xa3,0x38,0x48,0x3e,0x6f,0xee,0xff,0x08,0xcc, + 0xbd,0x30,0xc7,0xfc,0x6c,0x2b,0x30,0x08,0x8b,0x88,0x62,0x6b,0x19,0x33,0xf7,0x78, + 0xc7,0xe3,0xd7,0xe8,0x1d,0x05,0xd9,0xbb,0xfc,0x42,0x93,0x27,0xfe,0xce,0x52,0xa4, + 0x32,0x8e,0x31,0x36,0xb9,0x50,0x97,0x28,0xd4,0x42,0xf6,0x2b,0x50,0x7f,0xb0,0xf1, + 0xb9,0x5a,0xec,0x34,0xb3,0x16,0x76,0x5b,0xce,0xe1,0x0c,0xd7,0x4f,0xee,0xe4,0xc1, + 0x65,0xe8,0x14,0xa9,0x46,0x22,0x22,0xf6,0x26,0x5d,0xc2,0xb4,0x7c,0x0e,0x31,0x75, + 0xde,0x62,0x52,0xc6,0x95,0x4d,0x93,0x3f,0x01,0x6e,0x48,0x0e,0xdf,0x4d,0xfa,0xb3, + 0x69,0xaf,0x7a,0x11,0xc7,0x59,0xa1,0xf2,0x22,0x5d,0x4d,0xe6,0xff,0x1f,0xa8,0xfb, + 0x00,0x1a,0x74,0x88,0x45,0xac,0x23,0x64,0x19,0x2d,0x6a,0xaf,0xe9,0x7d,0x8c,0xba, + 0x76,0x46,0xe9,0x35,0xb0,0xa7,0x18,0xa6,0x9d,0xc9,0x30,0x4e,0x97,0x93,0x24,0xcc, + 0x81,0x66,0xa7,0xce,0xd5,0x2c,0xc0,0xf0,0x66,0x07,0x84,0x40,0x96,0xb7,0x9c,0xa3, + 0x2c,0xb8,0x70,0x02,0xc0,0xe2,0xc2,0x01,0xdc,0xb3,0xca,0xee,0x84,0x93,0x0b,0x46, + 0x2c,0xe2,0x31,0x88,0x63,0x00,0xb6,0xcc,0x1c,0x54,0x02,0x31,0x70,0x1a,0xb0,0x3f, + 0x69,0xd5,0x71,0x4d,0xf2,0x13,0x6f,0x7e,0x27,0x27,0x05,0x52,0x05,0x71,0x0e,0xa3, + 0xa7,0xbf,0x75,0x4c,0x54,0x94,0xa8,0xe1,0xa1,0x6d,0xc9,0x43,0x35,0x0b,0x6d,0x77, + 0x36,0x2d,0x6c,0x51,0xdd,0x4f,0xa0,0xfd,0xdc,0x93,0x75,0x7e,0xcf,0x10,0x54,0xc1, + 0x23,0xfe,0x51,0x37,0x08,0x59,0xe6,0xbb,0x46,0x21,0x2a,0x7f,0xc3,0x30,0xec,0x3f, + 0x72,0x1d,0x48,0x15,0x45,0x01,0x51,0x09,0x50,0x88,0xf5,0x24,0x55,0x0e,0x61,0x72, + 0x52,0x69,0xb1,0x04,0x17,0xac,0x5a,0xc0,0x8f,0x76,0x89,0xe0,0x85,0x2e,0xb9,0xb9, + 0xff,0x53,0x47,0x52,0xbd,0x00,0xa1,0x60,0xd5,0xe0,0xd9,0x84,0xd8,0x59,0x09,0xb1, + 0xfb,0x9d,0x10,0xbb,0x2b,0x21,0x6e,0x7f,0x27,0xc4,0xed,0x95,0x10,0x77,0xee,0x03, + 0x91,0x10,0x7c,0xa9,0xa7,0x00,0x7e,0xb2,0xeb,0xd1,0x6e,0x91,0x83,0x04,0x37,0xcb, + 0x89,0x14,0xc4,0x67,0x70,0x2a,0x95,0x01,0xd6,0x2e,0x08,0x16,0x10,0xbf,0xe8,0x96, + 0xc5,0x94,0x65,0x9c,0xc3,0xa2,0x44,0x3d,0xdb,0xa1,0xdf,0xb9,0xc1,0x4d,0x6a,0x2a, + 0xeb,0x9a,0x49,0x1a,0xe5,0x37,0x9b,0xfb,0xef,0xf1,0x0f,0x30,0xf1,0x74,0x7e,0xdf, + 0x8a,0xf9,0x55,0x18,0x03,0x1f,0x3b,0xc1,0x73,0x18,0xf4,0xdb,0xa8,0x58,0x45,0xa9, + 0x0c,0xac,0x2b,0xb3,0x52,0x11,0xfe,0xa6,0x19,0x67,0x55,0x36,0x17,0x21,0x68,0xe4, + 0x79,0x74,0x91,0x04,0x71,0x2d,0x5b,0x95,0xf1,0x71,0x25,0x18,0x8c,0x79,0x4c,0xb7, + 0xc1,0xfd,0xd4,0x71,0xc0,0xb4,0x80,0x95,0xba,0x62,0x15,0x99,0x2b,0x83,0x03,0x09, + 0xad,0xc5,0x51,0x5d,0x6b,0x02,0xba,0x88,0xc2,0x2b,0xcd,0xe1,0xb8,0x7e,0x06,0xa5, + 0xf9,0xc5,0x64,0x86,0x5f,0xe5,0xd5,0x25,0xcc,0x9b,0x7a,0xc4,0xc8,0xd8,0x34,0x07, + 0xf9,0x82,0xb9,0x00,0xcb,0x85,0x5e,0x7f,0x71,0x6c,0xd6,0xf1,0x28,0x19,0xe5,0x8b, + 0xc1,0x6a,0xca,0x5d,0x31,0xf7,0xd5,0xfe,0x76,0xfe,0x99,0xfe,0x76,0xff,0xfd,0xfb, + 0xdb,0xfd,0x67,0xfa,0xbb,0xfd,0xef,0xdf,0xdf,0xed,0x7f,0xa6,0xbf,0x3b,0x7f,0x7c, + 0x7f,0x05,0xc1,0x1b,0x2a,0x93,0xb1,0x51,0xaa,0x96,0x98,0x74,0x28,0x6d,0x3a,0xd6, + 0x61,0x9c,0xcd,0x69,0x80,0xfc,0x69,0x83,0x8e,0x69,0x0d,0x37,0x3f,0x2e,0x93,0x1c, + 0xd5,0x11,0xbd,0xb0,0x1d,0x3c,0xaa,0xb6,0xbc,0x98,0xc1,0xdf,0x28,0x77,0x70,0x29, + 0xf5,0x49,0xfd,0x66,0x89,0xee,0x3b,0x42,0x26,0xfa,0x0e,0x6d,0xac,0x93,0xba,0xc8, + 0x8b,0x98,0x55,0xa8,0xcd,0x7d,0xd1,0x91,0xf5,0x3c,0x08,0xb5,0xc7,0xd2,0xfa,0x94, + 0x61,0x8b,0x96,0x04,0x96,0xbe,0x8c,0x4d,0xb3,0x14,0x25,0xec,0xff,0x04,0x5d,0x73, + 0x3a,0x36,0x1f,0x92,0x55,0xde,0x85,0x45,0xf0,0xed,0x8a,0xc7,0x38,0x8d,0x1d,0x19, + 0x2e,0x50,0xe6,0x8e,0xca,0x75,0xb2,0xb9,0xff,0x31,0xcc,0x17,0x69,0x92,0x87,0x5c, + 0xb6,0x4e,0x31,0x93,0xb1,0x7f,0x25,0xf1,0x4e,0xc9,0xab,0xe4,0xbb,0x0e,0x42,0xd8, + 0xac,0xcb,0xc0,0xe8,0xbb,0x1a,0xf1,0x43,0x79,0x42,0x12,0x80,0x06,0xc3,0xbb,0x59, + 0x9b,0x35,0xe4,0x43,0x9d,0x3c,0xa1,0x6c,0xa5,0x18,0xaf,0x92,0x2e,0x65,0xa0,0xb3, + 0x20,0x5b,0xac,0x86,0x89,0xb9,0xdf,0x0a,0x12,0x88,0x30,0x5e,0x09,0xf1,0x55,0x10, + 0xc7,0xdf,0x0a,0xf0,0x22,0x28,0xc2,0x95,0x00,0x7f,0x84,0xcc,0x6f,0x1e,0x34,0xba, + 0xd7,0x56,0x0f,0x1a,0x73,0xbf,0x15,0x64,0x90,0x8d,0x57,0x4f,0xcd,0x01,0x64,0x56, + 0x01,0x96,0xd4,0x17,0xe9,0xf3,0x53,0xa4,0xad,0x13,0x94,0x2a,0x53,0xe9,0x9f,0xc5, + 0x29,0xa8,0xc2,0xbb,0x28,0x01,0xcb,0x90,0xf9,0x02,0x69,0xcc,0xc3,0xcd,0x57,0xe1, + 0x15,0x18,0x1b,0x62,0xd0,0x8f,0x1e,0xee,0x3d,0xe9,0x76,0x07,0x75,0x56,0x10,0x95, + 0x78,0x6f,0x2d,0xad,0xcd,0xfd,0xed,0x95,0x2d,0x1d,0xc7,0xe5,0x86,0xde,0xa5,0x59, + 0x28,0xdb,0x79,0x5c,0x1d,0xa9,0x6a,0x88,0xd6,0x4a,0x8d,0xbd,0xf5,0x75,0x85,0x5b, + 0x14,0xbf,0x24,0x57,0x53,0xed,0x1a,0xc7,0x20,0xa0,0xd2,0x22,0x23,0xdb,0xa2,0x2e, + 0x4d,0xac,0xe2,0x15,0xac,0xe0,0x83,0xc1,0xf9,0xaa,0x9c,0x08,0xf8,0xa2,0x30,0x57, + 0x2c,0x74,0xd5,0x71,0x20,0x51,0x9c,0xc2,0x56,0x54,0x71,0x95,0x60,0xf7,0x4b,0x84, + 0xf3,0x6d,0xee,0xd7,0x00,0x40,0x3f,0xad,0x59,0x9f,0xbe,0xeb,0x0a,0xbe,0x4d,0xcd, + 0x62,0x1c,0x17,0x53,0x5b,0xf0,0x75,0xb4,0xae,0x60,0x15,0xc1,0x3a,0x4a,0xae,0xca, + 0xae,0x54,0x9e,0x58,0x14,0x71,0x4a,0x56,0x18,0xfd,0x9e,0x45,0x68,0x89,0xed,0xb7, + 0x81,0xf8,0xba,0x9d,0xce,0xa0,0xb3,0x72,0x59,0x7d,0x0d,0x08,0xba,0xf3,0x14,0x98, + 0x56,0xef,0xdb,0xe0,0x90,0x2b,0xd0,0xe8,0x4e,0xab,0xf7,0x7d,0xfd,0x69,0x75,0x7b, + 0x56,0x97,0x9e,0x40,0x9f,0x20,0x4d,0xf6,0xea,0xc9,0xb7,0x76,0xab,0x63,0x83,0xdb, + 0x05,0x68,0x1d,0x05,0x6d,0xf7,0x1b,0x81,0x6d,0x5b,0xb0,0xf6,0xb0,0x6b,0xdb,0x12, + 0xd6,0x5e,0xaf,0xba,0x28,0xab,0x0a,0x87,0x3e,0x31,0xf8,0xef,0xe4,0xa3,0x79,0xb7, + 0x2c,0xea,0xbc,0x33,0x2b,0xa9,0x10,0x23,0x1b,0x4b,0x7e,0x25,0xc2,0xc3,0x0b,0xa0, + 0xdf,0xaa,0x8a,0x84,0x22,0x3d,0x2c,0x9c,0x9f,0x6a,0x26,0x59,0xd5,0x7c,0x67,0x98, + 0xef,0xb2,0x22,0xa0,0x31,0x51,0xac,0x97,0x61,0x08,0x0f,0xe5,0xca,0x3e,0xf2,0xee, + 0xfa,0xbe,0xf2,0x34,0x6e,0x6d,0xee,0x73,0x8c,0x50,0x48,0x7e,0x3f,0x95,0x2c,0xdd, + 0x77,0x7c,0xc6,0x9f,0x7d,0x82,0xa2,0x2e,0xc0,0x42,0x9f,0xa3,0xba,0x6c,0x64,0x7f, + 0x03,0xef,0x4c,0x38,0x7e,0x05,0x3d,0x74,0x86,0x8e,0xba,0x02,0xc0,0x71,0x3d,0xe7, + 0x76,0xa3,0xb1,0xcc,0x91,0xdf,0x66,0xd1,0xb8,0x68,0x0c,0x36,0xf0,0x36,0x92,0xc2, + 0xb9,0x8a,0x30,0xe2,0x05,0xcb,0x5e,0xfa,0x4e,0x9c,0xfa,0x30,0x02,0xcf,0x19,0xee, + 0x3b,0x7c,0x9b,0x43,0x70,0xed,0x62,0x9a,0xba,0xda,0x61,0x16,0xf9,0x0e,0xdf,0x11, + 0xe2,0x5e,0x7a,0xce,0xef,0xbf,0x3b,0x6d,0xcf,0x93,0x90,0xe8,0x70,0x65,0xbb,0x43, + 0xa0,0x08,0x04,0x83,0x46,0xb8,0x6d,0xdf,0xe9,0xa8,0x72,0xc7,0x1f,0x5f,0x9e,0xbc, + 0x3c,0x3d,0x81,0x72,0xb7,0x1b,0xac,0xa3,0xf4,0x9d,0x5b,0x72,0xc9,0xf4,0x9d,0xc6, + 0x28,0xfc,0x12,0x85,0x59,0xc3,0x07,0x5c,0xc3,0x17,0x67,0xc3,0xd7,0x4d,0xbb,0x8f, + 0x40,0x6e,0x3a,0x7d,0xa7,0xe3,0x3b,0x8b,0xce,0x35,0x7c,0xb6,0x76,0x7a,0xf8,0xf3, + 0x06,0x7f,0xb6,0xf1,0x67,0x97,0x52,0x7b,0xf4,0x93,0x52,0x9f,0xf6,0x9c,0x3b,0x7f, + 0x83,0x54,0x96,0xbe,0xb3,0xb2,0x0d,0xcc,0x86,0x8f,0xfa,0x36,0x3a,0x6d,0xd5,0xc6, + 0xae,0x6e,0x63,0x5b,0xb6,0xd1,0x69,0xb5,0xdb,0xd8,0x06,0xea,0x30,0xd0,0xc4,0xaa, + 0x36,0x30,0x1b,0x9b,0xa0,0x36,0x3a,0xdc,0x46,0xbb,0x6e,0x1c,0x4f,0x6b,0xc7,0xd1, + 0xa6,0x71,0xa0,0x5a,0x63,0xb5,0x41,0x92,0x13,0xc1,0x52,0x1b,0x98,0x8d,0x1f,0x49, + 0xdf,0xe9,0xd2,0xb0,0x31,0xb7,0xbf,0xb2,0xb8,0xfc,0x82,0xe2,0xdb,0x58,0x1c,0x15, + 0x12,0x0b,0x3a,0x26,0x50,0x9f,0xa9,0x38,0x7d,0x39,0x77,0x1b,0x77,0x83,0x0d,0x45, + 0x53,0xe1,0x65,0x10,0x1f,0xa2,0x68,0x76,0x49,0x40,0xfb,0xce,0x12,0x89,0x6c,0x09, + 0x13,0x2b,0x48,0xc1,0x5d,0xc2,0xa4,0x47,0x53,0xc7,0x7d,0x40,0x05,0x3c,0x47,0xdc, + 0x71,0xb1,0xe4,0x54,0x4a,0x6c,0xd1,0xfd,0x3a,0xc3,0xe1,0x50,0x76,0x09,0x61,0x30, + 0xa1,0x20,0x55,0x0a,0x1a,0x22,0x02,0xa4,0xdd,0x46,0x51,0x2b,0x21,0xea,0xeb,0xfa, + 0xf8,0xdf,0x1e,0xb4,0x22,0x20,0x2b,0x42,0x4d,0x9c,0x26,0x22,0x9a,0xbe,0xa7,0x71, + 0x9a,0x66,0xee,0xd2,0xf9,0xc1,0x49,0x3c,0xcf,0xd9,0x72,0x38,0x13,0x2a,0xdd,0xd5, + 0x76,0x83,0x86,0xaa,0xfa,0x4a,0x10,0x72,0xd9,0x85,0xe3,0x37,0x00,0x45,0x8e,0xaa, + 0x5c,0x51,0xcc,0xb9,0x1e,0xc0,0x2d,0xcc,0x37,0x8c,0x81,0x88,0x0a,0xfe,0x32,0x55, + 0xd1,0x5c,0xd3,0x2c,0xd3,0xfc,0x3a,0x77,0x88,0x2f,0x84,0x24,0xd7,0xc7,0xe8,0x1a, + 0x97,0x50,0x41,0x4b,0x68,0x1b,0x9a,0x73,0x3b,0xd0,0x5d,0xf8,0x34,0x7f,0x15,0xf0, + 0x3f,0xc0,0x72,0x1e,0x97,0x4a,0x14,0x32,0xaf,0x8b,0x79,0xf2,0xab,0x50,0xa0,0x6f, + 0x34,0xe8,0x3a,0xb0,0xfa,0x17,0x74,0xfc,0xf1,0x57,0x5a,0xbf,0x59,0xd3,0xfa,0x8d, + 0xd9,0x3a,0x82,0xeb,0x0c,0x36,0x62,0xe0,0x88,0x71,0xca,0xf8,0x98,0x45,0x88,0x0f, + 0xa0,0xa5,0x34,0x73,0x5c,0xcc,0xc0,0xef,0xf6,0x00,0xfe,0x3c,0x73,0xba,0x3b,0xf0, + 0xf7,0xf1,0x63,0x8d,0xc6,0x79,0x34,0xc1,0x6e,0x43,0xdd,0xc7,0xc4,0x9d,0xb6,0x9c, + 0x2e,0x4f,0xc0,0xe8,0xda,0x85,0x3c,0x0f,0xea,0x00,0xe5,0x11,0x68,0xf8,0x1c,0x38, + 0x21,0x18,0xb5,0xdc,0x02,0x7e,0xc2,0x2c,0x8b,0xa9,0x94,0x54,0x39,0xba,0x71,0x2d, + 0x60,0x9e,0x67,0x14,0x5a,0xe2,0x6f,0x45,0xe2,0xa3,0xe0,0x73,0xf8,0x76,0x59,0xb8, + 0x82,0x7e,0x65,0x8f,0x62,0xd0,0xf3,0x86,0xce,0xd9,0x79,0xed,0x00,0x86,0xce,0x76, + 0x57,0x8c,0x00,0xca,0xf1,0x75,0x3b,0x82,0x67,0x56,0x56,0x4c,0x04,0xed,0x6f,0x77, + 0xbd,0x56,0x91,0xbe,0x8a,0xae,0xc3,0x89,0xbb,0xe3,0x79,0x9a,0x98,0xa1,0x36,0x76, + 0x86,0x9b,0x7c,0xf7,0xe6,0xfd,0x2f,0x9f,0xde,0xbc,0x3f,0xfa,0xf0,0x09,0x1b,0x6a, + 0xb5,0xbb,0x03,0x23,0xe3,0xc3,0xcf,0xa7,0x9c,0xda,0x93,0xa9,0xc1,0x22,0x22,0x06, + 0x2b,0xc6,0xec,0x4b,0xae,0xeb,0xeb,0x55,0xeb,0xcb,0xd1,0xf9,0x06,0x70,0x5f,0xc2, + 0xf3,0x37,0xa8,0x8b,0x7f,0x0e,0xe2,0x65,0xe8,0xa2,0x19,0x2d,0x96,0xb7,0xe8,0x1b, + 0xa6,0x70,0x38,0xbd,0xf3,0xdc,0x60,0x04,0x3a,0x19,0x8b,0x6f,0xf4,0x79,0xf9,0x2c, + 0xd2,0x2b,0x57,0xb3,0x04,0x90,0x05,0x30,0x6a,0xb1,0xb0,0x05,0x62,0xa8,0x1e,0x6e, + 0xe0,0xd2,0xb2,0x86,0xee,0x82,0xd2,0xe4,0x3b,0x3b,0x34,0x33,0xfe,0x86,0x0c,0x4f, + 0x06,0x44,0x5f,0x86,0xb1,0xef,0x70,0x77,0xc2,0xe4,0x12,0x3b,0x84,0x84,0x20,0x7e, + 0x96,0xe6,0x59,0xb5,0xc9,0xf5,0x80,0x4a,0xa1,0x5c,0x2b,0x4e,0x71,0xd2,0x95,0x78, + 0x03,0xa4,0x21,0x7a,0x30,0x07,0x48,0x46,0x15,0xf1,0xe0,0x27,0x72,0x7f,0xe2,0x17, + 0x24,0x49,0xc4,0xb7,0xa7,0xa7,0x84,0x28,0x4e,0x11,0x15,0x8e,0x20,0x4a,0xc0,0x9c, + 0xd2,0x13,0x58,0x69,0xbf,0xd4,0xb6,0x89,0xf6,0x32,0x9c,0xe0,0xda,0xe3,0x0a,0x8c, + 0x02,0x76,0xab,0x98,0x13,0xb1,0xaa,0x0f,0x50,0xd2,0xea,0x04,0x51,0x3a,0x90,0x43, + 0xab,0x6e,0x3e,0x61,0xf5,0x56,0x2a,0xab,0x96,0xa9,0x61,0x11,0x42,0x84,0xba,0x92, + 0x4b,0x07,0xef,0xa1,0xab,0xc1,0x22,0x37,0xd0,0x9d,0xa4,0xd9,0x3c,0x88,0xa3,0x2f, + 0x21,0x95,0xa1,0xab,0xb2,0x1c,0xb3,0x24,0x81,0x51,0x85,0x40,0xc1,0x99,0x46,0x17, + 0x6e,0x16,0x5c,0x69,0x40,0x3c,0x94,0xbc,0x00,0x73,0x10,0x57,0x3a,0xe4,0x39,0x8f, + 0x1e,0x39,0x07,0x59,0x16,0xdc,0xb4,0xa2,0x9c,0xfe,0x62,0x05,0x0a,0x66,0x81,0x0a, + 0xcf,0xa9,0x04,0x2a,0x0d,0xb8,0x0f,0xf7,0x11,0xed,0xad,0xbe,0x83,0x17,0xcb,0x30, + 0x65,0x80,0xd0,0x3b,0x3b,0x77,0xee,0x14,0x02,0x6e,0x37,0x8c,0x62,0xdc,0x48,0x4b, + 0xa5,0x10,0xe7,0xa6,0xba,0x1b,0x3a,0xfa,0xb7,0x44,0x9c,0xa2,0x8e,0xca,0x67,0xc5, + 0x87,0x94,0x00,0xd6,0x3c,0x80,0x3e,0x3c,0x90,0xa0,0xe4,0x14,0x5b,0x51,0x99,0x33, + 0x45,0xcd,0xdd,0x52,0x4d,0xd9,0x6b,0x54,0x93,0x48,0xa2,0x6d,0x9f,0x03,0x7d,0x2c, + 0x5c,0x97,0x55,0x31,0x1b,0xc1,0x02,0x20,0xd5,0x39,0x8b,0xce,0x01,0xd7,0x02,0x91, + 0x1e,0x0a,0x67,0x92,0xd0,0x8c,0xcf,0xd7,0x7f,0x43,0x1e,0x75,0xb6,0x0b,0x50,0xbb, + 0xbd,0x36,0x14,0x3c,0x83,0x3f,0x3e,0x1e,0x66,0xe1,0x0f,0xf8,0xeb,0x3b,0x3d,0xf1, + 0xd5,0xa3,0xaf,0xce,0x2e,0x7e,0x9e,0x1b,0x32,0x7e,0x11,0x44,0x99,0xcb,0x6a,0xa1, + 0xef,0x90,0x20,0x85,0xd6,0xc2,0x28,0x26,0xaf,0xdf,0x45,0xb0,0xc0,0xe9,0x23,0x1a, + 0x14,0xa3,0xc6,0xa2,0x76,0x31,0x98,0x7f,0xe2,0xc5,0xa2,0x40,0x15,0x8e,0x90,0xa6, + 0x00,0x65,0x9f,0x38,0xf2,0x19,0x37,0x77,0x8e,0xbd,0xc7,0xd2,0x71,0x7a,0xce,0x25, + 0x68,0x7d,0x42,0xb1,0x67,0xb2,0x61,0x82,0xab,0x44,0xbe,0xea,0x16,0x51,0x3c,0x16, + 0x19,0x70,0xd7,0x54,0x09,0x06,0x0c,0x40,0x38,0x53,0xf1,0x7c,0xd9,0xa2,0xc5,0xfa, + 0x6d,0xac,0x13,0xbd,0x96,0xe8,0x5f,0xc8,0x54,0x4d,0xb1,0xa8,0x0a,0xa4,0x53,0xfa, + 0x22,0x85,0x20,0xa5,0x1b,0xf4,0x1a,0x4c,0xb0,0x1b,0x48,0xb0,0xbc,0x38,0x49,0x85, + 0x04,0x74,0xf3,0x6a,0xc3,0xaf,0xbd,0x9e,0xf0,0xa1,0x62,0xa1,0x56,0xab,0x25,0xb8, + 0xb5,0xa0,0x39,0xd0,0xcf,0x50,0x18,0xf4,0x95,0x38,0xb2,0xb3,0x3d,0x47,0xcd,0x39, + 0x86,0xc9,0x42,0x7f,0x46,0x2d,0xfa,0xf1,0xfc,0x39,0x35,0x24,0x33,0xcf,0x66,0x5f, + 0xa0,0x71,0x18,0xe9,0x17,0x68,0x15,0xd1,0x4b,0x93,0x3b,0x6a,0x51,0x32,0x16,0x7e, + 0xfd,0xb7,0x33,0x1a,0xe2,0xf9,0x19,0xd2,0x04,0x65,0x04,0xd7,0x76,0x46,0xe7,0x9c, + 0xc7,0xdf,0x12,0xb0,0xe4,0xef,0x00,0xf0,0x02,0xdc,0x51,0x35,0x45,0x9c,0x10,0x31, + 0x66,0x37,0x45,0xc9,0xd4,0x2f,0x84,0x4f,0xb9,0xf8,0xd5,0x61,0x13,0xc1,0x14,0x44, + 0x1a,0x14,0xe3,0x4c,0x62,0xcb,0x00,0xc6,0x19,0x58,0x1f,0x47,0x8b,0x00,0xb9,0x88, + 0x4c,0x01,0x2a,0x80,0x52,0xe4,0x8f,0x61,0x54,0xf4,0x7a,0x9e,0xd9,0x10,0x48,0x39, + 0xcf,0xe0,0x12,0x26,0x76,0xfc,0x0d,0xe1,0xec,0xa6,0x19,0xdf,0xe0,0x33,0x11,0xd0, + 0x00,0xfd,0x30,0x78,0x86,0x39,0x4c,0xd4,0xd0,0x71,0x6e,0x4b,0x92,0xad,0x4e,0xac, + 0x11,0x3f,0x37,0x06,0x25,0x84,0x2d,0xb6,0xc0,0x32,0x55,0x13,0x93,0x4c,0x29,0x11, + 0x94,0x4c,0xee,0x3b,0xc4,0x70,0x37,0x88,0x3c,0x6c,0x8e,0x39,0x6a,0x41,0xa2,0x87, + 0xa0,0xe8,0x57,0x2b,0x0e,0x93,0x0b,0xa0,0xa5,0x7d,0x10,0xbf,0xcf,0x39,0x49,0xd4, + 0x66,0xbe,0x21,0xf0,0x00,0x92,0x02,0xbe,0x3c,0x17,0x30,0x63,0xdc,0x13,0x27,0x7f, + 0x94,0x0c,0x4a,0xb4,0x35,0x8f,0x5e,0xfc,0xf2,0xea,0xed,0x87,0x0f,0x1f,0x61,0x5e, + 0x9a,0x68,0xc9,0xc0,0xf7,0xc9,0xf1,0xc1,0x7b,0xf8,0xdc,0x79,0x32,0xa0,0x12,0x7f, + 0x42,0xed,0x0d,0xf5,0x34,0xe0,0x65,0x93,0x74,0xbc,0x9c,0x87,0x49,0xd1,0xba,0x08, + 0x8b,0x97,0x71,0x88,0x3f,0x5f,0xdc,0xbc,0x99,0x60,0x36,0x17,0xd6,0x56,0x24,0x19, + 0xb1,0x2d,0xf1,0xcd,0x99,0x93,0x11,0x59,0xaa,0xd7,0x04,0x4a,0x49,0xd7,0x6e,0x1b, + 0xe4,0x18,0x2d,0xf2,0x38,0xbd,0xe8,0xb4,0x5d,0x25,0x5f,0xc5,0x14,0x5c,0x33,0xdb, + 0x85,0x79,0x0f,0x9b,0x3b,0x24,0xd9,0x65,0x9f,0x51,0x1a,0x8b,0xfe,0x8a,0xe6,0x63, + 0x61,0x0b,0x6b,0x1b,0x18,0x95,0x17,0xb4,0x02,0x95,0xa0,0xbc,0x44,0xb1,0x29,0x47, + 0xf9,0xd8,0x82,0xd5,0x6d,0x0b,0x30,0x79,0xc0,0x9a,0x36,0x06,0x75,0x3a,0x9f,0x81, + 0x8a,0x08,0xde,0xf1,0xab,0x16,0x64,0x18,0xa9,0xd8,0xaf,0x46,0x94,0x4c,0x53,0x30, + 0xb4,0xfe,0xe4,0x36,0x38,0x80,0xa7,0x81,0x22,0x5f,0x1b,0x59,0xc9,0x38,0x9d,0x84, + 0xb8,0x87,0xa1,0x95,0x50,0xc2,0x93,0x34,0x1b,0x68,0x6c,0x5a,0xe5,0xa8,0xd8,0x53, + 0x30,0xd9,0x8d,0xbc,0xdf,0x80,0x9e,0x8e,0x5b,0x09,0x30,0xa1,0x71,0xd9,0xd4,0xc1, + 0x02,0x41,0x43,0xe7,0x3c,0x30,0x6c,0x19,0xcc,0xc3,0x2c,0x97,0x27,0x43,0xf0,0x9d, + 0xb3,0x71,0x2b,0x9a,0x9c,0xb3,0xf9,0x5f,0xb5,0x7f,0xb0,0xce,0x42,0x34,0x18,0x4d, + 0x36,0xd0,0x18,0xa6,0x2f,0xa8,0x75,0x03,0x78,0x84,0x7f,0x3b,0xf8,0x2f,0x99,0x42, + 0xf8,0xe7,0x86,0xfe,0x74,0xf9,0xab,0x7b,0x23,0x44,0x9f,0x31,0x03,0xc2,0xee,0x33, + 0xd1,0xdf,0x69,0x0b,0x4d,0x4c,0xe1,0x09,0x4f,0x14,0x08,0x3c,0xcd,0x25,0x8e,0x7e, + 0x43,0x13,0xa0,0x95,0xc7,0xd1,0x38,0x74,0x41,0xc9,0xce,0x17,0x71,0x54,0xb8,0x0d, + 0xbf,0x01,0x5c,0x20,0x21,0x36,0x92,0xe5,0xe1,0x9b,0xa4,0x70,0x7f,0x23,0x7e,0x87, + 0xca,0x1e,0xdb,0x94,0x0b,0x45,0x7d,0x72,0xc0,0x58,0xe2,0x5c,0x60,0x1a,0xa1,0xfe, + 0x66,0x76,0xd2,0x55,0x80,0x2e,0x15,0x14,0x52,0x1c,0xb1,0x97,0x6a,0x66,0xe6,0x02, + 0xe5,0x88,0x9f,0xb2,0x91,0x6d,0x99,0xd8,0x7d,0x67,0xeb,0xef,0x79,0x7f,0xab,0x85, + 0xfb,0x70,0x38,0x96,0xe7,0x55,0x13,0x1e,0xcb,0x27,0x04,0xaf,0x8b,0xd8,0x26,0xd3, + 0xdf,0xb1,0x8d,0x7a,0xb5,0x08,0xba,0x86,0xfb,0x66,0xcf,0x27,0x13,0x98,0xdb,0x58, + 0x18,0x6d,0x00,0x93,0x58,0xd4,0xce,0x24,0x5f,0x02,0xdb,0x0a,0x72,0xdc,0x10,0x74, + 0x6f,0x41,0x10,0x2d,0x3c,0xaa,0x3d,0xb2,0x6b,0xff,0x26,0xd9,0x0b,0xd6,0xdf,0x35, + 0xfb,0x6c,0x7b,0x42,0xc6,0x4b,0xd0,0x5f,0xe6,0xc2,0xa3,0xc3,0x78,0x47,0x67,0xc8, + 0x6f,0x24,0x58,0xc8,0x1f,0xf2,0xdb,0x59,0xf7,0x5c,0xf8,0x43,0x7e,0x3b,0xdb,0x3e, + 0x17,0xfe,0x90,0xdf,0xce,0x76,0xce,0x85,0x3f,0xe4,0xb7,0xb3,0xde,0x39,0x0d,0x01, + 0x79,0x17,0xd2,0x00,0x4e,0x35,0x28,0x57,0x30,0x2d,0xac,0x7c,0xc2,0xb2,0x40,0xdb, + 0x87,0x85,0x66,0x2e,0x0c,0x35,0xba,0x4a,0xf4,0x0b,0x7e,0x6c,0x77,0x5a,0x5d,0xe0, + 0x53,0x7b,0xa8,0xea,0x70,0xfa,0x34,0x0b,0xe6,0xe1,0x2b,0x94,0x66,0xd3,0x0b,0xfc, + 0x43,0xea,0x7a,0xce,0x79,0x45,0x10,0xc5,0x48,0x0e,0x7c,0x3f,0x69,0x2b,0x0b,0xf3, + 0x34,0x06,0x43,0x47,0xac,0xf3,0x22,0x8b,0xc2,0x9c,0x4d,0xda,0x22,0x8d,0x51,0x79, + 0x6d,0x34,0xb0,0x7e,0x70,0x89,0x51,0xdb,0x43,0x87,0x36,0x58,0x05,0x4b,0xa0,0x60, + 0x5f,0x51,0x02,0x38,0x80,0xcc,0xf5,0x31,0xe0,0xee,0xa0,0x20,0xf3,0x91,0x7b,0x19, + 0xe4,0x07,0x4b,0x50,0xcf,0x83,0xe5,0x44,0x97,0x19,0xc7,0x11,0x9e,0xa9,0xe6,0xa6, + 0xf0,0xa2,0x5d,0x5d,0xfe,0xd5,0x9b,0x97,0x6f,0x8f,0x4e,0x48,0xd7,0x6b,0x90,0x0c, + 0x03,0xf8,0x88,0x4d,0xfa,0x0a,0xae,0xd5,0x17,0xc9,0x2a,0xf8,0xea,0xc9,0x2f,0xca, + 0xe3,0x2f,0x16,0x47,0x0d,0xd4,0x3c,0xe5,0x27,0xe5,0xf2,0x27,0x4a,0x30,0xfa,0x30, + 0x35,0xc3,0x1c,0x38,0x2b,0xe0,0x0b,0x07,0x8e,0xeb,0xad,0x48,0x49,0xa9,0x46,0xae, + 0x34,0xbd,0x30,0xf4,0x6b,0x20,0xcf,0x0e,0xd1,0x66,0xbb,0x31,0xc0,0x42,0x4a,0x8b, + 0x16,0x25,0xd5,0xb7,0xb2,0x81,0xb7,0x3d,0x2a,0xc8,0x1a,0xb3,0x84,0x47,0x1f,0x56, + 0x11,0x4c,0x26,0x25,0x58,0xdd,0x77,0xea,0x8e,0x80,0xc8,0x68,0x45,0xde,0x6e,0x30, + 0x52,0x74,0xd6,0x94,0xd3,0xa1,0xb3,0x67,0x53,0xa0,0x3a,0x60,0x47,0xa4,0x60,0xba, + 0x53,0x20,0x3c,0x10,0x89,0x3b,0xd0,0x4f,0x60,0xfd,0xee,0x88,0x72,0xcf,0x3d,0xe8, + 0xb0,0xfc,0xa9,0x1a,0xc5,0xa2,0xde,0xc0,0xb9,0xa3,0xee,0x9d,0x35,0x48,0x15,0x68, + 0x48,0x40,0x52,0x35,0x28,0x0d,0x17,0x8a,0x01,0x47,0x52,0xa5,0x0c,0x66,0x2e,0x04, + 0x38,0xea,0xa8,0x40,0xa4,0x67,0xdc,0x9b,0x07,0x23,0x65,0x53,0x83,0x09,0xd3,0x17, + 0x52,0xba,0x2c,0xc1,0x71,0x89,0x6d,0x6f,0x1b,0x42,0x9c,0x79,0x95,0x54,0x0e,0x25, + 0x64,0x6f,0xe3,0x3e,0x9c,0xb4,0x0b,0x4a,0x11,0x72,0xd2,0x12,0x37,0x8d,0xa6,0x53, + 0x37,0x07,0x8a,0x93,0xac,0x34,0x55,0x1e,0x0f,0xfc,0x5a,0xd0,0x97,0xfb,0x59,0x20, + 0x35,0x95,0x6e,0x8e,0xcf,0x30,0xd0,0xc6,0x10,0x87,0xcb,0x23,0xfd,0xf9,0xe3,0x9b, + 0xc3,0x74,0xbe,0x48,0x13,0x80,0x84,0xe2,0xeb,0xec,0xf3,0x39,0x34,0xe6,0x20,0x60, + 0xf8,0xc9,0x12,0x0c,0x7e,0x0c,0x50,0x81,0xa5,0x56,0x92,0x90,0xe0,0xc2,0x9a,0x00, + 0x96,0x26,0xa0,0x0b,0x57,0x1b,0x56,0x40,0xd1,0x04,0x74,0x80,0x3f,0x81,0xc7,0x62, + 0x22,0x96,0xd3,0xe9,0xf4,0xe5,0x61,0xf7,0xa0,0x6f,0x04,0x15,0x26,0x20,0x1a,0x03, + 0xed,0x36,0x90,0x22,0xf1,0xaf,0x88,0xe3,0xc6,0x9f,0x8a,0xf4,0x28,0x9f,0x28,0xac, + 0x71,0xae,0x49,0x86,0x47,0x07,0x5d,0x82,0x5f,0x5e,0xbd,0xab,0x4a,0x7b,0xaa,0x2a, + 0xf4,0x86,0x9c,0x56,0xd6,0x97,0x14,0x87,0x62,0xe7,0x19,0x70,0x47,0x98,0x50,0x2e, + 0x7f,0x96,0x38,0x7f,0x87,0xd5,0x29,0xb2,0xb1,0x15,0x2c,0x6e,0x90,0x96,0xf6,0x34, + 0x5a,0x94,0x24,0xc7,0x6b,0xa4,0x41,0x27,0x68,0xdc,0x3a,0x09,0x10,0xa0,0x26,0xa6, + 0x01,0x84,0x42,0x89,0x6a,0x82,0x04,0xd5,0x21,0x96,0xb4,0xf2,0x87,0xe5,0x7f,0x4d, + 0x61,0x1d,0x34,0x1e,0x35,0x3c,0xc9,0x5a,0x17,0x69,0x4e,0xb3,0xbd,0xcc,0xd0,0xf5, + 0x92,0x4e,0x6e,0x68,0x5c,0xe2,0xa6,0x63,0x4c,0xbb,0xdd,0x80,0x36,0x67,0x29,0xb2, + 0xf7,0xe3,0x0f,0x27,0xa7,0x0d,0x5f,0x9c,0xe6,0xcb,0xd1,0x98,0x69,0x88,0x1b,0xc2, + 0x9b,0xa7,0x20,0x0e,0x1a,0x28,0xe6,0x16,0x20,0x86,0xc7,0x74,0xf6,0x6f,0xeb,0xba, + 0x79,0x75,0x75,0xd5,0x04,0xa4,0xcd,0x9b,0x00,0x89,0x69,0x06,0x06,0x7e,0xe7,0xd3, + 0x41,0xdf,0x3e,0xb5,0x06,0x9a,0x52,0x18,0x2e,0x02,0xbc,0xbe,0xbc,0x2f,0x39,0x2b, + 0x18,0xeb,0x14,0x9f,0x02,0x2a,0xef,0x28,0xcd,0x8a,0x13,0xfa,0x90,0xb7,0x34,0xc3, + 0xc2,0xa8,0x49,0x75,0x3b,0x68,0xf0,0xe2,0xc2,0x86,0x25,0x10,0x4e,0xa3,0x24,0x9c, + 0x6c,0xc8,0xfb,0xbc,0xdd,0x4c,0x70,0x0d,0xf2,0x55,0xd3,0x1d,0xcd,0x4e,0xfd,0x25, + 0xcd,0x12,0x51,0xf2,0x4a,0x66,0x5e,0x38,0x9a,0x27,0x06,0x28,0x1a,0xb4,0x6b,0x44, + 0x08,0x0f,0xfc,0x23,0x5a,0x12,0x0d,0x91,0x40,0x00,0xac,0x08,0x39,0x85,0xe3,0x74, + 0x68,0x4a,0x91,0x36,0xf4,0xe2,0x1b,0x6c,0x80,0xca,0x48,0x57,0xb6,0x43,0xee,0x83, + 0x07,0x58,0x4c,0x38,0xd4,0x79,0x1e,0x64,0x33,0x28,0x7a,0xa0,0x22,0x29,0x97,0x52, + 0x0c,0xe9,0xce,0xe2,0x04,0xba,0x7c,0x55,0x3c,0x07,0xa2,0x13,0xf9,0x13,0x04,0xbb, + 0x57,0xb6,0xb0,0xa7,0x08,0x1f,0xd1,0x0d,0x6c,0x13,0x7f,0x82,0x16,0x31,0x46,0x4a, + 0x93,0x22,0xeb,0x08,0x16,0x1c,0x5f,0x1d,0x3e,0x10,0x52,0x93,0x2e,0x03,0x27,0xc5, + 0x47,0x26,0x88,0x6c,0x76,0xb6,0x63,0xf7,0x3c,0xea,0x68,0x03,0x91,0x35,0xc1,0xa5, + 0x97,0x7e,0x6e,0x10,0x4f,0x15,0x1d,0x87,0x9f,0x52,0xaa,0x96,0xc6,0x4f,0x88,0xb9, + 0x45,0x8c,0x4f,0xc0,0xa0,0x26,0xec,0xfb,0x8e,0xec,0xbc,0x31,0xf8,0xc7,0x8f,0x19, + 0xc2,0x33,0x56,0x8f,0x92,0xb4,0xa0,0x99,0x99,0x38,0x37,0x21,0x58,0x49,0x3a,0x62, + 0x7d,0x12,0x4d,0x1c,0xcc,0x0c,0x92,0xfc,0x2a,0xcc,0x5a,0xce,0x69,0x76,0x83,0x82, + 0x29,0x40,0x79,0xd7,0x42,0xb5,0x57,0x57,0xad,0xab,0x46,0x11,0xda,0x51,0x21,0x63, + 0xe2,0x31,0xb0,0x9b,0xaa,0x62,0xf4,0x36,0xa0,0x3e,0xbb,0x69,0xe1,0xe8,0xc2,0x2c, + 0x6b,0x08,0x0c,0x72,0xaf,0xc0,0x00,0xf3,0x94,0x29,0x00,0x1d,0xd6,0x65,0xee,0xaa, + 0x8c,0x78,0x4c,0xa0,0x91,0xa6,0xb4,0xae,0x31,0x70,0x0c,0xac,0xa0,0x1d,0x3a,0x90, + 0x84,0xc0,0xf4,0x87,0xb8,0xd6,0x1e,0xec,0x9b,0x50,0x99,0x6a,0xf7,0xa2,0xb8,0x0a, + 0x79,0x0d,0x36,0xd6,0x11,0xd0,0x18,0x2f,0x88,0x97,0x14,0x84,0x9d,0x5f,0x4d,0x44, + 0x77,0x1b,0xc1,0x64,0xf2,0x12,0x2f,0x25,0x7d,0x1b,0x81,0xa6,0x9a,0x80,0x02,0xd1, + 0xc0,0xe3,0x54,0xb3,0x68,0x82,0x3b,0x57,0x02,0x86,0xa1,0x3d,0xf1,0xd8,0x68,0x04, + 0x2c,0x77,0xeb,0xeb,0xe7,0xb3,0xf4,0xaa,0xb6,0x3e,0x6b,0x5f,0x8e,0xb5,0x44,0x71, + 0xd2,0xdc,0x5f,0xc9,0x7b,0x84,0xda,0xe0,0xaf,0xad,0xd9,0x17,0x56,0x54,0x40,0xa0, + 0xcc,0xbe,0x08,0x1b,0x74,0x7a,0xa1,0x2c,0x80,0xb2,0xe3,0xd2,0xf2,0x26,0xfe,0xaa, + 0x15,0x1d,0x18,0xa5,0xf6,0x1f,0xfe,0xaa,0xd5,0x1a,0xdf,0x91,0x7e,0xc1,0x5f,0x85, + 0x16,0xa3,0xfc,0x7d,0xbf,0x0a,0xfd,0x85,0xa4,0xf4,0x88,0x7a,0x5f,0x55,0xb7,0x47, + 0xc8,0x5f,0x49,0x75,0xeb,0xa3,0xf9,0x2b,0x7d,0x28,0x9e,0x70,0x3a,0x18,0x89,0xc1, + 0xb5,0x67,0xf8,0x11,0x80,0xbf,0x9f,0x84,0xc8,0x0b,0x0d,0xc3,0x08,0x94,0x14,0xf8, + 0xe3,0x29,0xb3,0x1f,0x5d,0x86,0x00,0xde,0x21,0xad,0xb1,0x0f,0x7f,0x48,0x1f,0x27, + 0xad,0x91,0xbe,0x40,0x35,0x22,0xac,0x93,0x1a,0x76,0x0b,0xd2,0x54,0xe9,0x7c,0xe8, + 0xcb,0xa4,0x0c,0x64,0x08,0x84,0x46,0xf8,0xfb,0x21,0xb1,0xd5,0x1f,0x91,0x8d,0xe2, + 0x56,0x16,0xc1,0xdf,0x4a,0xa1,0xa2,0x2d,0x61,0xd2,0xd5,0xab,0xc3,0xe6,0x06,0x94, + 0xce,0x0c,0x08,0xa5,0x9f,0x16,0x43,0x10,0xca,0x35,0x18,0xce,0x52,0xa8,0x7b,0xad, + 0x4b,0xf4,0x6f,0x33,0x61,0xcb,0xf6,0x64,0x09,0x8e,0x51,0x80,0x32,0xf2,0x70,0x14, + 0x14,0xb3,0xea,0x1a,0xe9,0x02,0xb4,0x64,0x33,0xd2,0xb8,0x9e,0x5e,0x58,0x8b,0x13, + 0x8f,0xf1,0xd0,0xe2,0xd2,0xd6,0xc7,0x80,0xf8,0x50,0xe3,0x23,0xe4,0x20,0x0d,0xda, + 0x47,0x8c,0x44,0x3c,0xc0,0x3f,0xfe,0xf5,0x7f,0x1b,0xdc,0xd9,0x7c,0xfd,0xc0,0x9d, + 0x80,0xfc,0x37,0x44,0x04,0x3e,0xa1,0x00,0x80,0xd9,0x55,0x9b,0x4d,0x88,0x28,0xf9, + 0x31,0x84,0xca,0x6a,0xc4,0xa2,0xb0,0x06,0x7e,0x35,0x24,0xd9,0xaf,0xb8,0xb4,0x5d, + 0x41,0x65,0xc2,0x5a,0x37,0x54,0xc8,0x1d,0x12,0x54,0x13,0xf9,0xf4,0x01,0x77,0x1c, + 0x3b,0x86,0x9d,0x70,0xc5,0x3a,0xc1,0x65,0xcb,0x1b,0x6a,0xcc,0x61,0x41,0x9c,0x2f, + 0x63,0x66,0x7f,0x38,0xfa,0xfa,0x01,0x96,0x39,0xa9,0xe6,0x70,0x74,0x8b,0x67,0xe1, + 0x8a,0x71,0x29,0xbb,0x94,0xbe,0x7f,0x40,0xd3,0x9b,0x3c,0xd0,0x6c,0xda,0xd7,0x70, + 0x43,0x0a,0xbe,0x70,0x99,0x39,0x7d,0x5d,0xc8,0xc2,0xe4,0x82,0x80,0xf1,0x5a,0x74, + 0xdc,0x48,0xb2,0x93,0x35,0x42,0x71,0x8b,0xc0,0xdb,0xa2,0x11,0x07,0x69,0xcb,0x98, + 0x06,0xc7,0x76,0x4c,0xa1,0xb9,0xf5,0x62,0xa1,0x61,0x32,0x76,0xd1,0x33,0xa0,0xa0, + 0x15,0x9d,0x13,0x14,0x67,0x51,0x5a,0x15,0x01,0x69,0xf2,0x96,0x84,0x2a,0x73,0x30, + 0xb2,0x5d,0x05,0x43,0x1f,0xa7,0xc9,0xa5,0x60,0xde,0x94,0x67,0x4b,0x63,0xf4,0x43, + 0x08,0x2b,0x17,0x4c,0x21,0xd9,0x0c,0x82,0x66,0x4b,0xf4,0xd7,0x16,0xfc,0xad,0x5d, + 0x6f,0x08,0x51,0x48,0x09,0x58,0xab,0xbc,0xdc,0x87,0x6a,0x7d,0x61,0xb2,0xd6,0x01, + 0x9c,0xa6,0x50,0x0c,0x40,0xc4,0xf5,0x50,0xd9,0x52,0x35,0x1c,0xcd,0x2a,0xb0,0x41, + 0xa0,0x45,0xd9,0x14,0xb9,0x35,0xa7,0x53,0xf2,0x1a,0xb5,0x89,0x75,0x74,0x1a,0x03, + 0xb3,0xf7,0x58,0xfe,0x39,0xee,0x60,0x40,0x9e,0x59,0x69,0x31,0x99,0x37,0x10,0x50, + 0x4d,0xf3,0xcf,0xa8,0x79,0xa8,0x05,0x23,0x87,0x5a,0xd4,0x17,0x1e,0x49,0x4d,0xe3, + 0x08,0x47,0x78,0xf3,0x2f,0x45,0xf7,0x9e,0x2b,0x2f,0x24,0xb1,0x57,0x40,0x5d,0x8b, + 0x3b,0x44,0x98,0x11,0x7d,0x43,0xae,0x8d,0x75,0x10,0xb3,0xad,0x7c,0x01,0xfc,0x2b, + 0x5b,0xce,0x19,0x75,0xe2,0xc3,0x2e,0x12,0x12,0x78,0x58,0x8e,0xf8,0x03,0xfa,0x70, + 0x06,0xa6,0x25,0x71,0x7e,0x5e,0xf0,0xa2,0x67,0x61,0x0b,0xf7,0x41,0x40,0xc8,0x23, + 0x66,0x43,0xdc,0x92,0xdc,0xe7,0x34,0xf4,0xa8,0xc4,0x69,0x9f,0xe6,0xd9,0xc5,0x14, + 0x0f,0xb7,0x2c,0xd4,0x37,0x6e,0x9c,0xdc,0x09,0xc6,0xae,0xc7,0x8a,0xe3,0x9a,0xb6, + 0x26,0xb8,0x45,0x71,0xd6,0xdc,0x03,0x1e,0xd2,0xec,0xa0,0xcf,0x43,0xac,0x68,0x31, + 0xb5,0x5c,0x88,0x37,0x18,0x79,0x84,0xe2,0x37,0xf6,0x1a,0x96,0x49,0x79,0xd0,0xe8, + 0xe5,0x8a,0x0c,0x26,0x45,0x7b,0x2c,0x8f,0xc9,0x1c,0x61,0x17,0x03,0xd9,0x23,0x95, + 0x4d,0x50,0xa4,0x00,0xf2,0x10,0xec,0x3b,0x38,0x35,0xae,0x84,0xf9,0x1e,0x6d,0x5f, + 0xbd,0xd9,0x8a,0x7b,0x21,0x30,0x0e,0xf7,0x52,0x6d,0x97,0xba,0x46,0x28,0x12,0x80, + 0xc6,0x83,0x59,0xbe,0x23,0x9b,0x0b,0xae,0xa5,0xf9,0xa3,0x36,0x4b,0x99,0x79,0x28, + 0xde,0x6d,0x92,0xbd,0x56,0xc2,0x70,0xea,0x3b,0x9e,0x54,0x98,0x84,0xa5,0xf3,0xfe, + 0xc3,0xe9,0x4b,0x8e,0x4b,0x22,0x92,0x04,0x5a,0x3c,0xb5,0xcf,0x43,0x46,0x78,0x8a, + 0x6f,0x0a,0xec,0x0d,0x93,0x99,0xd7,0xe1,0x75,0x65,0x4e,0x5e,0x44,0x71,0xec,0x8c, + 0x42,0x7a,0xbf,0x24,0x9c,0xc0,0xd2,0xdf,0x20,0xd5,0x71,0x12,0x16,0x40,0x0a,0xb0, + 0xce,0x01,0xd4,0xfb,0xd4,0x84,0x34,0xa5,0x93,0xf3,0xc4,0x3d,0x30,0xfc,0xce,0x01, + 0x50,0x8e,0xfb,0xf1,0x2f,0x1e,0x36,0x11,0x4d,0xe2,0x10,0x95,0xca,0x10,0x96,0x1d, + 0x16,0x38,0x3a,0x38,0x45,0xa5,0x46,0x83,0x65,0x7d,0x15,0x0d,0xd7,0xda,0x2e,0x5a, + 0x45,0x40,0x8f,0x35,0x63,0x76,0xa6,0x71,0xb4,0x70,0x23,0x60,0x69,0x69,0xe2,0x11, + 0x17,0x8a,0x26,0x5e,0x8b,0x22,0xd9,0x50,0xa7,0x12,0xf7,0x21,0xb8,0x8d,0x14,0x45, + 0x4a,0x8a,0xbb,0xc8,0x5c,0x02,0x16,0xe2,0x41,0x01,0x98,0x1b,0x81,0x65,0xea,0x36, + 0xcc,0x43,0x04,0xb2,0x9c,0xa5,0xb3,0x8a,0x36,0xd8,0xd1,0x0d,0xda,0x99,0x6e,0xca, + 0x78,0x33,0x0a,0x39,0x32,0x71,0x1d,0xa3,0x13,0x74,0x5a,0x02,0x16,0x25,0x82,0xc0, + 0x89,0x85,0xba,0xb6,0x5f,0x82,0x26,0x55,0x5a,0x6a,0x44,0xbc,0x92,0x95,0xb1,0xff, + 0x80,0x7d,0x0a,0xc4,0x6a,0xf6,0xa5,0x83,0x0c,0x57,0xe5,0x83,0x94,0x94,0x13,0x9c, + 0x56,0xd0,0xeb,0x57,0xf3,0x0c,0x58,0x82,0x5b,0x7f,0x77,0xa1,0xd8,0xef,0x84,0x4e, + 0xef,0x4f,0xc2,0xcb,0xc9,0x1c,0xc3,0x23,0x67,0x3a,0x3e,0x83,0x80,0xc8,0xdf,0xa8, + 0x61,0x48,0x90,0x1f,0x93,0x72,0x5a,0x5f,0xc0,0x71,0x13,0x22,0x03,0x68,0x56,0x4d, + 0x3b,0x4e,0xb7,0xd7,0x90,0x86,0x8a,0x22,0x19,0x69,0x7e,0xe8,0xc9,0xe6,0xd1,0x8c, + 0x02,0xd4,0x03,0xb6,0xfe,0x0e,0x79,0xa6,0x8f,0xb8,0xe1,0xf0,0xf5,0x44,0xc4,0x42, + 0xd1,0x05,0x9c,0x16,0x88,0xc9,0x07,0xc8,0x57,0x80,0x95,0x10,0x69,0x9f,0xcd,0xc9, + 0x6f,0x82,0x26,0x16,0xd1,0x81,0xa1,0x15,0x89,0x59,0xa4,0xa9,0x6b,0x88,0x83,0xfc, + 0x90,0x38,0xf7,0xa9,0x3d,0xa8,0xe4,0x0a,0xff,0xb5,0x1e,0x1d,0x35,0x0a,0xb6,0x1b, + 0x35,0xe8,0x79,0xa6,0x22,0xf6,0xe7,0x20,0x6e,0x94,0xe7,0x5a,0x4c,0x8b,0xd2,0xc8, + 0xf4,0xc9,0xf8,0x4a,0x51,0xec,0x7b,0xb5,0x8c,0x45,0x20,0x18,0x00,0x8e,0x04,0x02, + 0xbd,0xa3,0x92,0xf2,0x10,0x25,0x94,0xa3,0x93,0xf5,0x2d,0x71,0xbb,0x03,0xba,0xe6, + 0x02,0x74,0xcb,0xb5,0x5b,0x3d,0xea,0x28,0x95,0xe6,0x13,0xf5,0x50,0x96,0xc3,0x35, + 0xa1,0x10,0x3b,0x5d,0x69,0x88,0x5a,0xad,0x54,0x67,0xec,0x6d,0xe5,0xf0,0x01,0x17, + 0x96,0x48,0xd4,0xa5,0xc8,0x67,0xab,0xb0,0x28,0x4e,0xbb,0x43,0xaa,0xa8,0x40,0x9b, + 0x29,0xd0,0x89,0x07,0x01,0xff,0x26,0xb1,0xd7,0x17,0x12,0xa7,0xa1,0xce,0x58,0x63, + 0x9a,0x74,0xf7,0x42,0x3a,0x30,0xf8,0x84,0x29,0x02,0xfb,0x8f,0x9a,0x12,0x7b,0xac, + 0x10,0x08,0xca,0x08,0xae,0xad,0x27,0x82,0x7a,0x9e,0x17,0x15,0xa4,0x8a,0x3e,0x40, + 0x15,0x51,0x53,0xb6,0x01,0x3f,0x1f,0x08,0x28,0x81,0x33,0x12,0xa7,0xfb,0xd3,0xcc, + 0x51,0xfc,0x44,0xf7,0x00,0x77,0x82,0x6a,0xe9,0xc0,0x3e,0x37,0x6e,0x8c,0xca,0x3a, + 0x3d,0x4e,0x3d,0x4c,0x84,0xcf,0xcd,0x43,0x57,0x14,0xf6,0x8a,0x78,0x1c,0x6e,0xb7, + 0xe6,0x8d,0x92,0xae,0xc3,0xf1,0xae,0x2e,0x6d,0xb6,0xcb,0x09,0x72,0xe8,0x1d,0xb7, + 0x45,0x2e,0x45,0x68,0x94,0x8c,0xe3,0xe5,0x24,0xcc,0x69,0x1e,0xa2,0xb4,0xa1,0x2c, + 0xd8,0x65,0x0e,0x4a,0x22,0xfb,0xd0,0xf3,0x16,0xe5,0x7d,0x5c,0x26,0xe8,0x0c,0x82, + 0xf9,0x51,0xce,0x74,0x91,0x73,0xc8,0x09,0x86,0x5f,0xa1,0x6c,0x33,0xa4,0xd0,0x4c, + 0x44,0x5a,0xbe,0xd4,0x05,0xb5,0x6e,0x63,0x30,0x1c,0x5b,0x8b,0xa9,0xf1,0x92,0xa8, + 0x16,0xa4,0x61,0x3f,0x40,0xf6,0x6c,0x99,0x21,0x69,0xc2,0x27,0x96,0x2b,0x4d,0x09, + 0x43,0xca,0x7d,0x5c,0xb5,0x78,0x3c,0xcb,0xa8,0x2a,0xbb,0x07,0xf4,0xb0,0x2a,0x4d, + 0x71,0x37,0x70,0x10,0xf4,0xa3,0x42,0xf6,0xd5,0x71,0xb3,0x5d,0x42,0x24,0xc5,0xd8, + 0x45,0x72,0x1e,0x6c,0x20,0xbe,0x1f,0x3f,0xd6,0x4d,0xad,0xd0,0xca,0xcb,0x6a,0x37, + 0xf9,0x7f,0x12,0xf2,0x48,0xba,0x04,0x15,0x94,0x36,0x40,0x5f,0xdb,0x2b,0xbb,0xe6, + 0xb8,0xad,0xac,0x34,0x93,0x86,0xff,0x47,0x74,0x06,0x81,0x08,0x5b,0x86,0x9c,0x37, + 0x74,0xe9,0x43,0xbd,0xa2,0x2e,0xdc,0x3e,0x75,0x7a,0xba,0x80,0x89,0x83,0x6a,0x36, + 0x0d,0xfc,0x79,0x8c,0x44,0xb4,0xd9,0xde,0x2d,0x0b,0x72,0x62,0x7e,0x18,0xc1,0x7a, + 0xb9,0x04,0xc3,0xd8,0x35,0xfc,0xd5,0x42,0xe9,0x00,0x05,0x23,0x8c,0x61,0xe9,0x70, + 0xe0,0x0e,0x5e,0x51,0x05,0x06,0xf5,0x46,0x2b,0xe5,0x2a,0xae,0xda,0x0c,0x97,0x3f, + 0xc4,0x8e,0x38,0xda,0xe4,0x81,0x14,0xb7,0xaf,0xa2,0x18,0x2f,0x34,0x75,0xce,0x1a, + 0xfa,0xb2,0xc7,0x06,0x1b,0xe6,0xea,0x8d,0xc8,0x83,0x49,0xb0,0xc0,0x7b,0xda,0x86, + 0x14,0x8d,0x13,0x4c,0xd8,0x57,0xd1,0x27,0x13,0xcd,0xdf,0xc0,0xe5,0xd2,0x17,0x78, + 0x72,0x45,0x9c,0x43,0xad,0xb5,0x8f,0x5f,0x3f,0x06,0x8b,0xbe,0xc3,0x5b,0x64,0x1b, + 0x93,0x11,0x34,0x2b,0xf7,0xb7,0x7d,0xbd,0xd3,0xff,0x58,0x6e,0x7f,0x9f,0xfb,0xac, + 0xa9,0x2b,0x47,0x44,0xc7,0xf3,0x37,0xf0,0x86,0x26,0x0c,0xe7,0x29,0x87,0x91,0x43, + 0x35,0x76,0xa9,0x98,0x61,0x6f,0x58,0x29,0xa6,0xd0,0x1c,0x4f,0xe8,0x76,0x1e,0x06, + 0x44,0xf1,0xed,0x65,0x18,0xf2,0x63,0x40,0xa1,0xe8,0x21,0x90,0x9c,0xab,0xa1,0x60, + 0xf1,0xa6,0x80,0x02,0x93,0xe5,0x6f,0xa0,0x7e,0x27,0x91,0x61,0x18,0x5f,0x55,0xed, + 0x41,0x79,0x36,0x60,0xad,0x19,0xb1,0x73,0xc2,0x95,0xf4,0x5c,0xb8,0xd1,0xfa,0x8e, + 0xb1,0x6c,0xfd,0x8d,0x34,0x51,0xb0,0xa7,0x89,0x98,0x7d,0xda,0x42,0x44,0x7b,0x2e, + 0x19,0x38,0x54,0xe4,0x15,0x92,0x82,0x51,0x42,0x2a,0xa4,0x5c,0x66,0x43,0x19,0x16, + 0xf5,0x96,0xfd,0x73,0xce,0x1f,0x76,0x70,0x1b,0x0f,0xbd,0x00,0xd2,0x02,0xe4,0x65, + 0x2e,0x1e,0x61,0x94,0xac,0x92,0x13,0x8d,0x37,0x1b,0xdd,0x1e,0xdb,0x7e,0xd0,0x13, + 0x75,0xe4,0x01,0xfa,0xc2,0x58,0x65,0x6b,0xba,0x81,0xee,0x81,0x21,0xef,0x18,0x88, + 0x52,0x07,0x18,0xcc,0xee,0x8a,0xfb,0x62,0x35,0xc6,0x8e,0x5f,0xd1,0x70,0xb3,0xb9, + 0xc8,0x82,0x75,0x83,0x07,0x4a,0x72,0x36,0xf3,0xfb,0x0c,0x2f,0xd7,0x77,0x56,0x10, + 0x4f,0xf7,0x71,0xe1,0x45,0x19,0xc7,0x2a,0xe4,0x7e,0xf9,0xe8,0x28,0x9d,0x19,0x8d, + 0xd4,0xfd,0x3d,0xa0,0x47,0x40,0x63,0x30,0x06,0x5c,0x79,0xaa,0x19,0xd1,0xd1,0x46, + 0xc5,0x8a,0xe6,0xbd,0x21,0xfc,0x22,0xb4,0xd3,0x4f,0x5c,0x62,0xc2,0x04,0xb8,0x5b, + 0x13,0xc9,0x52,0x77,0x16,0xe2,0x80,0xc2,0xd4,0xec,0x35,0x35,0x50,0xb1,0xdc,0xf7, + 0x09,0xa5,0xa5,0x68,0x6e,0xa2,0x4f,0x8e,0x77,0x3e,0x3c,0x38,0x3e,0x51,0x9e,0x21, + 0x4c,0x38,0x7a,0x79,0x78,0x70,0x44,0x46,0x86,0x88,0x88,0x3e,0x7d,0x73,0xf8,0xd3, + 0x89,0x0c,0x26,0x36,0x1d,0x4b,0x87,0xb0,0x6a,0x5d,0x1d,0x66,0x86,0x82,0xe6,0x80, + 0x24,0x1f,0x0e,0x48,0xc0,0x55,0x8b,0x79,0xdc,0x32,0xa3,0x97,0xc4,0x37,0xc6,0x15, + 0xe1,0x12,0x1e,0xa3,0x05,0x08,0xe2,0x52,0x44,0x0a,0xa9,0x05,0x3b,0x6e,0xc9,0xdf, + 0x1c,0x9d,0xb4,0x21,0x56,0x3f,0x56,0x86,0x1f,0x1c,0x34,0xc3,0xcb,0x79,0xdc,0xc2, + 0x4b,0x4a,0x50,0x0d,0x94,0x0b,0xbb,0x76,0x3d,0x1b,0x4b,0x77,0xdc,0x12,0xf7,0x99, + 0x50,0x25,0xb1,0x8e,0x6b,0x97,0x2f,0xc5,0x2d,0x6a,0xbc,0x18,0x81,0x3c,0x38,0x4a, + 0x11,0x84,0xb6,0xe5,0xc8,0x0f,0x8a,0x6d,0x55,0x58,0x3b,0xc3,0x90,0xc6,0x46,0xaf, + 0xdd,0xc0,0x8d,0x6a,0x5a,0x20,0x0d,0xf8,0xb7,0x21,0x02,0x1c,0xe1,0xab,0x2b,0xbe, + 0x7a,0x6d,0x2e,0x88,0x5f,0x1b,0x58,0x92,0x8a,0x7e,0x6e,0xe8,0x50,0xc8,0x46,0xf7, + 0x73,0x43,0x87,0x42,0x36,0x7a,0x9f,0x25,0x4c,0x01,0x15,0xbe,0xcf,0xf1,0xe6,0x7e, + 0x64,0xc7,0xae,0x7b,0x36,0xfb,0x72,0xce,0x63,0xfa,0x02,0x82,0x5f,0x77,0x0e,0x83, + 0x53,0x5a,0x9d,0x1e,0x6a,0x55,0x90,0xf3,0xcc,0x31,0xc6,0xf0,0x03,0x1a,0xb7,0x4f, + 0x8c,0x00,0x62,0xa2,0xa8,0xd7,0x5f,0x4a,0x01,0x47,0xc8,0x2d,0x35,0x3c,0x23,0x7a, + 0x43,0x83,0x2a,0x1d,0xc2,0x31,0x50,0x63,0xba,0xba,0x71,0x61,0x1f,0xfe,0xd3,0x41, + 0xdc,0x6a,0x7f,0xb9,0x26,0x7e,0x18,0x87,0x82,0x44,0xcc,0x0e,0x73,0x4d,0xe5,0xf8, + 0x52,0x0f,0x6d,0xdf,0xe0,0x07,0xf1,0x3b,0xe1,0x40,0xa6,0x4c,0xe0,0xc9,0xf8,0x06, + 0x6b,0xc6,0x45,0x74,0xa8,0x69,0x98,0xe5,0xa0,0x54,0x32,0xcd,0x9b,0x0f,0xde,0xaa, + 0x0a,0xe4,0xa4,0xd4,0x95,0xcd,0xb7,0x5c,0xe3,0x65,0x3e,0xf3,0xd1,0x84,0xb3,0x4d, + 0x51,0x4a,0xd7,0x06,0xa3,0x86,0x24,0x77,0x4c,0xb8,0xcf,0xf7,0x6a,0x4e,0x07,0x59, + 0x1d,0xb4,0xb4,0x5c,0x61,0x9e,0x03,0x6d,0x0a,0xc6,0xb1,0x76,0x8b,0x83,0xba,0x63, + 0x4e,0x11,0x6e,0x86,0xde,0xe8,0x90,0x68,0x1a,0xfa,0x9a,0xcd,0x07,0x8a,0x42,0xc5, + 0xd9,0xc6,0xae,0xdd,0x24,0xe3,0x03,0x75,0x3d,0x92,0x2b,0x52,0xc4,0xed,0x37,0xf4, + 0x59,0x52,0x3d,0x4c,0xbc,0xd0,0xd3,0x10,0x7a,0xb6,0x45,0xf0,0x29,0x36,0x6f,0xd0, + 0x84,0x3a,0x97,0x42,0x69,0x7c,0xe6,0xc1,0x3e,0x10,0xa1,0x49,0xf9,0xde,0x31,0x7e, + 0x5c,0x1c,0xcd,0xb5,0x43,0x3e,0x0c,0x4f,0x4e,0x78,0xfc,0x6e,0xe8,0xdc,0x71,0x81, + 0x67,0x60,0x74,0x21,0x84,0x44,0x96,0xcc,0x35,0x88,0x81,0xee,0x44,0x97,0x7c,0x87, + 0x74,0xe5,0x80,0xb4,0xda,0xd9,0x03,0x31,0xd1,0x77,0x76,0x41,0xdc,0xf4,0x9d,0x2e, + 0xfc,0x01,0xbe,0xb7,0xd3,0x76,0x04,0xc9,0x1d,0x7f,0xe2,0xc0,0x99,0xe3,0xd7,0x3c, + 0x8f,0x5c,0xf9,0xf0,0xc3,0xc1,0xc7,0x93,0x97,0x18,0x23,0x86,0x02,0xf3,0x1d,0x5e, + 0x20,0xef,0x36,0xd4,0x0d,0xf2,0x8e,0xb8,0x42,0xbe,0x81,0x6e,0x3a,0xd4,0x21,0x73, + 0x59,0xef,0xe5,0xd1,0x8f,0x58,0x4b,0x54,0x07,0x85,0x75,0x0f,0xd6,0xc8,0x13,0x99, + 0xfb,0xe3,0xc7,0x37,0xc7,0x56,0x6e,0xc7,0xcc,0xfd,0xf8,0xf2,0xe0,0xf0,0xb5,0x99, + 0xdd,0xed,0xa2,0x87,0x52,0x9d,0xe6,0x78,0xfd,0xe6,0xfd,0xe9,0x2f,0x07,0x3f,0x9f, + 0x7e,0x40,0x23,0xf7,0x5b,0xef,0x93,0x6a,0x6c,0x80,0x16,0xad,0x20,0x37,0x4e,0xf8, + 0xc2,0x33,0x7d,0xa5,0x15,0x3a,0xaf,0x70,0x73,0x12,0xfd,0xd7,0x81,0xc3,0xd7,0x66, + 0xaa,0x4b,0xa5,0xc8,0x6a,0xf9,0xde,0x1b,0xa9,0xf4,0x8c,0xd0,0x00,0x5e,0x1f,0xbc, + 0x3f,0xc2,0x01,0x7c,0xa2,0xbb,0x1e,0xa9,0xdb,0xd3,0x2c,0xfc,0x6d,0x19,0x26,0x63, + 0x74,0xbe,0x05,0xa4,0x1a,0x38,0x6c,0x42,0xc2,0xa8,0xd2,0x96,0xf3,0x9a,0x2f,0x7b, + 0xa4,0xa2,0xbf,0x2d,0xa3,0xb0,0x68,0x16,0x69,0x13,0x87,0x2a,0x7a,0x8e,0xde,0x36, + 0x30,0xbe,0x81,0x06,0x52,0xf2,0xad,0x9b,0x9b,0xee,0xb0,0x3e,0x8e,0x81,0x54,0x68, + 0x99,0x1b,0x34,0xc3,0x7e,0x00,0x31,0x4a,0xde,0x58,0x16,0x6c,0x37,0xcf,0x3f,0xd9, + 0xe4,0xc5,0x06,0x21,0xf5,0xd6,0xc7,0xec,0xd7,0x75,0xd9,0xdc,0x45,0x09,0x64,0xb2, + 0xc8,0xb4,0xb6,0xc0,0x7e,0x8a,0x63,0x30,0xc0,0xe2,0x8f,0x68,0x14,0x90,0x3c,0x1d, + 0x98,0x9d,0xa1,0x5b,0x2f,0xa5,0x74,0x13,0x61,0x38,0xd8,0x8d,0x1f,0x10,0x90,0x67, + 0x15,0x55,0x3d,0xb6,0xcb,0xbe,0xae,0x2b,0x5b,0x1a,0x23,0x15,0x03,0xb3,0x7a,0x71, + 0x0d,0x83,0xc5,0x55,0x84,0x8e,0xbb,0x53,0xbc,0xea,0x16,0x63,0x2c,0x5c,0xa8,0x4e, + 0xf1,0xcf,0xf0,0x9f,0xfa,0x89,0x2a,0xe3,0x27,0xae,0xfa,0x09,0x84,0xf3,0xbb,0x56, + 0x4c,0xff,0x82,0xde,0x43,0x8b,0x85,0x20,0x62,0x42,0x41,0xff,0x8e,0xf4,0x8a,0xbf, + 0xfe,0x80,0x5b,0x0f,0xee,0xec,0x0b,0x4b,0x2e,0xa8,0xf7,0x18,0xd7,0xd9,0x0f,0xc2, + 0x5f,0xcb,0x12,0x5c,0x48,0x38,0x2a,0x65,0x89,0x70,0x0c,0xbc,0x65,0x91,0xaf,0x28, + 0x67,0xf6,0x85,0x21,0x1a,0xd1,0xbd,0x50,0xd3,0x92,0xac,0x56,0x40,0xae,0x54,0x19, + 0xa0,0x45,0xf7,0x9a,0xbb,0x8e,0x60,0x8f,0x3f,0x79,0xfa,0x4c,0xea,0x0d,0x83,0x14, + 0xd2,0x15,0x06,0xf1,0x98,0x8f,0xab,0xe9,0x10,0x28,0x8c,0x81,0x3a,0x7e,0x2d,0xcb, + 0x5f,0x72,0xf9,0x1b,0x2b,0xc0,0x18,0x2b,0xb8,0x37,0x8c,0x05,0x6a,0xe1,0xb5,0x82, + 0x1f,0x5c,0x47,0xf9,0xd1,0x48,0x59,0xc7,0x24,0xea,0x84,0xb6,0x45,0x1d,0x9f,0x8c, + 0x06,0xea,0x10,0x5b,0xb2,0x44,0x66,0xe7,0xb2,0x46,0x7d,0x52,0xa0,0x4b,0xcf,0x3c, + 0xd7,0x08,0xb2,0xbb,0x95,0x85,0x8b,0x38,0x18,0x87,0x6e,0xa3,0x89,0x46,0xe9,0x3f, + 0xfe,0xc7,0xbf,0x35,0x4c,0x61,0x31,0x19,0xe1,0xad,0x25,0xee,0xa5,0x56,0x0a,0xc9, + 0xb3,0xcf,0x7d,0x30,0xb6,0x29,0x21,0xf5,0x39,0xb7,0xe7,0x4e,0x46,0x1c,0x5b,0x74, + 0x89,0x58,0x82,0x8f,0x0e,0xba,0xda,0x29,0xd1,0xf3,0x84,0x3b,0xcc,0xf4,0xae,0x8e, + 0xde,0x46,0xc0,0x5a,0xdc,0x95,0x0d,0x88,0x33,0x47,0x58,0x48,0xa8,0xac,0x24,0x56, + 0x27,0x23,0x25,0x4f,0x29,0xcf,0x08,0x96,0x9a,0x48,0x3a,0xc6,0xd3,0x19,0xa2,0x3b, + 0x5b,0x14,0x24,0x8b,0x41,0xbc,0x03,0xc8,0x7f,0x36,0x74,0xa8,0x63,0xf8,0xfb,0xf1, + 0x90,0xb2,0xc0,0x88,0x45,0x30,0x1c,0xba,0x74,0xe6,0x4e,0x54,0x9f,0x71,0x37,0xc0, + 0x1e,0x85,0x2f,0x07,0xea,0x9d,0x9b,0x27,0x3a,0x44,0x37,0x4c,0x89,0x87,0x76,0xb3, + 0x31,0x32,0x1c,0x00,0x49,0x95,0xf9,0x02,0xa3,0xad,0x4e,0x70,0x21,0x69,0x83,0x9c, + 0x76,0x00,0x15,0x51,0xd0,0xb4,0xc1,0xcc,0xd2,0xcc,0x91,0x30,0x3a,0xce,0x52,0x50, + 0x57,0x8a,0x1b,0x56,0x87,0x28,0xaf,0x05,0x33,0x3a,0xd7,0x48,0x0a,0xe2,0xc5,0x2c, + 0xb0,0x2a,0x9a,0xc7,0x5a,0x75,0xc4,0xb0,0xa8,0xcc,0x41,0xca,0x1d,0x3c,0x1c,0xb7, + 0xab,0x67,0xd2,0x0d,0xa8,0xe6,0xbf,0x64,0x17,0xa3,0xc0,0xfd,0xd3,0x6d,0xe2,0xec, + 0xef,0x43,0xfe,0x9d,0xef,0x88,0xdf,0x7b,0xce,0x23,0x0c,0xe0,0x13,0x09,0xfa,0x77, + 0x70,0xe7,0xfd,0x0b,0x39,0x2a,0xd4,0xe1,0x06,0x7e,0x58,0xc2,0xb9,0x04,0xd2,0xe2, + 0x47,0x26,0x1a,0x18,0x2c,0x40,0x0f,0x43,0x70,0x22,0xfd,0xc6,0x44,0x7c,0x0d,0x82, + 0x93,0xe0,0x17,0x26,0x88,0xc3,0x0f,0x94,0xc4,0x91,0x69,0x1e,0x1e,0x78,0xc6,0x57, + 0x21,0x04,0x3c,0xfc,0x8d,0x89,0xf4,0x34,0x04,0xa7,0xe1,0x4f,0x4c,0x8a,0x55,0xd5, + 0x98,0x2b,0xc2,0x9f,0x83,0x3e,0x63,0xc7,0x48,0xc5,0xe2,0x66,0xb2,0xac,0x0e,0x5d, + 0x30,0x93,0xa9,0x47,0x7c,0x5c,0x41,0x4d,0x2c,0x06,0x86,0xbd,0x4c,0x2e,0xf9,0x70, + 0x9a,0x9e,0x5f,0xde,0x6e,0xb3,0x63,0x51,0x41,0x27,0xe7,0x15,0x8a,0x99,0xe6,0x87, + 0x38,0xdd,0xa2,0xf0,0x25,0xf2,0xc5,0xa1,0xc0,0x7d,0x71,0x28,0x50,0xef,0xc1,0x09, + 0xc6,0x20,0xce,0x0a,0x8a,0x8d,0x38,0x23,0xd1,0xdc,0x8d,0x2b,0x9d,0x27,0x4d,0x26, + 0x9f,0x48,0x66,0x94,0xfb,0x8b,0x39,0xa2,0xc3,0xe2,0x68,0x97,0xdd,0xa5,0xd2,0x30, + 0x81,0xc1,0x70,0x5f,0xf4,0xb9,0x43,0xee,0x86,0x3e,0x3f,0xe8,0xdc,0x69,0x66,0x8d, + 0x0a,0x02,0xc7,0xc4,0x30,0xa3,0x7a,0xf0,0x80,0x5d,0x54,0x72,0x5f,0x53,0x21,0x43, + 0xec,0xf3,0x71,0x98,0x40,0xd9,0x98,0x38,0x4e,0x73,0xdd,0x6d,0xe5,0xe6,0xa2,0x3d, + 0x42,0x05,0x00,0x3e,0x64,0x6d,0xa7,0xb4,0x03,0xa8,0x0a,0x88,0xb1,0x69,0x3a,0x67, + 0x6d,0x57,0x1d,0xf3,0x34,0xbb,0x22,0xca,0x8a,0x13,0x1c,0x65,0xf4,0xf8,0x25,0xbc, + 0x94,0xb4,0xff,0xa8,0x10,0xc8,0xc6,0x3a,0x4a,0xbb,0xe5,0x43,0x48,0x43,0xe3,0x0c, + 0xa8,0x61,0x64,0x99,0xf8,0x24,0x16,0xce,0x4a,0x10,0xcb,0x21,0x69,0x2f,0x93,0x84, + 0x31,0x30,0x3d,0x2c,0x19,0x69,0xb2,0x9c,0xaf,0xdb,0xd0,0xa5,0x05,0x5c,0x8c,0xba, + 0x30,0x0e,0x8d,0x96,0xbb,0x8e,0x9a,0x7f,0x21,0x24,0xa7,0x32,0xd8,0xd0,0xd6,0x44, + 0x16,0x49,0x9b,0xdb,0x90,0xc5,0x67,0x0c,0xda,0xda,0xfb,0xcb,0x05,0x9e,0x3e,0xed, + 0x61,0x01,0xda,0xfb,0xd6,0x42,0x65,0xeb,0xbf,0xb5,0xda,0x7f,0xda,0xf2,0x71,0x8f, + 0x00,0x75,0x04,0xda,0x2f,0xa8,0x8a,0x21,0x68,0xcf,0x50,0xeb,0xdf,0x7d,0x78,0x4f, + 0x9a,0x28,0xbd,0x82,0x64,0x3d,0x76,0xe3,0x9c,0xbc,0xc2,0xe7,0x6b,0x9a,0x1f,0xc3, + 0x8b,0x65,0x1c,0x80,0x32,0xc1,0x6f,0xdc,0x38,0xea,0x91,0x1b,0x47,0x95,0x35,0x95, + 0x35,0xf4,0xa4,0x2a,0x65,0x8d,0xdb,0xc0,0xa3,0xcb,0x82,0x3b,0x4b,0xe6,0x89,0x0a, + 0x89,0xd2,0x28,0x1e,0xa3,0x3a,0xe2,0x3b,0xaf,0x29,0x0d,0x25,0xf8,0xf1,0x6b,0x4a, + 0x1b,0x09,0x15,0x87,0x8c,0xb7,0x8f,0x60,0xea,0xb8,0xac,0xda,0x7c,0x82,0xb2,0x9e, + 0xc8,0x03,0x6b,0x3d,0x26,0xd6,0x0e,0x95,0x4f,0xf9,0xc2,0x7a,0x23,0x67,0x55,0xa5, + 0x94,0xbc,0xdb,0x38,0x76,0xa9,0x45,0x15,0x59,0xfa,0x39,0xd4,0x80,0x88,0x5d,0xb9, + 0x74,0xa0,0xac,0xb6,0x21,0x62,0x93,0x22,0x07,0x45,0xd1,0x27,0xa1,0xfd,0xc9,0x13, + 0xef,0xe2,0x64,0x1b,0xde,0xb8,0x81,0x57,0x9a,0x9c,0xa3,0x2e,0xaf,0x84,0xaf,0xc6, + 0xcc,0x8d,0xad,0x04,0x82,0x32,0x43,0x0a,0x0b,0xfa,0x49,0x7b,0x2c,0x7a,0x2f,0x69, + 0xd3,0x01,0x16,0xdf,0x25,0x6e,0x60,0x92,0x02,0x8c,0x6d,0x8e,0xc2,0x8b,0x28,0x39, + 0x86,0x9a,0x18,0x38,0x47,0x29,0x73,0x50,0xd7,0x4f,0x53,0x17,0x90,0xea,0x3b,0x37, + 0x32,0x11,0xbb,0xc6,0x89,0x84,0x69,0x23,0x87,0x07,0xcc,0x3b,0x21,0x94,0x80,0xa6, + 0xd7,0x01,0x3e,0x4c,0x81,0xd4,0x40,0x0f,0x04,0x36,0x8c,0x91,0x93,0x5e,0x42,0x43, + 0xf1,0x85,0x06,0x09,0xe6,0x18,0xdd,0x0f,0xf0,0x15,0x08,0x14,0x82,0x20,0xd4,0x0b, + 0xcf,0xb1,0xe1,0x35,0x26,0x2f,0x1a,0x06,0x38,0x56,0x41,0x9f,0x7a,0xe5,0x66,0x1b, + 0xaf,0xbf,0x94,0x8b,0x09,0x12,0x21,0x49,0x5a,0x6d,0x9b,0xdf,0xd4,0x68,0xd8,0x53, + 0x31,0xfb,0x62,0xce,0x05,0xb9,0x97,0xf4,0x3c,0x5c,0xdb,0xf3,0x00,0x9a,0x2f,0x2d, + 0x13,0x39,0x11,0xeb,0x51,0x7e,0x4d,0x7d,0x2a,0xa1,0xfc,0x5a,0x77,0xb4,0x8a,0xf2, + 0x5a,0xb4,0x5e,0x57,0x87,0x56,0x87,0x58,0xbc,0x42,0xbf,0x32,0x33,0x8a,0x99,0x18, + 0x0a,0x38,0xa1,0xec,0x3e,0xe8,0xaa,0x9f,0xec,0x0a,0x48,0x8c,0x3e,0x74,0x34,0x25, + 0xd5,0xc2,0xad,0x5b,0x46,0x83,0xfb,0x50,0xac,0x01,0x0d,0x30,0xbe,0x92,0x7c,0xeb, + 0x8b,0x69,0xc4,0x0a,0xcd,0x6f,0x11,0xa2,0x33,0x95,0x85,0x8c,0x0a,0xdc,0xa1,0xed, + 0x44,0x11,0xe8,0x0c,0x69,0xc6,0x69,0x49,0xb5,0xaa,0xec,0x35,0x8e,0xaa,0x09,0xb0, + 0x80,0xce,0x8e,0x57,0x33,0x84,0x8d,0xea,0xa2,0x03,0xa2,0x41,0xc0,0xa4,0x7a,0xab, + 0xe3,0xba,0x57,0xe8,0xea,0xf9,0x04,0x6c,0xdc,0x68,0xb3,0xfe,0xa8,0x81,0x59,0xc0, + 0xbe,0x1e,0x03,0xb9,0x84,0x82,0x8e,0x11,0xfd,0x1b,0x55,0xec,0x44,0xa0,0x6e,0x8f, + 0xae,0x68,0x81,0xd7,0xe4,0xba,0x78,0x2c,0xa0,0xe3,0x19,0x65,0xee,0x36,0xd6,0x63, + 0xb8,0x0a,0xa6,0x9a,0x37,0x8e,0xd3,0x3c,0xb4,0xf0,0x81,0x08,0x64,0x9e,0x52,0x77, + 0x78,0x87,0xfc,0x50,0x91,0xa5,0x2a,0x5f,0x91,0x9c,0x36,0x95,0x26,0x85,0xba,0x6b, + 0xbc,0x59,0x05,0x57,0x22,0xc9,0x50,0x49,0xd3,0xd7,0x9d,0x52,0x2a,0x90,0xa5,0xb2, + 0x05,0x4f,0xd3,0x85,0x40,0x15,0xc0,0x45,0x1d,0x0d,0xc6,0xfa,0x22,0x2d,0x8c,0x34, + 0xba,0xf4,0x80,0x4b,0x47,0xf9,0x09,0x39,0x31,0x23,0xda,0xee,0xce,0xc3,0x58,0x91, + 0x0f,0x91,0x13,0xe6,0x50,0x89,0xe7,0x40,0x0a,0x18,0x16,0x80,0x1b,0x26,0xd8,0xa6, + 0x3c,0x06,0x04,0xd2,0x06,0xd5,0x62,0x48,0x27,0x52,0xa9,0xd8,0x05,0x56,0xe1,0x76, + 0xab,0xbd,0x83,0x10,0x24,0x48,0x20,0x2b,0x74,0x0b,0x41,0xf2,0x93,0x7a,0x01,0x83, + 0x5a,0xb4,0x4b,0xa0,0xbc,0xb2,0x3c,0xbb,0xc6,0xeb,0x66,0x60,0xa8,0x84,0x8c,0xa6, + 0x43,0x9f,0x38,0xca,0x26,0xa5,0xd6,0x2f,0x45,0xfe,0xaa,0x11,0x58,0xb2,0x43,0x1d, + 0x8a,0x9e,0xe8,0x7c,0x95,0xd7,0x89,0xb6,0xcb,0xdc,0x4e,0xf4,0x61,0x05,0xab,0x5b, + 0x0d,0xae,0x53,0x0f,0xae,0xb3,0x0a,0x9c,0xd8,0x0e,0xd2,0x2a,0xbf,0x90,0x29,0x79, + 0x58,0xa0,0x70,0x3d,0x0a,0xd0,0x9e,0xec,0xf9,0xce,0xce,0xb9,0xf7,0xbd,0x43,0x29, + 0x75,0xe9,0xde,0x43,0x29,0xa1,0xe0,0xab,0x43,0xa9,0xf6,0xfb,0x5c,0x0c,0x50,0xe8, + 0xf2,0xa6,0xa2,0x10,0xdf,0x98,0xea,0x2c,0x11,0xfa,0x63,0x9c,0x33,0x43,0xaf,0x15, + 0x34,0x40,0x89,0xe2,0xb7,0x52,0xe9,0xe9,0x1e,0x10,0x93,0x48,0xbc,0x55,0x1c,0x3b, + 0xae,0x55,0x6b,0xba,0x5f,0xe3,0x80,0xd7,0x78,0x9b,0x10,0x0c,0x35,0x2e,0xf3,0x1f, + 0x22,0x51,0x2b,0xc3,0xd4,0x3c,0x70,0xb4,0x44,0x81,0x2b,0x58,0xb0,0xa9,0xcf,0x95, + 0x7b,0x0a,0x6b,0xee,0xeb,0x0a,0xd8,0x18,0x0f,0x1d,0xdf,0x90,0xc8,0x3f,0x3b,0x93, + 0x33,0x8e,0xbb,0x3e,0x72,0x9a,0xe9,0xb7,0x98,0x3a,0x95,0x8e,0xbf,0xcf,0xad,0x2e, + 0xd1,0xc2,0x1b,0xa3,0x13,0x8a,0xbc,0xbf,0x5b,0x18,0xc9,0x3c,0xbe,0xb1,0x3e,0xf1, + 0x17,0xff,0x6b,0x8f,0xf4,0x5b,0xab,0xde,0x89,0xc8,0x68,0xd2,0xe9,0x4f,0x83,0x8b, + 0xdc,0xb5,0x0d,0x07,0x23,0x7d,0x85,0x86,0x7d,0x3f,0xd6,0x5b,0x04,0x17,0xec,0x9a, + 0x87,0x1f,0xf2,0x28,0xda,0x1a,0xa6,0x0c,0xa5,0x6a,0xc2,0x08,0x81,0x6d,0xe2,0xc1, + 0x5f,0xc9,0x42,0x57,0x96,0x23,0x3e,0x08,0x9d,0x6e,0xf8,0x06,0x5f,0x14,0xa5,0x7f, + 0x5b,0x86,0xd9,0xcd,0x09,0xed,0x65,0xa4,0x99,0xdb,0x90,0x6f,0x25,0x55,0xe2,0x9b, + 0x1a,0x3f,0x35,0x0c,0x51,0xf6,0xb8,0xc4,0x8e,0x1b,0x8e,0x38,0x54,0x27,0x62,0xa4, + 0xd0,0x0e,0xf9,0x31,0xbe,0x59,0xcc,0xdc,0x9a,0x36,0xf8,0x32,0x56,0x3c,0x26,0xce, + 0x98,0xe9,0x80,0x5e,0xd9,0x81,0x89,0x90,0x9c,0x1c,0xab,0xb0,0xe7,0x15,0xd5,0x2d, + 0x5b,0x39,0x54,0x0b,0xae,0x22,0x9b,0x6c,0x83,0x06,0xef,0xcc,0x02,0x30,0xfc,0x2e, + 0x24,0x93,0x27,0x5e,0x4a,0xe6,0x29,0xf7,0xad,0x6e,0xa4,0x20,0xb9,0x55,0x32,0x04, + 0x84,0x08,0x83,0x4a,0xdd,0x8e,0xae,0x54,0x3e,0xc6,0xa9,0x46,0xc9,0x43,0x92,0x03, + 0x02,0x29,0x8f,0x8e,0x6e,0x7c,0x4c,0xcc,0xf0,0xf5,0xdd,0xc3,0xa3,0x4d,0x0c,0xd6, + 0xf4,0x68,0xe3,0x49,0xc4,0x2b,0xe1,0x99,0x46,0x48,0xb6,0xb7,0x5b,0xe4,0x0c,0xc4, + 0x9b,0xbe,0xda,0x51,0x3d,0x13,0x19,0xca,0xd0,0xa4,0xe3,0xc0,0xab,0xf6,0x7a,0x2e, + 0xee,0xe5,0xc6,0xbe,0xa8,0x58,0x82,0x38,0x4c,0xae,0x6e,0x31,0x07,0x1a,0x37,0x26, + 0x5b,0x9c,0xa1,0xb5,0x23,0x93,0x0e,0xe9,0xee,0xc4,0x06,0xe1,0xba,0x81,0x89,0x16, + 0x5f,0xab,0xbd,0xf1,0xab,0xb3,0x5b,0x52,0xca,0x96,0xa4,0x3a,0x6c,0x61,0x86,0xe1, + 0x45,0x5c,0xb1,0x23,0x3b,0x30,0xec,0x8c,0x2e,0xcc,0x25,0x5e,0x55,0xe7,0x5e,0xc1, + 0xd4,0xee,0x68,0xe5,0x85,0x90,0x06,0x93,0x0d,0xff,0x93,0x33,0x77,0x26,0xb2,0x89, + 0x47,0xd2,0x02,0x6b,0x7b,0xce,0x85,0x61,0x74,0xa0,0x2d,0x47,0x61,0xde,0x17,0x86, + 0xd1,0xc1,0xba,0xdd,0x85,0xc5,0x65,0xc9,0x4d,0x9b,0x05,0x7a,0x07,0x57,0x91,0xcf, + 0x2c,0x2a,0x4e,0x31,0xc0,0x94,0x2a,0xaa,0xa1,0xd1,0x2b,0x66,0xe8,0xfe,0xcd,0xd1, + 0x2e,0x69,0xb5,0x5a,0x9a,0xa3,0x50,0x78,0xf8,0x2f,0x92,0x95,0x44,0x9e,0xda,0x2d, + 0x17,0x09,0x44,0x2e,0xc8,0x08,0xce,0x2d,0x3e,0x1c,0x21,0x03,0x26,0xb0,0xeb,0xfc, + 0x61,0xe7,0x83,0xff,0x04,0x5a,0x61,0x92,0x47,0x93,0xf0,0x2f,0x08,0x0e,0x4c,0x03, + 0x68,0xad,0xc9,0xbb,0x81,0x60,0x81,0x5f,0x83,0x92,0x0e,0x0d,0x3d,0xa6,0x04,0xbb, + 0xfc,0x5f,0x11,0x18,0x94,0xa7,0xb6,0x74,0x8d,0x1b,0xa8,0x41,0x4d,0xc9,0x3a,0xe4, + 0x4c,0x97,0x4d,0xc0,0x82,0x13,0xbf,0xff,0xea,0x61,0x08,0x66,0x11,0x25,0xcb,0x50, + 0xc3,0xfd,0x8b,0xe9,0x74,0xc2,0xea,0xbe,0xe3,0x0a,0x85,0x0f,0xdd,0xe4,0xdb,0x74, + 0x40,0xec,0xaf,0x35,0x85,0x4c,0x39,0x4f,0x05,0x25,0xcc,0x04,0x16,0xcf,0x5b,0x1a, + 0x19,0x10,0x34,0x49,0x6c,0x68,0xc5,0xa7,0xe4,0x8f,0x3c,0xe0,0x21,0xab,0x94,0x90, + 0x3c,0x50,0x52,0x22,0x46,0x25,0xf9,0x81,0xdc,0x75,0xc4,0xb0,0x52,0x4b,0xff,0x32, + 0x81,0x93,0x18,0xc2,0x0a,0x3c,0xf6,0xa1,0x23,0x14,0x15,0xe8,0x27,0x37,0xf3,0xc2, + 0x2a,0x00,0xcd,0x89,0xbe,0x42,0x01,0x46,0x0e,0x77,0x11,0x72,0x09,0x9c,0x72,0x30, + 0xde,0x12,0x31,0xf4,0xf1,0x80,0xf5,0x3c,0x9d,0xe0,0xa5,0x13,0x45,0xdc,0x40,0xf7, + 0xa7,0xac,0xf3,0xf1,0x5e,0x75,0x32,0xab,0x8e,0x6a,0xe7,0xc5,0x9a,0x3a,0xa3,0x15, + 0xed,0xac,0xad,0x53,0x6a,0x67,0x4d,0xd1,0x12,0xf4,0x35,0x25,0x6d,0x98,0x6b,0x87, + 0x69,0x95,0x5c,0xdb,0x51,0x55,0x52,0x10,0xbb,0xa6,0x72,0x9e,0x20,0x9e,0x40,0x83, + 0x92,0xd7,0x00,0x43,0xc6,0xd4,0x70,0xcc,0x9b,0x86,0x2a,0xce,0x71,0xdc,0x5f,0xfe, + 0x80,0x91,0x94,0x22,0x0c,0xe2,0x3e,0x6a,0x0a,0xbb,0xf9,0x95,0x13,0x58,0x46,0xff, + 0xd2,0x85,0x80,0x32,0xb6,0xcf,0x72,0xfa,0xb2,0xcf,0xbe,0xe4,0xbd,0x65,0x9f,0x3d, + 0xf0,0x24,0xdb,0x61,0xcc,0xf2,0xd4,0xde,0x43,0x50,0xf1,0xe1,0xd5,0xf8,0x0b,0x4b, + 0x0b,0x4b,0x8b,0x63,0x0e,0x24,0x70,0xc3,0x4b,0x71,0x8f,0x02,0xf7,0x38,0x03,0xf9, + 0x54,0x09,0x6e,0x78,0x81,0xb2,0x06,0x94,0x7a,0x0e,0x44,0x26,0x11,0x66,0x5c,0x4d, + 0xe5,0x5c,0xf7,0x1d,0x82,0x22,0x76,0xa2,0xff,0x02,0x6b,0x02,0xc1,0x90,0xfa,0x01, + 0xec,0xca,0xce,0xfd,0xab,0xcc,0x45,0xbd,0xe1,0xae,0xac,0x0f,0x5c,0x7c,0x48,0x5e, + 0x47,0x85,0xd2,0x07,0x80,0xc5,0xe7,0x86,0xe0,0xae,0x46,0xaf,0x50,0xa4,0x08,0x1d, + 0x79,0xc0,0x23,0xb6,0x62,0x2c,0xc6,0x04,0x88,0xe1,0x8c,0xbf,0x32,0x14,0xc1,0x2e, + 0x10,0x18,0xe2,0x9b,0xbb,0x4b,0xb1,0x94,0x93,0x53,0x4c,0x0c,0xf3,0xb3,0xb6,0xba, + 0xe6,0xad,0xc8,0x5d,0x2a,0x59,0x3f,0x5c,0x33,0xcb,0x1c,0x2b,0xe8,0x4e,0x0c,0x77, + 0x91,0xd1,0xdf,0x23,0xbe,0xb9,0x50,0x1c,0x13,0xbf,0x75,0x16,0x78,0xbc,0x14,0x0f, + 0xc7,0xd2,0x04,0x3a,0xac,0x2a,0x69,0x8c,0xe8,0x09,0xf1,0xa5,0xe0,0xb3,0xf7,0xda, + 0x6b,0x22,0x7b,0x78,0x86,0x81,0x5e,0x92,0x7a,0xe4,0xdc,0xa2,0xdb,0x8e,0xaf,0x78, + 0xad,0x52,0x84,0xda,0xf7,0xa6,0xb3,0x90,0x96,0xac,0x15,0x54,0x0c,0x69,0x9a,0x8a, + 0x05,0x6a,0x68,0x51,0x28,0x39,0x6a,0xc6,0x17,0xcb,0x4c,0xd6,0x6e,0x2f,0x28,0x32, + 0x06,0xa4,0xf3,0x0c,0xcf,0x85,0xd2,0x04,0xfe,0xa5,0x8f,0xdd,0xa1,0x9f,0x7f,0xed, + 0x3b,0x37,0xbc,0x77,0xa2,0xd0,0x61,0xc7,0x15,0x84,0xb2,0xb3,0xa0,0x0c,0x41,0xfb, + 0xa1,0x2b,0x50,0xcb,0x89,0x6f,0xb0,0x91,0x95,0xc8,0x56,0x23,0x3b,0x79,0xfb,0x01, + 0x43,0x61,0x76,0xee,0x87,0x46,0xe2,0x10,0xdf,0x83,0x46,0xde,0x83,0x86,0x21,0xeb, + 0x45,0x56,0x8b,0xd3,0x4a,0xdc,0x04,0xbf,0x92,0x4b,0x27,0x3a,0x22,0x3a,0xad,0x20, + 0x2e,0xbb,0xc4,0x73,0x0e,0x88,0x4d,0x64,0x5d,0x7c,0xda,0x81,0xb9,0xd7,0x73,0xf1, + 0x63,0xa3,0xcf,0x53,0xa1,0xf3,0x51,0xde,0xe0,0x01,0x72,0x2b,0x71,0x84,0x77,0x01, + 0xe3,0xb1,0x9e,0xab,0x3c,0x6c,0x66,0x21,0xc6,0xa9,0xd4,0x55,0xcd,0xea,0xaa,0xc6, + 0xa2,0x6a,0x98,0x5f,0xad,0xae,0x5a,0xd7,0xa8,0x68,0x33,0x54,0xd5,0xe8,0xcc,0x45, + 0x2e,0xbf,0xcc,0xa3,0x9f,0x2b,0x14,0x2d,0xc4,0x24,0xfd,0x3c,0x37,0x50,0xdb,0x42, + 0x5a,0x91,0xdb,0x7e,0xa4,0x52,0xcc,0x6e,0x16,0x69,0x41,0x81,0x17,0x94,0xcf,0x14, + 0x86,0xd3,0x64,0x26,0xfc,0x15,0x2f,0xe7,0x45,0x32,0xb0,0xe9,0xb8,0xb2,0x2d,0xbb, + 0x75,0x56,0x8c,0xce,0xc5,0x71,0x26,0xaa,0x8d,0x03,0x82,0x05,0xad,0x45,0xc2,0x4a, + 0xe5,0x4f,0x75,0xb7,0x72,0xf7,0x00,0xe6,0xe0,0xc2,0x67,0x32,0xe7,0x0b,0x42,0x45, + 0xbc,0xac,0x56,0x0f,0x55,0xc8,0xac,0xd6,0x0d,0x7d,0xda,0x58,0x65,0xcd,0x8f,0x37, + 0x55,0x59,0x33,0x14,0x2c,0x43,0x5d,0xbb,0xfb,0x91,0xcc,0x86,0x8e,0xb8,0x8c,0x58, + 0x75,0xdb,0xa0,0x18,0x23,0xdc,0x60,0x16,0x91,0xa5,0x58,0x46,0x18,0x47,0xac,0xe0, + 0x85,0x72,0x1c,0xcd,0xc2,0xf1,0x6a,0xf4,0x34,0xba,0xd4,0xdd,0x64,0xbc,0x0b,0x81, + 0x40,0xa9,0x60,0x66,0xda,0xc1,0xa2,0x00,0x8c,0x60,0xaf,0x8e,0x1c,0x35,0x4a,0xe0, + 0x40,0x09,0x96,0xda,0xcc,0x64,0x18,0x43,0x03,0x06,0x74,0x8c,0x8a,0xe8,0x02,0xc6, + 0x6e,0x27,0x34,0xad,0x61,0xc9,0x92,0xbe,0x81,0x5b,0xd0,0xed,0x44,0xcb,0x3f,0xd2, + 0x85,0x9b,0x7c,0x15,0xb8,0xed,0x9f,0x53,0xd6,0x27,0xca,0xf1,0x4b,0xd0,0xb8,0x6f, + 0xd0,0xa6,0xc5,0x1f,0x26,0x15,0x69,0xdf,0x7d,0xa0,0x3b,0x18,0x49,0x54,0x9a,0xf2, + 0x7c,0xf5,0xce,0xae,0xb5,0x33,0x0b,0x55,0xe9,0x15,0x65,0x09,0x00,0xba,0x3a,0xb9, + 0xac,0xec,0xea,0x1a,0x40,0x1f,0x53,0xf9,0x3a,0x15,0x41,0x1f,0x90,0xb7,0xc8,0xc0, + 0x38,0x99,0x14,0x63,0x58,0xbb,0x85,0xe1,0xf5,0x81,0xbe,0x18,0x09,0xe5,0x5e,0x7b, + 0xbe,0x41,0x93,0x78,0x6d,0x30,0x50,0x9c,0x95,0xd4,0x34,0xb1,0xeb,0x0d,0x56,0x36, + 0x9f,0x99,0xcd,0xd7,0xec,0x56,0x0b,0x5a,0x50,0x7d,0xaa,0x34,0xcf,0xb1,0x57,0x56, + 0xf3,0xe5,0xc9,0x5d,0xd3,0x7c,0xa1,0x9a,0xbf,0xf7,0x66,0x39,0xd3,0xc1,0xea,0x8d, + 0xf2,0x55,0x4d,0x8d,0xcc,0xa6,0x56,0x53,0x82,0x05,0x5e,0xec,0xef,0x1b,0xf7,0x2e, + 0x7b,0x52,0x33,0x10,0x3b,0xd5,0xec,0x2d,0xe2,0x68,0x59,0xd2,0xca,0x12,0xb0,0x54, + 0xe9,0x0d,0x40,0xfd,0x79,0x12,0x16,0x78,0x2e,0x2f,0xb7,0x05,0x60,0x06,0xd2,0x39, + 0xc8,0x29,0x3a,0xd1,0x38,0x41,0x65,0xc8,0x2b,0xc9,0x14,0x45,0x8c,0x58,0x7a,0x49, + 0x07,0x0c,0x15,0xc3,0x55,0x92,0x9c,0x75,0x65,0xac,0x48,0x65,0x3c,0x1d,0x2c,0x4d, + 0x61,0x3e,0xf7,0x11,0xaf,0x4b,0x3c,0x89,0x68,0x74,0xe8,0x9e,0xca,0x0d,0x28,0x76, + 0x63,0xf2,0xe2,0xd9,0x55,0x45,0x24,0x25,0x3d,0x90,0x65,0xc4,0xf1,0x52,0x82,0x0e, + 0x06,0x1d,0x73,0x20,0xaf,0x51,0x6c,0x65,0x24,0xef,0xa1,0x0a,0xe5,0xdd,0xde,0xa1, + 0x50,0x5e,0x64,0x79,0x45,0x9f,0xfc,0x6e,0x23,0x0c,0xe9,0x95,0xb1,0xbc,0x87,0x22, + 0x96,0xf7,0xf0,0xb5,0x0e,0x31,0xa7,0x06,0x8e,0x6a,0x7d,0x18,0x28,0xf5,0xc4,0xd4, + 0x7d,0x93,0xa7,0xcb,0x0a,0x15,0x35,0x07,0x50,0x17,0x2b,0xda,0xed,0xec,0x0d,0x36, + 0xcc,0x42,0xd2,0x01,0xa6,0x63,0x3c,0x07,0x16,0x10,0x3b,0x62,0x53,0xf8,0xc2,0xc6, + 0xf7,0x0d,0xd8,0x3c,0x34,0x02,0x36,0x0f,0x79,0xe7,0xfb,0x10,0x43,0x36,0x19,0x27, + 0x22,0x64,0xf3,0x90,0x37,0xcc,0x0f,0xad,0xa0,0xcd,0xb1,0x88,0xda,0x5c,0x12,0x29, + 0x1e,0xb2,0x4b,0x52,0x5f,0xcd,0x0e,0x3d,0x39,0xfc,0xa4,0xc6,0x6e,0x05,0x4f,0x1e, + 0xae,0x8c,0x9e,0x3c,0x7c,0x5d,0x8a,0xef,0x28,0xa3,0xbb,0x26,0xc0,0xa3,0xa2,0x74, + 0x80,0x1e,0x7b,0x6e,0x46,0x7f,0x88,0xae,0x1d,0xa2,0xb7,0xf4,0x50,0xc5,0x7f,0x88, + 0x4e,0x1c,0xbe,0xe6,0xd4,0x91,0xc0,0xd9,0x8a,0x08,0x90,0x71,0x75,0xc7,0x80,0xa2, + 0xdc,0x8c,0x1c,0xaa,0x74,0x48,0xdb,0x97,0x08,0x1b,0xfe,0xfd,0x84,0x94,0x25,0x2b, + 0xd7,0x6f,0x58,0x8f,0x6b,0x76,0x15,0xc6,0x25,0x8f,0xbe,0xe8,0x7f,0x1b,0xb7,0x5b, + 0x44,0xaf,0xf9,0xf7,0x27,0xde,0xf0,0x38,0x7c,0x2d,0xde,0xa0,0x18,0xaf,0x8d,0x2f, + 0x91,0x05,0xac,0x7d,0xa5,0xb1,0xb1,0xb3,0x82,0x13,0x4a,0x05,0xb9,0x19,0x99,0x2b, + 0x3c,0x7e,0xa5,0x5c,0x42,0x9d,0x2c,0x62,0x6c,0x8b,0xaf,0x6d,0x81,0xb1,0x83,0xc4, + 0x40,0x90,0x4a,0x2d,0xa8,0x79,0xaa,0x29,0x52,0x6a,0xa1,0x76,0x87,0x66,0x5c,0xb3, + 0x97,0xa4,0xd2,0x4a,0x8e,0xd8,0x4a,0x3f,0xd5,0x8e,0x6c,0xb8,0xc8,0xa5,0xb4,0x56, + 0xf7,0x27,0xeb,0xaf,0xca,0x95,0xb8,0xb2,0xe2,0x7b,0xda,0xfa,0xc4,0xca,0xcf,0xf1, + 0x8e,0x1d,0xd0,0x94,0x77,0x77,0x56,0x38,0x78,0xdf,0xaf,0xf0,0xef,0xbe,0xff,0x46, + 0xf7,0x2e,0xcd,0x08,0xbe,0x81,0x80,0x0e,0xdd,0x31,0x47,0xf4,0x94,0x9c,0xb8,0x63, + 0x3b,0x78,0x44,0xf9,0x71,0xc7,0x76,0xfc,0x08,0xbb,0x72,0xc7,0xd5,0x0d,0xd0,0x7b, + 0xa0,0xa1,0xf2,0x52,0xc9,0xd8,0x42,0xdf,0x8a,0x09,0xa3,0x0d,0x6d,0xcd,0xa6,0xf4, + 0xce,0x24,0xdd,0x58,0x7f,0x4f,0x52,0xf5,0x04,0xa1,0xe0,0x65,0xc0,0x5e,0x1d,0xb9, + 0xd2,0xcd,0xc0,0xba,0xd4,0xa2,0x73,0xe3,0x7d,0x2b,0xc9,0x12,0x9c,0x8e,0xd1,0x52, + 0x67,0x55,0x4b,0x5d,0xb3,0xa5,0xee,0xca,0x96,0x2a,0xfb,0xb0,0xe3,0xda,0x2d,0xf9, + 0x6f,0x22,0xf4,0xd2,0x56,0xe4,0x0c,0xa6,0x74,0x26,0xb7,0x22,0xcd,0xdb,0x91,0x71, + 0xdb,0xd1,0xba,0x1f,0x99,0xb6,0x1e,0xc7,0xa5,0xad,0x47,0x0c,0x49,0xa2,0x77,0x1b, + 0xcc,0x5d,0x44,0x4c,0xbc,0xf1,0x56,0xef,0x25,0x8e,0x2b,0xdb,0x90,0xdf,0x05,0xe6, + 0x4e,0x89,0x97,0x8a,0x71,0xc6,0x9b,0x7e,0x2b,0x76,0xad,0x97,0xa2,0x2c,0xee,0x41, + 0x73,0xc1,0xfb,0x2c,0xa5,0x95,0xdb,0xd2,0xd5,0x99,0xda,0x36,0xe9,0xb2,0x8e,0x61, + 0xaf,0xa7,0xa0,0x65,0x1d,0xdb,0x34,0x09,0x68,0x29,0x89,0xe7,0xf2,0x8f,0x21,0x9c, + 0x6a,0x77,0x82,0x6c,0x5c,0x69,0xca,0x77,0x76,0x50,0x92,0x48,0x85,0x96,0x9e,0x24, + 0xea,0xca,0x0a,0x32,0xc4,0x46,0x6c,0xd5,0xd0,0x99,0x2d,0x8e,0x74,0x92,0x17,0xfc, + 0xa8,0x48,0x5d,0x91,0x20,0x23,0x9d,0x9e,0xdb,0xc9,0x24,0x91,0xd5,0xd9,0xbf,0xd2, + 0xf3,0x23,0x03,0x23,0xac,0x98,0x14,0x56,0x3e,0x99,0x60,0xc5,0xed,0x52,0x7d,0xba, + 0x74,0x4c,0x8e,0xda,0x0c,0xab,0x1c,0xaf,0x0c,0x9a,0x1c,0xaf,0x8a,0x69,0x1b,0x5b, + 0x01,0x68,0x2e,0xb7,0x8d,0xc7,0x2c,0xe9,0x07,0x5e,0x26,0x31,0x79,0xe1,0x70,0x6c, + 0xeb,0x3f,0xfe,0xfb,0xbf,0x39,0xb8,0x1b,0x2c,0x4e,0x49,0x42,0xef,0x75,0x88,0x6c, + 0x97,0x67,0x15,0xa5,0xd6,0xae,0x9a,0xdf,0x4e,0xb7,0xfc,0xae,0x8b,0x18,0x92,0xde, + 0xff,0xf9,0xe3,0x87,0x50,0xb9,0x28,0x39,0x50,0x27,0xb5,0xd0,0x6b,0x43,0xd1,0xb4, + 0x4a,0x35,0x91,0x6a,0x0f,0x45,0xd3,0x8d,0x57,0x46,0xe9,0xad,0x6b,0x01,0x77,0x94, + 0x28,0x8a,0x9d,0x6e,0x21,0x31,0x06,0x88,0xfb,0xc8,0x8c,0xbf,0x86,0xef,0x18,0x12, + 0xbd,0xbe,0x5d,0x05,0xdd,0x78,0x31,0xa6,0x8a,0xdd,0xa6,0x81,0xdd,0xbd,0xd5,0x55, + 0x8d,0xeb,0x00,0x6b,0xab,0x92,0x2e,0x66,0xde,0x2d,0xc4,0x4f,0x81,0x96,0xf7,0x24, + 0xc7,0x15,0x65,0x52,0x4a,0x32,0xf6,0x39,0xd0,0x75,0xc1,0xd5,0xeb,0xed,0xcb,0xc7, + 0x40,0xd5,0x56,0x15,0x9d,0x6c,0x22,0x8e,0xac,0xcf,0xe5,0x6a,0x87,0x97,0xe4,0x93, + 0xec,0xed,0x92,0x0c,0x12,0x5d,0x5d,0x74,0xbc,0xcf,0xb8,0xda,0x2f,0xc8,0x5c,0x93, + 0x97,0x93,0x6b,0x6e,0x41,0x37,0x24,0xca,0x3c,0xc5,0xd7,0x39,0xaf,0xdb,0x30,0x8e, + 0x0d,0x18,0xee,0x7a,0x6d,0x3b,0xf8,0x0a,0x07,0x9e,0x6d,0x73,0x7c,0x87,0x7f,0x5a, + 0x3a,0xef,0x6d,0xfb,0x6c,0x85,0x07,0xdf,0xb0,0xb1,0xd4,0x2c,0xac,0xd9,0x95,0x58, + 0xb5,0x25,0x61,0x3d,0x09,0x77,0x64,0x5b,0xc1,0x46,0x3f,0xfe,0x69,0x0f,0xf4,0x7d, + 0x30,0x53,0xe7,0x72,0x5e,0xd9,0xb9,0x6f,0xc7,0x98,0x92,0x72,0xea,0xa9,0x8a,0x95, + 0xf8,0x12,0x96,0x1c,0x9d,0x92,0xfb,0x64,0x49,0x42,0xeb,0x14,0xda,0xca,0x7d,0x1e, + 0xb6,0xf7,0xa8,0xfa,0xeb,0xfb,0x59,0x59,0x65,0x95,0x4f,0xbd,0x68,0xc7,0x73,0x4c, + 0x17,0x56,0x75,0x50,0x45,0xe4,0xb7,0x1a,0xa0,0xf4,0x72,0xc0,0x54,0x8c,0x8e,0x3a, + 0xbc,0x63,0x96,0xbd,0x5e,0xfc,0x78,0x83,0xca,0xee,0xaa,0x6c,0x7c,0xfd,0x81,0xe2, + 0xad,0xf9,0xca,0xff,0xfa,0x63,0xc4,0xf5,0xce,0x15,0x7e,0xa7,0x54,0xb9,0x58,0xec, + 0x58,0xa7,0xb2,0x77,0xe5,0x50,0x1c,0x56,0x76,0xbf,0x36,0x81,0x15,0x27,0x81,0xe1, + 0x44,0x29,0x39,0x75,0xee,0xee,0x49,0x40,0xa6,0x53,0xe5,0x50,0x1c,0x89,0xbe,0x57, + 0xc5,0xb2,0x5b,0x45,0x56,0x36,0xae,0x80,0xa8,0x20,0x62,0x75,0x6c,0x84,0x39,0xaf, + 0x84,0x74,0xeb,0x64,0xbf,0x61,0x11,0xe0,0x94,0xe0,0x0d,0x60,0x62,0x4e,0x3c,0xc5, + 0x6c,0xd4,0xc9,0x37,0x2b,0x14,0xea,0x00,0xef,0x23,0x69,0xf1,0xdb,0xd5,0x0d,0x4f, + 0xef,0xc4,0xe2,0xce,0x13,0xe1,0x3a,0x8c,0x57,0x5d,0x1e,0x07,0x39,0x78,0x5f,0x0d, + 0x54,0x6c,0x2d,0x88,0x9a,0xe8,0xf4,0x37,0xa4,0xae,0xbd,0x4a,0xae,0xbe,0xd6,0x1d, + 0x5f,0x6a,0xa5,0x9e,0x81,0xae,0xc4,0x7e,0x3d,0xb0,0xc6,0xdb,0x38,0x95,0xaf,0xd5, + 0x3b,0xcb,0x3c,0xe4,0x53,0xd3,0x62,0x67,0x07,0xaf,0x12,0xa1,0xf7,0xda,0xe9,0x7a, + 0xe7,0x75,0xb6,0x11,0xc2,0x21,0x62,0xc1,0xda,0xc5,0x55,0x4a,0xe7,0x9c,0x63,0x80, + 0x86,0x4a,0x7b,0xe0,0x30,0x02,0x9d,0x7c,0x16,0x2c,0x42,0x3e,0x3a,0xdd,0x10,0x17, + 0x6f,0xf1,0x23,0xdc,0xe6,0xdd,0x62,0x0f,0xdc,0x7b,0x5b,0xa6,0xf7,0x37,0xe1,0xe4, + 0x63,0x99,0xb2,0xcd,0xf7,0x15,0xa4,0x88,0xb3,0x3d,0x46,0xed,0xc4,0xbb,0xcf,0x44, + 0x3b,0x32,0xfc,0x4d,0xcf,0x37,0xa7,0x58,0xa2,0x43,0x94,0x1d,0x3a,0xea,0x4d,0x18, + 0xa6,0xf8,0x45,0x90,0xe9,0x8b,0x8c,0xf4,0x64,0x9e,0x9b,0x21,0x77,0x72,0xf3,0xf9, + 0x56,0xbe,0x79,0xc5,0xc0,0x7c,0x87,0xef,0x91,0xe9,0xe0,0x7b,0x57,0x3b,0xf8,0x54, + 0xd9,0x8e,0x2f,0x1d,0x47,0x22,0xf2,0x4e,0x6c,0xda,0x7e,0x37,0xb1,0x56,0xd7,0x23, + 0x5d,0x6f,0xa5,0x6f,0x8f,0x5e,0x35,0x3a,0x93,0x30,0xe5,0xbe,0x18,0x17,0xd2,0x0c, + 0xc6,0xb8,0x54,0xc1,0x5e,0x9a,0xb8,0xe9,0x9a,0x2d,0xc7,0x28,0xc5,0x26,0x87,0x31, + 0xde,0xae,0x2b,0xaa,0x7e,0x3b,0xf7,0x2b,0xf1,0x29,0xd3,0x0d,0xac,0x17,0x8a,0x7e, + 0xd8,0x1d,0xf0,0xf0,0xb5,0x21,0x53,0xe1,0x83,0xc9,0xaf,0x40,0xd0,0x6e,0xb3,0xe3, + 0x19,0x10,0xf0,0xc1,0xf6,0x6f,0x04,0xd0,0xb1,0x2e,0x20,0x31,0x73,0x26,0x61,0x8c, + 0x57,0x4f,0x7f,0x85,0x85,0xf1,0xd5,0x6d,0x9a,0xfa,0x81,0x57,0x95,0xa9,0xff,0x81, + 0x49,0xfd,0x56,0x88,0x87,0x20,0x72,0xd3,0xeb,0x5f,0x7a,0xfc,0xc6,0x2a,0xf6,0xd8, + 0xe1,0x3e,0x79,0x56,0xf5,0x12,0xfb,0x6c,0xad,0x7e,0x66,0xa7,0x56,0xa0,0xe9,0x9a, + 0x7f,0xe8,0xdc,0x96,0xe5,0x82,0xde,0x6e,0xb8,0x9f,0x50,0x30,0x4d,0x1a,0xeb,0xa5, + 0xca,0x3f,0xf1,0xfd,0x4f,0x78,0xad,0x62,0x4d,0x44,0x2d,0xb2,0x50,0x8a,0xaa,0xc5, + 0x0b,0x5d,0x1e,0x93,0xbf,0x52,0x54,0xc0,0xfb,0xc4,0x2b,0x15,0xd4,0xa1,0x20,0x23, + 0xfa,0x8e,0x0c,0x8b,0x7f,0xfc,0xeb,0xff,0x24,0x38,0x95,0x02,0xf8,0xd6,0x24,0x16, + 0x78,0xfd,0x85,0xee,0xae,0xa8,0x58,0x44,0xce,0xff,0xfd,0x3f,0x4e,0xd9,0x2a,0x42, + 0x96,0x48,0x19,0xf4,0x8c,0x45,0x4a,0xf5,0xa1,0x04,0x7e,0x95,0x46,0x28,0xa1,0x83, + 0x5d,0xc3,0x3d,0xc7,0xb7,0xee,0xf1,0x38,0x55,0xa5,0xe7,0xf5,0xf6,0x88,0xd5,0xf7, + 0x7a,0x6b,0x47,0x6d,0x4b,0xf3,0xfe,0x07,0xe4,0x9f,0x66,0xf8,0x8e,0x88,0x67,0xee, + 0x13,0xa0,0xab,0xb7,0xad,0x3a,0x90,0xaa,0x3b,0x2a,0x45,0xf4,0xb0,0xdb,0x33,0xc1, + 0xd3,0x66,0xdb,0x95,0x0e,0xe9,0xe5,0x5a,0xaf,0xa3,0xf5,0xb5,0x68,0x03,0xb6,0x52, + 0xeb,0x15,0xd8,0x5b,0xdf,0xd3,0x9a,0x55,0xcf,0xba,0xba,0x42,0x6c,0xa5,0x99,0xb6, + 0x9f,0x38,0x49,0x20,0x1f,0x1e,0xad,0xc0,0xd3,0xef,0xc3,0x37,0x56,0x5e,0x35,0x6b, + 0x45,0x7d,0x97,0xeb,0xac,0xd5,0x18,0xca,0x35,0xd5,0x35,0x67,0x55,0x72,0x16,0x0f, + 0xbb,0x57,0x09,0x7a,0x71,0xf9,0x61,0xb9,0x32,0xa0,0x5c,0x96,0x24,0x62,0x60,0x3f, + 0x48,0xe3,0x6b,0x42,0x08,0x8a,0x1d,0xff,0x21,0x72,0x68,0xc5,0xa2,0x36,0xa7,0x6f, + 0x28,0xbd,0x1c,0x86,0x88,0x22,0x8f,0x85,0x39,0x45,0x75,0x85,0x66,0x51,0xdd,0xee, + 0xa5,0xb9,0x83,0x33,0x70,0x6c,0x96,0xe4,0xd4,0x88,0x1b,0x74,0x94,0x43,0x23,0x96, + 0x5e,0x6d,0x2f,0x84,0xef,0xb0,0x4b,0xd7,0xf2,0x32,0x61,0x82,0xd9,0x8d,0x7c,0xc5, + 0x08,0xbb,0xbc,0xaf,0x11,0xd6,0xa3,0x3b,0x26,0x28,0xe5,0x4a,0x2d,0x5d,0x7a,0x98, + 0x47,0x8d,0x91,0xcf,0xdd,0xd3,0x6a,0x08,0x46,0x39,0x5d,0x54,0x6d,0xd1,0xff,0xb3, + 0x15,0x79,0x74,0x42,0xf2,0x2a,0xc0,0x87,0x5e,0x8d,0xf2,0xbe,0x59,0x80,0x9e,0xad, + 0xb5,0x07,0xf6,0xcf,0xda,0xc1,0xf7,0x9c,0x8b,0x95,0x96,0xb0,0x18,0x77,0xd9,0x0e, + 0xfe,0x4f,0x38,0x43,0xd8,0x5d,0xd1,0x5b,0x7a,0xe4,0xd9,0x5e,0x24,0x7a,0xf3,0xdf, + 0xc2,0xb8,0x75,0x1f,0x1d,0x3f,0x08,0x4a,0x66,0xad,0xbd,0x78,0x74,0x60,0x82,0x6f, + 0x81,0x7d,0x5c,0xa9,0xbc,0x7e,0x3d,0x7d,0xcb,0x8c,0x90,0x69,0x69,0xda,0xb4,0x6a, + 0x26,0x6e,0xcb,0x0b,0xce,0x58,0x97,0xce,0xdd,0xb7,0xb4,0xa1,0xac,0x50,0x77,0xed, + 0x94,0x9f,0x95,0x5c,0x6b,0x92,0x72,0x6b,0x92,0xf9,0x81,0x56,0x39,0x0d,0x40,0xed, + 0xe6,0xca,0x61,0xee,0x70,0x3f,0x14,0x59,0x12,0xe0,0x8f,0x60,0x97,0xe2,0xe9,0x56, + 0x61,0x2b,0x0a,0x4f,0x6d,0xb5,0x2f,0x25,0x8e,0x67,0x44,0x81,0x94,0xb9,0x5f,0x49, + 0xd8,0xac,0xee,0x63,0x80,0xa1,0xc2,0xfa,0x9e,0x5a,0x7a,0xec,0x42,0x5c,0x4c,0xa7, + 0x7d,0x8e,0xf4,0x0c,0x03,0xe7,0x1d,0xb4,0x14,0x5c,0x97,0xce,0x58,0xf0,0x15,0x73, + 0x1c,0xb2,0x8d,0x97,0xfa,0x9b,0xb7,0xc6,0x51,0x0a,0x77,0x08,0x0b,0x88,0xba,0x20, + 0x86,0xf8,0xce,0x75,0xee,0xe2,0x3b,0xba,0x18,0x5b,0x19,0xa4,0xfa,0x86,0x6b,0x23, + 0x77,0x75,0xff,0xad,0x37,0x5f,0x1d,0xdd,0x82,0x5b,0x80,0x9d,0x2d,0x5e,0xe6,0xa9, + 0x68,0xa8,0x86,0xcf,0xe2,0x7e,0x91,0xdc,0x78,0x69,0xb6,0x08,0x22,0x11,0x97,0xbf, + 0x47,0xe2,0x2a,0xed,0x7b,0x1d,0x2c,0xa3,0x82,0x6b,0x35,0x85,0x4a,0xe9,0x15,0xe7, + 0xd0,0xc4,0xa5,0xdd,0x96,0x62,0x41,0xe5,0xcb,0x27,0xd1,0x30,0x11,0x14,0xd7,0xfb, + 0x6a,0xc1,0xa0,0x48,0xae,0xd2,0x81,0x2d,0xe3,0xb8,0xa6,0xa9,0x55,0x07,0xd2,0xa4, + 0x71,0x2c,0xbb,0x29,0x66,0x62,0xed,0x4b,0x75,0x16,0x82,0xbf,0x6e,0xdc,0xe9,0xf8, + 0xe2,0xa8,0x74,0x45,0x89,0x99,0x25,0x2f,0x85,0xe1,0x8b,0x1f,0x6b,0x83,0xae,0xb6, + 0xc5,0xfb,0xd9,0xd2,0xe8,0x5d,0x13,0x5b,0x55,0x32,0x92,0xaa,0x11,0x56,0x65,0x6f, + 0x9d,0xb4,0xda,0x2f,0xc5,0x6d,0x82,0x97,0xc6,0x5d,0x82,0x97,0x27,0xe3,0x14,0x9d, + 0x26,0x9c,0x41,0x1f,0x66,0xee,0x21,0x5f,0x35,0xc8,0x19,0x35,0xd1,0x49,0x88,0xc7, + 0xc5,0xa5,0x88,0x39,0x5a,0x5c,0x1a,0x41,0x47,0x50,0x27,0x42,0xae,0xd6,0x58,0x2c, + 0x61,0x39,0x35,0x64,0xe2,0xc7,0x60,0x6a,0x96,0x31,0x6e,0x8f,0x14,0x09,0xe6,0xc7, + 0xdb,0x20,0x2f,0xac,0x6f,0xdc,0x3b,0x33,0xef,0x28,0x3c,0xfe,0xf3,0x2f,0x27,0x2f, + 0x0f,0x3f,0xbc,0xa7,0x07,0x3e,0x77,0xcc,0xd4,0xd3,0x97,0x18,0x5b,0xdd,0xc1,0x23, + 0x3c,0xaa,0xfa,0x9b,0x44,0x5c,0xcb,0xc5,0x9f,0x1f,0xd4,0xa3,0x8d,0xfa,0x60,0xc2, + 0x25,0x3f,0x8b,0xe7,0x16,0x32,0x98,0x57,0x0c,0x63,0xa8,0x06,0xa2,0x4d,0xca,0xc5, + 0x8c,0x9f,0x13,0x2f,0x9c,0xff,0x82,0x57,0x97,0xea,0x20,0xcc,0x88,0x0e,0xc0,0x73, + 0xf6,0x33,0x3c,0xa6,0xbe,0x4b,0x87,0xd6,0xe9,0xe5,0x8e,0x76,0xe5,0xe5,0x0f,0x2e, + 0x4f,0xb7,0x3b,0x3f,0x41,0x8d,0x9a,0x4f,0x32,0x02,0xdd,0xa4,0x73,0x97,0xef,0x6b, + 0xee,0x79,0x74,0x39,0x6a,0x7b,0x5b,0x1d,0xfb,0x35,0x7a,0x95,0xa4,0x51,0x7e,0x63, + 0x86,0xd2,0x8a,0xe6,0x5d,0xec,0x56,0xa7,0xf5,0xc4,0x33,0xbb,0x50,0xdf,0x03,0x7c, + 0x05,0x9d,0x3a,0xd0,0x51,0x4f,0x45,0xe7,0x40,0x92,0x05,0x5d,0xd6,0xba,0xed,0xad, + 0xed,0x54,0xb7,0x4b,0xb1,0x9f,0xd0,0xa6,0x79,0xc1,0x98,0x86,0xbc,0xd3,0x23,0xc8, + 0xdb,0xbd,0x32,0x64,0x63,0x77,0x96,0x00,0x6d,0xf7,0xd6,0x37,0xd4,0xde,0x29,0x9d, + 0x21,0xb9,0x3c,0x9c,0x05,0x00,0x2b,0x8f,0x8c,0xc0,0xf6,0x80,0x88,0x95,0xc9,0xe4, + 0x99,0x03,0x59,0xf8,0x58,0x8d,0x7a,0x20,0x15,0x2f,0xa8,0x36,0xdf,0x54,0x1d,0x6c, + 0xc8,0xb2,0x8f,0xf1,0xe2,0x7c,0x28,0xdd,0x94,0xb5,0xb1,0xcd,0xe0,0x2b,0xda,0x9c, + 0x75,0x3b,0x93,0xb5,0x05,0x5c,0xba,0xb0,0x49,0xc0,0x94,0xec,0xa9,0x6c,0xc6,0xb3, + 0xc6,0xdc,0xe6,0x58,0xea,0x8e,0x7e,0xa7,0xa6,0x3c,0xde,0x23,0xf9,0xba,0xc7,0xca, + 0xb8,0xb1,0x2b,0x1a,0xfe,0x27,0x80,0x44,0x3f,0x5e,0xe3,0x00,0x0f,0x6b,0x22,0xc0, + 0xc4,0xc9,0x4f,0xce,0x5c,0x17,0x5d,0xc5,0x25,0x56,0xc7,0x65,0x5c,0x52,0x40,0x46, + 0x9b,0x5e,0x0c,0xa6,0x00,0xae,0x76,0xeb,0x49,0xef,0x7c,0xe5,0x15,0x3d,0x33,0x71, + 0x3c,0x73,0xa6,0x2f,0x87,0xa1,0x06,0xec,0x7b,0x01,0x28,0x49,0x44,0x1a,0xb4,0xc5, + 0xad,0x3b,0xaa,0x1f,0x90,0x76,0x65,0xa6,0x99,0x47,0x35,0xc5,0x29,0x1a,0x50,0xeb, + 0x68,0x01,0x04,0x59,0x26,0x4e,0xf1,0xc2,0x88,0xb1,0xfb,0x42,0xa0,0xd6,0x0d,0x5b, + 0x1c,0x7a,0xad,0x8e,0x97,0x2a,0xd6,0xf5,0xb3,0x56,0x8e,0x40,0x93,0xf5,0xb7,0x90, + 0x5c,0xd3,0x05,0xb0,0xa8,0x9a,0x1b,0x2c,0x6b,0x4b,0x72,0x2a,0x0a,0x14,0xbc,0x2a, + 0x1f,0x66,0x55,0x4f,0x03,0x65,0x19,0x5e,0x5c,0x82,0x78,0x2b,0xc5,0x42,0x59,0xa8, + 0x32,0x83,0xa1,0x2c,0x7c,0xa9,0x68,0xa8,0x0a,0xc6,0xf0,0x2d,0x49,0xbc,0xfb,0x0a, + 0x59,0xa3,0x2f,0x43,0x98,0x40,0x76,0xb6,0xd0,0x73,0x23,0xb3,0x80,0x4d,0xfa,0x1c, + 0x87,0xe1,0x3b,0xdd,0x32,0x4d,0x9e,0x82,0x34,0x74,0x13,0x7e,0xc2,0x86,0x19,0x93, + 0xd0,0xa3,0xec,0x13,0x0d,0x0f,0x98,0x97,0x7b,0x9a,0xa7,0x27,0xe9,0x95,0x3e,0x25, + 0x99,0x5d,0x90,0x37,0x19,0xb9,0xff,0x63,0x7c,0x03,0xe6,0x8a,0x17,0x22,0xd5,0xe0, + 0x3b,0xbc,0x68,0xa9,0x1a,0x35,0x11,0xef,0xa3,0xe5,0x84,0xeb,0xed,0x42,0xf6,0xd5, + 0x2c,0x82,0x89,0x74,0x11,0xc4,0x33,0x09,0x11,0x37,0x07,0xa8,0x4c,0xb3,0x89,0x17, + 0x41,0xd1,0x9d,0x04,0xd8,0xc4,0x50,0xe2,0x5d,0xb1,0x6c,0x92,0x54,0x8a,0xf9,0x43, + 0x21,0xa2,0xfd,0x37,0x09,0xdf,0xb1,0x88,0x2c,0x06,0xbf,0x3f,0xc8,0xf7,0x62,0x4d, + 0xde,0xa3,0xa3,0x79,0x29,0x1c,0xaf,0x6e,0x7a,0x07,0x02,0x35,0x00,0x4f,0xdd,0x9b, + 0x03,0xa5,0xe9,0x4d,0x5a,0x4c,0xa4,0xa3,0x0b,0x4c,0xfa,0xd8,0x84,0xfa,0xbc,0x13, + 0xdc,0x5e,0x0c,0xf4,0x19,0x4f,0x39,0x9f,0x13,0xc5,0x01,0x62,0x9f,0x8e,0xc4,0xdb, + 0x0b,0x28,0xa2,0x67,0xe1,0x64,0x59,0x3d,0x6b,0x67,0x64,0x10,0x02,0x58,0x02,0xd3, + 0x7d,0xb3,0x79,0x71,0x90,0x44,0x73,0x7a,0xa7,0x81,0xee,0xe1,0x77,0x79,0x42,0xbd, + 0xd2,0x95,0xcf,0x42,0x4c,0x53,0x2b,0x35,0xb7,0x4b,0x2b,0x23,0x83,0xcc,0xa5,0x0a, + 0x44,0x68,0x4f,0xd4,0x04,0x52,0x01,0x23,0x81,0x34,0x3c,0x28,0xc9,0x4f,0x7e,0xf0, + 0xf1,0xb3,0xed,0x76,0xbb,0xd2,0xeb,0xe8,0xcb,0xb7,0x06,0x25,0xb3,0x22,0x22,0x95, + 0x15,0xc3,0xc3,0x38,0x10,0xaa,0x89,0x9d,0x25,0x6f,0xac,0x95,0xa9,0xd2,0x93,0x87, + 0x60,0x44,0x50,0xb2,0xcc,0x52,0x01,0xc9,0x08,0x47,0xc4,0x23,0x8b,0xd5,0x74,0x8f, + 0x80,0x64,0x7b,0x5c,0x61,0xe1,0xf2,0xeb,0x52,0x6a,0xad,0xf0,0x03,0x4c,0x6c,0x96, + 0x2e,0xc2,0x04,0xf4,0xbb,0xcb,0x28,0xbc,0x6a,0xdc,0xe7,0x5d,0xa9,0xb5,0x38,0x5f, + 0x31,0x89,0x6c,0xc5,0x1a,0x2b,0x54,0x22,0x9b,0x69,0x9e,0x75,0x22,0x53,0x3f,0x52, + 0x6a,0x99,0xa9,0x92,0xd9,0xea,0x58,0x99,0xfc,0x2a,0x43,0xf9,0x9a,0x26,0xcd,0x88, + 0xd1,0xec,0xc3,0xdc,0x8b,0xab,0xd6,0xfd,0x1c,0xde,0xd4,0xb9,0xc7,0xe8,0x5d,0x37, + 0x72,0x8e,0x40,0x01,0x56,0x8f,0x5e,0xe6,0xb0,0xd2,0xc2,0x06,0x3d,0xe3,0x63,0x30, + 0x27,0x6e,0x8e,0x2c,0x3d,0x61,0x18,0xac,0xdd,0x35,0x43,0xce,0xf0,0xfd,0xce,0x4a, + 0xa9,0x0a,0x1b,0xde,0xc5,0xfc,0x1b,0x1b,0xe4,0x5b,0x7f,0xaf,0x57,0x39,0x88,0xaf, + 0x69,0xb0,0x61,0xec,0x19,0xae,0x47,0x6d,0x89,0x94,0x8e,0xe2,0xb2,0x95,0x13,0xa8, + 0x94,0xd5,0x7e,0xe7,0xf2,0x61,0xf5,0x72,0xb5,0xb5,0xf4,0xb9,0xa2,0x72,0xed,0x0e, + 0x75,0x59,0x25,0xd2,0xf7,0x7c,0xf7,0xf5,0x95,0xd9,0x82,0xae,0xac,0x1e,0xdc,0xc3, + 0xf5,0x51,0x3a,0x9f,0x6c,0x9f,0xb0,0xaa,0x3b,0xa8,0x6c,0x3a,0x35,0x6a,0xdc,0xbd, + 0xa6,0xa5,0x55,0xf2,0x79,0x30,0xab,0x3a,0x3a,0x78,0x77,0xfc,0xcb,0xa7,0x0f,0x1f, + 0x8f,0xf8,0x09,0x06,0xd6,0x8e,0x1a,0x05,0x85,0xa0,0x61,0xb8,0x2a,0x29,0x4a,0x8d, + 0x51,0x10,0xe3,0xda,0x9d,0x88,0xa4,0x27,0x3d,0xf5,0xe4,0x3a,0x3f,0xac,0x40,0xef, + 0xf8,0x35,0x2e,0x70,0x62,0x6e,0x1a,0xe7,0xca,0x79,0x78,0x70,0xfa,0x53,0x15,0x76, + 0x9e,0x04,0x0b,0x03,0xf4,0x6f,0x4b,0x44,0x81,0x09,0x37,0x9d,0x16,0x26,0xd4,0x38, + 0xf8,0x62,0xc2,0xc4,0xc7,0x46,0x3e,0xa5,0x14,0xaa,0x28,0x8e,0x33,0xe8,0x21,0xb4, + 0xa6,0xc0,0x72,0x5d,0xf7,0xac,0x48,0x17,0xfc,0xa0,0x03,0x5e,0xa5,0x88,0x71,0x4a, + 0x78,0x27,0xb1,0x4d,0x63,0xea,0x72,0x7d,0xc5,0xb4,0x2d,0xaf,0x65,0xbb,0xf5,0x14, + 0x95,0x1a,0x53,0x01,0x97,0x2f,0x1a,0x32,0x75,0x88,0x37,0x4f,0x8c,0x67,0x6a,0x2f, + 0xcd,0x74,0xec,0x61,0x85,0x70,0x64,0xd7,0x29,0x4e,0x9c,0x1b,0xfd,0x5c,0xdf,0x28, + 0x9b,0x00,0x76,0x8b,0xe2,0x55,0x7b,0xdd,0xe0,0x67,0x23,0xb9,0xb6,0x3d,0x85,0xfe, + 0x0a,0x5e,0x3e,0x9b,0x78,0xb9,0xb3,0xa0,0x57,0x89,0x94,0x9e,0x74,0xa9,0xf8,0xe7, + 0x4c,0x74,0x55,0x7a,0x27,0x8f,0xda,0xb3,0x25,0x03,0x4c,0x97,0x46,0x76,0xc9,0xcf, + 0x66,0xf4,0xbe,0xbf,0xdf,0xd6,0x7c,0x56,0x7d,0x78,0x7a,0x52,0xbe,0x73,0x14,0xa5, + 0x59,0x15,0xc3,0x50,0x24,0x50,0x3f,0x92,0x7b,0x4f,0x79,0xa9,0xc3,0x2b,0x5f,0x99, + 0x10,0xe7,0x7b,0x4b,0xae,0x5c,0x7a,0x1b,0x47,0xbd,0x71,0xa4,0x2e,0xca,0xe7,0xdf, + 0xca,0xeb,0x22,0x14,0x58,0x53,0x76,0x08,0x71,0xf9,0x15,0x47,0x4d,0xf9,0xd4,0x9b, + 0x7a,0x3c,0x47,0x3c,0x14,0x84,0xde,0x30,0xe5,0x3c,0x94,0xaf,0xf9,0x04,0xf9,0x67, + 0xa9,0x4c,0x73,0x52,0xeb,0x97,0x20,0x9b,0x87,0x13,0x99,0x68,0x3d,0xce,0xa8,0x4a, + 0xa0,0x76,0x48,0x6f,0xca,0x0a,0xa3,0x54,0xf8,0x30,0x2d,0x0d,0xc0,0x06,0x87,0x6f, + 0x92,0x19,0xdf,0x2c,0xc6,0x45,0x52,0x69,0xf7,0x57,0x94,0xa3,0xab,0x36,0x95,0xd9, + 0xcb,0x4f,0xa7,0xdd,0x6d,0x58,0xb9,0xba,0xb4,0x01,0x62,0x05,0x58,0x18,0xa8,0xd1, + 0x87,0xf2,0x43,0x70,0xe5,0xce,0xd5,0xe8,0x9b,0xdf,0xd9,0x7f,0x8a,0x9e,0x21,0x7d, + 0xd3,0xc6,0x15,0x58,0x43,0x2e,0xfb,0xa2,0xd5,0xa4,0x44,0x49,0x64,0x5e,0xc5,0x9b, + 0x83,0x7c,0xa6,0x96,0xa4,0x23,0x5a,0x3f,0x26,0xc6,0x93,0x2e,0x9f,0x25,0x1a,0xac, + 0x7d,0xa6,0x84,0xc1,0xe8,0x97,0x4a,0x56,0xd1,0xdd,0xb7,0x3c,0x61,0xd2,0xe6,0xd5, + 0x4a,0x6f,0x26,0x67,0x21,0x5a,0x84,0x86,0x60,0xa6,0x47,0x94,0x1b,0xca,0x05,0x2e, + 0xdf,0xe5,0xf2,0x1c,0xfd,0xdb,0x35,0x9c,0xe6,0x50,0x10,0x33,0x58,0x97,0x74,0xf1, + 0x0d,0x2d,0xfb,0x86,0xf6,0x2b,0x3a,0xce,0x28,0xe2,0xa7,0xd4,0xf5,0xb4,0xe2,0x29, + 0x59,0x3a,0x33,0x04,0x35,0xe8,0x0a,0x6e,0x71,0x3f,0x4a,0xb9,0x28,0x5f,0x8b,0x85, + 0x11,0x53,0xd5,0x75,0xb2,0x72,0x49,0xb1,0x5a,0x53,0x7a,0xae,0x6a,0x0b,0x6f,0xa2, + 0xc7,0xbf,0xb3,0x62,0x1e,0xef,0x6f,0xfc,0x3f,0xa2,0x10,0x16,0xf7,0x92,0xe0,0x00, + 0x00, }; constexpr size_t AUDIO_IN_INDEX_HTML_GZ_LEN = sizeof(AUDIO_IN_INDEX_HTML_GZ); // diff --git a/firmware/patternflow/features/audio_in/core_audio_fft.h b/firmware/patternflow/features/audio_in/core_audio_fft.h index 55d3f516a..6b0604694 100644 --- a/firmware/patternflow/features/audio_in/core_audio_fft.h +++ b/firmware/patternflow/features/audio_in/core_audio_fft.h @@ -7,16 +7,22 @@ // and 5 of 7 either did not know it existed or had not tried it. What people // asked for is a different thing: the panel hearing the room on its own. // -// This is the analysis half, and it is written to run BEFORE any microphone -// exists, because the question that decides whether the feature is possible -// is not "can we read a mic" — it is "is there CPU left". The panel spends -// ~10 ms of every 16.6 ms frame pushing pixels. If a spectrum does not fit -// in what remains, no microphone helps. +// Owns: the transform and its buffers, the four raw band levels, the +// spectrum the page draws, and the verdict on what the input is (sourceLabel). +// Where the bands' edges are, and what a level becomes, is core_audio_in_map.h. // -// So `feed()` takes samples from wherever. A PDM mic on I2S would be the -// real source (GPIO43/44 are the only free header pins on this board — see -// the note in analyze()); until one is wired, synth() stands in and the cost -// measures the same, because an FFT does not know where its input came from. +// This is the analysis half, and it was written BEFORE any microphone +// existed, because the question that decided whether the feature was possible +// was not "can we read a mic" — it was "is there CPU left". The panel spends +// ~10 ms of every 16.6 ms frame pushing pixels. If a spectrum had not fitted +// in what remains, no microphone would have helped. +// +// So fill() takes a window from wherever. The real source is the PDM mic in +// core_audio_pdm.h; when that has nothing to give - the driver did not +// install, or the mic stopped answering - a synthetic three-tone source +// stands in, and the cost measures the same, because an FFT does not know +// where its input came from. What the synthetic source must not do is move a +// panel's knobs; the feature's fillInput sees to that. // // Deliberately naive: a plain radix-2 complex transform with the signal in // the real lane. A real-input FFT halves this and esp-dsp's assembly version @@ -180,12 +186,21 @@ inline void fill(uint32_t tick) { // edge that is halfway between the old value and the new one - 16 ms of a // slightly wrong band while somebody drags a handle. A mutex on the audio // path to avoid that would cost more than it saves. +// +// What an edge read here must never do is leave a band with no bins. A low +// edge in the last bin does (hzMin 7990: lo is N/2, and hi cannot pass it), +// whether it is stored that way or caught raw in the instant before the +// handler clamps it. The mean was then 0/0, and because each level is +// smoothed from the one before, that single NaN never left: `nan` in every +// poll and in /api/status until a reboot, and on the knob the band drives. +// A band over nothing hears nothing. inline void fold() { for (int b = 0; b < 4; b++) { const int lo = PFAudioInMap::binOf(PFAudioInMap::bands[b].hzMin); int hi = PFAudioInMap::binOf(PFAudioInMap::bands[b].hzMax); if (hi <= lo) hi = lo + 1; if (hi > N / 2) hi = N / 2; + if (hi <= lo) { bands[b] = 0.0f; continue; } float sum = 0.0f; for (int k = lo; k < hi; k++) sum += sqrtf(re[k] * re[k] + im[k] * im[k]); @@ -228,6 +243,11 @@ inline bool inputIsDeadRail() { // What the input actually is, for /api/status and the console page. Lives // here rather than beside the driver because the driver cannot see the // spectrum, and the spectrum is the only thing that tells these apart. +// +// The five strings this can return ("off", "pdm", the dead-rail one below, +// and PFAudioPdm::sourceName()'s "synth" and "synth (mic stalled)") are +// matched exactly by the page and listed in docs/rest-api.md. Add a sixth +// rather than rewording one. inline const char* sourceLabel() { if (!PFAudioInMap::micOn) return "off"; if (!PFAudioPdm::available()) return PFAudioPdm::sourceName(); diff --git a/firmware/patternflow/features/audio_in/core_audio_in_http.h b/firmware/patternflow/features/audio_in/core_audio_in_http.h index 83e954b5d..e66f10026 100644 --- a/firmware/patternflow/features/audio_in/core_audio_in_http.h +++ b/firmware/patternflow/features/audio_in/core_audio_in_http.h @@ -1,11 +1,19 @@ // ═══════════════════════════════════════════════════════════ // PatternFlow - /audio-in: watching and shaping the microphone // -// Three routes: +// Owns: the page and its four routes. The settings they read and write live +// in core_audio_in_map.h; nothing is stored here but the phone's last frame. // -// GET /audio-in the page -// GET /api/audio-in live levels + the current shaping. Polled. -// POST /api/audio-in set one band, or the microphone switch +// GET /audio-in the page +// GET /api/audio-in the whole configuration, read once on load +// GET /api/audio-in?levels=1 live levels, mapped values, spectrum. Polled. +// POST /api/audio-in settings: the switch, gain, auto, the two +// speeds, and any band's fields +// POST /api/audio-in/reset the firmware's defaults - one band, or all +// +// The contract, field by field, is docs/rest-api.md ("Microphone"). The phone +// capture app reads the configuration and posts monitor frames through the +// same routes, so a field here is not ours alone to rename. // // ── Why polling and not the websocket ─────────────────────────────────── // @@ -16,11 +24,11 @@ // the browser path composed out - which is exactly the build someone with a // microphone would want. // -// So: a small GET, polled at ~10 Hz by the page. The response is deliberately -// tiny (four levels, four mapped values, a peak) because this device's web -// server is single-connection and every byte here is a byte the panel is not -// rendering. The shaping is sent once on load and only re-sent when the page -// changes it. +// So: a GET the page polls. The poll reply carries no configuration at all +// (levels, what they map to, and a 64-bucket spectrum to draw) because this +// device's web server is single-connection and every byte here is time the +// one connection is not free for anything else. The configuration is sent +// once on load and the page sends back only what it changed. // // License: MIT // ═══════════════════════════════════════════════════════════ @@ -74,14 +82,20 @@ inline bool pageWatching() { } // "l0,l1,l2,l3;lo0,hi0,..lo3,hi3;s0,..,s63" - 4+8+64 floats, three groups. -// A frame that does not parse to exactly that shape is dropped whole. +// A frame that does not parse to exactly that shape is dropped whole, and so +// is one with a `nan` or an `inf` in it: these are printed back into the poll +// as they came, and neither is JSON. inline void parseFrame(const String& s) { float vals[76]; int n = 0, start = 0; for (unsigned i = 0; i <= s.length() && n < 76; i++) { const char c = i < s.length() ? s[i] : ','; if (c == ',' || c == ';') { - if (i > (unsigned)start) vals[n++] = s.substring(start, i).toFloat(); + if (i > (unsigned)start) { + const float v = s.substring(start, i).toFloat(); + if (!isfinite(v)) return; + vals[n++] = v; + } start = i + 1; } } @@ -111,18 +125,26 @@ inline void handleIndex() { // dragged handle spring back to whatever the device last managed to save. // // Field names are the extension's — levels, outputs, spectrum — because the -// page's paint loop is lifted from popup.js and renaming them here would mean -// editing code whose whole value is that it is not edited. +// page IS the extension's editor (tools/patternflow-audio-extension/editor.*, +// assembled for the device by toolchain/build_audio_in_page.py) and it paints +// both sources through one frame shape. inline void handleGet() { const bool levelsOnly = server().hasArg("levels"); if (levelsOnly) { - // idle=1 marks the page's own low-power mode (its Monitor toggle off): - // it still wants a status trickle, but must not count as an audience - - // the phone reads that demand and throttles its frames accordingly. + // idle=1 is a poll that must not count as an audience: the phone reads + // that demand in every frame reply and throttles itself by it, so a + // client that only wants a trickle of status says so. Nothing in this + // tree sends it today (the page's Monitor toggle, which did, is gone); + // it stays because the demand signal is the phone's contract. if (!server().hasArg("idle")) pagePollMs = millis(); const bool ext = extFresh(); - String j = "{\"source\":\""; + // One allocation instead of one per `+=`: the reply is about 700 bytes + // and a String grows 16 at a time, so unreserved this was some forty + // reallocs of internal heap, ten times a second. + String j; + j.reserve(768); + j += "{\"source\":\""; j += ext ? "phone" : PFAudioFFT::sourceLabel(); j += "\",\"ext\":"; j += ext ? "true" : "false"; @@ -140,8 +162,11 @@ inline void handleGet() { if (i) j += ','; j += String(ext ? extLevels[i] : PFAudioInMap::smoothLevel[i], 4); } - // The level as the mapping consumes it in auto mode - the page paints - // its dot and meters from this so what you see is what the knob gets. + // The level as the mapping consumes it in auto mode - sent so a page can + // paint its cursor from what the knob gets rather than from its own + // re-derivation. This and `outputs` are always the MICROPHONE's: while + // `ext` frames are being served the phone is doing its own mapping, and + // these two do not describe it. j += "],\"levelsN\":["; for (int i = 0; i < 4; i++) { if (i) j += ','; @@ -197,7 +222,10 @@ inline void handleGet() { return; } - String j = "{\"source\":\""; + // About 1.3 KB with four empty metas; see the note on the poll reply. + String j; + j.reserve(1536); + j += "{\"source\":\""; j += PFAudioFFT::sourceLabel(); j += "\",\"micOn\":"; j += PFAudioInMap::micOn ? "true" : "false"; @@ -260,14 +288,17 @@ inline void handleGet() { j += PFAudioInMap::lutSet[i] ? "true" : "false"; j += '}'; } + // Two decimals: the low edge is one FFT bin, 31.25 Hz, and at one decimal + // a client taking its axis from here was handed a bound that is not the + // one clampRange() holds a band to. j += "],\"hzRange\":["; - j += String(PFAudioInMap::MIN_HZ, 1); + j += String(PFAudioInMap::MIN_HZ, 2); j += ','; - j += String(PFAudioInMap::MAX_HZ, 1); + j += String(PFAudioInMap::MAX_HZ, 2); - // Three decimals, not five: this is 40 numbers ten times a second and the - // page draws them as bars a few pixels wide. The precision that matters is - // in `level`, which is what the shaping actually consumes. + // Three decimals: these are drawn as bars a few pixels wide. (`spec`, + // `level`, `out`, `rawPeak`, `rawDc` and `windows` ride in this reply for + // scripts and for diagnosing a microphone; the page draws from the poll.) j += "],\"spec\":["; { float s[PFAudioFFT::SPEC_BUCKETS]; @@ -283,16 +314,41 @@ inline void handleGet() { server().send(200, "application/json", j); } +// A band's number, or the value it already has when the field is absent, +// empty, or not a finite number. toFloat() is atof(), which reads `nan` and +// `inf` as exactly that, and a NaN walks through every constrain() below; see +// PFAudioInMap::finiteOr for what one stored NaN cost. inline float argFloat(const char* name, float fallback) { if (!server().hasArg(name)) return fallback; const String v = server().arg(name); if (!v.length()) return fallback; - return v.toFloat(); + return PFAudioInMap::finiteOr(v.toFloat(), fallback); +} + +// Does this request say anything about band N in the suffixed form? Every +// field a band has is on the list. It used to be five of them - the ones a +// whole-config save always carries - which meant a request with only +// `inMax2`, the top edge of one box, was answered {"ok":true} and changed +// nothing. A page that sends only what moved needs that to work. +inline bool hasBandArgs(const String& suffix) { + static const char* const FIELDS[] = {"hzMin", "hzMax", "inMin", "inMax", + "gain", "outMin", "outMax", "knob", + "muted", "lut", "meta"}; + for (const char* f : FIELDS) + if (server().hasArg(String(f) + suffix)) return true; + return false; } -// Apply whatever band fields arrived under the given argument names. Shared -// by the single-band form (bare names + band=N) and the editor's whole-config -// form (suffixed names, hzMin0..hzMin3), so the two cannot drift. +// Apply whatever band fields arrived under the given argument names; a field +// that did not arrive keeps its value. Shared by the single-band form (bare +// names + band=N) and the suffixed form (hzMin0..hzMin3), so the two cannot +// drift. +// +// Two pairs are settled TOGETHER after the fields are in, whichever half +// arrived: hzMin/hzMax (ordered, inside the analysable range, a bin apart) +// and inMin/inMax (inMax at least 0.01 above inMin). So one half of a pair +// sent alone can move the other half; a client that wants to know what was +// stored sends both. inline void applyBandArgs(int b, const String& suffix) { PFAudioInMap::Band& x = PFAudioInMap::bands[b]; const String sHzMin = "hzMin" + suffix, sHzMax = "hzMax" + suffix; @@ -348,9 +404,11 @@ inline void applyBandArgs(int b, const String& suffix) { } } -// One band per POST, addressed by index, or `mic` on its own. Partial -// updates are allowed: the page sends only the handle that moved, so dragging -// one edge cannot clobber the other three by round-tripping a stale copy. +// Every field is optional and a request changes only what it names: `mic` +// on its own, one slider, one edge of one box, or everything at once. That +// is the point - a page that sends back its whole copy on every edit lets +// two open tabs revert each other, and a drag clobber three bands it never +// touched. inline void handleSet() { // Monitor frames are state, not settings: no NVS save, no other fields. // The reply carries the demand signal - see pageWatching(). @@ -362,6 +420,18 @@ inline void handleSet() { : "{\"ok\":true,\"watch\":false}"); return; } + // Refused before anything is applied: a 400 that had already flipped the + // microphone switch in memory would not be a refusal. + int single = -1; + if (server().hasArg("band")) { + single = server().arg("band").toInt(); + if (single < 0 || single > 3) { + server().sendHeader("Cache-Control", "no-store"); + server().send(400, "application/json", + "{\"ok\":false,\"error\":\"band must be 0-3\"}"); + return; + } + } if (server().hasArg("mic")) { const String v = server().arg("mic"); PFAudioInMap::micOn = (v == "1" || v == "true"); @@ -370,41 +440,39 @@ inline void handleSet() { const String v = server().arg("auto"); PFAudioInMap::autoRange = (v == "1" || v == "true"); } + // These three keep their value on `nan` or `inf`, like a band's fields. An + // EMPTY value is still read as 0 and lands on the low clamp, as documented; + // only what is not a finite number is refused. A NaN here was worse than in + // a band: as the gain it made every sample NaN, as a glide speed it made the + // damped levels NaN, and neither came back with a reset. if (server().hasArg("micGain")) { - PFAudioInMap::micGain = constrain(server().arg("micGain").toFloat(), - PFAudioInMap::MIC_GAIN_MIN, - PFAudioInMap::MIC_GAIN_MAX); + const float v = PFAudioInMap::finiteOr(server().arg("micGain").toFloat(), + PFAudioInMap::micGain); + PFAudioInMap::micGain = + constrain(v, PFAudioInMap::MIC_GAIN_MIN, PFAudioInMap::MIC_GAIN_MAX); } if (server().hasArg("smoothing")) { - PFAudioInMap::smoothing = - constrain(server().arg("smoothing").toFloat(), 0.05f, 0.9f); + const float v = PFAudioInMap::finiteOr(server().arg("smoothing").toFloat(), + PFAudioInMap::smoothing); + PFAudioInMap::smoothing = constrain(v, 0.05f, 0.9f); } if (server().hasArg("attack")) { - PFAudioInMap::attack = - constrain(server().arg("attack").toFloat(), 0.05f, 0.9f); + const float v = PFAudioInMap::finiteOr(server().arg("attack").toFloat(), + PFAudioInMap::attack); + PFAudioInMap::attack = constrain(v, 0.05f, 0.9f); } // Single-band form: band=N plus bare field names. The strip-era contract, // still honoured. - if (server().hasArg("band")) { - const int b = server().arg("band").toInt(); - if (b < 0 || b > 3) { - server().send(400, "application/json", "{\"error\":\"band must be 0-3\"}"); - return; - } - applyBandArgs(b, ""); - } + if (single >= 0) applyBandArgs(single, ""); - // Whole-config form: suffixed field names (hzMin0..hzMin3, lut2, meta1...). - // The editor saves everything it owns in one request - four sequential - // POSTs on a single-connection server was a drag stuttering the panel. + // Suffixed form: field names carrying their band (hzMin0..hzMin3, lut2, + // meta1...). Any number of bands in one request, any subset of each one's + // fields - four sequential POSTs on a single-connection server was a drag + // stuttering the panel. for (int b = 0; b < 4; b++) { const String suffix(b); - if (server().hasArg("hzMin" + suffix) || server().hasArg("lut" + suffix) || - server().hasArg("knob" + suffix) || server().hasArg("muted" + suffix) || - server().hasArg("outMin" + suffix)) { - applyBandArgs(b, suffix); - } + if (hasBandArgs(suffix)) applyBandArgs(b, suffix); } PFAudioInMap::save(); @@ -412,11 +480,39 @@ inline void handleSet() { server().send(200, "application/json", "{\"ok\":true}"); } +// The defaults live in the firmware (core_audio_in_map.h resetBand) and the +// page asks for them here rather than keeping a table of its own - the one it +// had was the extension's, and "Reset band" put a 5-16 kHz band on an axis +// that ends at 8. +// +// band=N that band only: its range, window, gain, output range, knob, +// mute and curve. Nothing else moves. The reply names the band, so +// a client can tell this from an older firmware that ignored the +// argument and reset everything. +// (none) all four bands and curves, damping, attack and the input gain. +// Not the microphone switch and not auto range. inline void handleReset() { + server().sendHeader("Cache-Control", "no-store"); + if (server().hasArg("band")) { + // Exactly one digit. toInt() reads "" and "x" as 0, and a reset is not + // the request to guess on. + const String v = server().arg("band"); + if (v.length() != 1 || v[0] < '0' || v[0] > '3') { + server().send(400, "application/json", + "{\"ok\":false,\"error\":\"band must be 0-3\"}"); + return; + } + PFAudioInMap::resetBand(v[0] - '0'); + PFAudioInMap::save(); + String j = "{\"ok\":true,\"band\":"; + j += v[0]; + j += '}'; + server().send(200, "application/json", j); + return; + } PFAudioInMap::resetBands(); - PFAudioInMap::micGain = 8.0f; + PFAudioInMap::micGain = PFAudioInMap::MIC_GAIN_DEFAULT; PFAudioInMap::save(); - server().sendHeader("Cache-Control", "no-store"); server().send(200, "application/json", "{\"ok\":true}"); } 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 c8abf314d..2cb26cd3f 100644 --- a/firmware/patternflow/features/audio_in/core_audio_in_map.h +++ b/firmware/patternflow/features/audio_in/core_audio_in_map.h @@ -5,20 +5,32 @@ // a knob: the highs carry far less energy than the bass, so band 4 sits near // zero while band 1 saturates, and a room's noise floor is never at zero. // -// Four numbers per band fix that, and they are deliberately the SAME four the -// Chrome extension uses, computed the same way: +// Owns: the four bands' settings, where they are kept (NVS), and the +// arithmetic from a band's level to its knob. No I2S and no HTTP in here. +// +// Five numbers per band fix that, and they are deliberately the SAME five the +// Chrome extension's mapper uses, applied in the same order - window, then +// curve, then output range: // // inMin ignore below this the room's noise floor // inMax full by this what counts as loud, for this band -// gain curve between them >1 lifts a quiet band +// gain curve between them >1 lifts a quiet band (the curve, until +// the editor gives the band a table - see +// "Response curves" below) // outMin where the knob rests what the pattern does in silence // outMax where the knob peaks // -// mapBandOutput() in tools/patternflow-audio-extension/popup.js is the -// reference implementation. If one of them changes, the other has to: a +// The extension's half is mapBandOutput() in tools/patternflow-audio-extension. +// If one of them changes its order or its edge cases, the other has to: a // person who has learned the response graph on one path must not find the -// same handles doing something else on the other. That is the entire reason -// this file repeats the extension's arithmetic instead of inventing its own. +// same handles doing something else on the other. +// +// What the two do NOT share is the unit a level is measured in. The extension +// reads decibels off the browser's analyser and normalizes them; this side +// measures LINEAR amplitude, and every constant below was measured on that +// scale. inMin/inMax are therefore linear here, and in manual mode the level +// is placed between them linearly. The /audio-in page draws a dB axis and +// converts at its own boundary; the numbers the API carries are these. // // ── On the S3's quiet PDM input ───────────────────────────────────────── // @@ -83,9 +95,10 @@ struct Band { // same digital multiply. So: a runtime gain, WLED-style, user-tunable on // /audio-in. Applied only to the microphone - the synthetic source is // already full-scale and would clip. -inline float micGain = 8.0f; +constexpr float MIC_GAIN_DEFAULT = 8.0f; constexpr float MIC_GAIN_MIN = 1.0f; constexpr float MIC_GAIN_MAX = 16.0f; +inline float micGain = MIC_GAIN_DEFAULT; inline Band bands[4]; // filled by resetBands() or load(); see below @@ -102,8 +115,8 @@ inline Band bands[4]; // filled by resetBands() or load(); see below // tuned set (bands8 -> bands9 both did exactly that). The tables live under // their own keys, so an upgrade keeps the windows people measured. // -// A band with no table (lutSet 0) keeps the legacy gain exponent - the -// upgrade is invisible until the editor touches a band. +// A band with no table (lutSet 0) runs its gain exponent - which is what the +// measured defaults are, not a leftover: a factory panel has four of them. constexpr int LUT_POINTS = 33; inline uint8_t luts[4][LUT_POINTS]; inline uint8_t lutSet[4] = {0, 0, 0, 0}; @@ -128,8 +141,10 @@ inline float lutValue(int b, float u) { // one number means one feel on both paths; this side converts to its own // frame rate at use. Higher alpha = snappier; the editor shows it reversed // as "damping", tight to glassy. -inline float smoothing = 0.35f; -inline float attack = 0.65f; +constexpr float SMOOTHING_DEFAULT = 0.35f; +constexpr float ATTACK_DEFAULT = 0.65f; +inline float smoothing = SMOOTHING_DEFAULT; +inline float attack = ATTACK_DEFAULT; inline float smoothLevel[4] = {0, 0, 0, 0}; inline void smoothLevels(const float* level) { @@ -168,22 +183,32 @@ inline void smoothLevels(const float* level) { // inMin also clears the measured quiet-room floor per band (0.135/0.088/ // 0.024/0.010) so silence does not wobble the knobs. // -// One function rather than two literal tables: the HTTP reset handler and -// the fresh-boot path both call this, and the two copies they used to keep -// in sync are exactly the kind of pair that drifts. -inline void resetBands() { - const Band d[4] = { +// One table rather than a copy per caller: the fresh-boot path, the HTTP +// reset and the one-band reset all read this, and the copies they would +// otherwise keep in sync are exactly the kind of pair that drifts. The page +// asks for these (POST /api/audio-in/reset) instead of carrying its own. +inline void resetBand(int i) { + static const Band d[4] = { { 62.0f, 375.0f, 0.200f, 0.900f, 1.0f, 0.30f, 0.85f, 0, false}, { 375.0f, 1500.0f, 0.120f, 0.650f, 1.2f, 0.30f, 0.85f, 1, false}, { 1500.0f, 5000.0f, 0.045f, 0.160f, 1.6f, 0.30f, 0.85f, 2, false}, { 5000.0f, 8000.0f, 0.012f, 0.030f, 1.8f, 0.30f, 0.85f, 3, false}, }; - for (int i = 0; i < 4; i++) bands[i] = d[i]; - for (int i = 0; i < 4; i++) { - lutSet[i] = 0; - metas[i][0] = 0; - } - smoothing = 0.35f; + if (i < 0 || i > 3) return; + bands[i] = d[i]; + // No table: the band is back on its gain exponent, the measured default. + lutSet[i] = 0; + metas[i][0] = 0; +} + +// Every band, and the two speeds that belong to the mapping as a whole. Not +// the microphone switch and not auto range: those are how the panel is being +// used, not how it is tuned, and a reset that flipped either would be a +// surprise. +inline void resetBands() { + for (int i = 0; i < 4; i++) resetBand(i); + smoothing = SMOOTHING_DEFAULT; + attack = ATTACK_DEFAULT; } // The analysis is 512 points at 16 kHz, so a bin is 31.25 Hz and the last @@ -202,8 +227,27 @@ inline int binOf(float hz) { return b; } -// Ordered, inside the analysable range, and at least one bin wide - a band -// whose edges met would divide by zero in fold(). +// A setting that is a number, or `fallback`. NaN and the infinities are not +// settings. constrain() passes a NaN straight through (both of its tests are +// false), String() prints it as `nan`, and `nan` is not JSON: one stored NaN +// made GET /api/audio-in unreadable for good, and the page that could not +// read it had no way to offer the reset that would have cleared it. So nothing +// that is not finite is taken - from a request (core_audio_in_http.h) or from +// NVS (load, below). +inline float finiteOr(float v, float fallback) { return isfinite(v) ? v : fallback; } + +inline bool allFinite(const Band& b) { + return isfinite(b.hzMin) && isfinite(b.hzMax) && isfinite(b.inMin) && + isfinite(b.inMax) && isfinite(b.gain) && isfinite(b.outMin) && + isfinite(b.outMax); +} + +// Ordered, inside the analysable range, and a bin apart where there is room +// for one. Against the top there is not: a low edge within a bin of MAX_HZ +// keeps a band narrower than that (hzMin 7990 is stored as 7990..8000), and +// one that rounds to the last bin covers no bin at all. fold() reads such a +// band as silent rather than dividing by its width; this does not move the +// edge, because what a client stored is what it reads back. inline void clampRange(Band& b) { b.hzMin = constrain(b.hzMin, MIN_HZ, MAX_HZ); b.hzMax = constrain(b.hzMax, MIN_HZ, MAX_HZ); @@ -327,8 +371,9 @@ constexpr float NORM_LO_K = 1.5f; // normalization floor = ref * this constexpr float NORM_MIN_SPAN = 0.02f; constexpr float ENV_ATTACK = 0.3f; constexpr float ENV_RELEASE = 0.002f; -// The relative response window the normalized level runs through (the -// page shows the same 0.10..0.95 as the fixed in-handles in auto mode). +// The relative response window the normalized level runs through. The poll +// reply carries that normalized level (`levelsN`), so a page can place it in +// this same 0.10..0.95 and show where the knob really is. constexpr float AUTO_LO = 0.10f; constexpr float AUTO_HI = 0.95f; @@ -390,7 +435,8 @@ inline float normalized(int b, float level) { // The extension's mapBandOutput, in C++. Same clamps, same order, same edge // cases - including inMax being forced at least 0.01 above inMin, which is -// what stops a dragged handle pair from dividing by zero. +// what stops a dragged handle pair from dividing by zero. The level is placed +// in the window LINEARLY (see the header: this side's unit is amplitude). inline float mappedWindow(int b, float level, float inMinRaw, float inMaxRaw) { const Band& x = bands[b]; const float inMin = clamp01(inMinRaw); @@ -414,19 +460,6 @@ inline float mapped(int b, float level) { return mappedWindow(b, level, x.inMin, x.inMax); } -// How much of its knob a band actually uses between silence and its own -// recent peak. The extension calls this "travel" and shows it as a bar, -// because "this band is not doing anything" is the question people actually -// have, and it is not answerable from four raw numbers. -inline float travel(int b, float peakLevel) { - const Band& x = bands[b]; - const float span = fabsf(x.outMax - x.outMin); - if (span < 0.001f) return 0.0f; - return clamp01(fabsf(mapped(b, peakLevel) - mapped(b, 0.0f)) / span); -} -// (mapped() already routes through the auto window when autoRange is on, so -// travel and the page's readouts follow whichever mode is live.) - // ── Persistence ───────────────────────────────────────────────────────── // // One blob rather than 21 keys: NVS entries are the scarce thing here, and @@ -479,14 +512,19 @@ inline void load() { p.getBytes(NVS_KEY, bands, sizeof(bands)); // Saved settings are trusted to be the right shape, not to be sane: a // blob written by a build with a different sample rate would put edges - // past Nyquist, and fold() would read off the end of the spectrum. + // past Nyquist, and fold() would read off the end of the spectrum. Nor + // to be numbers: earlier builds stored a NaN when a request sent one, + // and no clamp removes it. Such a band goes back to its default (its + // curve table, read below, is kept). for (int i = 0; i < 4; i++) { + if (!allFinite(bands[i])) resetBand(i); clampRange(bands[i]); bands[i].knob = constrain(bands[i].knob, 0, 3); } } micOn = p.getBool(NVS_MIC, false); - micGain = constrain(p.getFloat(NVS_GAIN, micGain), MIC_GAIN_MIN, MIC_GAIN_MAX); + micGain = constrain(finiteOr(p.getFloat(NVS_GAIN, micGain), MIC_GAIN_DEFAULT), + MIC_GAIN_MIN, MIC_GAIN_MAX); autoRange = p.getBool(NVS_AUTO, autoRange); if (p.getBytesLength(NVS_LUTS) == sizeof(luts) && p.getBytesLength(NVS_LSET) == sizeof(lutSet)) { @@ -497,8 +535,10 @@ inline void load() { p.getBytes(NVS_META, metas, sizeof(metas)); for (int i = 0; i < 4; i++) metas[i][sizeof(metas[i]) - 1] = 0; } - smoothing = constrain(p.getFloat(NVS_SMOOTH, smoothing), 0.05f, 0.9f); - attack = constrain(p.getFloat(NVS_ATTACK, attack), 0.05f, 0.9f); + smoothing = constrain(finiteOr(p.getFloat(NVS_SMOOTH, smoothing), SMOOTHING_DEFAULT), + 0.05f, 0.9f); + attack = constrain(finiteOr(p.getFloat(NVS_ATTACK, attack), ATTACK_DEFAULT), + 0.05f, 0.9f); p.end(); } diff --git a/firmware/patternflow/features/audio_in/core_audio_pdm.h b/firmware/patternflow/features/audio_in/core_audio_pdm.h index 52b07b862..07805aa77 100644 --- a/firmware/patternflow/features/audio_in/core_audio_pdm.h +++ b/firmware/patternflow/features/audio_in/core_audio_pdm.h @@ -224,6 +224,8 @@ inline void begin() { for (int i = 0; i < WINDOW; i++) ring[i] = 0.0f; live = true; + // "PDM up: CLK" is the literal firmware/bundles/build.sh looks for in an + // image to prove this feature is in it. Reword around it, not through it. Serial.printf("[AUDIO-IN] PDM up: CLK=%d DAT=%d %luHz mono left dsr=%d\n", PF_AUDIO_IN_PDM_CLK, PF_AUDIO_IN_PDM_DAT, (unsigned long)RATE, PF_AUDIO_IN_PDM_DSR16 ? 16 : 8); diff --git a/firmware/patternflow/features/audio_in/feature_audio_in.h b/firmware/patternflow/features/audio_in/feature_audio_in.h index 59af7d11c..10f470075 100644 --- a/firmware/patternflow/features/audio_in/feature_audio_in.h +++ b/firmware/patternflow/features/audio_in/feature_audio_in.h @@ -1,31 +1,37 @@ // ═══════════════════════════════════════════════════════════ // PatternFlow - on-board audio input, as a feature // -// The fifth port onto the feature seam, and the first one that was not already -// living in the core — which makes it the more interesting test. The other -// four proved the hooks could carry features that already existed. This one -// asks whether they can carry a feature nobody has written yet. +// Owns: the descriptor, the analysis task, and the one place sound becomes +// knob lanes (fillInput). The parts it wires together each own one thing: // -// Answer so far: four hooks, no core edits, no sketch edits. +// core_audio_pdm.h the microphone - a PDM MEMS part on GPIO43/44 +// core_audio_fft.h a window of samples -> spectrum -> four band levels +// core_audio_in_map.h a band's level -> its knob; the settings and NVS +// core_audio_in_http.h /audio-in and /api/audio-in // -// setup - build the twiddle tables, and start the analysis task -// loop - run a window inline, when measuring the inline cost +// It was the first feature written on the seam rather than moved onto it, and +// it needed no core edit. The hooks it uses: +// +// setup - load the settings, build the FFT tables, start the task +// onNetwork - register the page and its API // fillInput - four bands drive the four knob lanes, exactly the way // the weather feature drives them from a temperature. A // pattern animates from sound without knowing what sound is. -// appendStatus - report what it costs, because that is the open question +// appendStatus - the `audioIn` block: what the analysis costs, and what the +// microphone is returning // -// PF_AUDIO_IN_CORE picks who pays: -// 1 the render loop (Core 1), inline. The naive placement. +// PF_AUDIO_IN_CORE picks who pays for the analysis: // 0 a task pinned to Core 0, where Wi-Fi lives and the panel does not. -// This is the arrangement that would ship, if it ships. +// What ships. +// 1 the render loop (Core 1), inline through the loop hook. Kept for +// measuring that placement against the other; no edition builds it. // -// There is no microphone yet, and that is deliberate. The panel spends -// ~10 ms of every 16.6 ms frame pushing pixels; if a spectrum does not fit -// in what is left, no microphone helps. So the cost gets measured first and -// the hardware question — a PDM mic on GPIO43/44, the only free header pins -// on this board, since N16R8's octal PSRAM claims 35-37 and everything else -// is HUB75 or an encoder — gets decided after, with a number in hand. +// The analysis was written and costed before there was a microphone - the +// panel spends ~10 ms of every 16.6 ms frame pushing pixels, and if a +// spectrum had not fitted in what is left no microphone would have helped. +// It fitted. The synthetic source that stood in for the microphone then is +// still in core_audio_fft.h, and is what the analysis runs on when the +// microphone is not delivering; see fillInput for why it stops there. // // License: MIT // ═══════════════════════════════════════════════════════════ @@ -41,8 +47,9 @@ #define PF_AUDIO_IN_CORE 0 #endif -// Off by default: while this is a measurement rather than a feature, it -// should cost a normal build exactly nothing. +// Off unless the composition says so: carrying this feature to measure it, or +// to watch the page, must not move anybody's knobs. The Audio edition turns +// it on in its overrides.h. #ifndef PF_AUDIO_IN_DRIVES_KNOBS #define PF_AUDIO_IN_DRIVES_KNOBS 0 #endif @@ -131,6 +138,16 @@ inline void fillInput(InputFrame& input) { // for. A hand on the encoder outranks both - the core drops any lane whose // knob moved this frame. if (!PFAudioInMap::micOn) return; + // Only a microphone that is delivering samples. When I2S would not install, + // or the mic stopped answering, the analysis carries on over the synthetic + // three-tone source (core_audio_fft.h fill()) - and without this line those + // tones reached the lanes as if the room were playing them: four knobs + // moving to nothing, while `source` said "synth (mic stalled)". It also + // covers the quarter second between the switch going on and the task + // installing the driver, when the levels are whatever the last session + // left. The synthetic source is for measuring the analysis, never for + // moving a panel. + if (!PFAudioPdm::available()) return; // Belt and braces over the default-off switch: somebody who ticks the box // on a panel with no microphone would otherwise pin all four knobs at // their resting position and find that turning one does not stick. diff --git a/firmware/toolchain/README.md b/firmware/toolchain/README.md index 45d1bcb98..e0ba2c076 100644 --- a/firmware/toolchain/README.md +++ b/firmware/toolchain/README.md @@ -4,7 +4,7 @@ The repository-level scripts around the firmware: what builds a module or a pack **Building things.** `build_module.py` compiles a module folder into a `.pfm` with the Xtensa toolchain; `port_preset.py` turns a curated preset header into a module folder; `make_pack.py` bundles built modules into the shareable Basics pack. `build_clock_glyphs.py` generates the Clock edition's digit glyphs. `release.py` is the two-command release (cut, publish) described in [`docs/RELEASING.md`](../../docs/RELEASING.md). -**Console pages.** The device serves each console page from a C string literal in a header. `console_pages.py` splices `../patternflow/console/*.html` into those headers (`build` to regenerate, `check` in CI); `console_serve.py` previews the pages locally; `build_audio_in_page.py` assembles the `/audio-in` page from the audio extension's editor in `tools/`. How to edit a page is [`../patternflow/console/README.md`](../patternflow/console/README.md). +**Console pages.** The device serves each console page from a C string literal in a header. `console_pages.py` splices `../patternflow/console/*.html` into those headers (`build` to regenerate, `check` in CI). One page is not written as a page: `/audio-in` is assembled by `build_audio_in_page.py` from the audio extension's mapping editor in `tools/patternflow-audio-extension/` and the panel's own half in `../patternflow/console/_audio_in_*`, with comments and indentation dropped on the way. `console_pages.py build` runs that step first, so one command regenerates everything; `console_pages.py check` and `build_audio_in_page.py --check` fail when the generated page is not what its sources assemble to, or is over its size budget (`BUDGET` in the script: bytes gzipped, as the panel sends the page). `console_serve.py` previews the pages locally against a mock panel, assembling `/audio-in` from its sources on every request; its audio endpoints are a port of the firmware's handlers, with `/mock?audio=` choosing what the panel hears, and when a handler in `features/audio_in/` or `features/audio/` changes that port changes with it. How to edit a page is [`../patternflow/console/README.md`](../patternflow/console/README.md). **Checks.** These are what `firmware-checks.yml` runs on every pull request that touches the firmware, and what you run before pushing: diff --git a/firmware/toolchain/build_audio_in_page.py b/firmware/toolchain/build_audio_in_page.py index dce59f609..ba1740c2f 100644 --- a/firmware/toolchain/build_audio_in_page.py +++ b/firmware/toolchain/build_audio_in_page.py @@ -1,447 +1,476 @@ # -*- coding: utf-8 -*- -"""Assemble console/audio-in.html from the extension's mapping editor. - - python firmware/toolchain/build_audio_in_page.py - python firmware/toolchain/console_pages.py build # then bake the header - -The editor module (tools/patternflow-audio-extension/editor.js) is the single -source of truth for the mapping UI - the port philosophy is COPY, not -re-extract (port_audio_ui.py's function-lifting era ended with it; that script -is retired). This assembler: - - - reads editor.html, editor.css and editor.js from the extension, - - swaps the cream instrument tokens for the console's dark ones (editor.js - reads its canvas colors from CSS variables, so the theme travels free) - and adds the phone layout the extension's wide tab never needed, - - injects a device bar (microphone switch, input gain, reset) above the - editor - the two controls that exist only on this side, - - replaces the chrome adapter with a fetch('/api/audio-in') adapter that - also converts levels between the device's linear scale and the editor's - dB-normalized display axis (calibration constants live HERE, in JS, - tweakable without a reflash). - -Run it after editing the editor, then console_pages.py build. CI's page sync -check keeps the generated header honest; nothing checks that YOU reran this - -the marker comment carries the source hash so drift is at least visible. +"""Assemble console/audio-in.html, the panel's /audio-in page, from its sources. + + python firmware/toolchain/console_pages.py build # the build: this page, then every header + python firmware/toolchain/console_pages.py check # everything in sync, this page included + + python firmware/toolchain/build_audio_in_page.py # only assemble this page + python firmware/toolchain/build_audio_in_page.py --check # CI: page == sources, and inside its budget + --sketch DIR work on another copy of firmware/patternflow (as console_pages.py) + +/audio-in is the one console page that is not written as a page. Its editor is +the browser extension's mapping editor, copied, never re-extracted, so the two +cannot drift apart; what is the panel's own sits beside the other console +sources. This script holds none of their text: + + tools/patternflow-audio-extension/ + editor.html the markup: what is between its two EDITOR BODY comment lines + is taken (the editor itself; the extension's own source bar + is above them and stays behind), and the \n' - '\n' - 'Patternflow - Mic mapping\n' - '\n' - '\n' - '\n\n' - + body + - '\n\n' - '\n' - '\n\n' -) +SKETCH = ROOT / 'firmware' / 'patternflow' + +PAGE = 'audio-in' +EDITOR_HTML, EDITOR_CSS, EDITOR_JS = 'editor.html', 'editor.css', 'editor.js' +EXT_ADAPTER = 'editor-adapter.js' # the extension's PFAdapter; the panel's goes in its place +BAR_FILE, CSS_FILE, ADAPTER_FILE = '_audio_in_bar.html', '_audio_in.css', '_audio_in_adapter.js' +# The line a shared script is taken down to: a comment that says only this +# (a rule drawn around the words is fine; a sentence that mentions them is not). +EXTENSION_ONLY = re.compile(r'//\W*EXTENSION ONLY\W*') +EXTENSION_ONLY_CSS = re.compile(r'/\*\W*EXTENSION ONLY\W*\*/') +# editor.html: the editor itself is what is between these two lines. Above them +# is the extension's own source bar, which the panel replaces with its frame. +BODY_BLOCK = re.compile(r'(.*)', re.S) +# _audio_in_bar.html: the line where the editor's body goes. +BODY_SLOT = re.compile(r'') + +# Bytes of the page as the panel sends it: stamped by console_pages.py (the +# chrome's ?h= and the fallback PF), gzip -9. This is the heaviest page the +# console has, and on the panel's own hotspot (2-5 KB/s measured) a kilobyte is +# 0.2 to 0.5 s before anything paints - so, like check_footprint.py's PINS, it +# is pinned, and growing it is a decision somebody makes rather than something +# that happens. It was 19,920 before the page stopped shipping its sources' +# comments and about 15,300 right after. To raise it, change the number and say +# in the commit what the bytes bought. (zlib builds differ by tens of bytes for +# the same input; a page that close to the line is over it.) +BUDGET = 17500 + +# The panel's TCP send buffer (lwIP TCP_SND_BUF; console_serve.py's TCP_WINDOW +# is the same number, and its --slow link is where this was measured). A reply +# leaves one of these at a time and the next waits for the last to be +# acknowledged, so a page one byte into another window costs a whole round +# trip more: 0.4 s there, for the document and for everything behind it. The +# budget is the line the build holds; a multiple of this is the line worth +# getting under inside it, and weight() says how full the page's last window +# is: a few dozen bytes in it are a round trip that taking them out gives back. +# This is the mock's model of the link, counted on the body alone. On a panel +# the reply's headers go into the first window too, and lwIP may start with +# less than a full buffer; where the steps fall there has not been measured. +WINDOW = 5760 + +# The one comment that does travel: whoever opens the generated page is told +# not to edit it. Short, because the panel serves it too. +GENERATED = '' + + +class BuildError(ValueError): + """A source this page cannot be assembled from; the message says which.""" + + +# ── the sources ──────────────────────────────────────────────────────────── + +def console_dir(sketch=None) -> Path: + return Path(sketch or SKETCH) / 'console' + + +def out_path(sketch=None) -> Path: + return console_dir(sketch) / (PAGE + '.html') + + +def shown(path) -> str: + try: + return Path(path).resolve().relative_to(ROOT).as_posix() + except ValueError: + return str(path) + + +def text_of(path: Path) -> str: + try: + return path.read_text(encoding='utf-8') # universal newlines: LF from here on + except OSError as e: + raise BuildError('%s: %s' % (shown(path), e.strerror or e)) + + +SCRIPT_TAG = re.compile(r'') + + +def script_files(editor_html: str, sketch=None) -> list: + """(name, path) of each script the page carries: the ones editor.html + loads, in its order, with the extension's adapter swapped for the panel's.""" + names = SCRIPT_TAG.findall(editor_html) + if len(names) != editor_html.lower().count('; one here is not') + for need in (EXT_ADAPTER, EDITOR_JS): + if names.count(need) != 1: + raise BuildError('%s: must load %s exactly once' % (EDITOR_HTML, need)) + return [(ADAPTER_FILE, console_dir(sketch) / ADAPTER_FILE) if name == EXT_ADAPTER + else (name, EXT / name) for name in names] + + +def sources(sketch=None) -> list: + """The files the page is assembled from. A preview server watches these + to know the page changed; it never raises, so that it can.""" + console = console_dir(sketch) + try: + scripts = [path for _, path in script_files(text_of(EXT / EDITOR_HTML), sketch)] + except BuildError: + scripts = [console / ADAPTER_FILE, EXT / EDITOR_JS] + return [EXT / EDITOR_HTML, EXT / EDITOR_CSS, console / BAR_FILE, console / CSS_FILE] + scripts + + +# ── what does not travel ─────────────────────────────────────────────────── +# Each takes a source's text and the name to blame, and returns it lean. See +# the docstring for why these are line rules and what they refuse. + +def lean_lines(lines) -> str: + return '\n'.join(s for s in (line.strip() for line in lines) if s) + + +def lean_js(text: str, name: str) -> str: + out = [] + for n, line in enumerate(text.split('\n'), 1): + s = line.strip() + if EXTENSION_ONLY.fullmatch(s): + break + if s.startswith('//') and '*/' in s: + raise BuildError( + '%s:%d: a // line holding */. If it closes a block comment, dropping ' + 'the line would leave that comment open; reword it.' % (name, n)) + if not s or s.startswith('//'): + continue + if s.count('`') % 2: + raise BuildError( + '%s:%d: an odd number of backticks. A template literal that runs past ' + 'the end of its line would have its inside stripped; keep each on one ' + 'line (or build the string with +).' % (name, n)) + if s.endswith('\\'): + raise BuildError( + '%s:%d: the line ends in a backslash. A string continued onto the next ' + 'line would lose that line\'s indentation; join it with + instead.' % (name, n)) + out.append(s) + text = '\n'.join(out) + if ' early' % name) + return text + + +# A CSS line that is left as written: it holds a string, an escape or a url(), +# and telling the inside of one from the sheet's own punctuation is what would +# need a tokenizer. +CSS_AS_WRITTEN = re.compile(r'''["'\\]|url\(''', re.I) + + +def lean_css(text: str, name: str) -> str: + text = EXTENSION_ONLY_CSS.split(text, 1)[0] + out, at = [], 0 + while True: + a = text.find('/*', at) + if a < 0: + out.append(text[at:]) + break + out.append(text[at:a]) + before = ''.join(out).rsplit('\n', 1)[-1] # this line, earlier comments already gone + if before.count('"') % 2 or before.count("'") % 2: + raise BuildError( + '%s:%d: /* inside a quoted string; this build would cut it out as a comment' + % (name, text.count('\n', 0, a) + 1)) + b = text.find('*/', a + 2) + if b < 0: + raise BuildError('%s:%d: a /* comment that never closes' + % (name, text.count('\n', 0, a) + 1)) + # A comment separates tokens (`a/**/b` is not `ab`), and it keeps its + # line breaks so that a line number below is still the source's. + out.append(' ' + '\n' * text.count('\n', a, b)) + at = b + 2 + + # White space the sheet does not need. Inside a line: around { } ; and , + # and after a colon (never before one: `a :hover` is not `a:hover`), and + # the semicolon that ends a block. Between lines: a break goes where it + # follows { ; , or comes before { }, which leaves one rule to a line, as + # the console's pages are written. Such a break is always between tokens + # (a string cannot be open at the end of a line, and the one way to make + # it so is refused), and every other break stays: `.a` over `.b {` is + # still two selectors. + text, written = '', False # written: the line just added was left as written + for n, line in enumerate(''.join(out).split('\n'), 1): + s = line.strip() + if not s: + continue + if s.endswith('\\'): + raise BuildError( + '%s:%d: the line ends in a backslash. A string continued onto the next ' + 'line would be joined to it wrongly; keep it on one line.' % (name, n)) + as_written = bool(CSS_AS_WRITTEN.search(s)) + if not as_written: + s = re.sub(r'\s*([{};,])\s*', r'\1', s) + s = re.sub(r':\s+', ':', s).replace(';}', '}') + if text and (text[-1] in '{;,' or s[0] in '{}'): + if text[-1] == ';' and s[0] == '}' and not written: + text = text[:-1] + text += s + else: + text += ('\n' if text else '') + s + written = as_written + if ' early' % name) + return text + + +def lean_html(text: str, name: str) -> str: + for tag in ('pre', 'textarea', 'script', 'style'): + if re.search(r'<%s\b' % tag, text, re.I): + raise BuildError( + '%s: <%s> in the markup. This build strips indentation and comments ' + 'line by line, which is not safe inside one.' % (name, tag)) + text = re.sub(r'', '', text, flags=re.S) + if ' line and a line') + frame = BODY_SLOT.split(text_of(console / BAR_FILE)) + if len(frame) != 2: + raise BuildError(BAR_FILE + ': needs exactly one line, where the ' + 'editor\'s body goes') + body = '\n'.join(part for part in ( + lean_html(frame[0], BAR_FILE), + lean_html(block.group(1), EDITOR_HTML), + lean_html(frame[1], BAR_FILE)) if part) + + css = '\n'.join((lean_css(text_of(EXT / EDITOR_CSS), EDITOR_CSS), + lean_css(text_of(console / CSS_FILE), CSS_FILE))) + + # One '] + + return '\n'.join([ + '', + '', + '', + '', + '', + '', + 'Patternflow - Audio', + GENERATED, + '', + '', + '', + body, + ] + scripts + [ + '', + '', + '', + ]) + + +def stamped_gzip(page: str, sketch=None) -> int: + """Bytes of `page` as the panel sends it: console_pages.py's own stamp and + its own gzip, so this cannot measure something other than what ships.""" + if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + import console_pages as cp + + sk = Path(sketch or SKETCH) + name, rel, delim = cp.CHROME + try: + _, chrome, _ = cp.split(cp.read(str(sk / rel)), name, delim) + shim = cp.load_shim(str(sk / 'console' / cp.SHIM_FILE)) + stamped = cp.stamp(page, cp.crc_of(chrome), shim, PAGE) + except (OSError, ValueError) as e: + raise BuildError(str(e)) + return len(gzip.compress(cp.payload(stamped), compresslevel=9, mtime=0)) + + +def weight(page: str, sketch=None): + """(gzip bytes as the panel sends it, one line saying so against the budget + and against the send windows it takes).""" + gz = stamped_gzip(page, sketch) + windows = -(-gz // WINDOW) + last = gz - (windows - 1) * WINDOW + return gz, ('%d bytes, %d gzip -9 as the panel sends it (budget %d, %s; ' + '%d send windows of %d: %d in the last, %d left in it)' % ( + len(page.encode('utf-8')), gz, BUDGET, + '%d spare' % (BUDGET - gz) if gz <= BUDGET else '%d OVER' % (gz - BUDGET), + windows, WINDOW, last, WINDOW - last)) + + +def on_disk(sketch=None): + """The page as written, LF, or None when there is none.""" + out = out_path(sketch) + return out.read_text(encoding='utf-8') if out.exists() else None + + +def write(sketch=None): + """Assemble and write the page. Returns (page, changed). Line endings stay + the file's own (a Windows checkout is CRLF, as every file around it), and a + page that already says this is not rewritten, so a build with nothing to + do touches nothing.""" + page = assemble(sketch) + out = out_path(sketch) + if on_disk(sketch) == page: + return page, False + nl = '\r\n' if out.exists() and b'\r\n' in out.read_bytes() else '\n' + with io.open(out, 'w', encoding='utf-8', newline='') as f: + f.write(page.replace('\n', nl)) + return page, True + + +def check(sketch=None) -> list: + """What is wrong, as lines to print; [] when the page on disk is what the + sources assemble to and is inside its budget.""" + try: + page = assemble(sketch) + gz, _ = weight(page, sketch) + except BuildError as e: + return ['console/%s.html cannot be assembled: %s' % (PAGE, e)] + problems = [] + if on_disk(sketch) != page: + problems.append( + 'console/%s.html is not what its sources assemble to (one of them was ' + 'edited, or the page was edited by hand).\n' + ' run: python firmware/toolchain/console_pages.py build' % PAGE) + if gz > BUDGET: + problems.append( + 'console/%s.html is %d bytes gzip -9 as the panel sends it: %d over its ' + 'budget of %d.\n' + ' On the panel\'s own hotspot every kilobyte is 0.2 to 0.5 s before anything ' + 'paints.\n' + ' Take the bytes back out, or raise BUDGET in ' + 'firmware/toolchain/build_audio_in_page.py and say in the commit what they bought.' + % (PAGE, gz, gz - BUDGET, BUDGET)) + return problems + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser( + description='Assemble console/audio-in.html from its sources. ' + 'The usual way in is console_pages.py build.') + ap.add_argument('--check', action='store_true', + help='write nothing: fail if the page on disk is not what the sources ' + 'assemble to, or is over its size budget') + ap.add_argument('--sketch', metavar='DIR', + help='another copy of firmware/patternflow to read console/_audio_in* ' + 'from and write the page into') + args = ap.parse_args(argv) + # Sources are UTF-8 and so are their names in a report; a cp949 console + # must not turn one into a traceback. + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, 'reconfigure'): + stream.reconfigure(errors='replace') + + if args.check: + problems = check(args.sketch) + for problem in problems: + print(problem, file=sys.stderr) + if problems: + return 1 + print('console/%s.html is what its sources assemble to: %s' + % (PAGE, weight(assemble(args.sketch), args.sketch)[1])) + return 0 + + try: + page, changed = write(args.sketch) + gz, line = weight(page, args.sketch) + except BuildError as e: + print('build_audio_in_page: %s' % e, file=sys.stderr) + return 1 + print('%s %s: %s' % ('wrote' if changed else 'unchanged', shown(out_path(args.sketch)), line)) + if gz > BUDGET: + print(' over its size budget: --check (and CI) fails until it is not', file=sys.stderr) + if changed: + print('the header still has to be baked: python firmware/toolchain/console_pages.py build ' + '(which also does this step)') + return 0 -import sys -if '--check' in sys.argv: - # CI: the page on disk must be what this script would write now — so an - # edit to the extension's editor, or to the adapter/overrides above, that - # was not baked into console/audio-in.html fails the build instead of - # shipping a stale page under a fresh-looking header. - current = OUT.read_text(encoding='utf-8') if OUT.exists() else '' - if current == page: - print('console/audio-in.html is up to date (editor %s)' % src_hash) - sys.exit(0) - print('console/audio-in.html is stale: rerun firmware/toolchain/build_audio_in_page.py, ' - 'then firmware/toolchain/console_pages.py build', file=sys.stderr) - sys.exit(1) - -io.open(OUT, 'w', encoding='utf-8', newline='\n').write(page) -print('wrote', OUT.relative_to(ROOT).as_posix(), len(page), 'bytes (editor %s)' % src_hash) +if __name__ == '__main__': + sys.exit(main()) diff --git a/firmware/toolchain/console_pages.py b/firmware/toolchain/console_pages.py index bed535177..3b74d9b1c 100644 --- a/firmware/toolchain/console_pages.py +++ b/firmware/toolchain/console_pages.py @@ -85,6 +85,13 @@ `extract` is lossless by construction (build right after it is a no-op), and `build` is idempotent. +One page is not written as a page. console/audio-in.html is assembled by +build_audio_in_page.py from the audio extension's mapping editor and the +console/_audio_in* files (an underscore marks a source that is not a page; +PAGES below is the list of pages). `build` assembles it first and `check` +holds it to its sources and to its size budget, so there is still one command +to build and one to check. Edit those sources, never audio-in.html. + --sketch DIR work on another copy of firmware/patternflow (a scratch copy to try a change of this script against) """ @@ -388,6 +395,18 @@ def unstamp(body, name="page"): return STAMPED_RE.sub(lambda m: CHROME_TAG, body) +# ── the page that is assembled ───────────────────────────────────────────── + +def assembled(): + """build_audio_in_page: console/audio-in.html is made by it, from sources + that are not pages (see the docstring). It takes the sketch as an argument + and reads this file's functions, never its state, so --sketch holds.""" + if HERE not in sys.path: + sys.path.insert(0, HERE) + import build_audio_in_page + return build_audio_in_page + + # ── commands ─────────────────────────────────────────────────────────────── def stamping(): @@ -417,6 +436,16 @@ def cmd_build(): crc, shim = stamping() changed = [] pages_gz = chrome_gz = 0 + gen = assembled() + try: + page, wrote = gen.write(SKETCH) + gz, weight = gen.weight(page, SKETCH) + except ValueError as e: + raise SystemExit("%s: %s" % (gen.PAGE, e)) + print(" %s.html %s its sources: %s" + % (gen.PAGE, "assembled from" if wrote else "already matches", weight)) + if gz > gen.BUDGET: + print(" %s: over its size budget; `check` (and CI) fails until it is not" % gen.PAGE) print(" stamping chrome h=%s, fallback %d bytes" % (crc, len(shim))) for name, rel, delim, src in targets(): dest = header_path(rel) @@ -480,13 +509,22 @@ def cmd_check(): problem = gz_problem(block, ident, storage, body) if problem: stale.append("%s (%s)" % (name, problem)) + # The assembled page against its own sources and its size budget. A header + # can match a page that no longer matches what it is made from. + gen = assembled() + problems = gen.check(SKETCH) if stale: print("out of sync: " + ", ".join(stale)) print("run: python firmware/toolchain/console_pages.py build") + for problem in problems: + print(problem) + if stale or problems: return 1 print("all %d console headers match their HTML, stamped with chrome h=%s; " "all %d gzip twins decompress to their literals" % (len(PAGES), crc, len(PAGES) + len(ASSETS))) + print("%s.html is what its sources assemble to: %s" + % (gen.PAGE, gen.weight(gen.assemble(SKETCH), SKETCH)[1])) return 0 diff --git a/firmware/toolchain/console_serve.py b/firmware/toolchain/console_serve.py index 98da06f66..51acca585 100644 --- a/firmware/toolchain/console_serve.py +++ b/firmware/toolchain/console_serve.py @@ -28,6 +28,40 @@ here, and a page that works against the mock still has to be tried on a device before you believe it. +One exception, because one page is an editor: /audio-in. Its endpoints +(/api/audio-in, /api/audio-in/reset, /api/audio) are a port of the firmware's +handlers, not a fixture: a request changes only the fields it carries, the +pairs settle as they do on the panel, a bad curve table is ignored with +{"ok":true}, replies have the handler's field order and decimals. The +sound is made up; what is done with it is not. And what the panel hears is +a state you pick, each entered as if it had been so for a while (each of +these addresses lands on /audio-in): + + /mock?audio=music the microphone on, a room with music in it (the default) + /mock?audio=silence on, a quiet room: the gates shut, every knob at rest + /mock?audio=off switched off after music: the analysis is parked and + the poll's numbers are the last window's, not zeros + /mock?audio=fresh a panel nobody has configured: off, nothing heard, + nothing learned, the factory mapping + /mock?audio=nomic on, nothing on the data pin: + source "pdm (no mic - data pin idle)" + /mock?audio=stalled on, the microphone stopped answering: source + "synth (mic stalled)", and the numbers are the + built-in test tones (levels far above 1), not a room + /mock?audio=phone off, the phone app posting monitor frames: source + "phone", ext:true, its own 0..1 units - while levelsN + and outputs stay the microphone's stale ones + /mock?audio=ext off, the browser extension holding the :81 socket + (audioClients 1); nothing of its sound reaches a page + /mock?audio=noread the configuration read (GET /api/audio-in) gets no + reply, and only that: the poll and every POST still + answer. Any other state ends it. + +The switches on the page work from any of them (mic=1 says "synth" for the +quarter second the driver takes, as on the panel), and the routes follow the +caps: /mock?caps=patterns,params,sleep,audio-in is the microphone without the +browser path, where /api/audio is a 404. + Pages and the chrome, though, go out exactly as the panel sends them (src/core_send.h): each page stamped by console_pages.py's own code (the chrome's ?h= and the fallback PF), with the same ETag, the same 304, and the @@ -36,7 +70,9 @@ `h`, and nothing cacheable under a Host that is not an address, a bare name or .local. The build here changes whenever a page, the chrome or the fallback is saved, so a saved edit still shows on refresh: the old `v` is -redirected. +redirected. /audio-in is not read off disk: it is assembled from its sources +(build_audio_in_page.py, on every request), so saving the editor, the +adapter or the panel's CSS shows on refresh like any other page. The link can be made as bad as the panel's. On the desk every request is instant and parallel; on the panel's own hotspot it is a phone in power @@ -54,16 +90,25 @@ --chrome404 /pf-console.js answers 404: the pages run on the fallback PF stamped into them --sketch DIR serve another copy of firmware/patternflow + +Timing something on the desk? Open it as http://127.0.0.1:8322. This listens +on IPv4 only, a browser tries IPv6 first under the name localhost, and every +request here is a connection of its own - so that detour is paid each time +(Chromium on Windows: /audio-in polls 9.4 times a second under 127.0.0.1 and +4.8 under localhost). """ import argparse import gzip import json +import math import threading import time import os import posixpath +import random import re +import struct import sys import urllib.parse import zlib @@ -75,6 +120,7 @@ sys.path.insert(0, HERE) import console_pages # noqa: E402 the stamp is its code, not a copy of it +import build_audio_in_page # noqa: E402 /audio-in is assembled, by its code PORT = int(os.environ.get("PF_CONSOLE_PORT", "8322")) @@ -110,7 +156,10 @@ class Link: REVALIDATE = "no-cache" NO_STORE = "no-store" DESK_CACHE = "no-cache" -TCP_WINDOW = 5760 # lwIP TCP_SND_BUF on the panel: one round trip per this many bytes +# lwIP TCP_SND_BUF on the panel: one round trip per this many bytes of a reply's +# body. build_audio_in_page.py counts /audio-in in the same number (its WINDOW), +# so the weight line it prints is in this link's steps; change them together. +TCP_WINDOW = 5760 def payload_etag(payload): @@ -168,7 +217,7 @@ def host_cacheable(host): CHROME_SRC = RAW_ASSETS["/pf-console.js"][:2] CAPS_FULL = ["patterns", "params", "sleep", "osc", "shows", "weather", "mqtt", "clock", - "audio-in", "midi"] + "audio", "audio-in", "midi"] CAPS_BARE = ["patterns", "params", "sleep"] @@ -201,13 +250,19 @@ def console_build(): """The panel's `build` changes whenever its pages could have; here, whenever a page, the chrome, the fallback or the stamping code is saved - so the self-heal path runs on the desk, and an immutable page never outlives - an edit.""" + an edit. The assembled page's sources count too: most of them are not in + console/ at all (the editor is the extension's).""" h = 0 for name in sorted(os.listdir(HTML_DIR)): if name.endswith((".html", ".js")): h = zlib.crc32(("%s:%d" % (name, os.stat(os.path.join(HTML_DIR, name)).st_mtime_ns)).encode(), h) for f in (os.path.join(SKETCH, "src", "theme_index.h"), os.path.join(HERE, "console_pages.py")): h = zlib.crc32(str(os.stat(f).st_mtime_ns).encode(), h) + for f in build_audio_in_page.sources(SKETCH) + [build_audio_in_page.__file__]: + try: + h = zlib.crc32(str(os.stat(f).st_mtime_ns).encode(), h) + except OSError: + h = zlib.crc32(b"missing", h) # assemble() says which, when the page is asked for return "%08x" % (h & 0xFFFFFFFF) # What the hotspot's channel scan "saw", for the /wifi page's name suggestions. SEEN_NETWORKS = ["wifiiii", "SK_WiFiGIGA7A1C", "olleh_WiFi_2F3B", "U+Net4E21", "iptime"] @@ -265,7 +320,7 @@ def __init__(self): def status(self): p = self.patterns[self.active] if self.patterns else None hs = getattr(self, "hotspot", {"mode": "auto", "up": False}) - return { + return dict({ "version": "3.10.4", "build": console_build(), "variant": self.variant, @@ -311,7 +366,7 @@ def status(self): "mqttRole": "off", "mqttState": "off", "mqttConnected": False, - } + }, **AUDIO.status(self.caps)) # appended by the features, when the build has them def show_state(self): cur = next((s for s in self.shows if s["slug"] == self.show_slug), None) @@ -333,6 +388,775 @@ def show_state(self): } +# ── the audio features: /api/audio-in and /api/audio ───────────────────── +# +# Everything else here answers with a fixture. This cannot: /audio-in is an +# editor, and what has to be right on the desk is what the firmware DOES with +# what the page sends it - a request carrying one edge of one box, a pair of +# fields that settle together, a curve table refused without a word, levels +# that are stale rather than zero. A kinder mock hides exactly the mistakes +# that lose somebody's mapping on a panel. +# +# So this part is a port, not a fixture: of features/audio_in/ +# core_audio_in_http.h (handleGet, handleSet, applyBandArgs, handleReset, +# parseFrame), core_audio_in_map.h (the defaults, clampRange, the gate, the +# mapping), the analysis task in feature_audio_in.h and the two /api/audio +# routes in features/audio/feature_audio.h - same order of fields, same +# decimals, same clamps, same refusals. docs/rest-api.md ("Microphone") is the +# contract in prose. WHEN ONE OF THOSE HANDLERS CHANGES, CHANGE THIS WITH IT. +# +# What is not ported is the sound. There is no FFT here: Room below is what one +# would have produced, for a room this file makes up. + +BIN_HZ = 16000.0 / 512.0 # a 512-point transform at 16 kHz +MAX_BIN = 256 +MIN_HZ, MAX_HZ = BIN_HZ, MAX_BIN * BIN_HZ +LUT_POINTS = 33 +SPEC_BUCKETS = 64 +BAND_FIELDS = ("hzMin", "hzMax", "inMin", "inMax", "gain", "outMin", "outMax", "knob", + "muted", "lut", "meta") +# core_audio_in_map.h resetBand(): hzMin hzMax inMin inMax gain outMin outMax +BAND_DEFAULTS = ((62.0, 375.0, 0.200, 0.900, 1.0, 0.30, 0.85), + (375.0, 1500.0, 0.120, 0.650, 1.2, 0.30, 0.85), + (1500.0, 5000.0, 0.045, 0.160, 1.6, 0.30, 0.85), + (5000.0, 8000.0, 0.012, 0.030, 1.8, 0.30, 0.85)) +AUDIO_ROUTES = ("/api/audio-in", "/api/audio-in/reset", "/api/audio") + +# /mock?audio=: the microphone switch, what is on the data pin, whether +# the phone app is posting monitor frames, how many senders hold the :81 +# socket. Each is entered as if the panel had been that way for a while. +# +# The start: the gate's noise reference is whatever the first two seconds of +# analysis bottom out at (trackEnvelopes' warm-up), and the first window is +# half empty with the transform's own smoothing climbing from zero - so a +# microphone that comes up in a steady room teaches the reference a fraction +# of the real level, and the gate then stands open on a sound that never +# changes. The real headers do exactly that when built on a host and fed a +# steady tone (reference at half the level, gate open, knob at its top). +# Whether a panel does it in a quiet room depends on what its microphone's +# first windows really hold, and nobody at this desk has measured that. The +# room here starts with a pop (see window()), which lands the reference on +# the floor: the behaviour the firmware's own notes describe. Until a panel +# says otherwise, "silence: every gate shut" is that assumption. +AUDIO_STATES = { + # switch, on the pin, phone frames, socket senders + "music": (True, "music", False, 0), + "silence": (True, "silence", False, 0), + "off": (False, "music", False, 0), + "fresh": (False, "music", False, 0), + "nomic": (True, "rail", False, 0), + "stalled": (True, "tones", False, 0), + "phone": (False, "music", True, 1), + "ext": (False, "music", False, 1), +} + + +def f32(v): + """A C float. Every number the firmware keeps is one, and what it prints is + the float's value, not the decimal the request carried.""" + try: + return struct.unpack("f", struct.pack("f", v))[0] + except OverflowError: + return math.copysign(math.inf, v) + + +def fstr(v, prec): + """Arduino's String(float, prec), which is the ESP32 core's dtostrf: half a + last digit is added and the rest cut off, digit by digit. Not printf: an + input gain of 8.25 reads back as 8.3 here and on the panel, 8.2 from '%.1f'.""" + if v != v: + return "nan" + if v in (math.inf, -math.inf): + return "inf" + neg = v < 0.0 + if neg: + v = -v + rounding = 2.0 + for _ in range(prec): + rounding *= 10.0 + v += 1.0 / rounding + tenpow, digits = 1.0, 1 + while v >= 10.0 * tenpow: + tenpow *= 10.0 + digits += 1 + v /= tenpow + out = ["-"] if neg else [] + left = digits + prec + while left > 0: + left -= 1 + d = min(9, int(v)) + out.append(chr(48 + d)) + if left == prec and prec > 0: + out.append(".") + v = (v - d) * 10.0 + return "".join(out) + + +ATOF = re.compile(r"[ \t\n\v\f\r]*([+-]?(?:[0-9]+\.?[0-9]*(?:[eE][+-]?[0-9]+)?" + r"|\.[0-9]+(?:[eE][+-]?[0-9]+)?|inf(?:inity)?|nan))", re.I) +ATOL = re.compile(r"[ \t\n\v\f\r]*([+-]?[0-9]+)") + + +def to_float(s): + """String::toFloat(): the number the text starts with, 0 when it starts + with none - so "abc" is 0, "1.5x" is 1.5 and "nan" is a NaN. Every clamp + lets a NaN through, so nothing takes one from here without finite_or().""" + m = ATOF.match(s) + return f32(float(m.group(1))) if m else 0.0 + + +def finite_or(v, fallback): + """PFAudioInMap::finiteOr(): NaN and the infinities are not settings. The + field keeps what it had (and a number too large for a float is an inf).""" + return v if math.isfinite(v) else fallback + + +def to_int(s): + m = ATOL.match(s) + return int(m.group(1)) if m else 0 + + +def constrain(v, lo, hi): + return lo if v < lo else hi if v > hi else v + + +def clamp01(v): + return 0.0 if v < 0.0 else 1.0 if v > 1.0 else v + + +def truthy(v): + return v in ("1", "true") + + +def bin_of(hz): + """core_audio_in_map.h binOf(). A NaN edge lands on bin 1 here; on the + panel that cast is undefined, and nothing sane depends on it.""" + b = int(hz / BIN_HZ + 0.5) if hz == hz else 1 + return 1 if b < 1 else MAX_BIN if b > MAX_BIN else b + + +class Args: + """server.hasArg() and server.arg(), over what the panel's web server + would have parsed (src/webserver/Parsing.cpp). Its rules, because a page + can be wrong about each of them and still work on a kinder parser: + + - the query string first, then the body, and arg() is the FIRST value; + - the body only under Content-Type: application/x-www-form-urlencoded. A + POST without that header reaches the handler with no body arguments at + all - a `band=2` reset sent that way resets every band; + - a name with no `=` is not an argument (`?levels` is the configuration); + - a name with an empty value is one (`lut2=` clears a table). + + Not read here, though the panel reads it: a multipart body (FormData). + + Values are kept as bytes-in-a-str (latin-1), as the panel keeps bytes: a + curve's `meta` is cut to 31 of them, not 31 characters.""" + + def __init__(self, query, body, ctype): + data = query + if body and ctype.startswith("application/x-www-form-urlencoded"): + data = (data + "&" if data else "") + body + self.list = [] + for part in data.split("&"): + k, eq, v = part.partition("=") + if eq: + self.list.append(tuple( + urllib.parse.unquote_to_bytes(x.replace("+", " ")).decode("latin-1") + for x in (k, v))) + + def has(self, name): + return any(k == name for k, _ in self.list) + + def get(self, name): + return next((v for k, v in self.list if k == name), "") + + def num(self, name, fallback): + """argFloat(): absent, empty, or not a finite number leaves the + stored value alone.""" + v = self.get(name) + return finite_or(to_float(v), fallback) if v else fallback + + +def parse_lut(csv): + """33 samples, 0..255, commas and digits only; anything else is no table.""" + out, acc, has = [], 0, False + for c in csv + ",": + if "0" <= c <= "9": + acc, has = acc * 10 + ord(c) - 48, True + if acc > 255: + return None + elif c == ",": + if not has or len(out) >= LUT_POINTS: + return None + out.append(acc) + acc, has = 0, False + else: + return None + return out if len(out) == LUT_POINTS else None + + +class Room: + """What a window of sound would have come out of the transform as: a + magnitude per FFT bin, in the firmware's unit (linear, unnormalized), built + from a few fixed shapes whose loudness is all that changes from one window + to the next. fold() and spectrum() are averages over bins, so the mock + takes the same averages of these. + + The numbers are sized from core_audio_in_map.h's own measurements at input + gain 8: the quiet-room medians (0.146 / 0.040 / 0.020 / 0.017 per default + band) for the floor, and music's p10..p90 (0.24..0.66, 0.16..0.49, + 0.05..0.12, 0.011..0.019) for the four voices - including the top band + barely clearing its own noise, which is the content and not a bug.""" + + FLOOR = ((31, 0.30), (62, 0.26), (125, 0.185), (250, 0.118), (375, 0.078), (750, 0.044), + (1500, 0.027), (3000, 0.019), (5000, 0.017), (8000, 0.016)) + # centre Hz, width in decades, loudness + VOICES = ((150.0, 0.45, 0.76), # kick and bass + (750.0, 0.26, 0.52), # the mids: chords, a voice + (2600.0, 0.22, 0.125), # presence + (6600.0, 0.10, 0.09)) # hats: the only thing the top band hears + # The built-in three-tone test signal (120 / 900 / 4200 Hz), which the + # analysis runs on when the microphone is not delivering: its 64-bucket + # spectrum as the real handler printed it on a host build (GET + # ?levels=1 with the I2S install failing). Nothing here is under 1.0 by + # accident - the transform is not normalized. + TONES = (0.89, 0.89, 0.89, 0.89, 0.89, 4.416, 4.416, 4.416, 4.416, 4.416, 4.416, 79.407, + 79.407, 79.407, 79.407, 125.878, 125.878, 125.878, 48.977, 48.977, 2.49, 2.49, + 0.699, 0.699, 0.295, 0.152, 0.089, 0.089, 0.056, 0.037, 0.021, 0.011, 0.006, + 0.003, 0.012, 0.034, 0.103, 0.513, 26.728, 34.585, 0.267, 0.043, 0.015, 0.006, + 0.003, 0.002, 0.001, 0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.001, 0.001, 0.013, 8.995, + 0.023, 0.001, 0.0, 0.0, 0.0, 0.0, 0.0) + + def __init__(self): + # PFAudioFFT::spectrum(): which bins each of the 64 log buckets averages. + lg0, lg1 = math.log10(MIN_HZ), math.log10(MAX_HZ) + self.buckets = [] + for i in range(SPEC_BUCKETS): + k0 = bin_of(10 ** (lg0 + (lg1 - lg0) * i / SPEC_BUCKETS)) + k1 = bin_of(10 ** (lg0 + (lg1 - lg0) * (i + 1) / SPEC_BUCKETS)) + self.buckets.append((k0, min(MAX_BIN, max(k1, k0 + 1)))) + hz = [k * BIN_HZ for k in range(MAX_BIN)] + floor = [0.0] + [self.floor_at(f) for f in hz[1:]] + voices = [[0.0] + [amp * math.exp(-((math.log10(f / fc) / width) ** 2)) for f in hz[1:]] + for fc, width, amp in self.VOICES] + tones = [0.0] * MAX_BIN + for (k0, k1), v in zip(self.buckets, self.TONES): + tones[k0:k1] = [v] * (k1 - k0) + # Running sums, so a band of any width is two lookups. + self.sums = [self.running(bins) for bins in [floor] + voices + [tones]] + + def floor_at(self, f): + pts = self.FLOOR + for (f0, v0), (f1, v1) in zip(pts, pts[1:]): + if f <= f1: + u = math.log(max(f, f0) / f0) / math.log(f1 / f0) + return v0 * (v1 / v0) ** u + return pts[-1][1] + + @staticmethod + def running(bins): + out, s = [0.0], 0.0 + for v in bins: + s += v + out.append(s) + return out + + def mean(self, part, lo, hi): + """The average of one shape over bins lo..hi-1, as fold() takes it. A + band with no bins in it (a low edge in the top bin) hears nothing.""" + s = self.sums[part] + return (s[hi] - s[lo]) / (hi - lo) if hi > lo else 0.0 + + @staticmethod + def music(t): + """How loud each voice is at time t, on a 24 second loop at 120 bpm: + a verse with next to no treble (the top band's gate stays shut, as it + does on a panel at listening volume), a chorus with hats, then two + seconds of near silence - so every gate can be watched opening and + closing.""" + bar = t % 24.0 + kick = math.exp(-5.0 * ((t * 2.0) % 1.0)) + hat = math.exp(-2.2 * ((t * 2.0 + 0.5) % 1.0)) + swell = 0.5 + 0.5 * math.sin(t * 2 * math.pi / 5.3) + lead = 0.5 + 0.5 * math.sin(t * 2 * math.pi / 3.1 + 1.0) + k = 0.05 if bar >= 22.0 else 1.0 + return [k * (0.20 + 0.80 * kick), k * (0.40 + 0.60 * swell), k * (0.35 + 0.65 * lead), + k * (1.0 if bar >= 10.0 else 0.06) * (0.25 + 0.75 * hat)] + + +ROOM = Room() +NOISE = 0 # Room.sums: the floor, then the four voices, then the test tones + + +class Audio: + """The two audio features of a panel, as far as /audio-in can tell.""" + + def __init__(self): + self.lock = threading.Lock() + self.rng = random.Random(7) + self.runtime = True # AUD, /api/audio's switch (feature_audio.h: on by default) + self.clients = 0 # senders on the :81 socket + self.noread = False # /mock?audio=noread + self.luts = [[0] * LUT_POINTS for _ in range(4)] + self.factory() + self.scene("music") + + # ── settings (core_audio_in_map.h) ─────────────────────────────────── + def reset_band(self, i): + """resetBand(): the measured defaults, and the band back on its gain + exponent. The table's bytes stay; only lutSet says it is not used.""" + d = BAND_DEFAULTS[i] + self.bands[i] = {"hzMin": d[0], "hzMax": d[1], "inMin": f32(d[2]), "inMax": f32(d[3]), + "gain": f32(d[4]), "outMin": f32(d[5]), "outMax": f32(d[6]), + "knob": i, "muted": False} + self.lut_set[i] = False + self.metas[i] = "" + + def reset_bands(self): + for i in range(4): + self.reset_band(i) + self.smoothing, self.attack = f32(0.35), f32(0.65) + + def factory(self): + """A panel nobody has configured: load() with nothing in NVS.""" + self.bands, self.lut_set, self.metas = [None] * 4, [False] * 4, [""] * 4 + self.reset_bands() + self.mic_on, self.mic_gain, self.auto = False, 8.0, True + + @staticmethod + def clamp_range(x): + x["hzMin"] = constrain(x["hzMin"], MIN_HZ, MAX_HZ) + x["hzMax"] = constrain(x["hzMax"], MIN_HZ, MAX_HZ) + if x["hzMin"] > x["hzMax"]: + x["hzMin"], x["hzMax"] = x["hzMax"], x["hzMin"] + if f32(x["hzMax"] - x["hzMin"]) < BIN_HZ: + x["hzMax"] = min(MAX_HZ, f32(x["hzMin"] + BIN_HZ)) + + # ── /mock?audio= ───────────────────────────────────────────────────── + def scene(self, name): + mic, room, phone, clients = AUDIO_STATES[name] + self.noread = False + if name == "fresh": + self.factory() + # The analysis as it is at boot: nothing heard, no floor learned. + self.fft = [0.0] * 4 # PFAudioFFT::bands + self.noise_ref = [-1.0] * 4 # < 0 = unlearned + self.env_hi = [0.0] * 4 + self.gate = [False] * 4 + self.votes = [0] * 4 + self.warmup = 120 + self.smooth = [0.0] * 4 # smoothLevel + self.raw_peak = self.raw_dc = 0.0 + self.windows = self.passes = 0 + self.live = self.stalled = False + self.park = self.since = 0 # passes left asleep; windows since I2S came up + self.t = 0.0 + self.last = None # the window spectrum() would read + self.ext_levels, self.ext_env, self.ext_spec = [0.0] * 4, [0.0] * 8, [0.0] * 64 + self.ext_at = self.poll_at = None + self.phone, self.phone_at = phone, 0.0 + self.phone_lo, self.phone_hi = [1.0] * 4, [0.0] * 4 + self.clients = clients + self.state = name + # ...and then as if it had been in this state for a while. The states + # with the switch off (fresh aside) heard music first, so what they + # hold is what a panel holds: the last window, not zeros. So did the + # microphone that then stopped answering. + if name != "fresh": + self.mic_on = self.live = True + if room in ("music", "tones"): + self.hear("silence", 180) # the warm-up finds the floor + self.hear("music", 720) + if room != "music": + self.hear(room, 1800 if room == "silence" else 300) # envelopes settle slowly + self.room, self.mic_on = room, mic + if not mic: + self.live = self.stalled = False + self.clock = time.monotonic() + if phone: + for back in range(120, 0, -1): + self.phone_frame(self.clock - back * 0.25, True) + + def hear(self, room, windows): + self.room, self.stalled = room, room == "tones" + for _ in range(windows): + self.window() + + # ── the analysis task (feature_audio_in.h), 60 passes a second ─────── + def advance(self, now): + """Catch the analysis up to now. No thread: nothing changes that a + request cannot see, so it runs when one arrives - two seconds' worth at + most, which is all a tab that was away needs.""" + n = int((now - self.clock) * 60.0) + if n > 0: + self.clock = now if n > 120 else self.clock + n / 60.0 + for _ in range(min(n, 120)): + self.tick() + if self.phone: + self.phone_frame(now) + + def tick(self): + if self.park: + self.park -= 1 + return + if not self.mic_on: + # Parked: I2S released, and a look at the switch every 250 ms. + # That wait is why `source` says "synth" for a moment after mic=1. + self.live = self.stalled = False + self.park = 14 + return + if not self.live: # PFAudioPdm::begin() + self.live, self.stalled, self.since = True, self.room == "tones", 0 + self.window() + + def window(self): + """analyze(), then the gate and the damping, as the task runs them.""" + self.t += 1.0 / 60.0 + self.passes += 1 + jit = [1.0 + 0.16 * (self.rng.random() - 0.5) for _ in range(4)] + if self.room == "tones": + # The microphone is not delivering, so the window is the test + # signal: no input gain, and a little drift as the window slides. + wob = 1.0 + 0.015 * math.sin(self.passes * 0.61) + scale, amps, noisy = 1.0, [0.0] * 4 + [wob], False + self.raw_peak, self.raw_dc = 0.99439, -0.0158 + 0.006 * math.sin(self.passes * 0.61) + elif self.room == "rail": + # Nothing on the data pin: it floats to a rail, every sample is + # the same, and a constant has no energy outside bin 0. Measured. + scale, amps, noisy = 0.0, [0.0] * 5, False + self.raw_peak, self.raw_dc = 0.94406, -0.94406 + self.windows += 1 + else: + voices = ROOM.music(self.t) if self.room == "music" else [0.0] * 4 + scale, amps, noisy = self.mic_gain / 8.0, voices + [0.0], True + # A microphone starts with a pop: its first few windows are far + # above the room. That is an assumption about the part, and it + # decides what the gate learns - see "The start", above + # AUDIO_STATES. + pop = 10.0 * 0.45 ** self.since if self.since < 10 else 0.0 + jit = [j + pop for j in jit] + self.raw_peak = 0.0036 + 0.014 * voices[0] + 0.0004 * jit[0] + self.raw_dc = -0.0041 + 0.0003 * (jit[1] - 1.0 - pop) + self.windows += 1 + self.since += 1 + self.last = (scale, amps, noisy, self.passes) + for b in range(4): + lo, hi = self.bins(b) + if hi <= lo: # fold(): no bin, so 0 at once + self.fft[b] = 0.0 + continue + v = scale * self.heard((lo, hi), amps, jit[b] if noisy else 0.0) + self.fft[b] = f32(self.fft[b] * 0.7 + v * 0.3) # fold()'s own smoothing + self.track() + a30, k30 = constrain(self.smoothing, 0.05, 0.9), constrain(self.attack, 0.05, 0.9) + rel60, atk60 = 1.0 - math.sqrt(1.0 - a30), 1.0 - math.sqrt(1.0 - k30) + for i in range(4): # smoothLevels() + a = atk60 if self.fft[i] > self.smooth[i] else rel60 + self.smooth[i] = f32(self.smooth[i] + (self.fft[i] - self.smooth[i]) * a) + + def bins(self, b): + """fold(): the FFT bins band b is averaged over, lo..hi-1.""" + lo = bin_of(self.bands[b]["hzMin"]) + return lo, min(MAX_BIN, max(bin_of(self.bands[b]["hzMax"]), lo + 1)) + + @staticmethod + def heard(bins, amps, floor): + """The average magnitude over those bins: the voices (and the test + tones) at these loudnesses, over this much of the noise floor.""" + return floor * ROOM.mean(NOISE, *bins) + sum( + a * ROOM.mean(i + 1, *bins) for i, a in enumerate(amps)) + + def track(self): + """trackEnvelopes(): the noise reference, the peak envelope, the gate.""" + warming = self.warmup > 0 + if warming: + self.warmup -= 1 + for i in range(4): + v, ref = clamp01(self.fft[i]), self.noise_ref[i] + if ref < 0.0: + ref = v + elif warming: + ref = min(ref, v) + elif not self.gate[i] and v < ref * 1.5 + 0.005: + ref += (v - ref) * 0.005 + self.noise_ref[i] = ref + self.env_hi[i] += (v - self.env_hi[i]) * (0.3 if v > self.env_hi[i] else 0.002) + lo = ref * 1.5 + if self.env_hi[i] < lo + 0.02: + self.env_hi[i] = lo + 0.02 + open_abs = (0.012, 0.050, 0.015, 0.012)[i] + if not self.gate[i]: + self.votes[i] = self.votes[i] + 1 if v > ref * 1.8 + open_abs else 0 + if self.votes[i] >= 8: + self.gate[i], self.votes[i] = True, 0 + else: + self.votes[i] = self.votes[i] + 1 if v < ref * 1.4 + open_abs \ + else max(0, self.votes[i] - 3) + if self.votes[i] >= 25: + self.gate[i], self.votes[i] = False, 0 + + # ── a level to a knob (core_audio_in_map.h) ────────────────────────── + def normalized(self, b, level): + if not self.gate[b]: + return 0.0 + lo = self.noise_ref[b] * 1.5 + return clamp01((level - lo) / max(self.env_hi[b] - lo, 0.02)) + + def mapped_window(self, b, level, in_min, in_max): + x = self.bands[b] + in_min = clamp01(in_min) + in_max = max(in_min + f32(0.01), clamp01(in_max)) + u = clamp01((level - in_min) / (in_max - in_min)) + if self.lut_set[b] and u == u: + pos = clamp01(u) * (LUT_POINTS - 1) + i = int(pos) + j = min(i + 1, LUT_POINTS - 1) + u = (self.luts[b][i] * (1.0 - (pos - i)) + self.luts[b][j] * (pos - i)) / 255.0 + else: + u = u ** (1.0 / constrain(x["gain"], f32(0.2), 4.0)) + return f32(x["outMin"] + u * (x["outMax"] - x["outMin"])) + + def mapped(self, b, level): + if self.auto: + return self.mapped_window(b, self.normalized(b, level), f32(0.10), f32(0.95)) + return self.mapped_window(b, level, self.bands[b]["inMin"], self.bands[b]["inMax"]) + + def source(self): + """sourceLabel(): five strings, matched exactly by the page.""" + if not self.mic_on: + return "off" + if not self.live: + return "synth" + if self.stalled: + return "synth (mic stalled)" + dead = abs(self.raw_dc) >= 0.5 and not any(v > 1e-4 for v in self.fft) + return "pdm (no mic - data pin idle)" if dead else "pdm" + + def spectrum(self): + """PFAudioFFT::spectrum(): 64 log buckets of the LAST window - so with + the analysis parked it is what that window left, again and again.""" + if not self.last: + return [0.0] * SPEC_BUCKETS + scale, amps, noisy, seed = self.last + rng = random.Random(seed) + return [scale * self.heard(bucket, amps, 1.0 + 0.5 * (rng.random() - 0.5) if noisy else 0.0) + for bucket in ROOM.buckets] + + # ── the phone app's monitor frames ─────────────────────────────────── + def ext_fresh(self, now): + return self.ext_at is not None and now - self.ext_at < 3.0 and not self.mic_on + + def watching(self, now): + return self.poll_at is not None and now - self.poll_at < 2.5 + + def parse_frame(self, s, now): + """parseFrame(): 4 levels; 4 x lo,hi; 64 buckets. 76 finite numbers + or nothing.""" + vals = [to_float(tok) for tok in re.split("[,;]", s) if tok][:76] + if len(vals) != 76 or not all(math.isfinite(v) for v in vals): + return + self.ext_levels, self.ext_env, self.ext_spec = vals[:4], vals[4:12], vals[12:] + self.ext_at = now + + def phone_frame(self, now, due=False): + """/mock?audio=phone: what the capture app posts, in its own text and + through the same parser - four frames a second while a page is + polling, one every two seconds when none is (it reads `watch`). The + phone analyses on the phone, in its own unit: 0..1 across its dB axis.""" + if not due and now - self.phone_at < (0.25 if self.watching(now) else 2.0): + return + self.phone_at = now + + def unit(v): + return clamp01((20.0 * math.log10(max(v, 1e-4)) + 45.0) / 47.0) + + amps = ROOM.music(now % 240.0) + levels = [] + for b in range(4): + v = unit(self.heard(self.bins(b), amps, 1.0)) + levels.append(v) + self.phone_hi[b] = v if v > self.phone_hi[b] else \ + self.phone_hi[b] + (v - self.phone_hi[b]) * 0.03 + self.phone_lo[b] = v if v < self.phone_lo[b] else \ + self.phone_lo[b] + (v - self.phone_lo[b]) * 0.03 + self.phone_hi[b] = max(self.phone_hi[b], self.phone_lo[b] + 0.06) + spec = [unit(self.heard(bucket, amps, 1.0)) for bucket in ROOM.buckets] + self.parse_frame("%s;%s;%s" % ( + ",".join("%.3f" % v for v in levels), + ",".join("%.3f,%.3f" % (self.phone_lo[b], self.phone_hi[b]) for b in range(4)), + ",".join("%.2f" % v for v in spec)), now) + + # ── the replies (core_audio_in_http.h): field order and decimals are the + # handler's, because the page compares what it re-reads ────────────── + def poll_json(self, now): + ext = self.ext_fresh(now) + j = '{"source":"%s","ext":%s' % ("phone" if ext else self.source(), "true" if ext else "false") + j += ',"rawPeak":%s,"rawDc":%s,"dropped":0' % (fstr(self.raw_peak, 5), fstr(self.raw_dc, 5)) + j += ',"levels":[' + ",".join(fstr(v, 4) for v in (self.ext_levels if ext else self.smooth)) + # Always the microphone's, also while the phone's frames are served. + j += '],"levelsN":[' + ",".join(fstr(self.normalized(i, self.smooth[i]), 4) for i in range(4)) + j += '],"outputs":[' + ",".join(fstr(self.mapped(i, self.smooth[i]), 4) for i in range(4)) + if ext: + j += '],"env":[' + ",".join('{"lo":%s,"hi":%s}' % (fstr(self.ext_env[i * 2], 4), + fstr(self.ext_env[i * 2 + 1], 4)) + for i in range(4)) + elif self.auto: + j += '],"env":[' + ",".join('{"lo":%s,"hi":%s}' % (fstr(self.noise_ref[i] * 1.5, 4), + fstr(self.env_hi[i], 4)) + for i in range(4)) + j += '],"spectrum":[' + ",".join(fstr(v, 3) for v in (self.ext_spec if ext else self.spectrum())) + return j + "]}" + + def config_json(self): + j = '{"source":"%s","micOn":%s,"micGain":%s,"autoRange":%s,"smoothing":%s,"attack":%s' % ( + self.source(), "true" if self.mic_on else "false", fstr(self.mic_gain, 1), + "true" if self.auto else "false", fstr(self.smoothing, 3), fstr(self.attack, 3)) + j += ',"rawPeak":%s,"rawDc":%s,"windows":%d' % (fstr(self.raw_peak, 5), fstr(self.raw_dc, 5), + self.windows) + j += ',"level":[' + ",".join(fstr(v, 4) for v in self.fft) + j += '],"out":[' + ",".join(fstr(self.mapped(i, self.fft[i]), 4) for i in range(4)) + bands = [] + for i, b in enumerate(self.bands): + bands.append( + '{"hzMin":%s,"hzMax":%s,"inMin":%s,"inMax":%s,"gain":%s,"outMin":%s,"outMax":%s,' + '"knob":%d,"muted":%s,"meta":"%s","lutSet":%s}' % ( + fstr(b["hzMin"], 1), fstr(b["hzMax"], 1), fstr(b["inMin"], 4), + fstr(b["inMax"], 4), fstr(b["gain"], 3), fstr(b["outMin"], 3), + fstr(b["outMax"], 3), b["knob"], "true" if b["muted"] else "false", + self.metas[i].replace("\\", "\\\\").replace('"', '\\"'), + "true" if self.lut_set[i] else "false")) + j += '],"bands":[' + ",".join(bands) + j += '],"hzRange":[%s,%s' % (fstr(MIN_HZ, 2), fstr(MAX_HZ, 2)) + j += '],"spec":[' + ",".join(fstr(v, 3) for v in self.spectrum()) + return j + "]}" + + def apply_band(self, b, sfx, a): + """applyBandArgs(): whatever arrived under these names; the two pairs + (hzMin/hzMax, inMin/inMax) are settled after, whichever half came.""" + x = self.bands[b] + x["hzMin"] = a.num("hzMin" + sfx, x["hzMin"]) + x["hzMax"] = a.num("hzMax" + sfx, x["hzMax"]) + self.clamp_range(x) + x["inMin"] = constrain(a.num("inMin" + sfx, x["inMin"]), 0.0, 1.0) + x["inMax"] = constrain(a.num("inMax" + sfx, x["inMax"]), 0.0, 1.0) + x["gain"] = constrain(a.num("gain" + sfx, x["gain"]), f32(0.2), 4.0) + x["outMin"] = constrain(a.num("outMin" + sfx, x["outMin"]), 0.0, 1.0) + x["outMax"] = constrain(a.num("outMax" + sfx, x["outMax"]), 0.0, 1.0) + if a.has("knob" + sfx): + x["knob"] = constrain(to_int(a.get("knob" + sfx)), 0, 3) + if a.has("muted" + sfx): + x["muted"] = truthy(a.get("muted" + sfx)) + floor = f32(x["inMin"] + f32(0.01)) + if x["inMax"] < floor: + x["inMax"] = min(1.0, floor) + if a.has("lut" + sfx): + csv = a.get("lut" + sfx) + table = parse_lut(csv) if csv else None + if not csv: + self.lut_set[b] = False # empty clears; the band runs `gain` + elif table: + self.luts[b], self.lut_set[b] = table, True + # ...and anything else is ignored: the reply is still {"ok":true} + if a.has("meta" + sfx): + self.metas[b] = a.get("meta" + sfx).split("\0")[0][:31] + + def handle_set(self, a, now): + if a.has("frame"): + # A monitor frame is state, not settings: nothing else is read. + self.parse_frame(a.get("frame"), now) + return 200, '{"ok":true,"watch":%s}' % ("true" if self.watching(now) else "false") + single = -1 + if a.has("band"): + single = to_int(a.get("band")) + if single < 0 or single > 3: # refused before anything is applied + return 400, '{"ok":false,"error":"band must be 0-3"}' + if a.has("mic"): + self.mic_on = truthy(a.get("mic")) + if self.mic_on and not self.live: + # The parked task looks at the switch every 250 ms, so on a + # panel the driver is up some time inside that. Here it is + # always the whole quarter second: the "synth" a page must + # not take for a verdict is then there every time. + self.park = 15 + if a.has("auto"): + self.auto = truthy(a.get("auto")) + if a.has("micGain"): + self.mic_gain = constrain(finite_or(to_float(a.get("micGain")), self.mic_gain), 1.0, 16.0) + if a.has("smoothing"): + self.smoothing = constrain(finite_or(to_float(a.get("smoothing")), self.smoothing), + f32(0.05), f32(0.9)) + if a.has("attack"): + self.attack = constrain(finite_or(to_float(a.get("attack")), self.attack), + f32(0.05), f32(0.9)) + if single >= 0: + self.apply_band(single, "", a) + for b in range(4): + # A band is applied when the request names ANY of its fields. + if any(a.has(f + str(b)) for f in BAND_FIELDS): + self.apply_band(b, str(b), a) + return 200, '{"ok":true}' + + def handle_reset(self, a): + if a.has("band"): + v = a.get("band") + if len(v) != 1 or not "0" <= v <= "3": # one digit, or nothing happens + return 400, '{"ok":false,"error":"band must be 0-3"}' + self.reset_band(int(v)) + return 200, '{"ok":true,"band":%s}' % v + self.reset_bands() # every band and curve, damping, attack... + self.mic_gain = 8.0 # ...and the input gain. Not the switch, not auto. + return 200, '{"ok":true}' + + def request(self, method, path, a, caps): + """(status, body) as the firmware answers it, or None where this build + has no such route and the web server's own 404 goes out.""" + now = time.monotonic() + self.advance(now) + if path == "/api/audio" and "audio" in caps: + if method == "GET": + return 200, '{"audioRuntime":%s,"audioClients":%d}' % ( + "true" if self.runtime else "false", self.clients) + if method == "POST": + if a.has("on"): + self.runtime = truthy(a.get("on")) + return 200, '{"ok":true,"audioRuntime":%s}' % ("true" if self.runtime else "false") + if path == "/api/audio-in" and "audio-in" in caps: + if method == "GET" and a.has("levels"): + if not a.has("idle"): + self.poll_at = now # a page is watching: the phone reads this + return 200, self.poll_json(now) + if method == "GET": + return 200, self.config_json() + if method == "POST": + return self.handle_set(a, now) + if path == "/api/audio-in/reset" and "audio-in" in caps and method == "POST": + return self.handle_reset(a) + return None + + def status(self, caps): + """What the two features append to /api/status.""" + with self.lock: + self.advance(time.monotonic()) + out = {} + if "audio" in caps: + out.update(audioRuntime=self.runtime, audioClients=self.clients) + if "audio-in" in caps: + # The timings are of the right order (the transform measured + # 329 us on a panel), not a capture; the rest is the model's. + runs = max(0, self.passes - 60) + out["audioIn"] = { + "core": 0, "lastUs": 1764 if runs else 0, "avgUs": 1742 if runs else 0, + "fillUs": 1307 if runs else 0, "fftUs": 329 if runs else 0, + "foldUs": 96 if runs else 0, "maxUs": 2410 if runs else 0, "runs": runs, + "heap": 0, "staticBytes": 9472, "source": self.source(), + "micWindows": self.windows, "micDropped": 0, + "rawPeak": round(self.raw_peak, 5), "rawDc": round(self.raw_dc, 5), + "bands": [round(v, 4) for v in self.fft], + } + return out + + +AUDIO = Audio() DEV = Device() FEATURE_NAV = feature_nav() @@ -354,10 +1178,12 @@ def log_message(self, fmt, *a): sys.stderr.write(" %s %s\n" % (self.command, self.path)) def log_request(self, code="-", size="-"): - if Link.rtt or Link.serial: - sys.stderr.write(" %s %s -> %s\n" % (self.command, self.path, code)) + # `note`: what an audio request carried, or how it ended (see audio()). + note, self.note = getattr(self, "note", ""), "" + if Link.rtt or Link.serial or (note and code != 200): + sys.stderr.write(" %s %s%s -> %s\n" % (self.command, self.path, note, code)) else: - sys.stderr.write(" %s %s\n" % (self.command, self.path)) + sys.stderr.write(" %s %s%s\n" % (self.command, self.path, note)) # Each request is a connection of its own (Connection: close, here as on # the panel): the handshake and the request cost a round trip @@ -366,10 +1192,16 @@ def log_request(self, code="-", size="-"): def handle_one_request(self): if Link.rtt: time.sleep(Link.rtt) - if Link.serial: - with Link.lock: - return BaseHTTPRequestHandler.handle_one_request(self) - return BaseHTTPRequestHandler.handle_one_request(self) + try: + if Link.serial: + with Link.lock: + return BaseHTTPRequestHandler.handle_one_request(self) + return BaseHTTPRequestHandler.handle_one_request(self) + except ConnectionError: + # The browser left mid-reply: a link clicked, a tab closed. A page + # that polls ten times a second does that on most navigations, + # and a traceback for each one buries the log. + self.close_connection = True def write_body(self, body): if not (Link.rtt or Link.bps): @@ -432,14 +1264,18 @@ def send_console(self, text, ctype, cache, etag=True): def send_page(self, path, name): """PFSend::PAGE: a page stamped exactly as console_pages.py build - stamps it into the header.""" + stamps it into the header. One page is not a file to read: /audio-in + is assembled from its sources by the code `build` assembles it with, + here on every request, so its sources are save-and-refresh too.""" f = os.path.join(HTML_DIR, name + ".html") - if not os.path.exists(f): + assembled = name == build_audio_in_page.PAGE + if not assembled and not os.path.exists(f): return self.send("no console/%s.html — run console_pages.py extract" % name, "text/plain", 404) try: - text = console_pages.stamp(read(f), console_pages.crc_of(raw_literal(*CHROME_SRC)), - console_pages.load_shim(), name) + text = console_pages.stamp( + build_audio_in_page.assemble(SKETCH) if assembled else read(f), + console_pages.crc_of(raw_literal(*CHROME_SRC)), console_pages.load_shim(), name) except (ValueError, RuntimeError, OSError) as e: # build would refuse this page; show why instead of a page the # device could never have served @@ -539,6 +1375,18 @@ def do_GET(self): else DESK_CACHE, etag=False) if path == "/mock": + dest = "/" + if any(k == "audio" for k, _ in self.qlist): + want = self.arg("audio") + if want != "noread" and want not in AUDIO_STATES: + return self.send("audio= is one of: %s, noread\n" % ", ".join(AUDIO_STATES), + "text/plain", 400) + with AUDIO.lock: + if want == "noread": + AUDIO.noread = True + else: + AUDIO.scene(want) + dest = "/audio-in" if "variant" in q: DEV.variant = q["variant"] or "core" want = q.get("caps", "full") if "caps" in q else None @@ -549,9 +1397,12 @@ def do_GET(self): DEV.via_hotspot = q["hotspot"] in ("1", "true", "on") if any(k == "busy" for k, _ in self.qlist): DEV.busy = self.arg("busy") if self.arg("busy") in ("update", "storage") else "" - return self.reply(302, [("Content-Type", "text/plain"), ("Location", "/"), + return self.reply(302, [("Content-Type", "text/plain"), ("Location", dest), ("Cache-Control", NO_STORE), ("Content-Length", "0")]) + if path in AUDIO_ROUTES: + return self.audio(path) + if path.startswith("/api/") or path == "/update/status": return self.api(path, q, {}) @@ -559,14 +1410,48 @@ def do_GET(self): def do_POST(self): path, q = self.parts() + if path in AUDIO_ROUTES: + return self.audio(path) p = dict(q) p.update(self.body_params()) self.api(path, q, p) def do_DELETE(self): path, q = self.parts() + if path in AUDIO_ROUTES: + return self.audio(path) self.api(path, q, dict(q), delete=True) + def audio(self, path): + """The audio features' routes, answered by the port above. The body is + read here, raw: body_params() drops empty values, and to these + handlers `lut2=` and `meta1=` mean something.""" + n = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(n).decode("latin-1") if n else "" + ctype = self.headers.get("Content-Type") or "" + args = Args(urllib.parse.urlsplit(self.path).query, body, ctype) + # The log line says what was sent - a save is judged by what it + # carried - with a curve table or a phone frame cut short. And it says + # when a body was sent that the panel would not have read. + self.note = "".join(" " + (v if len(v) <= 60 else "%s...(%d bytes)" % (v[:44], len(v))) + for v in body.split("&") if v) + if body and ctype.startswith("multipart/"): + self.note = " (a multipart body: the panel reads those, this mock does not)" + elif body and not ctype.startswith("application/x-www-form-urlencoded"): + self.note += " (BODY NOT READ: Content-Type %r is not the form one)" % ctype + with AUDIO.lock: + if (AUDIO.noread and self.command == "GET" and path == "/api/audio-in" + and not args.has("levels") and "audio-in" in DEV.caps): + # /mock?audio=noread: no reply at all, which is what a failed + # read of a panel is - it has no error to send for this route. + self.note = " (/mock?audio=noread)" + self.close_connection = True + return self.log_request("no reply") + answer = AUDIO.request(self.command, path, args, DEV.caps) + if answer is None: + return self.not_found() + self.send(answer[1].encode("latin-1"), "application/json", answer[0]) + # ── the fake device ────────────────────────────────────────────────── def api(self, path, q, p, delete=False): d = DEV @@ -835,6 +1720,7 @@ def api(self, path, q, p, delete=False): print(" caps /mock?caps=bare /mock?caps=full") print(" variant /mock?variant=simone-pd /mock?variant=core") print(" status /mock?hotspot=1 /mock?busy=update (hotspot=0, busy= to clear)") + print(" audio /mock?audio=" + "|".join(AUDIO_STATES) + "|noread") print(" editing firmware/patternflow/console/*.html — just save and refresh") print(" shipping python firmware/toolchain/console_pages.py build") if Link.device or Link.rtt or Link.bps or Link.serial: diff --git a/tools/README.md b/tools/README.md index 09f9fffff..2ba259960 100644 --- a/tools/README.md +++ b/tools/README.md @@ -4,13 +4,13 @@ Clients that Patternflow ships and that run on their own — a browser extension | Folder | What it is | Contract | | :--- | :--- | :--- | -| [`patternflow-audio-extension/`](patternflow-audio-extension/README.md) | Chrome/Edge extension: captures the current tab's audio, splits it into four bands you shape on a response graph, streams the levels to the panel | [`audio-ws-spec.md`](../docs/audio-ws-spec.md) | -| [`patternflow-audio-android/`](patternflow-audio-android/README.md) | The phone-side twin: captures what the phone is playing and drives the knobs with it, for filming | [`audio-ws-spec.md`](../docs/audio-ws-spec.md) | +| [`patternflow-audio-extension/`](patternflow-audio-extension/README.md) | Chrome/Edge extension: captures the current tab's audio, splits it into four bands, one per knob, that you shape as boxes on the live spectrum, and streams the four knob values to the panel | [`audio-ws-spec.md`](../docs/audio-ws-spec.md) | +| [`patternflow-audio-android/`](patternflow-audio-android/README.md) | The phone-side twin: captures what the phone is playing and drives the knobs with it, for filming. It takes its mapping from the panel | [`audio-ws-spec.md`](../docs/audio-ws-spec.md), and the microphone endpoints of [`rest-api.md`](../docs/rest-api.md#microphone-audio-edition) | | [`rtpmidi-probe/`](rtpmidi-probe/README.md) | A plain-Python RTP-MIDI session initiator that walks the MIDI contract against a panel from a machine with no MIDI driver | [`midi-spec.md`](../docs/midi-spec.md) | ## The extension is also firmware source -The band editor in `patternflow-audio-extension/` (`editor.html`, `editor.css`, `editor.js`, `editor-adapter.js`) is the **authoring source of the device's own `/audio-in` console page**: `firmware/toolchain/build_audio_in_page.py` assembles `firmware/patternflow/console/audio-in.html` from those files, and the `console-sync` workflow fails if the two drift. Edit the editor here, never the generated page, and do not move this folder — the build script and the workflow hold its path. +The mapping editor in `patternflow-audio-extension/` is the **authoring source of the device's own `/audio-in` console page**. Four of its files travel to the panel: `editor.html` (the editor's body), `editor.css` and `mapping.js` (each down to its `EXTENSION ONLY` line) and `editor.js`. `editor-adapter.js` does not: the panel has its own adapter, frame and styles beside the other console sources (`firmware/patternflow/console/_audio_in_*`). `python firmware/toolchain/console_pages.py build` assembles `firmware/patternflow/console/audio-in.html` from both halves (the step is `firmware/toolchain/build_audio_in_page.py`) and bakes the header the panel serves; `python firmware/toolchain/console_serve.py` previews the page from the sources without building. The `console-sync` workflow fails when the generated page is not what its sources assemble to, or is over its size budget. Edit the editor here, never the generated page, and do not move this folder — the build script and the workflow hold its path. ## License diff --git a/tools/patternflow-audio-extension/README.md b/tools/patternflow-audio-extension/README.md index 92265107b..664875e04 100644 --- a/tools/patternflow-audio-extension/README.md +++ b/tools/patternflow-audio-extension/README.md @@ -1,71 +1,194 @@ # Patternflow Audio Chrome Extension -Captures audio from the currently active Chrome/Edge tab, shows a live -spectrum, maps editable frequency bands to Patternflow knobs, and streams -the four knob lanes over WebSocket. The wire protocol is a written contract: +Captures the audio of a Chrome or Edge tab, splits it into four frequency +bands, and sends each band to one of the panel's four knobs over your local +network. The panel has to run the **Audio edition** +([patternflow.work/editions](https://patternflow.work/editions)). +[`AUDIO_GUIDE.md`](../../AUDIO_GUIDE.md) is the guide to everything around it; +the wire protocol is a written contract, [`docs/audio-ws-spec.md`](../../docs/audio-ws-spec.md). -The extension uses the same Patternflow mark as the web app for its toolbar -icon and popup header. +## Install + +It is not on the Chrome Web Store. Load it from this folder: + +1. Get the repository: `git clone`, or **Code → Download ZIP** on GitHub and + unzip it. +2. Open `chrome://extensions` (in Edge, `edge://extensions`). +3. Enable **Developer mode**. +4. Click **Load unpacked** and pick this folder, + `tools/patternflow-audio-extension`. + +**After pulling a newer copy, press Reload** on the extension's card in +`chrome://extensions`. Chrome goes on running the files it loaded until you +do. The card shows the version (`version` in `manifest.json`), which is how to +tell the copy that is loaded from the one on disk. + +**Reload keeps the mapping you had.** A copy before 0.2.0 started with Auto +range off and no curve, and unless you changed them its mapping still says so +after Reload: the Auto range switch is off, the four boxes are full height, +and under the curves it reads "This knob uses the default response." The new +defaults (Auto range on, the Smooth curve) are what a new install starts +with. To get them on a copy you already had, do one of: + +- in the mapping editor, select each knob in turn and press its Reset + (**Reset K1** to **Reset K4**), then switch **Auto range** on. Reset also + puts that knob's box and output range back to the default. The panel's + address is kept; +- **Remove** the extension on `chrome://extensions` and load it again from + this folder. That empties everything it stored, the panel's address + included. + +## Use + +1. Go to the tab that is playing: YouTube, SoundCloud, a DJ set, a web DAW. +2. Click the Patternflow Audio button in the toolbar, on that tab. +3. Under **Device**, type the panel's address: `patternflow.local`, or its IP + (the panel's NETWORK screen shows it). +4. Press **Start**. The button now reads **Stop**. + +The word in the popup's corner is the state, and the line under the address +says the rest: + +| | | +|---|---| +| **Idle** | Nothing is captured. | +| **Connecting** | The connection is not open yet, or the panel has not yet said whether it is listening. | +| **Live** | The tab is captured and the panel is being driven. The line reads `Capturing: `, or "No sound from this tab yet." / "... right now." while the tab is silent. | +| **Aud off** | Connected, but Audio-React is switched off on the panel, which then ignores what arrives. Press **Turn on**, which appears under the address. It is the same switch as `AUD` on the panel's NETWORK screen and *Browser extension and phone app* on the console's Audio page. | +| **Test** | The test connection (below) is up. | +| **Error** | The line says what: nothing at that address, a panel that does not run the Audio edition, a page Chrome does not let be captured. A connection that fails is retried by itself. | + +**While the tab is silent or paused the knobs are held**, each where its band +rests, until you press Stop. A knob you turn by hand during a pause goes back +there about five seconds after you let go. Stop hands all four back to the +encoders. Closing the captured tab ends the capture, and the popup says so. + +Enter in the address field starts a capture when nothing is running. While +one is running it only changes the address, and the capture follows it; it +never stops one. + +**Spectrum** is what the tab sounds like, 20 Hz to 20 kHz. + +**Test without audio** (click it open) connects without capturing anything: +**Connect**, then move K1 to K4. Each knob stays where its slider was put +until **Disconnect**. It is how to tell a panel that does not react from a +tab that is not being heard. ## The mapping editor -**Editor ↗** in the popup opens the full editor in its own tab -(`editor.html`): every band is a box drawn on the live spectrum — its width -is the frequencies it listens to, its height the level window it maps — and -a response curve turns position-in-window into knob position. Curves come as -presets (rise, fall, gate, steps, arch) or hand-dragged beziers, and every -shape bakes to a 33-point table the analysis side just interpolates — the -same table the device firmware will consume, which is the point: the editor -module (`editor.js`) is adapter-isolated (`editor-adapter.js`) and ports to -the device console by swapping the adapter only. Auto range makes the boxes -breathe with the room; grabbing a breathing edge takes manual control. -Opened outside the extension (plain http), it runs a synthesized demo -source — the fast way to iterate on the editor itself. - -## Install for local testing - -1. Open `chrome://extensions`. -2. Enable **Developer mode**. -3. Click **Load unpacked**. -4. Select this folder: `tools/patternflow-audio-extension`. -5. Open a tab that is playing audio. -6. Click the Patternflow Audio extension button. -7. Set the device host to `patternflow.local` or the board IP address. -8. Press **Start**. -9. Ensure **Audio-React (AUD)** is switched ON on the device (at the top of the console's `/audio-in` page or via the panel's NETWORK screen). - -Use the extension for YouTube, Spotify Web, browser games, or any other tab -or system audio. The built-in `http://patternflow.local/` page remains useful -for microphone input and audio-file experiments, but browser tab/system capture -is often blocked there because the ESP32 serves normal HTTP. - -For WebSocket-only debugging, press **WS Test** instead of **Start** and move -the K1-K4 sliders. This bypasses tab capture and audio analysis completely. - -## Controls - -- `Spectrum`: live frequency strength display. Drag across it to set the active band's range. -- `Add Band`: starts with one band, then adds more bands as needed. -- `Output`: min/max Patternflow knob output from 0.0 to 1.0. -- `Clamp / gain`: optional input low/high clamp and sensitivity. -- `K`: target Patternflow knob index. -- `Smoothing`: global response smoothing. -- `WS Test`: connect to the device and send manual K1-K4 movements. - -The extension sends delta messages like `d=0,v=0.125` to `ws://:81`. +**Mapping editor ↗** opens the editor in a tab of its own. From the top: + +- **Auto range**, **Attack** and **Damping** hold for all four knobs. With + Auto range on, each box follows the loudness of what it hears; with it off, + each has the window you set by hand. Attack is how fast a knob rises to a + hit, Damping how slowly it falls back. +- **The plot** is the live spectrum with four boxes on it, K1 to K4. A box's + width is the frequencies that knob listens to; its height is the + quiet-to-loud range mapped onto it, in dB. Drag a box to move it and an edge + to resize it. The line across a box is where the level sits in it now. +- **K1 to K4**, the tabs under the plot, select a knob. K1 is always knob 1. +- **Preview**, at the end of that row, puts a scope where the plot is: a test + signal (kick pulses, a noisy room, a slow swell) run through the selected + knob's attack, damping, curve and output range, which all stay adjustable + around it. The same button closes it, and so does Esc. +- **The knob's card**: its **response curve** (Smooth, Sharp, Fall, Gate, + Steps, Arch, or drag the two handles for a shape of your own), its **output + range** (how far the knob travels) and **Mute** (the knob goes back to its + encoder). +- **Reset K1** puts the selected knob back to its default. + +Every edit saves itself and reaches a running capture at once. A new install +starts with Auto range on and the Smooth curve on all four knobs. Switching +Auto range off goes back to the windows you set by hand; dragging a top or +bottom edge while it is on switches it off and starts all four windows from +where the boxes are at that moment. + +The mapping is kept in this browser (`chrome.storage`), not on the panel. The +panel's own Audio page has the same editor for its on-board microphone, with a +mapping of its own that is stored on the panel. + +The bar at the top says what is feeding the editor, in the popup's words, and +where it is going, and has a Stop. Opened outside the extension the editor +runs on a synthesized demo source, which is the fast way to work on the +editor itself: + + python -m http.server 8000 --bind 127.0.0.1 --directory tools/patternflow-audio-extension + # http://127.0.0.1:8000/editor.html + +Nothing is captured there and nothing is kept. + +## What it sends + +To `ws://
:81` (a port typed with the address is used instead), one +text message per frame: + + a=0.412,0.300,-,0.871 + +Four knob values from 0 to 1, with a `-` for a muted knob. The analysis runs +30 times a second. A frame that has not changed is sent again about every +210 ms, because the panel hands a knob back to its encoder 500 ms after the +last message that set it. Muting a knob sends `off=N` once, and Stop sends +`off`. The test sliders use the same `a=` message. Measured against a loopback +socket: 23–26 messages a second on music, 5 a second on silence. + +It also asks the panel three things over HTTP, on port 80 of the same address. +Each is asked once, never polled, because the panel serves one connection at +a time: + +- `GET /api/audio` when the socket opens, and once more when the popup is + opened while it reads *Aud off*: is Audio-React on? +- `POST /api/audio` with `on=1`: the **Turn on** button. +- `GET /api/status` once when the socket will not open: is anything there, + and does it run the Audio edition? + +Builds before 0.2.0 sent the test sliders as `k=N,v=F`, and the first ones +sent `d=N,v=F` deltas. The firmware still takes both. + +## The files + +| File | What it is | +|---|---| +| `manifest.json` | The extension itself. Permissions: `tabCapture`, `offscreen`, `storage`, `activeTab`, and `http://*/*` with `ws://*/*` to reach a panel on the local network. | +| `popup.html` `.css` `.js` | The capture console. It owns the panel's address (`patternflowAudioHost` in `chrome.storage.local`) and asks Chrome for the tab's stream. | +| `background.js` | The service worker: creates the offscreen document and relays messages between it and the pages. | +| `offscreen.html` `.js` | Holds the capture, runs the analysis and the mapping, owns the socket, and is the one place that knows the state every page prints (`phase`). | +| `mapping.js` | The mapping model, one object, `PFMap`: the defaults, what a stored mapping is turned into, the curves, and the chain from a level to a knob value. The mapping is stored under `patternflowAudioConfig`. | +| `editor.html` `.css` `.js` | The mapping editor. `editor.js` reaches its source only through `window.PFAdapter`; the contract is the comment at its top. | +| `editor-adapter.js` | `PFAdapter` for the extension (storage, the capture's frames), the editor's source bar, and the demo source. | +| `icons/` | The Patternflow mark, as the web app has it. | ## This folder is also firmware source -The editor (`editor.html`, `editor.css`, `editor.js`, `editor-adapter.js`) is -the authoring source of the panel's own `/audio-in` console page: -`firmware/toolchain/build_audio_in_page.py` assembles -`firmware/patternflow/console/audio-in.html` from these files, and the -`console-sync` workflow fails a pull request in which the two have drifted. -Edit the editor here and rebuild the page; never edit the generated page, and -do not move this folder. +The panel's own `/audio-in` console page is this editor. +`firmware/toolchain/build_audio_in_page.py` assembles that page from two +places: + +- here: `editor.html` (what is between its two `EDITOR BODY` lines), + `editor.css` and `mapping.js` (what is above their `EXTENSION ONLY` line) + and all of `editor.js`; +- the panel's half, in `firmware/patternflow/console/`: `_audio_in_bar.html` + (the page around the editor), `_audio_in.css` (the console's look) and + `_audio_in_adapter.js` (`PFAdapter` over `/api/audio-in`). + +So an edit to one of those four files here changes the panel's page as well. +After one: + + python firmware/toolchain/console_serve.py # preview: /audio-in is assembled from the sources on every request + python firmware/toolchain/console_pages.py build # regenerate the page and the header the panel serves + +Never edit the generated `firmware/patternflow/console/audio-in.html` or +`firmware/patternflow/features/audio_in/audio_in_index.h`, and do not move +this folder: the builder and the `console-sync` workflow hold its path. CI +fails a pull request whose generated page is not what its sources assemble +to, or whose page is over its size budget (17.5 KB gzipped, as the panel sends +it). Every byte of the shared half crosses the panel's Wi-Fi, so what only +the extension needs goes below the `EXTENSION ONLY` lines or into +`editor-adapter.js`. That includes the cream skin: above its line +`editor.css` is layout, and each surface brings its own colours. The rest is +in [`firmware/patternflow/console/README.md`](../../firmware/patternflow/console/README.md#the-page-that-is-assembled-audio-in). -The larger controller ideas that used to be listed here as "next" — spectrum +The larger controller ideas that used to be listed here as "next" (spectrum and waveform views, drag-to-select bands, per-band curves, gates and -smoothing — shipped as the mapping editor above; what remains open is tracked +smoothing) shipped as the mapping editor above. What remains open is tracked in the repository's issues. diff --git a/tools/patternflow-audio-extension/background.js b/tools/patternflow-audio-extension/background.js index 9d3f303f4..653eadb0c 100644 --- a/tools/patternflow-audio-extension/background.js +++ b/tools/patternflow-audio-extension/background.js @@ -1,26 +1,49 @@ +// Patternflow Audio — service worker: a relay. +// +// The popup and the editor talk to this; the offscreen document does the work +// and owns the truth about it (offscreen.js: is a capture running, to which +// address, did the socket open, why did it end). Chrome puts this worker to +// sleep when it likes and wakes it with empty memory, so nothing is kept here +// that cannot be asked for again. `state` is a copy of the offscreen +// document's last word, there so a `status` request is answered without a +// round trip; after a wake it is fetched afresh before anyone is told it. + const OFFSCREEN_URL = 'offscreen.html'; -let state = { +const IDLE = { running: false, - connected: false, manual: false, + connected: false, + phase: 'idle', + host: '', tabTitle: '', - host: 'patternflow.local', error: '', + note: '', + panelAudio: null, + heard: false, + silent: false, + held: [], levels: [], + pos: [], outputs: [], - spectrum: [] + spectrum: [], + env: [], + autoRange: false }; -async function ensureOffscreen() { - const offscreenUrl = chrome.runtime.getURL(OFFSCREEN_URL); +let state = { ...IDLE }; +let heard = false; // from the offscreen document, since this worker last woke + +async function offscreenExists() { const contexts = await chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'], - documentUrls: [offscreenUrl] + documentUrls: [chrome.runtime.getURL(OFFSCREEN_URL)] }); + return contexts.length > 0; +} - if (contexts.length > 0) return; - +async function ensureOffscreen() { + if (await offscreenExists()) return; await chrome.offscreen.createDocument({ url: OFFSCREEN_URL, reasons: ['USER_MEDIA'], @@ -28,109 +51,76 @@ async function ensureOffscreen() { }); } +function toOffscreen(message) { + return chrome.runtime.sendMessage({ ...message, target: 'offscreen' }); +} + +function take(patch) { + if (!patch) return; + state = { ...state, ...patch }; + heard = true; +} + +// Make `state` worth telling. With no offscreen document nothing is running, +// whatever the copy says; after a wake the copy is empty and the document is +// asked. +async function refresh() { + if (!(await offscreenExists())) { + state = { ...IDLE }; + heard = true; + return; + } + if (heard) return; + const reply = await toOffscreen({ type: 'status' }).catch(() => null); + take(reply && reply.state); +} + function broadcast() { chrome.runtime.sendMessage({ type: 'state', state }).catch(() => {}); } +// What the popup and the editor may ask the offscreen document to do. The +// message goes through as it came. +const COMMANDS = ['start', 'manual-connect', 'host', 'config', 'manual-value', 'panel-on', 'panel-check', 'stop']; + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { (async () => { - if (message.type === 'start') { - await ensureOffscreen(); - state = { - ...state, - running: true, - connected: false, - manual: false, - tabTitle: message.tabTitle || '', - host: message.config.host, - error: '' - }; - await chrome.runtime.sendMessage({ - target: 'offscreen', - type: 'start', - streamId: message.streamId, - config: message.config - }); - broadcast(); - sendResponse({ ok: true, state }); - return; - } - - if (message.type === 'manual-connect') { - await ensureOffscreen(); - state = { - ...state, - running: false, - connected: false, - manual: true, - tabTitle: '', - host: message.config.host, - error: '' - }; - await chrome.runtime.sendMessage({ - target: 'offscreen', - type: 'manual-connect', - config: message.config - }); - broadcast(); - sendResponse({ ok: true, state }); - return; - } - - if (message.type === 'manual-value') { - await chrome.runtime.sendMessage({ - target: 'offscreen', - type: 'manual-value', - knob: message.knob, - value: message.value - }).catch(() => {}); - sendResponse({ ok: true, state }); - return; - } - - if (message.type === 'stop') { - await chrome.runtime.sendMessage({ target: 'offscreen', type: 'stop' }).catch(() => {}); - state = { ...state, running: false, connected: false, manual: false, error: '' }; + if (message.type === 'offscreen-state') { + take(message.patch); broadcast(); - sendResponse({ ok: true, state }); + sendResponse({ ok: true }); return; } - if (message.type === 'config') { - state = { ...state, host: message.config.host }; - await chrome.runtime.sendMessage({ - target: 'offscreen', - type: 'config', - config: message.config - }).catch(() => {}); - broadcast(); + if (message.type === 'status') { + await refresh(); sendResponse({ ok: true, state }); return; } - if (message.type === 'release') { - await chrome.runtime.sendMessage({ target: 'offscreen', type: 'release' }).catch(() => {}); - sendResponse({ ok: true, state }); + if (!COMMANDS.includes(message.type)) { + sendResponse({ ok: false, error: 'Unknown message' }); return; } - if (message.type === 'status') { + // Only a start makes the document. Anything else with no document has + // nothing to act on: a mapping saved in the editor while nothing runs, a + // Stop pressed twice. + if (message.type === 'start' || message.type === 'manual-connect') { + await ensureOffscreen(); + } else if (!(await offscreenExists())) { + await refresh(); sendResponse({ ok: true, state }); return; } - if (message.type === 'offscreen-state') { - state = { ...state, ...message.patch }; - broadcast(); - sendResponse({ ok: true }); - return; - } - - sendResponse({ ok: false, error: 'Unknown message' }); - })().catch((error) => { - state = { ...state, running: false, connected: false, error: String(error.message || error) }; - broadcast(); - sendResponse({ ok: false, error: state.error, state }); + const reply = await toOffscreen(message); + if (!reply) throw new Error('The capture page did not answer.'); + take(reply.state); + if (message.type !== 'manual-value') broadcast(); + sendResponse({ ok: reply.ok !== false, error: reply.error, state }); + })().catch((failure) => { + sendResponse({ ok: false, error: String(failure.message || failure), state }); }); return true; diff --git a/tools/patternflow-audio-extension/editor-adapter.js b/tools/patternflow-audio-extension/editor-adapter.js index 4db7562cb..32cce65f2 100644 --- a/tools/patternflow-audio-extension/editor-adapter.js +++ b/tools/patternflow-audio-extension/editor-adapter.js @@ -1,68 +1,141 @@ // Patternflow Audio — editor adapter, extension flavor. // -// The editor module (editor.js) knows nothing about Chrome, the device, or -// where audio comes from: it talks to exactly this surface. Porting the -// editor to the device console means swapping THIS file for one that speaks -// fetch('/api/audio-in') — the module itself moves verbatim. That boundary is -// the whole architecture; do not let editor.js grow a chrome.* call. +// 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. // -// caps() -> { hzMin, hzMax } the source can actually analyze -// loadConfig() -> the shared config (host, autoRange, bands[4]) -// saveConfig(config) -> persist + hand to the live analysis -// onFrame(fn) -> fn({running, connected, levels, env, spectrum, ...}) -// requestStatus() -> ask for one immediate state push -// stop() -> stop capture +// 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) it runs a demo: synthesized -// music-ish spectrum, config in memory. That is a preview of the editor, not -// of the product — nothing persists and nothing is captured. +// 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' && frameFn) frameFn(message.state || {}); + if (message.type === 'state') take(message.state); + }); + $('stopBtn').addEventListener('click', () => { + chrome.runtime.sendMessage({ type: 'stop' }, () => void chrome.runtime.lastError); }); window.PFAdapter = { caps() { - return { hzMin: 20, hzMax: 20000 }; + return CAPS; }, loadConfig() { return new Promise((resolve) => - chrome.storage.local.get('patternflowAudioConfig', (stored) => - resolve(stored.patternflowAudioConfig || null))); + 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({ patternflowAudioConfig: config }, () => { + 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; - }, - requestStatus() { - chrome.runtime.sendMessage({ type: 'status' }, (response) => { - void chrome.runtime.lastError; - if (response && response.state && frameFn) frameFn(response.state); - }); - }, - stop() { - chrome.runtime.sendMessage({ type: 'stop' }, () => void chrome.runtime.lastError); - }, - captureHint: 'Start capture from the extension popup, on the tab that is playing.' + ask(); + } }; + ask(); return; } // ── demo adapter ────────────────────────────────────────────────────── - let demoConfig = null; + 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; @@ -86,42 +159,45 @@ return values; } - const envLo = [1, 1, 1, 1]; - const envHi = [0, 0, 0, 0]; + // 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() { - if (!frameFn) return; const spectrum = demoSpectrum(); - const bands = (demoConfig && demoConfig.bands) || []; - const levels = bands.map((band) => { + 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 i = Math.max(0, Math.floor(lo)); i <= Math.min(63, Math.ceil(hi)); i++) { sum += spectrum[i]; n++; } - return n ? sum / n : 0; - }); - levels.forEach((v, i) => { - if (v > envHi[i]) envHi[i] = v; else envHi[i] += (v - envHi[i]) * 0.01; - if (v < envLo[i]) envLo[i] = v; else envLo[i] += (v - envLo[i]) * 0.01; - if (envHi[i] < envLo[i] + 0.06) envHi[i] = envLo[i] + 0.06; + 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]); }); - frameFn({ + const state = { running: true, connected: false, demo: true, - levels, outputs: [], - spectrum, - env: demoConfig && demoConfig.autoRange ? envLo.map((lo, i) => ({ lo, hi: envHi[i] })) : [], - autoRange: !!(demoConfig && demoConfig.autoRange) - }); + 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 { hzMin: 20, hzMax: 20000 }; }, - loadConfig() { return Promise.resolve(demoConfig); }, + 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(); }, - onFrame(fn) { frameFn = fn; setInterval(demoFrame, 120); }, - requestStatus() { demoFrame(); }, - stop() {}, - captureHint: 'Demo mode — open this page from the extension for live audio.', - _setDemoConfig(config) { demoConfig = config; } + 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/tools/patternflow-audio-extension/editor.css b/tools/patternflow-audio-extension/editor.css index 8aa3e9fe8..da75d6c44 100644 --- a/tools/patternflow-audio-extension/editor.css +++ b/tools/patternflow-audio-extension/editor.css @@ -1,27 +1,25 @@ -/* Patternflow Audio — mapping editor. Same cream instrument palette as the - popup (popup.css is the source of these values). */ -:root { - --cream: #f4efe6; - --cream-2: #ede7db; - --ink: #141414; - --muted: #6b655a; - --faint: #a69f90; - --rule: #d9d1c0; - --led: #e8552e; - --ok: #267a43; - --bad: #b43b2d; - --field: #fbf7ef; - --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} +/* Patternflow Audio — mapping editor. -* { box-sizing: border-box; } + 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. -body { - margin: 0; - background: var(--cream); - color: var(--ink); - font: 12px/1.45 Inter, ui-sans-serif, system-ui, sans-serif; -} + 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; @@ -34,89 +32,143 @@ body { .mono { font-family: var(--mono); } .dimText { color: var(--muted); } -.hint { font-size: 11px; color: var(--faint); } +.hint { font-size: 12px; color: var(--muted); } +.hint:empty { display: none; } .spacer { flex-grow: 1; } -/* ── header ── */ -header { display: flex; align-items: center; justify-content: space-between; gap: 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; } -.kicker { font-family: var(--mono); font-size: 10px; letter-spacing: 0.09em; color: var(--muted); } -.cluster { display: flex; 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); -} -.chip.dim { color: var(--muted); } -.okChip { border-color: var(--ok); color: var(--ok); } - -.primaryBtn { - font: inherit; font-size: 12px; cursor: pointer; - border: 1px solid var(--ink); border-radius: 2px; - background: var(--ink); color: var(--cream); padding: 6px 14px; -} -.primaryBtn:disabled { opacity: 0.35; cursor: default; } .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:hover { border-color: var(--led); color: var(--led); } +.ghostBtn[aria-pressed=true] { border-color: var(--led); color: var(--ink); } +.ghostBtn.dan { color: var(--bad); } /* ── toolbar / toggles ── */ -.toolbar { display: flex; align-items: center; gap: 10px; } +/* 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; text-align: right; } +.damping .mono { font-size: 11px; min-width: 56px; } .toggleWrap { - font: inherit; cursor: pointer; display: flex; align-items: center; gap: 7px; + 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; display: inline-block; border: 0; cursor: pointer; padding: 0; - flex: none; + position: relative; flex: none; pointer-events: none; } -.toggle .dotK, .toggleWrap .dotK { +.dotK { position: absolute; left: 1px; top: 1px; width: 10px; height: 10px; background: var(--field); border-radius: 50%; transition: left 0.12s; } -.toggleWrap .toggle { pointer-events: none; } .toggleWrap.on .toggle { background: var(--ok); } .toggleWrap.on .dotK { left: 11px; } -.toggle.small.on { background: var(--ok); } -.toggle.small.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; background: var(--field); - border: 1px solid var(--rule); border-radius: 2px; + position: relative; isolation: isolate; background: var(--field); + border: 1px solid var(--rule); border-radius: 2px; overflow: hidden; } -#plot { display: block; cursor: default; touch-action: none; } +/* 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; + padding: 2px 7px; pointer-events: none; white-space: nowrap; } -.tag.sel { border-color: var(--led); } +.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; } -/* ── curve panel ── */ +/* ── 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; gap: 20px; background: var(--field); + display: flex; flex-wrap: wrap; gap: 14px 20px; background: var(--field); border: 1px solid var(--rule); border-radius: 2px; padding: 16px 18px; } -.col { display: flex; flex-direction: column; gap: 8px; } -.col.grow { flex-grow: 1; gap: 12px; } +.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); } -#curve { display: block; touch-action: none; } +/* 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; } +.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; @@ -124,85 +176,158 @@ header { display: flex; align-items: center; justify-content: space-between; gap 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); } -.stepsRow[hidden] { display: none; } +/* 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: 18px; height: 18px; padding: 0; line-height: 1; + 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); } -/* ── settings column ── */ -.bandHead { display: flex; align-items: baseline; gap: 8px; } -.bandName { font-size: 13px; font-weight: 600; white-space: nowrap; } .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); } -.seg { display: flex; } -.knobBtn { - font: inherit; font-family: var(--mono); font-size: 11px; cursor: pointer; - border: 1px solid var(--rule); background: var(--cream); color: var(--muted); - padding: 6px 14px; border-left-width: 0; -} -.knobBtn:first-child { border-left-width: 1px; border-radius: 2px 0 0 2px; } -.knobBtn:last-child { border-radius: 0 2px 2px 0; } -.knobBtn.on { border-color: var(--ink); background: var(--ink); color: var(--cream); } - -.outTrack { position: relative; height: 18px; cursor: pointer; touch-action: none; } -.outRail { position: absolute; left: 4px; right: 4px; top: 8px; height: 2px; background: var(--rule); } -.outFill { position: absolute; top: 7.5px; height: 3px; background: var(--led); } +/* 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: 4px; width: 9px; height: 10px; + position: absolute; top: 50%; width: 9px; height: 10px; margin: -5px 0 0 -4.5px; background: var(--field); border: 1px solid var(--ink); } - -/* ── footer chips ── */ -.chips { display: flex; align-items: center; gap: 8px; } -.chipCard { - font: 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.mutedChip { opacity: 0.55; } -.chipDot { width: 6px; height: 6px; background: var(--led); border-radius: 1px; } -.chipCard .mono { font-size: 11px; } -.arrow { font-size: 10px; color: var(--faint); } - -/* ── output presets + preview ── */ -.outPresets { display: flex; gap: 6px; } +.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; } -.outPreset:hover { border-color: var(--led); color: var(--led); } -.pvOverlay { - position: fixed; inset: 0; z-index: 50; - background: rgba(20, 20, 20, 0.45); - display: flex; align-items: center; justify-content: center; +/* ── 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; } } -.pvOverlay[hidden] { display: none; } -.pvCard { - background: var(--cream); border: 1px solid var(--ink); border-radius: 3px; - padding: 18px; display: flex; flex-direction: column; gap: 12px; - width: 640px; max-width: 94vw; +/* 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; } } -.pvHead { display: flex; justify-content: space-between; align-items: center; } -.pvChips { display: flex; 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; +/* 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; + } } -.pvSig.on { border-color: var(--led); font-weight: 600; } -#pvScope { display: block; background: var(--field); border: 1px solid var(--rule); } -.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); } + +/* ── 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/tools/patternflow-audio-extension/editor.html b/tools/patternflow-audio-extension/editor.html index 5fb9e6764..a8a299b97 100644 --- a/tools/patternflow-audio-extension/editor.html +++ b/tools/patternflow-audio-extension/editor.html @@ -2,12 +2,17 @@ + Patternflow Audio — Mapping
+
@@ -17,78 +22,125 @@
idle - patternflow.local:81 +
+ + +
+
- - - -
- Attack - - quick -
-
- Damping - - balanced + Each box follows the loudness of what it hears. Drag a top or bottom edge to take all four over by hand. +
+
+ + + +
+
+ + + +
-
+
-
B1·K1
-
B2·K2
-
B3·K3
-
B4·K4
+
K1
+
K2
+
K3
+
K4
+ +
+ + +
+
+ + + + +
+
-
- RESPONSE - +
+ Knob 1 +
-
- CURVE -
- - - - - - -
- - - + diff --git a/tools/patternflow-audio-extension/editor.js b/tools/patternflow-audio-extension/editor.js index d815e02f7..6cd2030c5 100644 --- a/tools/patternflow-audio-extension/editor.js +++ b/tools/patternflow-audio-extension/editor.js @@ -1,137 +1,142 @@ // Patternflow Audio — the mapping editor. // -// One module, no chrome.* and no fetch: everything outside this file arrives -// through window.PFAdapter (editor-adapter.js). The console port copies this -// file verbatim and swaps the adapter. +// 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 -// later the firmware — only interpolates, and never learns what a bezier is. +// 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; - -// ── config ────────────────────────────────────────────────────────────── - -const HZ_DEFAULTS = [[60, 250], [250, 2000], [2000, 5000], [5000, 16000]]; -const LUT_POINTS = 33; - -const PRESETS = { - smooth: { label: 'Smooth', curve: { type: 'bezier', id: 'smooth', y0: 0, y1: 1, p1x: 0.45, p1y: 0.05, p2x: 0.55, p2y: 0.95 } }, - sharp: { label: 'Sharp', curve: { type: 'bezier', id: 'sharp', y0: 0, y1: 1, p1x: 0.10, p1y: 0.65, p2x: 0.35, p2y: 1.00 } }, - fall: { label: 'Fall', curve: { type: 'bezier', id: 'fall', y0: 1, y1: 0, p1x: 0.45, p1y: 0.95, p2x: 0.55, p2y: 0.05 } }, - gate: { label: 'Gate', curve: { type: 'steps', id: 'gate', n: 2 } }, - steps: { label: 'Steps', curve: { type: 'steps', id: 'steps', n: 3 } }, - arch: { label: 'Arch', curve: { type: 'arch', id: 'arch' } } -}; - -const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0)); -const clampHz = (v) => Math.max(CAPS.hzMin, Math.min(CAPS.hzMax, Number(v) || CAPS.hzMin)); - -const CAPS = A.caps(); - -function defaultBand(index) { - const [hzMin, hzMax] = HZ_DEFAULTS[index % HZ_DEFAULTS.length]; - return { - hzMin, hzMax, - knob: Math.min(index, 3), muted: false, - inMin: 0, inMax: 1, gain: 1, - outMin: 0.30, outMax: 0.85, - curve: null, lut: null - }; -} - -function normalizeBand(raw, index) { - const band = { ...defaultBand(index), ...(raw || {}) }; - band.hzMin = clampHz(band.hzMin); band.hzMax = clampHz(band.hzMax); - if (band.hzMin > band.hzMax) [band.hzMin, band.hzMax] = [band.hzMax, band.hzMin]; - if (band.hzMax - band.hzMin < 10) band.hzMax = Math.min(CAPS.hzMax, band.hzMin + 10); - band.inMin = clamp01(band.inMin); band.inMax = clamp01(band.inMax); - if (band.inMax - band.inMin < 0.02) band.inMax = Math.min(1, band.inMin + 0.02); - band.outMin = clamp01(band.outMin); band.outMax = clamp01(band.outMax); - if (band.outMax - band.outMin < 0.05) band.outMax = Math.min(1, band.outMin + 0.05); - // Four bands, four knobs, one line between them: band i drives knob i. - // A free assignment existed and earned nothing but a control to explain. - band.knob = index; - band.muted = band.muted === true; - band.gain = Math.max(0.2, Math.min(4, Number(band.gain) || 1)); - if (band.curve && typeof band.curve !== 'object') band.curve = null; - if (band.lut && (!Array.isArray(band.lut) || band.lut.length < 2)) band.lut = null; - return band; -} - -function normalizeConfig(raw) { - const stored = Array.isArray(raw && raw.bands) ? raw.bands : []; - const bands = []; - for (let i = 0; i < 4; i++) bands.push(normalizeBand(stored[i], i)); - return { - host: (raw && raw.host) || 'patternflow.local', - smoothing: (raw && raw.smoothing) || 0.35, - sendIntervalMs: (raw && raw.sendIntervalMs) || 33, - autoRange: !!(raw && raw.autoRange), - attack: Math.max(0.05, Math.min(0.9, Number(raw && raw.attack) || 0.65)), - bands, - ...(raw && raw.manualExtra ? { manualExtra: raw.manualExtra } : {}) +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); } -// ── curve engine ──────────────────────────────────────────────────────── - -function evalCurve(curve, u) { - u = clamp01(u); - if (!curve) return u; - if (curve.type === 'steps') { - const n = Math.max(2, Math.min(8, Math.round(curve.n) || 2)); - const k = Math.min(n - 1, Math.floor(u * n)); - return k / (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 legacyCurve(band, u) { - return Math.pow(clamp01(u), 1 / band.gain); -} +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) : legacyCurve(band, u); -} - -function bakeLut(curve) { - const lut = []; - for (let i = 0; i < LUT_POINTS; i++) { - lut.push(Number(evalCurve(curve, i / (LUT_POINTS - 1)).toFixed(4))); - } - return lut; + return band.curve ? evalCurve(band.curve, u) : PFMap.curveValue(band, u); } // ── state ─────────────────────────────────────────────────────────────── -let cfg = normalizeConfig(null); +// The mapping being edited (null until init() has it), and the knob selected. +let cfg = null; let sel = 0; -let frame = { levels: [], env: [], spectrum: [], running: false, connected: false }; -let lastEnv = null; // most recent non-empty env, for the auto→manual handoff -let saveTimer = null; +let frame = {}; +let saveTimer = 0; +// Every edit saves itself, 150 ms after the last one of a burst. function persist() { clearTimeout(saveTimer); - saveTimer = setTimeout(() => A.saveConfig(cfg), 150); + 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) { @@ -144,69 +149,132 @@ function touchCurve(band, curve) { const $ = (id) => document.getElementById(id); const plotCanvas = $('plot'); const pctx = plotCanvas.getContext('2d'); -const M = { l: 48, r: 6, t: 22, b: 40 }; -let PW = 0, PH = 0; // plot area, css px - +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() { - const wrap = $('plotWrap'); - const cssW = wrap.clientWidth; - const cssH = 400; + 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.width = cssW + 'px'; 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 DECADES = Math.log10(CAPS.hzMax / CAPS.hzMin); 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); -// 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. -const 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 DB_LINES = [[1, '0'], [0.75, '-18'], [0.5, '-35'], [0.25, '-53'], [0, '-70']]; +// 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 device console wraps the same module in its dark tokens. +// 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, fallback) => (s.getPropertyValue(name) || fallback).trim(); - const led = v('--led', '#e8552e'); - const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(led.replace('#', '')); - const ledRgb = m ? `${parseInt(m[1], 16)}, ${parseInt(m[2], 16)}, ${parseInt(m[3], 16)}` : '232, 85, 46'; + 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', '#fbf7ef'), - cream: v('--cream', '#f4efe6'), - ink: v('--ink', '#141414'), - muted: v('--muted', '#6b655a'), - faint: v('--faint', '#a69f90'), - rule: v('--rule', '#d9d1c0'), - led, - ledA: (a) => `rgba(${ledRgb}, ${a})`, - ruleA: (a) => `rgba(${parseInt(v('--rule', '#d9d1c0').replace('#', '').slice(0, 2), 16)}, ${parseInt(v('--rule', '#d9d1c0').replace('#', '').slice(2, 4), 16)}, ${parseInt(v('--rule', '#d9d1c0').replace('#', '').slice(4, 6), 16)}, ${a})`, - inkA: (a) => `rgba(${parseInt(v('--ink', '#141414').replace('#', '').slice(0, 2), 16)}, ${parseInt(v('--ink', '#141414').replace('#', '').slice(2, 4), 16)}, ${parseInt(v('--ink', '#141414').replace('#', '').slice(4, 6), 16)}, ${a})` + 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 window a band maps right now: its own handles, or the breathing -// envelope while auto range runs. +// 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]; - if (cfg.autoRange) { - const env = (frame.env && frame.env[index]) || (lastEnv && lastEnv[index]); - if (env) return { lo: clamp01(env.lo), hi: clamp01(env.hi) }; - return { lo: 0.1, hi: 0.7 }; - } - return { lo: band.inMin, hi: band.inMax }; + 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) { @@ -215,7 +283,10 @@ function formatHz(hz) { // ── main plot drawing ─────────────────────────────────────────────────── -const MONO = '10px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'; +// 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(); @@ -227,9 +298,9 @@ function drawPlot() { // grid pctx.font = MONO; pctx.strokeStyle = T.ruleA(0.55); - pctx.fillStyle = T.faint; + pctx.fillStyle = T.muted; pctx.lineWidth = 1; - for (const [v, label] of DB_LINES) { + 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(); @@ -237,6 +308,11 @@ function drawPlot() { 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; @@ -289,26 +365,30 @@ function drawPlot() { pctx.beginPath(); pctx.moveTo(x0, yBot); pctx.lineTo(x1, yBot); pctx.stroke(); pctx.setLineDash([]); - // live level as a horizontal line across the box — the band's average. - // The line BELONGS to the box: below the window it sits pinned on the - // bottom edge ("resting under the floor"), above it on the top. Drawn at - // the raw position it wandered out of its own box, which read as broken. - const level = (frame.levels && frame.levels[i]) || 0; - const ly = Math.max(yTop + 1.5, Math.min(yBot - 1.5, yOf(level))); - pctx.strokeStyle = T.led; - pctx.lineWidth = 2; - pctx.beginPath(); - pctx.moveTo(x0 + 1, ly); - pctx.lineTo(x1 - 1, ly); - pctx.stroke(); + // 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 - 3.5, cy - 3.5, 7, 7); - pctx.strokeRect(cx - 3.5, cy - 3.5, 7, 7); + pctx.fillRect(cx - GRIP / 2, cy - GRIP / 2, GRIP, GRIP); + pctx.strokeRect(cx - GRIP / 2, cy - GRIP / 2, GRIP, GRIP); } } }); @@ -316,31 +396,37 @@ function drawPlot() { 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.style.left = Math.round(xOf(band.hzMin)) + 'px'; - tag.style.top = Math.round(yOf(win.hi) - 11) + 'px'; tag.classList.toggle('sel', i === sel); tag.classList.toggle('mutedTag', band.muted); - tag.querySelector('.tagText').textContent = 'B' + (i + 1) + (band.muted ? '·M' : ''); + 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'; }); } -// tiny curve glyph, used by tags and footer chips +// 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; - canvas.style.width = w + 'px'; canvas.style.height = h + 'px'; } const g = canvas.getContext('2d'); g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, w, h); - g.strokeStyle = color || theme().ink; + g.strokeStyle = color; g.lineWidth = 1.4; g.lineCap = 'round'; g.beginPath(); @@ -356,7 +442,6 @@ function drawGlyph(canvas, band, w, h, color) { // ── main plot interaction ─────────────────────────────────────────────── -const EDGE = 7; let drag = null; function hitTest(x, y) { @@ -371,8 +456,18 @@ function hitTest(x, y) { const insideX = x > x0 - EDGE && x < x1 + EDGE; const insideY = y > yTop - EDGE && y < yBot + EDGE; if (!insideX || !insideY) continue; - const nearL = Math.abs(x - x0) <= EDGE, nearR = Math.abs(x - x1) <= EDGE; - const nearT = Math.abs(y - yTop) <= EDGE, nearB = Math.abs(y - yBot) <= EDGE; + // 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' }; @@ -387,16 +482,22 @@ function hitTest(x, y) { return null; } -// Grabbing a breathing edge is the gesture that says "I'll take it from +// 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. -function freezeAutoWindows() { - const env = (frame.env && frame.env.length ? frame.env : lastEnv) || []; +// 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 e = env[i]; - if (!e) return; - band.inMin = clamp01(e.lo); - band.inMax = Math.max(band.inMin + 0.02, clamp01(e.hi)); + const env = liveEnv(i); + if (!env) return; + band.inMin = env.lo; + band.inMax = env.hi; + fitWindow(band); }); cfg.autoRange = false; syncAutoToggle(); @@ -407,26 +508,40 @@ function plotPointer(event) { 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); - if (cfg.autoRange && hit.mode !== 'move' && hit.mode !== 'l' && hit.mode !== 'r') { - freezeAutoWindows(); // any grab that touches a breathing edge takes over - } - const band = cfg.bands[hit.band]; - const win = bandWindow(hit.band); - drag = { - ...hit, - startX: x, startY: y, - hzMin: band.hzMin, hzMax: band.hzMax, - lo: win.lo, hi: win.hi - }; + // 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) { @@ -439,7 +554,16 @@ plotCanvas.addEventListener('pointermove', (event) => { return; } const band = cfg.bands[drag.band]; - const MINR = 1.12; // narrowest box, as a frequency ratio + 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 @@ -448,30 +572,34 @@ plotCanvas.addEventListener('pointermove', (event) => { 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 = drag.hzMax * 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; - const lo = Math.max(0, Math.min(1 - span, drag.lo + dv)); - band.inMin = lo; - band.inMax = lo + span; + 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. - if (drag.mode.includes('l')) band.hzMin = Math.min(hzOf(x), band.hzMax / MINR); - if (drag.mode.includes('r')) band.hzMax = Math.max(hzOf(x), band.hzMin * MINR); - if (drag.mode.includes('t')) band.inMax = Math.max(vOf(y), band.inMin + 0.02); - if (drag.mode.includes('b')) band.inMin = Math.min(vOf(y), band.inMax - 0.02); + // 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; - persist(); + if (moved) persist(); }; plotCanvas.addEventListener('pointerup', releasePlot); plotCanvas.addEventListener('pointercancel', releasePlot); @@ -480,15 +608,17 @@ plotCanvas.addEventListener('pointercancel', releasePlot); const curveCanvas = $('curve'); const cctx = curveCanvas.getContext('2d'); -const CM = { l: 30, r: 10, t: 12, b: 26 }; +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 = 356, cssH = 218; + const cssW = curveCanvas.clientWidth, cssH = 218; curveCanvas.width = cssW * dpr; curveCanvas.height = cssH * dpr; - curveCanvas.style.width = cssW + 'px'; curveCanvas.style.height = cssH + 'px'; cctx.setTransform(dpr, 0, 0, dpr, 0, 0); CW = cssW - CM.l - CM.r; CH = cssH - CM.t - CM.b; } @@ -538,47 +668,62 @@ function drawCurve() { cctx.strokeStyle = T.ink; cctx.lineWidth = 1; for (const [hx, hy] of [[c.p1x, c.p1y], [c.p2x, c.p2y]]) { - cctx.fillRect(cxOf(hx) - 3.5, cyOf(hy) - 3.5, 7, 7); - cctx.strokeRect(cxOf(hx) - 3.5, cyOf(hy) - 3.5, 7, 7); + 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 + // 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); - const level = (frame.levels && frame.levels[sel]) || 0; - const u = clamp01((level - win.lo) / Math.max(0.001, win.hi - win.lo)); - 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(); + 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 + // 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.faint; + cctx.fillStyle = T.muted; cctx.textAlign = 'left'; - cctx.fillText(cfg.autoRange ? 'in auto' : 'in ' + win.lo.toFixed(2), CM.l, CM.t + CH + 16); + cctx.fillText(cfg.autoRange ? 'auto range' : lo, CM.l, CM.t + CH + 16); cctx.textAlign = 'right'; - cctx.fillText(cfg.autoRange ? '' : win.hi.toFixed(2), CM.l + CW, CM.t + CH + 16); - cctx.fillText(cfg.bands[sel].outMax.toFixed(2), CM.l - 6, CM.t + 8); - cctx.fillText(cfg.bands[sel].outMin.toFixed(2), CM.l - 6, CM.t + CH); - cctx.textAlign = 'left'; - cctx.fillText(u.toFixed(2) + ' → ' + (cfg.bands[sel].outMin + v * (cfg.bands[sel].outMax - cfg.bands[sel].outMin)).toFixed(2), CM.l + 6, CM.t + 12); + 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 band = cfg.bands[sel]; - if (!band.curve || band.curve.type !== 'bezier') return; const rect = curveCanvas.getBoundingClientRect(); - const x = event.clientX - rect.left, y = event.clientY - rect.top; - const c = band.curve; - const near = (hx, hy) => Math.hypot(x - cxOf(hx), y - cyOf(hy)) < 12; - if (near(c.p1x, c.p1y)) curveDrag = 'p1'; - else if (near(c.p2x, c.p2y)) curveDrag = 'p2'; - else return; + curveDrag = curveHit(event.clientX - rect.left, event.clientY - rect.top); + if (!curveDrag) return; curveCanvas.setPointerCapture(event.pointerId); event.preventDefault(); }); @@ -615,13 +760,18 @@ function renderPresetChips() { 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); }); - $('legacyHint').hidden = !!band.curve; + // 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.curve, gain: 1 }, 40, 24, theme().ink); + drawGlyph(canvas, { curve: preset, gain: 1 }, 40, 24, theme().ink); }); } @@ -629,7 +779,7 @@ document.querySelectorAll('.preset').forEach((el) => { el.addEventListener('click', () => { const preset = PRESETS[el.dataset.p]; if (!preset) return; - touchCurve(cfg.bands[sel], structuredClone(preset.curve)); + touchCurve(cfg.bands[sel], structuredClone(preset)); drawCurve(); renderPresetChips(); placeTags(); @@ -653,19 +803,26 @@ function stepsAdjust(delta) { 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]; - $('bandName').textContent = 'Band ' + (sel + 1); + const lo = dbText(band.inMin); + $('bandName').textContent = 'Knob ' + (sel + 1); $('bandMeta').textContent = formatHz(band.hzMin) + ' – ' + formatHz(band.hzMax) + ' Hz' - + (cfg.autoRange ? ' · in auto' : ' · in ' + band.inMin.toFixed(2) + '–' + band.inMax.toFixed(2)); + + (cfg.autoRange ? ' · auto range' : lo && ' · ' + lo + ' to ' + dbText(band.inMax) + ' dB'); $('outLabel').textContent = band.outMin.toFixed(2) + ' – ' + band.outMax.toFixed(2); - const track = $('outTrack'); - const w = track.clientWidth - 10; - $('outLo').style.left = (5 + band.outMin * w - 5) + 'px'; - $('outHi').style.left = (5 + band.outMax * w - 5) + 'px'; + 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) => { @@ -677,12 +834,16 @@ document.querySelectorAll('.outPreset').forEach((el) => { }); }); +// 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 = Math.abs(v - band.outMin) < Math.abs(v - band.outMax) ? 'lo' : 'hi'; + outDrag = { lo: Math.abs(v - band.outMin) < Math.abs(v - band.outMax), was: [band.outMin, band.outMax] }; $('outTrack').setPointerCapture(event.pointerId); event.preventDefault(); }); @@ -691,13 +852,19 @@ $('outTrack').addEventListener('pointermove', (event) => { 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 - 0.05); - else band.outMax = Math.max(v, band.outMin + 0.05); + 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(); }); -const releaseOut = () => { if (outDrag) { outDrag = null; persist(); } }; -$('outTrack').addEventListener('pointerup', releaseOut); -$('outTrack').addEventListener('pointercancel', releaseOut); $('muteToggle').addEventListener('click', () => { const band = cfg.bands[sel]; @@ -705,21 +872,30 @@ $('muteToggle').addEventListener('click', () => { renderSettings(); renderChips(); drawPlot(); persist(); }); -$('resetBand').addEventListener('click', () => { - cfg.bands[sel] = defaultBand(sel); - touchCurve(cfg.bands[sel], structuredClone(PRESETS.smooth.curve)); - selectBand(sel); - 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); }); -// ── footer chips ──────────────────────────────────────────────────────── +// 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('.chipName').textContent = 'B' + (i + 1); chip.querySelector('.chipHz').textContent = formatHz(band.hzMin) + '–' + formatHz(band.hzMax); drawGlyph(chip.querySelector('canvas'), band, 18, 12, theme().muted); }); @@ -740,13 +916,21 @@ function selectBand(index) { // ── preview ───────────────────────────────────────────────────────────── // -// Ableton-style: synthetic test signals through the SELECTED band's whole +// 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 pvOverlay = $('pvOverlay'); +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; @@ -764,40 +948,32 @@ function pvSignal(t) { // 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 Math.min(1, Math.max(0, spike + 0.07 + (Math.random() - 0.5) * 0.03)); + 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 Math.min(1, Math.max(0, - 0.30 + 0.10 * Math.sin(t * 1.3) + (Math.random() - 0.5) * 0.22 + spike)); + return clamp01(0.30 + 0.10 * Math.sin(t * 1.3) + (Math.random() - 0.5) * 0.22 + spike); } // swell - return Math.min(1, Math.max(0, - 0.45 + 0.35 * Math.sin(t * Math.PI * 2 * 0.35) + (Math.random() - 0.5) * 0.04)); + 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. - const a = pvLevel < sig - ? Math.max(0.05, Math.min(0.9, cfg.attack)) - : Math.max(0.05, Math.min(0.9, cfg.smoothing)); + // (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]; - let u; - if (cfg.autoRange) { - u = clamp01((pvLevel - 0.10) / 0.85); - } else { - const inMin = clamp01(band.inMin); - const inMax = Math.max(inMin + 0.01, clamp01(band.inMax)); - u = clamp01((pvLevel - inMin) / (inMax - inMin)); - } - return band.outMin + bandCurveValue(band, u) * (band.outMax - band.outMin); + // 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 = 600, h = 230; + const w = pvW, h = pvH; pvCtx.clearRect(0, 0, w, h); pvCtx.strokeStyle = T.ruleA(0.5); pvCtx.lineWidth = 1; @@ -821,7 +997,7 @@ function pvDraw() { } function pvTick(now) { - if (pvOverlay.hidden) return; + 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 @@ -853,26 +1029,31 @@ function pvSchedule() { }, 300); } -function pvOpen() { +// 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; - pvScope.width = 600 * dpr; pvScope.height = 230 * dpr; - pvScope.style.width = '600px'; pvScope.style.height = '230px'; + pvW = pvScope.clientWidth; pvH = pvScope.clientHeight; + pvScope.width = pvW * dpr; pvScope.height = pvH * dpr; pvCtx.setTransform(dpr, 0, 0, dpr, 0, 0); - $('pvTitle').textContent = 'Preview — Band ' + (sel + 1); - pvIn = []; pvOut = []; pvT = 0; pvLast = 0; pvLevel = 0; - pvOverlay.hidden = false; - pvSchedule(); } -function pvClose() { - pvOverlay.hidden = true; +// 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', pvOpen); -$('pvClose').addEventListener('click', pvClose); -pvOverlay.addEventListener('click', (e) => { if (e.target === pvOverlay) pvClose(); }); +$('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; @@ -882,23 +1063,23 @@ document.querySelectorAll('.pvSig').forEach((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); - $('autoHint').textContent = cfg.autoRange - ? 'boxes breathe with the room — drag a top or bottom edge to take manual control' - : 'manual windows — the box edges are exactly what maps'; + $('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', () => { - if (cfg.autoRange) freezeAutoWindows(); - else cfg.autoRange = true; + cfg.autoRange = !cfg.autoRange; syncAutoToggle(); drawPlot(); drawCurve(); renderSettings(); persist(); }); -$('stopBtn').addEventListener('click', () => A.stop()); - // 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 @@ -907,68 +1088,90 @@ $('stopBtn').addEventListener('click', () => A.stop()); 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']]; -function dampWord(v) { - for (const [top, word] of DAMP_WORDS) if (v < top) return word; - return 'glassy'; -} +const dampWord = (v) => DAMP_WORDS.find(([top]) => v < top)[1]; function syncDamping() { const v = clamp01((0.9 - cfg.smoothing) / 0.85); - $('damping').value = String(v); + $('damping').value = v; $('dampingWord').textContent = dampWord(v); const k = clamp01((0.9 - cfg.attack) / 0.85); - $('attack').value = String(k); + $('attack').value = k; $('attackWord').textContent = ATK_WORDS.find(([top]) => k < top)[1]; } $('attack').addEventListener('input', () => { const v = clamp01($('attack').value); - cfg.attack = Math.max(0.05, Math.min(0.9, 0.9 - v * 0.85)); + 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 = Math.max(0.05, Math.min(0.9, 0.9 - v * 0.85)); + cfg.smoothing = 0.9 - v * 0.85; $('dampingWord').textContent = dampWord(v); persist(); }); -function renderStatus() { - const el = $('sourceChip'); - if (frame.demo) { el.textContent = 'demo source'; el.className = 'chip'; } - else if (frame.running && frame.connected) { el.textContent = (A.labels && A.labels.live) || 'live · tab audio'; el.className = 'chip okChip'; } - else if (frame.running) { el.textContent = 'capturing · connecting'; el.className = 'chip'; } - else { el.textContent = 'idle'; el.className = 'chip'; } - $('hostChip').textContent = cfg.host + ':81'; - $('captureHint').textContent = frame.running || frame.demo ? '' : A.captureHint; - $('stopBtn').disabled = !frame.running; -} - -A.onFrame((state) => { - frame = state || {}; - if (frame.env && frame.env.length) lastEnv = frame.env; - drawPlot(); - drawCurve(); - renderStatus(); -}); - +// 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(); - cfg = normalizeConfig(stored); - if (A._setDemoConfig) A._setDemoConfig(cfg); + readCaps(); + cfg = PFMap.normalizeConfig(stored, CAPS); sizePlot(); sizeCurve(); syncAutoToggle(); syncDamping(); selectBand(0); - renderStatus(); - A.requestStatus(); + // 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/tools/patternflow-audio-extension/manifest.json b/tools/patternflow-audio-extension/manifest.json index c4f409b7f..8e2ac5342 100644 --- a/tools/patternflow-audio-extension/manifest.json +++ b/tools/patternflow-audio-extension/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "Patternflow Audio", - "description": "Capture tab audio, map spectrum bands, and send knob deltas to Patternflow.", - "version": "0.1.0", + "description": "Capture a tab's audio, map four frequency bands onto the four knobs, and stream them to a Patternflow panel.", + "version": "0.2.0", "icons": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", diff --git a/tools/patternflow-audio-extension/mapping.js b/tools/patternflow-audio-extension/mapping.js new file mode 100644 index 000000000..975fc629b --- /dev/null +++ b/tools/patternflow-audio-extension/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/tools/patternflow-audio-extension/offscreen.html b/tools/patternflow-audio-extension/offscreen.html index 15521ab9d..ab66731bd 100644 --- a/tools/patternflow-audio-extension/offscreen.html +++ b/tools/patternflow-audio-extension/offscreen.html @@ -2,6 +2,7 @@ + diff --git a/tools/patternflow-audio-extension/offscreen.js b/tools/patternflow-audio-extension/offscreen.js index e216c148c..dd3cf4d36 100644 --- a/tools/patternflow-audio-extension/offscreen.js +++ b/tools/patternflow-audio-extension/offscreen.js @@ -1,4 +1,47 @@ -let config = null; +// Patternflow Audio — offscreen document: the analysis, the socket, and the +// truth about both. +// +// Tab capture hands over a MediaStream, and a MediaStream needs a document to +// live in; a service worker has none and is put to sleep besides. So this +// page holds the capture, runs the mapping (mapping.js) 30 times a second and +// writes the four knob lanes to the panel. +// +// It is also the one place that KNOWS whether a capture is running, which +// address the socket points at, whether that socket opened, whether the panel +// on the other end is listening, and why a capture ended. Every state message +// it sends carries all of that (see status()), so the service worker, which +// Chrome restarts with empty memory whenever it likes, can rebuild from any +// single one of them, or ask. +// +// An offscreen document has chrome.runtime and nothing else. No +// chrome.storage: the mapping arrives in messages (the popup reads it at +// Start, the editor sends every save) and is normalised HERE, on receipt, +// whoever sent it. A `host` inside a mapping is ignored; the address comes +// only with start, manual-connect and host, which only the popup sends. + +const WS_PORT = 81; // where the panel's audio socket listens +const TICK_MS = 33; // analyse and send at about 30 Hz +const REPORT_MS = 120; // levels and spectrum to the popup and the editor + +// The panel hands a lane back to its encoder 500 ms after the last message +// that set it, and docs/audio-ws-spec.md asks for a resend at least every 250. +// A frame that has not changed is resent on the first tick past this. The +// timer's 33 ms come out nearer 35, so that is six ticks, about 210 ms; at +// 200 it was seven, 245, and once in eight minutes 251. +const HOLD_MS = 180; + +const QUIET_MS = 2000; // a spectrum flat for this long is "no sound" +const ASK_MS = 4000; // how long the panel gets to answer over HTTP + +let config = null; // the mapping: PFMap.normalizeConfig() of what arrived +let address = ''; // as the popup sent it: a name or an IP, a port if one was typed +let host = ''; // 'name:port' the socket is pointed at, '' when idle +let tabTitle = ''; +let error = ''; // what is wrong right now +let note = ''; // why the last capture ended, when nobody pressed Stop +let running = false; // a tab is being analysed +let manual = false; // the test connection: a socket, no audio + let audioCtx = null; let analyser = null; let sourceNode = null; @@ -8,109 +51,251 @@ let ws = null; let tickTimer = null; let reconnectTimer = null; let lastLevelReport = 0; -let smoothing = [0, 0, 0, 0]; -let running = false; -let manual = false; let wsWanted = false; let wsSerial = 0; -let lastSentValues = [-1, -1, -1, -1]; + +// What the panel was last told, and when. `driven` is the lanes this socket +// has set and not yet handed back. let lastSentBody = ''; +let lastSentAt = 0; +let driven = [false, false, false, false]; + +// The test connection's sliders: a knob that was moved is held where it was +// put, null is a knob nobody touched. +let manualLanes = [null, null, null, null]; + +// Audio-React on the panel (the AUD row of its NETWORK screen): true, false, +// or null when the panel has not said. The socket opens either way and the +// panel ignores every frame while it is off, so an open socket alone is not +// "live". +let panelAudio = null; +let checking = false; // that question is on its way + +// Socket attempts in a row that never opened. The first one asks the panel's +// HTTP side what is there, once; `streak` changes whenever the answer to that +// would no longer be about the current attempt. +let failures = 0; +let streak = 0; + +// Per band: the smoothed level, and the envelope auto range maps it through. +let levels = [0, 0, 0, 0]; +let envelopes = [0, 1, 2, 3].map(PFMap.newEnvelope); + +// Per band, the loudest tick since the last report: its level, where that sat +// in the window and what the knob got. A report every 120 ms that carried the +// latest tick showed a kick at anything from 42% to 100% of its height. +let peakLevels = [-1, -1, -1, -1]; +let peakPos = [0, 0, 0, 0]; +let peakOutputs = [0, 0, 0, 0]; + +let heard = false; // this capture has had sound at least once +let heardAt = 0; // when it last had + +function connected() { + return !!ws && ws.readyState === WebSocket.OPEN; +} -// Auto-range envelopes, one pair per band: the floor and peak the band has -// recently seen, in the same dbNorm units as the levels. Attack is instant on -// both edges, release is a slow exponential (~8 s at the 30 Hz tick — the -// device runs 0.002 at 60 Hz, this is the same time constant). MIN_SPAN stops -// silence from collapsing the window into a noise amplifier. Same idea as the -// device's auto range, minus its microphone gate — tab audio is digital and -// silent means zero, so there is no whine floor to fence off. -let envLo = [1, 1, 1, 1]; -let envHi = [0, 0, 0, 0]; -const ENV_RELEASE = 0.004; -const ENV_MIN_SPAN = 0.06; -const AUTO_LO = 0.10; // relative squelch inside the tracked window -const AUTO_HI = 0.95; // relative full-scale - -function resetEnvelopes() { - envLo = [1, 1, 1, 1]; - envHi = [0, 0, 0, 0]; +// The one word for all of it. The popup and the editor both print from this, +// so they cannot disagree about what "live" means: +// idle nothing is running +// connecting no open socket yet, or the panel has not yet said whether it listens +// off connected, and Audio-React is switched off on the panel +// live a tab is captured and the panel is being driven +// test the test connection is up +// error see `error` +function phase() { + if (error) return 'error'; + if (!running && !manual) return 'idle'; + if (!connected() || checking) return 'connecting'; + if (panelAudio === false) return 'off'; + return running ? 'live' : 'test'; } -function trackEnvelopes(levels) { - for (let i = 0; i < 4; i++) { - const v = clamp01(levels[i] || 0); - if (v > envHi[i]) envHi[i] = v; - else envHi[i] += (v - envHi[i]) * ENV_RELEASE; - if (v < envLo[i]) envLo[i] = v; - else envLo[i] += (v - envLo[i]) * ENV_RELEASE; - if (envHi[i] < envLo[i] + ENV_MIN_SPAN) envHi[i] = envLo[i] + ENV_MIN_SPAN; +function status() { + return { + running, + manual, + connected: connected(), + phase: phase(), + host, + tabTitle, + error, + note, + panelAudio, + heard, + silent: running && performance.now() - heardAt > QUIET_MS, + held: manual ? manualLanes.slice() : [] + }; +} + +// Always the whole status, plus whatever is live. A patch that carried only +// `connected` left the service worker to remember the rest, and after a +// restart it remembered nothing: the popup said Idle over a running capture. +function patchState(live) { + chrome.runtime.sendMessage({ type: 'offscreen-state', patch: { ...status(), ...live } }).catch(() => {}); +} + +// The popup sends a checked address, with a port only if one was typed. +function wsHost() { + return /:\d+$/.test(address) ? address : `${address}:${WS_PORT}`; +} + +// The same panel over HTTP. Never the socket's port: a port that was typed is +// where the audio socket is, and the panel's API is on 80 whatever that says. +function httpName() { + return address.replace(/:\d+$/, ''); +} + +// One question to the panel's HTTP API. Returns null when nobody answered in +// time, else { status, json } with json null for anything that is not JSON. +// The panel serves one HTTP connection at a time, so these are single +// requests at moments that matter, never a poll. +async function ask(path, options) { + const abort = new AbortController(); + const timer = setTimeout(() => abort.abort(), ASK_MS); + try { + const reply = await fetch(`http://${httpName()}${path}`, { ...options, cache: 'no-store', signal: abort.signal }); + return { status: reply.status, json: reply.ok ? await reply.json().catch(() => null) : null }; + } catch (failure) { + return null; + } finally { + clearTimeout(timer); } } -const MIN_HZ = 20; -const MAX_HZ = 20000; +// Is the panel listening? Asked once when the socket opens (`opening`: until +// the answer is in, nobody is told "live"), and again each time the popup +// opens onto an open socket: the switch is on the panel and may have been +// thrown either way since, so "off" and "live" are both old news by then. +// Anything but a clear yes or no (no answer, a 404 from firmware that has no +// such route) is "not known": nothing is claimed about it, and an earlier +// answer stands. +async function checkPanel(opening) { + const serial = wsSerial; + if (opening) { + checking = true; + patchState(); + } + const answer = await ask('/api/audio'); + if (serial !== wsSerial) return; + const on = answer && answer.json && answer.json.audioRuntime; + if (typeof on === 'boolean') panelAudio = on; + checking = false; + patchState(); +} -function patchState(patch) { - chrome.runtime.sendMessage({ type: 'offscreen-state', patch }).catch(() => {}); +// The popup's Turn on. Returns what to tell the person when it did not work. +async function turnOn() { + const serial = wsSerial; + const answer = await ask('/api/audio', { + method: 'POST', + // The panel's web server reads a body as arguments only under this type. + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'on=1' + }); + const on = answer && answer.json && answer.json.audioRuntime; + if (typeof on !== 'boolean') { + return 'The panel did not answer. Audio-React can also be switched on at the panel: the AUD row of its NETWORK screen.'; + } + if (serial === wsSerial) panelAudio = on; + return ''; } -function normalizeHost(host) { - let value = (host || 'patternflow.local').trim(); - value = value.replace(/^https?:\/\//, '').replace(/^wss?:\/\//, '').replace(/\/.*$/, ''); - if (!value.includes(':')) value += ':81'; - return value; +// A socket that will not open says nothing about why. One look at the +// panel's HTTP side does: nobody there, a panel without the audio feature, or +// something that answers and still refuses the socket. Asked once per run of +// failures: the socket retries every 1.2 s, and a question per retry would +// queue up on an address that never answers. +async function diagnose() { + const mine = streak; + const name = httpName(); + const answer = await ask('/api/status'); + if (mine !== streak) return; + const caps = answer && answer.json && answer.json.caps; + if (!answer) { + error = `No panel at ${name}. Check the address (the panel's NETWORK screen shows its IP) and that this computer is on the same Wi-Fi. Retrying.`; + } else if (Array.isArray(caps) && !caps.includes('audio')) { + error = `The panel at ${name} does not run the Audio edition. Get it at patternflow.work/editions.`; + } else { + // Firmware from before `caps`, a wrong port, or not a panel at all: no + // more is known than that the socket stays shut. + error = `${Array.isArray(caps) ? 'The panel' : 'Something'} at ${name} answers, but the audio connection to ${host} does not open. Retrying.`; + } + patchState(); } -function wsUrl() { - return `ws://${normalizeHost(config.host)}`; +// A new target, or the current one answered: what was learned about the last +// run of failures no longer applies. +function newStreak() { + failures = 0; + streak++; } function connectWs() { clearTimeout(reconnectTimer); const previous = ws; + ws = null; try { if (previous) { previous.onopen = null; - previous.onerror = null; previous.onclose = null; previous.close(); } - } catch (error) {} + } catch (failure) {} const serial = ++wsSerial; - const socket = new WebSocket(wsUrl()); + panelAudio = null; + checking = false; + + let socket; + try { + socket = new WebSocket(`ws://${host}`); + } catch (failure) { + // Not something a socket can be opened to. The popup checks what is + // typed, so this came from elsewhere; trying again would fail the same. + error = `"${host}" is not an address a connection can be made to.`; + patchState(); + return; + } ws = socket; + let opened = false; socket.onopen = () => { if (socket !== ws || serial !== wsSerial) return; - patchState({ connected: true, error: '' }); - }; - - socket.onerror = () => { - if (socket !== ws || serial !== wsSerial) return; - patchState({ connected: false, error: 'WebSocket error' }); + opened = true; + error = ''; + newStreak(); + // The panel let go of every lane when the last socket closed: it has been + // told nothing, so the next tick says everything again. + resetWire(); + checkPanel(true); }; socket.onclose = () => { if (socket !== ws || serial !== wsSerial) return; - patchState({ connected: false }); - if (wsWanted && config) reconnectTimer = setTimeout(connectWs, 1200); + if (!opened && ++failures === 1) diagnose(); + patchState(); + if (wsWanted) reconnectTimer = setTimeout(connectWs, 1200); }; + + // The old socket is gone and this one is not open yet: say so now, before + // anyone reads a `connected` that was about the old address. + patchState(); } function send(msg, options = {}) { - if (!ws || ws.readyState !== WebSocket.OPEN) return false; + if (!connected()) return false; if (!options.control && ws.bufferedAmount > 0) return false; ws.send(msg); return true; } -// -1 is "nothing sent yet", so the first real level always goes out. With -// absolute values there is no baseline to match — only a cache of what the -// lane was last told. -function resetOutputBaselines() { - lastSentValues = [-1, -1, -1, -1]; +// The panel on the other end has been told nothing (a new socket, or `off`). +function resetWire() { lastSentBody = ''; + lastSentAt = 0; + driven = [false, false, false, false]; } // Absolute, not a delta. @@ -131,24 +316,35 @@ function resetOutputBaselines() { // also the quietest one. // // A '-' leaves a lane alone, which is what a muted band sends. +// +// An unchanged frame is NOT news, but it still has to be said: the panel +// releases a lane 500 ms after the last message that set it. Skipping +// unchanged frames handed the knobs back half a second into any silence, any +// pause, any curve resting on one step, while the popup said Live. So a frame +// goes out when it differs from the last one or when HOLD_MS have passed. function sendLanes(values) { + // A lane this socket set and no longer drives is handed back now, once, + // and not left to the panel's timeout. + for (let i = 0; i < 4; i++) { + if (values[i] === null && driven[i] && send(`off=${i}`, { control: true })) driven[i] = false; + } + if (values.every((v) => v === null)) return; + const body = values .map((v) => (v === null ? '-' : Math.max(0, Math.min(1, v)).toFixed(3))) .join(','); - if (body === lastSentBody) return; - if (send(`a=${body}`)) lastSentBody = body; -} - -function sendOutputValue(knob, value) { - const idx = Math.max(0, Math.min(3, Number(knob) || 0)); - const normalized = Math.max(0, Math.min(1, Number(value) || 0)); - if (Math.abs(normalized - lastSentValues[idx]) < 0.002) return; - if (send(`k=${idx},v=${normalized.toFixed(3)}`)) { - lastSentValues[idx] = normalized; + const now = performance.now(); + if (body === lastSentBody && now - lastSentAt < HOLD_MS) return; + if (!send(`a=${body}`)) return; + lastSentBody = body; + lastSentAt = now; + for (let i = 0; i < 4; i++) { + if (values[i] !== null) driven[i] = true; } } -async function stop() { +// Everything off and forgotten. `why` is for a capture that ended by itself. +async function stop(why = '') { running = false; manual = false; wsWanted = false; @@ -157,27 +353,30 @@ async function stop() { tickTimer = null; reconnectTimer = null; wsSerial++; + newStreak(); send('off', { control: true }); - resetOutputBaselines(); + resetWire(); try { if (ws) { ws.onopen = null; - ws.onerror = null; ws.onclose = null; ws.close(); } - } catch (error) {} + } catch (failure) {} ws = null; try { if (sourceNode) sourceNode.disconnect(); - } catch (error) {} + } catch (failure) {} sourceNode = null; if (mediaStream) { - mediaStream.getTracks().forEach((track) => track.stop()); + mediaStream.getTracks().forEach((track) => { + track.onended = null; + track.stop(); + }); mediaStream = null; } @@ -188,65 +387,93 @@ async function stop() { analyser = null; freqBuf = null; - smoothing = [0, 0, 0, 0]; - resetEnvelopes(); - patchState({ running: false, manual: false, connected: false, levels: [], outputs: [], spectrum: [], env: [] }); + config = null; + address = ''; + host = ''; + tabTitle = ''; + error = ''; + note = why; + panelAudio = null; + checking = false; + heard = false; + manualLanes = [null, null, null, null]; + levels = [0, 0, 0, 0]; + peakLevels = [-1, -1, -1, -1]; + envelopes = [0, 1, 2, 3].map(PFMap.newEnvelope); + patchState({ levels: [], pos: [], outputs: [], spectrum: [], env: [] }); } -async function start(streamId, nextConfig) { +async function start(message) { await stop(); - config = nextConfig; - running = true; - manual = false; - wsWanted = true; - resetOutputBaselines(); - - audioCtx = new AudioContext(); - mediaStream = await navigator.mediaDevices.getUserMedia({ - audio: { - mandatory: { - chromeMediaSource: 'tab', - chromeMediaSourceId: streamId - } - }, - video: false - }); - - analyser = audioCtx.createAnalyser(); - analyser.fftSize = 2048; - analyser.smoothingTimeConstant = 0.3; - freqBuf = new Float32Array(analyser.frequencyBinCount); + try { + config = PFMap.normalizeConfig(message.config, PFMap.SOURCE); + address = String(message.host || '').trim(); + host = wsHost(); + tabTitle = message.tabTitle || ''; + running = true; + wsWanted = true; + + audioCtx = new AudioContext(); + mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: { + mandatory: { + chromeMediaSource: 'tab', + chromeMediaSourceId: message.streamId + } + }, + video: false + }); - sourceNode = audioCtx.createMediaStreamSource(mediaStream); - sourceNode.connect(analyser); - analyser.connect(audioCtx.destination); + // The stream ends when its tab is closed. Nothing else says so: the + // analyser keeps returning silence and the socket stays open, so the popup + // went on reading Live over a tab that no longer existed. + mediaStream.getAudioTracks().forEach((track) => { + track.onended = () => stop('The captured tab was closed.'); + }); - connectWs(); - tickTimer = setInterval(tick, config.sendIntervalMs || 33); - patchState({ running: true, manual: false, error: '' }); + analyser = audioCtx.createAnalyser(); + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0.3; + freqBuf = new Float32Array(analyser.frequencyBinCount); + + sourceNode = audioCtx.createMediaStreamSource(mediaStream); + sourceNode.connect(analyser); + analyser.connect(audioCtx.destination); + + // "No sound yet" is said after QUIET_MS of nothing, counted from here. + heardAt = performance.now(); + + connectWs(); + tickTimer = setInterval(tick, TICK_MS); + } catch (failure) { + // Half a capture is not one: undo what was set up, then let the caller + // say why. + await stop(); + throw failure; + } } -async function manualConnect(nextConfig) { +async function manualConnect(target) { await stop(); - config = nextConfig; + address = String(target || '').trim(); + host = wsHost(); manual = true; wsWanted = true; - resetOutputBaselines(); connectWs(); - patchState({ running: false, manual: true, error: '' }); -} - -function sendManualValue(knob, value) { - const idx = Math.max(0, Math.min(3, Number(knob) || 0)); - const normalized = Math.max(0, Math.min(1, Number(value) || 0)); - const outputs = [0, 0, 0, 0]; - outputs[idx] = normalized; - sendOutputValue(idx, normalized); - patchState({ outputs }); + tickTimer = setInterval(tick, TICK_MS); } -function clamp01(value) { - return Math.max(0, Math.min(1, Number(value) || 0)); +// The popup committed another address while something is connected: follow it. +function retarget(target) { + if (!wsWanted) return; + const was = host; + address = String(target || '').trim(); + host = wsHost(); + if (host === was) return; + error = ''; + newStreak(); + resetWire(); // a different panel has been told nothing yet + connectWs(); } function hzToBin(hz) { @@ -256,158 +483,95 @@ function hzToBin(hz) { // Raw level only. Gain used to be folded in here, which meant it scaled the // signal BEFORE the input window clipped it — so raising boost also slid the // band out of its own window, and the two controls fought. It shapes the -// curve now, in mapBandOutput, which is the curve the popup draws. -function normalizeDb(db) { - return clamp01((db + 80) / 70); -} - -function bandEnergy(band) { - const minBin = Math.max(0, hzToBin(band.hzMin)); - const maxBin = Math.min(freqBuf.length - 1, hzToBin(band.hzMax)); +// curve now (PFMap.curveValue), which is the curve the editor draws. +function binsLevel(minBin, maxBin) { + minBin = Math.max(0, minBin); + maxBin = Math.min(freqBuf.length - 1, maxBin); if (maxBin < minBin) return 0; let sum = 0; for (let i = minBin; i <= maxBin; i++) sum += freqBuf[i]; - const avgDb = sum / (maxBin - minBin + 1); - return normalizeDb(avgDb); -} - -// The whole chain: -// -// level -> window (manual in/full, or the auto-tracked envelope) -// -> response curve -> [rests at .. peaks at] -// -// The response curve has two generations. Bands the editor has touched carry -// a baked 33-point lookup table (band.lut) — any shape at all, including -// falling and non-monotonic ones — and this side only interpolates, exactly -// like the firmware will. Bands never touched keep the legacy boost exponent -// (band.gain): above 1x a quiet band reaches its top early, below 1x a loud -// one holds back. Ends stay put either way. -function curveValue(band, u) { - const lut = band.lut; - if (Array.isArray(lut) && lut.length >= 2) { - const pos = clamp01(u) * (lut.length - 1); - const i = Math.floor(pos); - const j = Math.min(lut.length - 1, i + 1); - const f = pos - i; - return clamp01(Number(lut[i]) || 0) * (1 - f) + clamp01(Number(lut[j]) || 0) * f; - } - const gain = Math.max(0.2, Math.min(4, Number(band.gain) || 1)); - return Math.pow(clamp01(u), 1 / gain); -} - -function mapBandOutput(energy, band, index) { - let u; - if (config.autoRange && index !== undefined) { - // The band's own recent floor..peak is the window; a fixed relative - // squelch keeps the resting noise of the window's bottom from dancing. - const lo = envLo[index]; - const span = Math.max(0.001, envHi[index] - envLo[index]); - u = clamp01((clamp01((energy - lo) / span) - AUTO_LO) / (AUTO_HI - AUTO_LO)); - } else { - const inMin = clamp01(band.inMin ?? 0); - const inMax = Math.max(inMin + 0.01, clamp01(band.inMax ?? 1)); - u = clamp01((energy - inMin) / (inMax - inMin)); - } - const outMin = clamp01(band.outMin ?? 0); - const outMax = clamp01(band.outMax ?? 1); - return outMin + curveValue(band, u) * (outMax - outMin); + return PFMap.normalizeDb(sum / (maxBin - minBin + 1)); } function computeSpectrum() { const count = 64; const values = []; - const logMin = Math.log10(MIN_HZ); - const logMax = Math.log10(MAX_HZ); + const logMin = Math.log10(PFMap.SOURCE.hzMin); + const logMax = Math.log10(PFMap.SOURCE.hzMax); for (let i = 0; i < count; i++) { - const t0 = i / count; - const t1 = (i + 1) / count; - const hz0 = 10 ** (logMin + (logMax - logMin) * t0); - const hz1 = 10 ** (logMin + (logMax - logMin) * t1); - const minBin = Math.max(0, hzToBin(hz0)); - const maxBin = Math.min(freqBuf.length - 1, hzToBin(hz1)); - if (maxBin < minBin) { - values.push(0); - continue; - } - let sum = 0; - for (let bin = minBin; bin <= maxBin; bin++) sum += freqBuf[bin]; - values.push(normalizeDb(sum / (maxBin - minBin + 1))); + const hz0 = 10 ** (logMin + (logMax - logMin) * (i / count)); + const hz1 = 10 ** (logMin + (logMax - logMin) * ((i + 1) / count)); + values.push(binsLevel(hzToBin(hz0), hzToBin(hz1))); } return values; } function tick() { + // The test connection has no audio: it keeps saying where the sliders are. + if (manual) { + sendLanes(manualLanes); + return; + } if (!running || !analyser || !freqBuf || !config) return; analyser.getFloatFrequencyData(freqBuf); - const bands = config.bands || []; - const outputs = []; - const levels = []; - const alpha = config.smoothing ?? 0.35; - - while (smoothing.length < bands.length) smoothing.push(0); - if (smoothing.length > bands.length) smoothing.length = bands.length; - - // A lane nothing drives stays null, and goes out as '-'. Two bands on the - // same knob: the last one wins, deterministically, rather than whichever - // happened to get the socket first. - const lanes = [null, null, null, null]; + const bands = config.bands; + const auto = config.autoRange; // Levels first, for every band including muted ones — the editor shows a // muted band's level so you can see what it WOULD do, and the auto-range // envelopes keep tracking so unmuting does not open on a stale window. - for (let i = 0; i < bands.length; i++) { - const raw = bandEnergy(bands[i]); + for (let i = 0; i < 4; i++) { + const raw = binsLevel(hzToBin(bands[i].hzMin), hzToBin(bands[i].hzMax)); // Glide ballistics: a hit ATTACKS at its own speed, the fall RELEASES at // the damping - two user-set alphas. Symmetric smoothing made percussive // music feel late; this is the VU-meter split every reactive light wants. - const atk = Math.max(0.05, Math.min(0.9, config.attack ?? 0.65)); - const a = raw > smoothing[i] ? atk : alpha; - smoothing[i] = a * raw + (1 - a) * smoothing[i]; - levels[i] = smoothing[i]; - } - if (config.autoRange) trackEnvelopes(levels); - - for (let i = 0; i < bands.length; i++) { - const band = bands[i]; - const idx = Math.max(0, Math.min(3, Number(band.knob) || 0)); - if (band.muted) { - outputs[i] = 0; - continue; - } - const mapped = mapBandOutput(levels[i], band, i); - outputs[i] = mapped; - lanes[idx] = mapped; + 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]); } - // Muting a band hands its lane back once, and only if this client had it. + // The mapping itself is PFMap's: where each level sits in its window, and + // what the knob gets for that. Both go out with the levels, so the editor + // can draw what was mapped instead of working it out a second time. + // + // A lane nothing drives stays null, and goes out as '-'. + const lanes = [null, null, null, null]; for (let i = 0; i < 4; i++) { - if (lanes[i] === null && lastSentValues[i] !== -1) { - send(`off=${i}`, { control: true }); - lastSentValues[i] = -1; - } else if (lanes[i] !== null) { - lastSentValues[i] = lanes[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 (levels[i] > peakLevels[i]) { + peakLevels[i] = levels[i]; + peakPos[i] = pos; + peakOutputs[i] = output; } } sendLanes(lanes); const now = performance.now(); - if (now - lastLevelReport > 120) { + if (now - lastLevelReport > REPORT_MS) { lastLevelReport = now; + const spectrum = computeSpectrum(); + // Flat is what the plot shows as flat: nothing above a hundredth of it. + if (spectrum.some((v) => v > 0.01)) { + heard = true; + heardAt = now; + } patchState({ - levels, - outputs, - spectrum: computeSpectrum(), + levels: peakLevels.slice(), + pos: peakPos.slice(), + outputs: peakOutputs.slice(), + spectrum, // The editor draws these as each box's breathing top and bottom edge. - env: config.autoRange - ? envLo.map((lo, i) => ({ lo, hi: envHi[i] })) - : [], - autoRange: config.autoRange === true + env: auto ? envelopes.map((e) => ({ lo: e.lo, hi: e.hi })) : [], + autoRange: auto }); + peakLevels = [-1, -1, -1, -1]; } } @@ -415,49 +579,52 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.target !== 'offscreen') return; (async () => { - if (message.type === 'start') { - await start(message.streamId, message.config); - sendResponse({ ok: true }); - return; - } - - if (message.type === 'stop') { - await stop(); - config = null; - sendResponse({ ok: true }); - return; - } - - if (message.type === 'config') { - const oldUrl = config ? wsUrl() : ''; - config = message.config; - if (running) resetOutputBaselines(); - if (ws && oldUrl !== wsUrl()) connectWs(); - sendResponse({ ok: true }); - return; - } - - if (message.type === 'manual-connect') { - await manualConnect(message.config); - sendResponse({ ok: true }); - return; - } - - if (message.type === 'manual-value') { - sendManualValue(message.knob, message.value); - sendResponse({ ok: true }); - return; - } - - if (message.type === 'release') { - send('off', { control: true }); - resetOutputBaselines(); - sendResponse({ ok: true }); - return; + // What went wrong with the request itself, for whoever sent it. Not a + // fault of the capture, so it does not go into `error`. + let refused = ''; + + switch (message.type) { + case 'start': + await start(message); + break; + case 'manual-connect': + await manualConnect(message.host); + break; + case 'stop': + await stop(); + break; + case 'host': + retarget(message.host); + break; + case 'config': + // Only a running capture has a use for a mapping; the next Start + // brings its own. What changed goes out with the next tick: a new + // value as a new frame, a band that was muted as `off=N`. + if (running) config = PFMap.normalizeConfig(message.config, PFMap.SOURCE); + break; + case 'manual-value': + if (manual) { + manualLanes[Math.max(0, Math.min(3, Number(message.knob) || 0))] = PFMap.clamp01(message.value); + sendLanes(manualLanes); + } + break; + case 'panel-on': + refused = await turnOn(); + break; + case 'panel-check': + if (connected() && !checking) await checkPanel(false); + break; + case 'status': + break; + default: + sendResponse({ ok: false, error: 'Unknown message', state: status() }); + return; } - })().catch((error) => { - patchState({ running: false, connected: false, error: String(error.message || error) }); - sendResponse({ ok: false, error: String(error.message || error) }); + sendResponse({ ok: !refused, error: refused || undefined, state: status() }); + })().catch((failure) => { + error = String(failure.message || failure); + patchState(); + sendResponse({ ok: false, error, state: status() }); }); return true; diff --git a/tools/patternflow-audio-extension/popup.css b/tools/patternflow-audio-extension/popup.css index cc83cb1b5..742541b68 100644 --- a/tools/patternflow-audio-extension/popup.css +++ b/tools/patternflow-audio-extension/popup.css @@ -1,9 +1,7 @@ :root { --cream: #f4efe6; - --cream-2: #ede7db; --ink: #141414; --muted: #6b655a; - --faint: #a69f90; --rule: #d9d1c0; --led: #e8552e; --ok: #267a43; @@ -15,6 +13,26 @@ box-sizing: border-box; } +/* `hidden` has to win over the display values set below. */ +[hidden] { + display: none !important; +} + +/* Keyboard focus. The browser's own ring is near black, and on the filled + buttons that is black on black. */ +:focus-visible { + outline: 2px solid var(--led); + outline-offset: 2px; +} + +/* A text field shows its ring on a mouse click too. In the accent colour + that read as a warning, right against the red border of text that is not + an address, so the address keeps an ink ring. */ +input[type="text"]:focus-visible { + outline-color: var(--ink); + outline-offset: 0; +} + body { width: 460px; max-height: 620px; @@ -27,9 +45,7 @@ body { } header, -.section-head, -.band-head, -.band-actions { +.section-head { display: flex; align-items: center; } @@ -69,12 +85,18 @@ section { background: transparent; } +/* Neutral unless it has something to claim: green is "the panel is being + driven" (or the test connection is up), red is something wrong. Idle and + connecting are neither. Colour is the only thing that tells Live from + Idle, so the word is heavy enough to carry it. */ .status { min-width: 82px; padding: 5px 8px 4px; border: 1px solid var(--ink); background: var(--field); - font-size: 10px; + color: var(--muted); + font-size: 11px; + font-weight: 600; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: .12em; text-align: center; @@ -101,25 +123,26 @@ section { } input, -select, button { font: inherit; } -input[type="text"], -select { +input[type="text"] { width: 100%; + padding: 8px 9px; border: 1px solid var(--rule); background: var(--field); color: var(--ink); } -input[type="text"] { - padding: 8px 9px; +/* Text that is not an address: nothing was stored, nothing connects. The + ring of a focused field would hide a red border, so it turns red with it. */ +input[aria-invalid="true"] { + border-color: var(--bad); } -select { - padding: 3px 6px; +input[aria-invalid="true"]:focus-visible { + outline-color: var(--bad); } button { @@ -139,28 +162,25 @@ button:hover { color: var(--ink); } -.small-button, -.remove-band { - min-height: 26px; - padding: 5px 8px; -} - -.remove-band { +/* A control that has nothing to act on in the current state. */ +button:disabled { border-color: var(--rule); - background: transparent; + background: var(--rule); color: var(--muted); + cursor: default; } -.actions { - display: grid; - /* Two, since WS Test moved into the diagnostics fold. */ - grid-template-columns: 1fr 1fr; - gap: 7px; +/* The second button of a section: an action offered, not the main one. */ +button.quiet { margin-top: 8px; + padding: 0 14px; + background: transparent; + color: var(--ink); } -.actions button { - padding: 8px; +button.quiet:hover { + background: var(--ink); + color: var(--cream); } .detail { @@ -175,6 +195,13 @@ button:hover { gap: 8px; } +/* A test connection has no audio, so the plot has nothing to show, and with + it the open fold made the popup taller than the 600 px Chrome allows: K3 + and K4 ended up under the edge. The fold takes the plot's place. */ +body:has(#test[open]) .spectrum-section { + display: none; +} + .spectrum-wrap { position: relative; height: 132px; @@ -188,223 +215,29 @@ button:hover { height: 100%; } -.spectrum-bands { - position: absolute; - inset: 0; - pointer-events: none; -} - -.spectrum-band { - position: absolute; - top: 0; - bottom: 0; - border-left: 1px solid var(--led); - border-right: 1px solid var(--led); - background: rgba(232, 85, 46, .16); -} - -.spectrum-band span { - position: absolute; - left: 5px; - top: 5px; - color: var(--ink); - font-size: 10px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} - +/* The plot is logarithmic, so the labels are placed, not spread: popup.js + gives each one its `left`. Spread evenly, "1k" sat over 317 Hz. */ .spectrum-scale { - display: grid; - grid-template-columns: repeat(5, 1fr); + position: relative; + height: 15px; margin-top: 5px; color: var(--muted); font-size: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -.spectrum-scale span:last-child { - text-align: right; -} - -.global { - display: grid; - grid-template-columns: 1fr 42px; - align-items: center; - gap: 8px; -} - -.global label, -.manual-grid label { - display: grid; - align-items: center; - gap: 8px; - color: var(--muted); - font-size: 11px; -} - -.global label { - grid-template-columns: 74px 1fr; -} - -.manual-grid { - display: grid; - gap: 8px; -} - -.manual-grid label { - grid-template-columns: 28px 1fr 40px; -} - -.value, -output { - color: var(--ink); - text-align: right; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-variant-numeric: tabular-nums; -} - -.band { - margin: 0; - padding-top: 12px; -} - -.band-head { - justify-content: space-between; - gap: 8px; - margin-bottom: 8px; -} - -.band-title { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; -} - -.band-actions { - gap: 7px; -} - -.target { - display: grid; - grid-template-columns: auto 48px; - align-items: center; - gap: 5px; - color: var(--muted); - font-size: 11px; -} - -.band-strip, -.map-row { - display: grid; - align-items: center; - gap: 8px; - margin: 8px 0; -} - -.band-strip { - grid-template-columns: 1fr 1fr 92px; -} - -.map-row { - grid-template-columns: 48px 1fr 92px; -} - -.map-row > span, -.row span { - color: var(--muted); - font-size: 10px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - text-transform: uppercase; -} - -.band-readout { - display: grid; - grid-template-columns: 1fr auto 1fr; - gap: 4px; - align-items: center; - color: var(--muted); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 10px; -} - -.map-sliders { - display: grid; - gap: 4px; -} - -.row { - display: grid; - grid-template-columns: 54px 1fr 48px; - align-items: center; - gap: 8px; - margin: 6px 0; -} - -details.advanced { - margin-top: 8px; -} - -details.advanced summary { - color: var(--muted); - cursor: pointer; - font-size: 10px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - letter-spacing: .08em; - text-transform: uppercase; -} - -input[type="range"] { - width: 100%; - accent-color: var(--ink); -} - -.meter { - height: 6px; - margin-top: 8px; - overflow: hidden; - background: var(--cream-2); - border: 1px solid var(--rule); -} - -.meter.out { - height: 3px; - margin-top: 3px; -} - -.meter div { - width: 0; - height: 100%; - background: var(--led); - transition: width .08s linear; -} - -.meter.out div { - background: var(--ink); -} - -/* ── Named frequency ranges, per band ──────────────────────────────────── - The sliders are log-scaled now, so a range is reachable by hand — but - nobody should have to drag for "bass" when the name is the thing they - mean. Same six ranges the device's own audio page offers. */ -.hz-presets { - display: flex; - flex-wrap: wrap; - gap: 4px; - margin-top: 6px; +.spectrum-scale span { + position: absolute; + top: 0; + transform: translateX(-50%); } -.hz-presets button { - min-height: 0; - padding: 3px 7px; - font-size: 10px; - letter-spacing: 0.04em; - border-color: var(--rule); - background: transparent; - color: var(--muted); +.spectrum-scale span:first-child { + transform: none; } -.hz-presets button:hover { - color: var(--ink); - border-color: var(--ink); +.spectrum-scale span:last-child { + transform: translateX(-100%); } /* ── Diagnostics, folded ───────────────────────────────────────────────── @@ -428,241 +261,40 @@ input[type="range"] { width: 100%; } -/* ── Band selector ─────────────────────────────────────────────────────── - Four bands, one editor on screen. These say which one you are on, which - knob it drives, and whether it is muted — the three things worth knowing - before you switch. */ -.band-tabs { +.manual-grid { display: grid; - /* Four bands and All. */ - grid-template-columns: repeat(4, 1fr) 1.2fr; - gap: 6px; - margin-top: 10px; + gap: 8px; } -.band-tab { - display: flex; - flex-direction: column; +.manual-grid label { + display: grid; + grid-template-columns: 28px 1fr 40px; align-items: center; - gap: 1px; - padding: 6px 0 5px; - min-height: 0; - border-color: var(--rule); - background: transparent; - color: var(--muted); -} - -.band-tab strong { font-size: 13px; font-weight: 600; } -.band-tab span { font-size: 9px; letter-spacing: 0.08em; } - -.band-tab:hover { color: var(--ink); border-color: var(--ink); } - -.band-tab.active { - background: var(--ink); - border-color: var(--ink); - color: var(--cream); -} - -/* A muted band stays in place and stays reachable — it just is not driving - anything, and should not look like it is. */ -.band-tab.muted { opacity: 0.45; } -.band-tab.muted strong { text-decoration: line-through; } - -.spectrum-band { cursor: pointer; } -.spectrum-band.muted { opacity: 0.35; } - -.mute-band { - min-height: 26px; - padding: 5px 8px; - border-color: var(--rule); - background: transparent; - color: var(--muted); -} - -.mute-band.on { - border-color: var(--led); - color: var(--led); -} - -.hint { - font-size: 10px; - letter-spacing: 0.04em; - color: var(--faint); -} - -/* `hidden` loses to any explicit display, and .band sets one. Say it again. */ -.band[hidden] { display: none; } - -.band-tab.all strong { font-size: 11px; letter-spacing: 0.06em; } - -/* ── The response graph ────────────────────────────────────────────────── - Six sliders became one picture. Left to right is how loud the band is, - bottom to top is where the knob lands, and the line between them is every - number at once — which is the thing the sliders could not show. */ -.band-row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 10px; - margin-bottom: 6px; -} - -.band-key { + gap: 8px; color: var(--muted); - font-size: 10px; - letter-spacing: 0.14em; - text-transform: uppercase; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - white-space: nowrap; -} - -.band-note { color: var(--faint); font-size: 10px; } - -.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } - -.plot-wrap { display: flex; gap: 10px; } - -.plot-axis { - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: flex-end; - width: 26px; - height: 180px; - padding: 2px 0; - color: var(--faint); - font-size: 9px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; } -.plot-col { flex: 1; min-width: 0; } - -.plot { - position: relative; - height: 180px; - border: 1px solid var(--ink); - background: var(--field); - overflow: hidden; - cursor: grab; - /* The handles are dragged; the popup must not pan or select instead. */ - touch-action: none; - user-select: none; +output { color: var(--ink); -} - -.plot:active { cursor: grabbing; } - -/* the stretch of knob the music is allowed to use */ -.plot-out { position: absolute; left: 0; right: 0; background: #f0e6d6; } -/* the levels this band responds to at all */ -.plot-in { - position: absolute; top: 0; bottom: 0; - border-left: 1px dashed var(--faint); - border-right: 1px dashed var(--faint); -} - -.plot-curve { position: absolute; inset: 0; width: 100%; height: 100%; } - -.plot-vline { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--led); opacity: .55; } -.plot-hline { position: absolute; left: 0; right: 0; height: 1px; background: var(--led); opacity: .55; } - -.plot-dot { - position: absolute; - width: 9px; height: 9px; - margin: -5px 0 0 -5px; - border-radius: 50%; - background: var(--led); - box-shadow: 0 0 0 2px var(--field); -} - -/* Handles: triangles pointing at the edge they move. Input on the floor, - output on the right wall — so which axis a handle belongs to is visible - before it is dragged. */ -.h { position: absolute; width: 14px; height: 14px; background: var(--ink); } -.h-in-min, .h-in-max { bottom: 0; margin-left: -7px; clip-path: polygon(50% 0, 100% 100%, 0 100%); } -.h-out-min, .h-out-max { right: 0; margin-top: -7px; clip-path: polygon(0 50%, 100% 0, 100% 100%); } -.h.on { background: var(--led); } - -.plot-scale { - display: flex; - justify-content: space-between; - margin-top: 3px; - color: var(--faint); - font-size: 9px; + text-align: right; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-variant-numeric: tabular-nums; } -/* The same four numbers as words, for anyone who wants to read rather than - aim — and so a value can be checked without hovering a handle. */ -.readout { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 7px 14px; - margin-top: 12px; - padding: 10px 11px; - background: var(--field); - border: 1px solid var(--rule); -} - -.readout > div { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; -} - -.readout span { color: var(--muted); font-size: 11px; } -.readout output { font-size: 11px; } - -/* Boost bends the line rather than moving its ends, so it is not something - to drag on the plot. Seven steps, and the hint says what the shape means. */ -.boost-row { display: flex; align-items: center; gap: 12px; margin-top: 13px; } -.boost-steps { flex: 1; display: flex; gap: 3px; } - -.boost-steps button { - flex: 1; - min-height: 0; - height: 22px; - padding: 0; - border-color: var(--rule); - background: var(--field); +input[type="range"] { + width: 100%; + accent-color: var(--ink); } -.boost-steps button.soft { background: var(--cream-2); } -.boost-steps button.on { background: var(--ink); border-color: var(--ink); } -.boost-steps button:hover { border-color: var(--ink); } - -.boost-row .gainOut { width: 34px; text-align: right; font-size: 11px; } -.boost-hint { margin-top: 6px; color: var(--muted); font-size: 11px; } - -/* The meters said nothing about which was which. */ -.meter-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; } -.meter-row .meter { flex: 1; margin-top: 0; } - -.meter-key { - width: 26px; - color: var(--faint); - font-size: 9px; - letter-spacing: 0.1em; - text-transform: uppercase; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +input[type="range"]:disabled { + opacity: .45; } -/* How much of its knob this band actually uses. The number that names a - band which is technically working and doing nothing. */ -.travelOut { width: 42px; text-align: right; font-size: 10px; color: var(--muted); } -.travelOut.weak { color: var(--led); } - -/* The one road to the mapping editor — mapping no longer lives in the popup. */ -.editor-cta { +/* The one road to the mapping editor: mapping no longer lives in the popup. + Full width, and outlined like every second action (button.quiet), so the + one filled button of the popup's first screen is Start. */ +button.editor-cta { width: 100%; - font: inherit; - font-weight: 600; - cursor: pointer; - border: 1px solid var(--ink); - border-radius: 2px; - background: var(--ink); - color: var(--cream); - padding: 9px 12px; + margin-top: 0; } -.editor-cta:hover { background: var(--led); border-color: var(--led); } diff --git a/tools/patternflow-audio-extension/popup.html b/tools/patternflow-audio-extension/popup.html index af06a8524..40940e9ba 100644 --- a/tools/patternflow-audio-extension/popup.html +++ b/tools/patternflow-audio-extension/popup.html @@ -12,27 +12,27 @@

Patternflow Audio

-
Idle
+
Idle
-
+ +
- -
+ +
Open the tab you want to hear, then press Start.
-
- - -
+ +
- -
Bands, boxes and response curves live there.
+ +
Boxes and response curves for K1–K4 live there.
@@ -42,31 +42,27 @@

Patternflow Audio

-
- 20 - 100 - 1k - 10k - 20k -
+ +
-
+
Test without audio - -
- - - - -
+ +
+ + + + +
+ diff --git a/tools/patternflow-audio-extension/popup.js b/tools/patternflow-audio-extension/popup.js index 02ea94373..200fcd362 100644 --- a/tools/patternflow-audio-extension/popup.js +++ b/tools/patternflow-audio-extension/popup.js @@ -1,45 +1,37 @@ // Patternflow Audio — popup: the capture console. // -// This used to also be the mapping editor — band strips, drag handles, boost -// buttons — until the real editor moved to its own tab (editor.html), where -// boxes on a spectrum have room to be dragged. What remains here is what a -// popup is good at: start/stop the capture, point at the device, see that -// audio is flowing, and one button to the place where mapping actually -// happens. The popup never edits bands anymore; it only ships the stored -// config along with start/config messages. - -const MIN_HZ = 20; -const MAX_HZ = 20000; - -const DEFAULT_CONFIG = { - host: 'patternflow.local', - smoothing: 0.35, - sendIntervalMs: 33, - // Four, because the panel has four knobs. Shipping one meant a first run - // moved a single knob and looked broken; the device page has defaulted to - // four the whole time. - bands: [ - { hzMin: 60, hzMax: 250, knob: 0, outMin: 0.30, outMax: 0.85, inMin: 0, inMax: 1, gain: 1 }, - { hzMin: 250, hzMax: 2000, knob: 1, outMin: 0.30, outMax: 0.85, inMin: 0, inMax: 1, gain: 1 }, - { hzMin: 2000, hzMax: 5000, knob: 2, outMin: 0.30, outMax: 0.85, inMin: 0, inMax: 1, gain: 1 }, - { hzMin: 5000, hzMax: 16000, knob: 3, outMin: 0.30, outMax: 0.85, inMin: 0, inMax: 1, gain: 1 } - ] -}; - -let config = structuredClone(DEFAULT_CONFIG); +// What a popup is good at: start and stop the capture, say where the panel +// is, see that audio is flowing, and one button to the tab where mapping +// happens (editor.html). Two things are this file's and nobody else's. +// +// The panel's address. It has a storage key of its own, so the editor, +// which owns the mapping (PFMap.CONFIG_KEY), and the popup never write the +// same value, and an editor tab left open cannot put an old address back. +// It is committed on Enter, on leaving the field and on Start. Committing +// per keystroke dropped a live socket for every character and tried +// 192.168.0.4 on the way to 192.168.0.42. +// +// Asking for the capture. The tab's stream id needs the click that opened +// this popup. +// +// The mapping is read at Start and handed over as it is stored; nothing here +// edits it or writes it. What is running, what it is connected to, whether +// the panel is listening and why a capture ended belong to the offscreen +// document: it sums them up as one `phase`, and this file prints that. Every +// control here follows it too, so none is offered that has nothing to act on. + +const HOST_KEY = 'patternflowAudioHost'; +const DEFAULT_HOST = 'patternflow.local'; + +let host = DEFAULT_HOST; // the committed address +let hostError = ''; // why the field's text is not an address +let failure = ''; // what the last thing asked for here could not do let state = null; -let saveTimer = null; const $ = (id) => document.getElementById(id); -const clamp01 = (value) => Math.max(0, Math.min(1, Number(value) || 0)); -const clampHz = (value) => Math.max(MIN_HZ, Math.min(MAX_HZ, Number(value) || MIN_HZ)); -function storageGet(key) { - return new Promise((resolve) => chrome.storage.local.get(key, resolve)); -} - -function storageSet(value) { - return new Promise((resolve) => chrome.storage.local.set(value, resolve)); +function storageGet(keys) { + return new Promise((resolve) => chrome.storage.local.get(keys, resolve)); } function sendMessage(message) { @@ -60,123 +52,155 @@ function getStreamId(tabId) { }); } -function newBand(index = config.bands.length) { - const presets = [ - [60, 250], - [250, 2000], - [2000, 5000], - [5000, 16000] - ]; - const [hzMin, hzMax] = presets[index % presets.length]; - return { - hzMin, - hzMax, - knob: Math.min(index, 3), - outMin: 0.30, - outMax: 0.85, - inMin: 0.00, - inMax: 1.00, - gain: 1.00 - }; +// What someone typed, as the address to store: a host name or an IP, with a +// port only if they gave one. A pasted URL loses its scheme and path. Returns +// null when what is left is not an address. +// +// The shape is checked here and not left to `new URL`: Chrome's parser turns +// "not a host" into not%20a%20host and calls it valid. +const ADDRESS = /^(\[[0-9a-f:.]+\]|[\p{L}\p{N}](?:[\p{L}\p{N}._-]*[\p{L}\p{N}])?)(?::(\d+))?$/iu; + +function parseHost(text) { + const value = String(text || '').trim() + .replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') + .replace(/[/?#].*$/, ''); + if (!value) return DEFAULT_HOST; + + const parts = ADDRESS.exec(value); + if (!parts) return null; + const [, name, port] = parts; + if (port !== undefined && !(Number(port) >= 1 && Number(port) <= 65535)) return null; + try { + // The parser's spelling of it: lower case, 192.168.1 written out in full. + return new URL(`ws://${name}`).hostname + (port === undefined ? '' : `:${Number(port)}`); + } catch (error) { + return null; + } } -function normalizeBand(raw, index) { - const legacyBase = Number.isFinite(Number(raw.base)) ? Number(raw.base) : 0.30; - const legacyRange = Number.isFinite(Number(raw.range)) ? Number(raw.range) : 0.55; - let outMin = Number.isFinite(Number(raw.outMin)) ? Number(raw.outMin) : legacyBase; - let outMax = Number.isFinite(Number(raw.outMax)) ? Number(raw.outMax) : legacyBase + legacyRange; - - outMin = clamp01(outMin); - outMax = clamp01(outMax); - if (outMin > outMax) [outMin, outMax] = [outMax, outMin]; - - let inMin = Number.isFinite(Number(raw.inMin)) ? clamp01(raw.inMin) : 0; - let inMax = Number.isFinite(Number(raw.inMax)) ? clamp01(raw.inMax) : 1; - if (inMin > inMax) [inMin, inMax] = [inMax, inMin]; - if (inMax - inMin < 0.01) inMax = Math.min(1, inMin + 0.01); - - let hzMin = clampHz(raw.hzMin ?? 60); - let hzMax = clampHz(raw.hzMax ?? 250); - if (hzMin > hzMax) [hzMin, hzMax] = [hzMax, hzMin]; - if (hzMax - hzMin < 10) hzMax = Math.min(MAX_HZ, hzMin + 10); - - const band = { - hzMin, - hzMax, - knob: Math.max(0, Math.min(3, Number(raw.knob ?? index) || 0)), - muted: raw.muted === true, - outMin, - outMax, - inMin, - inMax, - gain: Math.max(0.2, Math.min(4, Number(raw.gain) || 1)) - }; - // The editor's fields ride through untouched. Rebuilding the band without - // them meant every popup save quietly erased the curves — Start wiped the - // mapping it was about to use. - if (raw.curve && typeof raw.curve === 'object') band.curve = raw.curve; - if (Array.isArray(raw.lut) && raw.lut.length >= 2) band.lut = raw.lut; - return band; +// state.host is the address as the socket uses it, port included. In a +// sentence the port every panel has is left off: the address as it was typed. +function shown(socketHost) { + return String(socketHost || '').replace(/:81$/, ''); } -// Four bands, always, because the panel has four knobs. -function normalizeConfig(raw) { - const stored = Array.isArray(raw?.bands) ? raw.bands : []; - const bands = []; - for (let i = 0; i < 4; i++) { - bands.push(normalizeBand(stored[i] || newBand(i), i)); +// Chrome's own words for the two ways it refuses a capture, in plain ones. +// Anything else is passed on as it came. +function plain(message) { + const text = String(message || ''); + if (/active stream/i.test(text)) { + return 'This tab is already being captured. Press Stop first.'; } - - return { - ...DEFAULT_CONFIG, - ...raw, - smoothing: Math.max(0.05, Math.min(0.9, Number(raw?.smoothing) || DEFAULT_CONFIG.smoothing)), - bands - }; + if (/activeTab|cannot be captured/i.test(text)) { + return 'Chrome does not let this page be captured. Go to the tab that is playing, open this popup there and press Start.'; + } + return text; } -function persistConfig() { - clearTimeout(saveTimer); - saveTimer = setTimeout(async () => { - await storageSet({ patternflowAudioConfig: config }); - await sendMessage({ type: 'config', config }); - }, 120); +// Take the field's text as the address. Returns it, or null when the text is +// not one; then nothing is stored and nothing connects. +function commitHost() { + const field = $('host'); + const next = parseHost(field.value); + if (!next) { + hostError = `"${field.value.trim()}" is not an address. Use a name or an IP, like patternflow.local or 192.168.0.42.`; + field.setAttribute('aria-invalid', 'true'); + renderState(); + return null; + } + + hostError = ''; + field.removeAttribute('aria-invalid'); + field.value = next; + if (next !== host) { + host = next; + chrome.storage.local.set({ [HOST_KEY]: host }); + // A capture or a test connection that is up follows the address. + if (state && (state.running || state.manual)) { + sendMessage({ type: 'host', host }).then((response) => { + if (response && response.state) state = response.state; + renderState(); + }); + } + } + renderState(); + return host; } -function setStatus(text, ok) { +// `tone`: 'ok' is green, 'bad' is red, nothing is neutral. +function setStatus(text, tone) { const el = $('status'); el.textContent = text; - el.className = 'status ' + (ok ? 'ok' : 'bad'); + el.className = 'status' + (tone ? ' ' + tone : ''); } +// The status, the sentence under the address and every control, from the +// state. The words are per phase (offscreen.js phase()); the editor's chip +// uses the same ones. function renderState() { - if (!state) { - setStatus('Idle', false); - drawSpectrum([]); - return; + const s = state || {}; + const at = shown(s.host); + + let label = 'Idle'; + let tone = ''; + let detail = s.note || 'Open the tab you want to hear, then press Start.'; + + if (failure) { + label = 'Error'; + tone = 'bad'; + detail = failure; + } else if (s.phase === 'error') { + label = 'Error'; + tone = 'bad'; + detail = plain(s.error); + } else if (s.phase === 'connecting') { + label = 'Connecting'; + detail = `Connecting to ${at}.`; + } else if (s.phase === 'off') { + // The socket is open and the panel ignores every frame: not live. + label = 'Aud off'; + detail = 'Connected, but Audio-React is off on the panel.'; + } else if (s.phase === 'live') { + label = 'Live'; + tone = 'ok'; + if (!s.silent) detail = s.tabTitle ? `Capturing: ${s.tabTitle}` : 'Capturing this tab.'; + else detail = s.heard ? 'No sound from this tab right now.' : 'No sound from this tab yet.'; + } else if (s.phase === 'test') { + label = 'Test'; + tone = 'ok'; + detail = `Test without audio: connected to ${at}. Move a slider.`; } - if (state.error) { - setStatus('Error', false); - $('detail').textContent = state.error; - } else if (state.running && state.connected) { - setStatus('Live', true); - $('detail').textContent = state.tabTitle ? `Capturing: ${state.tabTitle}` : 'Capturing current tab.'; - } else if (state.manual && state.connected) { - setStatus('WS Test', true); - $('detail').textContent = `Manual test connected to ws://${config.host}:81`; - } else if (state.running) { - setStatus('Connecting', false); - $('detail').textContent = `Connecting to ws://${config.host}:81`; - } else if (state.manual) { - setStatus('Connecting', false); - $('detail').textContent = `Connecting manual test to ws://${config.host}:81`; - } else { - setStatus('Idle', false); - $('detail').textContent = 'Open the tab you want to hear, then press Start.'; - } + // Text in the field that is not an address is about the field, which has + // its red border. Nothing was stored and nothing reconnected, so the status + // word stays what is true: a capture to the old address is still Live. + if (hostError) detail = hostError; + + setStatus(label, tone); + $('detail').textContent = detail; + + // One button: Start until a tab is captured, Stop while one is. + $('connect').textContent = s.running ? 'Stop' : 'Start'; + $('panelOn').hidden = s.phase !== 'off' || !!hostError; - drawSpectrum(state.spectrum || []); + // The test connection and a capture are one or the other. Its button + // disconnects what it connected; its sliders move a panel only while the + // test is up and the panel listens. + $('manualConnect').textContent = s.manual ? 'Disconnect' : 'Connect'; + $('manualConnect').disabled = !!s.running; + $('manualConnect').title = s.running ? 'Stop the capture first.' : ''; + document.querySelectorAll('.manual').forEach((input) => { + input.disabled = s.phase !== 'test'; + // A knob that is being held shows where it is held, also in a popup + // opened later. Not under the hand that is moving it. + const held = s.held && s.held[Number(input.dataset.knob)]; + if (typeof held === 'number' && input !== document.activeElement) { + input.value = held; + input.parentElement.querySelector('output').textContent = held.toFixed(2); + } + }); + + drawSpectrum(s.spectrum || []); } function drawSpectrum(values) { @@ -201,39 +225,68 @@ function drawSpectrum(values) { const barCount = Math.max(1, values.length); const barWidth = width / barCount; for (let i = 0; i < barCount; i++) { - const v = clamp01(values[i] || 0); + const v = PFMap.clamp01(values[i]); const h = Math.max(1, v * (height - 12)); ctx.fillStyle = i % 2 ? '#6b655a' : '#141414'; ctx.fillRect(i * barWidth, height - h, Math.max(1, barWidth - 1), h); } } +// The frequency labels under the plot. Its bars are equal steps of log +// frequency from the source's lowest to its highest (offscreen.js +// computeSpectrum), so a label belongs at the same fraction of the width. +function placeScale() { + const { hzMin, hzMax } = PFMap.SOURCE; + const span = Math.log(hzMax / hzMin); + for (const [hz, text] of [[20, '20'], [100, '100'], [1000, '1k'], [10000, '10k'], [20000, '20k']]) { + const mark = document.createElement('span'); + mark.textContent = text; + mark.style.left = `${(Math.log(hz / hzMin) / span * 100).toFixed(2)}%`; + $('scale').append(mark); + } +} + +// The reply to a request: the state to draw, or the reason there is none. +function adopt(response, fallback) { + if (response && response.state) state = response.state; + if (!response || !response.ok) throw new Error((response && response.error) || fallback); + renderState(); +} + async function startCapture() { - config.host = $('host').value.trim() || 'patternflow.local'; - await storageSet({ patternflowAudioConfig: config }); + const address = commitHost(); + if (!address) return; const [tab] = await tabQuery({ active: true, currentWindow: true }); if (!tab || !tab.id) throw new Error('No active tab to capture.'); const streamId = await getStreamId(tab.id); - const response = await sendMessage({ + const stored = await storageGet(PFMap.CONFIG_KEY); + adopt(await sendMessage({ type: 'start', streamId, tabTitle: tab.title || '', - config - }); + host: address, + config: stored[PFMap.CONFIG_KEY] || null + }), 'Failed to start capture.'); +} - if (!response || !response.ok) throw new Error(response?.error || 'Failed to start capture.'); - state = response.state; +async function startManual() { + const address = commitHost(); + if (!address) return; + adopt(await sendMessage({ type: 'manual-connect', host: address }), 'Failed to connect.'); +} + +async function stopAll() { + const response = await sendMessage({ type: 'stop' }); + if (response && response.state) state = response.state; renderState(); } -async function startManual() { - config.host = $('host').value.trim() || 'patternflow.local'; - await storageSet({ patternflowAudioConfig: config }); - const response = await sendMessage({ type: 'manual-connect', config }); - if (!response || !response.ok) throw new Error(response?.error || 'Failed to connect manual test.'); - state = response.state; +// Something asked for here did not happen. The state is still the offscreen +// document's; this is said over it until the next thing is asked. +function showFailure(error) { + failure = plain(error.message || error); renderState(); } @@ -242,11 +295,14 @@ function bindManualControls() { const output = input.parentElement.querySelector('output'); const update = async () => { output.textContent = Number(input.value).toFixed(2); - await sendMessage({ + const response = await sendMessage({ type: 'manual-value', knob: parseInt(input.dataset.knob, 10), value: parseFloat(input.value) }); + // Not drawn (nothing else changed), but kept: it carries where each + // knob is now held. + if (response && response.state) state = response.state; }; input.addEventListener('input', update); input.addEventListener('change', update); @@ -254,55 +310,137 @@ function bindManualControls() { } async function init() { - const stored = await storageGet('patternflowAudioConfig'); - config = normalizeConfig(stored.patternflowAudioConfig); - - $('host').value = config.host; + const stored = await storageGet([HOST_KEY, PFMap.CONFIG_KEY]); + if (typeof stored[HOST_KEY] === 'string') { + host = parseHost(stored[HOST_KEY]) || DEFAULT_HOST; + } else { + // Before the address had a key of its own it rode inside the mapping. + // Move it across once; the copy left behind is never read again. + const legacy = stored[PFMap.CONFIG_KEY] && stored[PFMap.CONFIG_KEY].host; + const moved = typeof legacy === 'string' ? parseHost(legacy) : null; + if (moved) { + host = moved; + chrome.storage.local.set({ [HOST_KEY]: host }); + } + } + $('host').value = host; + + // `change` is Enter and leaving the field. The popup closing is neither (it + // closes with the field still focused), so that is asked for by name. + $('host').addEventListener('change', commitHost); + window.addEventListener('pagehide', commitHost); + // "Not an address" was about the text as it stood. Once it is being typed + // over, the sentence goes; the next commit judges the new text. $('host').addEventListener('input', () => { - config.host = $('host').value.trim() || 'patternflow.local'; - persistConfig(); + if (!hostError) return; + hostError = ''; + $('host').removeAttribute('aria-invalid'); + renderState(); + }); + + // The form makes Enter in the address a submit, the same event as a click + // on the button, and the two are not always the same wish. + let typedEnter = false; + $('host').addEventListener('keydown', (event) => { + typedEnter = event.key === 'Enter'; }); - $('connect').addEventListener('click', async () => { + $('device').addEventListener('submit', async (event) => { + event.preventDefault(); + const entered = typedEnter; + typedEnter = false; + failure = ''; + const s = state || {}; + // A capture is running and the button reads Stop. A click on it stops. + // Enter in the address never does: there it means "this address", and + // the capture moves to it. + if (s.running) { + if (entered) commitHost(); + else await stopAll(); + return; + } + // Enter over a test connection means the same, not "capture". + if (entered && s.manual) { + commitHost(); + return; + } try { - setStatus('Starting', false); + setStatus('Starting'); await startCapture(); } catch (error) { - state = { running: false, connected: false, error: String(error.message || error) }; - renderState(); + showFailure(error); } }); $('manualConnect').addEventListener('click', async () => { + failure = ''; + if (state && state.manual) { + await stopAll(); + return; + } try { - setStatus('Connecting', false); + setStatus('Connecting'); await startManual(); } catch (error) { - state = { running: false, connected: false, manual: false, error: String(error.message || error) }; - renderState(); + showFailure(error); } }); - $('stop').addEventListener('click', async () => { - const response = await sendMessage({ type: 'stop' }); - state = response.state; + // Audio-React is a switch on the panel (the AUD row of its NETWORK screen); + // this throws it from here. + $('panelOn').addEventListener('click', async () => { + failure = ''; + $('panelOn').disabled = true; + const response = await sendMessage({ type: 'panel-on' }); + $('panelOn').disabled = false; + if (response && response.state) state = response.state; + if (!response || !response.ok) failure = (response && response.error) || 'The panel did not answer.'; renderState(); }); - $('release').addEventListener('click', () => sendMessage({ type: 'release' })); - // The mapping editor gets a real tab: boxes dragged on a spectrum need // more room than a popup that closes the moment focus leaves it. - $('openEditor').addEventListener('click', () => { - chrome.tabs.create({ url: chrome.runtime.getURL('editor.html') }); + // + // One tab. Every editor tab holds its own copy of the mapping and saves the + // whole of it, so a second tab's next edit put its older copy over the + // first one's work. If the editor is open anywhere, that tab comes forward. + $('openEditor').addEventListener('click', async () => { + const url = chrome.runtime.getURL('editor.html'); + let open = []; + try { + const tabs = await chrome.runtime.getContexts({ contextTypes: ['TAB'] }); + open = tabs.filter((tab) => String(tab.documentUrl).split(/[?#]/)[0] === url); + } catch (error) {} + if (!open.length) { + chrome.tabs.create({ url }); + return; + } + // Both asked for before either lands: the popup closes as soon as focus + // leaves it, and takes whatever was still waiting with it. + chrome.windows.update(open[0].windowId, { focused: true }); + chrome.tabs.update(open[0].tabId, { active: true }); }); bindManualControls(); + placeScale(); drawSpectrum([]); const response = await sendMessage({ type: 'status' }); state = response?.state || null; + // A test connection that is up is what this popup is about: show it. + if (state && state.manual) $('test').open = true; renderState(); + + // Whether the panel listens was learned when the socket opened, and the + // switch is on the panel (its AUD row, its console), where it may have been + // thrown either way since: "off" that is on again, or Live over a panel + // that now ignores every frame. Opening the popup is the moment someone + // looks, so ask once more: one request per popup, never a poll. + if (state && ['off', 'live', 'test'].includes(state.phase)) { + const again = await sendMessage({ type: 'panel-check' }); + if (again && again.state) state = again.state; + renderState(); + } } chrome.runtime.onMessage.addListener((message) => {