From 9de15e128f5d96e6d13b55818f3f7443aa160bcb Mon Sep 17 00:00:00 2001 From: dragon Date: Mon, 12 Oct 2026 22:57:32 +0800 Subject: [PATCH 01/51] docs: voice commands go to a resident session that speaks as the buddy ADR-0015 (proposed) records the voice design from the discussion in #132: the cloud only hears and speaks, one resident Agent session thinks and answers in the Character's voice and persona. CONTEXT.md gains voice command, resident session and spoken reply, and the Adapter becomes two-way. ADR-0007 notes that K0 long would become push-to-talk. Co-Authored-By: Claude Opus 5.5 --- CONTEXT.md | 25 ++++++++++++++--- docs/adr/0007-device-menu-behind-a-long-k1.md | 4 +++ ...sident-session-that-speaks-as-the-buddy.md | 27 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 docs/adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md diff --git a/CONTEXT.md b/CONTEXT.md index f7280037..882490cd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -11,7 +11,7 @@ The product name, in use since 2026-09-15 and written as one word since 2026-10- _Avoid_: Vibe Buddy (two words, the spelling until 2026-10-09), AgentBeacon, beacond, beacon-hook (old names, only when talking about history), 氛围助手 ("vibe assistant"; that is a description, not a name) **The buddy (氛围小助手)**: -The only anthropomorphic presence on the device: the one who watches the Agents and speaks up for you. It is a role, not a look; at any moment it wears exactly one Character. Before 2026-09-15 it was called 小灯灵 ("little lamp sprite"); in code it is `buddy`. +The only anthropomorphic presence on the device: the one who watches the Agents and speaks up for you. It is a role, not a look; at any moment it wears exactly one Character. It speaks in two ways: lines, written in advance, when an announcement fires; and spoken replies, written on the spot by the resident session in its persona, when the user talks to it. Before 2026-09-15 it was called 小灯灵 ("little lamp sprite"); in code it is `buddy`. _Avoid_: 小灯灵 (old name), pet (fine as a generic word, not when referring to this role), Pet, Codex pet, assistant (on its own it means an Agent, see below) **Character (角色)**: @@ -23,7 +23,7 @@ A local AI assistant observed by VibeBuddy, such as Codex or Claude Code. An Age _Avoid_: client, AI, assistant; also never means a subagent inside Claude Code **Adapter**: -The layer that translates one Agent's events into VibeBuddy domain concepts, consisting of the local privacy-filtering script and the event mapping inside the daemon. There is one Adapter per Agent; aggregation logic does not belong to the Adapter. +The layer that translates between one Agent and VibeBuddy domain concepts, in both directions: the Agent's events into VibeBuddy concepts (the local privacy-filtering script and the event mapping inside the daemon), and voice commands and spoken replies into and out of that Agent's Sessions. There is one Adapter per Agent; aggregation logic does not belong to the Adapter. _Avoid_: integration, plugin, connector **App**: @@ -65,11 +65,11 @@ _Avoid_: environment, entry point, entrypoint, surface, terminal (a terminal is ### Sound **Announcement voice (播报音色)**: -The single voice the buddy speaks with, shared by all its lines. It belongs to the Character and is never picked on its own: changing voice means changing Character. +The single voice the buddy speaks with, shared by all its lines and spoken replies. It belongs to the Character and is never picked on its own: changing voice means changing Character. _Avoid_: sound, speech (speech means the lines themselves), TTS **Persona (人设)**: -A Character's personality and way of talking, written down so that its lines can be written in it. A persona never sees what the Agents are working on. +A Character's personality and way of talking, written down so that its lines can be written in it. Lines written in a persona never see what the Agents are working on; only the resident session, writing spoken replies in it, does. _Avoid_: system prompt (that is one way to use a persona), personality setting, style **Line (台词)**: @@ -92,6 +92,20 @@ _Avoid_: do not disturb, DND, sound off The loudness level of the device speaker, from 20 to 100, stored on the device and kept across reboots; announcements and the Pomodoro chime share it. The App's slider is only a remote control, and it always shows the value the device reports back; the device menu changes it too. The minimum is not zero: the only way to silence the speaker is mute. _Avoid_: loudness, volume settings (that is a place in the App's UI, not this concept) +### Voice commands + +**Voice command (语音指令)**: +One sentence the user speaks to the box, turned into text and handed to the resident session as the user's own input. An Agent is the one who understands and acts on it; VibeBuddy only carries it, and never answers or executes a voice command itself. +_Avoid_: voice assistant (an assistant means an Agent), prompt (that is the Agent's own word), dictation, voice input + +**Resident session (常驻会话)**: +The one long-running Agent Session every voice command goes to, so the user never has to say which work they mean. It answers from what it can see of the Activities and hands work on to other Sessions, but does no work itself: it runs no commands and changes no files. There is only one, and it is never restarted to make room: when its context grows long, it is compacted. It is an Agent that speaks as the buddy, in the persona of the Character the user picked, and changes persona when the Character changes; it is never an Activity: it makes no task cards, announcements or stats. +_Avoid_: assistant, concierge, dispatcher, main session + +**Spoken reply (口头回复)**: +The short sentence the resident session writes for the user to hear in answer to a voice command, synthesized when it is spoken, in the announcement voice of the Character the user picked. Unlike a line, it is written at the moment it is spoken and may talk about what the Agents are doing. +_Avoid_: line (lines are written in advance), announcement, TTS, response + ### Modes **Mode (模式)**: @@ -151,6 +165,9 @@ Fixed wording for the App's English UI and English logs. Use these words when wr | 固件 / 刷入 | firmware / flash | | | 配对 / 已配对的电脑 | pair / paired computers | On the box's menu the row is COMPUTERS; "this computer" marks the one the App runs on | | UART 桥 / 原生 USB | UART bridge / native USB | | +| 语音指令 | voice command | | +| 常驻会话 | resident session | Not shown to users so far; in the UI the buddy is the one you talk to | +| 口头回复 | spoken reply | | ### Activity lifecycle diff --git a/docs/adr/0007-device-menu-behind-a-long-k1.md b/docs/adr/0007-device-menu-behind-a-long-k1.md index 48391ed4..4efab8ef 100644 --- a/docs/adr/0007-device-menu-behind-a-long-k1.md +++ b/docs/adr/0007-device-menu-behind-a-long-k1.md @@ -13,3 +13,7 @@ Muse's two-key layout, with K1's short press opening the menu: K1's one-press sw ## Consequences Leisure can no longer be started on demand; demos use the time-compressed build. The C fallback firmware keeps the old long K1. A volume change from the box prints the same `VOLUME ` line as `device.volume`, which the daemon already applies, so the app's slider follows without a protocol change. + +## Amendment + +ADR-0015 (proposed) gives K0 long to push-to-talk. Giving up a Pomodoro phase then lives only in the menu's STOP FOCUS / END BREAK row. diff --git a/docs/adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md b/docs/adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md new file mode 100644 index 00000000..5b8186d8 --- /dev/null +++ b/docs/adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md @@ -0,0 +1,27 @@ +--- +status: proposed +--- + +# Voice commands go to a resident Agent session that speaks as the buddy; the cloud only hears and speaks + +The box has a microphone, and with Wi-Fi and the Relay (ADR-0012) it can sit away from the computer, where it is the user's only way to reach their Agents. We decided that a voice command is recognized in the cloud, handed to one long-running **resident session** (a Claude Code or Codex session the App starts and keeps alive), and answered with a short **spoken reply** the resident session writes itself, synthesized on the spot in the voice and persona of the Character the user picked. The cloud only turns speech into text and text into speech; the thinking is done by the user's own Agent. The resident session runs no commands and changes no files: it reads the Activities, answers, and starts or messages the Sessions that do the work, which keep their own permission prompts. + +## Considered options + +- **The box as a dictation microphone next to the keyboard.** macOS dictation already does this for free; the box only adds something when it is away from the computer. +- **A new Session per voice command.** Needs no change to how Agents are started, but away from the computer you can neither watch nor steer a session you just spawned. +- **Send each command into the session of the top task card.** The user doesn't know which session a sentence will land in, which is the reason voice replies to "needs input" were dropped too. +- **A cloud language model as the brain**, as Muse gadgets and xiaozhi do. Makes the buddy an assistant (CONTEXT.md reserves that word for Agents), puts model cost on us, and it would still have to drive the Agents on the computer through the computer. +- **Muse's cloud itself.** Its Gadget SDK terms forbid tokens in devices that are sold. +- **Recognition on the computer** (macOS speech recognition, whisper.cpp). Free and private, but Mac-only or a large bundled model, and the audio still has to cross the link. +- **Spoken replies in a separate "Agent voice"**, so the buddy's lines and Agents' answers are told apart. Rejected: one Character speaking in two ways is one presence; a second voice from the box would be a second one. + +## Consequences + +- ADR-0007 still holds for lines: they are written in advance. Spoken replies are a second way the buddy speaks, written at the moment they are spoken, and they may talk about what the Agents are doing. The persona rule narrows to lines. +- ADR-0009's App still calls no model. The recognition and synthesis service lives in `vibebuddy-cloud` beside the Relay but is not the Relay; it calls the TTS vendor of each Character's voice (ElevenLabs, Volcano Engine Doubao) with our keys, and stores neither audio nor text. +- Voice is offered only to signed-in users, so accounts come first; whether it is paid is decided with accounts. Off by default. +- Delivery is per Agent and belongs to its Adapter, which becomes two-way: Claude Code through Channels (research preview; our plugin must be allow-listed or loaded with a development flag), Codex through its app-server (experimental). Both need the resident session started our way. Both are prototyped before one is chosen. +- K0 long becomes push-to-talk; giving up a Pomodoro phase stays in the device menu, which already has it (amends ADR-0007, device menu). +- The resident session is one session for good, compacted rather than restarted, so lasting rules belong in a notes file in its working directory. It makes no task cards, announcements or stats. +- Nothing here is built until the ES7210 microphone chain works on the box. From 1c6139512ef22137460b5d9493330a5b5acb1f6a Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 15:04:03 +0800 Subject: [PATCH 02/51] docs: ADR-0015 delivers voice commands through codex app-server Claude Code waits on Anthropic's terms for subscription login; ACP and Channels are recorded as rejected. Records the 2026-10-13 probe of a resident thread driven over stdio with a client-defined speak tool. Co-Authored-By: Claude Opus 5.5 --- ...o-to-a-resident-session-that-speaks-as-the-buddy.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md b/docs/adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md index 5b8186d8..1371a568 100644 --- a/docs/adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md +++ b/docs/adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md @@ -4,7 +4,7 @@ status: proposed # Voice commands go to a resident Agent session that speaks as the buddy; the cloud only hears and speaks -The box has a microphone, and with Wi-Fi and the Relay (ADR-0012) it can sit away from the computer, where it is the user's only way to reach their Agents. We decided that a voice command is recognized in the cloud, handed to one long-running **resident session** (a Claude Code or Codex session the App starts and keeps alive), and answered with a short **spoken reply** the resident session writes itself, synthesized on the spot in the voice and persona of the Character the user picked. The cloud only turns speech into text and text into speech; the thinking is done by the user's own Agent. The resident session runs no commands and changes no files: it reads the Activities, answers, and starts or messages the Sessions that do the work, which keep their own permission prompts. +The box has a microphone, and with Wi-Fi and the Relay (ADR-0012) it can sit away from the computer, where it is the user's only way to reach their Agents. We decided that a voice command is recognized in the cloud, handed to one long-running **resident session** (a Codex session the daemon keeps alive by driving the user's own `codex app-server` over stdio, with the user's own login), and answered with a short **spoken reply** the resident session writes itself, synthesized on the spot in the voice and persona of the Character the user picked. The cloud only turns speech into text and text into speech; the thinking is done by the user's own Agent. The resident session runs no commands and changes no files: it reads the Activities, answers, and starts or messages the Sessions that do the work, which keep their own permission prompts. ## Considered options @@ -13,6 +13,9 @@ The box has a microphone, and with Wi-Fi and the Relay (ADR-0012) it can sit awa - **Send each command into the session of the top task card.** The user doesn't know which session a sentence will land in, which is the reason voice replies to "needs input" were dropped too. - **A cloud language model as the brain**, as Muse gadgets and xiaozhi do. Makes the buddy an assistant (CONTEXT.md reserves that word for Agents), puts model cost on us, and it would still have to drive the Agents on the computer through the computer. - **Muse's cloud itself.** Its Gadget SDK terms forbid tokens in devices that are sold. +- **Claude Code first, through Channels.** Needs `--channels` at launch and an allow-listed plugin (ours would need `--dangerously-load-development-channels`), and Anthropic does not let third-party products use a claude.ai subscription login, which also rules out the Agent SDK and headless `claude -p` under the user's plan. Claude waits for written permission from Anthropic or a bring-your-own API key. +- **ACP (Agent Client Protocol).** One protocol for every Agent, but both adapters are Node programs sitting in front of the Agent's own server, and the second Agent it would unify is the one on hold. +- **Delivering into a session the user already has open, or into a desktop App.** Neither vendor gives a third party a way in: a terminal session is its own process with no inbound channel (hooks only go out), and the desktop Apps have no local API. - **Recognition on the computer** (macOS speech recognition, whisper.cpp). Free and private, but Mac-only or a large bundled model, and the audio still has to cross the link. - **Spoken replies in a separate "Agent voice"**, so the buddy's lines and Agents' answers are told apart. Rejected: one Character speaking in two ways is one presence; a second voice from the box would be a second one. @@ -21,7 +24,8 @@ The box has a microphone, and with Wi-Fi and the Relay (ADR-0012) it can sit awa - ADR-0007 still holds for lines: they are written in advance. Spoken replies are a second way the buddy speaks, written at the moment they are spoken, and they may talk about what the Agents are doing. The persona rule narrows to lines. - ADR-0009's App still calls no model. The recognition and synthesis service lives in `vibebuddy-cloud` beside the Relay but is not the Relay; it calls the TTS vendor of each Character's voice (ElevenLabs, Volcano Engine Doubao) with our keys, and stores neither audio nor text. - Voice is offered only to signed-in users, so accounts come first; whether it is paid is decided with accounts. Off by default. -- Delivery is per Agent and belongs to its Adapter, which becomes two-way: Claude Code through Channels (research preview; our plugin must be allow-listed or loaded with a development flag), Codex through its app-server (experimental). Both need the resident session started our way. Both are prototyped before one is chosen. +- Delivery is per Agent and belongs to its Adapter, which becomes two-way. For Codex the daemon is an app-server client: `thread/start` once, `thread/resume` after a restart, `turn/start` per voice command; the spoken reply arrives as a call to a client-defined `speak` tool (`dynamicTools`), so no MCP server is needed. The thread runs read-only with approvals declined. The user can open it in the Codex App with `codex://threads/`. Verified on 2026-10-13 with codex-cli 0.162.0: existing ChatGPT login reused, about 10 s per turn, memory kept across a process restart, file writes refused. +- app-server and `dynamicTools` are marked experimental, so the daemon pins the Codex versions it supports. OpenAI permits ChatGPT login for local and open-source apps; before commercial launch we apply for Sign in with ChatGPT. - K0 long becomes push-to-talk; giving up a Pomodoro phase stays in the device menu, which already has it (amends ADR-0007, device menu). - The resident session is one session for good, compacted rather than restarted, so lasting rules belong in a notes file in its working directory. It makes no task cards, announcements or stats. -- Nothing here is built until the ES7210 microphone chain works on the box. +- The resident session can be built and used from the command line first; the box side waits until the ES7210 microphone chain works. From d46347af221d1bbcc7abb7f739f45d15b25ba564 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 15:15:09 +0800 Subject: [PATCH 03/51] docs: ADR-0015 no longer assumes an ES7210 Co-Authored-By: Claude Opus 5.5 --- ...ommands-go-to-a-resident-session-that-speaks-as-the-buddy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md b/docs/adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md index 1371a568..eccaf056 100644 --- a/docs/adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md +++ b/docs/adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md @@ -28,4 +28,4 @@ The box has a microphone, and with Wi-Fi and the Relay (ADR-0012) it can sit awa - app-server and `dynamicTools` are marked experimental, so the daemon pins the Codex versions it supports. OpenAI permits ChatGPT login for local and open-source apps; before commercial launch we apply for Sign in with ChatGPT. - K0 long becomes push-to-talk; giving up a Pomodoro phase stays in the device menu, which already has it (amends ADR-0007, device menu). - The resident session is one session for good, compacted rather than restarted, so lasting rules belong in a notes file in its working directory. It makes no task cards, announcements or stats. -- The resident session can be built and used from the command line first; the box side waits until the ES7210 microphone chain works. +- The resident session can be built and used from the command line first; the box side waits until the microphone works on the box (I2C scans found no ES7210, so it is probably on the ES8311 ADC). From d56a3dbbc6e67d33805985f19c7caac49474b937 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 15:16:59 +0800 Subject: [PATCH 04/51] Add a microphone bring-up diagnostic: device.mic.record No firmware of ours has used the box's microphone. The I2C0 scans found no ES7210 at 0x40-0x43, so the mic is most likely on the ES8311's own ADC, which our ES8311 sequence (esp_codec_dev's WORK_MODE_BOTH) already powers, with samples on GPIO47 as in xiaozhi 1.9.4's atk-dnesp32s3-box config. - core: device.mic.record (cable only) reports chip IDs at 0x18 and 0x40-0x43, sets the ES8311 mic gain (register 0x16, as es8311_set_mic_gain), records, then sends per-100 ms RMS/peak levels for both slots and the samples as base64 lines, one per poll, with a CRC. - device: I2S0's receiver shares the transmitter's BCLK/WS (signal loopback) and reads GPIO47 into PSRAM only while a capture runs; 24 kHz 16-bit stereo, since the ES8311 has one LRCK for ADC and DAC. - tools/mic-record.py writes a WAV and says whether a slot is live. - docs/firmware-bringup.md: facts table with sources, how to run and read it. Co-Authored-By: Claude Opus 5.5 --- docs/firmware-bringup.md | 64 ++++++++++++ docs/hardware.md | 2 +- docs/protocol.md | 2 + firmware-rs/core/src/firmware.rs | 131 +++++++++++++++++++++++- firmware-rs/core/src/lib.rs | 1 + firmware-rs/core/src/mic.rs | 158 +++++++++++++++++++++++++++++ firmware-rs/core/tests/firmware.rs | 115 +++++++++++++++++++++ firmware-rs/device/src/main.rs | 36 ++++++- firmware-rs/device/src/mic.rs | 71 +++++++++++++ tools/mic-record.py | 130 ++++++++++++++++++++++++ 10 files changed, 703 insertions(+), 7 deletions(-) create mode 100644 firmware-rs/core/src/mic.rs create mode 100644 firmware-rs/device/src/mic.rs create mode 100755 tools/mic-record.py diff --git a/docs/firmware-bringup.md b/docs/firmware-bringup.md index d50d482b..b143f922 100644 --- a/docs/firmware-bringup.md +++ b/docs/firmware-bringup.md @@ -91,6 +91,70 @@ The review fixes applied before bring-up (see the PR) were in place, so it can't 7. **Change the voice once in the app.** When the write finishes, the box speaks in the new voice. Power-cycle it and check that the voice stays. 8. **Leave it on the UART bridge alone overnight**, with nothing on the USB port, and check the next morning that the screen still animates. This verifies that an output channel without a reader never blocks. +## Microphone + +The box has a microphone, and no firmware of ours has used it yet. `device.mic.record` is a bring-up diagnostic, like the screenshot: it records a few seconds and sends them back over the cable. It isn't a user feature, and neither app uses it. + +### What we know, and from where + +No ALIENTEK schematic or BSP for this board (ATK-DNESP32S3-BOX V1.1) could be found; the BOX3's wiki (a different board, with different I2S pins) doesn't count. So each fact below is either already seen on a box, or a candidate the diagnostic settles. + +| Fact | Value | Source | Confidence | +|---|---|---|---| +| Microphone ADC | The ES8311's own mono ADC; no ES7210 | No chip at 0x40–0x43 in the I2C0 scans of two boxes ([`hardware.md`](hardware.md)); xiaozhi 1.9.4's `atk-dnesp32s3-box` uses `Es8311AudioCodec` for both input and output | High that there's no ES7210 on the scanned boxes; the ES8311 ADC is a candidate until a recording shows it | +| ES8311 address | 0x18 (7-bit) | Seen on boxes | Verified | +| ES7210 addresses checked | 0x40–0x43 (AD1/AD0 straps; esp_codec_dev's `es7210_address_t`) | esp_codec_dev | The diagnostic reports each | +| Microphone data into the ESP32 | GPIO47 (I2S DIN) | xiaozhi 1.9.4 `config.h`, `AUDIO_I2S_GPIO_DIN` | Candidate, from the same board's config; only ever configured as an input | +| Clocks | Shared with the speaker: BCLK GPIO21, WS GPIO13, no MCLK. The ESP32's receiver runs off its transmitter's clocks (signal loopback), as ESP-IDF's full duplex does | Speaker pins verified on boxes; xiaozhi creates TX and RX on one I2S port with the same pins and `GPIO_NUM_NC` for MCLK | High | +| Format and rate | Standard I2S, 16-bit, two slots, 24 kHz. Not 16 kHz: the ES8311 has one LRCK for ADC and DAC, so input runs at the speaker's rate | xiaozhi asserts input rate == output rate (24000) for this codec | High | +| Which slot is the microphone | Left = ADC, right = DAC reference (silent unless the box is speaking) | esp_codec_dev writes ES8311 register 0x44 = 0x58, "ADCL + DACR"; our sequence already does | Medium; the per-slot levels show it | +| ADC already enabled at boot | Yes: our ES8311 sequence is esp_codec_dev's `ESP_CODEC_DEV_WORK_MODE_BOTH`, the mode the C firmware used, so the ADC is powered and its serial output unmuted (registers 0x0A, 0x0E, 0x14, 0x15, 0x17) | `firmware/main/agent_audio.c`; `core/src/audio.rs` test | Verified in code | +| Microphone gain | Register 0x16, 0–42 dB in 6 dB steps; 30 dB by default (xiaozhi's `AUDIO_CODEC_DEFAULT_MIC_GAIN`) | esp_codec_dev `es8311_set_mic_gain` | High | +| Boxes on the NS4168 variant | No codec; xiaozhi reads a digital microphone straight from the same DIN with the same clocks | xiaozhi `ATK_NoAudioCodecDuplex` | Untested; the diagnostic still records | + +### Running it + +1. Quit the VibeBuddy app, then flash with `just flash /dev/cu.usbmodemXXXX` (step 2 above). Check the boot report still says `AUDIO READY` and that announcements still play: the speaker's I2S setup changed (the receiver now shares its clocks). +2. Record, making some noise near the box (talk, clap, tap the case) while it records: + + ```bash + uv run --with pyserial python tools/mic-record.py --port /dev/cu.usbmodemXXXX --ms 3000 --out mic.wav + ``` + + `--gain-db` sets the microphone gain. The samples come back at about 11 s per recorded second, because the firmware writes the UART at 115200 baud whichever port you use. +3. Listen to `mic.wav`. The left channel should be your voice. + +The box replies, in order: + +``` +MIC PROBE 0x18 ID 8311 ES8311 chip ID (0xFD, 0xFE); NONE if nothing answers +MIC PROBE 0x40 NONE 0x40–0x43: an ES7210 would answer here, with the bytes of 0x3D, 0x3E +... +MIC GAIN 30 DB only with an ES8311 +MIC RECORDING 3000 MS +MIC CAPTURED 3010 MS 288000 BYTES +MIC LEVEL 0 L R one per 100 ms, raw units, full scale 32768 +MIC BEGIN 24000 2 16 288000 +MIC DATA 576 bytes of samples per line +MIC END 288000 +``` + +`MIC UNAVAILABLE` means the board has no input; `MIC BUSY`, that a recording is still going out; `MIC ERROR STALLED`, that no samples came for half a second (the receive DMA overflowed, or nothing clocks the receiver); `MIC ERROR MEMORY`, that the capture didn't fit. + +### Reading the result + +| What you see | What it means | +|---|---| +| Left `LIVE`, rising when you talk; right silent | The microphone works on this box. Record the box's serial number in [`hardware.md`](hardware.md) | +| Both slots `SILENT` (all zeros) | Nothing drives GPIO47: wrong pin, or the ES8311's ADC output isn't on | +| Both slots stuck at one value, or `SATURATED` with no sound | GPIO47 is floating or isn't the codec's data line | +| Left `FLAT` with a level above zero | The ADC runs but the microphone isn't reaching it: try `--gain-db 42` and noise right at the mic hole; then suspect the analog path (MIC bias, which input the ES8311 selects in register 0x14) | +| `LIVE` on the right, not the left | The slots are the other way round from esp_codec_dev's 0x44 setting | +| `MIC CAPTURED` far longer than asked | Samples were lost on the way in; the DMA buffer (250 ms) is too short for something the main loop did | +| Any `MIC PROBE 0x4x ID …` | This box has a chip where an ES7210 would be: it differs from the scanned boxes, so stop and look before trusting the ES8311 path | + +Boxes of this board differ (some have a touchscreen, some don't), so run it on each box you care about and say which box in the result. + ## Going back to the C firmware ```bash diff --git a/docs/hardware.md b/docs/hardware.md index 43c7c90f..9f7d852a 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -81,5 +81,5 @@ The hardware runs `xiaozhi` 1.9.4. The source at that pinned version contains an - Vendor schematic, BSP, and examples matching the exact PCB revision. - Touch controller wiring (RST, INT) on boxes that have one; whether a box has one is decided by probing `0x2E` (see above). - The chips at `0x1E`, `0x50` and `0x6A`. -- Microphone input chain. +- Microphone input chain. Most likely the ES8311's own ADC with samples on GPIO47 (xiaozhi 1.9.4), since no ES7210 answers at 0x40–0x43 on the scanned boxes; `device.mic.record` checks it on a box (see [`firmware-bringup.md`](firmware-bringup.md#microphone)). - Buzzer GPIO and active level. diff --git a/docs/protocol.md b/docs/protocol.md index 382faae5..33c1418f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -104,6 +104,8 @@ These messages are the app operating on the device itself. They don't count as a `device.screenshot` asks for the current screen. The device replies with `SHOT BEGIN 320x240 BACKLIGHT ON|OFF`, then `SHOT` lines of space-separated `rgb565:length` runs (the colour as four hex digits, row by row from the top left), then `SHOT END`; the daemon turns them into a PNG (`POST /v1/device/screenshot`), as does `tools/device-screenshot.py` over the cable. Over the cable the whole frame goes out at once, 16 runs a line. Over the local network or the Relay the device copies the frame when asked and sends one line every 30 ms, up to 180 runs (under 2 KB) a line, so it stays inside the Relay's 50 messages a second ([`relay-protocol.md`](relay-protocol.md)) and waits for room in the box's network buffer rather than drop part of a line; the picture is the screen at the moment of asking, however long the lines take. The idle screen takes one to two seconds; a screen with no two neighbouring pixels alike, about 13 s or more. Older firmware sends screenshots over the cable only, so over the network the request times out. +`device.mic.record` is a bring-up diagnostic for the microphone, over the cable only (over the network it gets `ERROR cable_only`): `ms` is the length, 100 to 5000 (default 3000), and `gain_db` the ES8311 microphone gain, 0 to 42 (default 30). The box reports what answers on I2C (`MIC PROBE`), records, then sends `MIC LEVEL` lines (RMS and peak per 100 ms), `MIC BEGIN`, one `MIC DATA ` line of 576 sample bytes per main loop pass, and `MIC END `. The daemon never sends it; `tools/mic-record.py` does. Lines and their meaning are in [`firmware-bringup.md`](firmware-bringup.md#microphone). + Character packs (format in `firmware-rs/core/src/character_pack.rs`), and the older voice packs (`firmware-rs/core/src/voice_pack.rs`), are written into the `voices` partition over the same serial link, without resetting and without esptool; both connection types behave the same. Stop-and-wait flow control: after each chunk the Mac waits for the device's acknowledgment, and sends nothing further without one. ```json diff --git a/firmware-rs/core/src/firmware.rs b/firmware-rs/core/src/firmware.rs index 390b40b0..49127a8e 100644 --- a/firmware-rs/core/src/firmware.rs +++ b/firmware-rs/core/src/firmware.rs @@ -11,7 +11,8 @@ use alloc::vec::Vec; use serde_json::{Map, Value}; use vibebuddy_protocol::{Event, ProtocolError, VERSION}; -use crate::audio::{Occasion, Sound, clamp_volume, VOLUME_DEFAULT}; +use crate::audio::{ES8311_ADDRESS, Occasion, SAMPLE_RATE, Sound, clamp_volume, VOLUME_DEFAULT}; +use crate::mic; use crate::buttons::{ButtonEvent, Buttons, Levels}; use crate::display::{Display, LinkPath, MAX_STATS, MAX_TASKS, Mode, Panel, PanelKind, SNAPSHOT_LINE_BYTES, Scene, Screen, Snapshot, State, TaskInput, Tone}; use crate::leisure::{Leisure, Tier}; @@ -101,6 +102,49 @@ pub trait Board: Screen { } /// Starts the box again from the top, as after power-on. fn restart(&mut self) {} + + /// One register read on the codec's I2C bus; None when nothing acknowledges. For the microphone + /// diagnostic ([`crate::mic`]). + fn i2c_read(&mut self, _address: u8, _register: u8) -> Option { + None + } + fn i2c_write(&mut self, _address: u8, _register: u8, _value: u8) -> Result<(), ()> { + Err(()) + } + /// Starts capturing `bytes` of I2S input (24 kHz, 16-bit, two slots, little endian). Err where the + /// board has no input. + fn mic_start(&mut self, _bytes: usize) -> Result<(), ()> { + Err(()) + } + /// The capture once all of it is in, or why it stopped short; None while it is still running. + fn mic_take(&mut self) -> Option, &'static str>> { + None + } +} + +/// The codec's registers through the board's I2C, for register sequences in core. +struct BoardI2c<'a, B: Board> { + board: &'a mut B, + address: u8, +} + +impl crate::audio::Registers for BoardI2c<'_, B> { + type Error = (); + fn read(&mut self, register: u8) -> Result { + self.board.i2c_read(self.address, register).ok_or(()) + } + fn write(&mut self, register: u8, value: u8) -> Result<(), ()> { + self.board.i2c_write(self.address, register, value) + } +} + +/// Where the microphone diagnostic is. +enum MicState { + Idle, + /// Capturing since this time. + Recording { since: u32 }, + /// Streaming the capture out as `MIC DATA` lines, one per poll. + Sending { capture: Vec, sent: usize }, } /// What the box is linked to without the Relay. @@ -170,6 +214,7 @@ pub struct Firmware { reported_lights_out: bool, reported_hour: i32, reported_look: alloc::string::String, + mic: MicState, } /// Borrows the Firmware's pomodoro and leisure director to build the context for one frame. @@ -280,6 +325,7 @@ impl Firmware { reported_lights_out: false, reported_hour: -1, reported_look: alloc::string::String::from("ROBOT"), + mic: MicState::Idle, } } @@ -565,6 +611,7 @@ impl Firmware { } self.continue_voice_begin(board); self.send_snapshot(board, now); + self.tend_mic(board, now); if self.menu.expire(now) { Self::write_literal(board, "MENU CLOSED\n"); self.show_menu(board); @@ -596,6 +643,84 @@ impl Firmware { } } + /// `device.mic.record`: report what answers on I2C where a microphone ADC could be, set the ES8311's + /// microphone gain if there is one, and start capturing; `tend_mic` reports and sends the result. + fn start_mic(&mut self, board: &mut B, fields: &BTreeMap) { + if !matches!(self.mic, MicState::Idle) { + return Self::write_literal(board, "MIC BUSY\n"); + } + let ms = number(fields, "ms").map_or(mic::DEFAULT_MS, |ms| (ms.max(0.0) as u32).clamp(mic::MIN_MS, mic::MAX_MS)); + let gain = number(fields, "gain_db").map_or(mic::DEFAULT_GAIN_DB, |db| (db.max(0.0) as u32).min(mic::MAX_GAIN_DB)); + let mut es8311 = false; + for (address, [high, low]) in mic::PROBES { + let id = board.i2c_read(address, high).zip(board.i2c_read(address, low)); + es8311 |= address == ES8311_ADDRESS && id.is_some(); + let line = match id { + Some((high, low)) => text!(32, "MIC PROBE 0x{:02X} ID {:02X}{:02X}\n", address, high, low), + None => text!(32, "MIC PROBE 0x{:02X} NONE\n", address), + }; + board.write(line.as_bytes()); + } + if es8311 { + let line = match mic::set_es8311_mic_gain(&mut BoardI2c { board: &mut *board, address: ES8311_ADDRESS }, gain) { + Ok(()) => text!(24, "MIC GAIN {} DB\n", mic::mic_gain_register(gain) as u32 * 6), + Err(()) => text!(24, "MIC GAIN ERROR\n"), + }; + board.write(line.as_bytes()); + } + match board.mic_start(mic::capture_bytes(ms)) { + Ok(()) => { + self.mic = MicState::Recording { since: board.now_ms() }; + board.write(text!(32, "MIC RECORDING {} MS\n", ms).as_bytes()); + } + Err(()) => Self::write_literal(board, "MIC UNAVAILABLE\n"), + } + } + + fn tend_mic(&mut self, board: &mut B, now: u32) { + match &mut self.mic { + MicState::Idle => {} + MicState::Recording { since } => { + let since = *since; + match board.mic_take() { + None => {} + Some(Err(reason)) => { + Self::write_value_line(board, "MIC ERROR ", reason.as_bytes()); + self.mic = MicState::Idle; + } + Some(Ok(capture)) => { + // Much longer than asked for means samples were lost on the way in. + let line = text!(48, "MIC CAPTURED {} MS {} BYTES\n", now.wrapping_sub(since), capture.len()); + board.write(line.as_bytes()); + for (index, [left, right]) in mic::levels(&capture).iter().enumerate() { + let at = index as u32 * mic::LEVEL_WINDOW_MS; + let line = text!(64, "MIC LEVEL {} L {} {} R {} {}\n", at, left.rms, left.peak, right.rms, right.peak); + board.write(line.as_bytes()); + } + let line = text!(48, "MIC BEGIN {} {} 16 {}\n", SAMPLE_RATE, mic::CHANNELS, capture.len()); + board.write(line.as_bytes()); + self.mic = MicState::Sending { capture, sent: 0 }; + } + } + } + // One line per pass, so the main loop keeps running while a minute of lines goes out over the UART. + MicState::Sending { capture, sent } if *sent < capture.len() => { + let end = (*sent + mic::DATA_CHUNK_BYTES).min(capture.len()); + let mut line = alloc::format!("MIC DATA {} ", *sent / mic::DATA_CHUNK_BYTES); + line.push_str(&mic::encode_base64(&capture[*sent..end])); + // One write, so the newline can't be dropped apart from its line. + line.push('\n'); + board.write(line.as_bytes()); + *sent = end; + } + MicState::Sending { capture, .. } => { + let line = text!(48, "MIC END {} {}\n", capture.len(), crc32(0, capture)); + board.write(line.as_bytes()); + self.mic = MicState::Idle; + } + } + } + fn set_link_lost(&mut self, board: &mut B, lost: bool) { self.link_lost = lost; let now = board.now_ms(); @@ -1289,6 +1414,10 @@ impl Firmware { "device.relay.set" => self.handle_relay(board, fields), // The radio belongs to the device layer, which reports what it hears as `WIFI SEEN` lines. "device.wifi.scan" => self.scan_requested = true, + // Microphone bring-up (docs/firmware-bringup.md): a diagnostic like the screenshot, so not agent + // activity. Over the cable only: a recording is hundreds of lines. + "device.mic.record" if self.remote => Self::write_literal(board, "ERROR cable_only\n"), + "device.mic.record" => self.start_mic(board, fields), // Link self-test: send the received string's length and CRC back to the Mac to check for corrupted serial bytes. "device.echo" => { if let Some(data) = string(fields, "data") { diff --git a/firmware-rs/core/src/lib.rs b/firmware-rs/core/src/lib.rs index f1807b45..7f141b67 100644 --- a/firmware-rs/core/src/lib.rs +++ b/firmware-rs/core/src/lib.rs @@ -26,6 +26,7 @@ pub mod lines; pub mod link; pub mod look; pub mod menu; +pub mod mic; pub mod ota; pub mod pomodoro; pub mod relay_box; diff --git a/firmware-rs/core/src/mic.rs b/firmware-rs/core/src/mic.rs new file mode 100644 index 00000000..cfd41612 --- /dev/null +++ b/firmware-rs/core/src/mic.rs @@ -0,0 +1,158 @@ +//! Microphone bring-up diagnostic (`device.mic.record`, docs/firmware-bringup.md): record a few seconds of +//! I2S input, then report levels and stream the raw samples back as lines. Not a user feature. +//! +//! What the board has is settled on the device, not here: the command first reports which chips answer on +//! I2C (the ES8311 at 0x18, and anything at 0x40-0x43, where an ES7210 would sit), then records whatever +//! arrives on the I2S data-in pin. The input shares the speaker's clocks (one LRCK on the ES8311), so it +//! runs at the speaker's rate: 24 kHz, 16-bit, two slots. With esp_codec_dev's ES8311 setup (register +//! 0x44 = 0x58, "ADCL + DACR") the left slot should carry the ADC and the right slot the DAC reference; +//! the per-slot levels show whether that holds. + +use alloc::string::String; +use alloc::vec::Vec; + +use crate::audio::{ES8311_ADDRESS, Registers, SAMPLE_RATE}; + +/// Two 16-bit slots per frame. +pub const CHANNELS: u32 = 2; +pub const FRAME_BYTES: usize = 4; +pub const DEFAULT_MS: u32 = 3000; +/// At most 5 s: 480 KB in PSRAM, and about a minute to send over the UART. +pub const MAX_MS: u32 = 5000; +pub const MIN_MS: u32 = 100; +/// xiaozhi's AUDIO_CODEC_DEFAULT_MIC_GAIN for this board. +pub const DEFAULT_GAIN_DB: u32 = 30; +pub const MAX_GAIN_DB: u32 = 42; +/// Raw bytes per `MIC DATA` line: 768 base64 characters, a line under 800 bytes like the protocol's 1 KB lines. +pub const DATA_CHUNK_BYTES: usize = 576; +/// One `MIC LEVEL` line per this much audio. +pub const LEVEL_WINDOW_MS: u32 = 100; + +/// Addresses whose chip ID the command reports, with the two ID registers read at each: the ES8311's +/// 0xFD/0xFE, and the ES7210's four strap addresses (AD1/AD0) with 0x3D/0x3E, the ID registers its +/// datasheet is reported to use (not confirmed from a primary source; the raw bytes are printed as read). +pub const PROBES: [(u8, [u8; 2]); 5] = [ + (ES8311_ADDRESS, [0xFD, 0xFE]), + (0x40, [0x3D, 0x3E]), + (0x41, [0x3D, 0x3E]), + (0x42, [0x3D, 0x3E]), + (0x43, [0x3D, 0x3E]), +]; + +/// Bytes captured in `ms` of input. +pub fn capture_bytes(ms: u32) -> usize { + (SAMPLE_RATE / 1000 * ms) as usize * FRAME_BYTES +} + +/// esp_codec_dev's `es8311_set_mic_gain`: the PGA gain in 6 dB steps, 0 to 42 dB, rounded down, written +/// as the whole of register 0x16. +pub fn mic_gain_register(db: u32) -> u8 { + (db.min(MAX_GAIN_DB) / 6) as u8 +} + +pub fn set_es8311_mic_gain(codec: &mut R, db: u32) -> Result<(), R::Error> { + codec.write(0x16, mic_gain_register(db)) +} + +/// RMS and peak of one slot over one window, in raw sample units (full scale is 32768). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Level { + pub rms: u32, + pub peak: u32, +} + +/// Levels of both slots for each `LEVEL_WINDOW_MS` of a capture of interleaved little-endian frames. +pub fn levels(capture: &[u8]) -> Vec<[Level; 2]> { + let window = (SAMPLE_RATE / 1000 * LEVEL_WINDOW_MS) as usize * FRAME_BYTES; + capture + .chunks(window) + .map(|piece| { + let mut squares = [0u64; 2]; + let mut peaks = [0u32; 2]; + let frames = (piece.len() / FRAME_BYTES).max(1) as u64; + for frame in piece.as_chunks::().0 { + for slot in 0..2 { + let sample = i16::from_le_bytes([frame[slot * 2], frame[slot * 2 + 1]]) as i32; + squares[slot] += (sample * sample) as u64; + peaks[slot] = peaks[slot].max(sample.unsigned_abs()); + } + } + core::array::from_fn(|slot| Level { rms: libm::sqrt(squares[slot] as f64 / frames as f64) as u32, peak: peaks[slot] }) + }) + .collect() +} + +/// Standard base64 with `=` padding, the counterpart of [`crate::voice_pack::decode_base64`]. +pub fn encode_base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let bits = (chunk[0] as u32) << 16 | (*chunk.get(1).unwrap_or(&0) as u32) << 8 | *chunk.get(2).unwrap_or(&0) as u32; + for index in 0..4 { + out.push(if index <= chunk.len() { ALPHABET[(bits >> (18 - 6 * index) & 63) as usize] as char } else { '=' }); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::voice_pack::decode_base64; + use std::vec; + + #[test] + fn gain_matches_esp_codec_dev() { + // es8311_set_mic_gain: <6 dB → 0 dB (0), ..., 30 dB → 5, ≥42 dB → 7. + assert_eq!(mic_gain_register(0), 0); + assert_eq!(mic_gain_register(5), 0); + assert_eq!(mic_gain_register(6), 1); + assert_eq!(mic_gain_register(30), 5); + assert_eq!(mic_gain_register(41), 6); + assert_eq!(mic_gain_register(42), 7); + assert_eq!(mic_gain_register(60), 7); + } + + #[test] + fn a_capture_is_24_khz_stereo_16_bit() { + assert_eq!(capture_bytes(1000), 96_000); + assert_eq!(capture_bytes(MAX_MS), 480_000); + } + + #[test] + fn levels_are_per_slot_and_per_window() { + // 100 ms of a full-scale square wave on the left, silence on the right, then 50 ms of a quieter left. + let mut capture = Vec::new(); + for index in 0..2400 { + let left: i16 = if index % 2 == 0 { 32767 } else { -32767 }; + capture.extend_from_slice(&left.to_le_bytes()); + capture.extend_from_slice(&0i16.to_le_bytes()); + } + for _ in 0..1200 { + capture.extend_from_slice(&(-100i16).to_le_bytes()); + capture.extend_from_slice(&7i16.to_le_bytes()); + } + assert_eq!( + levels(&capture), + vec![ + [Level { rms: 32767, peak: 32767 }, Level { rms: 0, peak: 0 }], + [Level { rms: 100, peak: 100 }, Level { rms: 7, peak: 7 }], + ] + ); + // i16::MIN has no positive counterpart; its peak is still reported, not wrapped. + assert_eq!(levels(&i16::MIN.to_le_bytes().repeat(2))[0][0].peak, 32768); + } + + #[test] + fn base64_round_trips() { + for length in 0..10 { + let bytes: Vec = (0..length).map(|index| (index * 37 + 250) as u8).collect(); + let encoded = encode_base64(&bytes); + let mut out = [0u8; 16]; + assert_eq!(decode_base64(encoded.as_bytes(), &mut out), Some(length)); + assert_eq!(&out[..length], &bytes[..]); + } + assert_eq!(encode_base64(b"foob"), "Zm9vYg=="); + assert_eq!(encode_base64(&[0u8; DATA_CHUNK_BYTES]).len(), 768); + } +} diff --git a/firmware-rs/core/tests/firmware.rs b/firmware-rs/core/tests/firmware.rs index 2f2bb089..4415771c 100644 --- a/firmware-rs/core/tests/firmware.rs +++ b/firmware-rs/core/tests/firmware.rs @@ -85,6 +85,12 @@ struct FakeBoard { peer_full: bool, /// Restarts asked for. restarts: usize, + /// Registers of the chips on I2C, by (address, register), and what was written. + i2c: std::collections::BTreeMap<(u8, u8), u8>, + i2c_writes: Vec<(u8, u8, u8)>, + /// The board has I2S input; and the size of the capture asked for, until it is taken. + mic_input: bool, + mic_capture: Option, } impl FakeBoard { @@ -108,6 +114,10 @@ impl FakeBoard { lan_peer: None, peer_full: false, restarts: 0, + i2c: [((0x18, 0xFD), 0x83), ((0x18, 0xFE), 0x11), ((0x18, 0x16), 0x24)].into(), + i2c_writes: Vec::new(), + mic_input: true, + mic_capture: None, } } @@ -195,6 +205,25 @@ impl Board for FakeBoard { self.codec_volume = Some(level); Ok(()) } + fn i2c_read(&mut self, address: u8, register: u8) -> Option { + self.i2c.get(&(address, register)).copied() + } + fn i2c_write(&mut self, address: u8, register: u8, value: u8) -> Result<(), ()> { + self.i2c.keys().any(|&(chip, _)| chip == address).then_some(()).ok_or(())?; + self.i2c.insert((address, register), value); + self.i2c_writes.push((address, register, value)); + Ok(()) + } + fn mic_start(&mut self, bytes: usize) -> Result<(), ()> { + self.mic_input.then_some(()).ok_or(())?; + self.mic_capture = Some(bytes); + Ok(()) + } + /// The left slot counts up, the right slot is silent. + fn mic_take(&mut self) -> Option, &'static str>> { + let bytes = self.mic_capture.take()?; + Some(Ok((0..bytes / 4).flat_map(|frame| [(frame % 256) as u8, 0, 0, 0]).collect())) + } } /// The key the box makes from the entropy `Firmware::new(7, …)` derives. @@ -1137,3 +1166,89 @@ mod relay { assert_eq!(board.take_lines(), ["ERROR cable_only"]); } } + +/// Polls until `MIC END` and returns every line. +fn poll_mic(firmware: &mut Firmware, board: &mut FakeBoard) -> Vec { + let mut lines = Vec::new(); + for _ in 0..2_000 { + board.advance(10); + firmware.poll(board); + lines.extend(board.take_lines()); + if lines.last().is_some_and(|line| line.starts_with("MIC END")) { + return lines; + } + } + panic!("no MIC END; got {} lines", lines.len()); +} + +#[test] +fn a_mic_recording_reports_probes_levels_and_the_samples() { + let (mut firmware, mut board, _) = booted(blank_flash()); + let lines = send(&mut firmware, &mut board, r#"{"version":1,"event":"device.mic.record","ms":200}"#); + assert_eq!( + lines, + [ + "MIC PROBE 0x18 ID 8311", + "MIC PROBE 0x40 NONE", + "MIC PROBE 0x41 NONE", + "MIC PROBE 0x42 NONE", + "MIC PROBE 0x43 NONE", + "MIC GAIN 30 DB", + "MIC RECORDING 200 MS", + ] + ); + // es8311_set_mic_gain(30 dB) writes 5 to register 0x16. + assert_eq!(board.i2c_writes, [(0x18, 0x16, 5)]); + assert_eq!(send(&mut firmware, &mut board, r#"{"version":1,"event":"device.mic.record"}"#), ["MIC BUSY"]); + + let lines = poll_mic(&mut firmware, &mut board); + assert_eq!(lines[0], "MIC CAPTURED 10 MS 19200 BYTES"); + assert_eq!(&lines[1..3], ["MIC LEVEL 0 L 144 255 R 0 0", "MIC LEVEL 100 L 147 255 R 0 0"]); + assert_eq!(lines[3], "MIC BEGIN 24000 2 16 19200"); + let data: Vec<&String> = lines.iter().filter(|line| line.starts_with("MIC DATA ")).collect(); + assert_eq!(data.len(), 19200usize.div_ceil(576)); + let mut samples = Vec::new(); + for (seq, line) in data.iter().enumerate() { + assert!(line.len() < 800, "a data line stays well under the 1 KB line limit"); + let mut parts = line.splitn(4, ' ').skip(2); + assert_eq!(parts.next().unwrap(), seq.to_string()); + let mut out = vec![0u8; 576]; + let length = voice_pack::decode_base64(parts.next().unwrap().as_bytes(), &mut out).unwrap(); + samples.extend_from_slice(&out[..length]); + } + let expected: Vec = (0..4800).flat_map(|frame| [(frame % 256) as u8, 0, 0, 0]).collect(); + assert_eq!(samples, expected); + assert_eq!(lines.last().unwrap(), &format!("MIC END 19200 {}", voice_pack::crc32(0, &expected))); + + // Done: another recording may start. + let lines = send(&mut firmware, &mut board, r#"{"version":1,"event":"device.mic.record","ms":100}"#); + assert_eq!(lines.last().map(String::as_str), Some("MIC RECORDING 100 MS")); +} + +#[test] +fn a_mic_recording_is_clamped_and_reports_what_is_missing() { + let (mut firmware, mut board, _) = booted(blank_flash()); + board.i2c.clear(); + board.i2c.insert((0x41, 0x3D), 0x72); + board.i2c.insert((0x41, 0x3E), 0x10); + board.mic_input = false; + let lines = send(&mut firmware, &mut board, r#"{"version":1,"event":"device.mic.record","ms":60000,"gain_db":99}"#); + assert_eq!( + lines, + [ + "MIC PROBE 0x18 NONE", + "MIC PROBE 0x40 NONE", + "MIC PROBE 0x41 ID 7210", + "MIC PROBE 0x42 NONE", + "MIC PROBE 0x43 NONE", + "MIC UNAVAILABLE", + ] + ); + // No ES8311: no gain write. + assert!(board.i2c_writes.is_empty()); + + board.mic_input = true; + let lines = send(&mut firmware, &mut board, r#"{"version":1,"event":"device.mic.record","ms":60000}"#); + assert_eq!(lines.last().map(String::as_str), Some("MIC RECORDING 5000 MS")); + assert_eq!(board.mic_capture, Some(480_000)); +} diff --git a/firmware-rs/device/src/main.rs b/firmware-rs/device/src/main.rs index ff33e2ec..d2874cec 100644 --- a/firmware-rs/device/src/main.rs +++ b/firmware-rs/device/src/main.rs @@ -7,7 +7,8 @@ //! codec (0x18, absent on the NS4168 variant). //! - XL9555 P0: bit7 LCD backlight, bit5 amplifier enable, bit4 K1, bit3 K2 (keys active low). //! - K0: BOOT key, GPIO0, active low. -//! - I2S0: BCLK GPIO21, WS GPIO13, DOUT GPIO14, no MCLK. +//! - I2S0: BCLK GPIO21, WS GPIO13, DOUT GPIO14, no MCLK. DIN GPIO47 (microphone, not yet verified on a +//! box; only read during the `device.mic.record` diagnostic). //! - UART0: TX GPIO43, RX GPIO44, to the CH343 bridge; the chip's own USB Serial/JTAG as well. #![no_std] #![no_main] @@ -17,6 +18,7 @@ extern crate alloc; mod audio; mod lcd; +mod mic; mod net; mod relay; mod serial; @@ -28,7 +30,7 @@ use embassy_executor::Spawner; use embassy_time::{Instant, Timer}; use esp_backtrace as _; use esp_hal::clock::CpuClock; -use esp_hal::dma::{DmaTxBuf, DmaTxStreamBuf}; +use esp_hal::dma::{DmaRxStreamBuf, DmaTxBuf, DmaTxStreamBuf}; use esp_hal::gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull}; use esp_hal::i2c::master::{AcknowledgeCheckFailedReason, Config as I2cConfig, Error as I2cError, I2c}; use esp_hal::i2s::master::{Channels, DataFormat, I2s, TdmConfig}; @@ -40,7 +42,7 @@ use esp_hal::time::Rate; use esp_hal::timer::timg::TimerGroup; use esp_hal::uart::{Config as UartConfig, Uart}; use esp_hal::usb::usb_serial_jtag::UsbSerialJtag; -use esp_hal::{Blocking, dma_tx_buffer, dma_tx_stream_buffer}; +use esp_hal::{Blocking, dma_rx_stream_buffer, dma_tx_buffer, dma_tx_stream_buffer}; use esp_storage::FlashStorage; use vibebuddy_firmware_core::audio::{self as codec, ES8311_ADDRESS, Registers, SAMPLE_RATE, Sound}; use vibebuddy_firmware_core::buttons::Levels; @@ -287,6 +289,25 @@ impl Board for DeviceBoard { } codec::set_es8311_volume(&mut self.codec(), level).map_err(|_| VolumeError::Failed) } + + fn i2c_read(&mut self, address: u8, register: u8) -> Option { + let mut value = [0u8]; + self.i2c.write_read(address, &[register], &mut value).ok().map(|()| value[0]) + } + + fn i2c_write(&mut self, address: u8, register: u8, value: u8) -> Result<(), ()> { + self.i2c.write(address, &[register, value]).map_err(|_| ()) + } + + fn mic_start(&mut self, bytes: usize) -> Result<(), ()> { + mic::DONE.reset(); + mic::START.signal(bytes); + Ok(()) + } + + fn mic_take(&mut self) -> Option, &'static str>> { + mic::DONE.try_take() + } } #[allow(clippy::large_stack_frames, reason = "main has to hold a lot of peripherals and buffers anyway")] @@ -351,20 +372,25 @@ async fn main(spawner: Spawner) -> ! { let lcd = Lcd::new(bus, parameters, frame); // Start the I2S stream first (all zeros): as in the C firmware, the clock runs before the codec is - // configured. + // configured. The receiver shares the transmitter's BCLK and WS (signal loopback), as ESP-IDF sets up a + // full-duplex channel pair on the same pins; it only runs during the microphone diagnostic. let i2s = I2s::new( peripherals.I2S0, peripherals.DMA_CH1, TdmConfig::new_tdm_philips() .with_sample_rate(Rate::from_hz(SAMPLE_RATE)) .with_data_format(DataFormat::Data16Channel16) - .with_channels(Channels::STEREO), + .with_channels(Channels::STEREO) + .with_signal_loopback(true), ) .expect("I2S0 config") .into_async(); let i2s_tx = i2s.i2s_tx.with_bclk(peripherals.GPIO21).with_ws(peripherals.GPIO13).with_dout(peripherals.GPIO14).build(); let stream: DmaTxStreamBuf = dma_tx_stream_buffer!(STREAM_BYTES, STREAM_CHUNK); spawner.spawn(audio::audio_task(i2s_tx, stream).expect("audio task")); + let i2s_rx = i2s.i2s_rx.with_din(peripherals.GPIO47).build(); + let mic_stream: DmaRxStreamBuf = dma_rx_stream_buffer!(mic::STREAM_BYTES, mic::STREAM_CHUNK); + spawner.spawn(mic::mic_task(i2s_rx, mic_stream).expect("mic task")); let k0 = Input::new(peripherals.GPIO0, InputConfig::default().with_pull(Pull::Up)); diff --git a/firmware-rs/device/src/mic.rs b/firmware-rs/device/src/mic.rs new file mode 100644 index 00000000..db43c8d3 --- /dev/null +++ b/firmware-rs/device/src/mic.rs @@ -0,0 +1,71 @@ +//! Microphone capture for the bring-up diagnostic (`device.mic.record`, firmware-core's `mic`): I2S0's +//! receiver, clocked by the transmitter's BCLK and WS (signal loopback, as ESP-IDF does for full duplex), +//! reads GPIO47 into a DMA stream buffer only while a capture is asked for, and the samples are copied +//! into PSRAM until there are enough. + +use alloc::vec::Vec; + +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::signal::Signal; +use embassy_time::{Instant, Timer}; +use esp_hal::Async; +use esp_hal::dma::DmaRxStreamBuf; +use esp_hal::i2s::master::I2sRx; + +/// 24 kHz × stereo × 16 bit = 96 KB/s, so 24 KB is 250 ms: how long the main loop may hold the executor +/// before samples are lost. +pub const STREAM_BYTES: usize = 24000; +pub const STREAM_CHUNK: usize = 2400; + +/// No sample for this long means the DMA stopped (its buffer overflowed) or the clock isn't running. +const STALL_MS: u64 = 500; + +/// How many bytes to capture. +pub static START: Signal = Signal::new(); +/// The capture, or why it stopped short. +pub static DONE: Signal, &'static str>> = Signal::new(); + +#[embassy_executor::task] +pub async fn mic_task(rx: I2sRx<'static, Async>, buffer: DmaRxStreamBuf) { + let mut idle = Some((rx, buffer)); + loop { + let wanted = START.wait().await; + let (rx, buffer) = idle.take().unwrap(); + // The capture lives on the heap, which is PSRAM first (main.rs). + let mut capture = Vec::new(); + if capture.try_reserve_exact(wanted).is_err() { + idle = Some((rx, buffer)); + DONE.signal(Err("MEMORY")); + continue; + } + let mut transfer = match rx.read(buffer) { + Ok(transfer) => transfer, + Err((_, rx, buffer)) => { + idle = Some((rx, buffer)); + DONE.signal(Err("I2S")); + continue; + } + }; + let mut last_data = Instant::now(); + let mut chunk = [0u8; STREAM_CHUNK]; + let result = loop { + while capture.len() < wanted { + let count = transfer.pop(&mut chunk[..STREAM_CHUNK.min(wanted - capture.len())]); + if count == 0 { + break; + } + capture.extend_from_slice(&chunk[..count]); + last_data = Instant::now(); + } + if capture.len() >= wanted { + break Ok(capture); + } + if last_data.elapsed().as_millis() > STALL_MS { + break Err("STALLED"); + } + Timer::after_millis(5).await; + }; + idle = Some(transfer.stop()); + DONE.signal(result); + } +} diff --git a/tools/mic-record.py b/tools/mic-record.py new file mode 100755 index 00000000..7e3b6445 --- /dev/null +++ b/tools/mic-record.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Microphone bring-up check: send `device.mic.record` to a box, collect the recording, write a WAV and say +whether each I2S slot carries a live microphone. + +Needs exclusive use of the serial port, so quit the VibeBuddy app first. The port must be named: the box may +share a Mac with other ESP32-S3 devices, which enumerate the same way (LESSONS.md). + +Usage: + uv run --with pyserial python tools/mic-record.py --port /dev/cu.usbmodemXXXX [--ms 3000] [--gain-db 30] [--out mic.wav] + +Talk, clap or tap near the box while it records. The firmware sends the samples back after recording, +about 11 s per recorded second (the UART is always written at 115200 baud, whichever port you use). +See docs/firmware-bringup.md, "Microphone", for what the output means. +""" + +import argparse +import base64 +import json +import math +import sys +import time +import wave +import zlib + +import serial + +FULL_SCALE = 32768 + + +def dbfs(value): + return -120.0 if value <= 0 else 20 * math.log10(value / FULL_SCALE) + + +def verdict(levels): + """What one slot's 100 ms levels say about it.""" + rms = [level[0] for level in levels] + peaks = [level[1] for level in levels] + if max(peaks) == 0: + return "SILENT (all zeros: nothing drives this slot, or wrong pin/slot)" + if max(peaks) >= FULL_SCALE - 1 and sum(peak >= FULL_SCALE - 1 for peak in peaks) > len(peaks) // 2: + return "SATURATED (pinned at full scale in most windows: gain too high or a floating pin)" + spread = dbfs(max(rms)) - dbfs(max(min(rms), 1)) + if spread < 6: + return f"FLAT (level barely moves, {spread:.1f} dB: noise floor or DC only; make some noise and retry)" + return f"LIVE (level moves {spread:.1f} dB between quiet and loud windows)" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--port", required=True, help="serial port of the box, e.g. /dev/cu.usbmodem8401") + parser.add_argument("--ms", type=int, default=3000, help="recording length, 100 to 5000 ms (default 3000)") + parser.add_argument("--gain-db", type=int, default=None, help="ES8311 microphone gain, 0 to 42 dB in 6 dB steps (firmware default 30)") + parser.add_argument("--out", default="mic.wav", help="WAV file to write (default mic.wav)") + args = parser.parse_args() + + fields = {"version": 1, "event": "device.mic.record", "ms": args.ms} + if args.gain_db is not None: + fields["gain_db"] = args.gain_db + line = json.dumps(fields, separators=(",", ":")).encode() + b"\n" + + port = serial.Serial(args.port, 115200, timeout=0.5) + port.reset_input_buffer() + # Short enough for the UART bridge in one piece (it corrupts writes over about 200 bytes). + port.write(line) + + rate = channels = bits = size = None + chunks = [] + levels = [] + end = None + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + text = port.readline().decode("utf-8", "replace").strip() + if not text.startswith("MIC "): + continue + words = text.split(" ") + kind = words[1] + if kind == "DATA": + if int(words[2]) != len(chunks): + print(f"missing data: got line {words[2]}, expected {len(chunks)}", file=sys.stderr) + return 1 + chunks.append(base64.b64decode(words[3])) + deadline = time.monotonic() + 5 + continue + print(text) + if kind in ("UNAVAILABLE", "BUSY", "ERROR"): + return 1 + if kind in ("RECORDING", "CAPTURED"): + deadline = time.monotonic() + 10 + elif kind == "LEVEL": + levels.append(((int(words[4]), int(words[5])), (int(words[7]), int(words[8])))) + elif kind == "BEGIN": + rate, channels, bits, size = (int(word) for word in words[2:6]) + # About 11 s per recorded second over the UART, plus margin. + deadline = time.monotonic() + 30 + size / 8000 + elif kind == "END": + end = (int(words[2]), int(words[3])) + break + port.close() + + if end is None: + print(f"no MIC END before the timeout ({len(chunks)} data lines received)", file=sys.stderr) + return 1 + samples = b"".join(chunks) + if len(samples) != end[0] or zlib.crc32(samples) != end[1]: + print(f"recording damaged on the way: {len(samples)} bytes, CRC {zlib.crc32(samples)}; box sent {end[0]} bytes, CRC {end[1]}", file=sys.stderr) + return 1 + + with wave.open(args.out, "wb") as output: + output.setnchannels(channels) + output.setsampwidth(bits // 8) + output.setframerate(rate) + output.writeframes(samples) + print(f"wrote {args.out}: {len(samples) // (channels * bits // 8) / rate:.2f} s, {rate} Hz, {channels} ch, {bits} bit") + + if levels: + print("\n time left rms/peak (dBFS) right rms/peak (dBFS)") + for index, (left, right) in enumerate(levels): + print(f"{index * 100:5d} ms {dbfs(left[0]):6.1f} / {dbfs(left[1]):6.1f} {dbfs(right[0]):6.1f} / {dbfs(right[1]):6.1f}") + left = verdict([left for left, _ in levels]) + right = verdict([right for _, right in levels]) + print(f"\nleft slot: {left}\nright slot: {right}") + # The right slot is expected to carry the speaker's reference, silent unless the box is speaking. + live = left.startswith("LIVE") or right.startswith("LIVE") + print(f"\nmicrophone on this box: {'WORKS' if live else 'NOT SHOWN'} (listen to {args.out} to be sure)") + return 0 if live else 3 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 243af874e5d007f0ebfccc1110d7f77ae9c1e1a4 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 15:17:09 +0800 Subject: [PATCH 05/51] Add an experimental resident session driven over codex app-server vibebuddyd can now keep one long-running Codex thread for voice commands (ADR-0015), behind VIBEBUDDY_RESIDENT_SESSION=1. It spawns the user's own codex app-server lazily, checks the existing login, starts the thread once (read-only, approvals on request, speak and list_activities as dynamic tools) and resumes it on every later start. Approvals and questions are declined; turns run one at a time and later ones queue. The resident thread's own Codex hooks are dropped by thread id or working directory, so it never becomes an Activity. A new vibebuddy CLI sends a typed voice command with 'vibebuddy say' and prints the thread's codex:// link with 'vibebuddy resident'. Co-Authored-By: Claude Opus 5.5 --- daemon/src/activity.rs | 94 +++++ daemon/src/app_server.rs | 227 +++++++++++ daemon/src/bin/vibebuddy.rs | 90 +++++ daemon/src/main.rs | 121 ++++++ daemon/src/resident.rs | 778 ++++++++++++++++++++++++++++++++++++ 5 files changed, 1310 insertions(+) create mode 100644 daemon/src/app_server.rs create mode 100644 daemon/src/bin/vibebuddy.rs create mode 100644 daemon/src/resident.rs diff --git a/daemon/src/activity.rs b/daemon/src/activity.rs index f92f852e..a004d804 100644 --- a/daemon/src/activity.rs +++ b/daemon/src/activity.rs @@ -184,6 +184,27 @@ pub struct TodaySummary { pub busy_seconds: u64, } +/// One Activity as the resident session sees it (ADR-0015): only what the task cards already show, plus the agent, +/// where it runs and how long ago things happened. Never prompts or replies, which the daemon doesn't hold anyway. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ActivitySummary { + pub agent: String, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub project: Option, + /// `working` or `needs_input`. + pub state: &'static str, + /// `app` (the agent's own desktop app), a host app's bundle id, `window` (Linux), `none` or `github`. + pub host: String, + /// Seconds in the current state. + pub state_for_s: u64, + /// Seconds since the agent last reported anything about it. + pub last_event_s: u64, + /// Work an agent delegated inside its own session, such as a Claude Code subagent. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub delegated: bool, +} + /// Today's stats rotated on the idle screen. #[derive(Debug, Default)] struct DailyStats { @@ -733,6 +754,45 @@ impl ActivityTracker { } } + /// Every Activity, newest first, for the resident session's `list_activities` tool. + pub fn summaries(&self) -> Vec { + let now = Instant::now(); + let mut activities: Vec<(&String, &Activity)> = self.activities.iter().collect(); + activities.sort_by_key(|(_, activity)| std::cmp::Reverse(activity.sequence)); + activities + .into_iter() + .map(|(key, activity)| { + let (agent, surface) = match &activity.source { + Some(ActivitySource::Codex { surface, .. }) => ("codex".to_owned(), Some(surface)), + Some(ActivitySource::ClaudeCode { surface, .. }) => ("claude-code".to_owned(), Some(surface)), + Some(ActivitySource::GitHubActions { .. }) => ("github-actions".to_owned(), None), + Some(ActivitySource::Cli { agent, surface, .. }) => (agent.clone(), Some(surface)), + None => ("unknown".to_owned(), None), + }; + let host = match (&activity.source, surface) { + (Some(ActivitySource::GitHubActions { .. }), _) => "github".to_owned(), + (_, Some(Surface::App { .. })) => "app".to_owned(), + (_, Some(Surface::Host { bundle_id, .. })) => bundle_id.clone(), + (_, Some(Surface::Window { .. })) => "window".to_owned(), + _ => "none".to_owned(), + }; + ActivitySummary { + agent, + title: activity.title.clone(), + project: self.projects.get(key).cloned(), + state: match activity.status { + ActivityStatus::Working => "working", + ActivityStatus::InputRequired => "needs_input", + }, + host, + state_for_s: now.saturating_duration_since(activity.status_since).as_secs(), + last_event_s: now.saturating_duration_since(activity.updated_at).as_secs(), + delegated: activity.child, + } + }) + .collect() + } + fn stats_lines(&self) -> Vec { let busy = self.stats.busy_seconds + self @@ -963,6 +1023,40 @@ mod tests { assert_eq!(tracker.focus_source(), Some(codex)); } + #[test] + fn summaries_list_every_activity_newest_first_with_agent_and_host() { + let mut tracker = ActivityTracker::default(); + let build = id("ci", "ci:1"); + tracker.observe(&build, "CI:BUILD", ActivityStatus::Working); + tracker.associate_source(&build, ActivitySource::GitHubActions { repo: "o/r".to_owned(), run_id: 1 }); + let waiting = id("thread", "thread:turn"); + tracker.note_project(&waiting, "VIBE-BUDDY"); + tracker.require_input(&waiting, "CX:REVIEW"); + tracker.associate_source( + &waiting, + ActivitySource::Codex { + thread_id: "thread".to_owned(), + surface: Surface::Host { + bundle_id: "com.mitchellh.ghostty".to_owned(), + ttys: vec![], + tmux: None, + focus_url: None, + }, + }, + ); + + let summaries = tracker.summaries(); + assert_eq!(summaries.len(), 2); + assert_eq!(summaries[0].agent, "codex"); + assert_eq!(summaries[0].title, "CX:REVIEW"); + assert_eq!(summaries[0].project.as_deref(), Some("VIBE-BUDDY")); + assert_eq!(summaries[0].state, "needs_input"); + assert_eq!(summaries[0].host, "com.mitchellh.ghostty"); + assert_eq!(summaries[1].agent, "github-actions"); + assert_eq!(summaries[1].host, "github"); + assert_eq!(summaries[1].state, "working"); + } + #[test] fn source_survives_activity_refreshes() { let mut tracker = ActivityTracker::default(); diff --git a/daemon/src/app_server.rs b/daemon/src/app_server.rs new file mode 100644 index 00000000..dbfafe51 --- /dev/null +++ b/daemon/src/app_server.rs @@ -0,0 +1,227 @@ +//! A client for `codex app-server`'s protocol: newline-delimited JSON-RPC over the child's stdio (ADR-0015). +//! +//! Requests are `{"id","method","params"}` and get `{"id","result"|"error"}` back; the server also sends +//! notifications and requests of its own. This module only moves messages: what they mean is up to +//! [`crate::resident`]. It takes any reader and writer, so tests drive it with an in-memory fake server. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; + +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::sync::{Mutex, mpsc, oneshot}; + +/// Something the server sent that isn't an answer to one of our requests. +#[derive(Debug)] +pub enum Incoming { + Notification { method: String, params: Value }, + /// A server-to-client request: it must be answered with [`Client::respond`] or [`Client::respond_error`]. + Request { id: Value, method: String, params: Value }, +} + +#[derive(Debug, PartialEq)] +pub enum RpcError { + /// The server answered with an error object. + Server { code: i64, message: String }, + /// The server is gone: its output ended, or writing to it failed. + Closed, +} + +impl std::fmt::Display for RpcError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Server { code, message } => write!(formatter, "app-server error {code}: {message}"), + Self::Closed => write!(formatter, "app-server exited"), + } + } +} + +type Pending = Arc>>>>; + +pub struct Client { + writer: Mutex>, + pending: Pending, + next_id: AtomicU64, + closed: Arc, +} + +impl Client { + /// Starts reading the server's output. Everything that isn't a response comes out of the returned receiver, + /// which ends when the server's output does. + pub fn spawn( + reader: impl AsyncRead + Send + Unpin + 'static, + writer: impl AsyncWrite + Send + Unpin + 'static, + ) -> (Self, mpsc::UnboundedReceiver) { + let pending: Pending = Arc::default(); + let closed = Arc::new(AtomicBool::new(false)); + let (incoming, receiver) = mpsc::unbounded_channel(); + tokio::spawn(read_loop(reader, pending.clone(), closed.clone(), incoming)); + let client = Self { + writer: Mutex::new(Box::new(writer)), + pending, + next_id: AtomicU64::new(1), + closed, + }; + (client, receiver) + } + + pub fn is_closed(&self) -> bool { + self.closed.load(Ordering::Relaxed) + } + + pub async fn request(&self, method: &str, params: Value) -> Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = oneshot::channel(); + self.pending.lock().expect("pending lock").insert(id, sender); + if self.closed.load(Ordering::Relaxed) { + self.pending.lock().expect("pending lock").remove(&id); + return Err(RpcError::Closed); + } + if let Err(error) = self.write(&json!({"id": id, "method": method, "params": params})).await { + self.pending.lock().expect("pending lock").remove(&id); + return Err(error); + } + receiver.await.unwrap_or(Err(RpcError::Closed)) + } + + pub async fn notify(&self, method: &str) -> Result<(), RpcError> { + self.write(&json!({"method": method})).await + } + + pub async fn respond(&self, id: Value, result: Value) -> Result<(), RpcError> { + self.write(&json!({"id": id, "result": result})).await + } + + pub async fn respond_error(&self, id: Value, code: i64, message: &str) -> Result<(), RpcError> { + self.write(&json!({"id": id, "error": {"code": code, "message": message}})).await + } + + async fn write(&self, message: &Value) -> Result<(), RpcError> { + let mut line = serde_json::to_vec(message).map_err(|_| RpcError::Closed)?; + line.push(b'\n'); + let mut writer = self.writer.lock().await; + let written = async { + writer.write_all(&line).await?; + writer.flush().await + } + .await; + written.map_err(|error| { + tracing::warn!(%error, "writing to the app-server failed"); + self.closed.store(true, Ordering::Relaxed); + RpcError::Closed + }) + } +} + +async fn read_loop( + reader: impl AsyncRead + Unpin, + pending: Pending, + closed: Arc, + incoming: mpsc::UnboundedSender, +) { + let mut lines = BufReader::new(reader).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.trim().is_empty() { + continue; + } + let Ok(mut message) = serde_json::from_str::(&line) else { + tracing::warn!("app-server sent a line that isn't JSON"); + continue; + }; + let method = message.get("method").and_then(Value::as_str).map(str::to_owned); + let params = message.get_mut("params").map(Value::take).unwrap_or(Value::Null); + match (method, message.get_mut("id").map(Value::take)) { + (Some(method), Some(id)) => { + let _ = incoming.send(Incoming::Request { id, method, params }); + } + (Some(method), None) => { + let _ = incoming.send(Incoming::Notification { method, params }); + } + (None, Some(id)) => { + let Some(sender) = id.as_u64().and_then(|id| pending.lock().expect("pending lock").remove(&id)) else { + tracing::warn!(%id, "app-server answered a request we didn't send"); + continue; + }; + let result = match message.get_mut("error") { + Some(error) => Err(RpcError::Server { + code: error.get("code").and_then(Value::as_i64).unwrap_or(0), + message: error.get("message").and_then(Value::as_str).unwrap_or_default().to_owned(), + }), + None => Ok(message.get_mut("result").map(Value::take).unwrap_or(Value::Null)), + }; + let _ = sender.send(result); + } + (None, None) => tracing::warn!("app-server sent a message with neither id nor method"), + } + } + closed.store(true, Ordering::Relaxed); + // Dropping the senders fails every request still waiting with `Closed`. + pending.lock().expect("pending lock").clear(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn responses_reach_their_request_and_the_rest_comes_out_as_incoming() { + let (client_side, server_side) = tokio::io::duplex(4096); + let (client_read, client_write) = tokio::io::split(client_side); + let (server_read, mut server_write) = tokio::io::split(server_side); + let (client, mut incoming) = Client::spawn(client_read, client_write); + + let server = tokio::spawn(async move { + let mut lines = BufReader::new(server_read).lines(); + let request: Value = serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + assert_eq!(request["method"], "ping"); + let id = request["id"].clone(); + let out = format!( + "{}\n{}\n{}\n", + json!({"method": "note", "params": {"n": 1}}), + json!({"id": "srv-1", "method": "ask", "params": {}}), + json!({"id": id, "result": {"pong": true}}), + ); + server_write.write_all(out.as_bytes()).await.unwrap(); + let answer: Value = serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + assert_eq!(answer, json!({"id": "srv-1", "result": {"ok": 1}})); + }); + + assert_eq!(client.request("ping", json!({})).await, Ok(json!({"pong": true}))); + assert!(matches!(incoming.recv().await, Some(Incoming::Notification { method, .. }) if method == "note")); + let Some(Incoming::Request { id, method, .. }) = incoming.recv().await else { panic!("expected a request") }; + assert_eq!(method, "ask"); + client.respond(id, json!({"ok": 1})).await.unwrap(); + server.await.unwrap(); + } + + #[tokio::test] + async fn a_server_that_goes_away_fails_waiting_requests() { + let (client_side, server_side) = tokio::io::duplex(4096); + let (client_read, client_write) = tokio::io::split(client_side); + let (client, mut incoming) = Client::spawn(client_read, client_write); + let waiting = tokio::spawn(async move { client.request("slow", json!({})).await }); + tokio::task::yield_now().await; + drop(server_side); + assert_eq!(waiting.await.unwrap(), Err(RpcError::Closed)); + assert!(incoming.recv().await.is_none()); + } + + #[tokio::test] + async fn server_errors_come_back_as_errors() { + let (client_side, server_side) = tokio::io::duplex(4096); + let (client_read, client_write) = tokio::io::split(client_side); + let (server_read, mut server_write) = tokio::io::split(server_side); + let (client, _incoming) = Client::spawn(client_read, client_write); + tokio::spawn(async move { + let mut lines = BufReader::new(server_read).lines(); + let request: Value = serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + let reply = json!({"id": request["id"], "error": {"code": -32600, "message": "no such thread"}}); + server_write.write_all(format!("{reply}\n").as_bytes()).await.unwrap(); + }); + assert_eq!( + client.request("thread/resume", json!({})).await, + Err(RpcError::Server { code: -32600, message: "no such thread".to_owned() }) + ); + } +} diff --git a/daemon/src/bin/vibebuddy.rs b/daemon/src/bin/vibebuddy.rs new file mode 100644 index 00000000..969c5356 --- /dev/null +++ b/daemon/src/bin/vibebuddy.rs @@ -0,0 +1,90 @@ +//! `vibebuddy`: the command line for the local daemon. It only talks to `vibebuddyd` over its HTTP API and never +//! opens the serial port itself (docs/architecture.md, decision 2). +//! +//! - `vibebuddy say `: hands a voice command, typed, to the resident session and prints its spoken replies. +//! - `vibebuddy resident`: prints the resident thread as a `codex://threads/` link for the Codex App. +//! +//! The resident session is experimental: the daemon has it only when started with `VIBEBUDDY_RESIDENT_SESSION=1`. + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::process::ExitCode; +use std::time::Duration; + +use serde_json::{Value, json}; + +/// A voice command waits for a whole Codex turn; the daemon gives up on it after two minutes. +const SAY_TIMEOUT: Duration = Duration::from_secs(180); +const QUICK_TIMEOUT: Duration = Duration::from_secs(5); + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let result = match args.first().map(String::as_str) { + Some("say") if args.len() > 1 => say(&args[1..].join(" ")), + Some("resident") => resident(), + _ => { + eprintln!("usage: vibebuddy say (a voice command for the resident session, typed)"); + eprintln!(" vibebuddy resident (prints the resident thread's codex:// link)"); + return ExitCode::from(2); + } + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("vibebuddy: {error}"); + ExitCode::FAILURE + } + } +} + +fn say(text: &str) -> Result<(), String> { + let answer = call("POST", "/v1/resident/say", Some(&json!({"text": text})), SAY_TIMEOUT)?; + let replies = answer["replies"].as_array().cloned().unwrap_or_default(); + if replies.is_empty() { + println!("(no spoken reply)"); + } + for reply in replies { + println!("{}", reply.as_str().unwrap_or_default()); + } + if let Some(elapsed) = answer["elapsed_ms"].as_u64() { + eprintln!("({:.1} s)", elapsed as f64 / 1000.0); + } + Ok(()) +} + +fn resident() -> Result<(), String> { + let info = call("GET", "/v1/resident", None, QUICK_TIMEOUT)?; + match info["url"].as_str() { + Some(url) => println!("{url}"), + None => println!("no resident thread yet; it starts with the first `vibebuddy say`"), + } + if info["enabled"] != true { + eprintln!("the resident session is off; start vibebuddyd with VIBEBUDDY_RESIDENT_SESSION=1"); + } + Ok(()) +} + +/// One request to the daemon at `VIBEBUDDY_BIND` (default 127.0.0.1:7331), like the hook's: no HTTP library. +fn call(method: &str, path: &str, body: Option<&Value>, timeout: Duration) -> Result { + let address = std::env::var("VIBEBUDDY_BIND").unwrap_or_else(|_| "127.0.0.1:7331".to_owned()); + let socket: SocketAddr = address.parse().map_err(|error| format!("invalid VIBEBUDDY_BIND: {error}"))?; + let unreachable = |error: std::io::Error| format!("vibebuddyd is not reachable at {address}: {error}"); + let mut stream = TcpStream::connect_timeout(&socket, QUICK_TIMEOUT).map_err(unreachable)?; + stream.set_read_timeout(Some(timeout)).map_err(unreachable)?; + let body = body.map(Value::to_string).unwrap_or_default(); + let request = format!( + "{method} {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(request.as_bytes()).map_err(unreachable)?; + let mut response = String::new(); + stream.read_to_string(&mut response).map_err(|error| format!("no answer from vibebuddyd: {error}"))?; + let (head, payload) = response.split_once("\r\n\r\n").ok_or("malformed answer from vibebuddyd")?; + let status: u16 = head.split_whitespace().nth(1).and_then(|code| code.parse().ok()).unwrap_or(0); + let value: Value = serde_json::from_str(payload).unwrap_or(Value::Null); + if !(200..300).contains(&status) { + let message = value["message"].as_str().map(str::to_owned).unwrap_or_else(|| format!("HTTP {status}")); + return Err(message); + } + Ok(value) +} diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 4750e6f0..3117d21b 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -1,4 +1,5 @@ mod activity; +mod app_server; mod ci; mod agent_hooks; mod claude_hooks; @@ -7,6 +8,7 @@ mod config; mod identity; mod lan; mod relay; +mod resident; mod link_alert; mod occasions; mod rom_flasher; @@ -92,6 +94,9 @@ struct AppState { updates: Arc>, /// "Check for updates" pressed: checks now instead of waiting for the daily one. check_updates: Arc, + /// The resident session voice commands go to (ADR-0015); None without a state directory, and in tests + /// unless one sets it. Present even when switched off, so its hook events are still recognised. + resident: Option>, } impl AppState { @@ -125,6 +130,7 @@ impl AppState { state_dir: None, updates: Arc::new(Mutex::new(Updates::new(None, None))), check_updates: Arc::new(Notify::new()), + resident: None, } } @@ -240,6 +246,13 @@ async fn main() { state.identity = identity; state.relay = relay_config; state.state_dir = config::state_dir(); + state.resident = state.state_dir.as_deref().map(|dir| { + let enabled = resident::enabled_by_env(); + if enabled { + warn!("the experimental resident session is on"); + } + Arc::new(resident::Resident::new(enabled, dir, resident::Launcher::Codex, state.activities.clone())) + }); state.device.lock().await.pin = pin; state.updates = Arc::new(Mutex::new(Updates::new( updates::Source::configured(), @@ -472,6 +485,8 @@ fn app(state: AppState) -> Router { .route("/v1/device/firmware", post(post_firmware)) .route("/v1/daemon/restart", post(post_restart)) .route("/v1/updates/check", post(post_check_updates)) + .route("/v1/resident", get(get_resident)) + .route("/v1/resident/say", post(post_resident_say)) .with_state(state) } @@ -1158,6 +1173,16 @@ async fn post_codex_hook( State(state): State, Json(hook): Json, ) -> (StatusCode, Json) { + // The resident session is an Agent, but never an Activity: no card, announcement or stats (ADR-0015). + if let Some(resident) = &state.resident + && resident.owns_hook(&hook.session_id, hook.thread_id.as_deref(), hook.cwd.as_deref()) + { + tracing::debug!(event = %hook.hook_event_name, "ignoring a hook from the resident session"); + return ( + StatusCode::ACCEPTED, + Json(ApiResponse { accepted: true, message: "resident session, ignored".to_owned() }), + ); + } state.hooks_seen.lock().await.codex = Some(chrono::Local::now()); state.notify_status(); let event = { @@ -1230,6 +1255,62 @@ async fn forward(state: AppState, event: Option) -> (StatusCode, Json, + /// Opens the resident thread in the Codex App. + url: Option, +} + +async fn get_resident(State(state): State) -> Json { + let thread_id = state.resident.as_ref().and_then(|resident| resident.thread_id()); + Json(ResidentInfo { + enabled: state.resident.as_ref().is_some_and(|resident| resident.enabled()), + url: thread_id.as_ref().map(|id| format!("codex://threads/{id}")), + thread_id, + }) +} + +/// One voice command, typed for now: waits for the resident session's spoken replies. +async fn post_resident_say(State(state): State, Json(request): Json) -> axum::response::Response { + use axum::response::IntoResponse; + let Some(resident) = state.resident.clone() else { + return (StatusCode::SERVICE_UNAVAILABLE, Json(ApiResponse { accepted: false, message: "no state directory".to_owned() })) + .into_response(); + }; + let text = request.text.trim(); + if text.is_empty() { + return (StatusCode::BAD_REQUEST, Json(ApiResponse { accepted: false, message: "text is empty".to_owned() })).into_response(); + } + let started = std::time::Instant::now(); + match resident.say(text).await { + Ok(answer) => { + info!(elapsed_ms = started.elapsed().as_millis() as u64, replies = answer.replies.len(), "voice command answered"); + Json(serde_json::json!({ + "thread_id": answer.thread_id, + "replies": answer.replies, + "elapsed_ms": started.elapsed().as_millis() as u64, + })) + .into_response() + } + Err(error) => { + warn!(%error, "voice command failed"); + let status = match error { + resident::ResidentError::Disabled => StatusCode::NOT_FOUND, + resident::ResidentError::Timeout => StatusCode::GATEWAY_TIMEOUT, + _ => StatusCode::SERVICE_UNAVAILABLE, + }; + (status, Json(ApiResponse { accepted: false, message: error.to_string() })).into_response() + } + } +} + #[cfg(test)] mod tests { use std::sync::Mutex; @@ -1402,6 +1483,46 @@ mod tests { panic!("event {index} never showed up"); } + #[tokio::test] + async fn the_resident_sessions_own_hooks_make_no_card_announcement_or_stats() { + let dir = std::env::temp_dir().join(format!("vibebuddy-resident-hooks-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let transport = Arc::new(RecordingTransport::default()); + let mut state = test_state(transport.clone()); + state.resident = Some(Arc::new(resident::Resident::new( + false, + &dir, + resident::Launcher::Codex, + state.activities.clone(), + ))); + let resident_cwd = dir.join("resident").display().to_string(); + for event in ["UserPromptSubmit", "PostToolUse", "Stop"] { + let hook: CodexHook = serde_json::from_value(serde_json::json!({ + "session_id": "resident-thread", + "turn_id": "t1", + "hook_event_name": event, + "cwd": resident_cwd, + })) + .unwrap(); + let _ = post_codex_hook(State(state.clone()), Json(hook)).await; + } + assert!(transport.events().is_empty()); + assert!(state.activities.lock().await.summaries().is_empty()); + assert_eq!(state.activities.lock().await.today().done, 0); + + // Any other Codex session still counts. + let hook: CodexHook = serde_json::from_value(serde_json::json!({ + "session_id": "work", + "turn_id": "t1", + "hook_event_name": "UserPromptSubmit", + "cwd": "/work/vibe-buddy", + })) + .unwrap(); + let _ = post_codex_hook(State(state.clone()), Json(hook)).await; + assert_eq!(state.activities.lock().await.summaries().len(), 1); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn the_app_version_leads_the_build_identity() { assert_eq!( diff --git a/daemon/src/resident.rs b/daemon/src/resident.rs new file mode 100644 index 00000000..f3f4ad35 --- /dev/null +++ b/daemon/src/resident.rs @@ -0,0 +1,778 @@ +//! The resident session (ADR-0015): the one long-running Codex thread every voice command goes to. +//! +//! The daemon drives the user's own `codex app-server` over stdio with the user's own login: `thread/start` once, +//! `thread/resume` on every later start, `turn/start` per voice command. The session answers through the `speak` +//! tool we define (`dynamicTools`), and reads the Activities through `list_activities`; it gets no tool that runs +//! commands or writes files, runs read-only, and every approval or question it raises is declined. +//! +//! Experimental and off unless `VIBEBUDDY_RESIDENT_SESSION=1`. The app-server starts on the first voice command +//! and again on the next one after it dies. Voice commands are taken one at a time; the ones that arrive meanwhile +//! wait their turn in arrival order. +//! +//! The resident session is never an Activity: the user's Codex hooks fire for its thread too, and +//! [`Resident::owns_hook`] is how the Codex adapter recognises and drops them. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::sync::{Mutex, mpsc}; +use tracing::{info, warn}; + +use crate::activity::ActivityTracker; +use crate::app_server::{Client, Incoming, RpcError}; + +/// The Codex version the app-server protocol was verified with. app-server and `dynamicTools` are experimental, +/// so other versions get a warning in the log, but are still tried. +pub const TESTED_CODEX_VERSION: &str = "0.162.0"; +/// Setting this to `1` turns the resident session on. +pub const ENABLE_VARIABLE: &str = "VIBEBUDDY_RESIDENT_SESSION"; +/// Names the `codex` binary explicitly; otherwise it is looked for on PATH and where installers put it. +pub const CODEX_VARIABLE: &str = "VIBEBUDDY_CODEX"; +/// How long a single request (handshake, thread start, turn start) may take. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +/// How long one voice command may take from `turn/start` to `turn/completed`; then the turn is interrupted. +const TURN_TIMEOUT: Duration = Duration::from_secs(120); + +pub fn enabled_by_env() -> bool { + matches!(std::env::var(ENABLE_VARIABLE).as_deref(), Ok("1" | "true")) +} + +#[derive(Debug, PartialEq)] +pub enum ResidentError { + Disabled, + NoCodex, + NotLoggedIn, + Rpc(String), + Timeout, + /// The turn ended without completing: interrupted or failed. + TurnEnded(String), +} + +impl std::fmt::Display for ResidentError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Disabled => write!(formatter, "the resident session is experimental and off; start vibebuddyd with {ENABLE_VARIABLE}=1"), + Self::NoCodex => write!(formatter, "codex was not found on PATH (set {CODEX_VARIABLE} to its path)"), + Self::NotLoggedIn => write!(formatter, "Codex is not logged in; run `codex login` first"), + Self::Rpc(message) => write!(formatter, "{message}"), + Self::Timeout => write!(formatter, "the resident session took longer than {} s and was interrupted", TURN_TIMEOUT.as_secs()), + Self::TurnEnded(message) => write!(formatter, "the turn ended without completing: {message}"), + } + } +} + +impl From for ResidentError { + fn from(error: RpcError) -> Self { + Self::Rpc(error.to_string()) + } +} + +/// What the resident session said in answer to one voice command. +#[derive(Debug, Serialize)] +pub struct Answer { + pub thread_id: String, + pub replies: Vec, +} + +/// How the app-server is reached: the real `codex` child, or an in-memory fake in tests. +pub enum Launcher { + Codex, + #[cfg(test)] + Fake(Box Pipes + Send + Sync>), +} + +pub struct Pipes { + pub reader: Box, + pub writer: Box, + /// Killed when the session is dropped. + pub child: Option, +} + +struct Session { + client: Client, + incoming: mpsc::UnboundedReceiver, + thread_id: String, + _child: Option, +} + +#[derive(Default, Deserialize, Serialize)] +struct Saved { + thread_id: Option, + /// The Codex version the thread was last resumed with, for diagnosing a protocol change. + codex_version: Option, +} + +pub struct Resident { + enabled: bool, + /// The working directory the resident thread runs in; the daemon owns it. + dir: PathBuf, + /// The same directory with symlinks resolved, as Codex may report it in hooks. + canonical_dir: PathBuf, + state_file: PathBuf, + launcher: Launcher, + activities: Arc>, + /// Held for a whole turn: one voice command at a time, the rest wait here in order. + session: Mutex>, + /// Read synchronously by the hook filter. + thread_id: std::sync::Mutex>, +} + +impl Resident { + /// `state_dir` is the daemon's state directory: the thread id is kept in `resident.json` there, and the + /// session works in `resident/`. + pub fn new(enabled: bool, state_dir: &Path, launcher: Launcher, activities: Arc>) -> Self { + let dir = state_dir.join("resident"); + let state_file = state_dir.join("resident.json"); + let saved = load(&state_file); + let canonical_dir = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone()); + Self { + enabled, + dir, + canonical_dir, + state_file, + launcher, + activities, + session: Mutex::new(None), + thread_id: std::sync::Mutex::new(saved.thread_id), + } + } + + pub fn enabled(&self) -> bool { + self.enabled + } + + pub fn thread_id(&self) -> Option { + self.thread_id.lock().expect("thread id lock").clone() + } + + /// Whether a Codex hook event comes from the resident session: by its thread, or by its working directory, + /// which also covers the user opening the thread in the Codex App and typing there. + pub fn owns_hook(&self, session_id: &str, thread_id: Option<&str>, cwd: Option<&str>) -> bool { + if let Some(resident) = self.thread_id.lock().expect("thread id lock").as_deref() + && (session_id == resident || thread_id == Some(resident)) + { + return true; + } + cwd.map(Path::new) + .is_some_and(|cwd| cwd.starts_with(&self.dir) || cwd.starts_with(&self.canonical_dir)) + } + + /// Hands one voice command to the resident session and waits for its spoken replies. + pub async fn say(&self, text: &str) -> Result { + if !self.enabled { + return Err(ResidentError::Disabled); + } + let mut slot = self.session.lock().await; + if slot.as_ref().is_some_and(|session| session.client.is_closed()) { + warn!("the resident app-server exited; starting it again"); + *slot = None; + } + if slot.is_none() { + *slot = Some(self.start().await?); + } + let session = slot.as_mut().expect("session was just started"); + let result = self.run_turn(session, text).await; + if session.client.is_closed() { + *slot = None; + } + result + } + + async fn start(&self) -> Result { + let (pipes, version) = match &self.launcher { + Launcher::Codex => spawn_codex().await?, + #[cfg(test)] + Launcher::Fake(connect) => (connect(), None), + }; + let (client, incoming) = Client::spawn(pipes.reader, pipes.writer); + request( + &client, + "initialize", + json!({ + "clientInfo": {"name": "vibebuddy", "title": "VibeBuddy", "version": env!("CARGO_PKG_VERSION")}, + "capabilities": {"experimentalApi": true}, + }), + ) + .await?; + client.notify("initialized").await?; + // Only the user's existing login is used; a login flow is never started from here. + let account = request(&client, "account/read", json!({})).await?; + if account.get("account").is_none_or(Value::is_null) + && account.get("requiresOpenaiAuth").and_then(Value::as_bool) != Some(false) + { + return Err(ResidentError::NotLoggedIn); + } + std::fs::create_dir_all(&self.dir) + .map_err(|error| ResidentError::Rpc(format!("cannot create {}: {error}", self.dir.display())))?; + let thread_id = match self.thread_id() { + Some(saved) => match self.resume(&client, &saved).await { + Err(ResidentError::Rpc(message)) if !client.is_closed() => { + warn!(thread = %saved, %message, "the resident thread could not be resumed; starting a new one"); + self.start_thread(&client).await? + } + other => other?, + }, + None => self.start_thread(&client).await?, + }; + *self.thread_id.lock().expect("thread id lock") = Some(thread_id.clone()); + let saved = Saved { thread_id: Some(thread_id.clone()), codex_version: version }; + if let Err(error) = save(&self.state_file, &saved) { + warn!(%error, "failed to save the resident thread id"); + } + info!(thread = %thread_id, "resident session ready"); + Ok(Session { client, incoming, thread_id, _child: pipes.child }) + } + + async fn start_thread(&self, client: &Client) -> Result { + let mut params = self.thread_params(); + params["dynamicTools"] = tools(); + let started = request(client, "thread/start", params).await?; + thread_id_of(&started) + } + + async fn resume(&self, client: &Client, thread_id: &str) -> Result { + // Dynamic tools live with the thread and survive a resume; the instructions are sent again so a change + // (a new Character, later) takes effect. + let mut params = self.thread_params(); + params["threadId"] = json!(thread_id); + let resumed = request(client, "thread/resume", params).await?; + thread_id_of(&resumed) + } + + fn thread_params(&self) -> Value { + json!({ + "cwd": self.dir, + "sandbox": "read-only", + "approvalPolicy": "on-request", + "developerInstructions": instructions(None), + }) + } + + async fn run_turn(&self, session: &mut Session, text: &str) -> Result { + // Whatever an earlier, timed-out turn left behind still gets its answer, but no longer counts. + while let Ok(message) = session.incoming.try_recv() { + self.handle(&session.client, message, &mut Vec::new()).await; + } + let started = request( + &session.client, + "turn/start", + json!({"threadId": session.thread_id, "input": [{"type": "text", "text": text}]}), + ) + .await?; + let turn_id = started.pointer("/turn/id").and_then(Value::as_str).unwrap_or_default().to_owned(); + let deadline = tokio::time::Instant::now() + TURN_TIMEOUT; + let mut replies = Vec::new(); + loop { + let message = match tokio::time::timeout_at(deadline, session.incoming.recv()).await { + Err(_) => { + let _ = session + .client + .request("turn/interrupt", json!({"threadId": session.thread_id, "turnId": turn_id})) + .await; + return Err(ResidentError::Timeout); + } + Ok(None) => return Err(RpcError::Closed.into()), + Ok(Some(message)) => message, + }; + if let Incoming::Notification { method, params } = &message + && method == "turn/completed" + && params.pointer("/turn/id").and_then(Value::as_str) == Some(turn_id.as_str()) + { + return match params.pointer("/turn/status").and_then(Value::as_str) { + Some("completed") => Ok(Answer { thread_id: session.thread_id.clone(), replies }), + status => Err(ResidentError::TurnEnded( + params + .pointer("/turn/error/message") + .and_then(Value::as_str) + .or(status) + .unwrap_or("unknown") + .to_owned(), + )), + }; + } + self.handle(&session.client, message, &mut replies).await; + } + } + + /// Answers a server-to-client request; notifications other than the turn's end need nothing. + async fn handle(&self, client: &Client, message: Incoming, replies: &mut Vec) { + let Incoming::Request { id, method, params } = message else { + return; + }; + let answered = match decline(&method) { + Some(result) => { + info!(%method, "declined a request from the resident session"); + client.respond(id, result).await + } + None if method == "item/tool/call" => { + let output = self.call_tool(¶ms, replies).await; + client.respond(id, output).await + } + None if method == "currentTime/read" => { + client.respond(id, json!({"currentTimeAt": chrono::Utc::now().timestamp()})).await + } + None => { + info!(%method, "refused an unsupported request from the resident session"); + client.respond_error(id, -32601, "not supported by VibeBuddy").await + } + }; + if let Err(error) = answered { + warn!(%error, "failed to answer the resident session"); + } + } + + async fn call_tool(&self, params: &Value, replies: &mut Vec) -> Value { + let tool = params.get("tool").and_then(Value::as_str).unwrap_or_default(); + let (success, text) = match tool { + "speak" => { + let text = params.pointer("/arguments/text").and_then(Value::as_str).unwrap_or_default().trim(); + if text.is_empty() { + (false, "text is empty".to_owned()) + } else { + info!(chars = text.chars().count(), "the resident session spoke"); + replies.push(text.to_owned()); + (true, "spoken".to_owned()) + } + } + "list_activities" => { + let activities = self.activities.lock().await.summaries(); + (true, json!({"activities": activities}).to_string()) + } + other => (false, format!("unknown tool {other}")), + }; + json!({"success": success, "contentItems": [{"type": "inputText", "text": text}]}) + } +} + +/// The answer that declines a server-to-client request asking for permission or input; None if it isn't one. +fn decline(method: &str) -> Option { + Some(match method { + "item/commandExecution/requestApproval" | "item/fileChange/requestApproval" => json!({"decision": "decline"}), + "execCommandApproval" | "applyPatchApproval" => json!({"decision": "denied"}), + "item/permissions/requestApproval" => json!({"permissions": {}}), + "mcpServer/elicitation/request" => json!({"action": "decline"}), + "item/tool/requestUserInput" => json!({"answers": {}}), + _ => return None, + }) +} + +/// The tools the resident session gets. Nothing here may run a command or write a file. +fn tools() -> Value { + json!([ + { + "type": "function", + "name": "speak", + "description": "Say something to the user out loud through the box. This is the only way the user hears you.", + "inputSchema": { + "type": "object", + "properties": {"text": {"type": "string", "description": "One or two short spoken sentences."}}, + "required": ["text"], + "additionalProperties": false, + }, + }, + { + "type": "function", + "name": "list_activities", + "description": "List what the user's coding Agents are doing right now, newest first: agent, task card title, \ + project, state (working or needs_input), where it runs, and seconds in that state.", + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": false}, + }, + ]) +} + +/// The developer instructions. `persona` is the seam for the Character the user picked (CONTEXT.md, Persona); +/// until Characters carry one, the buddy speaks plainly. +fn instructions(persona: Option<&str>) -> String { + let persona = persona.unwrap_or("You are friendly, calm and brief."); + format!( + "You are the buddy of VibeBuddy: a small desk box that watches the user's coding Agents (Codex, Claude Code, \ + OpenCode, GitHub Copilot CLI, Pi, GitHub Actions) and speaks up for them. {persona}\n\ + Every user message is one voice command, transcribed from speech. Answer only by calling the `speak` tool, \ + with one or two short sentences meant to be heard, not read: no Markdown, lists, code, paths or URLs. \ + Reply in the language the user spoke. Do not write any other answer.\n\ + To know what the Agents are doing, call `list_activities`; never guess. Titles there are short upper-case \ + task-card names; say them naturally.\n\ + You do no work yourself: run no commands, read or edit no files, browse nothing. If the user asks for work \ + to be done, say briefly that you can't start it yet." + ) +} + +fn thread_id_of(response: &Value) -> Result { + response + .pointer("/thread/id") + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| ResidentError::Rpc("app-server returned no thread id".to_owned())) +} + +async fn request(client: &Client, method: &str, params: Value) -> Result { + match tokio::time::timeout(REQUEST_TIMEOUT, client.request(method, params)).await { + Ok(result) => Ok(result?), + Err(_) => Err(ResidentError::Rpc(format!("app-server did not answer {method}"))), + } +} + +/// Starts `codex app-server` and checks its version against the one this was tested with. +async fn spawn_codex() -> Result<(Pipes, Option), ResidentError> { + let program = find_codex().ok_or(ResidentError::NoCodex)?; + let version = tokio::process::Command::new(&program) + .arg("--version") + .output() + .await + .ok() + .and_then(|output| String::from_utf8(output.stdout).ok()) + .and_then(|text| text.split_whitespace().last().map(str::to_owned)); + match version.as_deref() { + Some(TESTED_CODEX_VERSION) => {} + other => warn!( + version = other.unwrap_or("unknown"), + tested = TESTED_CODEX_VERSION, + "the resident session was tested with another Codex version; app-server is experimental and may differ" + ), + } + let mut child = tokio::process::Command::new(&program) + .arg("app-server") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true) + .spawn() + .map_err(|error| ResidentError::Rpc(format!("cannot start {}: {error}", program.display())))?; + info!(program = %program.display(), "started codex app-server for the resident session"); + let reader = child.stdout.take().expect("stdout is piped"); + let writer = child.stdin.take().expect("stdin is piped"); + Ok((Pipes { reader: Box::new(reader), writer: Box::new(writer), child: Some(child) }, version)) +} + +/// `VIBEBUDDY_CODEX`, else `codex` on PATH, else where installers put it: the daemon may run with launchd's bare PATH. +fn find_codex() -> Option { + if let Some(path) = std::env::var_os(CODEX_VARIABLE) { + return Some(PathBuf::from(path)); + } + let home = std::env::var_os("HOME").map(PathBuf::from); + std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).map(|dir| dir.join("codex")).collect::>()) + .unwrap_or_default() + .into_iter() + .chain(home.map(|home| home.join(".local/bin/codex"))) + .chain(["/opt/homebrew/bin/codex", "/usr/local/bin/codex"].map(PathBuf::from)) + .find(|path| path.is_file()) +} + +fn load(path: &Path) -> Saved { + std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_default() +} + +fn save(path: &Path, saved: &Saved) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, serde_json::to_string_pretty(saved).map_err(std::io::Error::other)?) +} + +#[cfg(test)] +mod tests { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + use super::*; + use crate::activity::{ActivityId, ActivityStatus}; + + /// How the fake app-server behaves, and what it saw. + #[derive(Default)] + struct Script { + logged_in: bool, + /// thread/resume fails, as if the rollout were gone. + lose_thread: bool, + /// Ends its output instead of completing this turn, as if the process died; only once. + die_on_turn: Option, + /// Server-to-client requests made during each turn, before it completes. + turn_requests: Vec<(String, Value)>, + /// Requests and notifications from the daemon, in order. + received: Vec, + /// The daemon's answers to the server's own requests. + answers: Vec, + /// How many times the server was started. + starts: usize, + } + + type Shared = Arc>; + + fn fake_server(script: Shared) -> Pipes { + script.lock().unwrap().starts += 1; + let (daemon_side, server_side) = tokio::io::duplex(1 << 16); + let (daemon_read, daemon_write) = tokio::io::split(daemon_side); + tokio::spawn(async move { + let (server_read, mut out) = tokio::io::split(server_side); + let mut lines = BufReader::new(server_read).lines(); + let mut turns = 0; + while let Ok(Some(line)) = lines.next_line().await { + let message: Value = serde_json::from_str(&line).unwrap(); + let Some(method) = message.get("method").and_then(Value::as_str).map(str::to_owned) else { + script.lock().unwrap().answers.push(message); + continue; + }; + script.lock().unwrap().received.push(message.clone()); + let id = message["id"].clone(); + let lose_thread = script.lock().unwrap().lose_thread; + let result = match method.as_str() { + "initialized" => continue, + "initialize" => json!({"userAgent": "fake"}), + "account/read" => { + let logged_in = script.lock().unwrap().logged_in; + let account = if logged_in { json!({"type": "chatgpt"}) } else { Value::Null }; + json!({"account": account, "requiresOpenaiAuth": true}) + } + "thread/start" => json!({"thread": {"id": "new-thread"}}), + "thread/resume" if lose_thread => { + let error = json!({"id": id, "error": {"code": -32600, "message": "no rollout found"}}); + out.write_all(format!("{error}\n").as_bytes()).await.unwrap(); + continue; + } + "thread/resume" => json!({"thread": {"id": message["params"]["threadId"]}}), + "turn/start" => { + turns += 1; + let turn_id = format!("turn-{turns}"); + let started = json!({"id": id, "result": {"turn": {"id": turn_id, "status": "inProgress"}}}); + out.write_all(format!("{started}\n").as_bytes()).await.unwrap(); + let requests = script.lock().unwrap().turn_requests.clone(); + for (number, (request, params)) in requests.iter().enumerate() { + let request = json!({"id": format!("srv-{turns}-{number}"), "method": request, "params": params}); + out.write_all(format!("{request}\n").as_bytes()).await.unwrap(); + } + for _ in &requests { + let Ok(Some(line)) = lines.next_line().await else { return }; + script.lock().unwrap().answers.push(serde_json::from_str(&line).unwrap()); + } + if script.lock().unwrap().die_on_turn.take_if(|turn| *turn == turns).is_some() { + return; + } + let completed = json!({"method": "turn/completed", "params": { + "threadId": message["params"]["threadId"], + "turn": {"id": turn_id, "status": "completed", "items": []}, + }}); + out.write_all(format!("{completed}\n").as_bytes()).await.unwrap(); + continue; + } + _ => { + let error = json!({"id": id, "error": {"code": -32601, "message": "unknown"}}); + out.write_all(format!("{error}\n").as_bytes()).await.unwrap(); + continue; + } + }; + out.write_all(format!("{}\n", json!({"id": id, "result": result})).as_bytes()).await.unwrap(); + } + }); + Pipes { reader: Box::new(daemon_read), writer: Box::new(daemon_write), child: None } + } + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vibebuddy-resident-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn resident(dir: &Path, script: &Shared, activities: Arc>) -> Resident { + let script = script.clone(); + Resident::new(true, dir, Launcher::Fake(Box::new(move || fake_server(script.clone()))), activities) + } + + fn logged_in() -> Shared { + Arc::new(std::sync::Mutex::new(Script { logged_in: true, ..Script::default() })) + } + + fn methods(script: &Shared) -> Vec { + script.lock().unwrap().received.iter().map(|message| message["method"].as_str().unwrap().to_owned()).collect() + } + + fn tool_call(tool: &str, arguments: Value) -> (String, Value) { + let params = json!({"threadId": "t", "turnId": "u", "callId": "c", "tool": tool, "arguments": arguments}); + ("item/tool/call".to_owned(), params) + } + + #[tokio::test] + async fn the_first_command_shakes_hands_and_starts_one_read_only_thread_with_our_tools() { + let dir = temp_dir("start"); + let script = logged_in(); + let resident = resident(&dir, &script, Arc::default()); + + let answer = resident.say("hello").await.unwrap(); + assert_eq!(answer.thread_id, "new-thread"); + assert_eq!(methods(&script), ["initialize", "initialized", "account/read", "thread/start", "turn/start"]); + let received = script.lock().unwrap().received.clone(); + assert_eq!(received[0]["params"]["capabilities"]["experimentalApi"], true); + assert_eq!(received[0]["params"]["clientInfo"]["name"], "vibebuddy"); + let start = &received[3]["params"]; + assert_eq!(start["cwd"], json!(dir.join("resident"))); + assert_eq!(start["sandbox"], "read-only"); + assert_eq!(start["approvalPolicy"], "on-request"); + assert!(start["developerInstructions"].as_str().unwrap().contains("speak")); + let tools: Vec<&str> = + start["dynamicTools"].as_array().unwrap().iter().map(|tool| tool["name"].as_str().unwrap()).collect(); + assert_eq!(tools, ["speak", "list_activities"]); + assert_eq!(received[4]["params"], json!({"threadId": "new-thread", "input": [{"type": "text", "text": "hello"}]})); + assert!(dir.join("resident").is_dir()); + assert_eq!(load(&dir.join("resident.json")).thread_id.as_deref(), Some("new-thread")); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn a_later_start_resumes_the_saved_thread_and_a_lost_one_is_replaced() { + let dir = temp_dir("resume"); + let saved = Saved { thread_id: Some("old-thread".to_owned()), codex_version: None }; + save(&dir.join("resident.json"), &saved).unwrap(); + let script = logged_in(); + let answer = resident(&dir, &script, Arc::default()).say("hi").await.unwrap(); + assert_eq!(answer.thread_id, "old-thread"); + assert_eq!(methods(&script), ["initialize", "initialized", "account/read", "thread/resume", "turn/start"]); + let resume = script.lock().unwrap().received[3]["params"].clone(); + assert_eq!(resume["threadId"], "old-thread"); + assert_eq!(resume["sandbox"], "read-only"); + assert_eq!(resume["approvalPolicy"], "on-request"); + + let script = logged_in(); + script.lock().unwrap().lose_thread = true; + let answer = resident(&dir, &script, Arc::default()).say("hi").await.unwrap(); + assert_eq!(answer.thread_id, "new-thread"); + assert_eq!(methods(&script)[3..5], ["thread/resume", "thread/start"]); + assert_eq!(load(&dir.join("resident.json")).thread_id.as_deref(), Some("new-thread")); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn without_a_login_nothing_starts_and_no_login_flow_is_begun() { + let dir = temp_dir("login"); + let script = Arc::new(std::sync::Mutex::new(Script::default())); + let error = resident(&dir, &script, Arc::default()).say("hi").await.unwrap_err(); + assert_eq!(error, ResidentError::NotLoggedIn); + assert_eq!(methods(&script), ["initialize", "initialized", "account/read"]); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn speak_becomes_the_reply_and_list_activities_reads_the_tracker() { + let dir = temp_dir("tools"); + let mut tracker = ActivityTracker::default(); + let id = ActivityId { session_id: "s".to_owned(), key: "s:t".to_owned() }; + tracker.observe(&id, "CX:REVIEW", ActivityStatus::Working); + let script = logged_in(); + script.lock().unwrap().turn_requests = vec![ + tool_call("list_activities", json!({})), + tool_call("speak", json!({"text": " One task is running. "})), + tool_call("speak", json!({"text": ""})), + tool_call("run_shell", json!({"command": "ls"})), + ]; + let answer = resident(&dir, &script, Arc::new(Mutex::new(tracker))).say("what's up?").await.unwrap(); + assert_eq!(answer.replies, ["One task is running."]); + + let answers = script.lock().unwrap().answers.clone(); + let text = |index: usize| answers[index]["result"]["contentItems"][0]["text"].as_str().unwrap().to_owned(); + assert_eq!(answers[0]["id"], "srv-1-0"); + assert_eq!(answers[0]["result"]["success"], true); + let listed: Value = serde_json::from_str(&text(0)).unwrap(); + assert_eq!(listed["activities"][0]["title"], "CX:REVIEW"); + assert_eq!(listed["activities"][0]["state"], "working"); + assert_eq!(answers[1]["result"]["success"], true); + assert_eq!(answers[2]["result"]["success"], false); + assert_eq!(answers[3]["result"]["success"], false); + assert_eq!(text(3), "unknown tool run_shell"); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn approvals_and_questions_are_declined_and_other_requests_refused() { + let dir = temp_dir("decline"); + let script = logged_in(); + script.lock().unwrap().turn_requests = [ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/permissions/requestApproval", + "mcpServer/elicitation/request", + "item/tool/requestUserInput", + "execCommandApproval", + "account/chatgptAuthTokens/refresh", + ] + .map(|method| (method.to_owned(), json!({}))) + .to_vec(); + resident(&dir, &script, Arc::default()).say("delete everything").await.unwrap(); + let answers = script.lock().unwrap().answers.clone(); + assert_eq!(answers[0]["result"], json!({"decision": "decline"})); + assert_eq!(answers[1]["result"], json!({"decision": "decline"})); + assert_eq!(answers[2]["result"], json!({"permissions": {}})); + assert_eq!(answers[3]["result"], json!({"action": "decline"})); + assert_eq!(answers[4]["result"], json!({"answers": {}})); + assert_eq!(answers[5]["result"], json!({"decision": "denied"})); + assert_eq!(answers[6]["error"]["code"], -32601); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn a_dead_app_server_fails_the_turn_and_is_started_again_on_the_next_command() { + let dir = temp_dir("restart"); + let script = logged_in(); + script.lock().unwrap().die_on_turn = Some(1); + let resident = resident(&dir, &script, Arc::default()); + assert!(matches!(resident.say("one").await, Err(ResidentError::Rpc(_)))); + let answer = resident.say("two").await.unwrap(); + assert_eq!(answer.thread_id, "new-thread"); + assert_eq!(script.lock().unwrap().starts, 2); + // The second start resumes the thread the first one made. + assert!(methods(&script).contains(&"thread/resume".to_owned())); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn concurrent_commands_wait_their_turn() { + let dir = temp_dir("queue"); + let script = logged_in(); + let resident = Arc::new(resident(&dir, &script, Arc::default())); + let (first, second) = tokio::join!(resident.say("one"), resident.say("two")); + assert!(first.is_ok() && second.is_ok()); + let turns: Vec = script + .lock() + .unwrap() + .received + .iter() + .filter(|message| message["method"] == "turn/start") + .map(|message| message["params"]["input"][0]["text"].clone()) + .collect(); + assert_eq!(turns, [json!("one"), json!("two")]); + assert_eq!(script.lock().unwrap().starts, 1); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn off_by_default_it_starts_nothing() { + let dir = temp_dir("off"); + let script = logged_in(); + let launch = script.clone(); + let resident = Resident::new(false, &dir, Launcher::Fake(Box::new(move || fake_server(launch.clone()))), Arc::default()); + assert_eq!(resident.say("hi").await.unwrap_err(), ResidentError::Disabled); + assert_eq!(script.lock().unwrap().starts, 0); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn hooks_from_the_resident_thread_or_its_directory_are_recognised() { + let dir = temp_dir("hooks"); + let saved = Saved { thread_id: Some("resident-thread".to_owned()), codex_version: None }; + save(&dir.join("resident.json"), &saved).unwrap(); + let resident = Resident::new(false, &dir, Launcher::Codex, Arc::default()); + let cwd = dir.join("resident").display().to_string(); + assert!(resident.owns_hook("resident-thread", None, None)); + assert!(resident.owns_hook("subagent", Some("resident-thread"), None)); + assert!(resident.owns_hook("other", None, Some(&cwd))); + assert!(resident.owns_hook("other", None, Some(&format!("{cwd}/notes")))); + assert!(!resident.owns_hook("other", Some("other"), Some("/work/vibe-buddy"))); + assert!(!resident.owns_hook("other", None, Some(&format!("{cwd}-not")))); + assert!(!resident.owns_hook("other", None, None)); + let _ = std::fs::remove_dir_all(dir); + } +} From 3567037762b2bb4957e5df663c66e1b85112fb27 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 15:17:09 +0800 Subject: [PATCH 06/51] docs: describe the resident session Co-Authored-By: Claude Opus 5.5 --- docs/codex-adapter.md | 2 ++ docs/resident-session.md | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 docs/resident-session.md diff --git a/docs/codex-adapter.md b/docs/codex-adapter.md index 930a5c94..abd9e480 100644 --- a/docs/codex-adapter.md +++ b/docs/codex-adapter.md @@ -78,6 +78,8 @@ Codex fires the same set of hooks for its own background sessions, typically the So `vibebuddyd` filters at the entry point: any Codex session that has no usable working directory and can't be found in the threads table is ignored (with one info log line): no card, no announcement, no K2 target. Both conditions are required: a freshly created real session may not have been written to the threads table yet, but it has a working directory. Before K2 opens a thread it checks once more that the thread exists, and skips to the next candidate if not. `tools/codex-hook.py` also writes one diagnostic line without the prompt to `~/Library/Logs/VibeBuddy/codex-hooks.log` (event name, session and thread ids, directory, whether there is a transcript), so the next time a session of unknown origin shows up you can see what the hook actually received. +The daemon's own resident session (ADR-0015) is a Codex thread too, and the user's hooks fire for it. Its hooks are dropped at the same entry point, by its thread id or by a `cwd` inside its working directory, so it never becomes an Activity; see [`resident-session.md`](resident-session.md). + The `vibebuddyd` release binary is started automatically after login by `~/Library/LaunchAgents/com.vibebuddy.vibebuddyd.plist` and restarted if it exits abnormally. Logs go to `~/Library/Logs/VibeBuddy/vibebuddyd.log`. When the USB device is temporarily absent, the process keeps running and periodically rediscovers it; hooks don't need to know about plugging and unplugging. After updating the daemon, run `cargo build --release -p vibebuddyd`, then restart the service with `launchctl kickstart -k gui/502/com.vibebuddy.vibebuddyd`. Stop the service before flashing firmware so it doesn't hold the serial port. diff --git a/docs/resident-session.md b/docs/resident-session.md new file mode 100644 index 00000000..b293c2db --- /dev/null +++ b/docs/resident-session.md @@ -0,0 +1,44 @@ +# Resident session + +The resident session is the one long-running Codex thread every voice command goes to ([ADR-0015](adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md); see "Voice commands" in [`CONTEXT.md`](../CONTEXT.md)). This is the first, command-line-only cut: the box can't hear yet, so a voice command is typed. + +It is experimental and off by default. Start `vibebuddyd` with `VIBEBUDDY_RESIDENT_SESSION=1` to turn it on. There is no App UI for it, on the Mac or on Linux. + +## Using it + +```sh +vibebuddy say "What are my agents doing?" # prints the spoken reply(ies); the time taken goes to stderr +vibebuddy resident # prints codex://threads/, to open the thread in the Codex App +``` + +`vibebuddy` is a second binary of the daemon package (`daemon/src/bin/vibebuddy.rs`). Like every client, it only talks to the daemon's HTTP API, at `VIBEBUDDY_BIND` (default `127.0.0.1:7331`): + +| Endpoint | Does | +| --- | --- | +| `POST /v1/resident/say` `{"text"}` | Runs one turn; answers `{"thread_id","replies":[...],"elapsed_ms"}`. 404 when off, 503 when Codex is missing or not logged in, 504 after two minutes. | +| `GET /v1/resident` | `{"enabled","thread_id","url"}` | + +## How it works + +`daemon/src/app_server.rs` is a newline-delimited JSON-RPC client for `codex app-server` on stdio; `daemon/src/resident.rs` is the session on top of it. + +- **Starting.** On the first voice command, and on the next one after the app-server dies, the daemon runs the `codex` it finds (`VIBEBUDDY_CODEX`, else PATH, else `~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, since launchd's PATH is bare). It sends `initialize` (`experimentalApi: true`) and `initialized`, then `account/read`: with no account it stops with "not logged in". It never starts a login. +- **One thread for good.** `thread/start` once, with `cwd` set to `resident/` in the daemon's state directory, `sandbox: "read-only"`, `approvalPolicy: "on-request"`, the developer instructions and the dynamic tools. The thread id is kept in `resident.json` beside it, and every later start sends `thread/resume` with the same settings (the tools survive a resume; the instructions are sent again). If the thread can't be resumed, a new one is started and saved. +- **Tools.** `speak({text})` is the spoken reply: the daemon collects it and hands it back to the caller (later it goes to the box). `list_activities({})` returns the Activities the aggregator already holds, as JSON: agent, task-card title, project, `working`/`needs_input`, host, seconds in that state and since the last event. There are no tools that run commands or write files. +- **Declining.** Every approval or question the session raises is declined: command and file-change approvals (`decline`, or `denied` on the legacy methods), permission requests (nothing granted), MCP elicitations (`decline`) and user-input requests (no answers). `currentTime/read` is answered; anything else gets a JSON-RPC "method not found". +- **Turns.** `turn/start` with the text, then the daemon answers tool calls until `turn/completed` for that turn. One turn at a time: a command that arrives during another waits for it, in order, rather than being rejected, since two quick sentences are both meant to be answered. A turn that takes over two minutes is interrupted (`turn/interrupt`). +- **Persona.** `instructions()` in `resident.rs` takes the Character's persona; until Characters carry one, it is `None` and the buddy speaks plainly. + +## Never an Activity + +The user's Codex hooks fire for the resident thread too: its `UserPromptSubmit`, `PostToolUse` and `Stop` reach `/v1/codex-hooks` like any session's. The Codex adapter drops a hook whose session or thread is the resident thread, or whose `cwd` is inside the resident directory (which also covers the thread opened in the Codex App), before anything is recorded: no card, announcement, stats, CI watch or K2 target. This works even with the resident session switched off. + +## Versions + +app-server and `dynamicTools` are experimental. This was built and checked against codex-cli 0.162.0 (`TESTED_CODEX_VERSION`); another version logs a warning and is still tried. The version last used is written to `resident.json`. + +## Known gaps + +- The thread loads the user's own Codex config: their global `AGENTS.md`, plugins, MCP servers and hooks. Read-only and declined approvals keep it from running commands or editing files, but an MCP tool that needs no approval is not blocked. +- A reply is returned when the turn completes, a few seconds after `speak` was called; for the box it should go out the moment `speak` arrives. +- `list_activities` sends task-card titles and project names to the model provider the user's Codex uses. From a3ba01c594fdb4c7d757adae6fc098ff761d44d0 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 15:28:21 +0800 Subject: [PATCH 07/51] Take the mic's DMA ring from the heap, not the main stack The first mic build boot-looped on a stack guard panic while drawing the look at boot. esp-hal's main stack is the internal DRAM left between the end of .bss and the reclaimed heap; the static 24 KB RX ring and the task's 2.4 KB local buffer took it from 47,784 to 21,032 bytes. The ring (12 KB, 4 descriptors) is now allocated from internal heap only while a capture runs and freed after, and the task copies straight from the ring into the PSRAM capture, so its pool is 184 bytes. The stack is 47,576 bytes, 208 below main. A lesson records how to check this. Co-Authored-By: Claude Opus 5.5 --- LESSONS.md | 6 ++ docs/firmware-bringup.md | 4 +- firmware-rs/device/src/main.rs | 7 +- firmware-rs/device/src/mic.rs | 161 ++++++++++++++++++++++++--------- 4 files changed, 127 insertions(+), 51 deletions(-) diff --git a/LESSONS.md b/LESSONS.md index b5649d0b..9b3b1a38 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -316,3 +316,9 @@ A step that only runs at release time is the one nobody exercises (see the v0.3. On 2026-10-12 the first OTA-layout firmware reset in a loop right after READY on a box: confirming the running image went through esp-bootloader-esp-idf's `OtaUpdater`, which parses the whole partition table and panics on our custom data subtypes (voices 0x40, link 0x41). Every host test passed, because the fake boards' flash had no otadata and the device-only code never ran there. The box flickered and clicked until the fix was flashed. Code that parses partitions or otadata at boot either lives in firmware-core with host tests built from the real `partitions.csv` (as `ota::confirm` now does), or is flashed to a box and watched through one full boot and a reset before the branch is called done. And back the box up first: a full `read-flash` took three minutes and made every later step low-risk. + +## On the firmware, every new static in internal RAM comes out of the main stack + +On 2026-10-13 the first microphone build boot-looped: `Detected a write to the stack guard value on ProCpu`, inside text drawing while the boot loaded the Character's look. With esp-hal the main task's stack is whatever internal DRAM is left between the end of `.bss` and the fixed start of the reclaimed heap (`_stack_end_cpu0` to `_stack_start_cpu0`). The build had added a 24 KB static DMA ring (`dma_rx_stream_buffer!`) and a 2.6 KB task (an async task's locals live in its static pool), and the stack went from 47,784 bytes to 21,032 with nothing in the build saying so. Host tests and the device build both passed. + +Before a firmware change that adds statics or `dma_*_buffer!` macros goes to a box, compare `_stack_start_cpu0 - _stack_end_cpu0` (`xtensa-esp32s3-elf-nm` on the release ELF) with main's. A buffer only one feature uses for a few seconds belongs on the heap for those seconds (internal capabilities if DMA touches it), not in `.bss` for the life of the box. diff --git a/docs/firmware-bringup.md b/docs/firmware-bringup.md index b143f922..cb23e800 100644 --- a/docs/firmware-bringup.md +++ b/docs/firmware-bringup.md @@ -139,7 +139,7 @@ MIC DATA 576 bytes of samples per line MIC END 288000 ``` -`MIC UNAVAILABLE` means the board has no input; `MIC BUSY`, that a recording is still going out; `MIC ERROR STALLED`, that no samples came for half a second (the receive DMA overflowed, or nothing clocks the receiver); `MIC ERROR MEMORY`, that the capture didn't fit. +`MIC UNAVAILABLE` means the board has no input; `MIC BUSY`, that a recording is still going out; `MIC ERROR STALLED`, that no samples came for half a second (the receive DMA overflowed, or nothing clocks the receiver); `MIC ERROR MEMORY`, that the capture (PSRAM) or the 12 KB receive ring (internal RAM, taken only while recording) didn't fit. ### Reading the result @@ -150,7 +150,7 @@ MIC END 288000 | Both slots stuck at one value, or `SATURATED` with no sound | GPIO47 is floating or isn't the codec's data line | | Left `FLAT` with a level above zero | The ADC runs but the microphone isn't reaching it: try `--gain-db 42` and noise right at the mic hole; then suspect the analog path (MIC bias, which input the ES8311 selects in register 0x14) | | `LIVE` on the right, not the left | The slots are the other way round from esp_codec_dev's 0x44 setting | -| `MIC CAPTURED` far longer than asked | Samples were lost on the way in; the DMA buffer (250 ms) is too short for something the main loop did | +| `MIC CAPTURED` far longer than asked | Samples were lost on the way in; the receive ring (125 ms) is too short for something the main loop did | | Any `MIC PROBE 0x4x ID …` | This box has a chip where an ES7210 would be: it differs from the scanned boxes, so stop and look before trusting the ES8311 path | Boxes of this board differ (some have a touchscreen, some don't), so run it on each box you care about and say which box in the result. diff --git a/firmware-rs/device/src/main.rs b/firmware-rs/device/src/main.rs index d2874cec..1d8f9abd 100644 --- a/firmware-rs/device/src/main.rs +++ b/firmware-rs/device/src/main.rs @@ -30,7 +30,7 @@ use embassy_executor::Spawner; use embassy_time::{Instant, Timer}; use esp_backtrace as _; use esp_hal::clock::CpuClock; -use esp_hal::dma::{DmaRxStreamBuf, DmaTxBuf, DmaTxStreamBuf}; +use esp_hal::dma::{DmaTxBuf, DmaTxStreamBuf}; use esp_hal::gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull}; use esp_hal::i2c::master::{AcknowledgeCheckFailedReason, Config as I2cConfig, Error as I2cError, I2c}; use esp_hal::i2s::master::{Channels, DataFormat, I2s, TdmConfig}; @@ -42,7 +42,7 @@ use esp_hal::time::Rate; use esp_hal::timer::timg::TimerGroup; use esp_hal::uart::{Config as UartConfig, Uart}; use esp_hal::usb::usb_serial_jtag::UsbSerialJtag; -use esp_hal::{Blocking, dma_rx_stream_buffer, dma_tx_buffer, dma_tx_stream_buffer}; +use esp_hal::{Blocking, dma_tx_buffer, dma_tx_stream_buffer}; use esp_storage::FlashStorage; use vibebuddy_firmware_core::audio::{self as codec, ES8311_ADDRESS, Registers, SAMPLE_RATE, Sound}; use vibebuddy_firmware_core::buttons::Levels; @@ -389,8 +389,7 @@ async fn main(spawner: Spawner) -> ! { let stream: DmaTxStreamBuf = dma_tx_stream_buffer!(STREAM_BYTES, STREAM_CHUNK); spawner.spawn(audio::audio_task(i2s_tx, stream).expect("audio task")); let i2s_rx = i2s.i2s_rx.with_din(peripherals.GPIO47).build(); - let mic_stream: DmaRxStreamBuf = dma_rx_stream_buffer!(mic::STREAM_BYTES, mic::STREAM_CHUNK); - spawner.spawn(mic::mic_task(i2s_rx, mic_stream).expect("mic task")); + spawner.spawn(mic::mic_task(i2s_rx).expect("mic task")); let k0 = Input::new(peripherals.GPIO0, InputConfig::default().with_pull(Pull::Up)); diff --git a/firmware-rs/device/src/mic.rs b/firmware-rs/device/src/mic.rs index db43c8d3..410712d2 100644 --- a/firmware-rs/device/src/mic.rs +++ b/firmware-rs/device/src/mic.rs @@ -1,23 +1,31 @@ //! Microphone capture for the bring-up diagnostic (`device.mic.record`, firmware-core's `mic`): I2S0's //! receiver, clocked by the transmitter's BCLK and WS (signal loopback, as ESP-IDF does for full duplex), -//! reads GPIO47 into a DMA stream buffer only while a capture is asked for, and the samples are copied -//! into PSRAM until there are enough. +//! reads GPIO47 into a DMA ring only while a capture is asked for, and the samples are copied into PSRAM +//! until there are enough. +//! +//! The ring is taken from the internal heap for the capture and given back after, not a static: every +//! static in internal RAM comes out of the main task's stack, which is whatever DRAM the statics leave +//! (LESSONS.md). A 24 KB static ring cost the stack 24 KB and the box boot-looped on a stack overflow. use alloc::vec::Vec; +use core::alloc::{GlobalAlloc, Layout}; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::signal::Signal; use embassy_time::{Instant, Timer}; +use esp_alloc::{HEAP, MemoryCapability}; use esp_hal::Async; -use esp_hal::dma::DmaRxStreamBuf; +use esp_hal::dma::aligned::DmaAlignedMut; +use esp_hal::dma::{DmaDescriptor, DmaRxStreamBuf}; use esp_hal::i2s::master::I2sRx; -/// 24 kHz × stereo × 16 bit = 96 KB/s, so 24 KB is 250 ms: how long the main loop may hold the executor -/// before samples are lost. -pub const STREAM_BYTES: usize = 24000; -pub const STREAM_CHUNK: usize = 2400; +/// 24 kHz × stereo × 16 bit = 96 KB/s, so 12 KB is 125 ms: how long the main loop may hold the executor +/// before samples are lost (the capture then ends with `STALLED`). +const RING_BYTES: usize = 12000; +/// esp-hal's stream buffer wants at least four descriptors; 3000 bytes each. +const RING_DESCRIPTORS: usize = 4; -/// No sample for this long means the DMA stopped (its buffer overflowed) or the clock isn't running. +/// No sample for this long means the DMA stopped (its ring overflowed) or the clock isn't running. const STALL_MS: u64 = 500; /// How many bytes to capture. @@ -25,47 +33,110 @@ pub static START: Signal = Signal::new(); /// The capture, or why it stopped short. pub static DONE: Signal, &'static str>> = Signal::new(); -#[embassy_executor::task] -pub async fn mic_task(rx: I2sRx<'static, Async>, buffer: DmaRxStreamBuf) { - let mut idle = Some((rx, buffer)); - loop { - let wanted = START.wait().await; - let (rx, buffer) = idle.take().unwrap(); - // The capture lives on the heap, which is PSRAM first (main.rs). - let mut capture = Vec::new(); - if capture.try_reserve_exact(wanted).is_err() { - idle = Some((rx, buffer)); - DONE.signal(Err("MEMORY")); - continue; - } - let mut transfer = match rx.read(buffer) { - Ok(transfer) => transfer, - Err((_, rx, buffer)) => { - idle = Some((rx, buffer)); - DONE.signal(Err("I2S")); - continue; +/// The DMA ring and its descriptors, in internal RAM (the stream buffer doesn't keep PSRAM coherent). +struct Ring { + descriptors: *mut DmaDescriptor, + bytes: *mut u8, +} + +impl Ring { + fn descriptors_layout() -> Layout { + Layout::array::(RING_DESCRIPTORS).unwrap() + } + + fn bytes_layout() -> Layout { + Layout::from_size_align(RING_BYTES, 4).unwrap() + } + + fn new() -> Option { + // SAFETY: both layouts are non-zero, and each block is initialized before any reference is made to it. + unsafe { + let descriptors = HEAP.alloc_caps(MemoryCapability::Internal.into(), Self::descriptors_layout()) as *mut DmaDescriptor; + let bytes = HEAP.alloc_caps(MemoryCapability::Internal.into(), Self::bytes_layout()); + let ring = Ring { descriptors, bytes }; + if descriptors.is_null() || bytes.is_null() { + return None; } - }; - let mut last_data = Instant::now(); - let mut chunk = [0u8; STREAM_CHUNK]; - let result = loop { - while capture.len() < wanted { - let count = transfer.pop(&mut chunk[..STREAM_CHUNK.min(wanted - capture.len())]); - if count == 0 { - break; - } - capture.extend_from_slice(&chunk[..count]); - last_data = Instant::now(); + for index in 0..RING_DESCRIPTORS { + descriptors.add(index).write(DmaDescriptor::EMPTY); } - if capture.len() >= wanted { - break Ok(capture); + bytes.write_bytes(0, RING_BYTES); + Some(ring) + } + } + + /// SAFETY: the buffer made here must be dropped before the ring is. + unsafe fn buffer(&mut self) -> Option { + // SAFETY: the blocks are ours alone and outlive the buffer (the caller's promise). + let (descriptors, bytes) = unsafe { + (core::slice::from_raw_parts_mut(self.descriptors, RING_DESCRIPTORS), core::slice::from_raw_parts_mut(self.bytes, RING_BYTES)) + }; + DmaRxStreamBuf::new(DmaAlignedMut::new(descriptors).ok()?, DmaAlignedMut::new(bytes).ok()?).ok() + } +} + +impl Drop for Ring { + fn drop(&mut self) { + // SAFETY: allocated by `new` with these layouts; dealloc ignores null. + unsafe { + if !self.descriptors.is_null() { + HEAP.dealloc(self.descriptors as *mut u8, Self::descriptors_layout()); } - if last_data.elapsed().as_millis() > STALL_MS { - break Err("STALLED"); + if !self.bytes.is_null() { + HEAP.dealloc(self.bytes, Self::bytes_layout()); } - Timer::after_millis(5).await; - }; - idle = Some(transfer.stop()); + } + } +} + +#[embassy_executor::task] +pub async fn mic_task(mut rx: I2sRx<'static, Async>) { + loop { + let wanted = START.wait().await; + let (result, idle) = capture(rx, wanted).await; + rx = idle; DONE.signal(result); } } + +async fn capture(rx: I2sRx<'static, Async>, wanted: usize) -> (Result, &'static str>, I2sRx<'static, Async>) { + // The capture lives on the heap, which is PSRAM first (main.rs). + let mut capture = Vec::new(); + if capture.try_reserve_exact(wanted).is_err() { + return (Err("MEMORY"), rx); + } + let Some(mut ring) = Ring::new() else { return (Err("MEMORY"), rx) }; + // SAFETY: the buffer is dropped below, before `ring`. + let Some(buffer) = (unsafe { ring.buffer() }) else { return (Err("MEMORY"), rx) }; + let mut transfer = match rx.read(buffer) { + Ok(transfer) => transfer, + Err((_, rx, buffer)) => { + drop(buffer); + return (Err("I2S"), rx); + } + }; + let mut last_data = Instant::now(); + let result = loop { + while capture.len() < wanted { + let available = transfer.peek(); + if available.is_empty() { + break; + } + let take = available.len().min(wanted - capture.len()); + capture.extend_from_slice(&available[..take]); + transfer.consume(take); + last_data = Instant::now(); + } + if capture.len() >= wanted { + break Ok(capture); + } + if last_data.elapsed().as_millis() > STALL_MS { + break Err("STALLED"); + } + Timer::after_millis(5).await; + }; + let (rx, buffer) = transfer.stop(); + drop(buffer); + drop(ring); + (result, rx) +} From ba9d298db0588fb82d9ebee0f0ca73355b49bf13 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 15:30:38 +0800 Subject: [PATCH 08/51] Start the resident app-server with no tools but ours The resident session's codex app-server now runs with the user's plugins, apps, MCP servers and Codex's own tool features (shell, exec, sub-agents, web search, browser, computer use and more) switched off through -c overrides. MCP servers are turned off one by one, named by 'codex mcp list --json', since an override can't empty mcp_servers. The user's AGENTS.md and hooks stay. A thread that can't be resumed is replaced, and the log now names both the old and the new thread id. The docs gain a privacy note on list_activities and say the box should speak a reply as soon as speak arrives. Co-Authored-By: Claude Opus 5.5 --- daemon/src/main.rs | 2 +- daemon/src/resident.rs | 106 +++++++++++++++++++++++++++++++++++++-- docs/resident-session.md | 11 ++-- 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 3117d21b..fa026a66 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -1485,7 +1485,7 @@ mod tests { #[tokio::test] async fn the_resident_sessions_own_hooks_make_no_card_announcement_or_stats() { - let dir = std::env::temp_dir().join(format!("vibebuddy-resident-hooks-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!("vibebuddy-main-resident-hooks-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let transport = Arc::new(RecordingTransport::default()); let mut state = test_state(transport.clone()); diff --git a/daemon/src/resident.rs b/daemon/src/resident.rs index f3f4ad35..8d4b9b60 100644 --- a/daemon/src/resident.rs +++ b/daemon/src/resident.rs @@ -211,8 +211,9 @@ impl Resident { let thread_id = match self.thread_id() { Some(saved) => match self.resume(&client, &saved).await { Err(ResidentError::Rpc(message)) if !client.is_closed() => { - warn!(thread = %saved, %message, "the resident thread could not be resumed; starting a new one"); - self.start_thread(&client).await? + let thread = self.start_thread(&client).await?; + warn!(old_thread = %saved, new_thread = %thread, %message, "the resident thread could not be resumed; started a new one"); + thread } other => other?, }, @@ -434,20 +435,94 @@ async fn spawn_codex() -> Result<(Pipes, Option), ResidentError> { "the resident session was tested with another Codex version; app-server is experimental and may differ" ), } + let base = tool_overrides(); + let servers = enabled_mcp_servers(&program, &base).await; let mut child = tokio::process::Command::new(&program) - .arg("app-server") + .args(app_server_args(&base, &servers)) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .kill_on_drop(true) .spawn() .map_err(|error| ResidentError::Rpc(format!("cannot start {}: {error}", program.display())))?; - info!(program = %program.display(), "started codex app-server for the resident session"); + info!(program = %program.display(), mcp_servers_off = servers.len(), "started codex app-server for the resident session"); let reader = child.stdout.take().expect("stdout is piped"); let writer = child.stdin.take().expect("stdin is piped"); Ok((Pipes { reader: Box::new(reader), writer: Box::new(writer), child: Some(child) }, version)) } +/// Features that give a Codex thread tools of its own: the resident session gets none of them, only ours. Plugins +/// and apps (connectors) bring tools and MCP servers; the rest are built-in tools. Set as `-c features.=false` +/// rather than `--disable`, which refuses a name another Codex version doesn't know. The user's hooks and +/// AGENTS.md stay: the hooks are filtered, and AGENTS.md only shapes how it talks. +const FEATURES_OFF: [&str; 15] = [ + "plugins", + "apps", + "shell_tool", + "unified_exec", + "multi_agent", + "goals", + "memories", + "image_generation", + "view_image", + "browser_use", + "computer_use", + "in_app_browser", + "sleep_tool", + "tool_suggest", + "skill_search", +]; + +/// The config overrides that strip a Codex thread of its own tools, before MCP servers. +fn tool_overrides() -> Vec { + let mut args: Vec = + FEATURES_OFF.iter().flat_map(|feature| ["-c".to_owned(), format!("features.{feature}=false")]).collect(); + args.extend(["-c".to_owned(), "web_search=\"disabled\"".to_owned()]); + args +} + +/// `codex app-server` with the tool overrides and every MCP server the user configured switched off. An override +/// can't empty `mcp_servers` (it merges), so each server is turned off by name. +fn app_server_args(base: &[String], mcp_servers: &[String]) -> Vec { + let mut args = base.to_vec(); + for name in mcp_servers { + args.extend(["-c".to_owned(), format!("mcp_servers.{name}.enabled=false")]); + } + args.push("app-server".to_owned()); + args +} + +/// The MCP servers that would be on, as Codex itself resolves them (`codex mcp list --json`, under the same +/// overrides, so servers that plugins bring are already gone). A name that can't be written as a bare TOML key +/// can't be switched off by an override; it is logged. +async fn enabled_mcp_servers(program: &Path, base: &[String]) -> Vec { + let output = tokio::process::Command::new(program) + .args(base) + .args(["mcp", "list", "--json"]) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await; + let listed = output.ok().and_then(|output| serde_json::from_slice::(&output.stdout).ok()); + let Some(servers) = listed.as_ref().and_then(Value::as_array) else { + warn!("could not list the user's MCP servers; the resident session may see their tools"); + return Vec::new(); + }; + servers + .iter() + .filter(|server| server.get("enabled").and_then(Value::as_bool) != Some(false)) + .filter_map(|server| server.get("name").and_then(Value::as_str)) + .filter(|name| { + let bare = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + if !bare { + warn!(server = %name, "an MCP server name that can't be overridden; the resident session may see its tools"); + } + bare + }) + .map(str::to_owned) + .collect() +} + /// `VIBEBUDDY_CODEX`, else `codex` on PATH, else where installers put it: the daemon may run with launchd's bare PATH. fn find_codex() -> Option { if let Some(path) = std::env::var_os(CODEX_VARIABLE) { @@ -759,6 +834,29 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn the_app_server_starts_without_plugins_apps_built_in_tools_or_the_users_mcp_servers() { + let args = app_server_args(&tool_overrides(), &["node_repl".to_owned(), "computer-use".to_owned()]); + assert_eq!(args.last().map(String::as_str), Some("app-server")); + let overrides: Vec<&str> = + args.chunks(2).filter(|pair| pair[0] == "-c").map(|pair| pair[1].as_str()).collect(); + for expected in [ + "features.plugins=false", + "features.apps=false", + "features.shell_tool=false", + "features.unified_exec=false", + "features.multi_agent=false", + "web_search=\"disabled\"", + "mcp_servers.node_repl.enabled=false", + "mcp_servers.computer-use.enabled=false", + ] { + assert!(overrides.contains(&expected), "missing {expected}"); + } + // Hooks stay on: the daemon filters them. + assert!(!args.iter().any(|arg| arg.contains("hooks"))); + assert!(!args.iter().any(|arg| arg == "--disable")); + } + #[test] fn hooks_from_the_resident_thread_or_its_directory_are_recognised() { let dir = temp_dir("hooks"); diff --git a/docs/resident-session.md b/docs/resident-session.md index b293c2db..4bc46e1a 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -23,7 +23,8 @@ vibebuddy resident # prints codex://threads/, to op `daemon/src/app_server.rs` is a newline-delimited JSON-RPC client for `codex app-server` on stdio; `daemon/src/resident.rs` is the session on top of it. - **Starting.** On the first voice command, and on the next one after the app-server dies, the daemon runs the `codex` it finds (`VIBEBUDDY_CODEX`, else PATH, else `~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, since launchd's PATH is bare). It sends `initialize` (`experimentalApi: true`) and `initialized`, then `account/read`: with no account it stops with "not logged in". It never starts a login. -- **One thread for good.** `thread/start` once, with `cwd` set to `resident/` in the daemon's state directory, `sandbox: "read-only"`, `approvalPolicy: "on-request"`, the developer instructions and the dynamic tools. The thread id is kept in `resident.json` beside it, and every later start sends `thread/resume` with the same settings (the tools survive a resume; the instructions are sent again). If the thread can't be resumed, a new one is started and saved. +- **One thread for good.** `thread/start` once, with `cwd` set to `resident/` in the daemon's state directory, `sandbox: "read-only"`, `approvalPolicy: "on-request"`, the developer instructions and the dynamic tools. The thread id is kept in `resident.json` beside it, and every later start sends `thread/resume` with the same settings (the tools survive a resume; the instructions are sent again). If the thread can't be resumed, a new one is started and saved, and the log names both the old and the new thread id. +- **Only our tools.** The app-server is started with the user's own Codex config, minus everything that gives a thread tools of its own: `-c features.=false` for plugins, apps (connectors), the shell and exec tools, sub-agents, goals, memories, image generation and viewing, the browser and computer use, sleep, tool suggestions and skill search, plus `web_search="disabled"`. An override can't empty `mcp_servers` (it merges), so the daemon first asks `codex mcp list --json`, under the same overrides, which servers would be on, and adds `-c mcp_servers..enabled=false` for each. Overrides are `-c` rather than `--disable`, which refuses a feature name another Codex version doesn't know. The user's global `AGENTS.md` and hooks stay: the hooks are filtered (below). Checked on 0.162.0: `mcpServerStatus/list` goes from seven servers (one with 365 app tools) to two, both off with no tools, and the thread's tool list is `speak`, `list_activities` and what Codex always adds and has no switch for: `apply_patch` (refused by the read-only sandbox, its approval declined), a clock, and the MCP resource readers (no server is on to read from). - **Tools.** `speak({text})` is the spoken reply: the daemon collects it and hands it back to the caller (later it goes to the box). `list_activities({})` returns the Activities the aggregator already holds, as JSON: agent, task-card title, project, `working`/`needs_input`, host, seconds in that state and since the last event. There are no tools that run commands or write files. - **Declining.** Every approval or question the session raises is declined: command and file-change approvals (`decline`, or `denied` on the legacy methods), permission requests (nothing granted), MCP elicitations (`decline`) and user-input requests (no answers). `currentTime/read` is answered; anything else gets a JSON-RPC "method not found". - **Turns.** `turn/start` with the text, then the daemon answers tool calls until `turn/completed` for that turn. One turn at a time: a command that arrives during another waits for it, in order, rather than being rejected, since two quick sentences are both meant to be answered. A turn that takes over two minutes is interrupted (`turn/interrupt`). @@ -37,8 +38,10 @@ The user's Codex hooks fire for the resident thread too: its `UserPromptSubmit`, app-server and `dynamicTools` are experimental. This was built and checked against codex-cli 0.162.0 (`TESTED_CODEX_VERSION`); another version logs a warning and is still tried. The version last used is written to `resident.json`. +## Privacy + +`list_activities` sends the task-card titles and project names the daemon holds, with each Activity's agent, state, host app and ages, to the model provider the user's Codex uses, as part of the resident thread. Until now these stayed on the computer and the box. Prompts and replies are not sent: the daemon never holds them. + ## Known gaps -- The thread loads the user's own Codex config: their global `AGENTS.md`, plugins, MCP servers and hooks. Read-only and declined approvals keep it from running commands or editing files, but an MCP tool that needs no approval is not blocked. -- A reply is returned when the turn completes, a few seconds after `speak` was called; for the box it should go out the moment `speak` arrives. -- `list_activities` sends task-card titles and project names to the model provider the user's Codex uses. +- A reply is returned when the turn completes, which in testing was 4 to 6 seconds after `speak` was called. The box should play a spoken reply the moment `speak` arrives, not at `turn/completed`. From 266fe185b07b3b1be892cca2bad23dcb5983e202 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 15:35:59 +0800 Subject: [PATCH 09/51] Capture the mic linearly into PSRAM, and report why when it fails On the box the first capture ended in MIC ERROR STALLED with nothing to say why. The 12 KB ring of four descriptors had to be relinked by the mic task before the DMA reached its end, and the task only runs when the main loop yields; once the DMA hits the end of the chain it stops for good. The DMA now writes the whole recording into one PSRAM buffer (esp-hal's DmaRxBuf handles the cache), so nothing depends on the main loop. Its descriptors (~1 KB for 5 s) come from the internal heap for the capture. Each capture also prints MIC DEBUG lines at 100 ms and at the end: descriptors filled, I2S0 and GDMA channel 1 registers, and how often GPIO47/21/13 changed level, so the next flash says where it stops. Main stack: 47,472 bytes (main: 47,784). Co-Authored-By: Claude Opus 5.5 --- docs/firmware-bringup.md | 14 +- firmware-rs/device/src/main.rs | 4 + firmware-rs/device/src/mic.rs | 234 +++++++++++++++++++++------------ 3 files changed, 168 insertions(+), 84 deletions(-) diff --git a/docs/firmware-bringup.md b/docs/firmware-bringup.md index cb23e800..2c57d846 100644 --- a/docs/firmware-bringup.md +++ b/docs/firmware-bringup.md @@ -139,7 +139,18 @@ MIC DATA 576 bytes of samples per line MIC END 288000 ``` -`MIC UNAVAILABLE` means the board has no input; `MIC BUSY`, that a recording is still going out; `MIC ERROR STALLED`, that no samples came for half a second (the receive DMA overflowed, or nothing clocks the receiver); `MIC ERROR MEMORY`, that the capture (PSRAM) or the 12 KB receive ring (internal RAM, taken only while recording) didn't fit. +`MIC UNAVAILABLE` means the board has no input; `MIC BUSY`, that a recording is still going out; `MIC ERROR STALLED`, that the DMA hadn't filled the buffer a second after the recording should have ended; `MIC ERROR MEMORY`, that the capture (PSRAM) or its DMA descriptors (internal RAM, taken only while recording) didn't fit. + +The DMA writes the whole recording straight into one buffer in PSRAM, so nothing depends on the main loop keeping up. Each recording also prints two `MIC DEBUG` lines, 100 ms in (`T100`) and at the end (`END` or `TIMEOUT`), for when it fails: + +``` +MIC DEBUG T100 DESC 2/71 I2S RXCONF … TXCONF … INTRAW … RXCLKM … RXEOF … TXSTATE … DMA ININT … INFIFO … INLINK … INSTATE … INDSCR … INPERI … OUTSTATE … OUTPERI … PINS DIN 312 BCLK 0 WS 0 +``` + +- `DESC done/total`: DMA descriptors (about 4 KB each) filled so far. At `T100` a working receiver has filled two (96 KB/s); 0 means no sample reached memory. +- `I2S …`: I2S0's RX_CONF (0x20), TX_CONF (0x24), INT_RAW (0x0C), RX_CLKM_CONF (0x30), RXEOF_NUM (0x64), TX status (0x6C). RX_CONF bit 2 is RX_START and bit 3 RX_SLAVE_MOD; TX_CONF bit 2 is TX_START and bit 27 SIG_LOOPBACK (field offsets from the esp32s3 0.37 PAC). +- `DMA …`: GDMA channel 1's IN_INT_RAW, INFIFO_STATUS, IN_LINK, IN_STATE, IN_DSCR (the descriptor it is on), IN_PERI_SEL (I2S0 is 3), and OUT_STATE and OUT_PERI_SEL for the speaker's side of the same channel. +- `PINS`: level changes over 2000 quick reads of GPIO47 (DIN), GPIO21 (BCLK) and GPIO13 (WS). DIN is an input, so 0 there means the data line doesn't move (or the codec sends silence). The clocks are outputs and may read 0 even when running. ### Reading the result @@ -150,7 +161,6 @@ MIC END 288000 | Both slots stuck at one value, or `SATURATED` with no sound | GPIO47 is floating or isn't the codec's data line | | Left `FLAT` with a level above zero | The ADC runs but the microphone isn't reaching it: try `--gain-db 42` and noise right at the mic hole; then suspect the analog path (MIC bias, which input the ES8311 selects in register 0x14) | | `LIVE` on the right, not the left | The slots are the other way round from esp_codec_dev's 0x44 setting | -| `MIC CAPTURED` far longer than asked | Samples were lost on the way in; the receive ring (125 ms) is too short for something the main loop did | | Any `MIC PROBE 0x4x ID …` | This box has a chip where an ES7210 would be: it differs from the scanned boxes, so stop and look before trusting the ES8311 path | Boxes of this board differ (some have a touchscreen, some don't), so run it on each box you care about and say which box in the result. diff --git a/firmware-rs/device/src/main.rs b/firmware-rs/device/src/main.rs index 1d8f9abd..28f95471 100644 --- a/firmware-rs/device/src/main.rs +++ b/firmware-rs/device/src/main.rs @@ -306,6 +306,10 @@ impl Board for DeviceBoard { } fn mic_take(&mut self) -> Option, &'static str>> { + while let Ok(note) = mic::NOTES.try_receive() { + self.write(note.as_bytes()); + self.write(b"\n"); + } mic::DONE.try_take() } } diff --git a/firmware-rs/device/src/mic.rs b/firmware-rs/device/src/mic.rs index 410712d2..ee200b1b 100644 --- a/firmware-rs/device/src/mic.rs +++ b/firmware-rs/device/src/mic.rs @@ -1,92 +1,128 @@ //! Microphone capture for the bring-up diagnostic (`device.mic.record`, firmware-core's `mic`): I2S0's //! receiver, clocked by the transmitter's BCLK and WS (signal loopback, as ESP-IDF does for full duplex), -//! reads GPIO47 into a DMA ring only while a capture is asked for, and the samples are copied into PSRAM -//! until there are enough. +//! reads GPIO47 by DMA straight into one linear buffer in PSRAM, as long as the whole capture. //! -//! The ring is taken from the internal heap for the capture and given back after, not a static: every -//! static in internal RAM comes out of the main task's stack, which is whatever DRAM the statics leave -//! (LESSONS.md). A 24 KB static ring cost the stack 24 KB and the box boot-looped on a stack overflow. +//! Nothing here is a static in internal RAM beyond the task itself: every such static comes out of the +//! main task's stack (LESSONS.md). The descriptors (12 bytes per 4 KB) come from the internal heap for the +//! capture and go back after. +//! +//! The first build streamed through a 12 KB ring and on the box never delivered a sample (`STALLED`), with +//! nothing saying why. So each capture also reports `MIC DEBUG` lines: the I2S and DMA registers and the +//! level changes seen on the pins 100 ms in and at the end, which the next flash can be read against. +use alloc::string::String; use alloc::vec::Vec; use core::alloc::{GlobalAlloc, Layout}; +use core::fmt::Write; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::Channel; use embassy_sync::signal::Signal; use embassy_time::{Instant, Timer}; use esp_alloc::{HEAP, MemoryCapability}; use esp_hal::Async; use esp_hal::dma::aligned::DmaAlignedMut; -use esp_hal::dma::{DmaDescriptor, DmaRxStreamBuf}; +use esp_hal::dma::{DmaDescriptor, DmaRxBuf}; use esp_hal::i2s::master::I2sRx; -/// 24 kHz × stereo × 16 bit = 96 KB/s, so 12 KB is 125 ms: how long the main loop may hold the executor -/// before samples are lost (the capture then ends with `STALLED`). -const RING_BYTES: usize = 12000; -/// esp-hal's stream buffer wants at least four descriptors; 3000 bytes each. -const RING_DESCRIPTORS: usize = 4; - -/// No sample for this long means the DMA stopped (its ring overflowed) or the clock isn't running. -const STALL_MS: u64 = 500; +/// PSRAM receive buffers must be aligned to the external burst size (16 bytes by default); 64 covers any. +const ALIGN: usize = 64; +/// Bytes one descriptor covers at least (esp-hal uses 4096 minus the burst alignment); spares go unused. +const DESCRIPTOR_BYTES: usize = 4096 - ALIGN; +/// The capture takes its own length plus this long before it counts as stalled. +const SLACK_MS: u64 = 1000; /// How many bytes to capture. pub static START: Signal = Signal::new(); /// The capture, or why it stopped short. pub static DONE: Signal, &'static str>> = Signal::new(); +/// `MIC DEBUG` lines for the main loop to print. +pub static NOTES: Channel = Channel::new(); -/// The DMA ring and its descriptors, in internal RAM (the stream buffer doesn't keep PSRAM coherent). -struct Ring { - descriptors: *mut DmaDescriptor, - bytes: *mut u8, +/// A block from the heap with the given capabilities, freed on drop. +struct Block { + pointer: *mut u8, + layout: Layout, } -impl Ring { - fn descriptors_layout() -> Layout { - Layout::array::(RING_DESCRIPTORS).unwrap() - } - - fn bytes_layout() -> Layout { - Layout::from_size_align(RING_BYTES, 4).unwrap() - } - - fn new() -> Option { - // SAFETY: both layouts are non-zero, and each block is initialized before any reference is made to it. - unsafe { - let descriptors = HEAP.alloc_caps(MemoryCapability::Internal.into(), Self::descriptors_layout()) as *mut DmaDescriptor; - let bytes = HEAP.alloc_caps(MemoryCapability::Internal.into(), Self::bytes_layout()); - let ring = Ring { descriptors, bytes }; - if descriptors.is_null() || bytes.is_null() { - return None; - } - for index in 0..RING_DESCRIPTORS { - descriptors.add(index).write(DmaDescriptor::EMPTY); - } - bytes.write_bytes(0, RING_BYTES); - Some(ring) +impl Block { + fn new(capability: MemoryCapability, size: usize) -> Option { + let layout = Layout::from_size_align(size, ALIGN).ok()?; + // SAFETY: size is non-zero (callers ask for at least one descriptor or frame). + let pointer = unsafe { HEAP.alloc_caps(capability.into(), layout) }; + if pointer.is_null() { + return None; } + // SAFETY: just allocated with this size; zeroed before any reference to it is made. + unsafe { pointer.write_bytes(0, size) }; + Some(Self { pointer, layout }) } +} - /// SAFETY: the buffer made here must be dropped before the ring is. - unsafe fn buffer(&mut self) -> Option { - // SAFETY: the blocks are ours alone and outlive the buffer (the caller's promise). - let (descriptors, bytes) = unsafe { - (core::slice::from_raw_parts_mut(self.descriptors, RING_DESCRIPTORS), core::slice::from_raw_parts_mut(self.bytes, RING_BYTES)) - }; - DmaRxStreamBuf::new(DmaAlignedMut::new(descriptors).ok()?, DmaAlignedMut::new(bytes).ok()?).ok() +impl Drop for Block { + fn drop(&mut self) { + // SAFETY: allocated in `new` with this layout. + unsafe { HEAP.dealloc(self.pointer, self.layout) }; } } -impl Drop for Ring { - fn drop(&mut self) { - // SAFETY: allocated by `new` with these layouts; dealloc ignores null. - unsafe { - if !self.descriptors.is_null() { - HEAP.dealloc(self.descriptors as *mut u8, Self::descriptors_layout()); - } - if !self.bytes.is_null() { - HEAP.dealloc(self.bytes, Self::bytes_layout()); - } +/// Reads a 32-bit peripheral register. +fn register(address: u32) -> u32 { + // SAFETY: a read of a fixed ESP32-S3 peripheral address, with no side effects for these registers. + unsafe { core::ptr::read_volatile(address as *const u32) } +} + +const I2S0: u32 = 0x6000_F000; +/// GDMA channel 1 (I2S0's DMA_CH1 in main.rs); channels are 0xC0 apart. +const GDMA_CH1: u32 = 0x6003_F000 + 0xC0; +const GPIO_IN: u32 = 0x6000_403C; +const GPIO_IN1: u32 = 0x6000_4040; + +/// How often GPIO47 (DIN), GPIO21 (BCLK) and GPIO13 (WS) changed level over 2000 quick reads. The two +/// clocks are outputs and may read 0 if their input path is off; DIN is an input and reads true. +fn pin_activity() -> [u32; 3] { + let sample = || [register(GPIO_IN1) >> 15 & 1, register(GPIO_IN) >> 21 & 1, register(GPIO_IN) >> 13 & 1]; + let mut last = sample(); + let mut changes = [0u32; 3]; + for _ in 0..2000 { + let now = sample(); + for pin in 0..3 { + changes[pin] += (now[pin] != last[pin]) as u32; } + last = now; } + changes +} + +/// One line of the receive path's state: descriptors done so far, then I2S0 RX_CONF, TX_CONF, INT_RAW, +/// RX_CLKM_CONF, RXEOF_NUM, TX state, and GDMA channel 1's IN_INT_RAW, INFIFO_STATUS, IN_LINK, IN_STATE, +/// IN_DSCR, IN_PERI_SEL, OUT_STATE, OUT_PERI_SEL, then the pin level changes. +fn note(stage: &str, descriptors: *const DmaDescriptor, count: usize) { + // SAFETY: the descriptors live for the capture; the DMA may write them, so read them volatile. + let done = (0..count).filter(|&index| unsafe { !core::ptr::read_volatile(descriptors.add(index)).flags.owner() }).count(); + let [din, bclk, ws] = pin_activity(); + let mut line = String::new(); + let _ = write!( + line, + "MIC DEBUG {stage} DESC {done}/{count} I2S RXCONF {:08X} TXCONF {:08X} INTRAW {:08X} RXCLKM {:08X} RXEOF {:08X} TXSTATE {:08X} \ + DMA ININT {:08X} INFIFO {:08X} INLINK {:08X} INSTATE {:08X} INDSCR {:08X} INPERI {:08X} OUTSTATE {:08X} OUTPERI {:08X} \ + PINS DIN {din} BCLK {bclk} WS {ws}", + register(I2S0 + 0x20), + register(I2S0 + 0x24), + register(I2S0 + 0x0C), + register(I2S0 + 0x30), + register(I2S0 + 0x64), + register(I2S0 + 0x6C), + register(GDMA_CH1 + 0x08), + register(GDMA_CH1 + 0x18), + register(GDMA_CH1 + 0x20), + register(GDMA_CH1 + 0x24), + register(GDMA_CH1 + 0x30), + register(GDMA_CH1 + 0x48), + register(GDMA_CH1 + 0x84), + register(GDMA_CH1 + 0xA8), + ); + let _ = NOTES.try_send(line); } #[embassy_executor::task] @@ -100,43 +136,77 @@ pub async fn mic_task(mut rx: I2sRx<'static, Async>) { } async fn capture(rx: I2sRx<'static, Async>, wanted: usize) -> (Result, &'static str>, I2sRx<'static, Async>) { - // The capture lives on the heap, which is PSRAM first (main.rs). - let mut capture = Vec::new(); - if capture.try_reserve_exact(wanted).is_err() { + let size = wanted.next_multiple_of(ALIGN); + let count = size.div_ceil(DESCRIPTOR_BYTES); + let (Some(data), Some(descriptors)) = (Block::new(MemoryCapability::External, size), Block::new(MemoryCapability::Internal, count * size_of::())) + else { return (Err("MEMORY"), rx); + }; + let first = descriptors.pointer as *mut DmaDescriptor; + // SAFETY: both blocks are ours alone, zeroed (a zeroed descriptor is `DmaDescriptor::EMPTY`), and outlive + // the buffer, which is dropped below before them. + let buffer = unsafe { + let descriptor_slice = core::slice::from_raw_parts_mut(first, count); + let data_slice = core::slice::from_raw_parts_mut(data.pointer, size); + match (DmaAlignedMut::new(descriptor_slice), DmaAlignedMut::new(data_slice)) { + (Ok(descriptor_slice), Ok(data_slice)) => DmaRxBuf::new(descriptor_slice, data_slice).ok(), + _ => None, + } + }; + let Some(buffer) = buffer else { return (Err("DMA BUFFER"), rx) }; + // The descriptors esp-hal linked, and the last of them: when the DMA hands that one back, it's full. + let (mut linked, mut last) = (1, first); + // SAFETY: esp-hal just linked these within the block; the DMA hasn't started. + while let Some(next) = unsafe { (*last).next.as_mut() } { + last = next as *mut DmaDescriptor; + linked += 1; } - let Some(mut ring) = Ring::new() else { return (Err("MEMORY"), rx) }; - // SAFETY: the buffer is dropped below, before `ring`. - let Some(buffer) = (unsafe { ring.buffer() }) else { return (Err("MEMORY"), rx) }; - let mut transfer = match rx.read(buffer) { + let transfer = match rx.read(buffer) { Ok(transfer) => transfer, Err((_, rx, buffer)) => { drop(buffer); return (Err("I2S"), rx); } }; - let mut last_data = Instant::now(); - let result = loop { - while capture.len() < wanted { - let available = transfer.peek(); - if available.is_empty() { - break; - } - let take = available.len().min(wanted - capture.len()); - capture.extend_from_slice(&available[..take]); - transfer.consume(take); - last_data = Instant::now(); + + let started = Instant::now(); + let deadline = (wanted / 96) as u64 + SLACK_MS; + let mut noted = false; + let finished = loop { + Timer::after_millis(20).await; + if !noted && started.elapsed().as_millis() >= 100 { + note("T100", first, linked); + noted = true; } - if capture.len() >= wanted { - break Ok(capture); + // SAFETY: as in `note`. + if unsafe { !core::ptr::read_volatile(last).flags.owner() } { + break true; } - if last_data.elapsed().as_millis() > STALL_MS { - break Err("STALLED"); + if started.elapsed().as_millis() > deadline { + break false; } - Timer::after_millis(5).await; }; + note(if finished { "END" } else { "TIMEOUT" }, first, linked); let (rx, buffer) = transfer.stop(); + let result = if finished { + let mut capture = Vec::new(); + if capture.try_reserve_exact(wanted).is_ok() { + // The buffer's own read invalidates the PSRAM cache for what the DMA wrote. + let mut received = 0; + for piece in buffer.received_data() { + let take = piece.len().min(wanted - received); + capture.extend_from_slice(&piece[..take]); + received += take; + } + if capture.len() == wanted { Ok(capture) } else { Err("SHORT") } + } else { + Err("MEMORY") + } + } else { + Err("STALLED") + }; drop(buffer); - drop(ring); + drop(descriptors); + drop(data); (result, rx) } From d5fcabc2b5ba6e2ce64eace46fe828182f96fc99 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 17:08:13 +0800 Subject: [PATCH 10/51] Count the mic capture by descriptor lengths, not buffer size On the box all 71 descriptors filled and the capture still came back MIC ERROR SHORT. The I2S receiver signals an EOF every 4095 samples (esp-hal leaves RXEOF_NUM at its 12-bit maximum, 8190 bytes), and each EOF closes the descriptor it lands in, so about every third 4080-byte descriptor holds 30 bytes and the buffer held about two thirds of the samples. GDMA IN_INT_RAW 0x13 (suc_eof set) fits this. The buffer is now 1.5x the capture plus two descriptors, and the capture ends once the handed-back descriptors hold enough bytes. MIC DEBUG adds the byte count and the first six descriptor lengths, so the next flash shows whether the 4080 4080 30 pattern is the cause. The BCLK/WS level counts are gone: those pins are outputs with the input path off, so the input register reads 0 however fast they run. Co-Authored-By: Claude Opus 5.5 --- docs/firmware-bringup.md | 11 ++--- firmware-rs/device/src/mic.rs | 75 +++++++++++++++++++++++------------ 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/docs/firmware-bringup.md b/docs/firmware-bringup.md index 2c57d846..97699453 100644 --- a/docs/firmware-bringup.md +++ b/docs/firmware-bringup.md @@ -139,18 +139,19 @@ MIC DATA 576 bytes of samples per line MIC END 288000 ``` -`MIC UNAVAILABLE` means the board has no input; `MIC BUSY`, that a recording is still going out; `MIC ERROR STALLED`, that the DMA hadn't filled the buffer a second after the recording should have ended; `MIC ERROR MEMORY`, that the capture (PSRAM) or its DMA descriptors (internal RAM, taken only while recording) didn't fit. +`MIC UNAVAILABLE` means the board has no input; `MIC BUSY`, that a recording is still going out; `MIC ERROR STALLED`, that the DMA hadn't handed back enough samples a second after the recording should have ended; `MIC ERROR SHORT`, that the buffer filled up with fewer samples than asked for; `MIC ERROR MEMORY`, that the capture (PSRAM) or its DMA descriptors (internal RAM, taken only while recording) didn't fit. -The DMA writes the whole recording straight into one buffer in PSRAM, so nothing depends on the main loop keeping up. Each recording also prints two `MIC DEBUG` lines, 100 ms in (`T100`) and at the end (`END` or `TIMEOUT`), for when it fails: +The DMA writes the whole recording straight into one buffer in PSRAM, so nothing depends on the main loop keeping up. The buffer is half as big again as the recording: the I2S receiver signals an EOF every 8,190 bytes (esp-hal leaves RXEOF_NUM at its 12-bit maximum, 4,095 samples), and each EOF closes the DMA descriptor it lands in, so about every third descriptor holds only a few bytes. Each descriptor's length says how many it holds, and the samples stay in order. Each recording also prints two `MIC DEBUG` lines, 100 ms in (`T100`) and at the end (`END` or `TIMEOUT`), for when it fails: ``` -MIC DEBUG T100 DESC 2/71 I2S RXCONF … TXCONF … INTRAW … RXCLKM … RXEOF … TXSTATE … DMA ININT … INFIFO … INLINK … INSTATE … INDSCR … INPERI … OUTSTATE … OUTPERI … PINS DIN 312 BCLK 0 WS 0 +MIC DEBUG T100 DESC 3/108 BYTES 8190 LENS 4080 4080 30 0 0 0 I2S RXCONF … TXCONF … INTRAW … RXCLKM … RXEOF … TXSTATE … DMA ININT … INFIFO … INLINK … INSTATE … INDSCR … INPERI … OUTSTATE … OUTPERI … DIN 200 ``` -- `DESC done/total`: DMA descriptors (about 4 KB each) filled so far. At `T100` a working receiver has filled two (96 KB/s); 0 means no sample reached memory. +- `DESC done/total` and `BYTES`: DMA descriptors (4,080 bytes each) handed back so far, and the sample bytes in them; at `T100` up to 9,600 (96 KB/s), counted only for descriptors already handed back. `DESC 0` means no sample reached memory. +- `LENS`: the first six descriptors' lengths, which show where the EOFs cut them. - `I2S …`: I2S0's RX_CONF (0x20), TX_CONF (0x24), INT_RAW (0x0C), RX_CLKM_CONF (0x30), RXEOF_NUM (0x64), TX status (0x6C). RX_CONF bit 2 is RX_START and bit 3 RX_SLAVE_MOD; TX_CONF bit 2 is TX_START and bit 27 SIG_LOOPBACK (field offsets from the esp32s3 0.37 PAC). - `DMA …`: GDMA channel 1's IN_INT_RAW, INFIFO_STATUS, IN_LINK, IN_STATE, IN_DSCR (the descriptor it is on), IN_PERI_SEL (I2S0 is 3), and OUT_STATE and OUT_PERI_SEL for the speaker's side of the same channel. -- `PINS`: level changes over 2000 quick reads of GPIO47 (DIN), GPIO21 (BCLK) and GPIO13 (WS). DIN is an input, so 0 there means the data line doesn't move (or the codec sends silence). The clocks are outputs and may read 0 even when running. +- `DIN`: level changes over 2000 quick reads of GPIO47. 0 means the data line doesn't move (or the codec sends exact silence). BCLK and WS can't be checked this way: they are outputs with their input path off, so their input register reads 0 however fast they run. ### Reading the result diff --git a/firmware-rs/device/src/mic.rs b/firmware-rs/device/src/mic.rs index ee200b1b..3056977f 100644 --- a/firmware-rs/device/src/mic.rs +++ b/firmware-rs/device/src/mic.rs @@ -6,9 +6,17 @@ //! main task's stack (LESSONS.md). The descriptors (12 bytes per 4 KB) come from the internal heap for the //! capture and go back after. //! -//! The first build streamed through a 12 KB ring and on the box never delivered a sample (`STALLED`), with -//! nothing saying why. So each capture also reports `MIC DEBUG` lines: the I2S and DMA registers and the -//! level changes seen on the pins 100 ms in and at the end, which the next flash can be read against. +//! The I2S receiver ends a DMA descriptor early at every EOF it signals, and esp-hal leaves RXEOF_NUM at +//! its 12-bit maximum: an EOF every 4095 16-bit samples, 8190 bytes, which on 4080-byte descriptors fills +//! two and leaves the third with 30 bytes. So a 288,000-byte buffer held only about two thirds of that in +//! samples when its last descriptor came back (`MIC ERROR SHORT` on the box, 2026-10-13). The samples are still +//! in order, each descriptor's length says how many it holds, so the buffer is 1.5 times the capture plus +//! some, and the capture ends once the descriptors handed back hold enough. +//! +//! A ring that had to be relinked by this task before the DMA reached its end stalled for good on the box +//! (`STALLED`); one linear buffer doesn't depend on the main loop. Each capture also reports `MIC DEBUG` +//! lines 100 ms in and at the end: the descriptors and bytes so far, the first descriptor lengths, the I2S +//! and DMA registers, and how often the data pin changed level. use alloc::string::String; use alloc::vec::Vec; @@ -75,38 +83,54 @@ fn register(address: u32) -> u32 { const I2S0: u32 = 0x6000_F000; /// GDMA channel 1 (I2S0's DMA_CH1 in main.rs); channels are 0xC0 apart. const GDMA_CH1: u32 = 0x6003_F000 + 0xC0; -const GPIO_IN: u32 = 0x6000_403C; const GPIO_IN1: u32 = 0x6000_4040; -/// How often GPIO47 (DIN), GPIO21 (BCLK) and GPIO13 (WS) changed level over 2000 quick reads. The two -/// clocks are outputs and may read 0 if their input path is off; DIN is an input and reads true. -fn pin_activity() -> [u32; 3] { - let sample = || [register(GPIO_IN1) >> 15 & 1, register(GPIO_IN) >> 21 & 1, register(GPIO_IN) >> 13 & 1]; +/// How often GPIO47 (DIN) changed level over 2000 quick reads. Only DIN: BCLK and WS are outputs whose +/// input path is off, so their input register reads a constant 0 however fast they run. +fn din_activity() -> u32 { + let sample = || register(GPIO_IN1) >> 15 & 1; let mut last = sample(); - let mut changes = [0u32; 3]; + let mut changes = 0; for _ in 0..2000 { let now = sample(); - for pin in 0..3 { - changes[pin] += (now[pin] != last[pin]) as u32; - } + changes += (now != last) as u32; last = now; } changes } -/// One line of the receive path's state: descriptors done so far, then I2S0 RX_CONF, TX_CONF, INT_RAW, -/// RX_CLKM_CONF, RXEOF_NUM, TX state, and GDMA channel 1's IN_INT_RAW, INFIFO_STATUS, IN_LINK, IN_STATE, -/// IN_DSCR, IN_PERI_SEL, OUT_STATE, OUT_PERI_SEL, then the pin level changes. -fn note(stage: &str, descriptors: *const DmaDescriptor, count: usize) { - // SAFETY: the descriptors live for the capture; the DMA may write them, so read them volatile. - let done = (0..count).filter(|&index| unsafe { !core::ptr::read_volatile(descriptors.add(index)).flags.owner() }).count(); - let [din, bclk, ws] = pin_activity(); +/// The descriptors the DMA has handed back, in chain order, and the sample bytes they hold. +fn received(first: *const DmaDescriptor, linked: usize) -> (usize, usize) { + let mut bytes = 0; + for index in 0..linked { + // SAFETY: the descriptors live for the capture; the DMA may write them, so read them volatile. + let flags = unsafe { core::ptr::read_volatile(first.add(index)).flags }; + if flags.owner() { + return (index, bytes); + } + bytes += flags.length() as usize; + } + (linked, bytes) +} + +/// One line of the receive path's state: descriptors handed back and the bytes they hold, the lengths of +/// the first six, then I2S0 RX_CONF, TX_CONF, INT_RAW, RX_CLKM_CONF, RXEOF_NUM, TX state, and GDMA channel +/// 1's IN_INT_RAW, INFIFO_STATUS, IN_LINK, IN_STATE, IN_DSCR, IN_PERI_SEL, OUT_STATE, OUT_PERI_SEL, then +/// the data pin's level changes. +fn note(stage: &str, first: *const DmaDescriptor, linked: usize) { + let (done, bytes) = received(first, linked); let mut line = String::new(); + let _ = write!(line, "MIC DEBUG {stage} DESC {done}/{linked} BYTES {bytes} LENS"); + for index in 0..linked.min(6) { + // SAFETY: as in `received`. + let _ = write!(line, " {}", unsafe { core::ptr::read_volatile(first.add(index)).flags.length() }); + } + let din = din_activity(); let _ = write!( line, - "MIC DEBUG {stage} DESC {done}/{count} I2S RXCONF {:08X} TXCONF {:08X} INTRAW {:08X} RXCLKM {:08X} RXEOF {:08X} TXSTATE {:08X} \ + " I2S RXCONF {:08X} TXCONF {:08X} INTRAW {:08X} RXCLKM {:08X} RXEOF {:08X} TXSTATE {:08X} \ DMA ININT {:08X} INFIFO {:08X} INLINK {:08X} INSTATE {:08X} INDSCR {:08X} INPERI {:08X} OUTSTATE {:08X} OUTPERI {:08X} \ - PINS DIN {din} BCLK {bclk} WS {ws}", + DIN {din}", register(I2S0 + 0x20), register(I2S0 + 0x24), register(I2S0 + 0x0C), @@ -136,7 +160,8 @@ pub async fn mic_task(mut rx: I2sRx<'static, Async>) { } async fn capture(rx: I2sRx<'static, Async>, wanted: usize) -> (Result, &'static str>, I2sRx<'static, Async>) { - let size = wanted.next_multiple_of(ALIGN); + // Room for the descriptors an EOF cuts short (see the top of this file). + let size = (wanted * 3 / 2 + 2 * DESCRIPTOR_BYTES).next_multiple_of(ALIGN); let count = size.div_ceil(DESCRIPTOR_BYTES); let (Some(data), Some(descriptors)) = (Block::new(MemoryCapability::External, size), Block::new(MemoryCapability::Internal, count * size_of::())) else { @@ -154,7 +179,7 @@ async fn capture(rx: I2sRx<'static, Async>, wanted: usize) -> (Result, & } }; let Some(buffer) = buffer else { return (Err("DMA BUFFER"), rx) }; - // The descriptors esp-hal linked, and the last of them: when the DMA hands that one back, it's full. + // How many descriptors esp-hal linked. let (mut linked, mut last) = (1, first); // SAFETY: esp-hal just linked these within the block; the DMA hasn't started. while let Some(next) = unsafe { (*last).next.as_mut() } { @@ -178,8 +203,8 @@ async fn capture(rx: I2sRx<'static, Async>, wanted: usize) -> (Result, & note("T100", first, linked); noted = true; } - // SAFETY: as in `note`. - if unsafe { !core::ptr::read_volatile(last).flags.owner() } { + let (done, bytes) = received(first, linked); + if bytes >= wanted || done == linked { break true; } if started.elapsed().as_millis() > deadline { From 0526c0bc735f92ee69e83e39c8058e77b2b03b12 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 17:20:46 +0800 Subject: [PATCH 11/51] Size the mic buffer for an EOF every 4094 bytes On the box the descriptors came back as 4080, 14, 4080, 14: the I2S EOF cuts one every 4094 bytes, so a capture needs about twice its size in descriptors. 2.5 times leaves room. Co-Authored-By: Claude Opus 5.5 --- firmware-rs/device/src/mic.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firmware-rs/device/src/mic.rs b/firmware-rs/device/src/mic.rs index 3056977f..fc61748b 100644 --- a/firmware-rs/device/src/mic.rs +++ b/firmware-rs/device/src/mic.rs @@ -10,7 +10,7 @@ //! its 12-bit maximum: an EOF every 4095 16-bit samples, 8190 bytes, which on 4080-byte descriptors fills //! two and leaves the third with 30 bytes. So a 288,000-byte buffer held only about two thirds of that in //! samples when its last descriptor came back (`MIC ERROR SHORT` on the box, 2026-10-13). The samples are still -//! in order, each descriptor's length says how many it holds, so the buffer is 1.5 times the capture plus +//! in order, each descriptor's length says how many it holds, so the buffer is 2.5 times the capture plus //! some, and the capture ends once the descriptors handed back hold enough. //! //! A ring that had to be relinked by this task before the DMA reached its end stalled for good on the box @@ -161,7 +161,7 @@ pub async fn mic_task(mut rx: I2sRx<'static, Async>) { async fn capture(rx: I2sRx<'static, Async>, wanted: usize) -> (Result, &'static str>, I2sRx<'static, Async>) { // Room for the descriptors an EOF cuts short (see the top of this file). - let size = (wanted * 3 / 2 + 2 * DESCRIPTOR_BYTES).next_multiple_of(ALIGN); + let size = (wanted * 5 / 2 + 2 * DESCRIPTOR_BYTES).next_multiple_of(ALIGN); let count = size.div_ceil(DESCRIPTOR_BYTES); let (Some(data), Some(descriptors)) = (Block::new(MemoryCapability::External, size), Block::new(MemoryCapability::Internal, count * size_of::())) else { From 15f5a6f3ecedb54da39b3cb84fd11b69b82c56a4 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 17:23:30 +0800 Subject: [PATCH 12/51] Record the mic result, explain the 4094-byte EOF, judge the mic slot - docs/hardware.md: box 30:ED:A0:A4:0D:08 recorded 5 s on 2026-10-13. The mic is the ES8311's ADC, samples on GPIO47, left slot; no ES7210; about -31 dBFS for a close voice at 30 dB gain. - device mic.rs and docs: RXEOF_NUM (0xFFE from esp-hal) counts bytes on the ESP32-S3, as ESP-IDF uses it, hence descriptors of 4080, 14, ...; the buffer stays at 2.5x, since lining EOFs up would need a raw register write racing esp-hal's that hasn't been tried on a box. - tools/mic-record.py judges slots from the samples with DC removed and the first 100 ms dropped, needs a window above -70 dBFS to call one LIVE, and bases the overall verdict on the left slot. - LESSONS.md: count received data by descriptor lengths, and make a bring-up diagnostic report its own state from the first build. Co-Authored-By: Claude Opus 5.5 --- LESSONS.md | 6 ++++ docs/firmware-bringup.md | 22 +++++++------ docs/hardware.md | 12 ++++++- firmware-rs/core/src/mic.rs | 7 ++-- firmware-rs/device/src/main.rs | 4 +-- firmware-rs/device/src/mic.rs | 18 +++++++---- tools/mic-record.py | 59 +++++++++++++++++++++++----------- 7 files changed, 88 insertions(+), 40 deletions(-) diff --git a/LESSONS.md b/LESSONS.md index 9b3b1a38..aa4ba9ac 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -322,3 +322,9 @@ Code that parses partitions or otadata at boot either lives in firmware-core wit On 2026-10-13 the first microphone build boot-looped: `Detected a write to the stack guard value on ProCpu`, inside text drawing while the boot loaded the Character's look. With esp-hal the main task's stack is whatever internal DRAM is left between the end of `.bss` and the fixed start of the reclaimed heap (`_stack_end_cpu0` to `_stack_start_cpu0`). The build had added a 24 KB static DMA ring (`dma_rx_stream_buffer!`) and a 2.6 KB task (an async task's locals live in its static pool), and the stack went from 47,784 bytes to 21,032 with nothing in the build saying so. Host tests and the device build both passed. Before a firmware change that adds statics or `dma_*_buffer!` macros goes to a box, compare `_stack_start_cpu0 - _stack_end_cpu0` (`xtensa-esp32s3-elf-nm` on the release ELF) with main's. A buffer only one feature uses for a few seconds belongs on the heap for those seconds (internal capabilities if DMA touches it), not in `.bss` for the life of the box. + +## A full DMA buffer is not a full recording + +The microphone bring-up on 2026-10-13 took four flashes after the stack fix. A four-descriptor ring stalled for good: the task that relinked used descriptors only ran when the main loop yielded, and once the DMA reached the end of the chain it stopped. A linear PSRAM buffer then filled every descriptor and still came back short: the I2S receiver signals an EOF every RXEOF_NUM bytes, each EOF closes the descriptor it lands in, and esp-hal leaves RXEOF_NUM at 0xFFE, so the descriptors came back 4080, 14, 4080, 14. I had read the TRM's formula as counting samples and predicted 4080, 4080, 30; the box showed bytes, as ESP-IDF's driver uses the register. + +Count received data by the descriptors' lengths, never by the buffer's size, and on the ESP32-S3 expect a receive descriptor to be cut short by a peripheral EOF. What made each flash count was the diagnostic reporting its own state (descriptors handed back, the first lengths, the I2S and DMA registers) instead of only pass or fail: the second and third flashes each named the next cause outright. Put that in the first build of any bring-up, since each flash of a shared box costs a turn. diff --git a/docs/firmware-bringup.md b/docs/firmware-bringup.md index 97699453..ecec6fd2 100644 --- a/docs/firmware-bringup.md +++ b/docs/firmware-bringup.md @@ -93,23 +93,23 @@ The review fixes applied before bring-up (see the PR) were in place, so it can't ## Microphone -The box has a microphone, and no firmware of ours has used it yet. `device.mic.record` is a bring-up diagnostic, like the screenshot: it records a few seconds and sends them back over the cable. It isn't a user feature, and neither app uses it. +The box has a microphone, and until 2026-10-13 no firmware of ours had used it. `device.mic.record` is a bring-up diagnostic, like the screenshot: it records a few seconds and sends them back over the cable. It isn't a user feature, and neither app uses it. ### What we know, and from where -No ALIENTEK schematic or BSP for this board (ATK-DNESP32S3-BOX V1.1) could be found; the BOX3's wiki (a different board, with different I2S pins) doesn't count. So each fact below is either already seen on a box, or a candidate the diagnostic settles. +No ALIENTEK schematic or BSP for this board (ATK-DNESP32S3-BOX V1.1) could be found; the BOX3's wiki (a different board, with different I2S pins) doesn't count. So each fact below was either seen on a box or is a candidate the diagnostic settles. The first recording, on box `30:ED:A0:A4:0D:08` on 2026-10-13, settled the microphone path ([`hardware.md`](hardware.md)). | Fact | Value | Source | Confidence | |---|---|---|---| -| Microphone ADC | The ES8311's own mono ADC; no ES7210 | No chip at 0x40–0x43 in the I2C0 scans of two boxes ([`hardware.md`](hardware.md)); xiaozhi 1.9.4's `atk-dnesp32s3-box` uses `Es8311AudioCodec` for both input and output | High that there's no ES7210 on the scanned boxes; the ES8311 ADC is a candidate until a recording shows it | +| Microphone ADC | The ES8311's own mono ADC; no ES7210 | No chip at 0x40–0x43 in the I2C0 scans of two boxes ([`hardware.md`](hardware.md)); xiaozhi 1.9.4's `atk-dnesp32s3-box` uses `Es8311AudioCodec` for both input and output | Verified on `30:ED:A0:A4:0D:08`: no chip at 0x40–0x43, and the recording is the voice | | ES8311 address | 0x18 (7-bit) | Seen on boxes | Verified | | ES7210 addresses checked | 0x40–0x43 (AD1/AD0 straps; esp_codec_dev's `es7210_address_t`) | esp_codec_dev | The diagnostic reports each | -| Microphone data into the ESP32 | GPIO47 (I2S DIN) | xiaozhi 1.9.4 `config.h`, `AUDIO_I2S_GPIO_DIN` | Candidate, from the same board's config; only ever configured as an input | +| Microphone data into the ESP32 | GPIO47 (I2S DIN) | xiaozhi 1.9.4 `config.h`, `AUDIO_I2S_GPIO_DIN` | Verified on `30:ED:A0:A4:0D:08` | | Clocks | Shared with the speaker: BCLK GPIO21, WS GPIO13, no MCLK. The ESP32's receiver runs off its transmitter's clocks (signal loopback), as ESP-IDF's full duplex does | Speaker pins verified on boxes; xiaozhi creates TX and RX on one I2S port with the same pins and `GPIO_NUM_NC` for MCLK | High | | Format and rate | Standard I2S, 16-bit, two slots, 24 kHz. Not 16 kHz: the ES8311 has one LRCK for ADC and DAC, so input runs at the speaker's rate | xiaozhi asserts input rate == output rate (24000) for this codec | High | -| Which slot is the microphone | Left = ADC, right = DAC reference (silent unless the box is speaking) | esp_codec_dev writes ES8311 register 0x44 = 0x58, "ADCL + DACR"; our sequence already does | Medium; the per-slot levels show it | +| Which slot is the microphone | Left = ADC, right = DAC reference (silent unless the box is speaking) | esp_codec_dev writes ES8311 register 0x44 = 0x58, "ADCL + DACR"; our sequence already does | Verified on `30:ED:A0:A4:0D:08`: the voice is on the left; the right stays silent while the box is quiet | | ADC already enabled at boot | Yes: our ES8311 sequence is esp_codec_dev's `ESP_CODEC_DEV_WORK_MODE_BOTH`, the mode the C firmware used, so the ADC is powered and its serial output unmuted (registers 0x0A, 0x0E, 0x14, 0x15, 0x17) | `firmware/main/agent_audio.c`; `core/src/audio.rs` test | Verified in code | -| Microphone gain | Register 0x16, 0–42 dB in 6 dB steps; 30 dB by default (xiaozhi's `AUDIO_CODEC_DEFAULT_MIC_GAIN`) | esp_codec_dev `es8311_set_mic_gain` | High | +| Microphone gain | Register 0x16, 0–42 dB in 6 dB steps; 30 dB by default (xiaozhi's `AUDIO_CODEC_DEFAULT_MIC_GAIN`). At 30 dB a voice close to the box is only about -31 dBFS, so real use needs more | esp_codec_dev `es8311_set_mic_gain`; the recording | High | | Boxes on the NS4168 variant | No codec; xiaozhi reads a digital microphone straight from the same DIN with the same clocks | xiaozhi `ATK_NoAudioCodecDuplex` | Untested; the diagnostic still records | ### Running it @@ -124,6 +124,8 @@ No ALIENTEK schematic or BSP for this board (ATK-DNESP32S3-BOX V1.1) could be fo `--gain-db` sets the microphone gain. The samples come back at about 11 s per recorded second, because the firmware writes the UART at 115200 baud whichever port you use. 3. Listen to `mic.wav`. The left channel should be your voice. +The tool judges each slot from the samples, with the DC offset removed and the first 100 ms dropped: `LIVE` (the level moves at least 6 dB and the loudest 100 ms is above -70 dBFS), `FLAT`, `NO SIGNAL`, `SILENT` (all zeros) or `SATURATED`. Its last line, `microphone on this box: WORKS` or `NOT SHOWN`, rests on the left slot, the microphone; it exits 0 only for `WORKS`. + The box replies, in order: ``` @@ -141,13 +143,13 @@ MIC END 288000 `MIC UNAVAILABLE` means the board has no input; `MIC BUSY`, that a recording is still going out; `MIC ERROR STALLED`, that the DMA hadn't handed back enough samples a second after the recording should have ended; `MIC ERROR SHORT`, that the buffer filled up with fewer samples than asked for; `MIC ERROR MEMORY`, that the capture (PSRAM) or its DMA descriptors (internal RAM, taken only while recording) didn't fit. -The DMA writes the whole recording straight into one buffer in PSRAM, so nothing depends on the main loop keeping up. The buffer is half as big again as the recording: the I2S receiver signals an EOF every 8,190 bytes (esp-hal leaves RXEOF_NUM at its 12-bit maximum, 4,095 samples), and each EOF closes the DMA descriptor it lands in, so about every third descriptor holds only a few bytes. Each descriptor's length says how many it holds, and the samples stay in order. Each recording also prints two `MIC DEBUG` lines, 100 ms in (`T100`) and at the end (`END` or `TIMEOUT`), for when it fails: +The DMA writes the whole recording straight into one buffer in PSRAM, so nothing depends on the main loop keeping up. The buffer is 2.5 times the recording: the I2S receiver signals an EOF every 4,094 bytes, and each EOF closes the DMA descriptor it lands in, so the descriptors come back 4080, 14, 4080, 14, …. esp-hal's `read()` sets RXEOF_NUM to 0xFFE, and on the ESP32-S3 that register counts bytes (as ESP-IDF uses it), not samples as the TRM's formula reads. Each descriptor's length says how many bytes it holds, and the samples stay in order. Lining the EOFs up with the descriptors would mean a raw write to RXEOF_NUM after esp-hal's, racing the first EOF; it hasn't been tried. Each recording also prints two `MIC DEBUG` lines, 100 ms in (`T100`) and at the end (`END` or `TIMEOUT`), for when it fails: ``` -MIC DEBUG T100 DESC 3/108 BYTES 8190 LENS 4080 4080 30 0 0 0 I2S RXCONF … TXCONF … INTRAW … RXCLKM … RXEOF … TXSTATE … DMA ININT … INFIFO … INLINK … INSTATE … INDSCR … INPERI … OUTSTATE … OUTPERI … DIN 200 +MIC DEBUG T100 DESC 4/179 BYTES 8188 LENS 4080 14 4080 14 0 0 I2S RXCONF … TXCONF … INTRAW … RXCLKM … RXEOF … TXSTATE … DMA ININT … INFIFO … INLINK … INSTATE … INDSCR … INPERI … OUTSTATE … OUTPERI … DIN 200 ``` -- `DESC done/total` and `BYTES`: DMA descriptors (4,080 bytes each) handed back so far, and the sample bytes in them; at `T100` up to 9,600 (96 KB/s), counted only for descriptors already handed back. `DESC 0` means no sample reached memory. +- `DESC done/total` and `BYTES`: DMA descriptors (up to 4,080 bytes each) handed back so far, and the sample bytes in them; at `T100` up to 9,600 (96 KB/s), counted only for descriptors already handed back. `DESC 0` means no sample reached memory. - `LENS`: the first six descriptors' lengths, which show where the EOFs cut them. - `I2S …`: I2S0's RX_CONF (0x20), TX_CONF (0x24), INT_RAW (0x0C), RX_CLKM_CONF (0x30), RXEOF_NUM (0x64), TX status (0x6C). RX_CONF bit 2 is RX_START and bit 3 RX_SLAVE_MOD; TX_CONF bit 2 is TX_START and bit 27 SIG_LOOPBACK (field offsets from the esp32s3 0.37 PAC). - `DMA …`: GDMA channel 1's IN_INT_RAW, INFIFO_STATUS, IN_LINK, IN_STATE, IN_DSCR (the descriptor it is on), IN_PERI_SEL (I2S0 is 3), and OUT_STATE and OUT_PERI_SEL for the speaker's side of the same channel. @@ -157,7 +159,7 @@ MIC DEBUG T100 DESC 3/108 BYTES 8190 LENS 4080 4080 30 0 0 0 I2S RXCONF … TXCO | What you see | What it means | |---|---| -| Left `LIVE`, rising when you talk; right silent | The microphone works on this box. Record the box's serial number in [`hardware.md`](hardware.md) | +| Left `LIVE`, rising when you talk; right silent | The microphone works on this box, as on `30:ED:A0:A4:0D:08`. Record the box's serial number in [`hardware.md`](hardware.md) | | Both slots `SILENT` (all zeros) | Nothing drives GPIO47: wrong pin, or the ES8311's ADC output isn't on | | Both slots stuck at one value, or `SATURATED` with no sound | GPIO47 is floating or isn't the codec's data line | | Left `FLAT` with a level above zero | The ADC runs but the microphone isn't reaching it: try `--gain-db 42` and noise right at the mic hole; then suspect the analog path (MIC bias, which input the ES8311 selects in register 0x14) | diff --git a/docs/hardware.md b/docs/hardware.md index 9f7d852a..2e0d55ce 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -69,6 +69,16 @@ A probe firmware, flashed to the app slot only, scanned I2C0 (SDA GPIO48, SCL GP - `0x1E`, `0x50` and `0x6A` were only seen answering; their identities above are guesses from common parts at those addresses, not read from the chips. - Dragon's daily box (`98:88:E0:06:8B:CC`) hasn't been probed. +### Microphone (2026-10-13) + +`device.mic.record` ([`firmware-bringup.md`](firmware-bringup.md#microphone)) recorded 5 s on box `30:ED:A0:A4:0D:08` (the one with the touchscreen): `MIC CAPTURED 5175 MS 480000 BYTES`, CRC correct. + +- The microphone is on the **ES8311's own ADC**. The ES8311 answers at 0x18 with chip ID `8311` (registers 0xFD, 0xFE); nothing answers at 0x40–0x43, so there is **no ES7210** on this box. +- Samples come in on **GPIO47** (I2S DIN), clocked by the speaker's BCLK GPIO21 and WS GPIO13 (24 kHz, 16-bit, two slots), with the ES8311 set up as esp_codec_dev does (register 0x44 = 0x58). +- The **left slot** is the microphone: dragon spoke close to the box and confirmed by ear that the recording is that voice. The right slot is the DAC reference and stays silent while the box doesn't speak (peak 908 raw, about -133 dBFS once the DC offset is removed). +- Levels at the default 30 dB gain, per 250 ms on the left: -31 to -35 dBFS while speaking close, -43 dBFS in a pause. That is quiet; real use needs more gain (up to 42 dB in the ES8311's PGA). +- Other boxes, including dragon's daily box, haven't been recorded. + ## Corroboration from the currently running firmware The hardware runs `xiaozhi` 1.9.4. The source at that pinned version contains an `atk-dnesp32s3-box` board directory that records the ST7789 i80, XL9555, ES8311, and a set of board-level pins. This corroborates the boot log and can be used as a candidate for cross-checking, but that repository is not ALIENTEK's official schematic/BSP and cannot bypass the PCB revision gate to freeze pins directly. @@ -81,5 +91,5 @@ The hardware runs `xiaozhi` 1.9.4. The source at that pinned version contains an - Vendor schematic, BSP, and examples matching the exact PCB revision. - Touch controller wiring (RST, INT) on boxes that have one; whether a box has one is decided by probing `0x2E` (see above). - The chips at `0x1E`, `0x50` and `0x6A`. -- Microphone input chain. Most likely the ES8311's own ADC with samples on GPIO47 (xiaozhi 1.9.4), since no ES7210 answers at 0x40–0x43 on the scanned boxes; `device.mic.record` checks it on a box (see [`firmware-bringup.md`](firmware-bringup.md#microphone)). +- The microphone on other boxes (verified on `30:ED:A0:A4:0D:08` only, see above). - Buzzer GPIO and active level. diff --git a/firmware-rs/core/src/mic.rs b/firmware-rs/core/src/mic.rs index cfd41612..075b10a2 100644 --- a/firmware-rs/core/src/mic.rs +++ b/firmware-rs/core/src/mic.rs @@ -5,8 +5,8 @@ //! I2C (the ES8311 at 0x18, and anything at 0x40-0x43, where an ES7210 would sit), then records whatever //! arrives on the I2S data-in pin. The input shares the speaker's clocks (one LRCK on the ES8311), so it //! runs at the speaker's rate: 24 kHz, 16-bit, two slots. With esp_codec_dev's ES8311 setup (register -//! 0x44 = 0x58, "ADCL + DACR") the left slot should carry the ADC and the right slot the DAC reference; -//! the per-slot levels show whether that holds. +//! 0x44 = 0x58, "ADCL + DACR") the left slot carries the ADC and the right slot the DAC reference, as a +//! recording on one box showed (docs/hardware.md, 2026-10-13); the per-slot levels show it on others. use alloc::string::String; use alloc::vec::Vec; @@ -17,7 +17,8 @@ use crate::audio::{ES8311_ADDRESS, Registers, SAMPLE_RATE}; pub const CHANNELS: u32 = 2; pub const FRAME_BYTES: usize = 4; pub const DEFAULT_MS: u32 = 3000; -/// At most 5 s: 480 KB in PSRAM, and about a minute to send over the UART. +/// At most 5 s: 480 KB of samples (the device's DMA buffer is 2.5 times that, in PSRAM), and about a +/// minute to send over the UART. pub const MAX_MS: u32 = 5000; pub const MIN_MS: u32 = 100; /// xiaozhi's AUDIO_CODEC_DEFAULT_MIC_GAIN for this board. diff --git a/firmware-rs/device/src/main.rs b/firmware-rs/device/src/main.rs index 28f95471..b9ee8e66 100644 --- a/firmware-rs/device/src/main.rs +++ b/firmware-rs/device/src/main.rs @@ -7,8 +7,8 @@ //! codec (0x18, absent on the NS4168 variant). //! - XL9555 P0: bit7 LCD backlight, bit5 amplifier enable, bit4 K1, bit3 K2 (keys active low). //! - K0: BOOT key, GPIO0, active low. -//! - I2S0: BCLK GPIO21, WS GPIO13, DOUT GPIO14, no MCLK. DIN GPIO47 (microphone, not yet verified on a -//! box; only read during the `device.mic.record` diagnostic). +//! - I2S0: BCLK GPIO21, WS GPIO13, DOUT GPIO14, no MCLK. DIN GPIO47 (the ES8311's ADC, left slot; +//! verified on one box, docs/hardware.md; only read during the `device.mic.record` diagnostic). //! - UART0: TX GPIO43, RX GPIO44, to the CH343 bridge; the chip's own USB Serial/JTAG as well. #![no_std] #![no_main] diff --git a/firmware-rs/device/src/mic.rs b/firmware-rs/device/src/mic.rs index fc61748b..05558d74 100644 --- a/firmware-rs/device/src/mic.rs +++ b/firmware-rs/device/src/mic.rs @@ -6,12 +6,18 @@ //! main task's stack (LESSONS.md). The descriptors (12 bytes per 4 KB) come from the internal heap for the //! capture and go back after. //! -//! The I2S receiver ends a DMA descriptor early at every EOF it signals, and esp-hal leaves RXEOF_NUM at -//! its 12-bit maximum: an EOF every 4095 16-bit samples, 8190 bytes, which on 4080-byte descriptors fills -//! two and leaves the third with 30 bytes. So a 288,000-byte buffer held only about two thirds of that in -//! samples when its last descriptor came back (`MIC ERROR SHORT` on the box, 2026-10-13). The samples are still -//! in order, each descriptor's length says how many it holds, so the buffer is 2.5 times the capture plus -//! some, and the capture ends once the descriptors handed back hold enough. +//! The I2S receiver ends a DMA descriptor early at every EOF it signals. esp-hal's `rx_start` writes +//! RXEOF_NUM = 0xFFE (its `usize::MAX - 1` cut to the 12-bit field), and on the box the EOFs came every +//! 4094 bytes: descriptor lengths 4080, 14, 4080, 14, … (2026-10-13). So the ESP32-S3 counts RXEOF_NUM +//! in bytes, as ESP-IDF's driver uses it (it writes the DMA buffer size in bytes), not in samples as the +//! TRM's formula reads. Each 4094 bytes of samples take two 4080-byte descriptors, about twice their size +//! in buffer. The samples stay in order and each descriptor's length says how many it holds, so the +//! buffer is 2.5 times the capture plus two descriptors, and the capture ends once the descriptors +//! handed back hold enough. +//! +//! Setting RXEOF_NUM to 4080 would line the EOFs up with the descriptors and drop the overhead, but +//! esp-hal writes it inside `read()` after resetting the receiver, so changing it means a raw register +//! write racing the first EOF. That hasn't been tried on a box; the PSRAM is cheap (1.2 MB for 5 s). //! //! A ring that had to be relinked by this task before the DMA reached its end stalled for good on the box //! (`STALLED`); one linear buffer doesn't depend on the main loop. Each capture also reports `MIC DEBUG` diff --git a/tools/mic-record.py b/tools/mic-record.py index 7e3b6445..6e63a1dd 100755 --- a/tools/mic-record.py +++ b/tools/mic-record.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Microphone bring-up check: send `device.mic.record` to a box, collect the recording, write a WAV and say -whether each I2S slot carries a live microphone. +whether the microphone works. On this board the microphone is the left slot (the ES8311's ADC); the right +slot is the speaker's reference, silent while the box doesn't speak (docs/hardware.md, 2026-10-13). Needs exclusive use of the serial port, so quit the VibeBuddy app first. The port must be named: the box may share a Mac with other ESP32-S3 devices, which enumerate the same way (LESSONS.md). @@ -25,24 +26,43 @@ import serial FULL_SCALE = 32768 +WINDOW_MS = 100 +# Quieter than this in every window is no signal, whatever the spread: a lone blip over digital silence +# (the right slot on 2026-10-13 went -71 then -120 dBFS) is not a microphone. +SIGNAL_DBFS = -70 def dbfs(value): return -120.0 if value <= 0 else 20 * math.log10(value / FULL_SCALE) -def verdict(levels): - """What one slot's 100 ms levels say about it.""" - rms = [level[0] for level in levels] - peaks = [level[1] for level in levels] +def slot_windows(samples, channels, slot, rate): + """One slot's samples with its DC offset removed, cut into 100 ms windows; the first window is dropped, + since the receiver starts mid-stream and the codec may still be settling.""" + values = [int.from_bytes(samples[index:index + 2], "little", signed=True) for index in range(slot * 2, len(samples), channels * 2)] + offset = sum(values) / max(len(values), 1) + size = rate * WINDOW_MS // 1000 + windows = [values[start:start + size] for start in range(size, len(values) - size + 1, size)] + return offset, [(math.sqrt(sum((value - offset) ** 2 for value in window) / len(window)), max(abs(value) for value in window)) for window in windows] + + +def verdict(windows): + """What one slot's windows (RMS without DC, raw peak) say about it.""" + if not windows: + return "TOO SHORT (record at least 300 ms)" + rms = [window[0] for window in windows] + peaks = [window[1] for window in windows] if max(peaks) == 0: - return "SILENT (all zeros: nothing drives this slot, or wrong pin/slot)" - if max(peaks) >= FULL_SCALE - 1 and sum(peak >= FULL_SCALE - 1 for peak in peaks) > len(peaks) // 2: + return "SILENT (all zeros)" + if sum(peak >= FULL_SCALE - 1 for peak in peaks) > len(peaks) // 2: return "SATURATED (pinned at full scale in most windows: gain too high or a floating pin)" - spread = dbfs(max(rms)) - dbfs(max(min(rms), 1)) + loudest = dbfs(max(rms)) + if loudest < SIGNAL_DBFS: + return f"NO SIGNAL (loudest window {loudest:.1f} dBFS)" + spread = loudest - dbfs(max(min(rms), 1)) if spread < 6: - return f"FLAT (level barely moves, {spread:.1f} dB: noise floor or DC only; make some noise and retry)" - return f"LIVE (level moves {spread:.1f} dB between quiet and loud windows)" + return f"FLAT (level barely moves, {spread:.1f} dB: noise floor only; make some noise and retry)" + return f"LIVE (loudest window {loudest:.1f} dBFS, {spread:.1f} dB above the quietest)" def main(): @@ -116,14 +136,17 @@ def main(): print("\n time left rms/peak (dBFS) right rms/peak (dBFS)") for index, (left, right) in enumerate(levels): print(f"{index * 100:5d} ms {dbfs(left[0]):6.1f} / {dbfs(left[1]):6.1f} {dbfs(right[0]):6.1f} / {dbfs(right[1]):6.1f}") - left = verdict([left for left, _ in levels]) - right = verdict([right for _, right in levels]) - print(f"\nleft slot: {left}\nright slot: {right}") - # The right slot is expected to carry the speaker's reference, silent unless the box is speaking. - live = left.startswith("LIVE") or right.startswith("LIVE") - print(f"\nmicrophone on this box: {'WORKS' if live else 'NOT SHOWN'} (listen to {args.out} to be sure)") - return 0 if live else 3 - return 0 + # The verdict comes from the samples themselves, DC removed and the first window dropped; the box's + # LEVEL lines above include DC and the start-up window. + slots = [slot_windows(samples, channels, slot, rate) for slot in range(channels)] + print() + for name, (offset, windows) in zip(("left (microphone)", "right (speaker reference)"), slots): + print(f"{name}: {verdict(windows)}; DC offset {offset:.0f}") + live = verdict(slots[0][1]).startswith("LIVE") + if verdict(slots[1][1]).startswith("LIVE"): + print("note: the right slot moves too; fine if the box spoke while recording, otherwise the slots may be swapped") + print(f"\nmicrophone on this box: {'WORKS' if live else 'NOT SHOWN'} (listen to the left channel of {args.out} to be sure)") + return 0 if live else 3 if __name__ == "__main__": From 79d2d3e38a03233e8d6feae9d90814e9a035f649 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 17:50:28 +0800 Subject: [PATCH 13/51] docs: renumber the voice ADR to 0017 main took 0015 (Noise) and 0016 (resumable transfers) meanwhile. Co-Authored-By: Claude Opus 5.5 --- daemon/src/activity.rs | 2 +- daemon/src/main.rs | 4 ++-- daemon/src/resident.rs | 2 +- docs/adr/0007-device-menu-behind-a-long-k1.md | 2 +- ...sident-session-that-speaks-as-the-buddy.md} | 0 docs/codex-adapter.md | 2 +- docs/resident-session.md | 2 +- mic-L-2.wav | Bin 0 -> 240044 bytes mic-R-2.wav | Bin 0 -> 240044 bytes mic-left-boosted.wav | Bin 0 -> 240044 bytes 10 files changed, 7 insertions(+), 7 deletions(-) rename docs/adr/{0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md => 0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md} (100%) create mode 100644 mic-L-2.wav create mode 100644 mic-R-2.wav create mode 100644 mic-left-boosted.wav diff --git a/daemon/src/activity.rs b/daemon/src/activity.rs index a004d804..0be7857b 100644 --- a/daemon/src/activity.rs +++ b/daemon/src/activity.rs @@ -184,7 +184,7 @@ pub struct TodaySummary { pub busy_seconds: u64, } -/// One Activity as the resident session sees it (ADR-0015): only what the task cards already show, plus the agent, +/// One Activity as the resident session sees it (ADR-0017): only what the task cards already show, plus the agent, /// where it runs and how long ago things happened. Never prompts or replies, which the daemon doesn't hold anyway. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct ActivitySummary { diff --git a/daemon/src/main.rs b/daemon/src/main.rs index ce38cd0d..e3037576 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -95,7 +95,7 @@ struct AppState { updates: Arc>, /// "Check for updates" pressed: checks now instead of waiting for the daily one. check_updates: Arc, - /// The resident session voice commands go to (ADR-0015); None without a state directory, and in tests + /// The resident session voice commands go to (ADR-0017); None without a state directory, and in tests /// unless one sets it. Present even when switched off, so its hook events are still recognised. resident: Option>, /// The box on the cable said `LINK NOISE` since its last `BOX KEY` (ADR-0015). @@ -1202,7 +1202,7 @@ async fn post_codex_hook( State(state): State, Json(hook): Json, ) -> (StatusCode, Json) { - // The resident session is an Agent, but never an Activity: no card, announcement or stats (ADR-0015). + // The resident session is an Agent, but never an Activity: no card, announcement or stats (ADR-0017). if let Some(resident) = &state.resident && resident.owns_hook(&hook.session_id, hook.thread_id.as_deref(), hook.cwd.as_deref()) { diff --git a/daemon/src/resident.rs b/daemon/src/resident.rs index 8d4b9b60..660e4e6a 100644 --- a/daemon/src/resident.rs +++ b/daemon/src/resident.rs @@ -1,4 +1,4 @@ -//! The resident session (ADR-0015): the one long-running Codex thread every voice command goes to. +//! The resident session (ADR-0017): the one long-running Codex thread every voice command goes to. //! //! The daemon drives the user's own `codex app-server` over stdio with the user's own login: `thread/start` once, //! `thread/resume` on every later start, `turn/start` per voice command. The session answers through the `speak` diff --git a/docs/adr/0007-device-menu-behind-a-long-k1.md b/docs/adr/0007-device-menu-behind-a-long-k1.md index 4efab8ef..e7ab3e5c 100644 --- a/docs/adr/0007-device-menu-behind-a-long-k1.md +++ b/docs/adr/0007-device-menu-behind-a-long-k1.md @@ -16,4 +16,4 @@ Leisure can no longer be started on demand; demos use the time-compressed build. ## Amendment -ADR-0015 (proposed) gives K0 long to push-to-talk. Giving up a Pomodoro phase then lives only in the menu's STOP FOCUS / END BREAK row. +ADR-0017 (proposed) gives K0 long to push-to-talk. Giving up a Pomodoro phase then lives only in the menu's STOP FOCUS / END BREAK row. diff --git a/docs/adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md b/docs/adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md similarity index 100% rename from docs/adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md rename to docs/adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md diff --git a/docs/codex-adapter.md b/docs/codex-adapter.md index abd9e480..c6b372a2 100644 --- a/docs/codex-adapter.md +++ b/docs/codex-adapter.md @@ -78,7 +78,7 @@ Codex fires the same set of hooks for its own background sessions, typically the So `vibebuddyd` filters at the entry point: any Codex session that has no usable working directory and can't be found in the threads table is ignored (with one info log line): no card, no announcement, no K2 target. Both conditions are required: a freshly created real session may not have been written to the threads table yet, but it has a working directory. Before K2 opens a thread it checks once more that the thread exists, and skips to the next candidate if not. `tools/codex-hook.py` also writes one diagnostic line without the prompt to `~/Library/Logs/VibeBuddy/codex-hooks.log` (event name, session and thread ids, directory, whether there is a transcript), so the next time a session of unknown origin shows up you can see what the hook actually received. -The daemon's own resident session (ADR-0015) is a Codex thread too, and the user's hooks fire for it. Its hooks are dropped at the same entry point, by its thread id or by a `cwd` inside its working directory, so it never becomes an Activity; see [`resident-session.md`](resident-session.md). +The daemon's own resident session (ADR-0017) is a Codex thread too, and the user's hooks fire for it. Its hooks are dropped at the same entry point, by its thread id or by a `cwd` inside its working directory, so it never becomes an Activity; see [`resident-session.md`](resident-session.md). The `vibebuddyd` release binary is started automatically after login by `~/Library/LaunchAgents/com.vibebuddy.vibebuddyd.plist` and restarted if it exits abnormally. Logs go to `~/Library/Logs/VibeBuddy/vibebuddyd.log`. When the USB device is temporarily absent, the process keeps running and periodically rediscovers it; hooks don't need to know about plugging and unplugging. diff --git a/docs/resident-session.md b/docs/resident-session.md index 4bc46e1a..1533ba19 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -1,6 +1,6 @@ # Resident session -The resident session is the one long-running Codex thread every voice command goes to ([ADR-0015](adr/0015-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md); see "Voice commands" in [`CONTEXT.md`](../CONTEXT.md)). This is the first, command-line-only cut: the box can't hear yet, so a voice command is typed. +The resident session is the one long-running Codex thread every voice command goes to ([ADR-0017](adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md); see "Voice commands" in [`CONTEXT.md`](../CONTEXT.md)). This is the first, command-line-only cut: the box can't hear yet, so a voice command is typed. It is experimental and off by default. Start `vibebuddyd` with `VIBEBUDDY_RESIDENT_SESSION=1` to turn it on. There is no App UI for it, on the Mac or on Linux. diff --git a/mic-L-2.wav b/mic-L-2.wav new file mode 100644 index 0000000000000000000000000000000000000000..fc84fc8c2a0d125770c6689202bb5e30bb17af0f GIT binary patch literal 240044 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 O9d#22 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ߪNeJ;OlR|% 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

xUK;lZJ0ElRG_P)23g%W8)Zhx}ICI>4k?F3* z&ha!Rp{Gf9?ocZ6q6^XGuJbjg?w#NrnPV;(UxpnjGF(r+<|9SY0Ik{)Bq|u7eyK-t zg2`Hq@1hn|z-enVGQ}?={jFeCx|*_VSu&X;7j9#wq0y(#TjtXYG<}OsJ6$!oK6P}` zGJXPkkDDVAqxUdte55Q^y7kxeLmfLHf7ajq(bLV2eiV#~?(I^VZOLAemnp5EQuQ-hmlv^%P&YCt^_F@9}QG8sPs^3|MbL9|>32LvCQ zHO;GxZ6heObzpZ?V_Kg7E4R6lHo-2!(`*(N2vfn6D+{O)^HDWLhGyYD-Wy&TF&()O zY1n@%Y$@WG17lIGZ?wLV5t$K5kNS`3|BSjHwL5Ag{Ef)RQNNE4kNQKDcf@bPA`dh~ zU5frB%J)Eh_)w_t{>KqJ5%(j$*}oN{lXQk*&J`9I<|_Fnh>i;2l-Mll6rnB<{t@5g zVWCu@;p;FrF5pAU$jxFGVF#X!Eun!{Ld(a@Sw$%Tbvnh>25x#K=?d5`ByimakvD2j zi7W!?!Jar256|8=;Inoj$Lt8Q#^2t48#!V-NWjxz>%Z|lawUHGn6(J3N5|DH=%}vD zCoLEjh^C48DqS|d9jW%tlz1wA;@U{>$obKaM%Y8ffs)>?-l%60{m#$Cy|hkt$GZx> zoYy|p_6myrj>pST^sGJhk@Y{GY5iJu{W1GVqwI?8ibB#u>w5zB-DZ#Tv%4cvlNGZ~ zGur8hsjjICec8fIGshYZ4aLR_>;|yYypEicT*oNq93+LC+%nzCMybrN{Ntlyw;Z8UVMa|y>L;UjtgM-{r!zPNqLp56;+!x#uJQ)-j)b5`g*ojF= zOYn`LGoS>eh}?sM_gxC6i`D+m{Y`;Kf|7#j1BL#@aIRSKMX=lPa`<-|pziDD8g*efCmG_{p8*^|UE<7C`)?2Lo9 zSo5B$Uu9EiRKo-5m@^JP>wV_e)HCXGywFx)koJK|LNgt^!MNY z=vVhoKfT}Z!Gl9}KmD-wEvq{8hxt0UhOZvHte>m>{-^)?<(-ErYa1y-dN8iKx(yVNPUV z$Z_5``KWy@ex0anOGSyIOp!Y9d0=6{)gTjU*ZUH>Bp-~X^1w3aqt2k>{e$=ql1$0l zAxg<~aHseaai-*tp_yT0VT4ef#4*f0G$ed7R2Q}qniU$kzij`PVUeM&=<2@<9+RAs zG)N*b^*R>#Sp229Kx_yi26m!K_|X3{6s3Fth<@0uRA2&A!M9>FUkVLU0Y{ArC7#}b zZ%^$-@e5|`!P$XV_ceu^l?cA6~nE2s?6WNO_2R zXk_SjAx)wG7UC#b2`#|XJt;IJBwX@c$iIcxhf~AdL-&S+1mD;<5`1A_C;I$AsHr3{nL zd?ZKS#Enyk#I2k?NZ8uDy|)Y0BPl4bRtFiBw+_1m*hIu45$iru-=*ODkk+P=C~8|S zTQD1K#%MgHMY`na!y7FA#`L1^rhuRKdhx>iIN89_dccpim zcl|Q@^QR5}>!&sl`<~YRfcN0tTBxqR`Kt2g?|%Qo=QZ#B=uz|jeec?yx*s3>*!ZVk zMjwto{#2ppzJ_^zbI)qeZIxs!9=*|R>|koq`ju{FBP>0ybV%c zDR`xY;PwRvP;nL+e46|+h3(jO+{g26@cYg`P(<}tVj7SsPzAh=s`zctG{_*FCSr?1 z^{w(f2D<819=M74U9H#-7BEx6g~~xyTn?gO3;3sYwSOM91k+u^uh{}BiXKY4YD_7% z-SeUhe8xK=?zm9)%{Z@l+&$ImaDzH0uU`FGF&?R)g5?Awk(#e>dI6$xFd&;CD2?*q?v zo^SsXLI@#5DU}K#gea9VHDkt%S-o6t-SMp*UA=nSTU)obcFgMP>g7?7+OcEStX{qP zs9U#o%JHCGdBM~4{h^bGMi2Y&XZauV{ZR#6<|l)lc>l;`)Ofh^(51u1!&jo_qv{TQ%j<}k zKI9P*6J8&BD)?JYXh?l<4D?oBL7@TRfmC)}K&PMX;4HeLV*$bJc~+jE@Id@QH-EN& znx7G*T;cvUsH!9J{N(#eu|@92IopV?#z3dI^WY^bb|KTsk;6*lxR^lt``(g{-KW-k zdM9;T1%7jkR;Ovx^s3U8;yJ-w(rk*H&@GQlw2n(9a@^maz3~;E5e&Twc==<$p!c(< zj*mmSjz0FoRw|^^zWqPi-}}dZwBPy1@7n(J{`UQUY1?l5OUHjhmHXnS&4(|$zU#Jr z#=t!IcSBD^wb;8~9p#RtNGY@Z3v`uuxn9+?oVQXAmrSxg6v-x_FC)pk5*al|p_MyM zCWDdvP78#5<|;Syz6`G`*ku!bZ?ShbZjlNwCv*o2z4Lr89~2&>!2Q*GfaaTnZFmE# z65f|cxWl-G139@FT847y6^IS58P0(s z%muKa^cZA_pzm#UmQi&$e~LiP%Y~CFj#BQBh7CbA5^!!&670C|va!zLvtMlIiFDl( zc*BdpuuOp}tQ%PrP1f<&N{bm;!{H_>a*)^t%Vq8}l3cY_wK)kGmyLdT9<|fop88@3^{c0!cYpol#lxBQ-*r6g_~fTw zwZHhs=HGwQ{C_|G-;ck$_xA3gAKw4owei<~`>^T1?*2#9zy0v$?w^17;qSj_d)gL; zv+BFf|LVH^eCfp}gU&;>!mDqrMYl(fPbJPQ%q1!1Dhjsib!$CPop@|_AOUR z6v1&wDWsI3R)hx2sRVBfmmz^43^Ob*sZXIV6>7G&{kj9Gta+SN9UKpi*!J2Eq+hUmUoPuL! ztY|b5+r&rE?nVerFF(T5I@5pdxgRF-FCN}`{A0KM(|tYv^Yg^xfJe^VfBre;+01j- z7k_}0AVjbyYRkdWPx38SiO@EPC?lXuxRn=!225Hw+Q|mG7WET-dcxEH3nZhB;&V zvtRVQ?Vjp#eQ@pJ%%c~Nzj%D~@!#MZ_(kU!PO3lr^kdt9w0_<0_>*7f?;njl`Ca$d z&p(G^Wnv)d`)C^t+QhAl^nKE3s!KXdsZb+}7>B4Z-2A3k=-<aN*GSITpgHNKi zP@)znh1-Yarv!;Z?3o7tkJ6XPR2`6DUY!S0x`>&KNt_o>N)Og7&eXH;@?`C2!`Ync z(L(QbA|sQY0?k&jRXP&cLX8&8io^AlI_9cuB@td5<#H=LmMx2d<$iUICSD^%3P<5G zf4O<7Zn5XBwL+@!o<9%u`>@P*B6BQ4GB%p^#`Jm>+PW_WLj=zKw!L9bf9!hM^|X7W z=imB%(SH>0)hB(c{ft4wkYKoa%WM8C1qy?36De7NU+_L-QH9uAY&-H5 zroRf2TJ#YrV%hNetD)ESucwBd3{DAdzgXx`85n!@K=G^%4sdjn( zJPnE@+9C%e@x)~&vI82I)3y3_q3-^Mal;Ea5)#w2CB>%IzSu$TsB-OO@VzZQ;oy7o z116zeh(SmACHGj!m)K2B@`8D8p&6Xg5GO7xB$eY5gsmiQb}DPyk4Qtfao{vOr; zyMx_@>7^>LGXy)5&>PsG+z9@Xo5~aM^3Y#GMHKWs=EeEJes z5J+&tbtW(4EHV%B4PM4{WK{crwn6xiy;ceEndkW%&5mg$ZlyAnCIL zcr;@`M(JKR>*A1FP=Lh4B)CkaNUbL0jwx`HdVu2USqhJGF0%=}JCkF^>|~NR8+j$F zjlaP^d3ZAFo0vbnH-GfYqgRf86SExa^uZrLaQfiL`~Pw@IX3u%yK#U0;9uWgIa+$e zCi?xOq1gAHis|Ej8I>HPd~fhQulKG*D-W+7zJxvFo9I8HMjnskANeNc|Dj%f%(q1F z4}Z)*7!?ya9v&0g!95i^k10TUFeUH`wDUgxe4Jg~K3w>Ge>eTAMAobvYK|(TClc9`Wrk9ucFeCQu4S&=TxG74 z*4f(5m9*u$#dwtj6PH}@$I~$>rY@L3edo^Y$#~OeW`?G-rdy_>CYmKF(q25B)sbOg zG-|dx1GiuJ_6He zhCThS?wP08`WORaFaP%H8hljtZ@6PwPzh&C6DJfhk)nM;xky(PsWL&kt=DyKsSMA| z1pV?1sPI{EKE*)!+~83PzlVsWVP~+z1Id`6ka*=#$C1O7xJxcao{B7w7~rj;dr3Qd z5;TU>5o=*>NGACH@NDF74tpIs7Iy5=G|292he{8X!kadF$P)1(zHJQa;Kqc%A3=)n zKQzGW;HpBC!!>w+E}@dU9uyPIheyo|I*CT?KZX8on5`sZ$5Q2!j~XFnf2n7)TaqgU zy75@ICODg`Kp@wnm#=lmw`YKw)NVsThPlG3eD_l*bb`?-R+=suLy>7_He?!oun!*G zsMm$-yY%EOY%n%dTDp#lY?bWw7;UP$dx@f_`eTc zJbd!-FI^`dfAaW)u8*Exef|g?Wy-+U@IDv4TE!&u^I`7DHL+~MbEXP+SMEaWLNU^z zuB>Lj*?AqmzbZ_>jL@X**)34A9a3CGZmIAK=0V-a-ao*MWXGbz%SUY$8ge~M1E&Bf z;+H&O_*v}P@ABRFH~21xA0FPol=#^Z;}QOmwZnr)F2`)dT#5e8knL zFZm%VkTNSlm0=+f#S1gh23Fa@)7W0?eL6tW5aMZdGJ5wZ!5l8a^b3r08X2CW1eXYx zSSN~OH?oWxK*{fQ+=F&v4-749-2W-Kp;~RP+9uh`pho%@U0eh*NgHF?dVxWbo^kf5Y>+ z(DVFB6r7!p@O%#aeC_e`2fzO5W#>p|(8GUz__qh{KZSID@%Y5gj?b<>|N8j{nC>4P z`19a{q1&(hh6Te>V+^TaDt~%U-Y(b8^FWp|tnkk+*u@J8i zm!PL$y$$!~~1Dtgug^EV{xwiHd6+R1>&6ac12EZ>BypG)%-bhNPkPVu5m! z2CdzFOr-~*vAfBv$K*KHtB%nG)holJ$i2oj7CirKq_b2ZVX_d!^mr(OBak@P3_=** ze(YJ|z?NzR&q;#2r44rr4UW#{%`BuAaiESaf`+1JBNAKU*!6j^xkRgxt0Zu;y44xr zF4LjIOHz^+%H_54%GvzcT09BCM9nB|j3H?q9mehA{n~FxA&d}yhe`ZXLDGu{{Q^|P zHBfSw^zNb${YB6E)32ZY{pqis*ux7K@Z@dR*rR`Y==$Ktj+qC)di0NnKXrcBdElqH zpMKFb^JM2~GEPUqNof*oTD!3Wr_i5?5Pzg6+I~MO+ zQY9% zrC_>QGvBPB&C#dP$$p5@i?o(W&vAjc=pVDn!qS%n+onz-hT=dc{Ry|3v#5PE+?6&R6LiDNrcZZc zvE?UxKINDOP>~g&K{cHQZbB(?Dm;872V=0CPY;|9@(MZ`BI5n&&{l*)WG?QNpQHX5 zy%BT$z2%snqetJn{{Fvw5c=V_AN{|$%W;4GX!4`!4~-vQe}CZUuj5{Qc;%!2{qWau zYw!Iw_CG)Pmk&HXu=(JxM_t}~9P1GKr}x}q{u;G*_)kYZJd(rz6b`K~@hb#Je9L1( zofHSfo(r6aX#rcT4rV?)#UjkcKn%bfvlKZJ>e7*C)O`DasYqm=46vT%C-N70#5)qoB0BBc+e~N{@qgH7xmTv>ytYYvTA(z4*M? zF#Or;e$m%&zQ#1>(d&<1eIT$FrU;+E;=PIzEMT75B6uoD8Cd8)-#7Q{>I?Zx?oiEZ zIXu~4zoNgE4WE1SL_9WX81uzu`Rc4jo;A;&rz#Q`d78wvTxh|y8=V`a#>;y}*4c!^ z*S5ee-_by;fMc))43x5cd~|(nm=UDonNfn>LjM%@ExOA|?qJx-L#HCkB1;bkW2-{+ zK<`J*M!FoH4gYPp8#*8%IwB1(l6UFQG3)}a@WMmQ!Ly;y!aBoB!;WG8A`CWz|I~(= z=2`ex>hN6pkc$)p^{X+!B>;4H29HghtL|5qW8bWrk4Fz#uq0F(-cl7z z(3QNQY>J+hOy^G@pR1oO#&e97>Bbq-=*iy6c4-y3HJM}05^IUy*ag)*Yman{?vqpUX^7vi{K>coAk)V z+i62iswXk!J^oGI%RbFlTJA4K~G|Tghhc3Csy$bc59Jzs>`R6Te!%}|K5XEWs4vZ2W#oNFfVWU?loNKE6` zq-yD{iLWN)qrT$8F~L|r{`}*i1&9!xdy@qfx%~C&Q0-v0@Ye9cunBsZiNWWuB81k$ zBFu&&pmF*F8~?w(S`~P|{I|Z_FWLuqsMpWI-x?)w#KtsR)Giu9)>^ZehtrcbC7a5e z63-MX*vf<@xvCNRw>DiLQbkUg?wgh7I1-U9i!^fNcXZNH-CEtNJaRoM2yZub2paew z#K`M2`(t*G)EOo8vlT(aoPiC(dk3baeSs=anPS;40Un&eAaRfd)3pjr{5zl{yTW3E z#8l4ofX-}-)#!KKmj)#o9h%B3NWw|<%z%RPzIQlOGgSPFHE>mw?Q6i~Uk^Q8EanJg z*m2esLFIkpSm*N&P7HVeYvoX+TLOb!c zbFNB$73W|Ss4DHCs(gSXH^W#J7=KC7(Kv!?c6`JaN?P1I@P!S*9cDfFWbi9E?MDPh z2lobkz*+ZuD9JYmR{JOV#-4u#7r@mQDFe=e1%WN9yP?sd$#$6--pusb%(qN6U7M`S z0g05fbK6=$WQb9vVDq$~b0xf^&2W)c?pGZc^fUW+LAhLtjaeDKd;;VB?+1`W%$O5z z;MBW*Xfi5?|JSHCY%p_q^^uR^0?y&*M!g?35dHJf_m2*~_ht0^Q7cjZ89jO=oIeot zWyD70vm=jVetGoMqk-sW@5cNx+RS(0_eFjg@g97^ZhRvuj8SZaQlspM{|(E)-fjyT z^phYoruj*k4Xk5+$9!4da_GnjCA#~F?Lzk@-DLGtH6}+Hd?%v!HZ^aqQO!x5aRhT1D1vVM-j6!5s-rq2+DUo%11DXxc z;a2n3O5wxKLC$-uTCfW$3f)8MMjq1VGomFopT|r0NX?*K@arMdRAb66^ZPm z8h65BnTE8iNbHF(gT*DoY_icW9O;jh2dltMya|PDIX3#~P{7>BeG|;>49X8=1yq8Y z$;1phl0)L03Jk;-qXHY8VddM%ic_ERUv;f_~ZdMHR)un!! zzB&8ryeqt$_D6bIK&XQ*(5-^bfzCe@jsvU*onzs^>A~FkI5L)#Y@=-pNfB1$<=%6=OmgHlB;lbkaP4T0)|uZK(7t+)im5%+(CIvNMrm!q(O!`B10I z#Z$;z#CGSf?*y14L21j8Nu`N1$LC_^BbKzwT~OB`ac8#{NwX<7$PKn_p)lcWsiq)l z&^eQ~hdlW-Mz8xIW-W1yYI-($!$fQzo4t-fQ&|DcW(Sn96@K@a4a~E?TdW2)89M|Y zJb^E<1ri1`IVXe2pqgF^+6WdxCvci84w(%VaVO!t7=SYEM({oE$*|ebNp31MPTHVj z&<&LND}5WGbDw8b`d6_|Gt;3<Vy@;D$lIll5I%z_n~*nF)?>2 z*giCDhQrZUu&O|QGYy{Xdb~TStC`CTH3|97bexKwV4TJv#VZlqInF|cJacAPCYh|o z^iGRf@3YCQNxuoTWMM=tEP9>w+FE3Ka}irrZ%ibJtiZj&nHK>sAHA{`axriHVc<#M zRh)^5f(P)G2R-{0oK5Yb2V-(1uw_UyWjE3ld}90{H=azrlcGxU)WGoyFNDg2Ho)9ScWDE=$CW z#cg#RL|(4%G58Kju{rQyHoy~}ye|n3m|l4Pi$IE$J9`qWB`1-SfGT%n+q1|CAkRF& zHtUW}k+mE=K`o}IJnK61S@ZXn6IMs9s?Z(9LD_K~&fOftz;+iD`GZJ{sRwylkL2A# z@XpKEv)5T`$(jb_sL7T))QQW|rM$Pi`G$pTR2AGg*-ZL$`Ycl+o6DOyFN=^;K{jd{ zFF{Yl#R+B&)$E@5!N|yP5l$~DxPrGvJ|9^f-V}}uCx}*uTZV?=3_LM#4i&<$UY7KK z{bK$3lfF;-ZbLCL^!&TtTSG&m3Te$`(&XwGeY|!0yh6I5STd~itaa}^vc7{{dm^*A z-ztjS1Lj%=JWD+^k((G=i>7_`?)@(LaF>ca$WWY1p^?hOzPg-s-;eBj8p(H^(DM~Q zOo$_KQGM(pu?fU~wimq$9D6=^%g#HvzNn5|V+mfE0479Sgf+h242< zg}#N0rhB&nZyC!%-R&~y40WhH2DZy_8ZyBvZPLvnAvb%~3!N-g-HSPR|3b}t(mV^9 z>ev(D3DzoZy)9W3V>(_x&rzh$cPW`m=aiXmduQ|E)&RM4HX2`1&>bG1IWu)s^7Ux+ zo1@~R(kk>p84H59Z1|wdR5#T*+MDZSqz&9aj`XnIo`W8}cqcUxiM6>N9HgH0dtJsp zj>%#}A>MYd&z}c9HHXCV}50cYW`;DE6{uUnoX92PA?yJf`5#~r;ffh~%=Q?V> z8-YHkMO1+#PH51jfO1wQ_>g!nLD9)SfLRz49n| zAOcJ6ddM_VBFG6m+YFFvE3H4?i?cM6hHO)8nylUSW({7TMZ{aPtqZL3@%F@H)-A<; ztjUlAa(CTE^+vC*a4laeTP{%NYjQLaO}Z-atzE`@4)v5{AYCN*+Xk=s#(Is%rBMrvA z-vi7)Z;lVaOtpB$A58Z5!0AHU5~2QReJKZHl`%LmQ1jALko3A{v;>)L zwcy4?$YT{IB@Ia$b#Otcz#I^e+U=I$JZ+@(I(NaH#zzKc9K+(F+RugNpy^;NvkMfu zP`_BzQ5w|P6zrHK!GoxEF;xiM;+zeN<$NDv#wR=t^Y1(;)`6A>Ij}$@F=f3GSQ%J~nOiwK8Tz$y?iRN#m=eswgd#BfQrL%KCqwvQ<>-R% zakX%J=%9bcgaKX^MB*$i_EWAH4j>KoI3a1yv zzK}wsC+@*d;EU-T&-#SbeT&IjK&r!p)y+g?B9O707=UW3d_7qci4=lLEqbPvxMkUj z3~XV=Qno?{rQOhLbaTAr}mE+N1$1udUV@JUwSr|?cS`U2o>b%hOdSo~n z+T0Mq#h2?ZuJx(=eg)<^!5S;+{nhj1J->hYyWaJe3z*Yojn<4-VY^f-Q^+}rbMxuS z998qGY@^fI1qXjADu8p=I@{)V6~Gx#RZ4Kz#=7N0K`DR7c#c8NY%Z9YRUnmUJ$3sM zJ<|^)fY8|pZ>`8fx8LHE2NFLr)){nA4D;Q&&~N0p6S|FfcNQbvJq{IKjI)g1w67o9 zvI4I-1`lq+NT0R?xp=<_R-c|}20ftx4o@#{Es{HQ2Mpd?PXmLCUroNR3R`Uhvh4eD z|Ci(b(;;#80hze>k7DvxkN1F}aoh(vHQ8_j0GEI@D9Q|n(RD_9(go^6ST!D-SVnxNwFlO1c@HHn$c;nyUPyUYh z;_G5;w*%f>81WprF#P$@Q&ho!==)-z7W07Ofs5dg{Cm&mJu^=~0&Viz&zq0`0jK$s z$1_jdAHPDT@K2q)k8OMJV5Zah=80%ebYU!85)Y~dLvEPUsnT_p?LMS+1cSi4XVr_z z%{dUNqz)}q3To3doD6j!;jus`OryuR#=J`bZbrS@NRU6=&2Zg-7tBa-%LRSk3*^mo zP`v7q-rVojf;zi}(TI*d-<=HZas*hOARM`Q!M|Ar&PWePj~1U}=xhlhWCJ+QM4J5u zIvx@9wx>DA@XnkKse=Z$0g1ae*M6z%J%1ZGr`Nr^gD&yd^Wfg zo)?0}FGRw66?QMFOfRS_JHXg?=0#T(%^8-y3LG=L3e zTNYf;;1`1EUk|R=6=sFsO{m?7Il^>UBzFL+bwcU(DX$NC&Zr&2e+iu%wh*De4L4#| zeTA1Ce2;w=&Yg11*eZO>4qO4@@FwOt8D2hM)>kt+@fK3t33V;QsR&LR0j(Y<*q%!k z9PWHNkx-uH9EslMQAmefKvx<^ibEzy53*g-kunS>qi%k+bR`z47RY8% zbC%A$jeGn5J2nk7wFFv1p_R;f0 z&s<+Dy?hQb@x_Xl}7 z<67)y)ixQVw@-J~Rz>z1P8{q*3&6QxU|WLBW>*Fnwb@P#Ff_^ZGS>p9ddF<2aLHiQ zcDR|qS;C|q$vw?*&=)zUfR)w-=S00@5)z{tX|YI@&&8Qypq&9jrvX224?W$v7F_i_ zMh}CaF>yWP-#OOAm_J{_)MtS6EtCt9pjy!U+~8IV#oa^kSHbT-kDg|psY9(*j?88; z=Hfa`&lV^GY9_vh9BZ(@NpK)&V!e(94lU$!)+OL2?b#%uQXxA3J2nbzKr?JINin$F zFIgHufaV*M^%ZN#cS5pAkuGmdyX=D+SC76?qGB#F6_k1O+lp*?vy21UArI+jO{1$L zdXeXF)Nrk+R`k{I6CrU*J$k9YDfO3r?-wWfzUueG_L_n1=y&~p>HiAdeA0{dzH4BQ zU3>QX=bL>OU&77wreaE=NL%dHbgZXsWEv}w!Q4X1x1}Jd7PJu;HW(vx%-Jiv)BGy^ zpCMVL(XZaW9<$$EXvGOH1fhWLg03vVf=+|L5?{Y6YV7-HpK)p#aMw@BCzMAlZSC%1RNMs|!qN}gJ8!};TS%rQuaoBGt zd+?)IKMbx4J_48FlfF9x4+PnmS_*pCL9^O}w(}3*72oO(czV3|$NqD#^rDino{6Se z)NJY|ZINzp!wc_b*`8+C0z&e23m0mxW;g=6Y_HnWp)=ldNq3dw%}968a`yqHtIN3^ zy$TXI9lRYIL0QT|&bt)331WI7^pres^lIGCpf8|-I@ZbmsLt`MGptMcNj&xcxHE(n%h1z=14HMY~= zfr0Tk(sxWRFQA%o?EC12Uq6v4L%0JXgo2R>Y2HlCypPgrB@2o(dpLR}P%nV|;>2`G z!HFVr@&R!~>Xyl1cyyuGxr~#(2^~T5!EWfEIzheAq24KiLVyCrxfnXwey=+31SU=q z^miHT(g0JVmn7>4kknL#h*wN_=HgD+bkRld|Cq)5sEgU^bvNmp|NR>q5gRaDfq?+%Z7B==M>w4j0~BG+4j*?lqk3oSX51dS7v zf>eh>O0!)%(cglXMmYcY;6gt`4)@2%jLV0LwhihO3TDPaILI=P8%WVh^gg8`9U1ol<(i#0Cd8i9C}O6Ex>p9TStEmjoE@e| z42Tv??036B{i*`HGaW?c8h20ViS%xuL4w~|;6e51z~oruBZXe<#{PaU!i(48B?n(S z-n{{7RB@hUOq!JpuJ=v!`M6C22=z=`=#B6-Xg20~4NyymLZ`ukbEbejh};t zIC!Kl!{g?5Q0&`upngAnU+=zr_%SmvvtFPXsAbM@{?Ha^43~UV6)8B~*yxwUj0QIf z&bbT_T8TS`3CB6%{kZB_>p-U1lAG;n@J!{HBh%3(656!_5c*=F!V&GnfaVhpDz?`~ z4ALsa$Q{uklfDH>GaXQ_mo8VT&Md|*bSxz*OQMQ=&n6^&SPIda_C;o|7vXpjTd5 zuh%nonm~Euf^d~dEqBm3E#Q1MfsRq{oZ%t?@89B<{QJ4ixp zn+!jH9g-YCVmOd|pc|(~wf8&{7-BGKEX5Ske30*_!U^Vu>Mih{&Y=(YBdPX|!q3k`!s2!8RKCT8o<_O~?$-P++mr!z<5_c? ziL_I?)uyLl&t=v(fVPl_4zXfG0w#tGlrbI>7rImuW%EMMoI*AhW#sWrp zP*&a=djuMvY$RKfEu~^AoFXp74EVxu&Fe3OQ$u$!DY^LS%#i2MgQ079OOoKv{2Ue8 z@gYBf9H-aup%%>R8OWoF0;~PAm#!~9d;U*wd3@F5_{{rh(9i$;_-{{K;qpj=lKrcp z6CffdNE76FZ<)yBxTzMeg+nPgYdE&W2W>Rp6bIGMWh5rZNIF}#Jq>-A&Qb58z^Z>M#&5woF4{8<-SH_Md*;x!$lugjCoX*OCjDy77aXW zd|xfS$|cJk?0PQ}*y*L-kv`IQ_t{x^p$6YEHX6a`Dfc;xEE|@$#wQiT6L8kS*@Z6? zb83pa2FY)rN`v>uc1*FmX0t#}0wq2ks+n0N9Jbr^+R|;aZK|w{cv2N`&A9D-i^R)I z<`nQ4^vEF9>|$oWSBK0)zQtpgP&Sc`rARGQ>FbeAX<4n;vbCJmB2E8Nx{9QRXF!#% z@=>0fZ&I?AT~K43d0QY)k;l&tgUEYL){2Z8-K0WRAd8h1OFu*ANzJ%m;=+XI1d$8G zkyJ_iCeBP0jej9d!MC!d8PbvoLh%l8n`BsQ7`X^iz)_*2@HR965d%+ArQH(LynZ60 zO5)LZ*3X|=HmoIVMsAmGv2hdmfEX^p_UM|`o(&y36frdoc?!)g8DP=J@9*&j;S;Rf z(@Y~0nkwOpsNA26)E^VV78z2+hW~(r&_0i*>I0DjUFDk*W7ZMO%^TT>>5cd3 z9dR>mrlaC2an`8mo!2cz+zJ;Dp%UnQZ5S>bk&PgE;!U$~L|_j`t^Czr1okig())R@ zJs1Mp{SO4Z*G+HkNVDaQNbf4dMvSLLzqNI9I|lqNB$)0In}{s?Zij3KhEoMyj3gi; z&4h)yGVEj#lGp-y=a*8O!|RtuU2=FOFpJ* z0(u7Y9$KG zM?hs=hjc(}I(P0PX;rArUTs{{z#+ne`?F>;s^1x-}45j;baLPbzw?N(3319!Eunj0}3I1DhurlNnJWyw$`ZS_WOM~t&FX%E@ zCT*B9SAc%yf!&H%h=zA6y!6mzDAd-Fiv2OajDHF$4M$AP(oQf z4fe)wBb}l>p!YDo=MbX=(>;*y!|#CJ&>^xMj-TI#hK9@rT39rwdg|dKjq`*<+9ehf zjCjmyI*^9fj7EQNK zA`KpDw)4{XacfC5h)^fQrqO=r45E>1S~4L4@hNI5LuLyNRO3v^9Br;x(S!uI#6|70 zVWo7>3=bQFp|5I% z2&y%_x3S1bBj`>{kcMtzp4rVzz}s}6s7v;jAvvdk$-$PgY`+GXAvwqlp&Xn?Zfpe@ zv)BFdFtI5+Sb!5!1*PFhOfMQiGN=nF4IwCxXM^gnV@QVQF&q`T6cn&t{4O$4Nfv=P zPjpBHZW8A#@Kk$26Y2-SCXOOSx`3G2r;!ylvEalPVnUr{9lSdWRy-S(NZ;P9snmGV z9EyB%ok?OM8$Sjku-?$7*KWwQ6s>gq$~qO9!8{FhDMyu|?p9|b(YQt#0qR5bTNZRt zrr8wPu#`5XmvSfiB}MpC)=socX{VYdeWkWzNu$LQ&j~r4*Ck`zkz#Sb`nS878HM`Yhu0l+ z15Bz1k%@8^JGWFO*E?$;3vZRkgW!l+pmK|MZKRhVlRyAkM7<-!fs40^XWtA*0*wL% z7_vK}?M=39C@B+AnVh%k-aTdFTe9t%z_$~CS68_K00|57cYdw~$4>Ei`o+jmG=N`&*4> z>MnUph(zw2tNrk1N!QPAg0Xizuk<^yeyKv}}H%@QIneQPZ zObPe5+2mzuGl|wqmpvEr-WIN8ZYeE$4oJzcv9@V5m4nSwzNyoOn$s-#`Yvsyp#Yge z;B12}Tu8mVheUTIjdyNTf-Y~_@iE-cs+y4!4XhGI4q zt|V)emS%^1XNw1o8m_BY>D-9j)Tlexy0>es;-RCumnBIydCI z=jgh3V)nd@Dg(t-Yt={qhvJOxE2tS|c00MC3>4lp7uE6m16R zR>qBt%{WlUZte{15^A;#Buv8$4y`j`nNWEi2x6XFRaOt(zTUoZ7k*!SH zuGBXfI?c8ustd(E$-Uf#VN+p7(uDaQvd%M&vB=3C*kzHEu>V5-5|i(nMwQxpMQYqC z-Qa9@z+=d>PqME8?;Gh_2P*fqI;QTn;6|yyr`6g@=hy_r<4trcRJU%IE_62OuD$LI zJMBi?R=jnMz0Nh}KwLo60U6x~jtgrCs#AeSBAKqs(%-h_xmN_J4yGcN+2meGH#ypp z=}1^HS{Qawv}UJx=X6&Z{?fU2c+CdJA2?1<+akP}cTEObsb>_;%ks!tKuz#^7R2$n zVMtqPTqA8#3=3q1QzALkQf$`>8X)pZTvR5uDSxjN-hk`IL1U$Dx;q2>ew{Omd~6GH zA~FZJILD^K*twXw#q^NuhZ}@Kwwwk@tH%10xpRwb?lRDc-o|7N-5K6P+K_Ge4ikAu zg5@*IvD=Zm8Jh`9+Buze*1+Ctp_b72#_AQ1^((8SrFhT*(p42JpCa-7uBl?J1%6r| zQ#C0|@1y0YJLhs#Roh*rmX#_+&rGGVZmW>0^sJzGtb0z}8fqF(H)T1NkOwW~t$1bn zEL&3v^+DHO4;jB7(9w~ufK2yG$RrQm$yjBoBh`&7;td`W2nx6I-geD$7xnA#A5%n| ztmOpg7pu0f*`zobC=^rdcIZ~u{O$2%%{7}Qdy0Llb&g)UrZZ5n6~!lRUzJm;IzrA} zxrsC;j!h>$&QiS+XQtcsp<2hR+c6Z*CH-a|G^izHi`y(t0iyqy-nwM$cM{Oyh*>jk z46G;X+mSSTbB9djxRsE~OahYHineB0zHMLQ%GT8`4O&iGXj>1+dbeuKn~Q8CtZu5S z-==E^jXJ2u?qQRBY)cJ)aizX_wMn0BI=dCQO3^g0#%l*Q>kZ6J@s?(%%M`E8TQM8X zT9QHa4>cz&(Z&+z=66!8*n7vx*Q_#Dvsd}XK7F4)id1W-(oq#L^K89~*v%|WrDoPt;E-p@(HV`mZGCZTopY>r zyk;XNhwq73;*~vHM(`z%lhW-=Fzw`0imYu(TGCZphTRe<11k}p+I)+JK& z?VFGu*=lb9cl;I=90iAFH)PcMM0&;1D($5XNzPG}CEV^TS`K!iBCz#qUDBN?NY1N5 zPL;&f8Z5sW_e7c;d!qB$AQU+hS&&*vJ``X*|+TGl~8CFC0GJv)Zeunq1N+*E@`>JHPv5x8MY}36h+7uczJ15PLK&igM)E_^njdU6Kc-bWUS)E-aNhQ_ioP{MM|)`XsxmHw!dgCG2Mhe=@>|beDn`g zt6{q{=>IYt@{taId9Qpo&RDhCq@!(Jf#=cJs>-eq86z@kBJ~0(awo-3UfYIU{#M!=zBZ&FoC09v@O($kv?x&HYu}|sY@M8f<>x2dsT}WU722}yP@^b4Xm?P z7)#8hyhXW6v&!6z+^h!gPPrBbUxE^@gxvK!RIDtdILB*!wDa)TdTcYdW4G~difKdv zsIwgSOU|y4SI#1Np>d0f)QS!y`@!k6LHKDJHc3d}wWxL4*v+#@JS;;dQVnk6vb}UD zXN<;6xNSs)dLETuITB-dNEGX`kG8#Tc|fYhpDzhcyiUwS2H~|N{CXVd>Iy*EFG9L_ z+upRf%M@p6MWzPviRmFl*tOgB+qIC{)~UOLpeGV6Ba^k-N?{X)RF`P`1@Z%=09GS& zlwbw*K$U#eu?KlVmCjshG`Jx#jvRQLitUKx8l8O#2xoc7p{cPgvahj^wy!{TAs1X= zZ(9u#3EJ&sU~GAk8_}~Y*;nH;_JG8&JBRefJfgO<_QmhD)`m-#+F^K(G{$zjtEl1< zk$$ygpYF(_#^Pu1#V2pbvC>(Jp7k8&+?9^aF2(Ptt1+%6=)>WNbuFPKxFk3sITVhP zY7ks{-7Dz%?uposr_l|xEJvMV3UvEyB)oTG2g^ox+lf7XCjJSLSHW>gaB6odaB2hx zxF5-wwcz2Op-G^qC&*NAabok-i5mW#Yrbo`GYi|>VmMwa9TzCa$xS#D7pQU6ZigQH znYRLoqaL&gx6IGT=e# z+$!H}+|nAup(zSAD?x$1xf5!Zm@13~+nUXC5JI%(D|pV=jrYwQD_=N*@9h$)!O7Th zBq13n!sY~0dg^@QFMc_YZU90pBOf_$@h=Vk{J66i{xpAlo*l;Du> zm`OWB(;@?a; zP<>Q`?xV#7zZ0Tq^egM7NJU8>V(UeJ|8z1KQEZepKX~*fHG=N`q?B^+BlIlz9&8*){SPv?F(o7 zP>CRM=nGJWZHJx=xc9mDebw9k?C(!E!Rfrv{rjgAKTmaAW3v5UNJD7{i7Mdf#pfTr zm>Mb?2bps&f4+6T1>{sM>bP_rkG0l_`aq{20i=Oily$3eO?@Plv96#WMPiKR+Ap0;1z!nAf2} zkRPu^Y+x?h7Frhi+faT;W#B#dfh&*~?gB-0TEJPbjF@2FMzYcm7Qm^Si3Bqt_Dj{C zg$xVQdV3gg3?aDOa_1q(M6ihRk=GhWVMB|~K=0o|?uQOgvKL2E*wAt7X5g*=c2{fB zS?jE8tlO=$=KJQeNB}`S3L11N&hIjV82kwSh8c5!Txg&O^(J$bzY>Z0O()!=oK?jV zv6E;*hE_9FQ4tHZ3uoR^7szkPP<6`Ybo2T1Rq%W?P47t?kh+v0=RhgdI%zw#C(})< zW!ciNM@?^fu|tgz`HF%-Wjlx6>6Bn^5Kih*$!w0c42}R7WPX@YJ5<@7gUYd!+UhKE zr|iG(EAg*@FXpm;H|r)7IZU1+Y`H2h6KzCpna_UYts;H(9=vCKRL>fwkADW}oWjrn zUSGH+YVf_AM+ZRm_+#|{fh@2L8i(ECn-Rf`?9b&WFZf z0lg-nXUuS7g13gQ9zAD*U9asU>uJknli5_Zr?nKradXu=9NULbu=zhW%^LLYsA}`; zsq5smSU7Z*%N(`Wat>10b@K%v)Ar2dPoJ5hN(&?j;-ivgX#_UZwqt(7?Sr+*0uT%x zAG(DUpfiF8Lk4L6O)sB=<@|?d+da-bQQe0gpZLk~r;i>SdGJBUtM>o<`}_Ca-23aj z|Gek;<8OcX*JkIoI}gL2x4s;Eogk`}7^a41I#Oze1yS{Y{x5pXMXVy!|NTlKX zY9wF9z0htWwP_?BN2=}yCe?{#8Z`5CM+*9j8mPT`;oxAqR=Vcm?U?sL`Zp6g8^61O z_c7nh3%ChZdgVc-ABoioO@a#gjXo&ZNX#_&1LgY-AZpGdqo)U(qq2QHNT|z1(ozCa z-<{Z!<(xE75R;yIVV_%W3#;x?L3m4%p#9s8=JV*raIC)MOCg@O#6S{KQ zSS3_yzT#q$33Qady*fYmx!{X|l)m#w998ta=nLvA>GkV*3iZuY&&%%TND2>ldhTU} zxLwxr_RMniO2T>}b~zQsG$V^3itM(KyzN<3LX}#FPhk4K4v$LrR%bc#Tk>d~G$>!4 z>s`-cd&Bp&Abt8e{@cq>=x_GlVu}5H0?R=U0)rOLz-Nd3j|_n3{Na&5Mv>u`J_Xm` zy(lF(w3diA{^{s%qpm~^z?ptJ;%?*$IFH|ie-j!Q?tl0m*meU^BoI}Cqdq)*774tm zybqye4(3gV2{GgTCHT$_$f6-5!Kp5QAl?(Mn;QoQm_0}fW+G2062B{sPcIZbY-FvZ zc{0H(GJ%-X4N7siqGOO?lMnO7rda; zMiIQa9h(`rJ?b_J!RKt#R#` zKf#}&uW{}N69NgB3q9&*N1sJM{h<4B*VpZj+JF7iPWxXv?jWt{^X}S#Akm(rVyb2u zx^;PmLa{(w%z}eTyOM#rQ({u#UA9NQG+|;Zs&f&cjSE4eVqd za$dcmlgdS6Z?9{CTY*;x6c}u5sB@W-$Uz|oFyCoh`N)hr4dtd1t|cj}4D>%D8_VeD zb0E&Q65l(LB{`UU@E&s-yMqokkt#O}G8>#tOp#BwkKQZ6i-TEnIL@);15~)%()ai7 z(}K?>Mix^6G>J8?5g<(`QVH_84m>CgRG3vZEqG`P(9v*E+ed@Wb=2n3yUFp*ojBAd z4M?**hRUN6b3`$im;9~RP1c%Fa}!P`64)s5+72~;S)pzQSAex_Sfno0C{;@SVkY=q zy6MI#wJbxH5BfQGq)OB>vO3cG=E3l}*GHj6n;Kew6)|Z4a-?_csr9q4=eM8#_w#el zqo3aDnd*st`eXNr$N&8Bvj=~E_&*Q7>%8;RKmQccG5m1u=ha?kLF=n8kTMW3Y7d6t zcVmR>xm#8;SA^ZqO*Maw1wR~Zt7@|dTbrtF(vA?DiaJd58j<5PjGB=FDog=t72!Kd zvX<_}$o5i#FFX$~Sj@pxOoS`oYJLXNyfC;6jE*#Dk-OR9!CoOZz;mAD&IVILHPFj` z4^H!qKvsx2%pv>+QvA+_pTtdR(wqdd@ zEQ$YpzgT4clzR8>Yw;wMv_d4!6vMk&>(&jGwFzq3TBjcDMd_F%@mzJz^{9GP&NN!A zQwkEPd!X0mBDc$AUjVnvIXG;)-sw6AkSE8n+CaCU!JgEDN|6bNtqZnjja#D4I&>&V z6u@oMt0A1V$y)M?V7UO=f4Z{qEd`lDL({T}JDB#X!J(~{em2eq<1Rv3QH0#ZN~oW6;cqJSYs40s z4Nt|#f%({(l*0SG5!}uBnL8eOIdqGAG9*3tXYK$f++rv<2C%to3-$?)g^QLCCsQt% z=_f;cg7bs7*gjwohl5gkHYhEi5Ngc<_?te)e7^y#ViKDbpd@@UzEFu^^OA;N+sjiA z`bPe~oPAUew)+CDn%d-Opdzo?!PhRs<^qy=uc6|S*eWnbX27>eBgG?~mtsl^on36;RhSFPW4H3x#v# zicF+zHF5 z`e8U@m^DfE1}##nDw}dlcC)eD<OEEWnYE2o^pIdt&om60j4+~s%JA6^_78JTj+IrP%Ofsv6ZcbOmk(ZMTM zMyA|y59iPyj$Rp=GG&)tx>#IUs8np)Xb=n#5(oqWfrNzQz8>!#r@La+YM$r$KA%7D z_v_tF@_3b|>1}GMkQzQpB`joORlz+KHkO%+8S-kULO+z2$Z8>PptfU6&=&fPWaqn* zXQ-S6|Lk3MV`oF!gH)W=AJa=G1r-K9Ik0)FQLx=RFw#rcxN7$_`^{b_EbdCTerE)_ zZ^f>ZOq8bWc6xR(^M*=iFO%=q^?HYDb7rI8e%p3;X>4h1#b8sdI&FDY@v?LA(z0Rk zj``|>0{TYre9c1Ayb}UvGCNKs>Ap|N;98p*dtUO>#!o3|JO1Ih{P~Ta1ZTSGRjsM~ z)zjBE@vRNRlvsY#yNq;Uz2Bi@f1uf+Pa@S<2mS4A&-3{ax1Num6xa zlTJz&%&t>s9{j3@ZL{S)P5L^lp;PIWcPrDbqz`5Ync-cjk54~%uQC0_PTcqb_D%@Cfok5tpPGgt5?P?I-A|vhGRL~DrYMx zTh=U>E?zQ^Lg6&>IlEu`(QaI1BmChnYd9+m&?3iwq-N$>8GBy(V<~j4uXtXbKP#Kw znfxD-ZJ&X$FsVo;b&IaN<;i z{yWlYdSC=ZpTIqP%y1NkCvT-Mbkn>wdNt7~p9P(|_V~!rTuwFC>5uyiPZh zxr2;@Oy6T~JF|#NcN=r9x!vLI*3Fi!CKz_j&Q@6HZT3p`LOl+JqiNlS_QJeoUaMz| z7{KWou#yo2FZITXWXZNDS(3BQ&02Jsbqj)4@CHtfNAOd13$1T-?6#_3|K?@8(Qk5@ z%apiHp51+Jo~?iJB`!@7irh2L6_7ulKl^3+ z+H@)E!jE5^`DqNM%C|r3UVcV)uyc-F512wJt9iIP^lQ`h3#1&4?u~ge_P<~6{OF<9~)!*qQd-JDSu}P-`mQEqu2cx|iqOG<495kkwA5sG#RH^E9G2 zJmq=EN2e+fp+C`oz$SSVSMA`5X}16ONVTz$dJ{{^ubzAdImuD#gps(a=$7bO)Tbu= zRl36x*&Svt9ZUcw!W@!;V6EgTs?K>)6A9$Ed?S7fIq@I(GkufnhcjTO7cof`=-4&x zPIngENF#IMLS~ka-381io1hieZCCB|;rhtjTqGT)OIai)S8!)=z9&?xZN;j0frkq*#( zwBb}I`vkIdKDvxfa-kldkirePjDxy`T$&91Y0_kcrz@XlZ}@o6u>f8xH7Sx}-7u+i ztw~*)98?^oJY{W1=Fwcs&>fB^=*i!xA=x%7u8?ZGoN8N5ui6l$A>-2zjpBAl4iiDQ z_^c@3pUDKK^=%ecH>9*NkArxj~Sj%=R*{ssd(|Fp`tJwggm@&pV?r{77DsNczE*eZWGjlQU7pZNSn3feLT<`X$|Ni=E&A#OD0nJT zH*LorlN~&FvJ0OB<@Zs6t0O(uo$S)IlM(zNQGGFdtR%qZm34+&(5i%h+Hnfi?5q>Qjz_jLYU(7+1!F3>hN6It6@XU?A{aQ>2zOr$##Z! zn>+<{VUOLd+tbb-*X~{v&q2v9x`3U>uHwD9-7J_ALmOpVecPweUy*tUIWfzncGclI zrQ+V0;Y!N3IvquhnT^`DQhNq zdLCaDY?Q*HZC}gdPoUZ?a#V5upuvO>Z{qJVf_Hy*-RH=48TV$%gsf*+njR;pR;EdlDbq7gXRKlA_pGz4)jisf|KyAPoVVpjBTR= zJzU)>X7wD+?A$~dmv0spGq9;6@JG=PQ@Jk zYyUiR26Ir4KZ~gyzFxEo?VLHE90b^*6$YGMHQl5Q*KZFx+dfZ@XWCojHn^r;KI+2? z&(MC2Ym8@0YFDmhIa@v1`*fm^;MHuK1~!D6znV!OnFTiensK9dtJxt&As1N7Tpw}t zZOU<=&aQT^4%%xV#xBALsfIZ{wkagTSa>3f*g)05jO*Ppkbu#>*}dE8Rqb|dwZo&X z-KyIjaW8U0M5B|+a-p@@i`}bqHSXl?w89_h-7Dhz)!QvQQJyjGHT_QShQZ-<6}cPt zN?jMWySY{^J6cc9t^)^P!N$yT)2f#D#k6Y@rI-VfV&U#0iuEj)pa6M*BTS8Bv&LKP zd$N;)ekK5;E6ra{y0bZ`Iv^K+8S;@hFSv}Sygj%-q>O~=k0kfWT&u$!BaNItoEUBh z8AX+%jL42Ii+142GDOa>QK*e##G^t%jyr2U)Ek)^my@WEeiUYsDGujAZ0wIy#2EQ` zf}S~3QB0=y1a3@M^d%_0<1w|WS!EB~9HGzGL}$m^B5NT+`=iuoge3C2Y-$wDDh((w zgw*MYFq3qI4#7+<`YdRIJ0&%=HIU_1yM5kd@n~3EWNt{y-hj%M57f8eQRE4#R-5aot;;9f>!&$ajYFNQ*A z5M>-RJG<8_y}fL>TEaU0X}r&)c>^?izYCm)Jn9xRQTObkw5SPrDwoLE@C7aIasT+{ z!^xnQiNe%=gS*LdR%8sD3V(FyThRyXV)OkEL%s|Z*wP*p?`I%;USOA$7!o3e3>-wl zu(vFzL|hwe2_%ahXpDLf^@smQAb=L{0#DAVgO+`jr&OfFb6`onJd1pd3!&^2}vQ{k!Md3Xw+1wp^Bq~v(St|u%0Cv>No!H~a9z;pi-JEE#qMS7 zRgwfdO3Jwj6<3<8YVUJ4W6NSM`ki<>_7L_Xc|jh}h^x|V3A6{LhRla%A99QPU^IFH z`Tjm%;jYSKl3t8-iZaM74eUSgeMN5iBlLKm9L#z$Ij;rM^MX^hVRNWmgYF)$?9kPa z*gd6napT^ecDI*vdYXCUv@6S>vHRwZ6vv?1Q`F zhauPf_I+lW`&KkhkAiLPv31nV`^sQ@7)qna5!dkg%ub(2>pLSch!%P8&w8|hWob*4 zF>KOT?;P5Z?SC2wL1$qIxi3B)m_ZkH9&Y9+9^V>&%l@R##70$yIy9Lri$82Wq!QN3 zrQnHR%|UxGI;TTZ-2ctgCLf7&`IIgNS9oN55}%GwS082-MZ1b18=O118d4f^Zohr! zQqWB}*hxWFQK6?|ePE-6+2D2WxJZ0ZvOBhY!DB!0GO?}p;h0>@vPd=yeGf$qL6?Hc z@$`NuE;wlQ^t-Q+cjY4Wz`j2aT!K5H_|PE4olJU=uDy!g%ibctLiDx&OVLwFJz9~X zP)ne3ug6_Ue!v8N&C>9AsT8Grovfey#yW8)bePYA5}{^gg-fIrP=>wix339`SnIB*m_QH;wzG5X@h1hNYY|N_Lztx=eeD0exRz`kJp8@v!?_ea-y&?*x_d zsfw7@l|%j<2)eSjxYKx`M}46{-+2*hhb$P)Meqai3mZ^-zC7^w7Q8*)hoW?H6oeFo zsKBsK!ZjIXBKtL**Wo}Ee13EAH$EoA@ExWCI}J2@4#yM+&|2DhdBPcH@%|FSE;vD;Vof};upHv!t6dPoUtCbW(p>1R%$+3MTOb+&B9zI7CPsj*7!Ui;{VP@nl6y<2*; z>TQmU4S@n~Bw442S$h+8f|lPYV@JOuxLR7aYIjc4pXbxZi8*tpy|M%GLCb;Kn@x)8 zcL5=dISsABC!8`))L5gzXHc)^G1=q9qZ&HPo!E;$VIZOeqM@GcWgpZAfgUJV^c=R6 zqL803hzebS=CPTb-NMnaq?&|&601ANJ@(+Ut067-I$Q#Q#wN!Hl*`VkhOhfb81tmY19+qx*nq5Z@F8(T2Yoa4@ z8g6w?Vtw3VY<^q;DZAxzL2FSh%Z^AvweXA$pPku~8`Z@K><^Qq4Ua;O)F~*!d9rw? zkso#qkJlnZ_sQKw$SkfMsu?^9eQfc1?Cw>~veArkbg63DND{SVsb$#!QFq?rwUn{z z?zc>!M{umPFSnTOqt;PHN2^8ReNlAd`J9z zTyboL4nxMh`v!Ghf3q`^kaDcin4n&+O!EW^P}h zM>XQVKEGbVeM?#}`BNoJE%WZzZFAPS?D_tM_}6ZB+wrf@;``9PS~H~M0_bHzaLM@l zpHfMWet%Z+;x{C(bv*y~XJ6vK-Xa4bY4Xlv|KnE^;ZMGOn=Q%ql;rQN@1KwFkU9DK zkN-S2^_P>6AAbM-lmGhH<3BbSixv!vDN9Bi1&@|TY!e$Qo|-J@G}DCde5E3YH>7>$ zu-i@yIWNu(D1+}y#z;4vK%K_k9&)=fO4z+f(aA^;t7B|1jfKqnMtq1WHU$Ry|313X zNoby3I=z-us^iS(PFz3nQa7ZX(PfgBbDb=_7VQFDw&oK$5)K!Rl|Uw}VcL_48}9V+ zXq<3eII;(i43mM8cC;TV%UIHI;v&8U9g{n|s)>o+7-SJAKG+_JHx}ir(iK}4Ylj=D zAfeTHI7NOF9|0S!$YrQKqY<-g_FVX^45;z)m^d^*Y!~!inE`FUN`ByN>bns@kR}L)bwXu-tH&uhew~v^1)X#99Xrp1kf9TO zdhqnUln3v$o@~^QFh%G&_26Wz?woECZeBI)_g=V>+dMI+;d;*E$%*3U#p29rJw6Ci z=kc-2B!x(z=}X8Ozrvg~TivK?h*iYZ#x}t`szrHL6>)|cK^?wRDZKVa{0Uo8geh<) z2=6AX;=9O*_Zvy%cnb9h>L}>GA5;>zJ)Ld4l3s z+O4Y9;gy!Hqq-kDi|WTgcR->7?n0f17yXf1Et=EoqfgfBEOJ z{Xd5OIqADg-@P;TKmB><4_n{<%jo~|r?|13fBE-`n}02T_P!zHRpMO3d^`?0qt(2o zbCjTt^f_yHobFEcC!LV64DNI6!SgsB%7Ow1pZY5OkLVo+&?xEI_hsQlG{dPF;1kn9 zVjDSXho}~P%yV4D=Ax0a?E)apqE)Dyugjwzt zUtC9gX1qWPnTQti=jrE}G911LE5wApu#S5z2lYW|s8Cn>Q77iIf$;}l#Y3zNb_BX{ zRk6?9Z`r@MA0Q=Mn9vF7FLP`#3`}xnn9oREozPp(?a@cO@hmU-!da^5@loZOxexCv6& z#?OprUkIM70d}&G^qw96+lNyRNs)WbHunCr-#=e7jV!8dDeJ?VlI;g1RqCja=1CB} zE50F3g+z1p@G^CGydoDxn<1_`ULWg@pXHtFz-v0HazmeT9`z*O(>ju8lfPq|T@CMJ z9j5-DNJKi3eLZ{k{l7o^cUet;S4r;C=C8?s$q2)#7@hfl;Z^J=D>N33Nx_*9GBjk1 zNZ#8{Lpg%3r1P{r^otKOS^x99W~65mddk=S`ER+phGI?=}SRjV;279|ujTU8}? zCXAs2>W34)7;92Im21(UmWEF89-2q1P(xo=9T*N&`f=;v&|aXgz2GspeM~>vA%R3Q zR~m=CHp~>Icdc+Wl?nC) zr2E;+{yXHCdfrB+pOOsE+1sVh-iLK0Fjoa8ZqoEOPrv-z!@u2x&R#zCZ+xZrD#|K(3?v6hD(;vV4%U^!_%eU}Y!ylvN zdj8EXHFJG$dd;;~F*Dj>Qjv9RB{L5CsZQrbvY^LQ;H9EV!3Ny3Uy)UNHI&^ytTP$Z zHZ8T=?Zbjk^S=CIjG9g9c!EZKA#w1?6>ZM(PkHNHINk^gWcXwsYOH>?b(fDhc!ymf z1G6wuU{MNO@5-Z%a4$Sb1#l5AA1mcuRG8e9T%di7;-ne=+G#v=0gXl@Q)i$uGQ!H0 zk+J1g+{V@akj;{u8X{tP#c{h$o0q)Gg-h=fYttY-xM zb`?y7R*#W-A?G)?K|G0sd``@46eI}%hiVO_~(@%G~ z17xOJvt>nX*KR7DSva=kFttX}$BDwmAQ^N($7?%$I})yeq6n6u8@-^7RN^$$iiM;R zm=bR#x9Gk*8G&z5cOvSYK*}&I$mlfN>2s+w=~2Hb{8j9k%j6$aor(JOCugr`{r%a0 z&0IgbkoDu)Q=}{XA)||&q?f1{r%6j_dhgb+j-uQ8I^))Re@Od7#&*WNGoPG+@BZGg zU#a=AY@VtvcJ$F`9CD5qYp;{omjfe9ccea{CvG|>3uk#(>^OOGrI8Xe)60-R`*AV( zgVUk!x3aJJP&|Zos?%|Uo4&OPF)v7baZOdr0?ed+wUgZ!7@lQ5Ua zzL&&4NQv`Y!G;;p%=nMapU=E%nIFMNT5B!daBT^R^hMrbpD`%+|K|YL@J_D5eOVKJ zGyGXtE9d;OGzG=r^G39?}$-HXmo|Jd4)74+p(tPJh&vI zFc=2$5f`XZ+~ zQ}GxC<5H2(r!M$g1EL^FKy%RM9_1|TcjvPuDsqi(72|slvsLfdETZGF*&XYXbU)c^ zeJi(Z3g-BuklZaKQSxMwa;dX)2+do1m{v4l%w=(eIyXmR7bw z5sIeb@EN%d9*;k)1R7Kdxqiw+xAD>x!$39%Ghg$kF^5Qk96od4MCbn)S7HVFY#aM{ zIcfp}6BFi*?lxxeg}e8dNRKdMncU2BrqItlvB={bdy`BSpUig#vqWaKA3U;}4o z7nkcEbQ|2+C{onUQApff=(KwrB{+l@*^s))B%8J^vjMNQs%?Ubiz&hz1$u`fsNc?o zZ(iSj^T<4EmhtJf%}veScrywM{0>K7?YzkR^iBL5<9vfjWD?Ei&N=7CUX}1>D&{~>V-jA7tsm!q zTq8l|_o(y!8{A#a#a|xInazqt&GMP0GqA{O97TAk7Vudc+*Jp^!x3;P_^J4I2xLf- zv2(aL%VmPYv<&^>{9!FFkXf!|ZcG_k+?j}KCPKxe#6YR!v%Co5Y6u1vAlaDIIA21$ zx=}r&Hc$(+L6#XLx9UFnpZd6-gf#Zz)RoGD=q{nMM77@?Z&GH$*zJoa2O(jRJ0q9u z@&GLJ{J1vAy)&_uakG#x_2gY$glIK=_!6u%x2#Tf5$=Huj%FV|n@5M*p!F3||J)3j zXI_3Yq#b_vIL!LIpl_MNj)~iFK{Ue-?-!*9X;DO++pi*Nw%V7E=6;B|9QzQi@&nI3 zCbCXf0ekA!-P60(+vp(3Psn9vDMkU2#=FgkJ3`B*vToC{NqVExhbAJx=37tgZnJCF z+U&#YrS|UCB3sVNFj}x08zlb~?b3i*P$@a_m!zTwd&F~yWt)z1&fa8|)LNlQO0%Nu-yzu&E@6I^sMuSbJ;>cYnD;u5`u;lu&8hn4U7!n-kH24!B*dCoy{zUe+kw){&!WyvHsaujFad z9T`&3Xd2;F^pVaps%j)5t`8#66?G;QoQ*At)Q_N9!pdR~%Zt9uA^oz(?2ZgE3kUndi^&95lVc-6F>c#krI>;i4WT&Rj z7thzD^AlUgR$JC=_MFX9TwCPUz#AHLOPEHRg9W0kLtHKyUaTCe z`(Wfe46ldzT8@uF9Ib^0nhIwwKdzsNn?)H5nMrpf7xIXU)asfeLZY>gltqn7n^<|o zNB(p^*5 z7A>&2Mk4#*{J|%riWoRlBKcT!nwk4e(P+>xbcX_b5R<;I{nP%aKp)CsA3atg+&F(w zPM|nA5x@PjU|Fz_?M$1ln?w!;0V72^vhi{CNv=a@i&1>Ixx{;*s=IjQTRjang zHcK{h=*DnvI`g*N>qG3$Oq&{Z-17CZjS)x6<}iEndHcLQi=;FAM#p;HYQL?A2}tJp z(3;NHx6%e1Z32D20}WZu%F~s4-eOsceQ#`wGEx(~i}MSu)G1}~UdrJOip(`6FBq+L zBy1EyJx_Da?G}1)J@I#(3(QK&!bacv1?wXlqV=R%XyZ-sL$SlKsfXdZHznC&xKt$R z;3R8!LWb0(N9srz%RiQNtUxP8b>~5YU!Z$JI^5%9Gw6suJ!xa!mv!uS@Z&!{c~7T1 zmYGzm8K!?~CJnmuNUvH3DMskHGLzhi^2C}Wizv;SdB+Wthc(UQTgcSzNvOqZG#yt6 z_tFqE5#23si4gpjB`A-XY)i69A1OOjLvqFEut^3#UjW9VIPzZm~<{D(_3|ML9z&qJQynYlJyh6?lBAG|-L{%~jd zqbc{Z+!u1F=cPaEczfNRGrjRzQdVUflj~I*V^kYg_N#p#_=~9uN%$eFzJ(e%{g6Yd zCp$(zXDaG=XXHdgc7!7QhP+iVfn%;F#ve7T=um26OHdZsAvb2LOr((6Ay-@Q zbj?zsv~rquqgYLcubCQS=YO-9dds5oc*i-RlHQK#r-M@^7V=!7`Qn@$Qs=4lFz34C z?W*d;c4~K9%xF|%qz2#QOoTPO4kn6_-(C#e`UW0Yx44wese-xD$0AAKOY#7xAh=v) zS6fE6^^7XeAN=g?&g&80&Q^x@9zH0-XIw=Dvt!ecMCp@ESF=~v77Je z+NouCk1DCl|S zev8-ULd)G&V04r+)+oE5qwSx3rLlT5BPuzQD887j9@ zjjBVJpye~I<)(Wfe*?~Lltq2l`B1ld{1i|E6P z@sPPRg=|QQc{W{|MU6bkk|c(O4rTUTdrEdnAob^r7bKaTCd2IWvnH0T)s^_Q`7^CD4h>6~gI=7NXZhO%;O*T>FB?N+->$$M_v?qqtC$NNma*}E}>;!d`u zMh{SobG!f|>Ja~5%qKjsthOjDGBo(|H&&8M^yXTsg2aVdlXvbS?g=xw7E04K^0S^o zfixOF!Uu5o`8bm)Be|%_5SJd&>0W{l_!(2)-!jE0r3?1Yyg&1wKa@@W8@$>-KTZAX zhZBz;|I=T-e|+QV&h)@c>Wiz8B(IsW7Hh5D>zUgmPwsty`w~urq?LV0Jj6XcQ|qg6 z_Y~ZtecZuQXiKL=@53HY9-0Vi39DuMmJaQ?m71;}T2=wBfLffBq$(T{s|TT$j`Doj z;?QI%Uf+2R_6oud3tU1iEhYApW_IqKs%d}-t^ueJZ<&q|9JN?Y_!f(g(r*2 zv@vp>vv3M#s2uFYX5(#C9NFL82N|jQo!A`{^wVZ$7*#w& z_o2oa*JfATE5$q+MV1t@8kS%8FPtY2;Ov5kjK^#9)o(@?3zzOM-9i02v8;m{FtBV} zELynn#<_5Hp_CqY?5Bq>^oHO3{D{n^hS^Iq;bhicGhD=16aUlri~66h8Ow}lWR3rh zA0YXsn=k%tW@9Sy=~d|U&mKRYxHFkf0@wHtdP9TBWgfC>*Q+<3uKx&>OU~d=AA)XK zNX~*uE-2h?NNW#yp_q(7vDTw?gJThPSGEjGyDYjyF&$GFUzw1SSOu-o4Yd(>?UBwS z1?rlFR-Vt&BS!S-w3n&p*qdzQc{+v1uZOps^i=1m zZq&`cV@ooKj(IRm^4@N0&*{u}|8LaMS!gjQkIyB?YVYyh%t^H2pzUJTUK1y!4ylak zpaU4ACdrK(VuRHX-NuxoPEjX+82(fy!ILKEZ&Dkq3vEH&>m{-CW@uZuj{090>6WF2 zIz>wK85sv(?8ol)IBoWEHdN)ScFwHY<>jTi6}#2F+OUQ)(Jo(?P+v5XwN>P3U9Gn@ zt-^XBec{gX)IvMG*o~#}#oR?X-0r?N*XV*JZyrH{Oo46Y=6}z!jl5&{BRpuC(ZxLY zTWFB)8?#M1PQj4Z1I#X_NEl2v-F$iT=jYGcXH!YDNy1(DpFD5wr{-zvkAIxK{d0q{ zefU?*?bRkSu zacmY(+(c|ojF$8cakNA+5;+=X#$nzecA|gDVQK$lfAqi{bc5$CN36v!ExV5;IK@xE{xeoP?d`B1WE1gX^4Ffm@%2(v%HWpdLd^N!t zX)P8wv9&ay77vyh5R3J+8ZLVFd*7*=vqX{f=XIX5R^sO@X7>Ek$<@DZ3_Ifj2KPGBvU| z%uE`4cC;F@L_?G^s#>9erq~DPemvYoKFQU{elnNNFsoN72UKn3IyJG6(reY^6W~fa zX*up8%XX3-{qLYdR-SlFHt{VI*~O{Nr@K<`r5B}rowk^=s~>*HcDfOQwCAMnSoiTs zGQU1y*L9BxN+nm;j#k~M&81V29x){vRYi1)V&;dbupY;#BxhyiOnYnJ5~OoUue0Kw zddT0yb?6&%Vb6wM4V~rd#L#ld!%!=kg!3UCVl`Uj;eaDxLHV}0Kd=+ccD9)5y$|h? zWp@zu;W-$l4stt&x4Upl_n_*{*vs2Nm+q z<8(Gc^%~vEbqHx6osPT>oxOOY$XT#u-*BxJk=j~mOSO;N&a)f4ZPBk4(=GSdO)yr* zZEbcNI^}%3-r8f4ta{lDwAuPCHB7Ybze!zqX6k$O{3qv&-x@ySYKjaczqntGTPp1j z$P>K+U*pQb8S$)SmS?Y@`GM=uGD_4|xgp#T-YSci-rWN0?2$X;Pf0Qmu?@rRR=S>7f@6@DIH;yd!6L7I%&`(hT>mCOjSW zjzroJCh&(o3$7sZTgHC1)z@W}%wEQ`|;zan0i(mfd#y^A-QWGNQBb4K$N zTe5hl-y*Yau?BriueH~jYN^KgWLoXvIuzNf;767*Wo##(x@x(Y=eL^Q9RmB(#PST? zvV7f6W_77eZOh_HWv_Yd{q%a%_I?udGFK*+EvutwoWy9S%2#SE!z=dX{>8S1n{$^; z{qz~P7wu;6LNTtMy4Ry@ds5zH(}xdPZT5k!ZuFKl?#6@9*e|uB3%Ch~#VxxbE5{FL zhP-m0ZLXj&Bs+`T2NNv&i)7AyEcz^1AfpIcp@heb; z(3e*v<|MUiiZw#Np2Zz9l0b@~+C=VX27aD8WphjimFEcg8Dk0?c{u_XcNWsV7qvrb z_-)vaccJjO4y8a}?SSKQp4vu)A}}fFcjyBj!$%xFQ2QVFd`yn>+17l9HnHHK$SrkW z_bzya^yEHYr~eUZ_hR;NeP~Ye{ok=Eu&_7%0@tIIPBJ~HkEz)+_&Xt_^3agk^j-GO`8si_JlSt19Wndh9*VPF?zu6%D7R2{2GDN~I(=;M z>Up|zHl6nK^p?eI@>Qi(xqSXj)v|kaa(-N{{=8WKZXWcA9qb`rY&686n4YD;1s zy?<81P;73TK%%h4^-#yoD(YjL%tXb^+KMA;&A zkMPv@q3%c#mqjL<8*HZA%C9IP9aj(#== zIjh;rjwKsAf>cYr<^Gb$>@t@wo@Wx!@uvLsN3Tlf?xXEVCrQ56pfu*0*63Aa^JP%Z zrHeOKir1WW3)2?Y+R#?UUcQ%|6_3YmZTuvhJP>&w_0jB&b}l=qe9XgdULA*BcHLkzi9 zaSD3DO0I7Y4tzaR)p^)QCixASo%svM0eL;=haBInH++Cx;n72~L)S>;EDBF&5;29+ zI{nZ9{(zeyDdK$Q&^JXQlvgR@Xn*Z~8XA%7Xs%k(RG;4c0-nMHo*#!t(22L=_$uQ5 zm+gCYFLG7S5 ztxFH7l`JbRh@y^7H?FWAekc3ZplfnB8&6KN`^tXT!8Ua_yg%M6XuIKD2&AD8QRI3< z8X$b>A>$<-G9GGyA~ygN+kkGcJ)$0FOWWHl!>95R#R%LjBfS4RsOyH<+*B*;sAcV> z{t7uTX$b>NV^Z1gr6n{a3Oa)>RUJCh2kfoWj?^T?lBPMMZs)ls6*zt*Zk~zs08gle z9$x%bg{8tB*?^`}M!i}mP%pylhh$;JP?E9_kyeBj^BMXbBfX)ZH4o)!Y~vig&NJ9U zdc#FgH(Klm5d5yAN*>+!cv`q(xnz${?)JC}wvK z*DvZ8-EU@Jr!KhPxXdMQ^2~_~+ShG3WF2p2-<*GQ1Eu7pdFO&|p_V;)8NSJ@3*!q@ z3x4+HwK$+1kh03m*=Fg2%dAJmDnlEgSj;nDw8(Hx%OTq3+6vd@aJIT|^4T1%o5ec? zsJ=VNp(wzgCl&5U=!MZJ$W!q@wXy-PCi^${Z~>mmCZ3l0m{?U|q5(%L_T(=9yg7QuUKDd$6mw#_NE^!2Pfxa^GVMMtJ@GYD(poa=dUe&1 z&!bSHRvw!>-ig;TjXd;hJhz_XQO8G)^&h={L_sD&J{t;QCYm2t5w9a>&dHokO!r?* zEj2)vXYSz%vbB0iRVzfja98RIJI{3eQrI)x93@QM&g0oo@y2rUCT!x_s^+;X!qJ?K z1K1(VZ+5%4AG7D{M|~nE1EmoP7RfbieKb4pk#XFOxQcPqO>8(FgU&RkX)^`)R>s;e z?$i`~l{uRh)On?B*JL}EZJRT&VOgK#*&N!c+By#-rI)#O(`L@*dGdmFFgqJJL$ zy46-HoB+8I)T{Vum+`!6;A@$!#nyN#rXG^j18b@F%++~I$BK4UvMRhk^P&A_IdJdo z^zQV#YFwV(YF{*O`~L{&a4S57m01Rl@-DpHA~5M2JPj3V8@iFp$682$xQtHnGOD_{ zCMDx}$lJx0_MD2+8!lOh~)Q z7m`E3wn5E%b|?i^s-7&(aVm{z90d;F7kK{a*tCv2-{#Hls$o>8z;fx>p5>EOJ2e|! z8!4N5cHR1|x^+GuPQl^LN))Rpt8F-0T^n9ffK$okYPRb)vf0np+o1-mo#zSdM(@zO zHc7&@hBMT_gh#wq$d48|Qt&kw*#`Jo#*Hd8uT^X0+;M$POil||brylmk!o+|+kLc` zCidvof|e66U*&5MOK9tShM$=GkJN5$DjML9Y(WCCRo3 z?^;cyE%L7HjI0>Gp%Ra3Ia7hQ7(-ky`_~jmOS*)9b_vDk6Y?|`y34F%BV?tY(?%Z+ zXsgkJEpl}_$)dl<2EGr?gqkg_FCmtSw1Y`@FK$+h_!72U8%0oN8hIxA`TPo3 zO)EBAD`Ws0r)0mS*;2&TTxTn@-hd7;x@@EKAQx!4m%R8E>x@;(u4fcIzSA;8mGThB zWHoQB2J1y8+g`rvTbZExzfWqTm}%YcYL+7p1+xrCaxJrM$>t0+lLzdYIyOg~4yY?C zc25~w+HJuf*~q3}NH~=24(&O}e)_yFZt#e-QyE}4?&CJV59T_iq|5q&%L{E^T&%JrNRdv4NAwOBZ5N0G!?y$ETSl~ z95!MqyLEHaJQL9ri10?Lj4o2xTy0)?-9Vwz%alI#u)wP;W6Rt@nwl9EREfmShUpBt6df$~ZU40(V~~ya zeg&Jouf0*sd?uN5XY5q%^ti6G!>vI5l}+Nqv@>?6NN}O9_pNvHoV9MYvZEz!cvI>u za(1!j8(tsB{p;B_?mXCzrQGOu2+W;c z$cE!i73Z>%?eXY_ohR6}CZpcX#QWaJB%)(Gm+LOaH7j*3xcks1w&81Sgt~bl@Kw;4 zB*zX14E_+vn%0mhG8)H7DX$4l4{N9X^OI-e;SV|; zkdUI%Ncz+)eHQFtYD^Co1@19g`$ikn%}x$MIntPSZyv$8&gV=E7xpg1Yh^xx9{~1yc1gs8sFfDN30EI|C9!$$Ws~9XiRtiT>A-dLjRx-`D)j5_Xjd%HXWeeoZsG0) z*hMY4(;jej8h0L`K%d;!Zr3=ooW>1U#GH~9upxMI)@!$loWjo*^qiJW7p{;x`ha{g zblMyS{AWgHay3*nN;*iLeVFswPFix2O=fLhwwoU0;+wZ*z#GflmdeZFqWwLq1A=%>=n(E_S%ts$!L!9NdwFE9!pyW79Ch#}a37foQa? z$sDUB=_Xk;6?KQ5jq?;$=fluZ9A{2wA5)^TpgCWqHIYk=9fn|DhypKagfIG?ngcc+G{Q^#I+ zc)gZ*fGKI4Cp{07I zj5p~;%f-cFbnnLHOZ?McG zHd$sR%<*np#@WX!xDz_4o4VGDN$<<$y*h(Br)0O)GYBo=6b|@I-@K??5)V6lS;*j) zwK5~AM?KaMUBm`zkn0n`-PEYIK!^PFI3zsXDP8U{!-*OF^rx)S0>*OCzijUd&cADkAm+4=m3mWRV_x?UTI-@H6a|l(c z_rBxXU|LmbL`nc!RX6!dw$r!X@x1f->5P<-Q*$RB5MyuQe`|-j*aPoX#-FK_d!~g1 zIg`Rh-eUn8fELK~f}7_eNn~yKjnASLzC*WP4i(!KbS-!^I2X$9$D(UERgHN3&WN7{ zor5%98`O?B?om+V!9ASgr{KW#zGar@l1w_xX<4x2+ESt2MBn5*Pj{fO=h}r7fqqW5 zW}A^KQ;vqIkIA=xxqZ2h>omeFMs2AB)lRP%VY;-2JF1~{_SttFu z8y!n^Qbke~dNSS7C+J-+z}?A(@~S)1O*LaX+J=Ml7Q3k@YAw1ZK@XFAB!~JZi&Ij^ zsW}q2$R^p8SeoD!}WbW0OLcCZ`^kl5dgMRZ(hkr@O#K-c@_=l2)RW$sK&l|EuBkwwMKqy{A)!v+)(;KH=`c3_U!t?br)A-9T=kB2Y9OwMI{c`H1V(#MHs7W!G zWQyna`x0tg(cFYdNTMyr<8yVc`n7XTNJmPX^UoU=L~!9`^F0eGOLt&wjzCK?E*osx z^%=4t`f<k7If{?Bn{uw#_BY>$>YN$Smhsi!q+v&I*+N6yOU0nae9wztQ2Oft%)sEvfaER z`|ynjGrbgLpHdUu0B=JY`7r!G$s>Bw6PTjno43LpmXU_EO7gZk z?Y#wyr5j$lYe(&_ar>B*4Y~_`y*qF&QEfg(gVD8Pb9Aqps5WJAFK0Hg*&q#UOrwXL z-EO5TBo%NqmK;vaR$!}pQ;plPc&&{&XfC~(7%F+nWzyAylcpWvT5w_50!M_Q)8v2lQj1ju9 zcxj=m7yr*wnedt6R-+aiXD%+qw@^g7_B=#Q=i%nVZl!}M+az0t+GsNB;zr`co-a3LKxl=0F+T?Rl`>OA1V> z=*htqb`-ggQM{rv;wGx;a>-Ru4}Q(6gB zuH4YJkTHpzY+D6c1q0FPkfYF7D@t$*NFZKi#TaAB!cH8B)kJqFtJK9>^)a!g30|^M z)0#YaoNO|D!A*FJyN~O&?dr)SGPFc+_(?mr@d95|aipcd3M0o4-{<7U*lqw1sR zZ({pU@0O{GVjRl0gzls(>JsJ1;cB?)&2jxP9ZW`RVy>XgxS?p}RMpd;wjJ(a#@8Hk zEqF5URHh(5Rq&^m%d^6lCAlF*q0NyK(RmRQJd1f@H^cLSH2aP~ykb1g8Zx>C3(%h% z`Bc#xcJE-&)wk;ps*}hi6U;Cifm}!j{@@&GzPE*ppAvL9bH1kCIJs{8$dkS7E&E=HRIV5e``T;g{F`TaX0QX!>y>>wrpxwYS$)Fx%=FeE}gyBTI4tly{g!He0xhZzW?0*TJXr>_Gq_E5gH=C?9UC+L zPN`k$7)GU^M|ZA=E$DPLJI}AqtPSlv*mXJzSDr4}*D5^mV&i_T_1e6AX>lv|AjdPa zQM34&>HO=a%};$j&gv!ktErdMmKrDw0~^KW_@x2Vz04V$B_xiU(1&+y_d}=@v)R1t z?uD=NTkh*MNJGE#G3O39GxO9$rARvcIa0BDHn~f6UN&)PCL&K2fL}32hJ8KC!&r?` zn}fo$4jQUZTRupdKAL}|7cEr{6L2}|w<5N_XJF<(jA>@?*Am?xkseVK(}k9|PHBjA zh0R0_py}X5#MksNQXK7!=~MKf1JOYs%Zpyd>69aHMolk`8I$+mp~;OQqa8wE8{ME1 zx>#9+K0=JnzKwkF8n{9&5E>dHs-+#`_k+sOoIE?^f~2MOk<-F<>a$>o_PZ_nPsJ`y z*2n&7sEbbbwV>OgY0}b$cZ*0Q`_y;cE!nQx%5s>t2ff{a8gI(BeGMYtj(z{$fzJ)S zd98TsI=q=ac#=-^Htk!B8`bMw^obX?qnXpSqec~PC{g_mS!>r)HjP^~_7W!76ILg* z8KbrD&G_QHO-bK5v(|2PS^Ig$#_jzUKl|Ul)tt>vn0*F^+1g+&MMIOllMQ#UfI0gv zw1x_g2ZmqcR^he}!gm8MoLtf85Qw17Nble#bx6v^_oZmw6h_ny6JdEIRdrB-%9Xmq z#wcH0tVX6CA-C9xW2_@_EGZ8%ZvIhKasir&arQtJNi&)Uus5rZH{oP;Bxh)h5cKoO z+AU1R_m?16F)f3RGI*>m$&@&%DS)9bbQ{q}HM+_Zqa@#E<2AD#{X+Nk3CZz$sQ5=9 zfeMb!B788d%vqr0R6wME<~L#Q||Bv!|ZV+SCv&c}Iq7Y?AvxEOBcKDkN_^e}Xi zJhMkZ-@qWvk*Z;Q&Fp3G--0sV?YY3@s}9zTi=3xSX8#IUW!XE!o8_xoRBj53dAWQg z7wt>ITHTTi&t%H$zE}6@7=&Eh(wFhS$e%x-`uK1E`NNk##{ZH#*Y~>qWy()xyvm;$ zD+Cf?vYa-GEl!Jo1=+JzELZr2()Mm(6VH};b~VDt4EHI1)eY)e_lC^ zl5HTmhSbDNymf9xJOqcTXf3MFZgOdQW2@tP;xgkqnMvDmV^-p07)cOjD4j=2k{h*E zY#eM*PV&&eWRsdZazq=S9jj+jJx0`1t1=#qTvgN=xriRc8J>&JD?6ftXGDYwG#96T z8!oT2q9Q);FGEU2-{I%#-CtyL9_1sOj{LCp9pM}=a_8V{$lg(*J}7d;G94~vb~)&9 zFhR*_Rn8AnVIBzmUIf_y@GO_kU8( zUVNdzm!SCZ{IjjU#Xo&Mb!qyu>C_)Sn)>gPTYuen`cHrT_WNHR|BuIs-={;S`KQ1B zZ%^xgsDs7u6-0)Ad4Bc><;>IPAOGC8ptd|(hSq_i$-Q*PDq#;*MD;%dKdTSgl!26| z%lOs43ceUxBE1>j&ikSzLaWFld#Q%0vO{i<9*ZwZs75&^jnU(ORO6M)QQm>kG#Wut zp4^V-{&r*oToe~tl=cV{H1ukzGEB~%NG^Bd=99=r4v(S7pN{EA<1nUZVTWK!6hp6V z#&?{p7PQ$^lyP~nHlDXD_=hSI2XQ{=qwk}?$UzS}t7wif#55nyCOa`T$-Lz&K+iC+LtW*nfLPk;s@Se1!Ml^5%0)d=y`4wDWTR_U zFx@rb1xj_OAmKM#ZY(8|)f})+!&Q1@9%08_%53A3=`MQQ0Yj4E{PS{w_&jrF>d#X* zo{j&|F?I28zxnIM@2@@i^81_Lzy8bBKmYbmU;gprpZ=K)iM2od8{fY>c5?FLAII>V zeEY-1+ZpTlOnRKf843hO8%YBvKzW1M{c>*PmDQH#)Xc^h?i)H ziLjQFEF~$HUy;qsX-M0NoBYAas+0v3)Bk$f^3G7|x%BQ|#p33#V2*X+*Qb9SarW!X zTeugR@cK_f1o%A7cRDQX4{7jYLxJV(?-h z5=lu(*wA1w5{RU8mlGNY1|zBIoWmY^@oFTPa>_2};Ns$imCANbryU&xK}L|#(SaG} zeeUNW&2DOaXa4`^`MrF{~k>WgERO#+IDV z9xZ?{^R$PdC6mOuf+Subxs!nVqTfP{NT@MKk5P{H? z45iQDldPgE-XLcJZxxDy%CxpLV>%uN_)2rBx|HGfgdW$1)@1ZO3AWQMU654vjNe?x zN&YW+g})P=hReB^@;xw1e#t+d_vObA^ZsYS+25r>-8%jo1-|~KkGkm7?0nk!`+9Qn z-90TiT>I1G zh3X$K&i{V)?%z@#3_M7MUUGWo_tSpTSWe!*FkpLw zHcej-FX(@K{DAj^tOJ-Dm-5?xm-IVj{=`8~-jjkq=P!Qz-GR$^^V&%(xCH}xcwcVj zDK?}k+&@*Rg*ZwoQu0y)C2YUp%hvZq2rcR=)RRX z3Dvwg7nN#hzx)CzymBLS?{@M{4v-;h+j49>wyVWPd3*JZotbL`KE~OX<#1<; z(aPPm>v5#ktW@DAP~i}sK!Mxx!nEXOS8^3PLfK*w$vHQk=@u{AzIjso%YXmr2Z;Co z3Vr4XUb=y~cYjZuEqFNgpy0vD`+xh7r&Is-pTC`a_QQ*D|Ja8=Je@9>&;7aO=dp#m z|L_0K-2Hor*<@3#jlb-M8hi>O@G%G;_ANH|A(4@-Rh6JBZe^k~pcI~PD66muY$SY1 zs#Y9z;!ep)2$PD~qf{~3s9*v-LOx)Cz12c;B)Kd_XzGf%@5%GZj-YKdXOdzM5im#F zw9oba=)MVN{5ksT`rro}AAE{ReepNP^iOnxtZqQ>Zuw}GsmFC3|CdP(TgYlmZ%iM_ z$l>&r(vOnc*pMty9n2_4l#;>>^?r&h37Jxc-fSsO#eS@tWb(6-3nYDXkne4XTt-QH zAx165@Odn=pZ5hlp$NpsdpPO(JeSDX>~z6XgkyaZhP4)@NU>dpBjg6z1evIwYfxGB zbMr83ea_?+r(+2|Nc~FcO7-#@6Z9pjxMKJTcbUtsy;#9dr{6>g{bC%?!Pwdr^wcGA z=W<{4teHvGIsD9R&3mGQ-Lyo~LGgdh{_OqL@bt>l>Ri=qxY5i`RS*ppBkQ-U-oWw2V61}WV+_q$0B)#E%a@LQB_8wgfZ2%r;7S% zh^=Bf6Z4rQH)^Prq_UJ5)CJ>I(^j5yJ?@TK%|hDzUd?+ac#{URuYK_4hvxl_*{AR^ z-XS0Cyna&O$6mzw;br|bwl592PxKof2))+117=uUpB^}ty9M{;Jo^(ZTCYR7gC8$` zEVx@FRDEP&Q=0#g?jtLewGmD}HG1|ciUc=Y!4~dJZF&`2%ggAgH%R`fCcUhOld+Wj z*&}95El_jH;G0w?oK~n%>Qz8@>mXO>anxNDhDT&jRYS?}NSC2oi(Rw)-4Zo*CH>4O zPT~{jq^cd|uLXtv-kY{fi(?KlL;I_x4cp6H)H@YuL{z9Fr#B?tzk1jwrEet|8P{AT z5Hvg4ESq1sHyht(Y&o|sqmg&uLdb!=If!b@%$;-jolqKdlEX6UY$E-Fjm_&Eyyiyq z>MlHeHJb(NILpxnRiX{jqS;zmt9M*~cg_1S;v>P1Ik><3{XuF8ZRTo5aIeUkEPlV4)PbS>Y?k*m@Z*j9 zk7@C{;B97o;Gi!O ze*f-$>irg!(OD?#itw|_vvgFK^0XoJ3sZO?9q5{;NO*N}&sO98>tHJVX}BRIW2@E9 zL}(*Wj86!zSA~z$fuvrQmJRI_OFh=*x-B1 z@On3|rN3~Jj`S0`I@36kHF&_a&q}O{CzZB!SZSqXN{rx9ZCf%eg_nxhe`VW>_*VDo zO}l%|w5BGpLjAIwjZnkp>?`w2&4%~21_hrB-^xu_E)(cWjg={@xxqDP^e@C6rE=HdNURV(A5_UrDFvL75>L4tf|-=w zjMm?>XJSt|HOUN49CgmYF5*f=uT*% zj(4gX*hIESRm`2$Qctv?5If;G)&v z8hYDAlDF~g@~+k<%Q^dM_LT*fl>^08)vGeQb*;j#N2li6=tswDdr`S2K?Aao<~H>5 z#*4`{m)(m?D{pm$y`!~W^p|7k1&lAJVUw4{)~fBPC-z$2D?u64 z#U!8>QV5Q1oHWMlqcE%PWDaG^a|$z0rDg3IP)hhu2k~`l@p3y;tI~5b3t^8my;s9j zPM;;$Zhdq>e-!O$;fEfbNQ=lna3AJoPtL>#(r<*D@zOz)zT$%}ZQp*yJ~etA6Iz=~ z`|9xrFMqJAHD_C3FQoCFOu$LCb4uFTXx?K6k(=ED&1M1hWN&&KPPuS`HK8V{C#hCl zO`dKH+=m&||6yjcSIEH5m0!fCaWSee;t5T_Ml8WAHx_M>UyT|JO1?g->Pt9LGkn*# zC&_f}2T5ldjXKaM(uN+ko2$y}ra2X1qesE}mrh_1+MqV(whD@kflqi5M%6ZTr}RpB}=) zn0?)Wz}$%vSft=2bB{lSRcrrHa4Zf{v;9H8_0gpR|NG-VLvVapuqslE^0Uxc{0lip zcRsHBUEP1%fTwjc|LDO72kXf?LXVLv*Lz6g)&4f=x2yWjkM1!4xdr3dxj#ocwBN@) zcAo8WJ^Ism5-Rm5um;IFw!*vZVa`;ED_8;9R`jM~<5&juXMzNs5fXL3fmhvuW;l%I zVvK$03TNPXc-PKgV=yZKOPn*X69T7;&nC+?#LaJjrP{)-ujRF3BWJT1rfS1xE?U%K zdm$8r=FJw^kPGXzBuDn*Pg=5HSnYi-y;#A`UCciL7^@}-Pu%hAjy3i3i%Zs(%C$je zN%fFA$vdHv5a~Ax6da?j^Sl=ZlyZZ(>U|`G-r9Gvojp=qaw4HlfQx=kY(kk{E z(yPhA)1|1%!^ow#tx7E?3Brb>I`>b9ms(CollyJ@{> z9kJ#?)!ek^KGl+JIQ#4HFH=t^a5t!*RWJ7N{k2c^B!;#;t+clOa?+Yjl2;G^yU6W+ z`f0^tKR3(}bh0x~6pISZwdR#3$aDR;EpGC5bvrB$^IHX^zb}1eA9Syf*o%qO`O4U@ zsvt45MYxIf^!|>q4nsvw3K<3?=q3r++|DLr`R=Lr=;gh3n88bY5>% zn?;*l)VeBEYWQE6U3&cG5e@qE5}a%?JlA)b22PRgqS#d5~`Cy%F7o?L>@(UM|J?cO`OcPMozy*%@JR@VE4*=bpY>}IC;S{qTzwsIz%*mp>q zjMpG}AKM(91l9~WGp^ON5xDfZ<*cNybSiuzH|5(`$)48 zSqOslQSTjr-s^bR?aJ6%Aa&S;LaPGjtMPR^x3HG|RoA8+AKk!uFI%$$>Z2}pB_=AQ z3#%sTrNdNCRV3o}ER8>heo1y@*UG?>;rZ}#<8#Hb0_Mm1V)b$rT)2(ZJgP7)noDtN z-KIKoz6v@W+sU|C2H~tv`a8oT_zeF<@=BL~)xR352w#l~lTe~2ad3sqi(axXo1p>0 z7Ti^ie1k6}HNlZ2k^W~>FL4(#iBcCcer8$jBjqsZk{(Ez7MO zj?0UeC3Gd2RnQM(XQFLV52}c4F|~u(VInz8uN1V0)zFiW0fNzEk`^xEXVF2u70iyy zuq+#>ijsVV+{j%!lfwIggQsCfM-N{EsoaG6DbLyXwh7(vIO=fnh@l4Aq2EA|dTpnJ zBb$)tybE%n;Z@2_rALQT1!gRJ-l!EbO0{8fwb+*B%m~e zv=Qn9{Im)bRi8!qB9FP>iOI*z{@bG zx*@^Ci04izrvl>1h`oy=m9(X}UdakeDHD#K9vbDzUnt|x_GUsN;Rge<*pP&*b&%v z#c`D+ION65q9Gq3&vP>RKF?~uj7%rI5fw~m6(q$}P=|E~I=z!*_%-;+UcpgT^3J&B z-adt2q>GL0DUv{@P+m{+6=~R}P|MxhtosdkL=mo^op`ISWP31qcsb#j2`?lWIP2QX1T_?T^9jRp1 z_#<+*yV31SXu+FYP_fbNZ;mV!v0@x<`$IzbI*f?ZHvp!`f68N&z!>ehr-3kz{xB46MX* z`o%U98~S1KoystxXHT z*-P+>uSsU|?~DqsLZ(btT#YT|q)O-SXH=<_X3l&$x9$?2X&Z!=JbGDq@)Q)?Ca44R zkXQx%Ud@zOgD*Wm9{fnc$)rkna~dci21Q`btd5zfQ+x;d#ev4@^@gSRX%#g1xZ22XxJ zs>aT}6I78kWYnmsu}|!shO!|;f_72T%i-_;y%&9tSe&kHpW>sC6AL`gsoxYJR0ZgHBRSNzzMGlD*{!% zgG|{rNPX(`q8cQ%BMClVkd5eNzr>F`#M^QUR-c^P(BWU;4w(0T?SBIGzKl2TAaC1k zc{bBvJ@X-|&DbJTVi(wu6_7w95++*VA$*VPj=P3?s|#1z02>%1Y=n!-A%z-B&1t9! zW+o#|{8=aIeZ%m+rlFUFqrZ_2L@TIAY~0=%kx`z$NirC7LiK@TBzvhzguO$ud&9e~ zH%-oJp4TcW*o8MOuv&11zwSiwQ^sCu@YTwuh8ihvW9UUGnFuq~tR}V@Hy{@mu*s-q zGt#rvy{JVY-%ST7y(n8g{Jaro!3eIB8!+sQ&nH&JyznL*nFYG$8s=AOwkyqa5E@iQ zbGRL*A^m8nqh0uWI!Swh0mT0O5{^6Z)f@B~A%WC$0txp_IcHcKOv~$`OQZ#C(5trb zZAWO8bgv`OeKTd9q;}mU>CFJ0=Mod@a8xPIZ=t+Y3C~-ER5sGTo-lng;FGd*t{w2B zj-;NE`&HzTJ>Qb~3QuUwn^vZ!X}Hrgat_^`z-wqR3YcomK=7+Zb7qsvqB41!nG&-p*P+r_BOUOx)C!@uJklOX z=U48AgKfq?{sklw+>KEt?t_EehbE?X&R~)M0ol&AIy<2gE|uDe8CCagMu-7 zCn80Tg^u|Zfn(5Nj+3f(KX{fm=3>+tnNG&DY`Kr#dgugnzt~?_;l@Fp+qa z+jpLhy&tZ+Q`HF_PEW_djiOL9FYKl=uE7^6m=x$@=w7;%RtOL))GIP}Oq#e0&>;FD z-i$@}^A!;}z2k7PPs4-wgs(#(v)S|bU@YMWVVKN(enJs?pGoI38x5z=#NWEof8F1( zGvuk;oi)_?I=u(Uz&i?eDG7%{GH%N`@&f0>Tl^Z!BoCC30^Gv>Acow~@51c2=!Oc= z(DX#N#hR4j?;oZkRV!*$R?^tg$q+gUQFK=0)U;><^aY3M)AG>vx2I;|J?l^DqRyD4 z7w%6@DI+St3qY{8YX#CeFXm_FH*oRNyAC!?7eM046Sezt`S^jzqO%2U0#O6RCf zmQr$(MHbE~)F~r0mF^UKsvMR|ZfY)_^CVS_E~N)0(wce@@|;(rBdNKSbL%#QrE1d0 zVJbnjt6`#Y658ou`Pqozoi>w__!UZq&!RrTsWU4dmKk9c)$n$H&eW@0HUUkDR1-Kk z(>y7{XKV@UVKrPr^{VBT>SQKm2!2Wu-)g9w{7)ZAtJNGx} z?{Vc?s8oO8YkQld6l;_x)FHDm2d$NFMm6#4e1?kuEBR@8F=Fx?~&=l>|b9Z-9TiftdbNVsgZhV_PrXmw=wC`*LV3FY%_UvqU({L1vWW>SUY{)~*(wpI_40|7P_Aa8HKgaLb z6euT8vN71@zk?Dh;x7y;*w_!jS)*;nZb)LqxxH;FN8M^Lw8+v zT9OnesSE|Ru7gBS3#nUb6iq5NbGcNUL#l8>6&$Tn{9`iq3s%TNBTB(|&11$a<7{B_ zoYYB`*$ZDQOy9i5tYRA4@??UV&bvFY4Z_n62u~)aApJOR*2oEM;rHu~3p1@Nl{DPl z3N$?8W_QrN=1Q8FZ@GVk1KUb=bR?vL?SxmCXK@_;4rE07YODXdK)J7qnn}SYb&bs6 zNi@&5NN|k!+wrHI=M&MAL?HJ|KGCm1+QNAcCyMGS8O`T4Hk6mRu?8XTHNl>Bk~gUJ zq;31it}R6S-s#d3V z`viZ^18G{}&GDat7<6{`{j&Nc(sd2I2N`(lCH62*a$2*(odHM4$9q*0b(NdCkKExO zWMz1viuj7f$<(;~Rv>;lLOoHr{QEE-qdVv&1;I?bd17PpC!UPC-D%jcHzO#X7!$nf zcFyp|pbGj~vp;8Ne9OpFI}ew)m+EKQUDCD*UEc2o9ak49)WaJN&asw z3JSHPWyYZ;d1RJ(hLQValIJ|Eyc^#QLm(%~$d+x2d7hYoUtrVLo#=$0txDE2t+K$v z_G&V4=+&Y=ZB8mib>c`aN={AA+B2@X&K)H5q?Kr-P!Hgo)u9g_VcKhgiqePKyQ^E|^l~@*^laQHM$y?RU#;+o-m3~&GoQ`wH+T?i<7B4XOX=Cnl zLmtEKa0Ksn4H?m=Xm)P)s|HTzt7Ns7M+{WMUqC@` zjLe5y_!VQAMdpY_ig0HyNWOBhWre7nTwm^b1G0bB=0ZKQdY)lm{{4! z@HI*zr@k!~7oSYQEa@u0>k;NDUU?D!RuF565r1MY^OJNmK3Ah2;I}2jl3z< z{9SsO>ITF0+zOpk!{YqcF@L%SFJ_XzS3POs%$tSk**5FwReZutPEB?&2qzVhxUvl_X=ooL%r#zbu-mZ31bCZIor%1Za zpzBEvH2UT|oZ@tNi*%I{{LMb$=A$ya4p~CsUEn_J<5aH;Ht@3!QAvKsB+;>x;hEey z=08A>wBfa|!Eo}99z(<3L1!mYyNR~k@U!v-I5Vl5rOD+BdX(qF#n;G>^ArAXd#J zw^BK!aNxcgj%N!-70=v=TUyJVeVgsV4fMTv@x^>!j*@dCW;$+6X@bZ;jNi@4p1X*- zt&?|#$}&a^!RD}ZBxYDvMn`;x8lj)RkHov!!QGK3yGD)G7crygF`^Uv+HassIY`g^ z&tX?!p49I7@U?JJ$nNW=N^y{daS3I>QLmBgb#W#)`{$sV6zx=^3265$GeOniZ=8Z$ z;@q;m6(q0&ScZA-r`*h7^}cI)wL~_JY zb_#>{P)YtFWMPkfFw()NeU)1Je()2j_OGa#OXYUDMzM7t;8tm5FVezuAtvC2%Z`u)(~V022PB_;3;EhE?(KV_O`eJy+)(RuwK6)OYG%r1IK78CQ3voi z-{5=|xjo;+9G1Rkj%=f^&*k*@N;+yi134v?^ufhE_bu{b-XQiD@QD}MSX_ihI7EFg z5Y-oa68et1TB5V9fW0W@3CANFVM}1qcRsXCHS&Z?WtjbO#Wfp@?Pi;I4O zdD>w(#%9>JX0%CS6H?1&PiUH(;WLVRKb_jPk=$%oq5*B09NzIG6l^v)Ri*rl73E{4 z8x77h+Or$%LvE-v=(kP$u8)-tochfqGp&&vC9#FFv7K@!oMbe5c_O+w@doL- zwWP2gN2Ork*3#|v(;_FW5k_hQ$tDwYL20P1QGjsk)ia^ck|`Nsu98E#q$?n5>2jX) zdZsR;)Yjej3hnImM6UZy?>X-Y_FVUwHmUq}&z#Q?TxIrhAT-M7R7aP;;9rIWl}umt z9o^b^(7fw&QPKD1$)>4O??;%~?A8*Bm^RKfeRLJi?Hd1l%htXGNBEE;OzqOfIXwc~ zbeP?nQ!+3e4|9_k6|Lkg8l`@GKV~QmB6~W({m_GBE|a}tI;z54HePwV-hUHr@;2%O z4SSS4enth!&$EhtZk1B0h1$l#N!KFxO2u)9`8}Dl(0f$FA^o07&qjEITDpTu;#>AB z5$a1cwVZ^ekdYhs4kSU*Faf^2Z(p70LRvk6_K1@_i3G%ZNp+skm) zj&D6ANoxV_mIHQ8^S1E3`RG%$TM{W+Ib>>~pF&B}g)8w8osDBh=N0=HdO&If>VML= zNL~w`;xj%QY@j2&&L{mR-qTO%_qyqIx2RT{=#xGEEI#Gbs5_k6XZiXc3kB(>&(SRp z1?Pey>tO}WSvlnEF>>v*`KOz^DZu%k7bAmD+Q?H;LRa6)6EzZbll@Z-&t4|!D@NQd zs_25~{Y)V?c*Y*{>{+9RWt+II=Qy{xt@v!K*@*r?tnEMj~mimy1Zcc z3~%fb6Uc6u_`}R(OL^Xx;*N0lXyPQMIfC!+Wp7x-yO2j(YdW>1g*on;LJ_BlG1DbJ zCezi$X?d90UW|M{r|D;Wa<|yT6wCIrK@e1hJ~EcRMWxUIi|jIWS!7p>J4VKG6EumH zsA<#~V%GK$Mb8=Zch#ghnVDR4$aK6{RqWorkhMqdG8bXm#6+-$Du6jOF8wP!IqS02 zXf>y7Gpal*Uyb6JA}SEGq>l~rW2ed6tz~9zmbm#zDL|*h&ebRdIOR{Won0Z5sEXO6 zkvF@Nvucd5janLl#yK3H$1^HAcbGgQxR`6)217_J^cgg$9OuF3b!g6Sv#sUI~&^E!EXJ`6j8{A}v(^L+ma; zhktG1S&Eazpr(l76E|}zf6EDVl+)Ub3ZxK5^F2|9zEh%e{Baf}g}hg=sk7GHq|TlgaH= z(XTGRXuSoW@VKX*io(jb>U?R`%9nSlNw72XmX5;T7IT>6FnXKlFo!(jTT~7^!))uT ze0S(bJv)NHFY!O4XLToQ_pI}TJm|gpnA3`#X90ghdoU@|$}~MY>KbI~xkwmAOq(E# z1s{Zu$YyyW%XkOwGmjU2Ml~JIL8PxEY->mOy0`*$KEaPYNS}2%l!jYrm7U@QGrR|M zsUqd~R_HQW0d}<1$Dn*WnebSd^oyCii|T65SLBDGK;3mK1ht+GfZ)~P*9cpgA@uRa z3nn)A@7A%MQ*c{_I&+?x#2rr^M6Ehz8ttetr{E_GQg$;FeY3~zn`DYo$Jc5R-oPmH zI7L9~E2Iao`fqX%p9;;>+X(ICDAas=@Q&{ulZvxU8g+b4>_H|I)Hc?rWGaZSLoxC* zoUx_SY55kl%>btY6pUz9)DmwO40-CD9_T0O@*6_qin4Et>7#@9z(B^@5Nho+WWTSF z+*(9(*8r>(x&a6QO|a;TU_-7Xbfa?YWj{KQq+z;Us?KC~J(1YN4luAMS2M(XYJxPx za!8*8=r~O5#Y|{LtVz@8Vg+3}HOWG*Lnn^6GP-rmoi;zMmeuOT5P2pm8-k<2e=hRPqeT90vfPTCp{{I7>Hy{H5 literal 0 HcmV?d00001 diff --git a/mic-R-2.wav b/mic-R-2.wav new file mode 100644 index 0000000000000000000000000000000000000000..f0bc69c8268385dea988fc1f9652f4592710337a GIT binary patch literal 240044 zcmeIup$&sj00htj7GQ*~f+$HXf+_?_Q&-0T41?(gBp4QJ(mwQ_ONu4&Yd70Pp`R!#C0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs j0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0v84TeVz;n literal 0 HcmV?d00001 diff --git a/mic-left-boosted.wav b/mic-left-boosted.wav new file mode 100644 index 0000000000000000000000000000000000000000..b88cf54581090cf0e12db9119356aad9da554366 GIT binary patch literal 240044 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 Date: Tue, 13 Oct 2026 17:51:22 +0800 Subject: [PATCH 14/51] docs: the box-to-computer messages for a voice command Co-Authored-By: Claude Opus 5.5 --- docs/protocol.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/protocol.md b/docs/protocol.md index cc5b8804..369309b8 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -189,6 +189,30 @@ Once on Wi-Fi the box also answers mDNS (DNS-SD) for `_vibebuddy._tcp` on port 7 USB stays in the clear: the cable is physical presence, as pairing already assumes. +## Voice commands (experimental) + +A voice command (ADR-0017) on a box linked over USB or the local network: the box records while K0 is held and streams the audio over the link; the computer turns it into text, hands it to the resident session and sends the spoken replies back. Over the Relay the box will talk to the voice service itself (ADR-0018); that path isn't described here yet. + +The computer offers voice, and the box only treats a long K0 as push-to-talk while it is offered: + +- `{"version":1,"event":"voice.available","on":true}`: sent after `device.hello` is answered, and again whenever it changes, when the resident session is switched on and speech recognition is configured; `"on":false` withdraws it. A box that hasn't been told, or was told `false`, keeps K0 long as it was. A new link starts with voice off until the computer says otherwise. + +One recording is a turn, numbered by the box from 1 after each boot. The box sends diagnostic lines, each under 1024 bytes: + +- `VOICE START `: K0 has been held for the long-press time and the box is recording. +- `VOICE DATA `: the next stretch of audio, `seq` from 0 without gaps. Audio is 24 kHz mono IMA ADPCM, two samples per byte, low nibble first, the same coding the box plays Character packs with (`firmware-rs/core/src/adpcm.rs`). `predictor` (a signed 16-bit sample) and `index` (0–88) are the coder's state before the line's first nibble, so every line decodes on its own and a lost line costs only its own stretch. At most 768 base64 characters per line. +- `VOICE END `: K0 was released, or 30 s passed; `samples` is how many were recorded. +- `VOICE CANCEL `: the recording was too short (under half a second) or the box gave it up; the computer drops the turn. + +The computer answers with JSON events carrying the same `turn`: + +- `voice.heard` with `text`: what was recognised (cut to fit the line), so the box can show it. +- `voice.reply` with `text`: one spoken reply, sent the moment the resident session says it; a turn may have several. +- `voice.done`: the turn is over; the box goes back to what it showed before. +- `voice.error` with `reason` (`not_heard` when recognition returned nothing, `unavailable` when recognition or the resident session failed, `busy` when another turn is still running) and an optional `message`; the turn is over. + +Spoken replies are shown as text for now; their audio will follow as `voice.audio` chunks once there is speech synthesis. + ## Stage 1 error output | Output | Meaning | From 0c7308f1ed86f39785979be06908e66beb65fe79 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 17:55:52 +0800 Subject: [PATCH 15/51] docs: ADR-0017 says where recognition runs and allows the user's own Co-Authored-By: Claude Opus 5.5 --- ...commands-go-to-a-resident-session-that-speaks-as-the-buddy.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md b/docs/adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md index eccaf056..b83def48 100644 --- a/docs/adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md +++ b/docs/adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md @@ -23,6 +23,7 @@ The box has a microphone, and with Wi-Fi and the Relay (ADR-0012) it can sit awa - ADR-0007 still holds for lines: they are written in advance. Spoken replies are a second way the buddy speaks, written at the moment they are spoken, and they may talk about what the Agents are doing. The persona rule narrows to lines. - ADR-0009's App still calls no model. The recognition and synthesis service lives in `vibebuddy-cloud` beside the Relay but is not the Relay; it calls the TTS vendor of each Character's voice (ElevenLabs, Volcano Engine Doubao) with our keys, and stores neither audio nor text. +- Recognition is Workers AI Whisper (`whisper-large-v3-turbo`), which detects the language itself. A box linked over USB or the local network streams its audio over the link to the daemon, which calls recognition and sends the spoken reply's audio back the same way, so a box on the cable needs no Wi-Fi; over the Relay the box calls the voice service itself (ADR-0018). A user may set their own OpenAI-compatible transcription endpoint and key instead; the daemon calls it, the key never reaches us, and with it set audio goes over the link even over the Relay (ADR-0015 keeps it private). Box and computer messages: docs/protocol.md, "Voice commands". - Voice is offered only to signed-in users, so accounts come first; whether it is paid is decided with accounts. Off by default. - Delivery is per Agent and belongs to its Adapter, which becomes two-way. For Codex the daemon is an app-server client: `thread/start` once, `thread/resume` after a restart, `turn/start` per voice command; the spoken reply arrives as a call to a client-defined `speak` tool (`dynamicTools`), so no MCP server is needed. The thread runs read-only with approvals declined. The user can open it in the Codex App with `codex://threads/`. Verified on 2026-10-13 with codex-cli 0.162.0: existing ChatGPT login reused, about 10 s per turn, memory kept across a process restart, file writes refused. - app-server and `dynamicTools` are marked experimental, so the daemon pins the Codex versions it supports. OpenAI permits ChatGPT login for local and open-source apps; before commercial launch we apply for Sign in with ChatGPT. From b6a3f84cff4d9c790b70b5e77dbf637be97abd71 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 18:04:05 +0800 Subject: [PATCH 16/51] Take push-to-talk voice commands from the box The computer side of docs/protocol.md "Voice commands (experimental)": - VOICE START/DATA/END/CANCEL lines are parsed and each DATA line is decoded on its own from the IMA ADPCM state it carries, with a decoder checked nibble for nibble against firmware-core's. Lost lines become silence and are logged; DATA lines stay out of the log. - voice.available goes out on every build report (after hello on each new link, and after a reboot) when the resident session is on and a speech recognizer is configured; never over the Relay. - Speech recognition sits behind a Recognizer trait with two backends: the user's own OpenAI-compatible endpoint, which wins, and Cloudflare Workers AI's Whisper for development. Settings come from the environment, speech.json in the config directory (kept out of config.json so an older App's PUT can't wipe the key), or a dev env file. The language is detected unless set. - On VOICE END: WAV -> recognizer -> voice.heard -> resident session -> voice.reply the moment speak is called -> voice.done, cut to fit the line; errors not_heard, unavailable and busy, one turn at a time. Resident::say_streaming hands replies over as they come; say and POST /v1/resident/say still collect them. An ignored test feeds a real recording through the real recognizer and resident session. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + README.zh-CN.md | 2 + daemon/src/main.rs | 66 +++ daemon/src/resident.rs | 85 +++- daemon/src/serial_transport.rs | 5 +- daemon/src/speech.rs | 386 ++++++++++++++++ daemon/src/voice.rs | 799 +++++++++++++++++++++++++++++++++ docs/protocol.md | 4 +- docs/resident-session.md | 32 +- 9 files changed, 1349 insertions(+), 32 deletions(-) create mode 100644 daemon/src/speech.rs create mode 100644 daemon/src/voice.rs diff --git a/README.md b/README.md index daeb60ee..3a0562d9 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,8 @@ cargo run -p vibebuddyd By default it only listens on `127.0.0.1:7331` and finds the box by its Espressif USB Serial/JTAG ID, `VID:PID 303A:1001`. `VIBEBUDDY_BIND` changes the listen address, `VIBEBUDDY_SERIAL_PORT` names a serial port explicitly, and `VIBEBUDDY_USB_SERIAL` picks one box among several identical ones; without it, the daemon connects to the box that has reported VibeBuddy firmware to it before, and when none of several devices is that box, the app asks which one is. Either variable shows up in the app's Device tab, so a pin left set can't pass for a missing box. A box running other firmware, such as Muse, is written nothing. HTTP `202 Accepted` means the event entered the bounded send queue; whether the box actually received it is what the daemon logs from the box's reply. +Voice commands are experimental and off by default: `VIBEBUDDY_RESIDENT_SESSION=1` turns on the resident session, and with a speech recognizer configured the box gets push-to-talk on a long K0. See [`docs/resident-session.md`](docs/resident-session.md). + The app normally supervises the daemon. On a development machine without the app you can install it as a LaunchAgent with [`packaging/com.vibebuddy.vibebuddyd.plist`](packaging/com.vibebuddy.vibebuddyd.plist), but don't run both: they'd fight over the serial port. For the app, the daemon also serves `GET /v1/status`, SSE `/v1/status/stream`, `/v1/config`, `/v1/device/{identify,volume,choice,screenshot,voice-pack,firmware}` and `/v1/daemon/restart`. When sending through the box's CH343 UART bridge, the daemon writes in line-rate chunks: the bridge can't take more than about two hundred bytes of continuous data and garbles the content without changing its length. The native USB port doesn't have this problem. See [`LESSONS.md`](LESSONS.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 16f386e7..d13e9750 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -256,6 +256,8 @@ cargo run -p vibebuddyd 默认只监听 `127.0.0.1:7331`,并按 Espressif USB Serial/JTAG 的 `VID:PID 303A:1001` 自动发现设备。可用 `VIBEBUDDY_BIND` 修改监听地址、`VIBEBUDDY_SERIAL_PORT` 显式指定串口,或用 `VIBEBUDDY_USB_SERIAL` 在多块相同设备中选择目标;不指定时,daemon 连之前报出过 VibeBuddy 固件的那块;插着好几块、又都不是那块时,App 会问哪一块是盒子。这两个变量设了都会显示在 App 的设备页上,免得忘了撤的固定被当成找不到盒子。运行其他固件(比如 Muse)的盒子,daemon 不会往里写任何东西。HTTP `202 Accepted` 表示事件进入有界发送队列;设备实际接收结果以 daemon 记录的设备响应为准。 +语音指令还在实验阶段,默认关闭:`VIBEBUDDY_RESIDENT_SESSION=1` 打开常驻会话,再配好语音识别,盒子长按 K0 就成了按住说话。详见 [`docs/resident-session.md`](docs/resident-session.md)。 + daemon 平时由 App 看管。没有 App 的开发机可以用 [`packaging/com.vibebuddy.vibebuddyd.plist`](packaging/com.vibebuddy.vibebuddyd.plist) 装成 LaunchAgent,但两者不能同时跑,会抢串口。daemon 还为 App 提供 `GET /v1/status`、SSE `/v1/status/stream`、`/v1/config`、`/v1/device/{identify,volume,choice,screenshot,voice-pack,firmware}` 与 `/v1/daemon/restart`。 经盒子的 CH343 UART 桥发送时 daemon 按线速分段写:这条桥一次吞不下超过两百字节的连续数据,会把内容错位而长度不变;原生 USB 口不受影响。教训见 [`LESSONS.md`](LESSONS.md)。 diff --git a/daemon/src/main.rs b/daemon/src/main.rs index e3037576..ad2bcdd0 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -17,8 +17,10 @@ mod screenshot; mod serial_transport; mod session_titles; mod source_opener; +mod speech; mod status; mod updates; +mod voice; mod voice_writer; use std::convert::Infallible; @@ -98,6 +100,8 @@ struct AppState { /// The resident session voice commands go to (ADR-0017); None without a state directory, and in tests /// unless one sets it. Present even when switched off, so its hook events are still recognised. resident: Option>, + /// Voice commands from the box (ADR-0017): its recording, and the turn being answered. + voice: Arc, /// The box on the cable said `LINK NOISE` since its last `BOX KEY` (ADR-0015). noise_seen: Arc, } @@ -134,6 +138,7 @@ impl AppState { updates: Arc::new(Mutex::new(Updates::new(None, None))), check_updates: Arc::new(Notify::new()), resident: None, + voice: Arc::new(voice::Voice::new(None, None)), noise_seen: Arc::new(std::sync::atomic::AtomicBool::new(false)), } } @@ -258,6 +263,7 @@ async fn main() { } Arc::new(resident::Resident::new(enabled, dir, resident::Launcher::Codex, state.activities.clone())) }); + state.voice = Arc::new(voice::Voice::new(speech::from_env(), state.resident.clone())); state.device.lock().await.pin = pin; state.updates = Arc::new(Mutex::new(Updates::new( updates::Source::configured(), @@ -372,6 +378,9 @@ async fn publish_device_message(state: &AppState, message: DeviceMessage) { { lan::remember(&lan::box_address_file(dir), address.trim()); } + if let DeviceMessage::Line(line) = &message { + handle_voice_line(state, line).await; + } let _ = state.device_bus.send(message.clone()); // Ask right after connecting; the device reports its mode, firmware build and voice again. if matches!(message, DeviceMessage::Connected { .. } | DeviceMessage::ConnectedNetwork { .. } | DeviceMessage::ConnectedRelay { .. }) { @@ -387,6 +396,24 @@ async fn publish_device_message(state: &AppState, message: DeviceMessage) { } } +/// Voice commands (docs/protocol.md, "Voice commands"): offered on every report of the box's build, which follows +/// hello on each new link and every reboot, and taken over USB or the local network. Over the Relay the box will +/// talk to the voice service itself, so voice isn't offered there and its lines are ignored. +async fn handle_voice_line(state: &AppState, line: &str) -> Option> { + let over_relay = state.device.lock().await.relay; + if line.contains(serial_transport::BUILD_MARKER) && !over_relay && state.voice.available() { + send_event(state, voice::available_event(true)); + } + if !line.starts_with("VOICE ") { + return None; + } + if over_relay { + tracing::debug!("ignoring a voice line over the Relay"); + return None; + } + state.voice.handle_line(line, &state.transport) +} + async fn open_k2_source(state: &AppState) { { let sources = state.activities.lock().await.focus_sources(); @@ -1564,6 +1591,45 @@ mod tests { assert_eq!(build_identity_from(None, "abc1234", ""), "abc1234"); } + /// A recognizer that is never called: these tests only look at whether voice is offered. + struct SilentRecognizer; + + impl speech::Recognizer for SilentRecognizer { + fn recognize(&self, _wav: &[u8]) -> Result { + Ok(speech::Transcript::default()) + } + } + + #[tokio::test] + async fn voice_is_offered_after_each_build_report_but_never_over_the_relay() { + let dir = std::env::temp_dir().join(format!("vibebuddy-voice-offer-{}", std::process::id())); + let offers = |transport: &RecordingTransport| { + transport.events().iter().filter(|event| event.event == "voice.available").map(|event| event.extra["on"].clone()).collect::>() + }; + let build = || DeviceMessage::Line("DISPLAY READY BUILD v9 2026-10-01".to_owned()); + for enabled in [true, false] { + let transport = Arc::new(RecordingTransport::default()); + let mut state = test_state(transport.clone()); + let resident = Arc::new(resident::Resident::new(enabled, &dir, resident::Launcher::Codex, Arc::default())); + state.voice = Arc::new(voice::Voice::new(Some(Arc::new(SilentRecognizer)), Some(resident))); + + publish_device_message(&state, DeviceMessage::Connected { port: "/dev/cu.x".to_owned(), bridge: false, usb_serial: None }).await; + assert!(offers(&transport).is_empty(), "a new link starts with voice off"); + publish_device_message(&state, build()).await; + // The box rebooting on the same link reports its build again, and forgets. + publish_device_message(&state, build()).await; + publish_device_message(&state, DeviceMessage::ConnectedNetwork { address: "192.168.1.23:7340".to_owned() }).await; + publish_device_message(&state, build()).await; + let expected = if enabled { vec![serde_json::json!(true); 3] } else { Vec::new() }; + assert_eq!(offers(&transport), expected); + + publish_device_message(&state, DeviceMessage::ConnectedRelay { url: "wss://relay.example".to_owned() }).await; + publish_device_message(&state, build()).await; + assert_eq!(offers(&transport), expected, "not over the Relay"); + } + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn status_reflects_device_lines_hooks_and_config() { let transport = Arc::new(RecordingTransport::default()); diff --git a/daemon/src/resident.rs b/daemon/src/resident.rs index 660e4e6a..bd3b006e 100644 --- a/daemon/src/resident.rs +++ b/daemon/src/resident.rs @@ -163,6 +163,15 @@ impl Resident { /// Hands one voice command to the resident session and waits for its spoken replies. pub async fn say(&self, text: &str) -> Result { + let mut replies = Vec::new(); + let thread_id = self.say_streaming(text, &mut |reply| replies.push(reply.to_owned())).await?; + Ok(Answer { thread_id, replies }) + } + + /// Hands one voice command to the resident session; each spoken reply goes to `on_reply` the moment the + /// session calls `speak`, not when the turn completes, which in testing was 4 to 6 seconds later. Returns the + /// thread id once the turn has completed. + pub async fn say_streaming(&self, text: &str, on_reply: &mut (dyn FnMut(&str) + Send)) -> Result { if !self.enabled { return Err(ResidentError::Disabled); } @@ -175,7 +184,7 @@ impl Resident { *slot = Some(self.start().await?); } let session = slot.as_mut().expect("session was just started"); - let result = self.run_turn(session, text).await; + let result = self.run_turn(session, text, on_reply).await; if session.client.is_closed() { *slot = None; } @@ -253,10 +262,15 @@ impl Resident { }) } - async fn run_turn(&self, session: &mut Session, text: &str) -> Result { + async fn run_turn( + &self, + session: &mut Session, + text: &str, + on_reply: &mut (dyn FnMut(&str) + Send), + ) -> Result { // Whatever an earlier, timed-out turn left behind still gets its answer, but no longer counts. while let Ok(message) = session.incoming.try_recv() { - self.handle(&session.client, message, &mut Vec::new()).await; + self.handle(&session.client, message, &mut |_| {}).await; } let started = request( &session.client, @@ -266,7 +280,6 @@ impl Resident { .await?; let turn_id = started.pointer("/turn/id").and_then(Value::as_str).unwrap_or_default().to_owned(); let deadline = tokio::time::Instant::now() + TURN_TIMEOUT; - let mut replies = Vec::new(); loop { let message = match tokio::time::timeout_at(deadline, session.incoming.recv()).await { Err(_) => { @@ -284,7 +297,7 @@ impl Resident { && params.pointer("/turn/id").and_then(Value::as_str) == Some(turn_id.as_str()) { return match params.pointer("/turn/status").and_then(Value::as_str) { - Some("completed") => Ok(Answer { thread_id: session.thread_id.clone(), replies }), + Some("completed") => Ok(session.thread_id.clone()), status => Err(ResidentError::TurnEnded( params .pointer("/turn/error/message") @@ -295,12 +308,12 @@ impl Resident { )), }; } - self.handle(&session.client, message, &mut replies).await; + self.handle(&session.client, message, on_reply).await; } } /// Answers a server-to-client request; notifications other than the turn's end need nothing. - async fn handle(&self, client: &Client, message: Incoming, replies: &mut Vec) { + async fn handle(&self, client: &Client, message: Incoming, on_reply: &mut (dyn FnMut(&str) + Send)) { let Incoming::Request { id, method, params } = message else { return; }; @@ -310,7 +323,7 @@ impl Resident { client.respond(id, result).await } None if method == "item/tool/call" => { - let output = self.call_tool(¶ms, replies).await; + let output = self.call_tool(¶ms, on_reply).await; client.respond(id, output).await } None if method == "currentTime/read" => { @@ -326,7 +339,7 @@ impl Resident { } } - async fn call_tool(&self, params: &Value, replies: &mut Vec) -> Value { + async fn call_tool(&self, params: &Value, on_reply: &mut (dyn FnMut(&str) + Send)) -> Value { let tool = params.get("tool").and_then(Value::as_str).unwrap_or_default(); let (success, text) = match tool { "speak" => { @@ -335,7 +348,7 @@ impl Resident { (false, "text is empty".to_owned()) } else { info!(chars = text.chars().count(), "the resident session spoke"); - replies.push(text.to_owned()); + on_reply(text); (true, "spoken".to_owned()) } } @@ -553,7 +566,7 @@ fn save(path: &Path, saved: &Saved) -> std::io::Result<()> { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use super::*; @@ -561,25 +574,27 @@ mod tests { /// How the fake app-server behaves, and what it saw. #[derive(Default)] - struct Script { - logged_in: bool, + pub(crate) struct Script { + pub(crate) logged_in: bool, /// thread/resume fails, as if the rollout were gone. lose_thread: bool, /// Ends its output instead of completing this turn, as if the process died; only once. - die_on_turn: Option, + pub(crate) die_on_turn: Option, /// Server-to-client requests made during each turn, before it completes. - turn_requests: Vec<(String, Value)>, + pub(crate) turn_requests: Vec<(String, Value)>, /// Requests and notifications from the daemon, in order. - received: Vec, + pub(crate) received: Vec, /// The daemon's answers to the server's own requests. answers: Vec, /// How many times the server was started. - starts: usize, + pub(crate) starts: usize, + /// The turn completes only once this is notified, so a test can look at what came before it. + pub(crate) hold: Option>, } - type Shared = Arc>; + pub(crate) type Shared = Arc>; - fn fake_server(script: Shared) -> Pipes { + pub(crate) fn fake_server(script: Shared) -> Pipes { script.lock().unwrap().starts += 1; let (daemon_side, server_side) = tokio::io::duplex(1 << 16); let (daemon_read, daemon_write) = tokio::io::split(daemon_side); @@ -628,6 +643,10 @@ mod tests { if script.lock().unwrap().die_on_turn.take_if(|turn| *turn == turns).is_some() { return; } + let hold = script.lock().unwrap().hold.clone(); + if let Some(hold) = hold { + hold.notified().await; + } let completed = json!({"method": "turn/completed", "params": { "threadId": message["params"]["threadId"], "turn": {"id": turn_id, "status": "completed", "items": []}, @@ -647,19 +666,19 @@ mod tests { Pipes { reader: Box::new(daemon_read), writer: Box::new(daemon_write), child: None } } - fn temp_dir(name: &str) -> PathBuf { + pub(crate) fn temp_dir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("vibebuddy-resident-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } - fn resident(dir: &Path, script: &Shared, activities: Arc>) -> Resident { + pub(crate) fn resident(dir: &Path, script: &Shared, activities: Arc>) -> Resident { let script = script.clone(); Resident::new(true, dir, Launcher::Fake(Box::new(move || fake_server(script.clone()))), activities) } - fn logged_in() -> Shared { + pub(crate) fn logged_in() -> Shared { Arc::new(std::sync::Mutex::new(Script { logged_in: true, ..Script::default() })) } @@ -667,7 +686,7 @@ mod tests { script.lock().unwrap().received.iter().map(|message| message["method"].as_str().unwrap().to_owned()).collect() } - fn tool_call(tool: &str, arguments: Value) -> (String, Value) { + pub(crate) fn tool_call(tool: &str, arguments: Value) -> (String, Value) { let params = json!({"threadId": "t", "turnId": "u", "callId": "c", "tool": tool, "arguments": arguments}); ("item/tool/call".to_owned(), params) } @@ -761,6 +780,26 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn a_spoken_reply_is_handed_over_when_speak_is_called_not_when_the_turn_completes() { + let dir = temp_dir("stream"); + let script = logged_in(); + let hold = Arc::new(tokio::sync::Notify::new()); + script.lock().unwrap().hold = Some(hold.clone()); + script.lock().unwrap().turn_requests = vec![tool_call("speak", json!({"text": "On it."}))]; + let resident = Arc::new(resident(&dir, &script, Arc::default())); + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + let turn = tokio::spawn({ + let resident = resident.clone(); + async move { resident.say_streaming("hi", &mut |reply| sender.send(reply.to_owned()).unwrap()).await } + }); + assert_eq!(receiver.recv().await.as_deref(), Some("On it.")); + assert!(!turn.is_finished(), "the turn is still running"); + hold.notify_one(); + assert_eq!(turn.await.unwrap().unwrap(), "new-thread"); + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn approvals_and_questions_are_declined_and_other_requests_refused() { let dir = temp_dir("decline"); diff --git a/daemon/src/serial_transport.rs b/daemon/src/serial_transport.rs index b0a65868..cf8843e5 100644 --- a/daemon/src/serial_transport.rs +++ b/daemon/src/serial_transport.rs @@ -863,9 +863,10 @@ fn process_device_line(line: &[u8], firmware: &mut Firmware, event_sender: &mpsc } else { let line = String::from_utf8_lossy(line).into_owned(); // Screenshot rows (hundreds of them) stay out of the log; their BEGIN and END lines and every other - // diagnostic line are still logged, so a screenshot that never finishes shows where it stopped. + // diagnostic line are still logged, so a screenshot that never finishes shows where it stopped. So do the + // lines of recorded audio, which carry what the user said. let screenshot_row = line.starts_with("SHOT ") && !line.starts_with("SHOT BEGIN") && line != "SHOT END"; - if !screenshot_row && !line.starts_with("ECHO ") { + if !screenshot_row && !line.starts_with("ECHO ") && !line.starts_with("VOICE DATA ") { info!(message = %line, "device message"); } DeviceMessage::Line(line) diff --git a/daemon/src/speech.rs b/daemon/src/speech.rs new file mode 100644 index 00000000..9dfdc309 --- /dev/null +++ b/daemon/src/speech.rs @@ -0,0 +1,386 @@ +//! Speech recognition for voice commands (ADR-0017): a recorded turn, as a WAV file, in; its text out. +//! +//! Behind [`Recognizer`] so the backend can change: the plan is the voice service in `vibebuddy-cloud`, called with +//! the account session. Until then there are two: +//! +//! - The user's own recognizer: any OpenAI-compatible transcription endpoint (`VIBEBUDDY_STT_BASE_URL`, +//! `VIBEBUDDY_STT_API_KEY`, `VIBEBUDDY_STT_MODEL`). It wins over Cloudflare when both are set. +//! - Cloudflare Workers AI's Whisper (`CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_AI_TOKEN`), for development. +//! +//! A setting comes from the environment, else from `speech.json` in the config directory (the user's own +//! recognizer, which an App setting will write later: a file of its own, mode 0600, because `PUT /v1/config` +//! replaces the whole config and an older App would wipe a key it doesn't know), else from a `KEY=VALUE` file named +//! by `VIBEBUDDY_STT_ENV_FILE` (development credentials). All are read at start. Credentials are never logged. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use base64::Engine; +use serde_json::{Value, json}; +use tracing::{info, warn}; + +/// Picks the backend: `cloudflare`, `openai` or `off`. Unset, the user's own recognizer if set, else Cloudflare. +pub const BACKEND_VARIABLE: &str = "VIBEBUDDY_STT"; +/// A `KEY=VALUE` file the other variables can come from, so credentials stay out of the daemon's environment. +pub const ENV_FILE_VARIABLE: &str = "VIBEBUDDY_STT_ENV_FILE"; +/// The language spoken, as an ISO 639-1 code (`zh`, `en`); unset (or `auto`), the recognizer detects it. +pub const LANGUAGE_VARIABLE: &str = "VIBEBUDDY_STT_LANGUAGE"; +/// The user's own recognizer, in the config directory: `{"base_url","api_key","model","language"}`. +pub const SETTINGS_FILE: &str = "speech.json"; +const CLOUDFLARE_ACCOUNT: &str = "CLOUDFLARE_ACCOUNT_ID"; +const CLOUDFLARE_TOKEN: &str = "CLOUDFLARE_AI_TOKEN"; +const OWN_BASE_URL: &str = "VIBEBUDDY_STT_BASE_URL"; +const OWN_API_KEY: &str = "VIBEBUDDY_STT_API_KEY"; +const OWN_MODEL: &str = "VIBEBUDDY_STT_MODEL"; +const CLOUDFLARE_MODEL: &str = "@cf/openai/whisper-large-v3-turbo"; +/// A 30 s recording is under 1.5 MB of WAV; Workers AI took 3.6 to 4.4 s for 5 s of it. +const TIMEOUT: Duration = Duration::from_secs(30); +const MAX_RESPONSE: u64 = 1024 * 1024; + +/// What was heard. +#[derive(Debug, Default, PartialEq)] +pub struct Transcript { + /// Empty when nothing was said. + pub text: String, + /// The language the recognizer detected, when it says. + pub language: Option, +} + +/// Turns one recording into text. Blocking: it is called on a blocking thread. +pub trait Recognizer: Send + Sync { + /// `wav` is a whole 16-bit PCM WAV file. + fn recognize(&self, wav: &[u8]) -> Result; +} + +/// An HTTP request, built apart from sending it so the tests can check it. +#[derive(Debug)] +pub struct HttpRequest { + pub url: String, + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +/// Whisper on Cloudflare Workers AI. +pub struct Cloudflare { + account_id: String, + token: String, + language: Option, +} + +impl Cloudflare { + fn request(&self, wav: &[u8]) -> HttpRequest { + let mut body = json!({"audio": base64::engine::general_purpose::STANDARD.encode(wav)}); + if let Some(language) = &self.language { + body["language"] = json!(language); + } + HttpRequest { + url: format!("https://api.cloudflare.com/client/v4/accounts/{}/ai/run/{CLOUDFLARE_MODEL}", self.account_id), + headers: vec![ + ("Authorization".to_owned(), format!("Bearer {}", self.token)), + ("Content-Type".to_owned(), "application/json".to_owned()), + ], + body: body.to_string().into_bytes(), + } + } + + fn parse(status: u16, body: &str) -> Result { + let parsed: Value = serde_json::from_str(body).map_err(|_| format!("Workers AI answered {status} with no JSON"))?; + if let Some(text) = parsed.pointer("/result/text").and_then(Value::as_str) { + let language = parsed.pointer("/result/transcription_info/language").and_then(Value::as_str); + return Ok(Transcript { text: text.trim().to_owned(), language: language.map(str::to_owned) }); + } + let message = parsed.pointer("/errors/0/message").and_then(Value::as_str).unwrap_or("no text in the answer"); + Err(format!("Workers AI answered {status}: {message}")) + } +} + +impl Recognizer for Cloudflare { + fn recognize(&self, wav: &[u8]) -> Result { + let (status, body) = send(self.request(wav))?; + Self::parse(status, &body) + } +} + +/// An OpenAI-compatible `POST {base}/audio/transcriptions`: OpenAI itself, or a server the user runs. +pub struct OpenAiCompatible { + base_url: String, + api_key: Option, + model: String, + language: Option, +} + +impl OpenAiCompatible { + fn request(&self, wav: &[u8], boundary: &str) -> HttpRequest { + let mut body = Vec::new(); + let mut field = |name: &str, extra: &str, value: &[u8]| { + body.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"{extra}\r\n\r\n").as_bytes()); + body.extend_from_slice(value); + body.extend_from_slice(b"\r\n"); + }; + field("file", "; filename=\"voice.wav\"\r\nContent-Type: audio/wav", wav); + field("model", "", self.model.as_bytes()); + field("response_format", "", b"json"); + if let Some(language) = &self.language { + field("language", "", language.as_bytes()); + } + body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); + let mut headers = vec![("Content-Type".to_owned(), format!("multipart/form-data; boundary={boundary}"))]; + if let Some(key) = &self.api_key { + headers.push(("Authorization".to_owned(), format!("Bearer {key}"))); + } + HttpRequest { url: format!("{}/audio/transcriptions", self.base_url.trim_end_matches('/')), headers, body } + } + + fn parse(status: u16, body: &str) -> Result { + let parsed: Value = serde_json::from_str(body).map_err(|_| format!("the recognizer answered {status} with no JSON"))?; + if (200..300).contains(&status) + && let Some(text) = parsed.get("text").and_then(Value::as_str) + { + let language = parsed.get("language").and_then(Value::as_str).map(str::to_owned); + return Ok(Transcript { text: text.trim().to_owned(), language }); + } + let message = parsed.pointer("/error/message").and_then(Value::as_str).unwrap_or("no text in the answer"); + Err(format!("the recognizer answered {status}: {message}")) + } +} + +impl Recognizer for OpenAiCompatible { + fn recognize(&self, wav: &[u8]) -> Result { + let mut nonce = [0u8; 12]; + getrandom::fill(&mut nonce).map_err(|error| error.to_string())?; + let boundary = format!("vibebuddy-{}", nonce.iter().map(|byte| format!("{byte:02x}")).collect::()); + let (status, body) = send(self.request(wav, &boundary))?; + Self::parse(status, &body) + } +} + +fn send(request: HttpRequest) -> Result<(u16, String), String> { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(TIMEOUT)) + .http_status_as_error(false) + .tls_config(ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::PlatformVerifier).build()) + .user_agent(concat!("vibebuddyd/", env!("CARGO_PKG_VERSION"))) + .build() + .into(); + let mut builder = agent.post(&request.url); + for (name, value) in &request.headers { + builder = builder.header(name, value); + } + // The URL may carry the Cloudflare account id; errors name only the host. + let host = request.url.split('/').nth(2).unwrap_or_default().to_owned(); + let mut response = builder.send(&request.body[..]).map_err(|error| format!("calling {host}: {error}"))?; + let status = response.status().as_u16(); + let body = response + .body_mut() + .with_config() + .limit(MAX_RESPONSE) + .read_to_string() + .map_err(|error| format!("reading the answer from {host}: {error}"))?; + Ok((status, body)) +} + +/// The recognizer the environment and the settings files configure, if any. +pub fn from_env() -> Option> { + let own = crate::config::config_dir().map(|dir| read_settings(&dir.join(SETTINGS_FILE))).unwrap_or_default(); + let file = std::env::var_os(ENV_FILE_VARIABLE).map(|path| read_env_file(Path::new(&path))).unwrap_or_default(); + configure(&|name| { + std::env::var(name) + .ok() + .or_else(|| own.get(name).cloned()) + .or_else(|| file.get(name).cloned()) + .filter(|value| !value.trim().is_empty()) + }) +} + +type Named = (&'static str, Arc); + +fn configure(lookup: &dyn Fn(&str) -> Option) -> Option> { + let language = lookup(LANGUAGE_VARIABLE).filter(|language| language != "auto"); + let shown = language.clone().unwrap_or_else(|| "detected".to_owned()); + let own = || -> Option { + let backend = OpenAiCompatible { + base_url: lookup(OWN_BASE_URL)?, + api_key: lookup(OWN_API_KEY), + model: lookup(OWN_MODEL).unwrap_or_else(|| "whisper-1".to_owned()), + language: language.clone(), + }; + Some(("an OpenAI-compatible endpoint", Arc::new(backend))) + }; + let cloudflare = || -> Option { + let backend = + Cloudflare { account_id: lookup(CLOUDFLARE_ACCOUNT)?, token: lookup(CLOUDFLARE_TOKEN)?, language: language.clone() }; + Some(("Cloudflare Workers AI", Arc::new(backend))) + }; + let backend = lookup(BACKEND_VARIABLE); + let chosen = match backend.as_deref() { + Some("off") => return None, + Some("cloudflare") => cloudflare(), + Some("openai") => own(), + Some(other) => { + warn!(backend = %other, "unknown speech recognizer; expected cloudflare, openai or off"); + return None; + } + // The user's own recognizer is their choice; Cloudflare is only there for development. + None => own().or_else(cloudflare), + }; + match (&chosen, backend) { + (Some((name, _)), _) => info!(language = %shown, "speech recognition: {name}"), + (None, Some(backend)) => warn!(%backend, "the speech recognizer is missing its settings"), + (None, None) => {} + } + chosen.map(|(_, recognizer)| recognizer) +} + +/// The user's own recognizer from `speech.json`, under the names of the variables that override it. +fn read_settings(path: &Path) -> HashMap { + let Ok(text) = std::fs::read_to_string(path) else { return HashMap::new() }; + let Ok(Value::Object(settings)) = serde_json::from_str::(&text) else { + warn!(path = %path.display(), "the speech settings are not a JSON object; ignored"); + return HashMap::new(); + }; + settings_to_variables(&settings) +} + +fn settings_to_variables(settings: &serde_json::Map) -> HashMap { + [("base_url", OWN_BASE_URL), ("api_key", OWN_API_KEY), ("model", OWN_MODEL), ("language", LANGUAGE_VARIABLE)] + .into_iter() + .filter_map(|(field, variable)| Some((variable.to_owned(), settings.get(field)?.as_str()?.to_owned()))) + .collect() +} + +/// `KEY=VALUE` lines; `export`, quotes, blank lines and `#` comments are allowed. A missing file is logged. +fn read_env_file(path: &Path) -> HashMap { + match std::fs::read_to_string(path) { + Ok(text) => parse_env_file(&text), + Err(error) => { + warn!(path = %path.display(), %error, "cannot read the speech recognizer's settings file"); + HashMap::new() + } + } +} + +fn parse_env_file(text: &str) -> HashMap { + text.lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .filter_map(|line| { + let (key, value) = line.strip_prefix("export ").unwrap_or(line).split_once('=')?; + let value = value.trim(); + let value = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .or_else(|| value.strip_prefix('\'').and_then(|value| value.strip_suffix('\''))) + .unwrap_or(value); + Some((key.trim().to_owned(), value.to_owned())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lookup(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option { + move |name| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| (*value).to_owned()) + } + + #[test] + fn the_cloudflare_request_carries_the_wav_as_base64_and_the_language_only_when_set() { + let backend = Cloudflare { account_id: "acct".to_owned(), token: "tok".to_owned(), language: Some("zh".to_owned()) }; + let request = backend.request(b"RIFFwav"); + assert_eq!(request.url, "https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/openai/whisper-large-v3-turbo"); + assert!(request.headers.contains(&("Authorization".to_owned(), "Bearer tok".to_owned()))); + let body: Value = serde_json::from_slice(&request.body).unwrap(); + assert_eq!(body, json!({"audio": "UklGRndhdg==", "language": "zh"})); + + let backend = Cloudflare { language: None, ..backend }; + let body: Value = serde_json::from_slice(&backend.request(b"x").body).unwrap(); + assert!(body.get("language").is_none()); + } + + #[test] + fn the_cloudflare_answer_gives_its_text_and_language_or_its_error() { + let heard = Cloudflare::parse(200, r#"{"result":{"text":" 你好 ","transcription_info":{"language":"zh"}},"success":true}"#); + assert_eq!(heard.unwrap(), Transcript { text: "你好".to_owned(), language: Some("zh".to_owned()) }); + let error = Cloudflare::parse(401, r#"{"success":false,"errors":[{"code":10000,"message":"Authentication error"}]}"#); + assert_eq!(error.unwrap_err(), "Workers AI answered 401: Authentication error"); + assert!(Cloudflare::parse(502, "").is_err()); + } + + #[test] + fn the_openai_request_is_multipart_with_file_model_and_language() { + let backend = OpenAiCompatible { + base_url: "http://127.0.0.1:8000/v1/".to_owned(), + api_key: Some("sk-test".to_owned()), + model: "whisper-large-v3".to_owned(), + language: Some("zh".to_owned()), + }; + let request = backend.request(b"RIFF\x00\x01", "BOUNDARY"); + assert_eq!(request.url, "http://127.0.0.1:8000/v1/audio/transcriptions"); + assert!(request.headers.contains(&("Content-Type".to_owned(), "multipart/form-data; boundary=BOUNDARY".to_owned()))); + assert!(request.headers.contains(&("Authorization".to_owned(), "Bearer sk-test".to_owned()))); + let mut expected = b"--BOUNDARY\r\nContent-Disposition: form-data; name=\"file\"; filename=\"voice.wav\"\r\nContent-Type: audio/wav\r\n\r\nRIFF\x00\x01\r\n".to_vec(); + expected.extend_from_slice(b"--BOUNDARY\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\nwhisper-large-v3\r\n"); + expected.extend_from_slice(b"--BOUNDARY\r\nContent-Disposition: form-data; name=\"response_format\"\r\n\r\njson\r\n"); + expected.extend_from_slice(b"--BOUNDARY\r\nContent-Disposition: form-data; name=\"language\"\r\n\r\nzh\r\n"); + expected.extend_from_slice(b"--BOUNDARY--\r\n"); + assert_eq!(String::from_utf8_lossy(&request.body), String::from_utf8_lossy(&expected)); + + let keyless = OpenAiCompatible { api_key: None, language: None, ..backend }; + let request = keyless.request(b"", "B"); + assert!(!request.headers.iter().any(|(name, _)| name == "Authorization")); + assert!(!String::from_utf8_lossy(&request.body).contains("language")); + } + + #[test] + fn the_openai_answer_gives_its_text_or_its_error() { + assert_eq!(OpenAiCompatible::parse(200, r#"{"text":"hello there"}"#).unwrap().text, "hello there"); + let error = OpenAiCompatible::parse(400, r#"{"error":{"message":"bad file"}}"#).unwrap_err(); + assert_eq!(error, "the recognizer answered 400: bad file"); + } + + #[test] + fn the_backend_is_chosen_by_name_or_by_which_settings_are_there() { + assert!(configure(&lookup(&[])).is_none()); + assert!(configure(&lookup(&[("CLOUDFLARE_ACCOUNT_ID", "a")])).is_none(), "a token is needed too"); + assert!(configure(&lookup(&[("CLOUDFLARE_ACCOUNT_ID", "a"), ("CLOUDFLARE_AI_TOKEN", "t")])).is_some()); + assert!(configure(&lookup(&[("VIBEBUDDY_STT_BASE_URL", "http://localhost:8000/v1")])).is_some()); + assert!(configure(&lookup(&[("VIBEBUDDY_STT", "off"), ("CLOUDFLARE_ACCOUNT_ID", "a"), ("CLOUDFLARE_AI_TOKEN", "t")])).is_none()); + assert!(configure(&lookup(&[("VIBEBUDDY_STT", "openai"), ("CLOUDFLARE_ACCOUNT_ID", "a"), ("CLOUDFLARE_AI_TOKEN", "t")])).is_none()); + assert!(configure(&lookup(&[("VIBEBUDDY_STT", "whisper.cpp")])).is_none()); + } + + #[test] + fn the_users_own_recognizer_wins_over_cloudflare() { + let both = configure(&lookup(&[ + ("CLOUDFLARE_ACCOUNT_ID", "a"), + ("CLOUDFLARE_AI_TOKEN", "t"), + ("VIBEBUDDY_STT_BASE_URL", "http://127.0.0.1:9/v1"), + ])) + .unwrap(); + // Nothing listens on port 9, so the error names the endpoint that was chosen. + let error = both.recognize(b"").unwrap_err(); + assert!(error.contains("127.0.0.1:9"), "{error}"); + } + + #[test] + fn the_settings_file_maps_onto_the_variables() { + let settings = json!({"base_url": "https://stt.example/v1", "api_key": "k", "model": "m", "extra": 1}); + let variables = settings_to_variables(settings.as_object().unwrap()); + assert_eq!(variables.get("VIBEBUDDY_STT_BASE_URL").map(String::as_str), Some("https://stt.example/v1")); + assert_eq!(variables.get("VIBEBUDDY_STT_API_KEY").map(String::as_str), Some("k")); + assert_eq!(variables.get("VIBEBUDDY_STT_MODEL").map(String::as_str), Some("m")); + assert_eq!(variables.len(), 3); + } + + #[test] + fn the_env_file_takes_plain_quoted_and_exported_lines() { + let parsed = parse_env_file("# dev keys\nA=1\nexport B=\"two\"\n\nC='three'\nnot a line\n"); + assert_eq!(parsed.get("A").map(String::as_str), Some("1")); + assert_eq!(parsed.get("B").map(String::as_str), Some("two")); + assert_eq!(parsed.get("C").map(String::as_str), Some("three")); + assert_eq!(parsed.len(), 3); + } +} diff --git a/daemon/src/voice.rs b/daemon/src/voice.rs new file mode 100644 index 00000000..1ac715c4 --- /dev/null +++ b/daemon/src/voice.rs @@ -0,0 +1,799 @@ +//! Voice commands from a box linked over USB or the local network (ADR-0017; docs/protocol.md, "Voice commands +//! (experimental)"). +//! +//! The box records while K0 is held and sends the audio as `VOICE` diagnostic lines: 24 kHz mono IMA ADPCM, each +//! line carrying the coder's state so it decodes on its own. On `VOICE END` the daemon decodes the turn, has it +//! recognised ([`crate::speech`]), sends `voice.heard`, hands the text to the resident session and sends each spoken +//! reply as `voice.reply` the moment the session says it, then `voice.done`. One turn at a time: a turn that ends +//! while another is still running gets `voice.error` `busy`. + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; + +use base64::Engine; +use tokio::task::JoinHandle; +use tracing::{debug, info, warn}; +use vibebuddy_protocol::{Event, MAX_MESSAGE_BYTES}; + +use crate::resident::Resident; +use crate::serial_transport::Transport; +use crate::speech::Recognizer; + +pub const SAMPLE_RATE: u32 = 24_000; +/// The box stops at 30 s; a little more is kept in case its clock and ours disagree, nothing beyond. +const MAX_SAMPLES: usize = SAMPLE_RATE as usize * 31; +const LINE_PREFIX: &str = "VOICE "; + +// IMA ADPCM, exactly as firmware-rs/core/src/adpcm.rs decodes it; the box encodes with its inverse. +const STEPS: [i32; 89] = [ + 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, 50, 55, 60, 66, 73, 80, 88, 97, 107, 118, 130, + 143, 157, 173, 190, 209, 230, 253, 279, 307, 337, 371, 408, 449, 494, 544, 598, 658, 724, 796, 876, 963, 1060, 1166, + 1282, 1411, 1552, 1707, 1878, 2066, 2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358, 5894, 6484, 7132, 7845, + 8630, 9493, 10442, 11487, 12635, 13899, 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767, +]; +const INDEX_CHANGE: [i32; 16] = [-1, -1, -1, -1, 2, 4, 6, 8, -1, -1, -1, -1, 2, 4, 6, 8]; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Decoder { + predictor: i32, + index: i32, +} + +impl Decoder { + pub fn new(predictor: i16, index: u8) -> Self { + Self { predictor: predictor.into(), index: i32::from(index).min(88) } + } + + pub fn decode(&mut self, nibble: u8) -> i16 { + let step = STEPS[self.index as usize]; + let mut diff = step >> 3; + if nibble & 4 != 0 { + diff += step; + } + if nibble & 2 != 0 { + diff += step >> 1; + } + if nibble & 1 != 0 { + diff += step >> 2; + } + self.predictor = if nibble & 8 != 0 { self.predictor - diff } else { self.predictor + diff }.clamp(-32768, 32767); + self.index = (self.index + INDEX_CHANGE[(nibble & 15) as usize]).clamp(0, 88); + self.predictor as i16 + } + + /// The nibble that brings the decoder closest to `sample`, applied: the inverse the box encodes with. + #[cfg(test)] + pub fn encode(&mut self, sample: i16) -> u8 { + let step = STEPS[self.index as usize]; + let mut diff = i32::from(sample) - self.predictor; + let mut nibble = 0u8; + if diff < 0 { + nibble = 8; + diff = -diff; + } + if diff >= step { + nibble |= 4; + diff -= step; + } + if diff >= step >> 1 { + nibble |= 2; + diff -= step >> 1; + } + if diff >= step >> 2 { + nibble |= 1; + } + self.decode(nibble); + nibble + } + + #[cfg(test)] + pub fn state(&self) -> (i16, u8) { + (self.predictor as i16, self.index as u8) + } +} + +/// Two samples per byte, low nibble first. +pub fn decode_line(predictor: i16, index: u8, bytes: &[u8]) -> Vec { + let mut decoder = Decoder::new(predictor, index); + bytes.iter().flat_map(|byte| [byte & 15, byte >> 4]).map(|nibble| decoder.decode(nibble)).collect() +} + +#[derive(Debug, PartialEq)] +pub enum VoiceLine { + Start { turn: u32 }, + Data { turn: u32, seq: u32, predictor: i16, index: u8, bytes: Vec }, + End { turn: u32, samples: u32 }, + Cancel { turn: u32 }, +} + +/// None when the line isn't a `VOICE` line; an error when it is one but malformed. +pub fn parse_line(line: &str) -> Option> { + let rest = line.trim().strip_prefix(LINE_PREFIX)?; + let fields: Vec<&str> = rest.split(' ').collect(); + let number = |index: usize| fields.get(index).and_then(|field| field.parse::().ok()); + let parsed = match (fields[0], fields.len()) { + ("START", 2) => number(1).map(|turn| VoiceLine::Start { turn }), + ("END", 3) => number(1).zip(number(2)).map(|(turn, samples)| VoiceLine::End { turn, samples }), + ("CANCEL", 2) => number(1).map(|turn| VoiceLine::Cancel { turn }), + ("DATA", 6) => (|| { + let bytes = base64::engine::general_purpose::STANDARD.decode(fields[5]).ok()?; + let index = fields[4].parse::().ok().filter(|index| *index <= 88)?; + Some(VoiceLine::Data { turn: number(1)?, seq: number(2)?, predictor: fields[3].parse().ok()?, index, bytes }) + })(), + _ => None, + }; + Some(parsed.ok_or_else(|| format!("malformed voice line: {}", shorten(rest)))) +} + +fn shorten(text: &str) -> &str { + let mut end = text.len().min(60); + while !text.is_char_boundary(end) { + end -= 1; + } + &text[..end] +} + +/// One turn's audio as it arrives, each line already decoded, by seq. +struct Recording { + turn: u32, + lines: BTreeMap>, + samples: usize, +} + +/// The turn's audio, in order, `samples` long; a lost line becomes silence as long as a line, and audio lost at the +/// end is filled up to `samples`. Returns the seqs that were missing. +fn assemble(lines: &BTreeMap>, samples: usize) -> (Vec, Vec) { + let samples = samples.min(MAX_SAMPLES); + let line_length = lines.values().map(Vec::len).max().unwrap_or(0); + let mut audio = Vec::with_capacity(samples); + let mut missing = Vec::new(); + let mut next = 0; + for (&seq, line) in lines { + for lost in next..seq { + missing.push(lost); + audio.resize(audio.len() + line_length, 0); + } + audio.extend_from_slice(line); + next = seq + 1; + } + while audio.len() < samples && line_length > 0 { + missing.push(next); + next += 1; + audio.resize(audio.len() + line_length, 0); + } + audio.resize(samples, 0); + (audio, missing) +} + +/// A 24 kHz 16-bit mono WAV file. +pub fn wav(samples: &[i16]) -> Vec { + let data = (samples.len() * 2) as u32; + let mut out = Vec::with_capacity(44 + data as usize); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&(36 + data).to_le_bytes()); + out.extend_from_slice(b"WAVEfmt "); + out.extend_from_slice(&16u32.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&SAMPLE_RATE.to_le_bytes()); + out.extend_from_slice(&(SAMPLE_RATE * 2).to_le_bytes()); + out.extend_from_slice(&2u16.to_le_bytes()); + out.extend_from_slice(&16u16.to_le_bytes()); + out.extend_from_slice(b"data"); + out.extend_from_slice(&data.to_le_bytes()); + for sample in samples { + out.extend_from_slice(&sample.to_le_bytes()); + } + out +} + +/// `voice.available`: whether the box may treat a long K0 as push-to-talk. +pub fn available_event(on: bool) -> Event { + let mut event = Event::named("voice.available"); + event.extra.insert("on".to_owned(), on.into()); + event +} + +fn turn_event(name: &str, turn: u32) -> Event { + let mut event = Event::named(name); + event.extra.insert("turn".to_owned(), turn.into()); + event +} + +/// An event carrying `text`, cut at a character boundary (with an ellipsis) until the line fits. +fn fitted(text: &str, build: impl Fn(String) -> Event) -> Event { + let mut keep = text.len(); + loop { + let shown = if keep < text.len() { format!("{}…", &text[..keep]) } else { text.to_owned() }; + let event = build(shown); + let size = serde_json::to_vec(&event).map(|bytes| bytes.len()).unwrap_or(0); + if size <= MAX_MESSAGE_BYTES || keep == 0 { + return event; + } + // Every byte of text is at least one byte of JSON, so this many fewer is always enough room. + keep = keep.saturating_sub(size - MAX_MESSAGE_BYTES + '…'.len_utf8()); + while !text.is_char_boundary(keep) { + keep -= 1; + } + } +} + +fn text_event(name: &str, turn: u32, text: &str) -> Event { + fitted(text, |text| { + let mut event = turn_event(name, turn); + event.extra.insert("text".to_owned(), text.into()); + event + }) +} + +fn error_event(turn: u32, reason: &str, message: &str) -> Event { + fitted(message, |message| { + let mut event = turn_event("voice.error", turn); + event.extra.insert("reason".to_owned(), reason.into()); + event.message = Some(message); + event + }) +} + +fn send(transport: &dyn Transport, event: Event) { + match event.to_ndjson() { + Ok(frame) => { + if let Err(error) = transport.send(frame) { + warn!(?error, event = %event.event, "a voice event could not be queued for the box"); + } + } + Err(error) => warn!(%error, "failed to encode a voice event"), + } +} + +/// Clears the running flag however the turn ends. +struct Running(Arc); + +impl Drop for Running { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +pub struct Voice { + recognizer: Option>, + resident: Option>, + recording: std::sync::Mutex>, + running: Arc, +} + +impl Voice { + pub fn new(recognizer: Option>, resident: Option>) -> Self { + Self { recognizer, resident, recording: std::sync::Mutex::new(None), running: Arc::default() } + } + + /// Voice is offered when the resident session is on and speech recognition is configured. + pub fn available(&self) -> bool { + self.recognizer.is_some() && self.resident.as_ref().is_some_and(|resident| resident.enabled()) + } + + /// Takes one `VOICE` line from the box. A turn that ends starts a task, returned so tests can wait for it. + pub fn handle_line(&self, line: &str, transport: &Arc) -> Option> { + let parsed = match parse_line(line) { + None => return None, + Some(Err(error)) => { + warn!(%error, "ignoring a voice line"); + return None; + } + Some(Ok(parsed)) => parsed, + }; + let mut recording = self.recording.lock().expect("recording lock"); + match parsed { + VoiceLine::Start { turn } => { + if let Some(old) = recording.as_ref().filter(|old| old.turn != turn) { + warn!(old = old.turn, new = turn, "a voice turn started before the last one ended; dropped the last one"); + } + info!(turn, "voice turn recording"); + *recording = Some(Recording { turn, lines: BTreeMap::new(), samples: 0 }); + } + VoiceLine::Data { turn, seq, predictor, index, bytes } => { + if recording.as_ref().is_none_or(|current| current.turn != turn) { + // Its START was lost: the audio still counts. + debug!(turn, "voice data without its start"); + *recording = Some(Recording { turn, lines: BTreeMap::new(), samples: 0 }); + } + let current = recording.as_mut().expect("recording was just made"); + let line = decode_line(predictor, index, &bytes); + if current.samples + line.len() > MAX_SAMPLES { + debug!(turn, seq, "voice turn over the longest recording; line dropped"); + } else { + current.samples += line.len(); + current.lines.insert(seq, line); + } + } + VoiceLine::Cancel { turn } => { + if recording.as_ref().is_some_and(|current| current.turn == turn) { + *recording = None; + } + info!(turn, "voice turn cancelled by the box"); + } + VoiceLine::End { turn, samples } => { + let taken = recording.take_if(|current| current.turn == turn); + drop(recording); + return self.end(turn, samples as usize, taken, transport.clone()); + } + } + None + } + + fn end(&self, turn: u32, samples: usize, recording: Option, transport: Arc) -> Option> { + let (Some(recognizer), Some(resident)) = (self.recognizer.clone(), self.resident.clone().filter(|resident| resident.enabled())) + else { + send(&*transport, error_event(turn, "unavailable", "voice commands are off on this computer")); + return None; + }; + let Some(recording) = recording.filter(|recording| !recording.lines.is_empty()) else { + warn!(turn, "a voice turn ended with no audio"); + send(&*transport, error_event(turn, "not_heard", "no audio arrived")); + return None; + }; + if self.running.swap(true, Ordering::AcqRel) { + info!(turn, "a voice turn ended while another is still running"); + send(&*transport, error_event(turn, "busy", "another voice command is still running")); + return None; + } + let running = Running(self.running.clone()); + let (audio, missing) = assemble(&recording.lines, samples); + if !missing.is_empty() { + warn!(turn, lost = missing.len(), seqs = ?missing, "voice lines were lost; filled with silence"); + } + Some(tokio::spawn(async move { + let _running = running; + run_turn(turn, audio, recognizer, resident, transport).await; + })) + } +} + +async fn run_turn(turn: u32, audio: Vec, recognizer: Arc, resident: Arc, transport: Arc) { + let started = Instant::now(); + let seconds = audio.len() as f32 / SAMPLE_RATE as f32; + let wav = wav(&audio); + let heard = tokio::task::spawn_blocking(move || recognizer.recognize(&wav)) + .await + .unwrap_or_else(|error| Err(error.to_string())); + let recognised_ms = started.elapsed().as_millis() as u64; + let transcript = match heard { + Ok(transcript) => transcript, + Err(error) => { + warn!(turn, %error, "speech recognition failed"); + send(&*transport, error_event(turn, "unavailable", &error)); + return; + } + }; + if transcript.text.is_empty() { + info!(turn, seconds, recognised_ms, "nothing was heard in the voice turn"); + send(&*transport, error_event(turn, "not_heard", "nothing was recognised")); + return; + } + info!(turn, seconds, recognised_ms, language = transcript.language.as_deref().unwrap_or("unknown"), chars = transcript.text.chars().count(), "voice turn heard"); + send(&*transport, text_event("voice.heard", turn, &transcript.text)); + let mut replies = 0; + let answered = resident + .say_streaming(&transcript.text, &mut |reply| { + replies += 1; + info!(turn, elapsed_ms = started.elapsed().as_millis() as u64, "voice reply"); + send(&*transport, text_event("voice.reply", turn, reply)); + }) + .await; + match answered { + Ok(_) => { + info!(turn, replies, elapsed_ms = started.elapsed().as_millis() as u64, "voice turn done"); + send(&*transport, turn_event("voice.done", turn)); + } + Err(error) => { + warn!(turn, %error, "the resident session failed the voice turn"); + send(&*transport, error_event(turn, "unavailable", &error.to_string())); + } + } +} + +#[cfg(test)] +pub mod tests { + use serde_json::json; + + use super::*; + + /// Encodes `samples` as the box does: lines of `per_line` samples, each with the coder state it starts from. + pub fn voice_lines(turn: u32, samples: &[i16], per_line: usize) -> Vec { + let mut encoder = Decoder::new(0, 0); + let mut lines = vec![format!("VOICE START {turn}")]; + for (seq, chunk) in samples.chunks(per_line).enumerate() { + let (predictor, index) = encoder.state(); + let bytes: Vec = chunk + .chunks(2) + .map(|pair| { + let low = encoder.encode(pair[0]); + let high = pair.get(1).map(|&sample| encoder.encode(sample)).unwrap_or(0); + low | high << 4 + }) + .collect(); + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + lines.push(format!("VOICE DATA {turn} {seq} {predictor} {index} {data}")); + } + lines.push(format!("VOICE END {turn} {}", samples.len())); + lines + } + + pub fn sine(count: usize) -> Vec { + (0..count).map(|index| ((index as f32 * 0.05).sin() * 12000.0) as i16).collect() + } + + #[test] + fn lines_parse_and_malformed_ones_are_told_apart_from_other_lines() { + assert_eq!(parse_line("VOICE START 3"), Some(Ok(VoiceLine::Start { turn: 3 }))); + assert_eq!(parse_line("VOICE END 3 12000\r"), Some(Ok(VoiceLine::End { turn: 3, samples: 12000 }))); + assert_eq!(parse_line("VOICE CANCEL 3"), Some(Ok(VoiceLine::Cancel { turn: 3 }))); + assert_eq!( + parse_line("VOICE DATA 3 7 -1200 42 AQL/"), + Some(Ok(VoiceLine::Data { turn: 3, seq: 7, predictor: -1200, index: 42, bytes: vec![1, 2, 255] })) + ); + assert!(parse_line("VOICES hsiaoyu").is_none()); + assert!(parse_line("MODE LEISURE").is_none()); + for bad in ["VOICE DATA 3 7 -1200 89 AQL/", "VOICE DATA 3 7 40000 1 AQL/", "VOICE DATA 3 x 0 0 AQL/", "VOICE DATA 3 7 0 0 !!", "VOICE END 3", "VOICE SING 1"] { + assert!(matches!(parse_line(bad), Some(Err(_))), "{bad}"); + } + } + + /// The decoder here and the box's are the same: fed the same nibbles from zero, they give the same samples. + #[test] + fn the_decoder_matches_firmware_core() { + let mut ours = Decoder::new(0, 0); + let mut box_side = vibebuddy_firmware_core::adpcm::Decoder::default(); + let mut seed = 0x1234_5678u32; + for _ in 0..200_000 { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let nibble = (seed >> 28) as u8; + assert_eq!(ours.decode(nibble), box_side.decode(nibble)); + } + } + + /// A line decoded on its own, from the state it carries, is the same as the stream decoded straight through. + #[test] + fn a_line_decodes_on_its_own_from_the_state_it_carries() { + let original = sine(10_000); + let lines = voice_lines(1, &original, 1152); + let mut box_side = vibebuddy_firmware_core::adpcm::Decoder::default(); + let mut straight = Vec::new(); + let mut separately = Vec::new(); + for line in &lines { + if let Some(Ok(VoiceLine::Data { predictor, index, bytes, .. })) = parse_line(line) { + straight.extend(bytes.iter().flat_map(|byte| [byte & 15, byte >> 4]).map(|nibble| box_side.decode(nibble))); + separately.extend(decode_line(predictor, index, &bytes)); + } + } + assert_eq!(straight[..10_000], separately[..10_000]); + for (k, (&decoded, &expected)) in separately.iter().zip(&original).enumerate().skip(60) { + assert!((i32::from(decoded) - i32::from(expected)).abs() < 600, "sample {k}: {decoded} vs {expected}"); + } + } + + #[test] + fn lost_lines_become_silence_of_a_lines_length() { + let lines: BTreeMap> = [(0, vec![1; 4]), (2, vec![3; 4]), (3, vec![4; 2])].into(); + let (audio, missing) = assemble(&lines, 14); + assert_eq!(audio, [1, 1, 1, 1, 0, 0, 0, 0, 3, 3, 3, 3, 4, 4]); + assert_eq!(missing, [1]); + // Lines lost at the end are filled up to what END says was recorded. + let lines: BTreeMap> = [(1, vec![2; 4])].into(); + let (audio, missing) = assemble(&lines, 11); + assert_eq!(audio, [0, 0, 0, 0, 2, 2, 2, 2, 0, 0, 0]); + assert_eq!(missing, [0, 2]); + // An odd count cuts the padding nibble of the last byte. + let lines: BTreeMap> = [(0, vec![5; 4])].into(); + assert_eq!(assemble(&lines, 3).0, [5, 5, 5]); + } + + #[test] + fn the_wav_header_is_24_khz_16_bit_mono() { + let file = wav(&[1, -1]); + assert_eq!(file.len(), 48); + assert_eq!(&file[..4], b"RIFF"); + assert_eq!(u32::from_le_bytes(file[24..28].try_into().unwrap()), 24_000); + assert_eq!(u16::from_le_bytes(file[22..24].try_into().unwrap()), 1); + assert_eq!(u16::from_le_bytes(file[34..36].try_into().unwrap()), 16); + assert_eq!(&file[44..], [1, 0, 255, 255]); + } + + #[test] + fn long_text_is_cut_at_a_character_to_fit_the_line() { + let long = "语音".repeat(400); + let event = text_event("voice.heard", 1, &long); + let frame = event.to_ndjson().expect("fits"); + assert!(frame.len() <= MAX_MESSAGE_BYTES + 1); + let text = event.extra["text"].as_str().unwrap(); + assert!(text.ends_with('…') && long.starts_with(text.trim_end_matches('…'))); + assert!(frame.len() > MAX_MESSAGE_BYTES - 8, "cut no more than needed: {}", frame.len()); + // Quotes take two bytes of JSON each. + let quoted = "\"".repeat(2000); + assert!(text_event("voice.reply", 1, "ed).to_ndjson().is_ok()); + assert_eq!(text_event("voice.reply", 1, "short").extra["text"], "short"); + } + + #[derive(Default)] + pub struct RecordingTransport { + frames: std::sync::Mutex>>, + } + + impl Transport for RecordingTransport { + fn send(&self, frame: Vec) -> Result<(), crate::serial_transport::TransportError> { + self.frames.lock().unwrap().push(frame); + Ok(()) + } + } + + impl RecordingTransport { + pub fn events(&self) -> Vec { + self.frames.lock().unwrap().iter().map(|frame| serde_json::from_slice(frame).unwrap()).collect() + } + } + + /// Answers every recording with the same result, and keeps the WAV files it was given. + struct FakeRecognizer { + answer: Result<&'static str, &'static str>, + heard: std::sync::Mutex>>, + } + + impl Recognizer for FakeRecognizer { + fn recognize(&self, wav: &[u8]) -> Result { + self.heard.lock().unwrap().push(wav.to_vec()); + self.answer + .map(|text| crate::speech::Transcript { text: text.to_owned(), language: Some("en".to_owned()) }) + .map_err(str::to_owned) + } + } + + use crate::resident::tests::{Shared, logged_in, resident, temp_dir, tool_call}; + + struct Rig { + voice: Voice, + transport: Arc, + recognizer: Arc, + script: Shared, + dir: std::path::PathBuf, + } + + impl Rig { + fn new(name: &str, answer: Result<&'static str, &'static str>, script: Shared) -> Self { + let dir = temp_dir(&format!("voice-{name}")); + let recognizer = Arc::new(FakeRecognizer { answer, heard: Default::default() }); + let resident = Arc::new(resident(&dir, &script, Arc::default())); + let voice = Voice::new(Some(recognizer.clone()), Some(resident)); + Self { voice, transport: Arc::default(), recognizer, script, dir } + } + + /// Feeds the lines; returns the task the last one started, if any. + fn feed(&self, lines: &[String]) -> Option> { + let transport: Arc = self.transport.clone(); + lines.iter().filter_map(|line| self.voice.handle_line(line, &transport)).last() + } + + fn names(&self) -> Vec { + self.transport.events().iter().map(|event| event.event.clone()).collect() + } + + fn error(&self) -> (String, String) { + let events = self.transport.events(); + let error = events.iter().find(|event| event.event == "voice.error").expect("a voice.error"); + (error.extra["reason"].as_str().unwrap().to_owned(), error.message.clone().unwrap_or_default()) + } + } + + impl Drop for Rig { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } + } + + #[tokio::test] + async fn a_turn_is_heard_answered_reply_by_reply_and_done() { + let script = logged_in(); + let hold = Arc::new(tokio::sync::Notify::new()); + script.lock().unwrap().hold = Some(hold.clone()); + script.lock().unwrap().turn_requests = + vec![tool_call("speak", json!({"text": "Two agents are working."})), tool_call("speak", json!({"text": "One needs you."}))]; + let rig = Rig::new("turn", Ok("what are my agents doing"), script); + + let task = rig.feed(&voice_lines(4, &sine(30_000), 1152)).expect("the turn runs"); + // Both replies reach the box while the turn is still open in the app-server. + while rig.names().len() < 3 { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + assert_eq!(rig.names(), ["voice.heard", "voice.reply", "voice.reply"]); + assert!(!task.is_finished()); + hold.notify_one(); + task.await.unwrap(); + + let events = rig.transport.events(); + assert_eq!(rig.names(), ["voice.heard", "voice.reply", "voice.reply", "voice.done"]); + assert!(events.iter().all(|event| event.extra["turn"] == 4)); + assert_eq!(events[0].extra["text"], "what are my agents doing"); + assert_eq!(events[1].extra["text"], "Two agents are working."); + assert_eq!(events[2].extra["text"], "One needs you."); + let wav = rig.recognizer.heard.lock().unwrap()[0].clone(); + assert_eq!(wav.len(), 44 + 30_000 * 2, "the whole recording, as 16-bit samples"); + let turn_text = rig.script.lock().unwrap().received.iter().find(|message| message["method"] == "turn/start").unwrap() + ["params"]["input"][0]["text"] + .clone(); + assert_eq!(turn_text, "what are my agents doing"); + } + + #[tokio::test] + async fn lost_lines_still_make_a_turn_of_the_recorded_length() { + let rig = Rig::new("gaps", Ok("hello"), logged_in()); + let mut lines = voice_lines(1, &sine(5_000), 1000); + // No START and no line 2: the rest still counts. + lines.retain(|line| !line.starts_with("VOICE START") && !line.starts_with("VOICE DATA 1 2 ")); + rig.feed(&lines).expect("the turn runs").await.unwrap(); + let wav = rig.recognizer.heard.lock().unwrap()[0].clone(); + assert_eq!(wav.len(), 44 + 5_000 * 2); + let silence = &wav[44 + 2_000 * 2..44 + 3_000 * 2]; + assert!(silence.iter().all(|byte| *byte == 0), "line 2 is silence"); + assert_eq!(rig.names().last().map(String::as_str), Some("voice.done")); + } + + #[tokio::test] + async fn nothing_recognised_is_not_heard_and_the_session_is_not_asked() { + let rig = Rig::new("empty", Ok(""), logged_in()); + rig.feed(&voice_lines(2, &sine(12_000), 1152)).unwrap().await.unwrap(); + assert_eq!(rig.names(), ["voice.error"]); + assert_eq!(rig.error().0, "not_heard"); + assert_eq!(rig.script.lock().unwrap().starts, 0); + } + + #[tokio::test] + async fn a_failed_recognizer_or_session_is_unavailable() { + let rig = Rig::new("stt-down", Err("Workers AI answered 401: Authentication error"), logged_in()); + rig.feed(&voice_lines(1, &sine(12_000), 1152)).unwrap().await.unwrap(); + assert_eq!(rig.error(), ("unavailable".to_owned(), "Workers AI answered 401: Authentication error".to_owned())); + + // Codex isn't logged in: what was heard is still shown, then the error. + let rig = Rig::new("no-login", Ok("hi"), Arc::new(std::sync::Mutex::new(Default::default()))); + rig.feed(&voice_lines(1, &sine(12_000), 1152)).unwrap().await.unwrap(); + assert_eq!(rig.names(), ["voice.heard", "voice.error"]); + assert_eq!(rig.error().0, "unavailable"); + } + + #[tokio::test] + async fn a_turn_that_ends_while_another_runs_is_busy() { + let script = logged_in(); + let hold = Arc::new(tokio::sync::Notify::new()); + script.lock().unwrap().hold = Some(hold.clone()); + let rig = Rig::new("busy", Ok("first"), script); + let first = rig.feed(&voice_lines(1, &sine(12_000), 1152)).unwrap(); + assert!(rig.feed(&voice_lines(2, &sine(12_000), 1152)).is_none()); + let events = rig.transport.events(); + let busy = events.iter().find(|event| event.event == "voice.error").unwrap(); + assert_eq!((busy.extra["turn"].as_u64(), busy.extra["reason"].as_str()), (Some(2), Some("busy"))); + hold.notify_one(); + first.await.unwrap(); + // Once it is over, the next turn runs. + hold.notify_one(); + rig.feed(&voice_lines(3, &sine(12_000), 1152)).expect("not busy any more").await.unwrap(); + } + + #[tokio::test] + async fn a_cancelled_turn_is_dropped_and_an_end_without_audio_is_not_heard() { + let rig = Rig::new("cancel", Ok("hi"), logged_in()); + let mut lines = voice_lines(5, &sine(6_000), 1152); + lines.pop(); + lines.push("VOICE CANCEL 5".to_owned()); + assert!(rig.feed(&lines).is_none()); + assert!(rig.names().is_empty(), "a cancelled turn gets no answer"); + assert!(rig.feed(&["VOICE END 5 6000".to_owned()]).is_none()); + assert_eq!(rig.error().0, "not_heard"); + assert!(rig.recognizer.heard.lock().unwrap().is_empty()); + } + + /// The left channel of a 16-bit PCM WAV file, and its sample rate. + fn left_channel(file: &[u8]) -> (Vec, u32) { + let mut at = 12; + let (mut channels, mut rate) = (1usize, 0u32); + while at + 8 <= file.len() { + let size = u32::from_le_bytes(file[at + 4..at + 8].try_into().unwrap()) as usize; + let body = &file[at + 8..(at + 8 + size).min(file.len())]; + match &file[at..at + 4] { + b"fmt " => { + channels = u16::from_le_bytes([body[2], body[3]]) as usize; + rate = u32::from_le_bytes(body[4..8].try_into().unwrap()); + assert_eq!(u16::from_le_bytes([body[14], body[15]]), 16, "16-bit PCM only"); + } + b"data" => { + let samples = body.chunks_exact(2 * channels).map(|frame| i16::from_le_bytes([frame[0], frame[1]])).collect(); + return (samples, rate); + } + _ => {} + } + at += 8 + size + size % 2; + } + panic!("no data chunk"); + } + + /// Prints each event the moment the daemon sends it, with the time since `VOICE END`. + struct TimedTransport { + end: std::sync::Mutex>, + events: std::sync::Mutex>, + } + + impl Transport for TimedTransport { + fn send(&self, frame: Vec) -> Result<(), crate::serial_transport::TransportError> { + assert!(frame.len() <= MAX_MESSAGE_BYTES + 1); + let event: Event = serde_json::from_slice(&frame).unwrap(); + let ms = self.end.lock().unwrap().map(|end| end.elapsed().as_millis()).unwrap_or_default(); + println!("+{ms:>6} ms {}", String::from_utf8_lossy(&frame).trim_end()); + self.events.lock().unwrap().push((ms, event)); + Ok(()) + } + } + + /// A real recording through the whole computer side, without the box: encoded as the box encodes it, fed as + /// `VOICE` lines, recognised by the real recognizer and answered by the real resident session. Run by hand: + /// + /// ```sh + /// VIBEBUDDY_E2E_WAV=mic.wav VIBEBUDDY_STT_ENV_FILE=~/.vibebuddy-dev-ai.env VIBEBUDDY_CODEX=codex-without-hooks \ + /// cargo test -p vibebuddyd e2e_ -- --ignored --nocapture + /// ``` + /// + /// `VIBEBUDDY_CODEX` should start Codex with `-c features.hooks=false`, or the turn's hooks reach the daemon on + /// 7331 and the box shows the test as an Activity. The thread lives in a temporary state directory. + #[tokio::test(flavor = "multi_thread")] + #[ignore = "uses the network, a speech recognizer's credentials and the user's Codex"] + async fn e2e_a_real_recording_through_the_real_recognizer_and_resident_session() { + let _ = tracing_subscriber::fmt().with_env_filter("vibebuddyd=info").with_test_writer().try_init(); + let path = std::env::var("VIBEBUDDY_E2E_WAV").expect("VIBEBUDDY_E2E_WAV names a recording"); + let (left, rate) = left_channel(&std::fs::read(path).unwrap()); + assert_eq!(rate, SAMPLE_RATE, "the box records at 24 kHz"); + let recognizer = crate::speech::from_env().expect("a speech recognizer is configured"); + let dir = temp_dir("voice-e2e"); + let resident = Arc::new(Resident::new(true, &dir, crate::resident::Launcher::Codex, Arc::default())); + let voice = Voice::new(Some(recognizer), Some(resident)); + let timed = Arc::new(TimedTransport { end: Default::default(), events: Default::default() }); + let transport: Arc = timed.clone(); + + // 1152 samples are 576 bytes, 768 base64 characters: the most a line may carry. + let lines = voice_lines(1, &left, 1152); + assert!(lines.iter().all(|line| line.len() < 1024)); + println!("{:.2} s of audio in {} lines", left.len() as f32 / SAMPLE_RATE as f32, lines.len()); + let (end, data) = lines.split_last().unwrap(); + for line in data { + assert!(voice.handle_line(line, &transport).is_none()); + } + *timed.end.lock().unwrap() = Some(Instant::now()); + voice.handle_line(end, &transport).expect("the turn runs").await.unwrap(); + + let events = timed.events.lock().unwrap(); + let names: Vec<&str> = events.iter().map(|(_, event)| event.event.as_str()).collect(); + assert_eq!(names.first(), Some(&"voice.heard"), "{names:?}"); + assert_eq!(names.last(), Some(&"voice.done"), "{names:?}"); + assert!(names.contains(&"voice.reply"), "{names:?}"); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn voice_is_offered_only_with_a_recognizer_and_the_resident_session_on() { + let dir = temp_dir("voice-offer"); + let on = || Some(Arc::new(resident(&dir, &logged_in(), Arc::default()))); + let off = Some(Arc::new(Resident::new(false, &dir, crate::resident::Launcher::Codex, Arc::default()))); + let recognizer = || Some(Arc::new(FakeRecognizer { answer: Ok(""), heard: Default::default() }) as Arc); + assert!(Voice::new(recognizer(), on()).available()); + assert!(!Voice::new(None, on()).available()); + assert!(!Voice::new(recognizer(), off).available()); + assert!(!Voice::new(recognizer(), None).available()); + + // Lines that arrive anyway are told voice is off. + let voice = Voice::new(None, on()); + let transport = Arc::new(RecordingTransport::default()); + let shared: Arc = transport.clone(); + for line in voice_lines(1, &sine(12_000), 1152) { + voice.handle_line(&line, &shared); + } + assert_eq!(transport.events()[0].extra["reason"], "unavailable"); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/docs/protocol.md b/docs/protocol.md index 369309b8..966033a5 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -195,7 +195,7 @@ A voice command (ADR-0017) on a box linked over USB or the local network: the bo The computer offers voice, and the box only treats a long K0 as push-to-talk while it is offered: -- `{"version":1,"event":"voice.available","on":true}`: sent after `device.hello` is answered, and again whenever it changes, when the resident session is switched on and speech recognition is configured; `"on":false` withdraws it. A box that hasn't been told, or was told `false`, keeps K0 long as it was. A new link starts with voice off until the computer says otherwise. +- `{"version":1,"event":"voice.available","on":true}`: sent after `device.hello` is answered (the daemon sends it on every `DISPLAY READY BUILD`, so a box that rebooted on the same link is told again), and again whenever it changes, when the resident session is switched on and speech recognition is configured; `"on":false` withdraws it. A box that hasn't been told, or was told `false`, keeps K0 long as it was. A new link starts with voice off until the computer says otherwise. One recording is a turn, numbered by the box from 1 after each boot. The box sends diagnostic lines, each under 1024 bytes: @@ -209,7 +209,7 @@ The computer answers with JSON events carrying the same `turn`: - `voice.heard` with `text`: what was recognised (cut to fit the line), so the box can show it. - `voice.reply` with `text`: one spoken reply, sent the moment the resident session says it; a turn may have several. - `voice.done`: the turn is over; the box goes back to what it showed before. -- `voice.error` with `reason` (`not_heard` when recognition returned nothing, `unavailable` when recognition or the resident session failed, `busy` when another turn is still running) and an optional `message`; the turn is over. +- `voice.error` with `reason` (`not_heard` when recognition returned nothing or no audio arrived, `unavailable` when recognition or the resident session failed, `busy` when another turn is still running) and an optional `message`; the turn is over. Spoken replies are shown as text for now; their audio will follow as `voice.audio` chunks once there is speech synthesis. diff --git a/docs/resident-session.md b/docs/resident-session.md index 1533ba19..96c57a76 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -1,6 +1,6 @@ # Resident session -The resident session is the one long-running Codex thread every voice command goes to ([ADR-0017](adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md); see "Voice commands" in [`CONTEXT.md`](../CONTEXT.md)). This is the first, command-line-only cut: the box can't hear yet, so a voice command is typed. +The resident session is the one long-running Codex thread every voice command goes to ([ADR-0017](adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md); see "Voice commands" in [`CONTEXT.md`](../CONTEXT.md)). A voice command reaches it spoken to a box linked over USB or the local network (below), or typed with `vibebuddy say`. It is experimental and off by default. Start `vibebuddyd` with `VIBEBUDDY_RESIDENT_SESSION=1` to turn it on. There is no App UI for it, on the Mac or on Linux. @@ -25,11 +25,35 @@ vibebuddy resident # prints codex://threads/, to op - **Starting.** On the first voice command, and on the next one after the app-server dies, the daemon runs the `codex` it finds (`VIBEBUDDY_CODEX`, else PATH, else `~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, since launchd's PATH is bare). It sends `initialize` (`experimentalApi: true`) and `initialized`, then `account/read`: with no account it stops with "not logged in". It never starts a login. - **One thread for good.** `thread/start` once, with `cwd` set to `resident/` in the daemon's state directory, `sandbox: "read-only"`, `approvalPolicy: "on-request"`, the developer instructions and the dynamic tools. The thread id is kept in `resident.json` beside it, and every later start sends `thread/resume` with the same settings (the tools survive a resume; the instructions are sent again). If the thread can't be resumed, a new one is started and saved, and the log names both the old and the new thread id. - **Only our tools.** The app-server is started with the user's own Codex config, minus everything that gives a thread tools of its own: `-c features.=false` for plugins, apps (connectors), the shell and exec tools, sub-agents, goals, memories, image generation and viewing, the browser and computer use, sleep, tool suggestions and skill search, plus `web_search="disabled"`. An override can't empty `mcp_servers` (it merges), so the daemon first asks `codex mcp list --json`, under the same overrides, which servers would be on, and adds `-c mcp_servers..enabled=false` for each. Overrides are `-c` rather than `--disable`, which refuses a feature name another Codex version doesn't know. The user's global `AGENTS.md` and hooks stay: the hooks are filtered (below). Checked on 0.162.0: `mcpServerStatus/list` goes from seven servers (one with 365 app tools) to two, both off with no tools, and the thread's tool list is `speak`, `list_activities` and what Codex always adds and has no switch for: `apply_patch` (refused by the read-only sandbox, its approval declined), a clock, and the MCP resource readers (no server is on to read from). -- **Tools.** `speak({text})` is the spoken reply: the daemon collects it and hands it back to the caller (later it goes to the box). `list_activities({})` returns the Activities the aggregator already holds, as JSON: agent, task-card title, project, `working`/`needs_input`, host, seconds in that state and since the last event. There are no tools that run commands or write files. +- **Tools.** `speak({text})` is the spoken reply: the daemon hands it on the moment it is called (`Resident::say_streaming`), to the box as `voice.reply`, or collected for `POST /v1/resident/say`. `list_activities({})` returns the Activities the aggregator already holds, as JSON: agent, task-card title, project, `working`/`needs_input`, host, seconds in that state and since the last event. There are no tools that run commands or write files. - **Declining.** Every approval or question the session raises is declined: command and file-change approvals (`decline`, or `denied` on the legacy methods), permission requests (nothing granted), MCP elicitations (`decline`) and user-input requests (no answers). `currentTime/read` is answered; anything else gets a JSON-RPC "method not found". - **Turns.** `turn/start` with the text, then the daemon answers tool calls until `turn/completed` for that turn. One turn at a time: a command that arrives during another waits for it, in order, rather than being rejected, since two quick sentences are both meant to be answered. A turn that takes over two minutes is interrupted (`turn/interrupt`). - **Persona.** `instructions()` in `resident.rs` takes the Character's persona; until Characters carry one, it is `None` and the buddy speaks plainly. +## From the box + +The box side of push-to-talk is in [`protocol.md`](protocol.md), "Voice commands (experimental)"; `daemon/src/voice.rs` is the computer side and `daemon/src/speech.rs` the recognizers. + +- **Offered.** Voice is on when the resident session is on and a speech recognizer is configured. The daemon then sends `voice.available` `on: true` every time the box reports its build (after `device.hello` on each new link, and after a reboot). Never over the Relay: there the box will talk to the voice service itself. Nothing switches either condition at run time yet, so `on: false` is never sent. +- **Recording.** `VOICE DATA` lines are decoded as they arrive, each from the IMA ADPCM state it carries (the same decoder as `firmware-rs/core/src/adpcm.rs`, checked against it in the tests). On `VOICE END` the lines are put in seq order; a lost line becomes silence as long as a line, audio lost at the end is filled up to the sample count `END` gives, and the missing seqs are logged. A turn's audio is kept only in memory and only until it is recognised; `VOICE DATA` lines stay out of the log. +- **A turn.** The audio goes to the recognizer as a 24 kHz 16-bit mono WAV file; the text goes to the box as `voice.heard` and to the resident session as a voice command; each `speak` goes to the box as `voice.reply` as soon as it is called; `voice.done` follows `turn/completed`. Text is cut at a character, with an ellipsis, to fit the 1024-byte line. Errors: `not_heard` (no audio arrived, or the recognizer heard nothing), `unavailable` (voice is off, recognition failed, or the resident session failed, with the reason as `message`), `busy` (a turn ended while another is still being answered; unlike `vibebuddy say`, a voice turn doesn't wait). A typed command and a voice turn still queue behind each other in the resident session. +- **Timing.** A 5 s recording, checked end to end on 2026-10-13 (recognition by Workers AI, codex-cli 0.162.0, app-server started cold): `voice.heard` 2.2 s after `VOICE END`, the spoken reply at 10.1 s, `voice.done` at 14.0 s. + +The end-to-end check is an ignored test, `e2e_a_real_recording_through_the_real_recognizer_and_resident_session` in `voice.rs`: it encodes the left channel of a WAV file as the box would and feeds it through the real recognizer and the real resident session, in a temporary state directory. Its doc comment says how to run it; give it a `VIBEBUDDY_CODEX` that adds `-c features.hooks=false`, or the turn's hooks reach the daemon on 7331 and show up on the box. + +### Speech recognition + +Behind the `Recognizer` trait, so the planned voice service in `vibebuddy-cloud` (called with the account session) can take over. For now there are two backends, picked at start: + +| Backend | Settings | Used | +| --- | --- | --- | +| The user's own recognizer: any OpenAI-compatible `POST {base}/audio/transcriptions` | `VIBEBUDDY_STT_BASE_URL`, `VIBEBUDDY_STT_API_KEY` (optional), `VIBEBUDDY_STT_MODEL` (default `whisper-1`) | When set; it wins over Cloudflare | +| Cloudflare Workers AI, `@cf/openai/whisper-large-v3-turbo` | `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_AI_TOKEN` | Otherwise; for development | + +`VIBEBUDDY_STT=cloudflare|openai|off` forces the choice. `VIBEBUDDY_STT_LANGUAGE` (`zh`, `en`, …) tells the recognizer the language; unset, it detects it, and the log names what it detected. + +Each setting is taken from the environment, else from `speech.json` in the config directory (`{"base_url","api_key","model","language"}`, the user's own recognizer), else from a `KEY=VALUE` file named by `VIBEBUDDY_STT_ENV_FILE` (development credentials, so they stay out of the daemon's environment and out of the repository). All are read when the daemon starts. `speech.json` is a file of its own, not part of `config.json`, because `PUT /v1/config` replaces the whole config and an App that doesn't know the field would wipe the key; whoever writes it (an App setting, later) writes it with mode 0600. Credentials are never logged, and errors name only the recognizer's host. + ## Never an Activity The user's Codex hooks fire for the resident thread too: its `UserPromptSubmit`, `PostToolUse` and `Stop` reach `/v1/codex-hooks` like any session's. The Codex adapter drops a hook whose session or thread is the resident thread, or whose `cwd` is inside the resident directory (which also covers the thread opened in the Codex App), before anything is recorded: no card, announcement, stats, CI watch or K2 target. This works even with the resident session switched off. @@ -42,6 +66,4 @@ app-server and `dynamicTools` are experimental. This was built and checked again `list_activities` sends the task-card titles and project names the daemon holds, with each Activity's agent, state, host app and ages, to the model provider the user's Codex uses, as part of the resident thread. Until now these stayed on the computer and the box. Prompts and replies are not sent: the daemon never holds them. -## Known gaps - -- A reply is returned when the turn completes, which in testing was 4 to 6 seconds after `speak` was called. The box should play a spoken reply the moment `speak` arrives, not at `turn/completed`. +A voice command's audio leaves the computer for the speech recognizer: in development that is Cloudflare Workers AI, under the developer's own account; with the user's own recognizer, only that endpoint (and its key goes nowhere else). The recognised text then goes to the user's Codex model provider as the voice command. The daemon keeps neither the audio nor the text once the turn is over, and logs neither. From 2daaadb8965dc863611d0c25c33c48781d89881c Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 18:04:43 +0800 Subject: [PATCH 17/51] Remove test recordings committed by mistake Co-Authored-By: Claude Opus 5.5 --- mic-L-2.wav | Bin 240044 -> 0 bytes mic-R-2.wav | Bin 240044 -> 0 bytes mic-left-boosted.wav | Bin 240044 -> 0 bytes 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 mic-L-2.wav delete mode 100644 mic-R-2.wav delete mode 100644 mic-left-boosted.wav diff --git a/mic-L-2.wav b/mic-L-2.wav deleted file mode 100644 index fc84fc8c2a0d125770c6689202bb5e30bb17af0f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 240044 zcmW)I4?Np<{(t^TNF)-8L?R)CXp~acY;Nvs`sMV?)pu_0?DVT&uDGT+7wPwqfj#5UY(xyTP2_b|K`78f^FW*NV-BeZM^ZvYEujjwl z=e6w2O!4Pv#4lbc_?NQ({#`^60)arnOLhT)_`}x-1PT#^`1ODM?mzwjUxP$csY@1~ zJ$?4LW7;wG*`xW1zmB~rds%Y--szEF4L-W_>)t;9WjwpO1rZh$>NQiw~z>G0PUE?{U^f97Xqo6CMhkucm z5ZN7>A8Cq^gmbwAtgB%oTr0PfQx;auY7QBpQ$nifqqO|sa_R<2PHqm$4=N#9i8Ozt zp90_LZ}p$TtMRQ^fnSEdGN33x>VFS^3#-7^`kMnxgfv2Lz!+}Me=tB3a2_9zdk3A3 zEI_wn=lqO*96u!bQ(qNAfVksP9W^)baT6!kPIAJX?Pv86G)F*yDzwZ9UKjJ!L{~r6{kHx{C zpZe#=cl`Qx2i_fdS@KuecN0@Hzm;El>R9q>lGmFy3%1G(`6lbZtaa8U@$Mj|uo?ct z{*r(UVl8o$)I}R*h(eRt387r3J50(ohfhbe@T$0zyzXeZpj^-?n2pxQM90zN7vp{L z`jhy$H%<=48RBfhn;6?0#i(V z)E)jcL4n{wjFF!Z*~v|dK=Gy{+QUs8HH#lw#lFVQV&OxZ7)Gj(s-mK(DsmsWmY5&F zAczS9Vp{+!0O`lWdi}6?3C@fg!?j_A*j7ATcYHry>(`IX#|iwK@Yy)2-z}^Qqw;43 zSp8kNc-%*56}k{h!4#mqh&12Zo+e)g;x*(|uiI5`Qyh*Sk`4_f^)AIg(KW5*Xk}|0 zO`rO$=e6>PnUAJFpL}`Zmtz4B{{8De-23Ru|GfL~3(G&B+&=yBhaY|U;gf&-&);qD zKW_c+IuSjYRRThEMVEfBUlRt*PFHijR zR6_zP9v^q*q~fIh1XobSFAHaf(YX~ILzorbR~54;w4Ci=O)y938B{JgKbS--3K4}g zhZw0sS}Wrk(;U)Cub^y@swpKj2Te!U(G`?TN+Y$3swGPTuLo4(68#GaG-33Z__PrH8I9n|}@|6jjY_~QG)Uk!eLuTFM; zs^-bq^R{KhTJaYBKxwV8U9Ejf_Z{2vP zH9<-YJ6aT3z`e*V;Ho*PTxA%K)yV8)3K^mhd~gY6n0S1>umn~>en5v`HpYR4UV^E_ zq+>22htXzCD>fb@@Y6sg%=woPNCbR99iD>u0E5S!!4>#TVHsElsu@!U^}s+~M%?gJ zxD&jy-dInHyUW>SE4AHp8XVnL)PcyT(NAo(Y~^lsY~*Oimd6%di#5-46bFw3rxqUk z{`)%FTi=C9x9&%b{GhaT$JFI0aULANEfp3#{G_)v3L zS_C#q8JQkg$YFAB!mq1gbhen0OP7RLnYrxru#0RiT@$RQR8jH4Wx>iIQD9}DoY)pv zMOKkjL@&`q8X=p5swpy(f`BE>22D`gg1Pi^Y6+#9Mx*D_Q|aj;iBu?S28q!eG8EDZ z@3#V~LQInc_Xb}IvXT`PN)Us@3Uoo2N+k{kaQ)NZELDhUUo9d6B|z9bZdbb1cOX6J zHkIxw4Sid^I`ukfy=c{{_9|bVAA1}#i~E5#X&L|Qd)#++Dejwj$xrV6>)oFXec%6w z{=fDA{GVZ;t@MWWg$@RPdwKHvAAUKtFio5MXmRy`wyQW=y%O`@Zil+Z$kjDcdWhE+uLhWBvOSPHnl z`Qeq3=c40ck_6Zj1JJAFFe!+F`V-_601%cbq8UcEXQOE(-4mN@KdCNB9F# z`H^{?_>d%aQ`EV*pC=`x5EDO+A3dcLro^O&^MkvCim5$R0o}*Ma>Nmem^WguQPmMv zsE$^afN2gEP|N9!aHc)X*^qh~KX@=m1Lu=QvXZN54IxR)LWYW&!ZI?8skOxPfC+y& z5g#Na@(4M^CQ=j0K|zsc025fXEN%A!Y|w263q=G3M=IB;cBbcsnD5Bob<5# z&?@HDu&d$x$kHf7q!Q49k5|sii|FJ{az{Cx+$Ii}UCK&jHiy)Na@mwH8Ot16&FBrT z2_B)>Go~52Of{`0sD<1c)JoA(C#c0#N+5&ONNyoFk?>FjTE7DSa$+3;5EM>~QhMhQ z@1nM#&rP9n5N21J@1`#c`UBQ&vdJw4_Hl>Eb?u1laGRB;w0-P8#T0Lp7$iE<8gE^y z?bB*C$x9{A`scHs-kZnF*F1i8I_TjC4-+TjA8m{+N#{pBcVD{~dVlBZxv#>$JazZG zfp5O}{otuDem3xv{#U>F*Sn7-mmb}Emc2Z)nyRf?9bK1gQ%re>DEk#>GqQ!~pvjpg zMiJa~t&~xUo{}A84rs+o0xkt=f=rYON-=SR&>YxL(gdo=a3*xK95UCAfL)jv?b~i7vnD^ z_$R$_DnH@v_`|rf!o~O@s1|lg7o~ zm{}}IsEkoflaVNaMzWSV7EGXuDMC^vAwQ5GI2ho-<8hZT80-f)1wK8XA7AVDIzo%k zAQRD3C>iR8=gyJH#dP!B@}mNK^?s#E1ZPtQ-OIPttP`%2)+(2;FZQUL7k5;Z%ePi2 z%a_$0b^knO=CeoMan88+L7gn<-dlsu?kCG$oxJ`8spwG7DOHMv*-PL5b>z|Q|NLb8 zgTq_j{^Q?zzUX`P%M)WIbG2&WX6}K&Qn+qf$b43^P^s@VNnF(^1&I<$34=a>w|QC) zT)LuFm8Nl{`moQxm@cIY=&h7A_?e`L(%r%50)^%&Z@vD|;B^JGg~Y`iCOBXCII*11 z50wN7{0sap`xlby@1JZ> zNI7{kraks*TzlMmv6Flsw>>;A>W1JqaigbviH0Qq6nDaJ;u-~cF>w)9+~%-^u-UNL za9#vHtdA{aw}&OM)vOAdn3PP=1f&rXiI)Nygk(~75Q#z{rw57xXnxmW8j7)Av4X0cVp`CZm>$0!^qZ(A-&v2;LqPN*-$F7_-GFZ^9o07afpDLGFtG17jhV_!h1-g4 zrJ-bVLfg91rz%#;o((HLnQxI#$Pb=OC~H;MpN~DU%)T@IlSgMB-G3A@@#fesCw}$t z-WX@RZtRw9=DXj2&v|h9ThG@^l80aY@$N5&emC%!FaG`R_T6`fzUcRS{@2?F{r~Uo zXT#qO{bBfjMjlCi^>Aq>`)P}+a7D2`y=%4(I6IHd9$j!WxCn?=R54nHO7qPj_?Ri| z7EX<4;U>^tG!ngq%*GU>&!ZgJ4*yJ25519Hz$Hgj#x}&oo$@}Q{Tqt$CV4m69!Iw zoajr;JDqYm8tUfbm(Hb9Q$Kx?c*b|;=8NCHbSV0x=t7GAwEl(V7xbs=Pc_7i@bhA* zvAh%b*j)Z>_$0fE+Y}*=NaAL&^|Um~6s0nl9XuP14>kw!gQZj{RR~k1CP0lh5jKGT zwfJY_NLZR*C9pvn@v^_x?-EjjypCM<;gChhWw*rjz|D8X9{KD(OS6e$a+_NA(spZi zMmKM*JJyTz#Ty!(cr9~fX1Q&Zu~xEnX_d9yzm%gGt^FMjA zHpR@|N680f0g{@>z%KECkg)gXQR0LFMa)& zZ-*cJbE0-u{Vab~z1g%Yf>Sx`c?~m#SK>4{t=|WjeB2ZuNw^1bLToeUI@EfBe>3h9 zveMh-lX$OsZ+N&Kz0>5LLuUshk{bX+r?CXQ2Qlq&y7*le@gyM$}1^X zMby+6Ui{OUE2nkwvGKR#+_4SOm67;x6)!7FA9079#4QV(=G@`&BPEe>QL+4T{@cPU zCw1|oCw)RL@cvlg=!yJTpWv9OPx9NLcb$twai-nwuzs$NG zzdIlQ6!-MfQ<}U*J}j@5|8{ophoDEp*B^iK%(RlSP1?8F zE4?KsEjq_vNU-`F{W7uDs6O-*+*cFmG^qAhT(O=qZwjD+s}7$9dzf#Xv{YJ?ENtsl zYl>Tq?I0>B*O&!tDOVYN7)MRGny?tZC?v)wC884PiMsf9mFZ72BYXkI+t0&XknIf+apI~cqfcP(+N@BYTO;6GS(Dhh^&Yh;W4Ak zks?r2s<_nfNv| zjnQSyHTwdf@(#15eDxakI#P9 z(P^o&l-kA}w_Q|kHMWtMLd#-}M;Zn6cz@Bkw6m|ipZSM#(b@jlKl|yEf7yKHk7<7t zT}<9T-H?=bN^uf6JX~YwTLNB`jw@%97-hg(%9%-kdDB?ta5;PzcS2dxw~4<=@xA2y zkvr8bdT_cizU$;Gao7{&n9`^gZg-eD93Pn%Wds$$9ExT2hB6r`G)+(mVG4f+R|Ps$ zpI;NU4MX!+2Brfav63?hlmJ&iGfepmzbVum&!k7>o%9rV=pK=y-I3*NcFB(#kII2X z=Ub~R!hOC`yLCw?S*=@ZT0?3RwfQ={u2L7T&)1KxOIC-~g$p!Q|3dn6(z6{!j{F@t zY5w3zpL~9j&}YLhkL-Ji>Tq>-u+}KYVgUO_doxSJDY=_fAy~H`s1QSgMPrq$Ee6C->jf8j~;#l+Zn(5v`z?zzyS&^(ruSra0p8L8DIN|2aR zN8yq?h<*NJI3%$ssFt3@=0&7L>G-e2T}yPH{xPyaj#hk;E=}?f&0}EnhB;y#J-;?!mp+zaD;YZ!UWk zyZ?rdMr;ldg_$E`1&so8B&ew&*?}_wM*m{JIZ!>No*{?Xfps^#M%|@OIm|xZ;jEQs zYqk#9oHnK{_vlR&(m#!&3|) zcVG`uO-Lm-2OH^hrX+MWRL*P<%j1Z`#zG~~C7Vd)WHG5SPzcOr5GN*}$Xyf?B|m7w zzY2Mk}UyUJMFW;yG*3zL-E|)B* z7c$gmpl9`|Y3knR994@-_-szzKZBY0C*ZGB-+un}`(Hg8*}nT|#PVg-{kgAx7+(A0 z@!gN^KK|lq|4QHcpZ&P^sPB(=|Mjb%e(!orS;m`Qapm~O2ZI*EFo%wXHqvQSEs=)i zdmB9i&U`EBFzXO?AUL2}v9>gLKioaRR@WwWGha{J=-5cyDl>Ll37!_e%D|#v0@D=H zDYPZ}z0fWic=79$!<3j5HA ziiHFPAs={fdf*tGUos&LUxPCN!tBR#&>~kBpa82S-<%1kxOS&&w_vYjuff>4!#5=D zWbSHrvJHtFn$_#enQJUS0>#?aHLcpbIH%50k1cj6UspsuUYPyM^upw?K$HB(y+7Q0 z|I52yo*p?l9P`Ed|6KUOKH&Lc?TZh;`0Jf7@7QmLeEI@t0D*V@KAJdr{#n|3^*-N~ z=!c@VFePC^4m&hG*c@yEEvO2V?k!v~IuY%__JM-UL0b`oqgBT-&o4Y|-FF!qj4cOz zYqR5~`z_>kTw72sdy@ag$seB{c;OdkVqa=ceV+QiFWxzWN*sw_KDC;3Eg1##Bsys| z;bP*~3A1sNf~(P45z4TkFf}We?G7zuRWWBno5K^L&kC<5{3&@h<;F{2{rHDhuBLzb zvRNDIB6;RAlD2IYao%HkAod7Lg*dPobk zkWx%W5*z)8{Y=0JdV%q)QDRgvA`#J#D#9e9TfisbJDQw4ThrmJiMylJr);tf8Cw}! zV|ux^a1FaYym4#&)*3BzM1j zHvH<{-}nE}|5d+z=v9~`FVAE@7i={hzUQ6rbC6^d2aO$)LQ4r*#obOv3vzS59%AaP{*B$#3U-asokg^|I%8MPXn z3og@mv`v_D5*6PaH+teh%s|Y!xR2u-5=03Namr{FkIUn7M_H{5Jq<+`lIkd9(C-bQ z4WW%}MMOoEk8k8R@|z-L5wnp5K0kUKbTe>G_!k8?1OqY0D7q}%93B^e56|N$LyPI@ z!J^<%=sT%^s%U_p%uoYUgbq->68$R(6aEGM3_xBbSQ5TB;CcWBs)PWjzAc~zcsvhV zhidQ&5Gg08e zfv{I*mL3&&5)sSboEcm`j~S^1)_Vqb3tND8!3-?Iyy6{lT-#$C>NX{Myuq;@Z!Fls zo2qT4?mmPAML=s2wP+WPL{J8%Qf0IznwW)+&_|6&Uja9&6tpaAw2zPC8+lT07vKp> z*d&|4ZV2sX#E0aEGFfh@)o#$kM%Y459!JR0u^ZX#p`{#bL~kU4mmX;j&kxsw7G?-9 z;C6zRB4bcsQs~$!_`2B;bBF{yhO%G>rIFl3$PUbdc~K0yK8a8nzyl5J7zvvL25|-8 z#$^(YS>zT*iK)Sf@z+8BPR3556xa^G8e9=38O8M|T~~oQGM!Fah9%dcur)YtI%Q6* zQ+!xtrkLAJLq@!@a64_&W#~0@ZBOjX8V$zL-Ra$Q@bI_eZ+#c@U|#m@yYtfDejR%6FL(bu9CkNo_*eHH zejD}ZvO=J3+AXu*IlADz;%54!|95?J{9L%~0DQ23S;#SmcSYRfWU&;CvJfk|l@O0> zLt=f|NDlfB%JFdvUW#t0}Q({`ZCH)9=Me1-XJZVq*CfQTixRWRf5n_$M{te@|Ua zV8`)JcE@pr*TBV6hSR}mG)MaQ^)dL^)o6ZntRN0t7Zoo*yo*%{c)BQ56e?vZ80L^N zP(U--UC-JsHTdidTB z1P7t_-3J$g;9luLzSiauwtLK*SMnq1+n}f->f>&n97XxyZYTWBLU`(NYB^j zZuM{SHWlkRT7rhZdVYnma(%gJX;{^woLVqH-%+ZRpUl@P&OE6Do#q|+#O$*lTIUvK zJEjvSzZ(mbKK%CCci+kWD!V5=bN^qzeE8*w(ceiwMkV-pLWYhm z3O0g9n?o!iHB#9j(+mQe#JR{V=Z{BE@(Xy%a25C^1a4OZ9awx4&&bP)k;F1j%*ILu zHXbjk5xUq}cwOT$BTA#1qbCJ-1nseLF;}C{3VHZlt~Vn?7QGyb|zSr)-s3KNwS&E#d|J;Z>QT>X&f>#w-uWj;Qobx zd7AYGU7OyecdSc*%cN@iHu^VvwkkGx>xqEPQZ=_$3N$sVEt*?v)$7&kecA%e#u9%q zN3Bw~uH0HNtz2Ime13kRR@wh->RHLNVnzS_*i%q3<-(bX>CCBn6Y)Q6%phmK|6yt7 z;4w$O_4MA-WsOl&4C=z12CtJC`A}0lUm~da9f&$?ApuL_(og`CVUsC3MvK5P%w%@#I$m1AGv}c2akmYzP%(M8JmN+kE{f} z7?cPYQSnF&O5%On^R~wTjHTR8x35~Mj#2kn&wv|sRA%kl6Pbz+>JP`Q zX*Q9y@u2fy$ZR&H8+qH^dabr&BW+V;$kvp1!WgQM^0%^343q)SOl>n|I6?%P}(%Gme=*|DXj77Xgad%=f>X z2zs^pbTHq&(-8?BpFz$!KR4sG}9W$o0ORNJE!Xq%TW zEz6clmhOQT0FM5%Vb%Rbf{OLDdM;r4#1!r^M^X2T^^`HcFuV0=YN}&)5U@fEXapbp z@WG=$|8QI>e}0_(g!SZg`3FzldJ_NSmvhB1tzMpcU2*T(%yacZhpKvo^AFgx z3y@epL(MAS0^bkUfhWQbV@6E!MgYSaz&B2US}BZ@M3(c;g)2jatl6;faA$;+R~Bw$ zr*TZ+uVFboY!$OT)XC`$Z;H4G^Cby1V;Sg0selW(Tp6GYUX+U039cJ2;$}o?L?bwB zg&av(KJX?i$H&&Ql%WY>0Y9ZqAQ_QG$k6 zjn9D|H-;3Tn=zoFqh3Sm-A&F)*Bfq|XULo4PIoY!7aVds)|m>LjodPD$lLGRWA5he z#G6v#?520I+cmn&nweFvW*F2PP4OEYb&8+{jIjT!sWvUQ8$d2EP-gP<@OuiX1h`O^ECQ{J@Jy<)#2X zDd$SLdd@Yd(ez*yRS76112{?^shr3lWd{}zRiOCHp%qv$HW@2MRUz`+J)Tc}d}Iqk z;_W;VIZ8o;G1>>5DEnD^oU_Zxb1vIb4pn=72Yj>su-u|QJgytXz6RW}bW{CqKFsE- zotCZ4&AN4p-nymT8q}ApC$Hr%D_3h)(^scf?yYdt@yoom3{a+k583M@3T$S2&t`E6U~hr8@8nlGlp0QmtHT)O4@P)(F~oonWJB zy<gk=W!<6}*oLYt=~nI@;ozJ(_kg*dvYUHgIy77L;4`ONd+bz~ z*>%yA;GK4-daTgd>rk1f>qr(d(XR?}J>`&8$?r%H=J2Lp!&zUC=dSeiv{TW|R#oqkw;wC*>J{!M8_pc|I;4`jHbn;@+^zux?1GHE{^s$}mbO z7jR*ENOy=kq>)}gm6N9^cu0;^23x5)Vj{6O;MfI=$B6>upu-4=F3@gv(CL_A zbQ`c621*L4Qjs?m9E@WGeds+4u2LZ+^5~XH`;a5mPPg=$W{s65(|*Ch#GYWML0`C8 zWK{2(jlG7(4V9*HwLm+pRjeU3)k{07_~o(XrWM{ws`~tba6t-lCPzJ?_NuBE<`+Iv zWkSuU)Erga0%dV#QKbS%^!$@&_aL1oP#37hOScw^mG_@#sNR8B+hT!=pi-%eHEpZs z*N$}{@ro9(VBeN$J87q2t9mShen zj4Sp}#y4SUXoJ`3CO{@jfSv%%ruTgWxugr;iyj>yhKrt?zD|$vD8Z3#Wm|7LE1XJ4 zyS3$@b=SH#0vu=ZK(&Y69@DpM`L_DE#oLvJo{e<4yHhu0>x~_y>*?iF`=D- zi8G<;)(vl<;PZ2uTD4=P0yG6Bpzr>b^wnDJj83*LThG^JubS2-v|i|CB5>y9+PY1= z0ky^0yrjpj*KNi_s?1=h-!9x%?KuyJtc~^-yAnK#bB;<^sjmoWLd_w@kR)scUKCIh zSQFSBSV8HbvcbJ=g+x{b10U89-W#43F%*80JIOuEm-A<%uR@lO9EFcX#UCc15|>ZK z#>Wb8o}7&T@u^+NN>US+Pw7v!Cmbe3CrCimah^gYz8(KO?rN+gb|{V*f92%uxC^nj zqw`{J#^@m%y9lY*ve>Q@uf)C)bCG{8ayC4bN9PShEJrkiH*xAC;-Yv_QeIjFC9ITH zz;0y?g&yZ;9FW)HgU+l8F+X)$)ap!__Na{lI8m0a zX0=KUC_djHx1{Xk+Wf<8RDpK6bDr}@a><6Lm)ol36|bsjqg_h==C>px1+ z1lE!zL5b9|5O=5=aBj>0CBC}DD#MRcn>)$Q(2n#15(=%ZaW)FIh?+ zAiv6$y}qX z_G$Vw=U3*`iE#B=G;QEx-dm_!II}pvbbXnza(>0N*r85eu2tV#%25|BvKA@JZOg@K z&0-q#L;UjKDsRoS!e16I^)GPL6)UM4*-Fz&j=ESyS#~Y=Xp&ciE2O2mg?md#wN^a` zOsZ)~ph;V+S*-)t^#0;&iyJGgFr%)o)@n#A8*BZW)~&Qn+J<_~sI4(b!O!S97&AGx z^LC8~^|otAxt?ntlxNh(LFAw*xGMiz|AxT)pmM5_YNd_^;e%wfgis-P1UESq93Eid z22lFrqDFW(_|BMw*vi`xaib@%#u;JCCUA<@4aV*{-esuc;koSH!vQ)HBGsHQYhd_byyZ$Js8_d6gtu0mI#Ytd&g z@1hD(1jKt@K9YcT0Ls4U!@HWCuejWM_CY#$a;B

zPp}kNl-40D)!Jhb9!?&RY+1H;=u6Yb7!Z(Eq0I=NuQYWV`KHc;v_lGbL)M+v?HXg! zUc6~yuYPxAYXsPq%a{p@QG>C@&;!nQn=x%W+t94f*%&i4?&R&3f@8thY+yH$};Lv(eG936Ovm zMWdjXHGn$Z8%d3v=4e6(Sokm}Czopu8(|KGUJa9k;lr9(X$&4MlaT;P_k4J%=n6(n zXme;1lM-Tuyk`bIK7=1)q74Rn$rDgGrDnBq zHGg$*6~8J3e8O1ctvS{%gI_MtB*G*E?+?CDgI=PiZS`%@b`-mL&R09ddoLdTW^_q+q$&>e0FLjelu25%b=Nx)HB1+A0L50TT$LTbS! zYzWqbh(V7~(|9m(3PHc931NdyI02kU5>f-GPfyc=I$@+1(0Sm!=7%sLU64jG1kDDC zf~26)8HxR%)m4!419_kT7h)%1QcYp6L&jO@lDPQZPa)|{K~DRmM|bQgHoTQ&%eALF z>Y;iF&@GSMhJ16+f!HQ<$^pG!vnN>DX1DpQz4Is*^j4=^Y->4a+Rr#hFgIH-IPj2A zz`94Bla{pou3h;~=fOB&vN+f-!0tU4>I=~57dA$2J2 zYj3xvT2c?p=0aPFQ+335jXLXXHaq>O-Q_#Fcy!Tz{4N^d^My{aO@CNr7dw^UVduM; zP|v;A1jyTzS#vFYmUPhKm5u^?qeXBaKD1i!kZ(7+bw>@ZaaYfg@8}J;*!?=5mT~*2ZIZ!CR!@E&6S}fh7x?8G+F^u1a+febi&nE zGvv(fkZIbnYv=&~Kt|~e(n6+L1Il_W@i@c5!d*sdFtvVF;9Jdr-cx{1!@h|%W99(~ zH3Hg>M^2zIkR*5w^8qp$#RuPqfY^a)#fQYyq4zdm@oBf&Aq5{O%iRcUsoR=vPB< zM`=88n|%k>pa``djvRu5P_qKfKWb zZgP>%sx_@E)M*>QnhJma1jT(&Y8vYOU;Y-7T$dp z^>pf4hw|NL>iIr7>oI07V*dV9#xs>->@f>+*AuXf)-nC+^!Hib`-im2 z+Sw1D&Myd50_D{FoT5Oj(n<{Rrq=!3gDmI?GH1K%flq^s$Mq14$rX_CZ>09oRiMAs zgr=}ejONhpumPA+a_+GfkP=~nl)(Tf3?|T}FGAM20zM|O8rep675iLh0n-Y&yAoX7 z5?Xe!id;fa6Dmj~${08V!^9cjK3sAwwJg{b42dB?EV<0?&~(W1^w3SfcBPQEnr7vP zrZOpjOw3`8zzCWc#dJ{FnR-ShBR|AJu|hYLld+^0;+%hmpVyBM`nCga^s7P-_`Ha0 zq!!VFcn87sQN5QDZE%_@gcNbjqj1l9ZE(`%p0l1=m)rX~V)*~M-Ik(wt7FhBQ>7ZRu;LoxS z3HEkJi=A(k9wb>t?7fajdzUTA!nZCvOWhUj2X2w8-GO(R+!F7&m*&UIMVJz}bE~n#2n^~n zI@OPapYu-z?JJR3LTG}$7?pnoUK4;N$w;n16Df~W6etQZfNL0aVgCHXu zU$wTtI`9vYNj*d^?B?YV2Lp469WY@E2}V#{6%-Y@3Q|y)0wqED!Rf(r=yXkRvdR!H zoW&TpA>~vqoLVEZh1mn>A%ot@)G?2zSwDkB!_rC_bY?k|AF5`Q(^D9;P`lS44PQsW z18y&*$!N8(pTZ6*p?MhW^=Y<{frHcCyG+u`k?j*-HgaxX5VS%Q)x; zRy++8cgR!^|8B~D!CtSibQim?g8rIzIAk8(ms*-^1khhHEWU&CgLcrC+8tQXXA10c zyAtl125*w*CV0=+jznG-FzR$j1q>nwk$8;OPl(S1&XbJI4)78yNVT9fv;|m+QzR6* z09+~>v4W667Emd{%Ha55d{95>QV}Vn!17pNeFmu1OqzgN097fc)&v*QMyQ4$ z3e2=T@VZ*)-9e4ye#!(@MsWpUNi_jgz~CzbTZpN|YJ4*;)gKADo+`iVnAebns2WI_ zzJ$JvPDZ(q67V^;U^8nTQVd?$TN?ID!wS$&-v>xO@(gkqLH6ajIfx?U6nt$CBF%l( zrGvEg8*c8A&0b}(*$GGTqf+M`I~OqHs~ve>fMy2~~u?OfZ_?HTu6(3=|! zrMo)7l@v(ii-9W_>{J3`xukE?C9eqq&oDMgn_ZjI4fQ5(*I+6$lMboY5sTX~;yK2? z%RZ*>4$KWHdJE(AQ(&d&I;=9FpAZi?q$aR3AU{wIth5+ZzxcosLLI4(TtH<|QmIC` z>PG7EwxAh0pqSD~CqSmO7@UmCARp}U)I(hmK;f>Sm|+XW3`s67Gm{~tRnehMVaOE^C6I%rRsu&r^6kVoi))Tsk+^=pMRJq_J~XvU}jSvF%P&?&xS z4p)e}j~qkJdMiOmBp|MU>T&jn04J5=Ogg&aEOYpPrB1?j&H(hc8-P5a%N?ac+UuNI zVlFdt4-BT+h z?Min>w&D$XNa|PWmAZQ?QZ+|YxW)y=u|V^VnxN*diq{ggnQJqvX)8#rV%-WG)f8Qo zMyjsWq-roLnJbr;`HQt`m8M18zg7Ubvg)OG)cq?tnn6&AUw(#pc7CBiJq5Uw2Y8gX zRts#sAM})TK)1e~V`sV$-dl}+WJ|qG+NSN)7{%KeTa`w@(EH8iF>?{zU1|G8rUtk- zhm8HZjR$3BrP*}ohM&0NaMh{;^*aF)$vS(Jy9x6BPPlV)p8H6o-v%}nGR$?*--`XA zcL#Qnq~r-=Kj9env;{T5b`X|S1)6aNJp*z@g)}L>f{`D}W7Qw;MAFp^KFsG%NMXvU z-N6*lg$rq;G%mG;s-ue;urtXjhuu^eWG_tM<#Xw+)irma#CG4-QC_Ym%)|d ztMv9j)hk{3j#mH?R)G74brwOt72BltYxX9{*q9D`t$;V3Sx%uWtI zGRgLHEpBUyO$Qrlu)FNc^;99wV5+e#=<~2aPk@wCm7f*81N{3Xv=Z|sHVv9%sPH!i5|a z_X>Fa1F#8_;PP4`!rN$34 z!@glA0}oTQkC`6Y$@DP=Y!d4lQ%r;G2-LYNkV~Y%-lPP2?jSgtDnC9h(Jup(*E!hu zKHiME2RpAVh!#kdHDjt#TEsc87jVT4vKCU>39e???<+q#=NN!wncKwN?>T6JX_N%} zmJ*|I-(W7Vq#X7g==LP0cC!&?XMx>d{dAQ+>M?G?lkwcBl^+Ek=r`} z>7O>3ZncO$coKq%V1iBkX55=t7EX*sqBY1XKE5l>&4(Ph3hHIlR(~kAr8#5as&_lY zwko^G#rDEa;Csb;+g0gux{nnkvimH&|13Z^Z@4R6SZ9^RU@d^_cl_$KU$su!&$+VP z18z4|Q5LYYbB-a0!EwdOceKMy&GQw2CS!8;I=H~sQrvN%%uL#K771uFH^A#2|G#8@ zzO~$fhpULPpezG$WeP!=A=u643`mQ+;d);MZ%DYWhWfqf%yXu~`{hHDKNo5jW}%nu z#UVQoAAslA;H3i=JL|asX}MXrqprG?-s2n)4W`AYF9$sbict%wNHSCo@I5Vf1D6qc zca~3#GGi{`dipqmO~0+2;R|c7wCWZO97Vx7z7gjP57xzSP3A649MFzfDV}Dl!DrnZp9z#0g$1L zK+({R{yDHCZX_xL+Au8S1E0YqbH>?*%w4-Jko?vGgEpA64i$%b=LN`(Wg}~lalUij zcM$cyR1eibu;d%lA-gxaowD5p6F6-%X{QVlG1n|*R+S~ga>uT+OB{4N+v+wo8x02W zrc&3sM$lYeR4&(QHG1r}8vH}?q1XvJhC9hq?>0Cpz=^&AzNf)qHuvnoQ$Y5ncL_U5 zJNb}!E`(m!VwtuTf^OD%bgau5ylFn)k-%OCRpkbhBL!Xa7_+C@QXr*&ta+Fn^}g3I z9e%}d{+XzI;Jdcr?qQ3;b-3fn@;&gW;Jh!v{?!g59V5dw;|MTan+Rh;a!`YffXAhv zS?~d0gMNX>V?F|vwGOL-`zh@>DHLE1hzIouA7~D22tIbWfL+0p5Gn%|1PM%JI1RWz zt$z6d!^C543H&Tj=<|W~$w)@fLqxDybSbckIEXt|%$i_7q7RLM{nQD+4%oD>BDTW3 zH^G#b6SIL67D9Hk5PJ(yUn}fYrXzO%-*&);1}9A-#@gAaY|GSn#z>iziX1zdkhKrL)+wE@=egRD@2XW1#RH(Pyn zr*rbC($x*MZ32glgYJRE-SP8FwgOrSnFMeYuvU@zL|Y4l_PqwjXn!AouOv5-}WM6}AkgE&Ey5|6zhQJ@y~ z;|l!w@GKNDHVtcny`N$9d)^W7@z2Bkx`TcVQ-bP;eM<)XtVp;n`2mfvi_IXU!*guz zVef&@kweTRH2Rycc)-YKaD(9M=KNn`+l+Bx(y*17w~&d5OmIN@;hF;)!GIt~=m+2P zINR!iZFD0%>qH3rjO%|3p66l?NQC6xEmR3=1MU+6A_cbDP#!O03x2{+eGM=TZX6js zEr{dZGKHeSF1Q3Z;BhxygxbCU8HB4Y<53r=dn5KzXNnWD7PflGRE!+>EHYcOqY!xU zZCAao2APTCz#M{<0wmAG;K;n^Ert1a6_TrRJMT~j`Y!=A-gHyn-f=d@w+Fj{kR3<` z<*?g|hi-T6i0bMDj#lZIhR^bxrM5A%3ebGTz8tVZv$@yA-J66N%Yzx>gq%mEX&SU; zgL%kYzLx>klDzW5K3>ZrxrngyGeyEL8T*QkS)gJtWbOB z;m(lyYXU|Ix4;20<1YK>KtkN=w}rWnEcyQ&y$?{+d7iFI0)apvkPsjci9};#W8=WU z$TT}l)0bXcD7kW*9d_xW7cZ8URI|+)cJW|gNy(L3&N9m!ym<7;ZBA3eEWNmTVP)kG zGxXr#;J`>ri$4M+|UKz8|!Al;iS(4aWl0tN#<^e^+v#{IWQ!b!mX z=VqsN;+vW0y`10#)sHe7?*8R|m8XgK<}^3xo9vXQN&a8(7X+gJ2!93x;FEo;cgE}X z9QSbkBEi)OouQL+LKXL}Ys{+kJZFs%0y;dE{9C99uCts19=X~%uee>~?@mRJT$wmn zI`?N?6RytvDQ}x#85dlJ>z;cC28qPa_u6KW8NJW<+A8!tZ^=Q05?Ppn3{Y)Ixl|X? z9ia{{kxaqOnC9t7!D%`rN(pQUP9!goh0df1tq56p@3Xbc{SF->&tqQmKj>dK<|4@B`_F%^Wue$qS$3ZVH4WTMZgrmDUeNMc$+4}QNzMZBa$N)QR;oOh2n6Zg7ySmr6qVdq&j#I zy(2)xxy75sXJA>N6U=hjF?&AeozO5*YT=9|cAe#{aFP?A*w$fd;I@*!AzAM=Pc1cA zBXpdtghC?x?|;batm_JKyK5;#1^wAKj1Q9&Yv+{hkT+ z+<{sGTP4$IeuH5$RGdV4D=I`@{oT-;m=WxDhrg2s(R*!a~sR}EHI5HU;7nTf}=e7jJnVgRyNEa~V-}cTMmlAA1lOTb?4wNi7C-~U&G1>56 zp!KQu&qMhb-YT_c+iSK`H*Z*V+$WT4jii+tQQI2KzS;aV6Z`h1oW%_8>&E45^G!UM zgY(U%22-i&HZJ9>vrTVhvxE4Ps%CJ>Jnw#Pcs@FHVah%={48hcce=cnWjfn)*>mHh z?E44LzWhF9GU4f~@qkg|<1b+VoF0pR`~#8zfA~YtAInGoaqRG)1D-s1*8TeQT-jpD z%7`U()4O|SzXxw+jVL*2G_*_R<^Dd1TOm8LAgY2_G&5=>QV~_er@V`~d?(XIU;5{c zx`iM42QI7g51l{Md?+2hi(Z{^s6yKeOC~ch|43?Lf6|fT6UUGJAmjMM4^(8ACyoan zpG-b-EdQ7#IsJpr$hwZd{||}3hOX3b>|@fh_ukJxzLs+6hqqEpKTsW;PntO9KIToj z_w|HJd!E zFkF~k)n~oQdp-PW_(j08v?m|^`N7k>PjBLoO?uk;b`#_0Gw+n6z!A-Uu#Ea8^$#_%lZ5JbCa(Oq$t zL>%VxDj7nu!=}*gnwckwq<5tzSs@(0Ml=*NdGJZO0}W*;ygGDPS_ku>N8T=*5k+8f$|8RU&I3ODT&;SG1zrO)1(U2P2H~0#-o{Q}>(rDRbFv z>fq_=0!-c>zuW9n0(PmhJ}V*%*%~~n zZL^DSdtX&Up-{rf=dL`&u8bSCE4J^82qRbDv3{DJE|}sT=?Aqpm;we(c%U zAO7?qdH%GK4#TDVj1FIwJ zS)m+{X^qQ@6X7e9pr1u4bC_2M`O%89R;IKtQJhx}p{z!!=E)Uwk!ih* O9d#22 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ߪNeJ;OlR|% z1l%OTe?DmT{3A`yNt4ItZj_m6K744?hx4l6w!}^5r%m?h6wUAJUlyFtaXO?L_)Wk4}b81>Ti!GC^>xnr|iPpYotzG;Rimjk7 zyS(JC7L2;+rf83uX&N3eYr@KnG)hE?E}ixkyOT0#%)X?TpU~UWLm?cr^%Z7^MmaSp z^36k++3&D^A7O)Il4{as^{>4Net{Nx$0EChlr6J(A+#&bqOP#T1{pJc&JT z&eLOWA0Hn-tMhXlcU1D4Hy0K*jEM?;33py~+fAeMn|}elVFe{g=u&8VbL(ZB=Hgxs z_C4M%kBg!K4xrua^>*2pJEg|vzDar`%Zy)rmF?gg-}CynUC^(5-mc$B4oRVU>GGMj z$&|h#FS?fY?2Xx^eKM0H;tu`B9doe6f;YFMXa3srS3R|r_PE>TG$k{=mAk*umMj^6 zX26!zWn)i*Sy|1Sa5H|$SM~n&9DG`KyUeQKmAoY#%>L}Ggt~iwy_y)3%;PiW z_5(8C3%IQYOp7C-p@E0;8a9Hf`lnS;ytsy^kf=9BC)X;4svmwCwC00)5mv*R>k{{Q z9uJxzV{X_r3w|w`u@SEu_w){6wo>7mzx6i>(Af;rSrx2m^`a6;=U!M^=&%1sTKPGu z%_eh8@EUyV)qWJhH*LntwFB&jY#zf}|MA`D6xwM%dNqV03cZl^#bv(76kBG>+bS@z;{bLf%B!S+~#rwbYk`u(T7>9HY`iLl$y7hof|K&)!|0 z#oywTkJCZx`h)=tCDrgfCnO(Un&6e0WkO1cj_N*$%OQErk7H4?XtTECc<4Kjds2zp1&btmpE*bt;l;Fukf5Z9lGhK0O+L-tlbXv&p}7 zjbx8L7+p7d_*vIT$J55qy60`r*Ya3Z|K-$Qrbjmam?5S2%*fHdTpqdktZ}sd+4*PZ zo@P8vd^R%LJKFT@;OM}}mLKPyl}a9{dmhlkj%V%hIhogwIkbA7N8*!X+kI`><2}?a zT9kY}J@nU`$418U%t|r_on=XI_yX))i|=A59JJJoRwK1GDrx1I8)Ra>v#*p^u3H$^8*o>%-fHb!=eY4s@!W*|l~HMQ9ro#i@aDjqjxHV0zFS4fl$)|C zWw*?L%gc3$^74124kqWV*pOD9Hkj6wc7VU|FdQpn&UVsmWu*4U*74%fZmpjAWE z6cW~x5upg%4fTGo{9>#jBRl;rJ;sUU%{rGVG@Uh3S&ghXkha?M`zUz_41Fm2KnmNJ zOY*fU*O&;(;C~$yimNNC}qRlpB z9lDXQZH7$wwwaJl@&_CFeJ8Y5No<)e$VD9s;-)(c*%ED((+A$RazTG@Lh61O+6iQM zPzII1y8)^GHgWH=qlt5TAEj>wX6G9pUl(}><#WK@yT%82p(@7{U-pg;#M^^AIiC1z z^N;;wGkmtSuaEs08yS5ebr(~9BHsUF*c*4$zY1zm81zyvcbz_o8$xdot)f`epv~!pbV9LLbrr^D$iT zZD6eQKfN1%BYR%s(nji#BT~SmbDC@SO;*F>L;CIccY~(hJ5zYEsgQWxVru_D&PHe# zE1d}~bm!lNE>*oIiBdFa-zuo5zQpKsYC66rDP?G$`5l1HL>E`v$M5NB`B3x6BRR7- zmvXQ!D7Qp%_H|$;6h#iFUS0T`+1R3iWr?Yk%P%DD^E1s`wl-@^ zEH|=!wszt5<%d(BME5RwHCw!-U`6@TGmH3fBSR_IsgeStRUK_gt6UcD=~zNY|Jabc zDbg}MHJ6imW`&Hmg>7$Ar_Ll?j9yNcKVrtlS<{wvWBJZeczqjqY&&&~GW5Zc@}9OZ$sjSLv+ZC_2Q%YBiWycFf4-Vh{`}#){kIJ7` zKTjFUr+zpX=Zk;dIC|Y}(=~GQNz;>>kt?I+`V`{Nui&Dp$5vso=3ewXKlilz$mXOk`1wfivZ)3E%;$?LY$5-C$7 z!@2~&wA=-eR9EGy1y#(YWCzt z-i*yO%L#N)v!>A5g|oDEpU7}q3n!?=7^WoF%J}S*e9XPu_lA=_T?{)ZXQ?ya10b z<{+M=eK?peLEQuzcOU+4ZOh1*e zC03kXK(pMO(Vf<^qA9tDHrjt%(W|MuYej9^o~)#-p{$Zs%~`E!6&cMK+KP;lw4Bt- znh85odScbFNw;8!-lD+j_NMp5GSY6RoJ(#>-L_)Duh@#K&V%{J6}ra5oVW>)lEKu- ziaw3t#mmQOzRIQ5^x%y1lOM?{ElLTkhEGzOk}pt*ZAh+yB-YR{ckvcA`TGp>Z`@f` z?C(NiR<|2>f<9!gQ>zkM|Be(bK-CC^L-_)s+uOET|-i%p2tfvStKyXqToEI$=M~%G>PWv7-v8=ebLOs zTJAQl8`*MQS{k;>CacA!1ier9%VTht(BgMQ-dXSj-7!@)a6O!e$K@`yV`I1tDRv9p zEOdz>>v=3>K}O_9+<7%50jffVS{|2C@puh)+j;KC51?v0Uk^;q;G7Glp(mIrYF!-5 zfVsN}b5$l+D@w@{?vg`LlTA{+_t}IdXlZY{^Z%OCHspgweLo~^c`Xh=K{Lz?e=P<7 z1L-p%Nh16Utp@()13K7O)FN%Xi8oB`Z+zTac&-A9lP;RW(y7+h!*6CNHzH=j7A*D+ zy5`W9R4605m8LVaeg=J6xtY0@8sx0}s*Nu;kF{ascKLI)Qz4I+ubOjjGZ)}>6K4Sq zJvcnf)X?5b(!IAj=QImrGVbx*q~LG*q2-5lChs?EwbK6#EGzYDs!Pd8J+b`yilU5x zmHk9qyMA%y7d`LSy}##|NAf=V)vmmv_lw?d%{!8J;#U>Fy!8H_HTU1o&#imE zHfM5m{;KsqyZ*B~Sq-ugTctzgLuA|JH?EbZeit@>p01|CS<~nzIAwnbZRL5RCrsYQ ze!TXy>PhXRjz@zJ;{W;b{jC3#KXCEy{^{Qh_Sb!X{BO_vyWjQx;cuS}R1I}K-13)# z;k=QJG_*x9yr9~P%syLKt-&ay`xd4)XJoFbUY)z9XU*o-XMcVzr~3Wu_pZFx`}3Wv z>Q@b|uFbAned6c&*>|#HKOcDSzOO1LH@@b|&zs-x%RT*m*Lx+|`+ioKeJv+3w?4Q3 z{o?mp-W&f#(l0;wW$`Zxb8Fs9c|Y$L$KEgX-#4$BT2u7?p&B|^H%j%SZ<=40?VB~8;i>=J_Fwjm>>I5f+eqDb>eUbLe$RE~ zY~Xq5l%|yxD@iR$ZCTMkIa-`vl+h5|LB&$*bc;e{>yvLs#zRXRpGJ*7)}Wb=(ZU%FOwrqF5Q*5eR_FU>9~HiS>S>NmdIJ~^k|CFnqEMjJ<3hGUQW|Nidd zQ~%}e{q`S*{_d-Rs{|Chh{-~Z-v}f_y6;M z{g2=N{9pae|M9>6%b&IXrTwS2|Nfu<{HuTU<$?=8YyP_Bzxx;e>6`!d|NJ*yKmF^B zI~VU={Er=vMxX9^J}ObC^<9b_h;8<^b)2wGsi!j9vhv<5`$c(P_OCw9ty{A(r`~fl zn0@_cv6W4$Mt)YedQaBA^zyWeS=DQH=5GGwXTONNw=4JKyq$Szzo=f*x~k6WHTV5} z?_K|S+v@8-kNjfmKf3bk^ZBoSHJA6#{;PlgkH6XQ&o}%}>o>0b)dvOZ-)#6FHvZzj z{%8OAy-hzBeDF`U7yYK_`u}V2~<~q&l zrJzT9@tG5hJUS5ylT?O{8OhLsQx}aVKH2T8i#-N$-Yz3ZBKGPlfq2oLg-nm3+u4ETEFw3BLu6H=&p(RvM!Dw|G9TS}1UAufw;XpX zG%Xe~#WR7%r>D^K=n~|XpQf3RG+yO33l?EGJ%uS}o#NzhYT0qz8$JTRWBxk41y~=Wl=~4m}PAn-08=~WQ9>ioj z6|EAK&}z&|^aRXQu+zScPB{!NSLm+5rAP%2taQ1+$T!v*66SiRv!=wOn?v%U6N66p ztT&%7E6#Pcb-a}GT4x^r*!+3(a5L|hLyg>@|5o>N?Wgs>tN-UZmmj@q_f-Gx{-GM5 zhUVsbt=FFV4DvLmXP67hWx;d93na?yfSBV zFW{vziag7`TEH33#b=%gM_v|LLA~iI@mvREt%M?_7cz?QJgGgqa5_qn0a%8;nmx<1 z%EdyqVjpJ37EOR`Your*V^fZN>l#`Clf;SU#&9Y)sk{$;-2;*XO@V!Zf`BnmU*L^^ zBYsLj1t*$iVdt`wSr$gJR}ziPNN1)p@GGe`@B?Z+aKgJ4VM}+@nU6X(%ry+v9C5~v)Zs_UsGcv{>)IAur=I-R!}Cq!^?$4TsBRUX@~$6ztLMJ^ zm%5LC(aVl^M)iE$cXJ?YB1KpZ1jsHh4weF@mc;$8bjrSxF2sJDS_cndaK7lL{uEqvVuw&akgC)SeTx0(^p- zo`u*o=79Ef6r0C#C@#}HGCYM|g^Xy{I-7%fhKaMe-aE}Z)yKlK_$2$k9T*b$Ax>FG zVV}PxAUL4Xugu59&1D?(%A&1P8Zb9dU_UbpcV9Go#%GDutcQL3Dfh_1OfC^lNj3O;I(j&>gk8>(f~uECFU1p~V>&X)j7nM_{2%L- z9CxjYdCxbP&(}ifCkLrRYr1XBTF6?sXt2$)CmTm|#|y`$nsX}Sv(KMCQLsDy(st;{ zpQS%NTyAN8^!nk~Ex&F4m&T8OdeX43akbIq7r#e8NIlv^g?$Cg~SBBfl}P`Mi(1=pDiBcZl_6hZ96z=NK3v#Qh`-N zK~k0mw8A>@nzBIpzXHxC9Knt)zh+hEBSQ!tbEu#qu~DPKiBEu5!>(ztr*0C#ShbTh zg>dzP5$uuz0WRbx_#kpZmfW~|UlNz~;eNxGTM&k-0 z0ecjrui1j(GpRs&s%Tkh+%)YhrkN?G9Ama2ckZGgb)K!eJ@H6=Po*C|rmo=53HF^TCYOx_JC&mx zT_Mty_Vgk%#GW{f`PCl#x0DTU8z0(TJMpd#34C$jLsVfyE&{#jrn7~(4f4ckQXE{u zSKt}WhCiSLYOagu%FjYCrib#k9~v$#@~)41qRD%o{v8i|BO?>d8Kwk}-4|^(a)xuk!u7!`B% zUM+muxi~x0z}BtU%Vbp38;P-`SjI6{7A=AV?l$G7S0PVIk8!V|9CuA;DmmB;xE3I- zTt#9sEDRc$_7aaY_%9pi)zq*(_Eg&Ky`MSe?LAGK#l+>oo`bt8_NKWBJqaYzt}a5( zUKN~NM0ixi(C?*Ureb%gPeNrRcW~d*It9bko)5L6YG-TvuG58$nmO45$MOlPW295A zR_VydIh3I8pqTYDSEd?Xp4;`|Ce2tkQi&P!mZfZ=TAyqn87rYqkZkpxOw~ckE zLJ3P(=9A4FQ{hw`%G|>F$~oI2Nl%!`TFNozu85Wudct$&O708f9ayI~!`Dhya#!wy z0Z_mG_I8{t5!_D!o~T=!L^!5&Ym2)~9ym_6;aY&dhD@+viwZ&vW?N-D*gX(Sh?jhi zu+KY|VWzt25$Ti3pWLCpyy>8Ey+Vm(P&~t5XRfW2r@hz7q&*ex0-S(aXU_I@%%C&r zGSYXO;V)mjs`qMPh11zC%GDzqb#a$17hSIMINThe)rmBFRCjN8h`&xOqTZP~Id*0KSfGE1#O26V?r7Td&MP@;kN92L z&W?*0C(E|F$ccOlt$K|zc7D9h;TUeYlTImm#&Fj{xn~Q!iE@{QxogmSbctWLOF*sf^qgVCSygHfFxkygU#83F%e(G84(McxS0L_wF6O zQEe!A8Rvq$;|(%NwnSJ^F;WTlUte?1*_FQc#p{$;_jcJ_>$!!DV>aP*{O&MU0Xda% z)Isn($C$q^V|0<(PRPUF%!N0kf3MVCf*H4LgTKbxMW8is%+%-G!5f0-_1oP679X_- zIQmOR-7K_dZZXx0sf1-q?6~b(>sLW71mB}}C)*{5U+!PYrtJpf(K($eXT{UCtS-v^ zmnM_Y^OLA{e?F76nzeGrHM3N3{}p2!IR^s`^%=kUM*qarZGL1^tY422u)k$l(uDL@x zvXA1n>2T2`na1weKg-XsVKr4~OJC(cO?icq^HQ^%qpzH!c-iY?E1^Z!pQc_G96C>J)g_w+O9j-cJC*ZE&t)@};|_btZ2GI@ZIf<9<_>C>`yE#wN7Vb@-mP4|*BzrjEY1$Rz6Y5G)9dryTbEyMvC4QY>*32h zrD*nY#Q#2U)QzJ1psh|C2NKAB~jp{J* z!+p`>ksx*8hyHh+iuG59W9Ny%8PSz~hhJ`YyR;v5e&cv;ke;BJ9UeOB9PLjH92430 zL@o&C3|rBGssIu_a%)`CJ5(O{x5EV@cy1;bQ$&|={_cZYfwQ}2XY*8rTkdbwN6Kh8 z>4x*oN&ELD(j^zId3|Log``Wqm!oXVKI;ROZ85`Z=&e0(6;ev3();>6ZiV-SwEGuX z*<&>ea5Z@|-}oO1@_olXlV>~s>+gR)6wCPb>5mVP$jo?s{KcF6<)0h$!+EZz;1|EO zF^}utm44s-Z;_uIB=h=rJ1Hp7N=Q2KnIILr-IhD9S#5Dv@qQl{&Z(UGzB6JjhVxS2n>ZjO`3g_=1 z`hT(A2P7UfT`y&EF2`bO-=K@5H;;GsPaN|fNw|M7ot`q=sIa`)8-DdbrJvfRWjarZ z?1;CfV>JAfSO4g$o4nxnJbpUlH0gonVD}Z`{9FJ2Zo7Y!!vl?FwASyP_kN1ar4i=) zJJ0QArTpWcVgyAFDHF1pB)_?LkNigHm0=Q2Ki{tO`}tjg9|$IfVvU$~Dl9*woZI4( zJ7XDT@{UD!MpgNWW;Z(&+EUSf{_bC++&4=4zv!%Udlp9*XLuoNpsUId!&C~g7;3Nb zX-N;oAmzLWYvSGBdscVuv1K76xIvV}CT`#D`1Zm2$~V7xJBGu$Iq=UF!V!Vb+p&CM z!|I!g|G95@p5>E>e!?=p_{+yXN|&kOgiy8f`O%+ikM>mWaYw&Vur??g{Yl@IMLaBt z^hvhP{B-~S-kBovxN)_9bt7MX74Wc;c+u-W-^_D=9M_uwbhfeW8#Lnx# zJoxwj(VC8Ww1?F0CrgNb^YKyq-i7su&W7H_^W$=j3&8Kz?xc=KK9!a^x!c3;k1h z7q)`r4$99Q^8eAbwNIoJKu;qI)6RQR$;s{6FaZk61r5ug$@<%EKkGP?&MT) z-`jO%c(%J_!9b~G^LCZ2RLrIgmAwN9=Oo%u};pRiRtYtz@W9>$bLx%)+K2&V&I z3;mNq4@X>PX0HpaMB5JYe-1VU?a)7Yt(i5i33%nfe-A8TMQ@*;Pqy{3l0v70BmK;; z88iG>Vjf*W2(6)HujeeLtYvT=BmVXq)DWvtlZK6U?~VN-(G#@vS;-ig#K&UzMv&Sq z1v^F2odjRK(XKlr0q&ZudUf)<~~;d+M=Q zj#*7widpS^#j#~A5MB^Ld7It4xSM7yU9|7JwczgvZ3?5>a?K@`LfAR8anX>q(6V`l zpmmY%y1bUV!d%MU&LsrCI_Z?WU1j^@&X$d^aAd2+`NEE1rC6_6Jp3|cceIDx;h631 z&3LH$a&~ubWi90{9N9Sh+PsW8JoJsZM2B4xvjrJr*lF#G+`z^NY=T0#50$Sn!JMvK z$$a_YcFHTQL*a`eYtxoJ=`!$j#WKfaSUP6y#((2jrme}=j7`n1V>axOEFn;Gc4ESB z*IagEYbSV){3B}_)R?3-1!m`2_%@21x;H9Us#iJCQg3WIuH~DnEO!0(~k%mG5BO@6aY7%t8%9ruj*l{c&o6Qahgsh!gkhOk?Zij<(sdTXLqM=CW->rP5 zSw+gl)<;M)E!;YV&zeCN!UbdB+HP&bZv(%u1Co=|klj}THEb7&L9TG^N5>KC80{iL zYF8`;JU&thQaoBbFS_*aE_S7OSy_?10!|WkyydKEZ=!E3Ll1Tv#ZAn}=2r5=yi!cz z3pjC@=4N&FiATqp?3It4;F~V_9!;2!&AQ8=kf3-b zF$9b}cMBAmBuoY3L8c+V>xAEdobMLr+(bfp;obuHd27hk)CMMIN@OH~l1^iHyy>C9 zGdDrdx}{P;eZq6m@R|hX#c4<6p2L-SV@CrOQj60!$b608Wlue*g)aLZCL~6OoIR%< z8E|EPh;Cm7^0Iwbt$VFSr5dtn_aRs|df|JXMtXMl#{Jh0Nb$6%A@-w}tli>czi3|D z*m$t(##RWXDQ_d8g5h}T^)z<*iEC+i9^&D+Eky#4Jqe&{-MYDhM5#FIO>@%HW%RWnk65uUGqyObc=W46S)lXOb*oS?)aUo>yFUn z^ul9e&#Ny-3c-hXQV$R{|79~4%JWEQ=8}<)3-|7d02=@@6d8RWvD|>Zs1WQTh^G>+?C>0czG>VRy$dubd_QjnwvmfpFrMh1G0Xi zL61;9H(4}`T2q|4WTnrNgB=BVsTjSc{mtLOrojNUpA2lM>g9@M+G5v&$zo6DDo4VB zU_}I#KM4ff3d{x!3-}q}L}fh}t!m9R3vnjeB5iTgoVrkD+B7G_eVntZT2d}nE^y2g zbCJ0Q`oTI=-eM+v%$;kQFQu!gP$N>zEnvDftQIfttXA2OZU|59;g?oR?22s}^jG*T zvccrpcu@ha?O~*jWF!020?th~G`RL0hnDpsq)%ADYDIQ0XmGbcsZE1}rW39n5>f!s z)j&ma7kQ~WaHbr`=J%o#ZO;kt;j|!`NuXDS&mBClb%F(c7z+|nuV8|1fmZGuSne`H zKNz2Z&`{Z+0k{QX!wdL?v!QvXV#|{T1sEOF9Z>Pt8&+|aKp9$y=cNpcF9A3}1SnAR zZ3!!dOI4s7U4?&CZOyO{K=-W1^95J;;-)EcS-et)t-abzSTHXZLf47B7^udRR=QUT zR+Ew7l(13{s)$`tnE@|n5!3@4U=#ikydUIG!*O;TDafE~AW!U?vkdBqFyxC|r6p43 zo=SQ;_GWa>8J-#^fWfCzknelduiHn;4;L`~1lVAA`-ub`?`hu+Ke|xpe_u!v2>e?8 z2!0g;jX=lm#@<>ZD8sg)o?E~?#v<@Sxb-XwX0A3Sog?I`Fe$HPBiDp(@{044cuKJg zse-rBor7tfYYlV`5X^lP`Z z6XQ9WlVc7Vn`UCvX_PXgQGGp_HXs?e_-wKJU}tZ~-Hxb^6Y{%lt4}=I`di0t*w0T81q7{g7u4~ym@+cp@^Qk{ON|sO zI|0+qKd3~0L_OZ~K5Ha4vl{Sy5|(NfFhwwy%o7dUdh@IqGwN-v)6})eS`L$TzAyT|?RnBw_w@6Q-j40g2c2Dt=BIT}KYIFcpYhpegOZ`f(FYp# zWVvqB(6rzP)ypyK1!$#>_SHHa!LWG9Crn zG%EBlNjyHMoMrObVkL9SxqOb0B|x@PIT!*WdI2=oCD=j=p(nFD-6Y5#u7e)F$%M^lSX7eVFPxMY&CofK(7>^q98+xGDj)+HZtDA@P{j1L|4nz#f z2XgzHd-^-?bT#)p>XSbc_uuYY##4QBKtI?uxIB1Wd0cgIL_c~?LmbP|TpMwisG2OF zu9!9HPRtyiEt{`c)LIIldo)Au8wk!B4bG1v;1|oCVnEXjbG-&#-yKpIROYv-xfC(^ zwtJChA7}|-)Hu-i8mQ6OALn~kgQ0Mnn(ax3id+W1E(0#U5~$(Uk&|;29>6S04TTSd zdKI)FVr;(D9-AI~{455AKq-e#u8W*RE~CUim8XJIPDXB`D5+NLN0U91JxEl$!o3od zhivH9`@E{L$JS73?qw7yr#qlLRJi!pbrW`Zy3W`rA!jb7qB zwsBBoWA7$+b8wB{LxNNDkH{Op2hJ+|n()wNZ+{7z)(g00oZ*xLGYopQVz7N=EAgvM zOUXzgR9mFW_WaKl!|4UfLbmCmfiWks>rHe`bBr1BRKj?|Xzs9l#AkGUGTr)ITzvqidcUF>|E zIR-hR@n8ZXH)-=bJU><>amG5zh!w6~WWKu@+*OX}MV!b5Obs*NtBH}qImRvKRI;Va zEmk2<$yf3#d6zj?*)r^B!uba17>c+r1boqD(J5iRw~!@f3voksiV~qKIV`U7En-bD zY#fyz$A1fRfDGSSpLTCL-^}mxowVN{(1e}w5&9PfTnLEuEA)}^as(d+`Ue|=vi)QD zsnAXod7Hd1^RMzQanJbB1@Zi3mVptEtpk%$@0ILX^Jc-bs z$i3FtF`RlY2Bii>l_GF`Ww;Gy_ZZ=^k09nilfLPs+PaFAgzI?zwP2WP-~!~M7HvRx za0$ERDv%&6K(I2e6{DWP3AJoBXD+9iQ;Z@b(IB2n(L<4^V^3FTYo;_4X_K2{+v?tt zyJP1^hX?xmHhY?Tmiq?>4-MSyIo^3k{%zZ4$NP%e_AYs7YyaafTEB0MtG^QH`OVMy{PD0tLe0l#B1zy}Pl5O8#_uZBZ?Qfc?h#(Jx4=j3&Gd=p%Gd^W zCnp3tjS04lp@5>Oo2`Qa$WE^;#J!9v6K7ZoGlbiMJNOdb0U5i1Q^C*nKI2mcm8yP5;X^{TrS^`;%`fFxxu^J`K069ey#(yu=`w zuQTZ9s&q{=g;TLpB~y2%9478*N`_AiIuE_44pYynP7Wygu6N(*ebjdkRngt2U$xzB zW5_)^Jlp>v|LTdyql1tC)Y{w@A%Ca+Y}__7U-JSIH3cB*{kmULJAZUOw3DtM(N$Ym`A{VNxDb0SrM%=2|x z6}Z|8<~lo-r$TjD!!WT@*-W+$d5tvOvB{|1RY*oZ>rw7WVn%yaQo!`YmNSWRm6l3N zhr+$rCEHDcJoO4V_Y=r+_`B@xvC~ivrBgTYG|OmOuOvn+BLm7JEp$yJdLp%y9?Qr9 zZIsLq(E2>{y^2uNa&XRNBZJbBp6uy}`oYnCow7p>qeB1W`T*JIF>sIB`D$UVr@<;b zxC==@$d?ZX>GSW~aIJ#HL$S!w*O$YQm#{$7$7{Q$s%I1Cl6C7-{hI5lh~Y(b+DOT; zao}2y@o64(ugyKP-JdFsx4+l!BR|{rUh4_jSC3vjdMcfI^u@y;T7KK;*5KFZ)AYB- zuNpsUYHoI;Svvfrx6QBZcDtnKJ*A7PUu{K=MVZWtL=H(6ydgFEb|MN$hwglg6Y;=@M*N z`SeJz-U=xbsC8uc6?JY%t)*6>ZX$Y;DHa+DI*C5q7ujBEtZ6QtpW)r)L-vXGEfwxS zdtL8e=GWq17!(pL*vFUrJ@^xGf#k!Gy&dMa%$_a0|NTK7X_D0=i@p-^|JPp2m-Gyc-S7N_H{cxI%UzBMnTfJTTe` zcoJ$b^Xhe@x!TnwMh7NxTa%H1WkRmOG}3Njpl6pYF&DDtET~t7x~OUEq(&2_E*de8 z#Er;@t_=D;bLe}&|J!GSyaYknJu0zoJ%(4EU$xlT=-o4JY>Ca>-w%r5HFEK4*y1&yM~%huktd}3(&U{ zxqaLu-WlAM0-O+Ljt+{r9qcItyiUH2&*V+u)D}Ru*}__f%1VqI)1DQ)4&r?m`0It_ zWRG%>bxI{A3<~54ocA$I5%Ubgz%JvJ@hW-UycCX%NAPJ7xcg=J76_(&{|Nopb9@)w zero?uM3#UIe@9^xDjzF$^tsTB<+4qzXfBbP$+-oU%LG`x;mjiNb`?~)hZdFF7G{zZ z&l0l6y$)Uv61-!2WTVcy=eQ@3yGWZzl1{_S0m+mg|MWu5w1L!#($~oJSPOv*UiLf= zoaiDGb0Kv;PcND!X-|)9G>xN-;obqCeuwTK6w8Vq6k+WTTK_Elx#`bMPntX$KX|ae z{(sl(zW?zLZufV8_t$U#yYf}V|EV1K?u{Q@e)O*Qs9XE#^X5N2{qov?R@qt zfk+DlmJO38J3Zw*I2G5?3&p^-1xGWo#qa1wC_b~On=yaMO zbRm;(dc;yJn5Zi0$5;k-14jhKs*RJwzvQjP4G_;)`PBMZ{8WBA!7bFETfUckD+J}< zot$hIhg-^tGF7NtiJp;^3{Mhu8~pJ?qMd492lC_#WV?g!i$8a?EyU`$++YkdrWh;q zCuWi-&yHMIt49{ql%b;oKD}$5K3#rK^@>LM?bd6LyriE=|6O|R(UX?trjw1V=9ewb zkH)3fANxIdSH9kL?U`&aM}1;66$%%|ymB!fv%_R-JMwGlklS_}6~cqv7S#U*dok)zL2 zyV*Usx8Yh$hDx~te#YC-Ra8UWp9UA8j8=qCyPA{9QL!g@F~Tze(}AVp%i_c0_K?S6 z-=Gs83%eRRxqm6*!hycX*8MA?Tj7}znGw&!UBguS-VR}g)`s2+N!piYC zihFv~GnHb34mcOYfNC&ENW1J=U&w6+!vUFPOHB*mi^(QAbmw_m+C<#s`SERyLOnS6 zUY}F{!M?hlbDclP--(!W0RXl`!&kDvbf)4L5X8~&y7Y18{n|K8O6$V>LA zysd-L)z(8E)Q<|LTBZxPmq6%)%bI71$5F?=By^ZR^2~z>Mj=@wpx7!_Wg<*$+Z2+{`ew~OEx^$?(Cdji?iPvS&y^mpLK|qBSg6Tv7pMZ*2DY)f( zN>Jz%&ZqNbK1Y1Yp?!re54vn2w~8CW-9l9whdI*`%$ahSnV4Z|q27}q!TC1T0xft3 z?q`!%4NXOfq-p4tAdHGBo0xXRBAb>#$#g#ps+$xfd?F}K@4-g}mee*AG|W>kkD)6& z1~zt@dDD~%jeo^#-E_S6=w#Yhyt-=S=!kgOY4CLa*&apriQa_X@4I`SzNv`nxYqGb z`(N6g$liH;zUAJ-{)eFto10%X_cZQrnt8bPc&+tZ`@zos&c!~F>e`54{J6GiI!td^ z$c8(=8~)~hFUfb`sFOl zDxxtv5TWLdAkTtpSLCvZzf=5PWD6n9Qbx41{_XtrqsTDZgqx)f$pH?o6}ZK-XvOq= zuXX69Z5*O^7JNcEf(&22=u&WjP z!@rDJh}?>-kGvnT6c!yGj(5X9^2mN=Sbq3Oq~XBOfxk!I3U3H&i1>Tt?;|fqd>4Kz z#C`wO{TKF^hnI!&C0U`$u<)?Hko@3FfrUYi;!IJCztA_?J0J5YyNAh* zPe*m?2=%HNc{D2gX(*o6^kO{oB6=~k553(@+|qrhzhW?(vd^h<;QQ zV|62&Lq3?q^$xtJjCyvb_suS+uHL6UU8_&Wp#`pM`?hVg^}MVZeL?8MchRHyNayfR zP|I>l)RUL8Yi-xtPb>Cyyx-;2&lpS_%28h%QIAwiT$?_tE19DhvgXu=G*cN)!IZ_4 zg=o|I5@BT>TX!31y8_G`v%$Qf!$}Rs*y~{Us#`!!tOOoGaYYs`+7+nMj>@N636QH{hNKkr{i{Y}hxeMSj zY+{&j!k9cY=mm3mWL_kX`D+mOsB^KW=!i)b zn7-QJM3x|Vmh6}WX45V3IEB!fH-P-#u$*RzL@!NRHksp0W%If7G3cg`>uY8hQz=@r z_QX`hgkb!h+BmYU5osbuHR|iC+bZ#}aVSr5kHZ?!ztsX`b?r14)M(q+~I|f$k*AZ0yBzZyND#wt4nF7 zS!0f|a6VIid)8?xZ^8kyqq@ni@!Zj`hb1F6HBTKi#2Bo5R?_dHe6!!7@B41Of}!wx zdi1H}=^fOwZn7_0dF>AEzis=Xb?VXZqaX2heBZj=`ca#6+t-Ti?gxF_$^^`uwpI5u znu!VA;&)~qXsdM45gBhAD;GpYib;(;Q{`$nCc8;+EN8<}d+FD0`6alVTS4%1218Z( zS`Ekf72L+>KxQa?-47mrJ_z(Wa0Un1+-0tsy zPl1ROsxGq09J{kfDGi%yZmN#T^D z>dC|w&CFDw3)%F9f&nv&dT@&qL1nSRl|}JP#Pl273Dn0mn6Ham6Odcig$yg?3xFje z0}oLH4q6I`@6Z&$@8^JA_j0H$E+J`ygG5K-CK-EN>00-5`sziC(0toiZaTd{UPw08 zKtWxn3)Ac8vUP%)qm$M#1vU`!v58TiQRks}IMp7h&JP|P__(jD_g){PpV8m@tga{O zDX)9AXST2T*`5BhXXpDjhiXRUV<{8sQ-$~*vJI6BSC?|Z>6Wfak-6>&z9181Ln%C` zn^0O$z{!6Tgj72#(CTp!-Dd{gj~&d|>RH8{YV6CRQPqgq;Y=OeXlkz=rVO7*1!i^$ zlyYokN*EgK8P|~`rl4oxURZ|{uf^-4S0TF`9)&_)yEhs25?`Pcl%ZR&Z-b`;H})M1 zX$@6MIwg-K{^5_q4u?&LH0_HF`ck~I?**#ea8UueOW9u^w>?_>d&{fl z@s@p$W*&a^2witq&EWdrIrTa9ZB6bBU%v?z{-&weI5D5Ql#cvf6FA86t7K#bN8+ux z1($y}k}2)Uk4EIc9|iSR3>r@_QZVd1j@GSNID-_}K8to6kj=LadVn+NAxd~3+R+qyme0(_shHNCEP)2;lIv zfC-s|yQ0O6GfQ7E%fLAwr|X?IP8O@LYXsw`N7sk*RQLK6*dPb=z1bUpz4KJZ7o7(? z-^0u`LjHs72U&#d^C#>l=cRvc`LyLvkKcJb^Z1L$|3DA&s_pwu=bqF3?+-pwozrM1 z(`U;K`CwJWE)^q3A{y!k5}1!lkU#}niQpZ7hVzAiJW2xTIPQ%s{H@l>5|0MtVpVxS z(dk-*3fG3Y@@eoYDyUX34QA8pj4Vv{(mWEW_FGa#HiFZrM&7OnpU7Fzr37&4RC#qX zu!+X>E*59M)i=aH1TOzuzR&&3g8G6pf}4U|MQ;V^#Pz|E`@Y$CA&4&u6!!)5CAWh6 z0+an+MT8(ikXdv!AV(h?5cD-!0)vxWZ zt*;;7k@6mnKK%aS=gsGv4mAGP&mT1Y6FQF1+dl6&*jd+M?2!yuhtkFv<3-peotSFT zr5J^#riB($!SX5O`lvQcpd3$b-P@gj1i@>d{fhP`yV)6~Vlsnlr(MV5yU~*r@W#c! z1>ZzU*&DVufw&23u^IJZ9JpjbL3!U*%N;I z_eOx>lYnpSBF?~@o+XS-PAn>;DsPBDcy_0($v4jTh>wncU!W7j`XqulU}A;f#!lqt zaLm{`Bw`=Sr&>WPLIR{K2@ZZ4nF!~!1{5nA9P~&vhpur8xhNCZR6l^no4M5r6=9_# z4V=Lfj?Y1jHlpGq>`a2+%)#^6vQ7uFX<{YXa?xV7Y??*JO&xKvYie^ceIiG5et1!t z-Z$Gv>r3d3=o)V8f9&#j_0j0Vw&q8_JZbp0@nzGi#vdD}9*q9@*B^KNG*$O`-GA4; ztbKX^ceO#aZuRf{H1iYd!TzSNrT>t%bu4x!bh-8G2XjXcY6iz+$88f~dgcO>A=W~m zE!=sP04`~atNkR^x~a%!Y7!Ezs!@+P!o|D;WzZQYzl*#a>DBadmVL)oVcnLD&)WZLR3l9K}sRUy4)#S$=)K z+5W8okpUILOhFo#gbuyRdkf!TH@k%0%@$xk&GFiy+N-6#!Jl=9TZ4*Z z5KIH}JrwR0?nQ1Aq+MP?!^DR0@)~k+_{g6w*?A0dm@r) z^YE*z=f%sZ^J*PsnlXNU`0K%Q%EtcQp*zF9Lx%?5>HeVYmkhMzU}h>YCnNP5v6gvj1Z?<O}=J6TNpXxCi;*rbuxHMf!+wU#opjiB5@?k{6PVeHSnzT!>sb@FMDQ1T%CgY$NoA zBvE`T@Ty<5|FJ;#pzfesf%vp>q6z#i`IcgGt75lsrI>G-agtp{dPfzP$-9cXql95W zcgcb3BiXYOtY}Pl;EzZByVp)r+=6z)5!7lv5|l&M4};Ks%W6*`T?Z2@a!G*MN4`OA zD4E;TrOw69#6U$*pglf8()6o7LN9%D$QgU1qaA;iO|`kS{~-Tu+x5qf9)9%bxA059 z-{Ran)%e@y8QdH*kKcPdC;e0F7p>>9QT?KOxaXsRI_zELL(M}+huH87)J@*jHqB}D zW}|FLvnqaWg_2VD@(6NelE7CDMxG`Qj4X)<4X2hETeL80EWG&G$Z`0bEtZTI?33&p z>8JKh^=aT2c^~n)^y?%$hJF)HSu0MnVtB%>o(1R&a?l?r=r+&;^61%KOl+9buvfap zN`%WHo*(C(%`N271;Kvd!W?|dseXwfPVhJTwt}ArUlzw;U-Ve=iR9mc6UAjgZ|$4h zcS>>?%BhUNZ*c2>BKcA>5_}{m-#<1WT$B~i>L(TO@ouJKcP_*B+y-R>hnvK{#fn3p zUydzGI<*gP1@5gqYG*TYaw(XUe1v>cDYitl$U-)QMjC@@ZPp9rYQ?f)!2~LyU`1#a z&lk=pwC84T>Y$#PF>6nb-5iS@57ShuAE@3_`V6M4@>J)PHND1;+wI3YXFLDVMeF{m z<5PJUy1(DHO|`K*9NOESZg-#UY3x1O6V(%-j8{uF5twYj|E`fu9f!k0Ylxk{GG~~- zxqV!kpJ0D?!n}*y|t%HTw0u@0aDyI^13N??O%wn?Z!SC7fs%M?S-dM?T z1vLqKS}i#1c8ZP`Gkr7k@=4%MoCR6!8vagqklES`4*%b` zLy-I0fLSRCd~zZZ$+n={rz3exvw931JG%nSgw#)>xysBj$1h4uDMqvYfu3f(X~5=f z`s7%|sDAwDWV&`8{rR0io+@=XcerYZruwX(J#c6sK&j|s_Y5lX&~<-~j{VTH{?4xz zdc_GS31*dV4t@*0O>@uPo)fsu6NVpX&Vnv5h>r05ID1k6&RwQ4X<-u`XXbJ#z8NGi zfkrpE5srjgJE#8+y#0ET3e(#pBw!|cm^_m3*=kv4P<3jc-e97eOM`l`-5rk9eymj1r_fGuN+fTd#@dbJ6Pz=!P!#AtKg)0XZU8}bFYP($t)=H z&lG;=efB04Mfo<&+%FC6&b%sEAYMn{51Uk}zO(i?Wv$e1uf=`p>17<_s@ z`m0QkuQf=g*?E})o>}#3>hm;PJ=pXb>^2Hk?Nbv8Shp?kP3A6H!LB`OFzMp86ilAF z=2B;@P-31R-JUQ`7LFg+*i?^{k5q%uf}9=YpTm z|AJq!pqxwej`QV1nWOgAVDn7$E*3QTRr*c)Mtk3aVkFtO65j{WH`r%Kuz{^!8UCMq z?|NuQIR0Nk(IfLo^RDt`;#6}K-WRk$lUapHN-o}(NM1DbW?SgEqgf4f3z&R6sDW+R z$qPMF$yrDfHX{%1IB3*2T|}-{7dA-JvB;pC#oiK0kGpRmMFxIjq&MUt&9!VRA4z}~ zOi0ck4W15$ObWQ!mKS1B+W2c4OiK!v%!{;TAy_9wOcLvK@%qAfj-g1`GA)7*jfR=aDnD`{G2Sqq@*%Y}kZz;hH-U=CP` z%|;V1+q(c~5)~Ucm7m4uman_tVY~}e|2{C_D*X~M|40-b!FRI*T`pf#i{1Av(Bi^{ zf`Fs|j(<6B?

xUK;lZJ0ElRG_P)23g%W8)Zhx}ICI>4k?F3* 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 diff --git a/mic-R-2.wav b/mic-R-2.wav deleted file mode 100644 index f0bc69c8268385dea988fc1f9652f4592710337a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 240044 zcmeIup$&sj00htj7GQ*~f+$HXf+_?_Q&-0T41?(gBp4QJ(mwQ_ONu4&Yd70Pp`R!#C0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs j0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0v84TeVz;n diff --git a/mic-left-boosted.wav b/mic-left-boosted.wav deleted file mode 100644 index b88cf54581090cf0e12db9119356aad9da554366..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 240044 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 Date: Tue, 13 Oct 2026 18:11:59 +0800 Subject: [PATCH 18/51] Push-to-talk voice commands on the box While the computer offers voice (voice.available), a long K0 records until it is let go, up to 30 s, and streams the audio as VOICE START/DATA/END/CANCEL lines (docs/protocol.md). Without voice, K0 long still gives up the phase. - core/voice_command.rs: the turn (24 kHz IMA ADPCM, 1152 samples a line, each line carrying the coder state it starts from; under 0.5 s is cancelled) and the overlay: LISTENING with a level meter and seconds left, THINKING, then what was heard and the replies, wrapped; errors; closes 15 s after voice.done, after a quiet minute, or on K2. Needs-input, failed and a phase ending close it and cancel a recording, as with the menu. - adpcm.rs: an Encoder matching the decoder, with round-trip tests. - buttons.rs: K0LongEnd, K0 let go after a long press. - device/mic.rs: streaming through a circular DMA ring in PSRAM (128 descriptors, owner checks off, so it never stops), drained by the mic task every 10 ms into a heap queue; MIC STREAM reports rate, drops and backlog. Nothing new in static RAM: main stack 42,128 bytes vs 42,656 on main. - VOICE DATA goes over native USB and the local network only (Board:: write_stream): the UART's 11.5 KB/s can't carry 16 KB/s and blocks. - Microphone gain 42 dB while recording a voice command. - Comma and apostrophe glyphs; the font still has no CJK. - voice_preview example and docs images; docs updated (protocol, device menu, pomodoro, firmware bring-up, ADR-0007 amendment). Co-Authored-By: Claude Opus 5.5 --- docs/adr/0007-device-menu-behind-a-long-k1.md | 2 +- docs/device-menu.md | 23 +- docs/firmware-bringup.md | 16 + docs/images/voice-error.png | Bin 0 -> 2149 bytes docs/images/voice-listening.png | Bin 0 -> 2023 bytes docs/images/voice-reply.png | Bin 0 -> 2705 bytes docs/pomodoro.md | 2 +- docs/protocol.md | 16 +- firmware-rs/core/examples/voice_preview.rs | 174 +++++ firmware-rs/core/src/adpcm.rs | 158 +++- firmware-rs/core/src/buttons.rs | 35 +- firmware-rs/core/src/canvas.rs | 2 + firmware-rs/core/src/display.rs | 36 +- firmware-rs/core/src/firmware.rs | 195 ++++- firmware-rs/core/src/lib.rs | 1 + firmware-rs/core/src/voice_command.rs | 736 ++++++++++++++++++ firmware-rs/core/tests/firmware.rs | 170 ++++ firmware-rs/device/src/main.rs | 63 +- firmware-rs/device/src/mic.rs | 257 +++++- firmware-rs/device/src/serial.rs | 3 +- 20 files changed, 1793 insertions(+), 96 deletions(-) create mode 100644 docs/images/voice-error.png create mode 100644 docs/images/voice-listening.png create mode 100644 docs/images/voice-reply.png create mode 100644 firmware-rs/core/examples/voice_preview.rs create mode 100644 firmware-rs/core/src/voice_command.rs diff --git a/docs/adr/0007-device-menu-behind-a-long-k1.md b/docs/adr/0007-device-menu-behind-a-long-k1.md index e7ab3e5c..cd9891c4 100644 --- a/docs/adr/0007-device-menu-behind-a-long-k1.md +++ b/docs/adr/0007-device-menu-behind-a-long-k1.md @@ -16,4 +16,4 @@ Leisure can no longer be started on demand; demos use the time-compressed build. ## Amendment -ADR-0017 (proposed) gives K0 long to push-to-talk. Giving up a Pomodoro phase then lives only in the menu's STOP FOCUS / END BREAK row. +ADR-0017 (proposed) gives K0 long to push-to-talk while the computer offers voice commands (`voice.available`, docs/protocol.md). Giving up a Pomodoro phase then lives only in the menu's STOP FOCUS / END BREAK row; while voice isn't offered, K0 long still gives up the phase as before. diff --git a/docs/device-menu.md b/docs/device-menu.md index 0427550f..28f77d6d 100644 --- a/docs/device-menu.md +++ b/docs/device-menu.md @@ -15,7 +15,7 @@ The frequent actions keep their keys, unchanged: | Gesture | Does | Change | |---|---|---| | K0 | Pomodoro start / pause / resume | none | -| K0 long | Give up the current phase | none (also in the menu) | +| K0 long | Give up the current phase; while the computer offers voice commands, push-to-talk instead (see [Voice commands](#voice-commands)) | none (also in the menu) | | K1 | Switch between On duty and Pomodoro | none | | **K1 long** | ~~Leisure now~~ | **opens the menu** | | K2 | Take me to the agent's window | none | @@ -37,7 +37,7 @@ Muse manages with two keys because many of its boards only have two. This box ha | Row | Shown | K0 does | |---|---|---| -| STOP FOCUS / END BREAK | only while a phase is running, paused or waiting to start its break | gives up the phase, like K0 long, and closes the menu | +| STOP FOCUS / END BREAK | only while a phase is running, paused or waiting to start its break | gives up the phase, like K0 long without voice commands, and closes the menu | | VOLUME | always | steps 20 → 35 → 50 → 65 → 80 → 100 → 20 and plays the "All done!" line at the new level, as the app's preview does | | MUTE | always | toggles mute, like K2 long | | COMPUTERS | on a box with a key (ADR-0012); shows how many computers are paired | opens the COMPUTERS view below | @@ -93,6 +93,25 @@ Every screen shows how the box is linked as a small icon in its top left: a plug - **Volume and mute stay where they are.** The menu changes the same values as the app and K2 long. A volume change from the menu prints the `VOLUME ` line `device.volume` already uses, so the daemon's status, and the app's slider, follow; mute keeps its `MUTE ON` / `OFF` lines and stays device-only, as [`app.md`](app.md) says. - **Nothing moves to the box that belongs to the Mac.** Connecting agents, the voice library, firmware updates, launch at login and notifications all stay in the app: they need the Mac's files, network or system settings. The box holds one voice pack at a time, so there's nothing to choose between on it. The menu is about making the box's own controls visible, not about replacing the app. +## Voice commands + +Status: built 2026-10-13, not yet tried on a box; experimental, off until the computer offers it (ADR-0017, [`protocol.md`](protocol.md), "Voice commands (experimental)"). The screens are rendered by the firmware on the Mac: `cargo run -p vibebuddy-firmware-core --example voice_preview -- `. + +A voice command has an overlay of its own, drawn like the menu's panel and, like the menu, not a mode: the mode underneath keeps running. + +| Gesture | Without voice commands | While the computer offers them | +|---|---|---| +| K0 long | gives up the Pomodoro phase | push-to-talk: records while K0 stays down, up to 30 s; giving up the phase is the menu's STOP FOCUS / END BREAK | + +![Recording](images/voice-listening.png) ![What was heard and the reply](images/voice-reply.png) ![An error](images/voice-error.png) + +- **LISTENING** appears when the long press fires, a second after K0 went down, and that is when recording starts. The meter shows the level of each 100 ms (green, then yellow, then orange near the top); under it, the seconds left. Letting go sends it; under half a second is taken as a slip and cancelled. K2 cancels; other keys do nothing while recording. +- **THINKING** follows until the computer answers. What it heard shows dimmed, then each spoken reply as it comes, in large type while it fits and small type after that; past what small type holds, the oldest rows give way. The title says **REPLY** once there is one. +- After the computer says the turn is done, the text stays up 15 s to be read. An error shows a short message for 6 s: NOT HEARD, UNAVAILABLE, BUSY, or NO MICROPHONE when the box couldn't start recording. With nothing from the computer for a minute, the overlay closes on its own. +- K2 closes the overlay; a long K0 starts the next command; any other key closes it and does its own job. +- **Events that need the user win**, as with the menu: input required, failed, or a Pomodoro phase ending closes the overlay, and a recording in progress is given up (`VOICE CANCEL`). +- **Text only.** Spoken replies are text until there is speech synthesis, so mute changes nothing here. The firmware's 5×7 font is ASCII only: there are no CJK glyphs, so Chinese shows as one placeholder per character. Curly quotes become straight ones. + ## Constraints from the hardware - **Backlight is on or off**: it is bit 7 of the XL9555 expander, not a PWM pin, so there is no brightness row. diff --git a/docs/firmware-bringup.md b/docs/firmware-bringup.md index ecec6fd2..41029d88 100644 --- a/docs/firmware-bringup.md +++ b/docs/firmware-bringup.md @@ -168,6 +168,22 @@ MIC DEBUG T100 DESC 4/179 BYTES 8188 LENS 4080 14 4080 14 0 0 I2S RXCONF … TXC Boxes of this board differ (some have a touchscreen, some don't), so run it on each box you care about and say which box in the result. +### Voice commands + +A voice command records the same input, but for as long as K0 is held (up to 30 s), so it can't use one linear buffer: that would be 7 MB. The DMA writes round a ring instead, 128 descriptors over 520 KB of PSRAM, linked in a circle with owner checks off, so it never reaches an end and never stops, however late the reader. The microphone task reads the descriptors the DMA has handed back every 10 ms, takes the left slot's samples out (the stream runs on across descriptors: frames don't line up with the 4,094-byte EOFs) and queues them for the main loop, which encodes and sends them. An EOF every 4,094 bytes takes two descriptors, so the ring holds 2.7 s of audio before the DMA comes round to what hasn't been read. The ring and the queue are taken from the heap when recording starts and given back when it stops: no statics (LESSONS.md). + +The microphone gain is 42 dB while recording a voice command (the PGA's maximum): at the diagnostic's 30 dB a voice close to the box was about -31 dBFS; 42 dB should bring it to about -19. + +When a recording stops, the box prints one line about it: + +``` +MIC STREAM MS SAMPLES DROPPED BACKLOG OF 128 +``` + +About 24 samples per millisecond means nothing was lost on the way in. `DROPPED` counts samples the main loop's 2 s queue had no room for. A `BACKLOG` near 128 means the DMA lapped the reader and some audio was overwritten. `MIC STREAM MEMORY` and `MIC STREAM I2S` mean the ring couldn't be allocated or the transfer didn't start. + +What to check on the first flash: with the computer offering voice, hold K0 and talk. `VOICE START 1`, then about 21 `VOICE DATA` lines a second with `seq` counting up without gaps (over USB), `VOICE END 1 ` with `samples` about 24,000 per second held, and `MIC STREAM` with about 24 samples per millisecond and a small backlog. The meter should move with your voice and sit near the bottom when quiet. Decode the data lines (24 kHz IMA ADPCM, each line from its own `predictor` and `index`) and listen. Check that announcements still play afterwards, and that a long K0 still gives up a phase once voice is withdrawn. + ## Going back to the C firmware ```bash diff --git a/docs/images/voice-error.png b/docs/images/voice-error.png new file mode 100644 index 0000000000000000000000000000000000000000..fa1fb9f3089bcc19e191d8aefef03c7c8ccf0b46 GIT binary patch literal 2149 zcmds3eNfWr78XIpG+q19EVHt7EmN`7SVb(c+}2XF{6JA?vqe+X)<`K5=$39{T5C5y zB4hPdRt9K_nu=0>u!dioshbL-M5HO8Xe5T*pY7b4JNN!^|G)1%bIyC_J@5O@oH@@k z=UPZm!0J^SSLx~Ltv>v{f2f`wXg{!5LiGW=9J~twIOEjs(P@BRSs=YDBr70*S{?R3 za3bUGjIb(C(gEU+`|#S=@0@sfmF9Ggno}Rx5X}n~i-N^_V3_*zw}%hn?$0N_GuHpm za6cyp9CVciNgZFelNIP0^7KJrp!}vysH(7;4NpzD&^9npw>@;7`t3|gGBrd|a?yyo z2r+#DKr}+3$Yb>0?*XG=p#MbWL6G8nbn>4JMBm8ivXkSm$rF-hl&ZJBi z-m(Dil$&Ijh5H+*&}PhxFjjI)x13#gTy5;q!N<155XpOCkP1s>JA;sASWMd%$)RTF z(P#@rv~7Rlc=pwg&{jPHQ_~l_Ac%#{r+%{pvZL22Kc7um4C`yrsdTLH#r|@0_S-48 zFKcq6j*W_T8Dq@x@6`1kuN(JTc@PCM@4hI}aT=R_6PZ2%xi_%OugV5JG4w2b!-r=a z#qz2LXoDZz*TG<6X`sTp65*jvwMBPvJGE%WFVfm z-x##74bgQ%KMUsuGT*$Ok^V4BvP)V@s^=(5EZZQa^Bxw;z9X-(Sfp$>1I!=l_10mK zJ#Y9EULF=O*bVBoqJJTBEr_zvE|ZB2il*r)E#7LE8jDnuV91U$b~v0Z2vzxls^uEQ zGk}8oSSYi5&C8`)y6je#i%ww5L;Qq;I^dwS?&6V%7H^kntZw1OMjRnWzd4aJG9#RG z9VCZ)^#uDOGqE{p-;Bj~oMzcZG|v_@Tcn?A$)g{O*)F|h)EjtakidLUFEQE+ti}ti@uj_|zl;(S zYe#zrZJng}8XrFIn>`JdM=Go z7g5MZ$3qAGc|=8R6^7Xu_l8Sv^HjtRmdq4V6hq{?+%-obrd?^rKx!FxhJt6SBW0Sr z$N@HGU~!`E1_Q2VqtRKT=HlI(I|@`-&*xWZb?+w zJri*c6krXw-9f|Pa>m(u-NZuw2zwR@>pKk)e6<1Q=Z0{b&UgC9F%_GsP{`iH%%FtuT^estQ&O{+J9 z)x~@qrXK{Bgd1l}xhV@?>HM%v0?ubZzu9RJ9sg%!w>A9HNPab6iZ3l8y~o&qQI!wD zLB(&oTJHv~+`9wK=qw(W7uZYwj%`IOoROtz?GLcfTq>4WV1i77?v5ZDB8f|9f?8Gb z{UYbjd&am5hY%K_^f^N8M{BHo{@w%GERYCUU0&M6H=cTKr z5vXf?HHL@kpYGGVZD1EKxjxa_ZS_k7jfD-Ta4v8Iv{s@9`#>O` z-+1qYqR$P6xo-(>o^&T8416-}E_`WMo;^*W8*M^pC}EmpoHM~-bVgN=fvb_f%>41` z-sqvKwge{}F5f}_e9Vrnet#Yh0qFmKqr&LR>`)3r1TAU@m=bQ9p_5iG7#bya@xD2_ dB6I%((=ELvB#qg2lKaZAaQI-5Kg$ny`Jau<^ko15 literal 0 HcmV?d00001 diff --git a/docs/images/voice-listening.png b/docs/images/voice-listening.png new file mode 100644 index 0000000000000000000000000000000000000000..60deb19845ba3362f51a3a0cb5b5179ce99cc380 GIT binary patch literal 2023 zcmb7F3s6&M7EVHV$U{`XfK&pu)^U*lQkE(dC{Ixvq?A{Ago->?6jLP-NRR{zTUR97 zZKWy@WXB>#5C$TT1QN@0%~a4=dMLtds4_pPB^?@dm{CP` zXST#!o5gs9?t^?lu%R8gh3F;tQiTFlPHrw+yF74z3zpI=loLb7og?g!{JI7Ki z05>)eJbom2JhKca57Zz))O*Y=NB@FTamd{(<4|)uz1ZtJaMeD@?Ky6c7b z9JpWr-}PiXG1KbrE@<`}8D~{^V|_Y={v$0G?Hg=ZligiTC3+-x61)useuX2}wu3j! zgD`fs_gFyqp&242vb7JL0c@XgpYLV(T720732%=C!x?g~a@dN3VN}o(3@fxO#q*iV z*Y~drTCUQ3cQ1cWa#1X!ag>s()L}p6l@#C$N0}n(ofF)jrop94Mx%Ay32ygW=390sA`lNksxmi_4qfNM$YdNW^TC+`y?mDTIGXv%y1RQE3~Yvh zn#fRVwl|goR20QB;lTnqe2!iclrK>+#z7o_6;lwoY`)U=SkXm zJeIM0q4Rd2<$Kew9ZToD(EHnnfEvESkV5+0&THippHZ&gG!LS$nIp5}m^50Vj%Arj zpn9bfS(G-~M1H&>gn z-~SjhJ6vh5F8IalxO7)Ap;8EK(a~9ISYKy$z-C(vuZ;3WVeEW#Tc;+%PLcDG6`&47pVolu??rI zgG3`_>!{%R1FENU^q%4uQn;X@>_pO+7I?j?9A4E|!hLOj#VqH!d+04cJwZT@B7JnuNavdSU97b4w`9QXHuI=n?bOgT0vs<3y3jQOV% zHT(gNkE2$LQotEFKb_`dODH=*5>r2MOrGq)@ss8CTc&c%f7cb)pDx3P{>A9OS^pl3Kn~(LKi7^ldsgH1 zL%eUVroZydY`pR(7B}d8XTV#PT$;3hfM1psJ7q~4^5v&!E;S@_={4aw>W*-KQ|;dV z)Ejo)wbd!AH0j=wHc?iU5x1siU`_JR7%lzTWs`Hork?(#9|RozvtCLurDU{TB|qo{ z2p>B8WR^C8Db7xvh}WcHvXU>F3gjtWk&nVmtsSvb3?liY(-;j_OKTKAno_&`1NrNV zAMQtvCAB6>Gbp&RF$KBv|L^RNp}dm-Q(7_%9IJQ1zD%(%m-k#E;8s5kZ9+gSB!F;D zy}boyEBc{=7-*b$y0CL=nPgXHBN7FkYYWp+^8sys^qE(Fh<;W5suO0JT7?oA6xcD& z7nWwjsW~lyP2YXu;`YX{gdIme;!oa^hrWzQffaTrsR(rVJ^E3W_gcr-+lr$;&z;)p zBxesI_y?leYFk+Z1wMtmx~)SdJ&%nz=S?ELW?ywm=(dk22FG%7w67C~xXlALlh;{4 zU3;Fg$1&+Y%fk94?jWZ)W^ES+rbKXro;bNpS@YVPQZZMwP4s@>-Gg_Q9xBi4uJ&_f z*;2$ju;LRrI{|h{a*XH%JiJqQ55gTrN bN1(IMR&Mcf6ZF@Ze%CRP$cVPE%!>a3s@K>O literal 0 HcmV?d00001 diff --git a/docs/images/voice-reply.png b/docs/images/voice-reply.png new file mode 100644 index 0000000000000000000000000000000000000000..24cb6fc242fdd2d2595660dc92b2d8526ce44105 GIT binary patch literal 2705 zcmcguX;f2Z8ooCyMs_L!0)jwm6@o-5C=~=*j6emdA_y3QjI0JIAd3MKEK5;=c3Kt% z5w!>fAqXT95<_YbEL%{Pkc7<*h9wY`Bti(8o7&UPnVB;`=l;0w{oZr#cfRj=-{*PW zd|z*rx{84c008P9$K6f>0O$;T3*a#5z3F4&0EMmb$Im80QT~8HcA6fvP;vEeJ9;{` zf?KuvyQY}sr=w+b+Q`>EXU#yWSZp-ftn2pW zx1J3;-(R^B*6`Uu`dmZA#y}sHy4)g`Wj&+pU={Ll8%u@k#FWvH3Q+4($`S}fq6lE9 zqP|vg%6s0pD=>IfMGbDwARuA?9u`yt(WEaq;nWGyq8VMtUYO`8zipSpm+?Boh!q^KvKXYNY?hs_wMpCg8uJZB>a2i-jkrv007&PBYTYP9t$ zRUHgK`uzuP_?@h375VasQkjhpV_z@<98*${Z`25COePEFx0Lx00t!uVZA@=P3wt9G zTTs2*yjcrW9?S(4p1_g2u73;&H$un}yq#E!5KWw--NM25!;D_$13EcyB*6|kK#4jM z9|wL{1l8fN_H>?@oX0B{i|g9SvJ1)p7P_UpGz;p2^;h-Mf`01lxQif?+z2c;-<(NK zk>GZ0H=nJ*oA;o~DK$>bLLa=F)QWZ!T?hR8DE<(=J4wSdC@ z2^`HEPrKZ)GS9JM%yF#m@Ap5UKic)o)?&nt`>d$mk|8-;&*U2h0oNNSFwOc{CL*aP zq2&CW>6KM!<|D@gy=A26I16$6&!^G016g3`d9YEMjbw*=2DaS9l|0;AsZi5%X>8N} zJ*^X_hF}zU%zc~7iGm?6Hnx{a#seN|)Gttfdbcw2QncBdOSK|ReW^J30H?ctDj0XS z{zKa<0d3F3d8dx%roM1q1PvFi-j(XUZc_S|)YR1HjbfU9vFm|kA^UdWT}e?zFZ}C;_O0Q2&Z7{o=>aN$-h4^9@!j~ZNKPRX=jjm4) zxLnnK7gYr))LycLImGQe)kaop`LNOQCeOOa$4I*7CGygsm@yb9Z@~p7{t$*E_rA${ zMOAiTc|Hjfv=5Yc7V1C@D^V9DQ~8VSBI367O3TqklGBM3XEx}nX7`{DhdR_g=+6#~ zEhrq0A5nU0)5jgLJLGcIB9Z_Gl)-3$4aau6v7!w{lZ%pO@u=P3$$q=ImFfsXX!|qT z)BJmUq2{tTg(x-Bceauf)s5hk`3SMGEZs8OWyd^{$Lq!Qkyt|CN#X@-bP+`Gcfsno z7AKoFV{X?eN&A#tC5R5$5MQY6KTL6Q)~I4wF{rD?3FVVyeY#k4>Z}zOae0Y%KRA?T zOqH%hNv627{R=_^QSR`byus5LjDhJuRyPBe?7g9&^>(dW~Iow@rU9;s?izqI#he+rd6MClgU1WgKB`aFd=4v!)BFD-W;IPB<1^v9-c zz|wyhw@c?}IXkgc(x36&&*k#!LJX(Gi>;J*!rw$VjyRGtnLM>pSCx=k9Sxs+NN;SEQmGSmNfRRW)*CTOi*iM+0tZH3R@UFoAigGvk^Qzzx(yS{ zPNhg~cdxqs4boiOtC4P{U{I|m-mn%C_`N_&jaJ&6NDMQyV37l9pN zrZBat6LN?;qaTON^pTqLN+aw4CL9iJn0n|8D*ImhQ8ila>{gEth9g*dodE7(Nc|IOfhPt*&NY{Y+~9u&5>?9IhOdPI^qxedZ%JL4?!A$w>z3WHYEH*>jrw#1TR#ZU}0<&O7Sl!Fl9ShIp@hX{- zD9fhbo$3#p>4XK-3lEBa6vgQ4_2ct@rcV^0dqu+LdtUum1(pV;`ZF z2UFO%z#e(q0gN~*ukHh2d4B$mfk~{%RqP#K{6>6R$2YQH*EYMQ1uo@Dy_sR_VO%x1 z`: K0 has been held for the long-press time and the box is recording. +- `VOICE START `: K0 has been held for the long-press time and the box is recording. Recording starts here, a second after K0 went down, when the screen says LISTENING; nothing from before is kept. - `VOICE DATA `: the next stretch of audio, `seq` from 0 without gaps. Audio is 24 kHz mono IMA ADPCM, two samples per byte, low nibble first, the same coding the box plays Character packs with (`firmware-rs/core/src/adpcm.rs`). `predictor` (a signed 16-bit sample) and `index` (0–88) are the coder's state before the line's first nibble, so every line decodes on its own and a lost line costs only its own stretch. At most 768 base64 characters per line. -- `VOICE END `: K0 was released, or 30 s passed; `samples` is how many were recorded. -- `VOICE CANCEL `: the recording was too short (under half a second) or the box gave it up; the computer drops the turn. +- `VOICE END `: K0 was released, or 30 s passed; `samples` is how many were recorded, always an even number (a last sample without a partner for its byte isn't sent). It counts the samples of lines that were dropped too, so the computer can tell how much is missing. +- `VOICE CANCEL `: the recording was too short (under half a second) or the box gave it up (K2, or an event that needs the user took the screen, or voice was withdrawn); the computer drops the turn. + +`VOICE DATA` comes to about 21 lines and 16 KB a second, and goes only over the native USB port and the local network. The UART bridge carries 11.5 KB/s at 115200 baud and its writes hold up the box's main loop, so data lines never go there; a computer on the bridge would see `START` and `END` with no data, so it shouldn't offer voice there. The Relay doesn't carry them either (see above). A data line that doesn't fit right now (the USB host isn't reading, the network connection's buffer is full) is dropped whole rather than waited for: a gap in `seq`, never a stalled recording. `START`, `END` and `CANCEL` go everywhere the box's other lines go. The computer answers with JSON events carrying the same `turn`: - `voice.heard` with `text`: what was recognised (cut to fit the line), so the box can show it. - `voice.reply` with `text`: one spoken reply, sent the moment the resident session says it; a turn may have several. -- `voice.done`: the turn is over; the box goes back to what it showed before. -- `voice.error` with `reason` (`not_heard` when recognition returned nothing, `unavailable` when recognition or the resident session failed, `busy` when another turn is still running) and an optional `message`; the turn is over. +- `voice.done`: the turn is over. The box leaves what it heard and the replies up for 15 s to be read (K2 closes them sooner), then goes back to what it showed before; with nothing to show, it goes back at once. +- `voice.error` with `reason` (`not_heard` when recognition returned nothing, `unavailable` when recognition or the resident session failed, `busy` when another turn is still running) and an optional `message`; the turn is over. It may come while the box is still recording (`busy` right after `START`): the box stops recording and sends nothing more for that turn. The box shows `message` if there is one, otherwise a short text of its own, for 6 s. + +`voice.error` is also the name of the box's reply to a failed voice pack write ("Device maintenance" above). The two never meet: that one only goes from the box to the computer, this one only from the computer to the box, and it carries `turn`. + +The box ignores answers for any turn but the one on its screen: after K2 closed it, after a new turn started, or after a minute without a word from the computer, when it closes the overlay on its own. Spoken replies are shown as text for now; their audio will follow as `voice.audio` chunks once there is speech synthesis. diff --git a/firmware-rs/core/examples/voice_preview.rs b/firmware-rs/core/examples/voice_preview.rs new file mode 100644 index 00000000..7f44b890 --- /dev/null +++ b/firmware-rs/core/examples/voice_preview.rs @@ -0,0 +1,174 @@ +//! Renders a voice command's overlay (docs/device-menu.md, "Voice commands") to PPM on the Mac: the real +//! firmware on a fake board, with a fake microphone and the computer's answers fed in as lines, so the +//! pictures in the docs are what the box shows. +//! +//! Usage: cargo run -p vibebuddy-firmware-core --example voice_preview -- + +use std::cell::Cell; +use std::fs; +use std::path::{Path, PathBuf}; + +use vibebuddy_firmware_core::audio::Sound; +use vibebuddy_firmware_core::buttons::Levels; +use vibebuddy_firmware_core::canvas::{FRAME_BYTES, HEIGHT, WIDTH}; +use vibebuddy_firmware_core::display::Screen; +use vibebuddy_firmware_core::firmware::{AudioStatus, Board, Firmware, FrameAction, VolumeError}; +use vibebuddy_firmware_core::storage::{Flash, FlashError}; + +/// No partitions: settings and voice packs aren't what these pictures are about. +struct NoFlash; + +impl Flash for NoFlash { + fn read(&mut self, _offset: u32, _bytes: &mut [u8]) -> Result<(), FlashError> { + Err(FlashError) + } + fn write(&mut self, _offset: u32, _bytes: &[u8]) -> Result<(), FlashError> { + Err(FlashError) + } + fn erase(&mut self, _from: u32, _to: u32) -> Result<(), FlashError> { + Err(FlashError) + } +} + +struct PreviewBoard { + now: Cell, + frame: Vec, + flash: NoFlash, + keys: (bool, bool, bool), + /// The fake microphone: running, and what it has recorded since the last take. + mic: bool, + samples: Vec, +} + +impl Screen for PreviewBoard { + fn frame(&mut self) -> &mut [u8] { + &mut self.frame + } + fn present(&mut self) -> Result<(), ()> { + Ok(()) + } + fn set_backlight(&mut self, _on: bool) -> Result<(), ()> { + Ok(()) + } +} + +impl Board for PreviewBoard { + fn now_ms(&self) -> u32 { + self.now.get() + } + fn write(&mut self, _bytes: &[u8]) {} + fn flash(&mut self) -> &mut dyn Flash { + &mut self.flash + } + fn with_frame_and_output(&mut self, _action: &mut FrameAction) {} + fn init_display(&mut self) -> bool { + true + } + fn init_audio(&mut self, _volume: u32) -> AudioStatus { + Ok("ES8311") + } + fn init_buttons(&mut self) -> Option { + Some(Levels::default()) + } + fn read_buttons(&mut self) -> (bool, Option<(bool, bool)>) { + (self.keys.0, Some((self.keys.1, self.keys.2))) + } + fn play(&mut self, _sound: Sound) -> Result<(), ()> { + Ok(()) + } + fn stop_audio(&mut self) {} + fn audio_busy(&self) -> bool { + false + } + fn set_volume(&mut self, _level: u32) -> Result<(), VolumeError> { + Ok(()) + } + fn voice_start(&mut self) -> Result<(), ()> { + self.mic = true; + Ok(()) + } + fn voice_take(&mut self, samples: &mut Vec) { + samples.append(&mut self.samples); + } + fn voice_stop(&mut self) { + self.mic = false; + } +} + +struct Preview { + directory: PathBuf, + board: PreviewBoard, + firmware: Firmware, +} + +impl Preview { + fn new(directory: &Path) -> Self { + let mut board = PreviewBoard { now: Cell::new(1000), frame: vec![0; FRAME_BYTES], flash: NoFlash, keys: (false, false, false), mic: false, samples: Vec::new() }; + let mut firmware = Firmware::new(1, 1000, b"v0.3.2-11-g0adc18b 2026-10-06 16:34", "0.3.2"); + firmware.boot(&mut board); + let mut preview = Self { directory: directory.to_path_buf(), board, firmware }; + preview.send(r#"{"version":1,"event":"device.heartbeat","build":"0.3.1 v0.3.1-12-ga4a667c 2026-10-06 16:26","hour":14,"day":20261005}"#); + preview.send( + r#"{"version":1,"event":"task.start","title":"CC:MUSE DUAL BOOT","stats":["13 DONE","1 ASKS","46M BUSY"],"tasks":[{"title":"CC:MUSE DUAL BOOT","status":"working","elapsed_s":420,"project":"VIBE-BUDDY"},{"title":"CX:RELEASE NOTES","status":"done","elapsed_s":1500,"project":"VIBE-BUDDY"}]}"#, + ); + preview + } + + fn send(&mut self, line: &str) { + self.firmware.receive(&mut self.board, line.as_bytes()); + self.firmware.receive(&mut self.board, b"\n"); + } + + /// Holds K0 for `hold_ms`; while the microphone runs it hears a tone at `amplitude`. + fn hold_k0(&mut self, hold_ms: u32, amplitude: f32) { + self.board.keys.0 = true; + let mut held = 0; + while held < hold_ms { + self.board.now.set(self.board.now.get() + 10); + held += 10; + if self.board.mic { + let start = self.board.now.get() as f32 * 24.0; + self.board.samples.extend((0..240).map(|index| ((start + index as f32) * 0.06).sin() * amplitude).map(|sample| sample as i16)); + } + self.firmware.poll(&mut self.board); + } + } + + fn release_k0(&mut self) { + self.board.keys.0 = false; + self.board.now.set(self.board.now.get() + 10); + self.firmware.poll(&mut self.board); + } + + fn snapshot(&self, name: &str) { + let mut out = format!("P6\n{WIDTH} {HEIGHT}\n255\n").into_bytes(); + for pair in self.board.frame[..FRAME_BYTES].as_chunks::<2>().0 { + let pixel = u16::from_be_bytes([pair[0], pair[1]]) as u32; + out.push((((pixel >> 11) & 0x1f) * 255 / 31) as u8); + out.push((((pixel >> 5) & 0x3f) * 255 / 63) as u8); + out.push(((pixel & 0x1f) * 255 / 31) as u8); + } + fs::write(self.directory.join(format!("{name}.ppm")), out).expect("write PPM"); + } +} + +fn main() { + let directory = PathBuf::from(std::env::args().nth(1).expect("usage: voice_preview ")); + fs::create_dir_all(&directory).expect("create directory"); + + let mut preview = Preview::new(&directory); + preview.send(r#"{"version":1,"event":"voice.available","on":true}"#); + // Four seconds in (one of them the long press itself), speaking at about -21 dBFS. + preview.hold_k0(4000, 4000.0); + preview.snapshot("voice-listening"); + preview.release_k0(); + preview.snapshot("voice-thinking"); + preview.send(r#"{"version":1,"event":"voice.heard","turn":1,"text":"what is codex doing right now"}"#); + preview.send(r#"{"version":1,"event":"voice.reply","turn":1,"text":"It's writing the release notes. Should be done in a few minutes."}"#); + preview.snapshot("voice-reply"); + + preview.hold_k0(2500, 4000.0); + preview.release_k0(); + preview.send(r#"{"version":1,"event":"voice.error","turn":2,"reason":"not_heard"}"#); + preview.snapshot("voice-error"); +} diff --git a/firmware-rs/core/src/adpcm.rs b/firmware-rs/core/src/adpcm.rs index 779a3a57..56c0defe 100644 --- a/firmware-rs/core/src/adpcm.rs +++ b/firmware-rs/core/src/adpcm.rs @@ -1,4 +1,6 @@ -//! Decoding a Character pack line: 16 kHz mono IMA ADPCM in, the codec's 24 kHz stereo PCM out. +//! Decoding a Character pack line: 16 kHz mono IMA ADPCM in, the codec's 24 kHz stereo PCM out. And the +//! same coding the other way for voice commands ([`Encoder`], docs/protocol.md): the microphone's 24 kHz +//! samples out as IMA ADPCM, which the computer decodes with the same tables. //! //! Each line is a standalone stream: predictor and step index start at zero, two samples per byte, //! low nibble first. The upsampling is linear interpolation, 2 input samples to 3 output samples; @@ -21,6 +23,17 @@ pub struct Decoder { } impl Decoder { + /// A decoder that picks up a stream at a known state: a voice command line carries the coder's + /// predictor and step index before its first nibble, so each line decodes on its own. + pub fn with_state(predictor: i16, index: u8) -> Self { + Self { predictor: predictor as i32, index: (index as i32).clamp(0, 88) } + } + + /// The predictor (the last sample) and the step index. + pub fn state(&self) -> (i16, u8) { + (self.predictor as i16, self.index as u8) + } + pub fn decode(&mut self, nibble: u8) -> i16 { let step = STEPS[self.index as usize]; let mut diff = step >> 3; @@ -39,6 +52,49 @@ impl Decoder { } } +/// The standard IMA ADPCM encoder (the one `tools/character_pack.py` uses): each nibble is chosen against +/// the decoder's own prediction, so the decoder above reproduces exactly what the encoder tracked. +#[derive(Clone, Copy, Debug, Default)] +pub struct Encoder { + decoder: Decoder, +} + +impl Encoder { + /// The state a decoder needs to start at the next nibble: predictor and step index. + pub fn state(&self) -> (i16, u8) { + self.decoder.state() + } + + pub fn encode(&mut self, sample: i16) -> u8 { + let step = STEPS[self.decoder.index as usize]; + let mut diff = sample as i32 - self.decoder.predictor; + let mut nibble = 0u8; + if diff < 0 { + nibble = 8; + diff = -diff; + } + if diff >= step { + nibble |= 4; + diff -= step; + } + if diff >= step >> 1 { + nibble |= 2; + diff -= step >> 1; + } + if diff >= step >> 2 { + nibble |= 1; + } + self.decoder.decode(nibble); + nibble + } + + /// Two samples to one byte, low nibble first. + pub fn encode_pair(&mut self, first: i16, second: i16) -> u8 { + let low = self.encode(first); + low | self.encode(second) << 4 + } +} + /// Turns one line's ADPCM bytes into 24 kHz stereo PCM, a piece at a time. The caller feeds the /// stored bytes in order; `fill` asks for the next byte through `next_byte`. pub struct LineStream { @@ -122,37 +178,16 @@ mod tests { use super::*; use std::vec::Vec; - /// The same IMA encoder as tools/character_pack.py, so the two can be checked against each other. + /// Packs samples two to a byte, as a Character pack line stores them. fn encode(samples: &[i16]) -> Vec { - let mut decoder = Decoder::default(); - let mut out = Vec::new(); - for pair in samples.chunks(2) { - let mut byte = 0u8; - for (half, &sample) in pair.iter().enumerate() { - let step = STEPS[decoder.index as usize]; - let mut diff = sample as i32 - decoder.predictor; - let mut nibble = 0u8; - if diff < 0 { - nibble = 8; - diff = -diff; - } - if diff >= step { - nibble |= 4; - diff -= step; - } - if diff >= step >> 1 { - nibble |= 2; - diff -= step >> 1; - } - if diff >= step >> 2 { - nibble |= 1; - } - decoder.decode(nibble); - byte |= nibble << (half * 4); - } - out.push(byte); - } - out + let mut encoder = Encoder::default(); + samples + .chunks(2) + .map(|pair| match pair { + [first, second] => encoder.encode_pair(*first, *second), + _ => encoder.encode(pair[0]), + }) + .collect() } fn sine(count: usize) -> Vec { @@ -214,4 +249,65 @@ mod tests { assert_eq!(pieces, whole); assert_eq!(pieces.len(), 452, "301 samples at 16 kHz become ceil(301 × 1.5) at 24 kHz"); } + + /// What a computer does with a voice command line: start a decoder at the line's state and decode + /// every nibble, low first. + fn decode_line(predictor: i16, index: u8, bytes: &[u8]) -> Vec { + let mut decoder = Decoder::with_state(predictor, index); + bytes.iter().flat_map(|&byte| [byte & 15, byte >> 4]).map(|nibble| decoder.decode(nibble)).collect() + } + + #[test] + fn the_encoder_tracks_exactly_what_the_decoder_reproduces() { + let original: Vec = (0..4800).map(|index| (libm::sinf(index as f32 * 0.07) * 9000.0 + libm::sinf(index as f32 * 0.31) * 3000.0) as i16).collect(); + let mut encoder = Encoder::default(); + let mut tracked = Vec::new(); + let bytes: Vec = original + .chunks(2) + .map(|pair| { + let byte = encoder.encode_pair(pair[0], pair[1]); + tracked.push(encoder.state().0); + byte + }) + .collect(); + let decoded = decode_line(0, 0, &bytes); + assert_eq!(decoded.len(), original.len()); + // The encoder's predictor after each byte is the decoder's output for its second sample. + assert_eq!(decoded.iter().skip(1).step_by(2).copied().collect::>(), tracked); + for (index, (&got, &want)) in decoded.iter().zip(&original).enumerate().skip(40) { + assert!((got as i32 - want as i32).abs() < 700, "sample {index}: {got} vs {want}"); + } + } + + #[test] + fn a_stream_cut_into_lines_decodes_line_by_line_from_each_lines_state() { + let original: Vec = (0..3000).map(|index| (libm::sinf(index as f32 * 0.05) * 15000.0) as i16).collect(); + let mut encoder = Encoder::default(); + let mut whole = Vec::new(); + let mut lines = Vec::new(); + for piece in original.chunks(600) { + let (predictor, index) = encoder.state(); + let bytes: Vec = piece.chunks(2).map(|pair| encoder.encode_pair(pair[0], pair[1])).collect(); + whole.extend_from_slice(&bytes); + lines.push((predictor, index, bytes)); + } + let from_start = decode_line(0, 0, &whole); + // Dropping the second line loses only its stretch: the third still decodes exactly. + let third = &lines[2]; + assert_eq!(decode_line(third.0, third.1, &third.2), from_start[1200..1800]); + let joined: Vec = lines.iter().flat_map(|(predictor, index, bytes)| decode_line(*predictor, *index, bytes)).collect(); + assert_eq!(joined, from_start); + } + + #[test] + fn extremes_stay_in_range() { + let mut encoder = Encoder::default(); + let samples = [i16::MAX, i16::MIN, i16::MAX, i16::MIN, 0, 0, i16::MAX, i16::MAX]; + let bytes: Vec = samples.chunks(2).map(|pair| encoder.encode_pair(pair[0], pair[1])).collect(); + let decoded = decode_line(0, 0, &bytes); + assert_eq!(decoded.len(), samples.len()); + let (_, index) = encoder.state(); + assert!(index <= 88); + assert_eq!(Decoder::with_state(-5, 200).state(), (-5, 88), "an index past the table is clamped"); + } } diff --git a/firmware-rs/core/src/buttons.rs b/firmware-rs/core/src/buttons.rs index f9ace952..9cf54ced 100644 --- a/firmware-rs/core/src/buttons.rs +++ b/firmware-rs/core/src/buttons.rs @@ -1,6 +1,7 @@ //! Each of the three keys does one thing, regardless of mode. Short presses fire on //! release; holding past the threshold fires a long press, and the release then no -//! longer counts as a short press. +//! longer counts as a short press. K0's release after a long press is an event of its own, +//! since a voice command records for as long as K0 stays down. //! //! On the board (ATK-DNESP32S3-BOX V1.1) K0 is the BOOT key, wired straight to GPIO0; K1 and //! K2 are P0.4 and P0.3 on the XL9555 expander. All three are active low. Reading the pins is @@ -14,8 +15,11 @@ const LONG_PRESS_MS: u32 = 1000; pub enum ButtonEvent { /// K0: pomodoro start / pause / resume. K0Short, - /// K0 long press: abandon the current phase. + /// K0 long press: push-to-talk while the computer offers voice commands, otherwise abandon the + /// current phase. K0Long, + /// K0 let go after a long press: the end of a push-to-talk recording. + K0LongEnd, /// K1: switch between duty and pomodoro. K1Short, /// K1 long press: send the buddy off to leisure right now. @@ -31,6 +35,8 @@ enum Press { None, Short, Long, + /// Released after a long press. + LongEnd, } #[derive(Clone, Copy)] @@ -65,7 +71,7 @@ impl Button { self.long_fired = false; return Press::None; } - return if self.long_fired { Press::None } else { Press::Short }; + return if self.long_fired { Press::LongEnd } else { Press::Short }; } if pressed && !self.long_fired && now.wrapping_sub(self.pressed_at) as i32 >= LONG_PRESS_MS as i32 { self.long_fired = true; @@ -96,18 +102,19 @@ impl Buttons { /// K0 and the expander are read separately: when the expander read fails only K0 is updated, as in the C firmware. pub fn update(&mut self, k0: bool, expander: Option<(bool, bool)>, now: u32) -> [Option; 3] { - fn emit(press: Press, short: ButtonEvent, long: ButtonEvent) -> Option { + fn emit(press: Press, short: ButtonEvent, long: ButtonEvent, long_end: Option) -> Option { match press { Press::None => None, Press::Short => Some(short), Press::Long => Some(long), + Press::LongEnd => long_end, } } let mut events = [None; 3]; - events[0] = emit(self.k0.update(k0, now), ButtonEvent::K0Short, ButtonEvent::K0Long); + events[0] = emit(self.k0.update(k0, now), ButtonEvent::K0Short, ButtonEvent::K0Long, Some(ButtonEvent::K0LongEnd)); if let Some((k1, k2)) = expander { - events[1] = emit(self.k1.update(k1, now), ButtonEvent::K1Short, ButtonEvent::K1Long); - events[2] = emit(self.k2.update(k2, now), ButtonEvent::K2Short, ButtonEvent::K2Long); + events[1] = emit(self.k1.update(k1, now), ButtonEvent::K1Short, ButtonEvent::K1Long, None); + events[2] = emit(self.k2.update(k2, now), ButtonEvent::K2Short, ButtonEvent::K2Long, None); } events } @@ -138,13 +145,23 @@ mod tests { } #[test] - fn holding_fires_long_once_and_release_is_silent() { + fn holding_fires_long_once_and_release_is_not_a_short_press() { let mut buttons = Buttons::new(Levels::default(), 0); k0(&mut buttons, true, 100); assert_eq!(k0(&mut buttons, true, 1099), None); assert_eq!(k0(&mut buttons, true, 1100), Some(ButtonEvent::K0Long)); assert_eq!(k0(&mut buttons, true, 2500), None); - assert_eq!(k0(&mut buttons, false, 2600), None); + // Push-to-talk needs to know when K0 comes back up. + assert_eq!(k0(&mut buttons, false, 2600), Some(ButtonEvent::K0LongEnd)); + assert_eq!(k0(&mut buttons, false, 2700), None); + } + + #[test] + fn k1_and_k2_say_nothing_when_let_go_after_a_long_press() { + let mut buttons = Buttons::new(Levels::default(), 0); + buttons.update(false, Some((true, true)), 100); + assert_eq!(buttons.update(false, Some((true, true)), 1100), [None, Some(ButtonEvent::K1Long), Some(ButtonEvent::K2Long)]); + assert_eq!(buttons.update(false, Some((false, false)), 1200), [None, None, None]); } #[test] diff --git a/firmware-rs/core/src/canvas.rs b/firmware-rs/core/src/canvas.rs index 6bb51203..c110c573 100644 --- a/firmware-rs/core/src/canvas.rs +++ b/firmware-rs/core/src/canvas.rs @@ -59,6 +59,8 @@ fn glyph_row(character: u8, row: usize) -> u8 { b'0'..=b'9' => DIGIT_GLYPHS[(upper - b'0') as usize][row], b'-' => if row == 3 { 0x1f } else { 0 }, b'.' => if row == 6 { 0x04 } else { 0 }, + b',' => match row { 5 => 0x04, 6 => 0x08, _ => 0 }, + b'\'' => if row < 2 { 0x04 } else { 0 }, b':' => if row == 2 || row == 5 { 0x04 } else { 0 }, b'/' => 1 << if row < 5 { 4 - row } else { 0 }, b'!' => if row < 5 || row == 6 { 0x04 } else { 0 }, diff --git a/firmware-rs/core/src/display.rs b/firmware-rs/core/src/display.rs index 38e1eccd..21904e5c 100644 --- a/firmware-rs/core/src/display.rs +++ b/firmware-rs/core/src/display.rs @@ -131,9 +131,19 @@ pub enum PanelKind { List, /// Facts: a small label, a large value. Facts, + /// A voice command being recorded: a level meter lit to this many of its `METER_STEPS` segments, and + /// the first line's label under it. + Meter(u8), + /// Wrapped text, one row per line's label, at this scale (2 or 1); see [`VOICE_TEXT_LARGE`]. + Text(u8), } -/// A panel over the whole screen (the device menu): what it says is the caller's, drawing it is the display's. +/// Columns and rows a `Text` panel holds at scale 2 and at scale 1. +pub const VOICE_TEXT_LARGE: (usize, usize) = (19, 7); +pub const VOICE_TEXT_SMALL: (usize, usize) = (38, 13); + +/// A panel over the whole screen (the device menu, a voice command): what it says is the caller's, drawing +/// it is the display's. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Panel { pub kind: PanelKind, @@ -1482,8 +1492,16 @@ fn draw_panel(canvas: &mut Canvas, panel: &Panel) { } canvas.draw_text(120, y - 3, value, 2, tone_color(*tone), 13); } + PanelKind::Meter(_) => canvas.draw_text_centered(150 + index * 18, label, 2, tone_color(*tone)), + PanelKind::Text(scale) => { + let (scale, (columns, _)) = if scale >= 2 { (2, VOICE_TEXT_LARGE) } else { (1, VOICE_TEXT_SMALL) }; + canvas.draw_text(PANEL_LEFT, 56 + index * if scale == 2 { 18 } else { 10 }, label, scale, tone_color(*tone), columns); + } } } + if let PanelKind::Meter(level) = panel.kind { + draw_meter(canvas, level); + } canvas.fill_rect(PANEL_LEFT, 196, PANEL_RIGHT - PANEL_LEFT, 1, COLOR_MUTED); let mut x = PANEL_LEFT; for (key, action) in &panel.hints { @@ -1495,6 +1513,22 @@ fn draw_panel(canvas: &mut Canvas, panel: &Panel) { } } +/// The voice command's level meter: green for most of the range, then yellow, then orange near the top. +fn draw_meter(canvas: &mut Canvas, level: u8) { + let steps = crate::voice_command::METER_STEPS as i32; + let x = PANEL_LEFT + 5; + for step in 0..steps { + let lit = step < level as i32; + let color = match step * 16 / steps { + _ if !lit => half_bright(COLOR_MUTED), + 0..=9 => COLOR_DONE, + 10..=12 => COLOR_INPUT, + _ => COLOR_WORKING, + }; + canvas.fill_rect(x + step * 14, 92, 12, 40, color); + } +} + /// How the box is linked to its computer (USB wins, then the local network, then the Relay). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LinkPath { diff --git a/firmware-rs/core/src/firmware.rs b/firmware-rs/core/src/firmware.rs index 02024f94..351da01d 100644 --- a/firmware-rs/core/src/firmware.rs +++ b/firmware-rs/core/src/firmware.rs @@ -28,6 +28,7 @@ use crate::noise::Sealer; use crate::link::{self, Link, LinkStore, PairError, Wifi}; use crate::look::Look; use crate::voices::{Pick, Voices, VoiceError}; +use crate::voice_command::{self, Out as VoiceOut, VoiceCommand}; pub const MAX_LINE_BYTES: usize = 1024; const LINE_BUFFER_BYTES: usize = MAX_LINE_BYTES + 2; @@ -127,6 +128,20 @@ pub trait Board: Screen { fn mic_take(&mut self) -> Option, &'static str>> { None } + + /// Starts the microphone for a voice command ([`crate::voice_command`]): the left slot (the ES8311's ADC) + /// at 24 kHz, without end, until [`Board::voice_stop`]. Err where the board can't record. + fn voice_start(&mut self) -> Result<(), ()> { + Err(()) + } + /// Appends the samples recorded since the last call, without waiting. + fn voice_take(&mut self, _samples: &mut Vec) {} + fn voice_stop(&mut self) {} + /// Writes one line of an audio stream (`VOICE DATA`) to the links that can carry one, and drops it whole + /// where it doesn't fit right now rather than wait: a gap in `seq` is what the computer expects then. + fn write_stream(&mut self, line: &[u8]) { + self.write(line); + } } /// The codec's registers through the board's I2C, for register sequences in core. @@ -224,6 +239,10 @@ pub struct Firmware { reported_hour: i32, reported_look: alloc::string::String, mic: MicState, + /// Voice commands: whether the computer offers them, the turn and its overlay. + voice: VoiceCommand, + /// The microphone is running for a voice command. + voice_mic: bool, } /// Borrows the Firmware's pomodoro and leisure director to build the context for one frame. @@ -336,6 +355,8 @@ impl Firmware { reported_hour: -1, reported_look: alloc::string::String::from("ROBOT"), mic: MicState::Idle, + voice: VoiceCommand::new(), + voice_mic: false, } } @@ -637,9 +658,10 @@ impl Firmware { self.continue_voice_begin(board); self.send_snapshot(board, now); self.tend_mic(board, now); + self.tend_voice(board, now); if self.menu.expire(now) { Self::write_literal(board, "MENU CLOSED\n"); - self.show_menu(board); + self.show_overlay(board); } self.tick_buttons(board); let transition = self.pomodoro.tick(board.now_ms()); @@ -671,7 +693,7 @@ impl Firmware { /// `device.mic.record`: report what answers on I2C where a microphone ADC could be, set the ES8311's /// microphone gain if there is one, and start capturing; `tend_mic` reports and sends the result. fn start_mic(&mut self, board: &mut B, fields: &BTreeMap) { - if !matches!(self.mic, MicState::Idle) { + if !matches!(self.mic, MicState::Idle) || self.voice_mic { return Self::write_literal(board, "MIC BUSY\n"); } let ms = number(fields, "ms").map_or(mic::DEFAULT_MS, |ms| (ms.max(0.0) as u32).clamp(mic::MIN_MS, mic::MAX_MS)); @@ -747,6 +769,10 @@ impl Firmware { } fn set_link_lost(&mut self, board: &mut B, lost: bool) { + if lost && !self.link_lost { + // Whoever offered voice is gone; the next link offers it again if it can. + self.set_voice_available(board, false); + } self.link_lost = lost; let now = board.now_ms(); self.display.set_link_lost(board, &scene!(self, now), lost); @@ -849,6 +875,23 @@ impl Firmware { /// its own job: leisure hides nothing that needs a look first. fn on_button(&mut self, board: &mut B, event: ButtonEvent) { let now = board.now_ms(); + // Letting go of K0 after a long press only ends a recording. + if event == ButtonEvent::K0LongEnd { + if self.voice.listening() { + self.end_recording(board, now); + } + return; + } + // The voice command's overlay: while recording only K2 (cancel) counts; afterwards K2 closes it, a + // long K0 starts the next command, and any other key closes it and then does its own job. + if self.voice.is_open() { + match event { + ButtonEvent::K2Short => return self.close_voice(board), + _ if self.voice.listening() => return, + ButtonEvent::K0Long if self.voice.available() => {} + _ => self.close_voice(board), + } + } if self.menu.is_open() { self.leisure.note_activity(now); let action = self.menu.key(event, self.menu_context(now), now); @@ -878,14 +921,20 @@ impl Firmware { ButtonEvent::K1Long => { self.menu.open(self.menu_context(now), now); Self::write_literal(board, "MENU OPEN\n"); - self.show_menu(board); + self.show_overlay(board); return; } - ButtonEvent::K0Short | ButtonEvent::K0Long => {} + ButtonEvent::K0Short | ButtonEvent::K0Long | ButtonEvent::K0LongEnd => {} } + // While the computer offers voice commands, a long K0 is push-to-talk; giving up a phase is then in + // the menu (ADR-0017). if event == ButtonEvent::K0Long { - self.stop_phase(board); + if self.voice.available() { + self.start_recording(board, now); + } else { + self.stop_phase(board); + } return; } let before = self.pomodoro.view(now); @@ -954,7 +1003,7 @@ impl Firmware { MenuAction::Close => Self::write_literal(board, "MENU CLOSED\n"), MenuAction::StopPhase => { Self::write_literal(board, "MENU CLOSED\n"); - self.show_menu(board); + self.show_overlay(board); self.stop_phase(board); } // The preview goes through announce, so it is silent when muted, like the app's. @@ -966,29 +1015,130 @@ impl Firmware { MenuAction::ForgetComputers => self.forget_computers(board), MenuAction::NewIdentity => { Self::write_literal(board, "MENU CLOSED\n"); - self.show_menu(board); + self.show_overlay(board); return self.new_identity(board); } } - self.show_menu(board); + self.show_overlay(board); } - /// Something that needs the user takes the screen back from the menu. - fn close_menu(&mut self, board: &mut B) { + /// Something that needs the user takes the screen back from the menu and from a voice command (a + /// recording is given up). + fn close_overlays(&mut self, board: &mut B) { if self.menu.is_open() { self.menu.close(); Self::write_literal(board, "MENU CLOSED\n"); - self.show_menu(board); + self.show_overlay(board); + } + if self.voice.is_open() { + self.close_voice(board); } } - /// Hands the display the panel for what the menu shows now, or none when it's closed. - fn show_menu(&mut self, board: &mut B) { + /// Hands the display the panel for what the menu shows now, or the voice command's overlay, or none. + fn show_overlay(&mut self, board: &mut B) { let now = board.now_ms(); - let panel = self.menu.view().map(|view| self.menu_panel(view, now)); + let panel = match self.menu.view() { + Some(view) => Some(self.menu_panel(view, now)), + None => self.voice.panel(now), + }; self.display.set_panel(board, &scene!(self, now), panel); } + fn write_voice(board: &mut B, out: impl IntoIterator) { + for out in out { + match out { + VoiceOut::Line(line) => board.write(line.as_bytes()), + VoiceOut::Data(line) => board.write_stream(line.as_bytes()), + } + } + } + + /// `voice.available`, a new link or a lost one. + fn set_voice_available(&mut self, board: &mut B, on: bool) { + let out = self.voice.set_available(on); + Self::write_voice(board, out); + self.stop_voice_mic(board); + self.show_overlay(board); + } + + /// K0 held while voice is offered: the microphone at voice gain, any sound stopped so the box doesn't + /// hear itself, and a new turn. + fn start_recording(&mut self, board: &mut B, now: u32) { + self.voice.cancel(); + self.stop_voice_mic(board); + // A box without the ES8311 (the NS4168 variant) has no gain to set; its microphone, if any, is untested. + let _ = mic::set_es8311_mic_gain(&mut BoardI2c { board: &mut *board, address: ES8311_ADDRESS }, voice_command::GAIN_DB); + let started = matches!(self.mic, MicState::Idle) && board.voice_start().is_ok(); + if started { + self.voice_mic = true; + board.stop_audio(); + let start = self.voice.start(now); + Self::write_voice(board, [start]); + } else { + self.voice.no_microphone(now); + } + self.show_overlay(board); + } + + fn take_voice_samples(&mut self, board: &mut B) { + let mut samples = Vec::new(); + board.voice_take(&mut samples); + let out = self.voice.push(&samples); + Self::write_voice(board, out); + } + + /// K0 let go, or the 30 s are up: the last samples, then `VOICE END` (or `VOICE CANCEL` if it was short). + fn end_recording(&mut self, board: &mut B, now: u32) { + self.take_voice_samples(board); + let out = self.voice.release(now); + Self::write_voice(board, out); + self.stop_voice_mic(board); + self.show_overlay(board); + } + + /// K2, or something that needs the user: the overlay closes, and a recording is given up. + fn close_voice(&mut self, board: &mut B) { + let out = self.voice.cancel(); + Self::write_voice(board, out); + self.stop_voice_mic(board); + self.show_overlay(board); + } + + fn stop_voice_mic(&mut self, board: &mut B) { + if self.voice_mic && !self.voice.listening() { + board.voice_stop(); + self.voice_mic = false; + } + } + + /// Each pass: stream what the microphone has, end the recording at 30 s, move the meter, and close the + /// overlay when its time is up. + fn tend_voice(&mut self, board: &mut B, now: u32) { + if self.voice.listening() { + self.take_voice_samples(board); + if self.voice.due(now) { + self.end_recording(board, now); + } + self.show_overlay(board); + } + if self.voice.expire(now) { + self.show_overlay(board); + } + self.stop_voice_mic(board); + } + + /// `voice.heard`, `voice.reply`, `voice.done` and `voice.error` for a turn. + fn on_voice_turn(&mut self, board: &mut B, fields: &BTreeMap, event: &str) { + let Some(turn) = number(fields, "turn").filter(|turn| *turn >= 0.0) else { return }; + let text = string(fields, if event == "voice.error" { "message" } else { "text" }); + let now = board.now_ms(); + if self.voice.event(event, turn as u32, text, string(fields, "reason"), now) { + self.stop_voice_mic(board); + self.show_overlay(board); + } + } + /// What the menu draws for a view. pub fn menu_panel(&self, view: View, now: u32) -> Panel { let line = |label: &[u8], value: &[u8], tone| (label.to_vec(), value.to_vec(), tone); @@ -1161,7 +1311,7 @@ impl Firmware { }; Self::report_pomodoro(board, if focus_ended { "FOCUS END" } else { "BREAK END" }); // The alarm waits for K0, which the menu would take. - self.close_menu(board); + self.close_overlays(board); if focus_ended { self.save_tally(board); } @@ -1184,7 +1334,7 @@ impl Firmware { fn tend_leisure(&mut self, board: &mut B) { let now = board.now_ms(); let pomodoro = self.pomodoro.view(now); - if !self.display.agent_idle() || pomodoro.run == Run::Running || self.link_lost || self.menu.is_open() { + if !self.display.agent_idle() || pomodoro.run == Run::Running || self.link_lost || self.menu.is_open() || self.voice.is_open() { self.leisure.note_activity(now); } @@ -1282,7 +1432,7 @@ impl Firmware { } if matches!(state, State::InputRequired | State::Failed) { - self.close_menu(board); + self.close_overlays(board); } let shown = self.display.show_tasks(board, &scene!(self, now), state, title.map(str::as_bytes), &tasks); if shown.is_err() { @@ -1394,7 +1544,11 @@ impl Firmware { // The Mac asks when it first connects: mode, firmware build and voice are only reported // at boot or on change, and the daemon restarts more often than the device, so without // asking it would never know. - "device.hello" => self.announce_state(board), + // A new link starts with voice commands off; the computer offers them after this if it can. + "device.hello" => { + self.set_voice_available(board, false); + self.announce_state(board); + } // The daily greeting or a welcome back: a line and nothing else. It isn't agent activity, // so it neither changes the screen nor wakes leisure, and only those may be said this way. "buddy.say" => { @@ -1419,11 +1573,14 @@ impl Firmware { None => self.announce_volume(board), } // The app's slider can move while the menu shows the volume. - self.show_menu(board); + self.show_overlay(board); if fields.get("preview") == Some(&Value::Bool(true)) { self.announce(board, Occasion::Done); } } + // Voice commands (docs/protocol.md): the computer offers them, then answers each turn. + "voice.available" => self.set_voice_available(board, fields.get("on") == Some(&Value::Bool(true))), + "voice.heard" | "voice.reply" | "voice.done" | "voice.error" => self.on_voice_turn(board, fields, event), _ if event.starts_with("voice.") => self.handle_voice_event(board, fields, event), // Pairing is the computer at the other end of the cable telling the box who it is (ADR-0012). // Only over the cable: whoever is at the box decides who may link and which network it joins. diff --git a/firmware-rs/core/src/lib.rs b/firmware-rs/core/src/lib.rs index 5aedbbad..d9cc952a 100644 --- a/firmware-rs/core/src/lib.rs +++ b/firmware-rs/core/src/lib.rs @@ -32,6 +32,7 @@ pub mod ota; pub mod pomodoro; pub mod relay_box; pub mod storage; +pub mod voice_command; pub mod voice_pack; pub mod voices; diff --git a/firmware-rs/core/src/voice_command.rs b/firmware-rs/core/src/voice_command.rs new file mode 100644 index 00000000..1816cf23 --- /dev/null +++ b/firmware-rs/core/src/voice_command.rs @@ -0,0 +1,736 @@ +//! Voice commands on the box (ADR-0017; the contract is docs/protocol.md, "Voice commands (experimental)"): +//! while the computer offers voice, holding K0 records, and the recording streams out as `VOICE` lines; the +//! computer answers with what it heard and the resident session's spoken replies, which the box shows on an +//! overlay. Like the device menu, the overlay is not a mode: the mode underneath keeps running. +//! +//! This module is the turn and the overlay, with no hardware: it takes the microphone's samples, encodes them, +//! frames the lines and says what the overlay shows. `firmware.rs` feeds it keys, samples and events, writes +//! its lines and starts and stops the microphone. + +use alloc::string::String; +use alloc::vec::Vec; + +use crate::adpcm::Encoder; +use crate::display::{Panel, PanelKind, Tone, VOICE_TEXT_LARGE, VOICE_TEXT_SMALL}; +use crate::mic::encode_base64; + +/// The microphone runs at the speaker's rate (docs/firmware-bringup.md, "Microphone"). +pub const SAMPLE_RATE: u32 = 24_000; +/// Samples per `VOICE DATA` line: 576 bytes of ADPCM, 768 base64 characters, the protocol's limit. A line +/// is 48 ms of audio, so about 21 lines a second, 16 KB/s of text. +pub const SAMPLES_PER_LINE: usize = 1152; +/// A recording stops on its own after this much audio. +pub const MAX_SAMPLES: u32 = SAMPLE_RATE * 30; +/// If the samples stop coming, the recording still ends this long after it started. +const MAX_RECORDING_MS: u32 = 31_000; +/// Shorter than this is a slip of the finger: the turn is cancelled. +pub const MIN_RECORDING_MS: u32 = 500; +/// The ES8311's microphone gain while recording a voice command. At the 30 dB the bring-up diagnostic +/// defaults to, a voice close to the box was only about -31 dBFS; 42 dB (the most the PGA has) brings it +/// to about -19. +pub const GAIN_DB: u32 = 42; +/// With nothing from the computer for this long after the recording, the overlay closes. +pub const QUIET_CLOSE_MS: u32 = 60_000; +/// After `voice.done`, the replies stay up this long to be read (or until K2). +pub const READ_MS: u32 = 15_000; +/// An error stays up this long. +pub const ERROR_MS: u32 = 6_000; +/// Segments in the level meter. +pub const METER_STEPS: u8 = 16; +/// The meter shows the level of each 100 ms. +const LEVEL_WINDOW: u32 = SAMPLE_RATE / 10; +/// The meter's range: silence at the bottom, a voice up close near the top. +const METER_FLOOR_DB: f32 = -60.0; +const METER_TOP_DB: f32 = -10.0; +/// Replies kept for the overlay; older ones scroll away anyway. +const MAX_REPLIES: usize = 8; + +/// A line for the computer. Data lines may only go where an audio stream fits (docs/protocol.md): the +/// device layer drops them, whole, rather than hold up the main loop. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Out { + Line(String), + Data(String), +} + +/// Why a turn ended badly, from `voice.error`'s `reason`, or the box's own. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Reason { + NotHeard, + Unavailable, + Busy, + /// A reason this firmware doesn't know. + Other, + /// The box couldn't start its microphone. + NoMicrophone, +} + +impl Reason { + fn from_name(name: &str) -> Self { + match name { + "not_heard" => Self::NotHeard, + "unavailable" => Self::Unavailable, + "busy" => Self::Busy, + _ => Self::Other, + } + } + + fn title(self) -> &'static [u8] { + match self { + Self::NotHeard => b"NOT HEARD", + Self::Unavailable => b"UNAVAILABLE", + Self::Busy => b"BUSY", + Self::Other => b"VOICE ERROR", + Self::NoMicrophone => b"NO MICROPHONE", + } + } + + fn text(self) -> &'static str { + match self { + Self::NotHeard => "Didn't catch that. Try again, a bit closer.", + Self::Unavailable => "Voice isn't working on the computer right now.", + Self::Busy => "Still on the last one.", + Self::Other => "Something went wrong.", + Self::NoMicrophone => "This box can't record.", + } + } +} + +/// The recording of one turn: the encoder, the line being filled and the level meter. +struct Recording { + turn: u32, + started: u32, + /// Samples encoded so far, including those in lines that were dropped on the way out. + samples: u32, + seq: u32, + encoder: Encoder, + /// The coder's state before the first nibble of the line being filled. + line_state: (i16, u8), + bytes: Vec, + /// A sample waiting for the next one: a byte holds two. + held: Option, + /// The meter window: sum and sum of squares (the mean is taken out, the ADC has a DC offset). + sum: i64, + squares: u64, + count: u32, + level: u8, +} + +impl Recording { + fn new(turn: u32, now: u32) -> Self { + Self { + turn, + started: now, + samples: 0, + seq: 0, + encoder: Encoder::default(), + line_state: (0, 0), + bytes: Vec::with_capacity(SAMPLES_PER_LINE / 2), + held: None, + sum: 0, + squares: 0, + count: 0, + level: 0, + } + } + + fn full(&self) -> bool { + self.samples >= MAX_SAMPLES + } + + fn push(&mut self, samples: &[i16], out: &mut Vec) { + for &sample in samples { + self.measure(sample); + if self.full() { + continue; + } + let Some(first) = self.held.take() else { + self.held = Some(sample); + continue; + }; + if self.bytes.is_empty() { + self.line_state = self.encoder.state(); + } + self.bytes.push(self.encoder.encode_pair(first, sample)); + self.samples += 2; + if self.bytes.len() * 2 == SAMPLES_PER_LINE { + out.push(self.data_line()); + } + } + } + + fn measure(&mut self, sample: i16) { + self.sum += sample as i64; + self.squares += (sample as i64 * sample as i64) as u64; + self.count += 1; + if self.count == LEVEL_WINDOW { + let mean = self.sum as f64 / self.count as f64; + let power = (self.squares as f64 / self.count as f64 - mean * mean).max(0.0); + self.level = meter_level(libm::sqrt(power) as f32); + (self.sum, self.squares, self.count) = (0, 0, 0); + } + } + + fn data_line(&mut self) -> Out { + let (predictor, index) = self.line_state; + let mut line = alloc::format!("VOICE DATA {} {} {} {} ", self.turn, self.seq, predictor, index); + line.push_str(&encode_base64(&self.bytes)); + line.push('\n'); + self.seq += 1; + self.bytes.clear(); + Out::Data(line) + } + + /// The last, shorter line and `VOICE END`. A sample left without a partner isn't sent. + fn finish(&mut self, out: &mut Vec) { + if !self.bytes.is_empty() { + out.push(self.data_line()); + } + out.push(Out::Line(alloc::format!("VOICE END {} {}\n", self.turn, self.samples))); + } +} + +/// An RMS in sample units to meter segments: -60 dBFS and below is none, -10 dBFS and above all. +pub fn meter_level(rms: f32) -> u8 { + if rms < 1.0 { + return 0; + } + let db = 20.0 * libm::log10f(rms / 32768.0); + let steps = (db - METER_FLOOR_DB) / (METER_TOP_DB - METER_FLOOR_DB) * METER_STEPS as f32; + (steps.max(0.0) as u8).min(METER_STEPS) +} + +enum Stage { + Idle, + Listening(Recording), + /// The recording went out; waiting for the computer. Quiet since the last thing it said. + Thinking { turn: u32, heard: Option, replies: Vec, quiet_since: u32 }, + /// `voice.done`: the replies stay up to be read. + Done { heard: Option, replies: Vec, until: u32 }, + Failed { reason: Reason, message: Option, until: u32 }, +} + +pub struct VoiceCommand { + available: bool, + /// The last turn's number; turns count from 1 after each boot. + turn: u32, + stage: Stage, +} + +impl Default for VoiceCommand { + fn default() -> Self { + Self::new() + } +} + +impl VoiceCommand { + pub const fn new() -> Self { + Self { available: false, turn: 0, stage: Stage::Idle } + } + + /// The computer offers voice: K0 long is push-to-talk. + pub fn available(&self) -> bool { + self.available + } + + /// `voice.available`, or a new link (which starts with voice off). Withdrawn mid-recording, the turn is + /// given up. + pub fn set_available(&mut self, on: bool) -> Option { + self.available = on; + if on { None } else { self.cancel() } + } + + pub fn listening(&self) -> bool { + matches!(self.stage, Stage::Listening(_)) + } + + /// The overlay is up. + pub fn is_open(&self) -> bool { + !matches!(self.stage, Stage::Idle) + } + + /// K0 held long enough, with the microphone running: a new turn. A turn still waiting for its replies is + /// left to the computer, which answers the new one `busy` if it must. + pub fn start(&mut self, now: u32) -> Out { + self.turn += 1; + self.stage = Stage::Listening(Recording::new(self.turn, now)); + Out::Line(alloc::format!("VOICE START {}\n", self.turn)) + } + + /// The microphone couldn't start: no turn, just the overlay saying so. + pub fn no_microphone(&mut self, now: u32) { + self.fail(Reason::NoMicrophone, None, now); + } + + /// The microphone's samples since the last call: `VOICE DATA` lines for each full line. Past 30 s of + /// audio they only move the meter; [`Self::due`] then ends the recording. + pub fn push(&mut self, samples: &[i16]) -> Vec { + let mut out = Vec::new(); + if let Stage::Listening(recording) = &mut self.stage { + recording.push(samples, &mut out); + } + out + } + + /// The recording has run its 30 s (or the samples stopped coming): end it as if K0 were let go. + pub fn due(&self, now: u32) -> bool { + match &self.stage { + Stage::Listening(recording) => recording.full() || now.wrapping_sub(recording.started) >= MAX_RECORDING_MS, + _ => false, + } + } + + /// K0 let go (or the 30 s are up): `VOICE END`, or `VOICE CANCEL` for a press under half a second, which + /// closes the overlay too. The caller has already handed over the last samples. + pub fn release(&mut self, now: u32) -> Vec { + let Stage::Listening(recording) = &mut self.stage else { return Vec::new() }; + let turn = recording.turn; + if now.wrapping_sub(recording.started) < MIN_RECORDING_MS { + self.stage = Stage::Idle; + return alloc::vec![Out::Line(alloc::format!("VOICE CANCEL {turn}\n"))]; + } + let mut out = Vec::new(); + recording.finish(&mut out); + self.stage = Stage::Thinking { turn, heard: None, replies: Vec::new(), quiet_since: now }; + out + } + + /// K2, or something that needs the user taking the screen: a recording is given up with `VOICE CANCEL`; + /// anything else just closes, and the rest of that turn's events are ignored. + pub fn cancel(&mut self) -> Option { + let stage = core::mem::replace(&mut self.stage, Stage::Idle); + match stage { + Stage::Listening(recording) => Some(Out::Line(alloc::format!("VOICE CANCEL {}\n", recording.turn))), + _ => None, + } + } + + /// The turn this event is about, if it is the one on the overlay. + fn current(&self, turn: u32) -> bool { + match &self.stage { + Stage::Listening(recording) => recording.turn == turn, + Stage::Thinking { turn: current, .. } => *current == turn, + _ => false, + } + } + + /// `voice.heard`, `voice.reply`, `voice.done` or `voice.error` from the computer. Events for any turn but + /// the one on the overlay are ignored. True when the overlay changed. + pub fn event(&mut self, event: &str, turn: u32, text: Option<&str>, reason: Option<&str>, now: u32) -> bool { + if !self.current(turn) { + return false; + } + match event { + "voice.error" => { + let message = text.filter(|text| !text.is_empty()).map(String::from); + self.fail(Reason::from_name(reason.unwrap_or("")), message, now); + true + } + // Only a recording that has ended can be heard or answered; the computer is ahead of itself. + _ if self.listening() => false, + "voice.done" => { + let Stage::Thinking { heard, replies, .. } = core::mem::replace(&mut self.stage, Stage::Idle) else { return false }; + // With nothing to read, there's nothing to wait for. + if heard.is_some() || !replies.is_empty() { + self.stage = Stage::Done { heard, replies, until: now.wrapping_add(READ_MS) }; + } + true + } + "voice.heard" | "voice.reply" => { + let Stage::Thinking { heard, replies, quiet_since, .. } = &mut self.stage else { return false }; + *quiet_since = now; + let Some(text) = text.filter(|text| !text.trim().is_empty()) else { return false }; + if event == "voice.heard" { + *heard = Some(String::from(text.trim())); + } else { + if replies.len() == MAX_REPLIES { + replies.remove(0); + } + replies.push(String::from(text.trim())); + } + true + } + _ => false, + } + } + + fn fail(&mut self, reason: Reason, message: Option, now: u32) { + self.stage = Stage::Failed { reason, message, until: now.wrapping_add(ERROR_MS) }; + } + + /// Closes the overlay once its time is up: an error or the replies read, or the computer quiet for a + /// minute. True if it just closed. + pub fn expire(&mut self, now: u32) -> bool { + let over = match &self.stage { + Stage::Thinking { quiet_since, .. } => now.wrapping_sub(*quiet_since) >= QUIET_CLOSE_MS, + Stage::Done { until, .. } | Stage::Failed { until, .. } => now.wrapping_sub(*until) as i32 >= 0, + Stage::Idle | Stage::Listening(_) => false, + }; + if over { + self.stage = Stage::Idle; + } + over + } + + /// What the overlay shows now; None when it is closed. + pub fn panel(&self, now: u32) -> Option { + let close = alloc::vec![(&b"K2"[..], &b"CLOSE"[..])]; + match &self.stage { + Stage::Idle => None, + Stage::Listening(recording) => { + let elapsed_s = now.wrapping_sub(recording.started) / 1000; + let left = crate::text!(16, "{} S LEFT", 30u32.saturating_sub(elapsed_s)); + Some(Panel { + kind: PanelKind::Meter(recording.level), + title: b"LISTENING".to_vec(), + lines: alloc::vec![(left.as_bytes().to_vec(), Vec::new(), Tone::Dim)], + selected: None, + hints: alloc::vec![(b"K0", b"LET GO"), (b"K2", b"CANCEL")], + }) + } + Stage::Thinking { heard, replies, .. } => { + let title: &[u8] = if replies.is_empty() { b"THINKING" } else { b"REPLY" }; + Some(text_panel(title, heard.as_deref(), replies, close)) + } + Stage::Done { heard, replies, .. } => Some(text_panel(b"REPLY", heard.as_deref(), replies, close)), + Stage::Failed { reason, message, .. } => { + let text = [String::from(message.as_deref().unwrap_or(reason.text()))]; + Some(text_panel(reason.title(), None, &text, close)) + } + } + } +} + +/// The heard text dimmed, then the replies, wrapped to the panel: large type while it fits, small type +/// after that, and past what small type holds, the oldest rows give way. +fn text_panel(title: &[u8], heard: Option<&str>, replies: &[String], hints: Vec<(&'static [u8], &'static [u8])>) -> Panel { + let layout = |(columns, rows): (usize, usize)| { + let mut lines: Vec<(Vec, Vec, Tone)> = Vec::new(); + if let Some(heard) = heard { + lines.extend(wrap(heard, columns).into_iter().map(|row| (row, Vec::new(), Tone::Dim))); + } + for reply in replies { + lines.extend(wrap(reply, columns).into_iter().map(|row| (row, Vec::new(), Tone::Plain))); + } + let fits = lines.len() <= rows; + if !fits { + lines.drain(..lines.len() - rows); + } + (lines, fits) + }; + let (lines, scale) = match layout(VOICE_TEXT_LARGE) { + (lines, true) => (lines, 2), + _ => (layout(VOICE_TEXT_SMALL).0, 1), + }; + Panel { kind: PanelKind::Text(scale), title: title.to_vec(), lines, selected: None, hints } +} + +/// What the 5x7 font can draw: ASCII as it is, and one placeholder glyph for each other character (the +/// font has no CJK, so Chinese shows as a row of placeholders). +fn drawable(text: &str) -> Vec { + text.chars() + .map(|character| match character { + ' '..='~' => character as u8, + '\n' | '\t' => b' ', + '\u{2018}' | '\u{2019}' => b'\'', + '\u{201C}' | '\u{201D}' => b'"', + _ => 0x7F, + }) + .collect() +} + +/// Word wrap to `columns`; a word longer than a row is cut. +pub fn wrap(text: &str, columns: usize) -> Vec> { + let mut rows: Vec> = Vec::new(); + let mut row: Vec = Vec::new(); + for word in drawable(text).split(|&byte| byte == b' ').filter(|word| !word.is_empty()) { + let mut word = word; + if !row.is_empty() && row.len() + 1 + word.len() > columns { + rows.push(core::mem::take(&mut row)); + } + while word.len() > columns { + if !row.is_empty() { + rows.push(core::mem::take(&mut row)); + } + rows.push(word[..columns].to_vec()); + word = &word[columns..]; + } + if word.is_empty() { + continue; + } + if !row.is_empty() { + row.push(b' '); + } + row.extend_from_slice(word); + } + if !row.is_empty() { + rows.push(row); + } + rows +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adpcm::Decoder; + use crate::voice_pack::decode_base64; + use std::string::ToString; + + fn lines(out: &[Out]) -> Vec { + out.iter() + .map(|out| match out { + Out::Line(line) | Out::Data(line) => line.clone(), + }) + .collect() + } + + fn tone(level: i16, count: usize) -> Vec { + (0..count).map(|index| if index % 2 == 0 { level } else { -level }).collect() + } + + #[test] + fn a_turn_streams_numbered_lines_and_ends_with_the_sample_count() { + let mut voice = VoiceCommand::new(); + voice.set_available(true); + assert_eq!(voice.start(1000), Out::Line("VOICE START 1\n".into())); + // 2.5 lines' worth arrives in uneven pieces. + let samples = tone(3000, SAMPLES_PER_LINE * 5 / 2); + let mut out = Vec::new(); + for piece in samples.chunks(700) { + out.extend(voice.push(piece)); + } + assert_eq!(out.len(), 2); + assert!(matches!(out[0], Out::Data(_))); + out.extend(voice.release(2000)); + let lines = lines(&out); + assert_eq!(lines.len(), 4); + for (seq, line) in lines[..3].iter().enumerate() { + assert!(line.starts_with(&std::format!("VOICE DATA 1 {seq} ")), "{line}"); + assert!(line.len() < 1024 && line.ends_with('\n')); + } + assert_eq!(lines[3], "VOICE END 1 2880\n"); + assert!(!voice.listening()); + assert!(voice.is_open(), "the overlay waits for the computer"); + } + + #[test] + fn a_line_holds_768_base64_characters_and_decodes_on_its_own() { + let mut voice = VoiceCommand::new(); + voice.start(0); + let samples: Vec = (0..SAMPLES_PER_LINE * 3).map(|index| (libm::sinf(index as f32 * 0.04) * 8000.0) as i16).collect(); + let out = lines(&voice.push(&samples)); + assert_eq!(out.len(), 3); + let mut decoded_lines = Vec::new(); + for line in &out { + let fields: Vec<&str> = line.trim_end().split(' ').collect(); + assert_eq!(fields.len(), 7, "VOICE DATA turn seq predictor index base64"); + assert_eq!(fields[6].len(), 768); + let (predictor, index): (i16, u8) = (fields[4].parse().unwrap(), fields[5].parse().unwrap()); + let mut bytes = [0u8; 576]; + assert_eq!(decode_base64(fields[6].as_bytes(), &mut bytes), Some(576)); + let mut decoder = Decoder::with_state(predictor, index); + decoded_lines.push(bytes.iter().flat_map(|&byte| [byte & 15, byte >> 4]).map(|nibble| decoder.decode(nibble)).collect::>()); + } + // Each line decoded from its own header continues the previous exactly: the third alone matches + // the third stretch of the stream decoded from the start. + let mut decoder = Decoder::default(); + let mut encoder = Encoder::default(); + let whole: Vec = samples + .chunks(2) + .flat_map(|pair| { + let byte = encoder.encode_pair(pair[0], pair[1]); + [decoder.decode(byte & 15), decoder.decode(byte >> 4)] + }) + .collect(); + assert_eq!(decoded_lines[2], whole[SAMPLES_PER_LINE * 2..]); + let longest = "VOICE DATA 4294967295 4294967295 -32768 88 ".len() + 768 + 1; + assert!(longest < 1024, "the longest possible line is {longest} bytes"); + } + + #[test] + fn under_half_a_second_is_cancelled_and_closes() { + let mut voice = VoiceCommand::new(); + voice.start(1000); + voice.push(&tone(100, 4000)); + assert_eq!(lines(&voice.release(1499)), ["VOICE CANCEL 1\n"]); + assert!(!voice.is_open()); + // Turns keep counting. + assert_eq!(voice.start(2000), Out::Line("VOICE START 2\n".into())); + assert_eq!(lines(&voice.release(2500))[0], "VOICE END 2 0\n"); + } + + #[test] + fn thirty_seconds_is_the_most_a_turn_records() { + let mut voice = VoiceCommand::new(); + voice.start(0); + let second = tone(500, SAMPLE_RATE as usize); + let mut data = 0; + for at in 1..=31 { + data += voice.push(&second).len(); + if voice.due(at * 1000) { + break; + } + } + assert!(voice.due(30_000)); + let out = lines(&voice.release(30_000)); + assert_eq!(out.last().unwrap(), &std::format!("VOICE END 1 {MAX_SAMPLES}\n")); + assert_eq!(data + out.len() - 1, (MAX_SAMPLES as usize).div_ceil(SAMPLES_PER_LINE)); + } + + #[test] + fn a_recording_whose_samples_stop_still_ends() { + let mut voice = VoiceCommand::new(); + voice.start(0); + assert!(!voice.due(30_999)); + assert!(voice.due(31_000)); + } + + #[test] + fn an_odd_sample_is_held_for_its_partner() { + let mut voice = VoiceCommand::new(); + voice.start(0); + voice.push(&[1]); + voice.push(&[2, 3]); + assert_eq!(lines(&voice.release(1000)).last().unwrap(), "VOICE END 1 2\n", "the third sample has no partner"); + } + + #[test] + fn withdrawing_voice_or_k2_gives_up_a_recording() { + let mut voice = VoiceCommand::new(); + voice.set_available(true); + voice.start(0); + assert_eq!(voice.set_available(false), Some(Out::Line("VOICE CANCEL 1\n".into()))); + assert!(!voice.available() && !voice.is_open()); + voice.set_available(true); + voice.start(0); + assert_eq!(voice.cancel(), Some(Out::Line("VOICE CANCEL 2\n".into()))); + voice.start(0); + voice.release(1000); + assert_eq!(voice.cancel(), None, "after the recording, closing says nothing"); + assert!(!voice.event("voice.reply", 3, Some("late"), None, 2000), "a closed turn's events are ignored"); + } + + #[test] + fn heard_then_replies_then_done_then_read_time() { + let mut voice = VoiceCommand::new(); + voice.start(0); + voice.release(1000); + assert_eq!(voice.panel(1000).unwrap().title, b"THINKING"); + assert!(!voice.event("voice.heard", 7, Some("other turn"), None, 1100)); + assert!(voice.event("voice.heard", 1, Some("what is codex doing"), None, 1200)); + assert!(voice.event("voice.reply", 1, Some("It's writing the tests."), None, 1300)); + assert!(voice.event("voice.reply", 1, Some("Almost done."), None, 1400)); + let panel = voice.panel(1400).unwrap(); + assert_eq!(panel.title, b"REPLY"); + assert_eq!(panel.kind, PanelKind::Text(2)); + let rows: Vec<(String, Tone)> = panel.lines.iter().map(|(row, _, tone)| (String::from_utf8(row.clone()).unwrap(), *tone)).collect(); + assert_eq!( + rows, + [ + ("what is codex doing".to_string(), Tone::Dim), + ("It's writing the".to_string(), Tone::Plain), + ("tests.".to_string(), Tone::Plain), + ("Almost done.".to_string(), Tone::Plain), + ] + ); + assert!(voice.event("voice.done", 1, None, None, 2000)); + assert!(!voice.expire(2000 + READ_MS - 1)); + assert_eq!(voice.panel(2000).unwrap().title, b"REPLY"); + assert!(voice.expire(2000 + READ_MS)); + assert!(voice.panel(2000 + READ_MS).is_none()); + } + + #[test] + fn done_with_nothing_to_read_closes_at_once() { + let mut voice = VoiceCommand::new(); + voice.start(0); + voice.release(1000); + assert!(voice.event("voice.done", 1, None, None, 1500)); + assert!(!voice.is_open()); + } + + #[test] + fn a_quiet_computer_closes_the_overlay_after_a_minute() { + let mut voice = VoiceCommand::new(); + voice.start(0); + voice.release(1000); + voice.event("voice.heard", 1, Some("hello"), None, 20_000); + assert!(!voice.expire(20_000 + QUIET_CLOSE_MS - 1), "each event restarts the wait"); + assert!(voice.expire(20_000 + QUIET_CLOSE_MS)); + } + + #[test] + fn errors_show_a_short_message_and_go() { + let mut voice = VoiceCommand::new(); + voice.start(0); + voice.release(1000); + assert!(voice.event("voice.error", 1, None, Some("not_heard"), 3000)); + let panel = voice.panel(3000).unwrap(); + assert_eq!(panel.title, b"NOT HEARD"); + assert_eq!(panel.lines[0].0, b"Didn't catch that."); + assert!(voice.expire(3000 + ERROR_MS)); + + // The computer's own message wins; and `busy` can come while still recording, which ends it. + voice.start(10_000); + assert!(voice.event("voice.error", 2, Some("Another turn is running"), Some("busy"), 10_100)); + assert!(!voice.listening()); + let panel = voice.panel(10_100).unwrap(); + assert_eq!((panel.title.as_slice(), panel.lines[0].0.as_slice()), (&b"BUSY"[..], &b"Another turn is"[..])); + assert!(!voice.event("voice.reply", 2, Some("x"), None, 10_200), "the turn is over"); + } + + #[test] + fn the_overlay_says_listening_with_a_meter_and_time_left() { + let mut voice = VoiceCommand::new(); + voice.start(1000); + assert_eq!(voice.panel(1000).unwrap().kind, PanelKind::Meter(0)); + // 100 ms of a loud tone on a DC offset: the offset doesn't count. + let loud: Vec = tone(8000, LEVEL_WINDOW as usize).iter().map(|sample| sample + 2000).collect(); + voice.push(&loud); + let panel = voice.panel(4500).unwrap(); + assert_eq!(panel.title, b"LISTENING"); + assert_eq!(panel.kind, PanelKind::Meter(meter_level(8000.0))); + assert_eq!(panel.lines[0].0, b"27 S LEFT"); + assert_eq!(panel.hints, [(&b"K0"[..], &b"LET GO"[..]), (&b"K2"[..], &b"CANCEL"[..])]); + } + + #[test] + fn the_meter_spans_silence_to_a_voice_up_close() { + assert_eq!(meter_level(0.0), 0); + assert_eq!(meter_level(30.0), 0, "-61 dBFS"); + // A close voice at 42 dB, about -19 dBFS, is high on the meter but not pinned. + let close = meter_level(32768.0 * libm::powf(10.0, -19.0 / 20.0)); + assert!((12..METER_STEPS).contains(&close), "{close}"); + assert_eq!(meter_level(32767.0), METER_STEPS); + } + + #[test] + fn long_text_moves_to_small_type_and_keeps_the_newest() { + let mut voice = VoiceCommand::new(); + voice.start(0); + voice.release(1000); + voice.event("voice.heard", 1, Some("tell me everything"), None, 1000); + let long = "word ".repeat(40); + voice.event("voice.reply", 1, Some(&long), None, 1000); + let panel = voice.panel(1000).unwrap(); + assert_eq!(panel.kind, PanelKind::Text(1)); + for _ in 0..5 { + voice.event("voice.reply", 1, Some(&long), None, 1000); + } + voice.event("voice.reply", 1, Some("the end"), None, 1000); + let panel = voice.panel(1000).unwrap(); + assert_eq!(panel.lines.len(), VOICE_TEXT_SMALL.1); + assert_eq!(panel.lines.last().unwrap().0, b"the end"); + } + + #[test] + fn wrapping_cuts_long_words_and_marks_what_the_font_lacks() { + assert_eq!(wrap("a bb ccc", 4), [b"a bb".to_vec(), b"ccc".to_vec()]); + assert_eq!(wrap("abcdefghij", 4), [b"abcd".to_vec(), b"efgh".to_vec(), b"ij".to_vec()]); + assert_eq!(wrap(" spaced\nout ", 20), [b"spaced out".to_vec()]); + // Chinese has no glyphs: one placeholder per character, not per byte. + assert_eq!(wrap("你好 ok", 10), [[0x7F, 0x7F, b' ', b'o', b'k'].to_vec()]); + assert_eq!(wrap("it\u{2019}s", 10), [b"it's".to_vec()]); + assert!(wrap("", 10).is_empty()); + } +} diff --git a/firmware-rs/core/tests/firmware.rs b/firmware-rs/core/tests/firmware.rs index 41e3ff02..0a597dd6 100644 --- a/firmware-rs/core/tests/firmware.rs +++ b/firmware-rs/core/tests/firmware.rs @@ -95,6 +95,10 @@ struct FakeBoard { /// The board has I2S input; and the size of the capture asked for, until it is taken. mic_input: bool, mic_capture: Option, + /// The voice command microphone is running; samples waiting to be taken; `VOICE DATA` lines written. + voice_running: bool, + voice_samples: Vec, + stream: Vec, } impl FakeBoard { @@ -124,6 +128,9 @@ impl FakeBoard { i2c_writes: Vec::new(), mic_input: true, mic_capture: None, + voice_running: false, + voice_samples: Vec::new(), + stream: Vec::new(), } } @@ -231,6 +238,21 @@ impl Board for FakeBoard { self.mic_capture = Some(bytes); Ok(()) } + fn voice_start(&mut self) -> Result<(), ()> { + self.mic_input.then_some(()).ok_or(())?; + self.voice_running = true; + Ok(()) + } + fn voice_take(&mut self, samples: &mut Vec) { + samples.append(&mut self.voice_samples); + } + fn voice_stop(&mut self) { + self.voice_running = false; + self.voice_samples.clear(); + } + fn write_stream(&mut self, line: &[u8]) { + self.stream.push(String::from_utf8(line.to_vec()).unwrap()); + } /// The left slot counts up, the right slot is silent. fn mic_take(&mut self) -> Option, &'static str>> { let bytes = self.mic_capture.take()?; @@ -1356,3 +1378,151 @@ fn a_mic_recording_is_clamped_and_reports_what_is_missing() { assert_eq!(lines.last().map(String::as_str), Some("MIC RECORDING 5000 MS")); assert_eq!(board.mic_capture, Some(480_000)); } + +const VOICE_ON: &str = r#"{"version":1,"event":"voice.available","on":true}"#; + +/// Holds K0 for `hold_ms`, with the microphone delivering 24 samples a millisecond while it runs; returns +/// the lines printed (the `VOICE DATA` lines are in `board.stream`). +fn hold_k0_talking(firmware: &mut Firmware, board: &mut FakeBoard, hold_ms: u32) -> Vec { + board.advance(100); + board.k0 = true; + firmware.poll(board); + let mut held = 0; + while held < hold_ms { + board.advance(10); + held += 10; + if board.voice_running { + board.voice_samples.extend((0..240).map(|index| if index % 2 == 0 { 4000 } else { -4000 })); + } + firmware.poll(board); + } + board.k0 = false; + firmware.poll(board); + board.take_lines() +} + +#[test] +fn without_voice_a_long_k0_still_gives_up_the_phase() { + let (mut firmware, mut board, _) = booted(blank_flash()); + assert_eq!(press(&mut firmware, &mut board, Key::K0, 50), ["POMODORO FOCUS START", "MODE POMODORO"]); + assert_eq!(press(&mut firmware, &mut board, Key::K0, 1100), ["POMODORO STOPPED"]); + assert!(!board.voice_running); + // Offered and then withdrawn, it's the same. + send(&mut firmware, &mut board, VOICE_ON); + send(&mut firmware, &mut board, r#"{"version":1,"event":"voice.available","on":false}"#); + press(&mut firmware, &mut board, Key::K0, 50); + assert_eq!(press(&mut firmware, &mut board, Key::K0, 1100), ["POMODORO STOPPED"]); +} + +#[test] +fn with_voice_a_long_k0_records_a_turn_and_the_phase_keeps_running() { + let (mut firmware, mut board, _) = booted(blank_flash()); + assert_eq!(press(&mut firmware, &mut board, Key::K0, 50), ["POMODORO FOCUS START", "MODE POMODORO"]); + assert!(send(&mut firmware, &mut board, VOICE_ON).is_empty()); + let lines = hold_k0_talking(&mut firmware, &mut board, 2000); + // Recording starts when the long press fires, a second in, and runs until K0 comes up. + assert_eq!(lines.first().map(String::as_str), Some("VOICE START 1")); + assert!(!lines.iter().any(|line| line.starts_with("POMODORO")), "{lines:?}"); + let end = lines.iter().find(|line| line.starts_with("VOICE END 1 ")).expect("VOICE END"); + let samples: usize = end.rsplit(' ').next().unwrap().parse().unwrap(); + assert!((22_000..=24_500).contains(&samples), "about a second at 24 kHz: {samples}"); + assert!(!board.voice_running, "the microphone stops with the recording"); + // Every data line went to the stream, numbered without gaps, the last one shorter. + assert_eq!(board.stream.len(), samples.div_ceil(1152)); + for (seq, line) in board.stream.iter().enumerate() { + assert!(line.starts_with(&format!("VOICE DATA 1 {seq} ")) && line.ends_with('\n') && line.len() < 1024, "{line}"); + } + // The microphone ran at voice gain: 42 dB is register 0x16 = 7. + assert!(board.i2c_writes.contains(&(0x18, 0x16, 7))); + // The overlay counts as the user being there. + assert_eq!(press(&mut firmware, &mut board, Key::K2, 50), Vec::::new(), "K2 closes the overlay"); + assert_eq!(press(&mut firmware, &mut board, Key::K0, 50), ["POMODORO PAUSED"], "the phase was never given up"); +} + +#[test] +fn a_tap_past_the_long_press_is_cancelled() { + let (mut firmware, mut board, _) = booted(blank_flash()); + send(&mut firmware, &mut board, VOICE_ON); + let lines = hold_k0_talking(&mut firmware, &mut board, 1300); + assert_eq!(lines, ["VOICE START 1", "VOICE CANCEL 1"]); + assert!(!board.voice_running); +} + +#[test] +fn the_computers_answers_fill_the_overlay_and_k2_closes_it() { + let (mut firmware, mut board, _) = booted(blank_flash()); + send(&mut firmware, &mut board, VOICE_ON); + hold_k0_talking(&mut firmware, &mut board, 1800); + let before = board.presents; + assert!(send(&mut firmware, &mut board, r#"{"version":1,"event":"voice.heard","turn":1,"text":"what is codex doing"}"#).is_empty()); + assert!(send(&mut firmware, &mut board, r#"{"version":1,"event":"voice.reply","turn":1,"text":"Writing tests."}"#).is_empty()); + assert!(board.presents >= before + 2, "each answer redraws the overlay"); + // Another turn's answers change nothing. + let before = board.presents; + send(&mut firmware, &mut board, r#"{"version":1,"event":"voice.reply","turn":9,"text":"nope"}"#); + assert_eq!(board.presents, before); + send(&mut firmware, &mut board, r#"{"version":1,"event":"voice.done","turn":1}"#); + // K2 closes it rather than asking the computer to open a source; the next K2 does that again. + assert!(press(&mut firmware, &mut board, Key::K2, 50).is_empty()); + assert_eq!(press(&mut firmware, &mut board, Key::K2, 50), [r#"{"version":1,"event":"button","button":"K2","action":"press"}"#]); +} + +#[test] +fn a_needs_input_event_takes_the_screen_and_gives_up_the_recording() { + let (mut firmware, mut board, _) = booted(blank_flash()); + send(&mut firmware, &mut board, VOICE_ON); + board.advance(100); + board.k0 = true; + firmware.poll(&mut board); + board.advance(1100); + firmware.poll(&mut board); + assert_eq!(board.take_lines(), ["VOICE START 1"]); + assert!(board.voice_running); + let lines = send(&mut firmware, &mut board, r#"{"version":1,"event":"agent.input_required","title":"CC:ASK"}"#); + assert!(lines.contains(&"VOICE CANCEL 1".to_owned()), "{lines:?}"); + assert!(!board.voice_running); + board.advance(500); + board.k0 = false; + firmware.poll(&mut board); + assert!(board.take_lines().is_empty(), "letting go afterwards says nothing"); +} + +#[test] +fn a_new_link_starts_with_voice_off() { + let (mut firmware, mut board, _) = booted(blank_flash()); + send(&mut firmware, &mut board, VOICE_ON); + send(&mut firmware, &mut board, r#"{"version":1,"event":"device.hello"}"#); + press(&mut firmware, &mut board, Key::K0, 50); + assert_eq!(press(&mut firmware, &mut board, Key::K0, 1100), ["POMODORO STOPPED"]); + // A link that goes quiet takes voice with it too. + send(&mut firmware, &mut board, VOICE_ON); + board.advance(16_000); + firmware.poll(&mut board); + board.take_lines(); + assert!(hold_k0_talking(&mut firmware, &mut board, 1500).iter().all(|line| !line.starts_with("VOICE"))); +} + +#[test] +fn a_box_that_cannot_record_says_so_and_sends_nothing() { + let (mut firmware, mut board, _) = booted(blank_flash()); + board.mic_input = false; + send(&mut firmware, &mut board, VOICE_ON); + let before = board.presents; + let lines = hold_k0_talking(&mut firmware, &mut board, 1500); + assert!(lines.iter().all(|line| !line.starts_with("VOICE") && !line.starts_with("POMODORO")), "{lines:?}"); + assert!(board.presents > before, "the overlay says there's no microphone"); +} + +#[test] +fn the_diagnostic_and_a_voice_command_share_the_microphone() { + let (mut firmware, mut board, _) = booted(blank_flash()); + send(&mut firmware, &mut board, VOICE_ON); + board.advance(100); + board.k0 = true; + firmware.poll(&mut board); + board.advance(1100); + firmware.poll(&mut board); + board.take_lines(); + let lines = send(&mut firmware, &mut board, r#"{"version":1,"event":"device.mic.record"}"#); + assert_eq!(lines, ["MIC BUSY"]); +} diff --git a/firmware-rs/device/src/main.rs b/firmware-rs/device/src/main.rs index 36d58aec..d71ae86d 100644 --- a/firmware-rs/device/src/main.rs +++ b/firmware-rs/device/src/main.rs @@ -8,7 +8,8 @@ //! - XL9555 P0: bit7 LCD backlight, bit5 amplifier enable, bit4 K1, bit3 K2 (keys active low). //! - K0: BOOT key, GPIO0, active low. //! - I2S0: BCLK GPIO21, WS GPIO13, DOUT GPIO14, no MCLK. DIN GPIO47 (the ES8311's ADC, left slot; -//! verified on one box, docs/hardware.md; only read during the `device.mic.record` diagnostic). +//! verified on one box, docs/hardware.md; read while a voice command records, and by the +//! `device.mic.record` diagnostic). //! - UART0: TX GPIO43, RX GPIO44, to the CH343 bridge; the chip's own USB Serial/JTAG as well. #![no_std] #![no_main] @@ -151,6 +152,22 @@ struct DeviceBoard { } impl DeviceBoard { + /// To the computer linked over the network: a line with no room is dropped whole, before it is sealed + /// (noise::SealedLines). + fn write_lan(&mut self, bytes: &[u8]) { + if let Some((conn, lines)) = &mut self.lan_peer { + let conn = *conn; + lines.lines( + bytes, + |length| net::room(conn) > length, + |line| { + net::send(conn, line.as_bytes()); + net::send(conn, b"\n"); + }, + ); + } + } + fn expander(&mut self) -> Expander<'_> { Expander { i2c: &mut self.i2c } } @@ -181,18 +198,7 @@ impl Board for DeviceBoard { fn write(&mut self, bytes: &[u8]) { self.transport.write(bytes); - // A line with no room is dropped whole, before it is sealed (noise::SealedLines). - if let Some((conn, lines)) = &mut self.lan_peer { - let conn = *conn; - lines.lines( - bytes, - |length| net::room(conn) > length, - |line| { - net::send(conn, line.as_bytes()); - net::send(conn, b"\n"); - }, - ); - } + self.write_lan(bytes); if let Some(lines) = &mut self.relay_peer { lines.lines( bytes, @@ -204,6 +210,14 @@ impl Board for DeviceBoard { } } + /// `VOICE DATA`, 16 KB/s of it: USB and the local network only. The UART carries 11.5 KB/s at 115200 + /// baud and its writes block, so the stream would stall the main loop there; the Relay's 50 messages a + /// second are for the box's other output (a voice command over the Relay is ADR-0018's). + fn write_stream(&mut self, line: &[u8]) { + self.transport.write_usb(line); + self.write_lan(line); + } + fn lan_send(&mut self, conn: u32, bytes: &[u8]) { net::send(conn, bytes); } @@ -328,7 +342,7 @@ impl Board for DeviceBoard { fn mic_start(&mut self, bytes: usize) -> Result<(), ()> { mic::DONE.reset(); - mic::START.signal(bytes); + mic::COMMAND.signal(mic::Command::Record(bytes)); Ok(()) } @@ -339,6 +353,19 @@ impl Board for DeviceBoard { } mic::DONE.try_take() } + + fn voice_start(&mut self) -> Result<(), ()> { + mic::start_stream(); + Ok(()) + } + + fn voice_take(&mut self, samples: &mut alloc::vec::Vec) { + mic::take_samples(samples); + } + + fn voice_stop(&mut self) { + mic::stop_stream(); + } } #[allow(clippy::large_stack_frames, reason = "main has to hold a lot of peripherals and buffers anyway")] @@ -404,7 +431,8 @@ async fn main(spawner: Spawner) -> ! { // Start the I2S stream first (all zeros): as in the C firmware, the clock runs before the codec is // configured. The receiver shares the transmitter's BCLK and WS (signal loopback), as ESP-IDF sets up a - // full-duplex channel pair on the same pins; it only runs during the microphone diagnostic. + // full-duplex channel pair on the same pins; it only runs while a voice command records and during the + // microphone diagnostic. let i2s = I2s::new( peripherals.I2S0, peripherals.DMA_CH1, @@ -540,6 +568,11 @@ async fn main(spawner: Spawner) -> ! { net::CREDENTIALS.signal(wifi_credentials(&firmware)); } firmware.poll(&mut board); + // The microphone's own report on a stream that just ended. + while let Ok(note) = mic::NOTES.try_receive() { + board.write(note.as_bytes()); + board.write(b"\n"); + } // Before restarting, close the Relay socket and the network connections so the computers and the // Relay see the box go at once, rather than after their silence timeouts; a new identity has a new // box-id, so nothing would replace the old one's socket (#122). The wait also lets the last line out. diff --git a/firmware-rs/device/src/mic.rs b/firmware-rs/device/src/mic.rs index 05558d74..dcf0803c 100644 --- a/firmware-rs/device/src/mic.rs +++ b/firmware-rs/device/src/mic.rs @@ -1,6 +1,11 @@ -//! Microphone capture for the bring-up diagnostic (`device.mic.record`, firmware-core's `mic`): I2S0's -//! receiver, clocked by the transmitter's BCLK and WS (signal loopback, as ESP-IDF does for full duplex), -//! reads GPIO47 by DMA straight into one linear buffer in PSRAM, as long as the whole capture. +//! Microphone capture: I2S0's receiver, clocked by the transmitter's BCLK and WS (signal loopback, as ESP-IDF +//! does for full duplex), reads GPIO47 by DMA. Two users: +//! +//! - The bring-up diagnostic (`device.mic.record`, firmware-core's `mic`) captures into one linear buffer +//! in PSRAM, as long as the whole capture. +//! - A voice command (firmware-core's `voice_command`) streams for as long as K0 is held, up to 30 s: a +//! linear buffer for that would be 7 MB, so the DMA writes round a ring instead ([`Ring`], below), and +//! this task moves the left slot's samples out of it into a queue the main loop takes from. //! //! Nothing here is a static in internal RAM beyond the task itself: every such static comes out of the //! main task's stack (LESSONS.md). The descriptors (12 bytes per 4 KB) come from the internal heap for the @@ -24,19 +29,23 @@ //! lines 100 ms in and at the end: the descriptors and bytes so far, the first descriptor lengths, the I2S //! and DMA registers, and how often the data pin changed level. +use alloc::collections::VecDeque; use alloc::string::String; use alloc::vec::Vec; use core::alloc::{GlobalAlloc, Layout}; +use core::cell::RefCell; use core::fmt::Write; +use critical_section::Mutex; + use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::Channel; use embassy_sync::signal::Signal; use embassy_time::{Instant, Timer}; use esp_alloc::{HEAP, MemoryCapability}; use esp_hal::Async; -use esp_hal::dma::aligned::DmaAlignedMut; -use esp_hal::dma::{DmaDescriptor, DmaRxBuf}; +use esp_hal::dma::aligned::{DmaAlignedMut, DmaAlignedRef}; +use esp_hal::dma::{BurstConfig, DmaDescriptor, DmaRxBuf, DmaRxBuffer, Owner, Preparation}; use esp_hal::i2s::master::I2sRx; /// PSRAM receive buffers must be aligned to the external burst size (16 bytes by default); 64 covers any. @@ -46,8 +55,28 @@ const DESCRIPTOR_BYTES: usize = 4096 - ALIGN; /// The capture takes its own length plus this long before it counts as stalled. const SLACK_MS: u64 = 1000; -/// How many bytes to capture. -pub static START: Signal = Signal::new(); +/// Descriptors in the voice command's ring: 520 KB of PSRAM. An EOF every 4094 bytes takes two descriptors +/// (one full, one cut short), so the ring holds 64 × 4094 bytes, 2.7 s of audio, before the DMA comes round +/// to what this task hasn't read yet. +const RING_DESCRIPTORS: usize = 128; +/// Samples the queue to the main loop holds (2 s); past that, new ones are dropped and counted. +const QUEUE_SAMPLES: usize = 48_000; +/// How often the streaming task looks at the ring. +const STREAM_POLL_MS: u64 = 10; + +pub enum Command { + /// Capture this many bytes for the diagnostic. + Record(usize), + /// Stream the left slot into [`SAMPLES`] until [`Command::Stop`]. While streaming, a second `Stream` + /// just empties the queue for the new turn. + Stream, + Stop, +} + +pub static COMMAND: Signal = Signal::new(); +/// The left slot's samples while streaming, for the main loop. Its storage is on the heap (PSRAM), taken +/// when a stream starts and given back when it stops; the static itself is a few words. +static SAMPLES: Mutex>> = Mutex::new(RefCell::new(VecDeque::new())); /// The capture, or why it stopped short. pub static DONE: Signal, &'static str>> = Signal::new(); /// `MIC DEBUG` lines for the main loop to print. @@ -158,13 +187,219 @@ fn note(stage: &str, first: *const DmaDescriptor, linked: usize) { #[embassy_executor::task] pub async fn mic_task(mut rx: I2sRx<'static, Async>) { loop { - let wanted = START.wait().await; - let (result, idle) = capture(rx, wanted).await; - rx = idle; - DONE.signal(result); + match COMMAND.wait().await { + Command::Record(wanted) => { + let (result, idle) = capture(rx, wanted).await; + rx = idle; + DONE.signal(result); + } + Command::Stream => rx = stream(rx).await, + Command::Stop => {} + } + } +} + +/// Starts streaming for a voice command; an empty queue either way. +pub fn start_stream() { + critical_section::with(|cs| SAMPLES.borrow_ref_mut(cs).clear()); + COMMAND.signal(Command::Stream); +} + +/// Stops streaming and gives the queue's memory back. +pub fn stop_stream() { + COMMAND.signal(Command::Stop); + critical_section::with(|cs| *SAMPLES.borrow_ref_mut(cs) = VecDeque::new()); +} + +/// Moves the samples streamed so far to `out`. +pub fn take_samples(out: &mut Vec) { + critical_section::with(|cs| out.extend(SAMPLES.borrow_ref_mut(cs).drain(..))); +} + +/// The voice command's DMA ring: descriptors in internal RAM linked in a circle, each over its own +/// `DESCRIPTOR_BYTES` of one PSRAM block. The DMA never reaches an end, so it never stops however late +/// this task is (a linear chain the main loop had to relink stalled for good, LESSONS.md); owner checks +/// are off, so if the task falls a whole ring behind, the DMA writes over what wasn't read rather than +/// stopping, and [`Reader`] counts it. +struct Ring { + descriptors: Block, + data: Block, +} + +impl Ring { + fn new() -> Option { + let descriptors = Block::new(MemoryCapability::Internal, RING_DESCRIPTORS * size_of::())?; + let data = Block::new(MemoryCapability::External, RING_DESCRIPTORS * DESCRIPTOR_BYTES)?; + Some(Self { descriptors, data }) + } + + fn descriptor(&self, index: usize) -> *mut DmaDescriptor { + // SAFETY: within the block, which holds RING_DESCRIPTORS descriptors. + unsafe { (self.descriptors.pointer as *mut DmaDescriptor).add(index) } + } +} + +// SAFETY: both blocks live as long as the ring, which the transfer holds until it is stopped; the +// descriptors point only into the data block and at each other. +unsafe impl DmaRxBuffer for Ring { + type View = Ring; + type Final = Ring; + + fn prepare(&mut self) -> Preparation { + for index in 0..RING_DESCRIPTORS { + let next = self.descriptor((index + 1) % RING_DESCRIPTORS); + // SAFETY: ours, and the DMA hasn't started. + let descriptor = unsafe { &mut *self.descriptor(index) }; + // SAFETY: within the data block. + descriptor.buffer = unsafe { self.data.pointer.add(index * DESCRIPTOR_BYTES) }; + descriptor.set_size(DESCRIPTOR_BYTES); + descriptor.reset_for_rx(); + descriptor.next = next; + } + // What the CPU wrote while zeroing must not be written back over what the DMA writes. + // SAFETY: the block is ours, 64-byte aligned and a multiple of 64 bytes, so no other data shares + // its cache lines. + unsafe { DmaAlignedRef::new_unchecked(core::slice::from_raw_parts(self.data.pointer, RING_DESCRIPTORS * DESCRIPTOR_BYTES)) }.invalidate(); + Preparation { + start: self.descriptor(0), + accesses_psram: true, + burst_transfer: BurstConfig::default(), + check_owner: Some(false), + auto_write_back: true, + } + } + + fn into_view(self) -> Ring { + self + } + + fn from_view(view: Ring) -> Ring { + view } } +/// Reads the ring in order: each descriptor the DMA has handed back (owner CPU) holds `length` bytes of the +/// interleaved stream, which runs on across descriptors (frames don't line up with the 4094-byte EOFs). +/// The left slot is bytes 0 and 1 of each 4-byte frame. +struct Reader { + next: usize, + /// Position in the current frame, and the low byte of a left sample split across descriptors. + offset: usize, + low: u8, + samples: u32, + dropped: u32, + /// The most descriptors found waiting at one look; near the ring's size means the DMA lapped us. + backlog: usize, +} + +impl Reader { + fn new() -> Self { + Self { next: 0, offset: 0, low: 0, samples: 0, dropped: 0, backlog: 0 } + } + + /// `first` is the ring's first descriptor; the transfer holds the ring itself. + fn drain(&mut self, first: *mut DmaDescriptor) { + let mut found = 0; + let mut pending: Vec = Vec::new(); + for _ in 0..RING_DESCRIPTORS { + // SAFETY: within the ring's descriptors, which live until the transfer is stopped. + let pointer = unsafe { first.add(self.next) }; + // SAFETY: the DMA writes descriptors; read them volatile. Internal RAM isn't cached on the S3. + let descriptor = unsafe { core::ptr::read_volatile(pointer) }; + if descriptor.owner() == Owner::Dma { + break; + } + found += 1; + let length = descriptor.len(); + // SAFETY: this descriptor's slice of the data block, which the DMA has finished with. + let bytes = unsafe { core::slice::from_raw_parts(descriptor.buffer as *const u8, length) }; + // The cache may hold what was there a lap ago. The slice is 64-byte aligned and whole lines + // are invalidated, all within this descriptor's DESCRIPTOR_BYTES. + // SAFETY: as above; nothing else uses these lines. + unsafe { DmaAlignedRef::new_unchecked(core::slice::from_raw_parts(descriptor.buffer as *const u8, DESCRIPTOR_BYTES)) }.invalidate(); + for &byte in bytes { + match self.offset { + 0 => self.low = byte, + 1 => pending.push(i16::from_le_bytes([self.low, byte])), + _ => {} + } + self.offset = (self.offset + 1) % 4; + } + let mut handed_back = descriptor; + handed_back.reset_for_rx(); + // SAFETY: as above. + unsafe { core::ptr::write_volatile(&raw mut (*pointer).flags, handed_back.flags) }; + self.next = (self.next + 1) % RING_DESCRIPTORS; + } + self.backlog = self.backlog.max(found); + if pending.is_empty() { + return; + } + self.samples += pending.len() as u32; + critical_section::with(|cs| { + let mut queue = SAMPLES.borrow_ref_mut(cs); + if queue.capacity() == 0 { + queue.reserve_exact(QUEUE_SAMPLES); + } + let room = QUEUE_SAMPLES.saturating_sub(queue.len()).min(pending.len()); + queue.extend(&pending[..room]); + self.dropped += (pending.len() - room) as u32; + }); + } +} + +/// Streams until told to stop, then reports `MIC STREAM MS SAMPLES DROPPED BACKLOG +/// OF `: about 24 samples a millisecond means nothing was lost on the +/// way in, and a backlog near the ring's size means the DMA lapped the reader. +async fn stream(rx: I2sRx<'static, Async>) -> I2sRx<'static, Async> { + let Some(ring) = Ring::new() else { + let _ = NOTES.try_send(String::from("MIC STREAM MEMORY")); + return rx; + }; + let first = ring.descriptor(0); + let transfer = match rx.read(ring) { + Ok(transfer) => transfer, + Err((_, rx, _)) => { + let _ = NOTES.try_send(String::from("MIC STREAM I2S")); + return rx; + } + }; + let started = Instant::now(); + let mut reader = Reader::new(); + loop { + Timer::after_millis(STREAM_POLL_MS).await; + match COMMAND.try_take() { + Some(Command::Stream) => { + critical_section::with(|cs| SAMPLES.borrow_ref_mut(cs).clear()); + reader.dropped = 0; + } + Some(Command::Stop) => break, + // The firmware doesn't start the diagnostic while a voice command records; if it did, it would + // run once the stream is over. + Some(Command::Record(bytes)) => { + COMMAND.signal(Command::Record(bytes)); + break; + } + None => {} + } + reader.drain(first); + } + let (rx, ring) = transfer.stop(); + drop(ring); + let mut line = String::new(); + let _ = write!( + line, + "MIC STREAM {} MS {} SAMPLES {} DROPPED BACKLOG {} OF {}", + started.elapsed().as_millis(), + reader.samples, + reader.dropped, + reader.backlog, + RING_DESCRIPTORS + ); + let _ = NOTES.try_send(line); + rx +} + async fn capture(rx: I2sRx<'static, Async>, wanted: usize) -> (Result, &'static str>, I2sRx<'static, Async>) { // Room for the descriptors an EOF cuts short (see the top of this file). let size = (wanted * 5 / 2 + 2 * DESCRIPTOR_BYTES).next_multiple_of(ALIGN); diff --git a/firmware-rs/device/src/serial.rs b/firmware-rs/device/src/serial.rs index 926cbc46..3c9f506e 100644 --- a/firmware-rs/device/src/serial.rs +++ b/firmware-rs/device/src/serial.rs @@ -151,7 +151,8 @@ impl Transport { } } - fn write_usb(&mut self, data: &[u8]) { + /// USB alone, for a stream the UART can't keep up with. + pub fn write_usb(&mut self, data: &[u8]) { for &byte in data { if self.usb.write_byte_nb(byte).is_ok() { self.usb_stalled = false; From 53d59dcca546be892faf692334d4582a253bd4e7 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 18:15:54 +0800 Subject: [PATCH 19/51] Keep voice off the UART bridge and refuse a busy turn at its start The box sends no VOICE DATA over the CH343 bridge, so voice.available is no longer sent there; the log says why voice stayed off (bridge or Relay). A turn that starts while another is still being answered gets voice.error busy right after VOICE START, as docs/protocol.md now allows, so the box stops recording; the rest of that turn is dropped. The voice tests now make their VOICE lines with firmware-core's own VoiceCommand, so they check the daemon against what the box writes: 1152-sample lines, an even END count that includes lost lines, and gaps filled to keep later audio in place. Co-Authored-By: Claude Opus 5.5 --- daemon/src/main.rs | 26 +++++-- daemon/src/voice.rs | 147 ++++++++++++++++++++------------------- docs/resident-session.md | 6 +- 3 files changed, 100 insertions(+), 79 deletions(-) diff --git a/daemon/src/main.rs b/daemon/src/main.rs index ad2bcdd0..4740bc06 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -397,12 +397,22 @@ async fn publish_device_message(state: &AppState, message: DeviceMessage) { } /// Voice commands (docs/protocol.md, "Voice commands"): offered on every report of the box's build, which follows -/// hello on each new link and every reboot, and taken over USB or the local network. Over the Relay the box will -/// talk to the voice service itself, so voice isn't offered there and its lines are ignored. +/// hello on each new link and every reboot, and taken over native USB or the local network. The box sends no audio +/// over the UART bridge, which is too slow for it, and over the Relay it will talk to the voice service itself, so +/// voice isn't offered on either, and voice lines over the Relay are ignored. async fn handle_voice_line(state: &AppState, line: &str) -> Option> { - let over_relay = state.device.lock().await.relay; - if line.contains(serial_transport::BUILD_MARKER) && !over_relay && state.voice.available() { - send_event(state, voice::available_event(true)); + let (over_relay, on_bridge) = { + let device = state.device.lock().await; + (device.relay, device.bridge) + }; + if line.contains(serial_transport::BUILD_MARKER) && state.voice.available() { + if over_relay { + info!("voice commands not offered: the box is reached through the Relay"); + } else if on_bridge { + info!("voice commands not offered: the box is on the UART bridge, which can't carry its audio; use its native USB port"); + } else { + send_event(state, voice::available_event(true)); + } } if !line.starts_with("VOICE ") { return None; @@ -1601,7 +1611,7 @@ mod tests { } #[tokio::test] - async fn voice_is_offered_after_each_build_report_but_never_over_the_relay() { + async fn voice_is_offered_after_each_build_report_but_never_over_the_relay_or_the_uart_bridge() { let dir = std::env::temp_dir().join(format!("vibebuddy-voice-offer-{}", std::process::id())); let offers = |transport: &RecordingTransport| { transport.events().iter().filter(|event| event.event == "voice.available").map(|event| event.extra["on"].clone()).collect::>() @@ -1626,6 +1636,10 @@ mod tests { publish_device_message(&state, DeviceMessage::ConnectedRelay { url: "wss://relay.example".to_owned() }).await; publish_device_message(&state, build()).await; assert_eq!(offers(&transport), expected, "not over the Relay"); + // The box sends no audio over the UART bridge. + publish_device_message(&state, DeviceMessage::Connected { port: "/dev/cu.wchusbserial1".to_owned(), bridge: true, usb_serial: None }).await; + publish_device_message(&state, build()).await; + assert_eq!(offers(&transport), expected, "not over the UART bridge"); } let _ = std::fs::remove_dir_all(dir); } diff --git a/daemon/src/voice.rs b/daemon/src/voice.rs index 1ac715c4..ad082ef3 100644 --- a/daemon/src/voice.rs +++ b/daemon/src/voice.rs @@ -62,36 +62,6 @@ impl Decoder { self.index = (self.index + INDEX_CHANGE[(nibble & 15) as usize]).clamp(0, 88); self.predictor as i16 } - - /// The nibble that brings the decoder closest to `sample`, applied: the inverse the box encodes with. - #[cfg(test)] - pub fn encode(&mut self, sample: i16) -> u8 { - let step = STEPS[self.index as usize]; - let mut diff = i32::from(sample) - self.predictor; - let mut nibble = 0u8; - if diff < 0 { - nibble = 8; - diff = -diff; - } - if diff >= step { - nibble |= 4; - diff -= step; - } - if diff >= step >> 1 { - nibble |= 2; - diff -= step >> 1; - } - if diff >= step >> 2 { - nibble |= 1; - } - self.decode(nibble); - nibble - } - - #[cfg(test)] - pub fn state(&self) -> (i16, u8) { - (self.predictor as i16, self.index as u8) - } } /// Two samples per byte, low nibble first. @@ -140,6 +110,8 @@ struct Recording { turn: u32, lines: BTreeMap>, samples: usize, + /// Answered `busy` at its start: the rest of it is dropped without another answer. + refused: bool, } /// The turn's audio, in order, `samples` long; a lost line becomes silence as long as a line, and audio lost at the @@ -290,16 +262,26 @@ impl Voice { if let Some(old) = recording.as_ref().filter(|old| old.turn != turn) { warn!(old = old.turn, new = turn, "a voice turn started before the last one ended; dropped the last one"); } - info!(turn, "voice turn recording"); - *recording = Some(Recording { turn, lines: BTreeMap::new(), samples: 0 }); + // Refused at once rather than at its end, so the user isn't left talking to a box that won't listen. + let refused = self.running.load(Ordering::Acquire); + if refused { + info!(turn, "a voice turn started while another is still running"); + send(&**transport, error_event(turn, "busy", "another voice command is still running")); + } else { + info!(turn, "voice turn recording"); + } + *recording = Some(Recording { turn, lines: BTreeMap::new(), samples: 0, refused }); } VoiceLine::Data { turn, seq, predictor, index, bytes } => { if recording.as_ref().is_none_or(|current| current.turn != turn) { // Its START was lost: the audio still counts. debug!(turn, "voice data without its start"); - *recording = Some(Recording { turn, lines: BTreeMap::new(), samples: 0 }); + *recording = Some(Recording { turn, lines: BTreeMap::new(), samples: 0, refused: false }); } let current = recording.as_mut().expect("recording was just made"); + if current.refused { + return None; + } let line = decode_line(predictor, index, &bytes); if current.samples + line.len() > MAX_SAMPLES { debug!(turn, seq, "voice turn over the longest recording; line dropped"); @@ -317,6 +299,9 @@ impl Voice { VoiceLine::End { turn, samples } => { let taken = recording.take_if(|current| current.turn == turn); drop(recording); + if taken.as_ref().is_some_and(|taken| taken.refused) { + return None; + } return self.end(turn, samples as usize, taken, transport.clone()); } } @@ -400,25 +385,27 @@ pub mod tests { use super::*; - /// Encodes `samples` as the box does: lines of `per_line` samples, each with the coder state it starts from. - pub fn voice_lines(turn: u32, samples: &[i16], per_line: usize) -> Vec { - let mut encoder = Decoder::new(0, 0); - let mut lines = vec![format!("VOICE START {turn}")]; - for (seq, chunk) in samples.chunks(per_line).enumerate() { - let (predictor, index) = encoder.state(); - let bytes: Vec = chunk - .chunks(2) - .map(|pair| { - let low = encoder.encode(pair[0]); - let high = pair.get(1).map(|&sample| encoder.encode(sample)).unwrap_or(0); - low | high << 4 - }) - .collect(); - let data = base64::engine::general_purpose::STANDARD.encode(bytes); - lines.push(format!("VOICE DATA {turn} {seq} {predictor} {index} {data}")); - } - lines.push(format!("VOICE END {turn} {}", samples.len())); - lines + /// The lines the box itself sends for a turn of `samples`: made by firmware-core's own voice command code, + /// so these tests hold the daemon to what the box really writes. + pub fn voice_lines(turn: u32, samples: &[i16]) -> Vec { + use vibebuddy_firmware_core::voice_command::{Out, VoiceCommand}; + let mut box_side = VoiceCommand::new(); + box_side.set_available(true); + // Turns count from 1 after each boot. + for _ in 1..turn { + box_side.start(0); + box_side.cancel(); + } + let mut out = vec![box_side.start(0)]; + for piece in samples.chunks(700) { + out.extend(box_side.push(piece)); + } + out.extend(box_side.release(10_000)); + out.into_iter() + .map(|out| match out { + Out::Line(line) | Out::Data(line) => line.trim_end().to_owned(), + }) + .collect() } pub fn sine(count: usize) -> Vec { @@ -458,7 +445,7 @@ pub mod tests { #[test] fn a_line_decodes_on_its_own_from_the_state_it_carries() { let original = sine(10_000); - let lines = voice_lines(1, &original, 1152); + let lines = voice_lines(1, &original); let mut box_side = vibebuddy_firmware_core::adpcm::Decoder::default(); let mut straight = Vec::new(); let mut separately = Vec::new(); @@ -600,7 +587,7 @@ pub mod tests { vec![tool_call("speak", json!({"text": "Two agents are working."})), tool_call("speak", json!({"text": "One needs you."}))]; let rig = Rig::new("turn", Ok("what are my agents doing"), script); - let task = rig.feed(&voice_lines(4, &sine(30_000), 1152)).expect("the turn runs"); + let task = rig.feed(&voice_lines(4, &sine(30_000))).expect("the turn runs"); // Both replies reach the box while the turn is still open in the app-server. while rig.names().len() < 3 { tokio::time::sleep(std::time::Duration::from_millis(5)).await; @@ -627,21 +614,37 @@ pub mod tests { #[tokio::test] async fn lost_lines_still_make_a_turn_of_the_recorded_length() { let rig = Rig::new("gaps", Ok("hello"), logged_in()); - let mut lines = voice_lines(1, &sine(5_000), 1000); - // No START and no line 2: the rest still counts. + let original = sine(5_000); + let mut lines = voice_lines(1, &original); + assert_eq!(lines.last().map(String::as_str), Some("VOICE END 1 5000")); + assert_eq!(lines.len(), 7, "START, four full lines of 1152 samples and one of 392, END"); + // No START and no line 2 (samples 2304 to 3455): the rest still counts. lines.retain(|line| !line.starts_with("VOICE START") && !line.starts_with("VOICE DATA 1 2 ")); rig.feed(&lines).expect("the turn runs").await.unwrap(); let wav = rig.recognizer.heard.lock().unwrap()[0].clone(); assert_eq!(wav.len(), 44 + 5_000 * 2); - let silence = &wav[44 + 2_000 * 2..44 + 3_000 * 2]; - assert!(silence.iter().all(|byte| *byte == 0), "line 2 is silence"); + let decoded: Vec = wav[44..].chunks(2).map(|pair| i16::from_le_bytes([pair[0], pair[1]])).collect(); + assert!(decoded[2_304..3_456].iter().all(|sample| *sample == 0), "line 2 is silence"); + // END counts the lost line's samples too, as the box's does, so what follows the gap keeps its place. + for k in (3_456 + 60..5_000).step_by(97) { + assert!((i32::from(decoded[k]) - i32::from(original[k])).abs() < 600, "sample {k}"); + } assert_eq!(rig.names().last().map(String::as_str), Some("voice.done")); + + // The last line lost: filled up to the count END gives. + let rig = Rig::new("gap-at-end", Ok("hello"), logged_in()); + let mut lines = voice_lines(1, &original); + lines.retain(|line| !line.starts_with("VOICE DATA 1 4 ")); + rig.feed(&lines).expect("the turn runs").await.unwrap(); + let wav = rig.recognizer.heard.lock().unwrap()[0].clone(); + assert_eq!(wav.len(), 44 + 5_000 * 2); + assert!(wav[44 + 4_608 * 2..].iter().all(|byte| *byte == 0)); } #[tokio::test] async fn nothing_recognised_is_not_heard_and_the_session_is_not_asked() { let rig = Rig::new("empty", Ok(""), logged_in()); - rig.feed(&voice_lines(2, &sine(12_000), 1152)).unwrap().await.unwrap(); + rig.feed(&voice_lines(2, &sine(12_000))).unwrap().await.unwrap(); assert_eq!(rig.names(), ["voice.error"]); assert_eq!(rig.error().0, "not_heard"); assert_eq!(rig.script.lock().unwrap().starts, 0); @@ -650,12 +653,12 @@ pub mod tests { #[tokio::test] async fn a_failed_recognizer_or_session_is_unavailable() { let rig = Rig::new("stt-down", Err("Workers AI answered 401: Authentication error"), logged_in()); - rig.feed(&voice_lines(1, &sine(12_000), 1152)).unwrap().await.unwrap(); + rig.feed(&voice_lines(1, &sine(12_000))).unwrap().await.unwrap(); assert_eq!(rig.error(), ("unavailable".to_owned(), "Workers AI answered 401: Authentication error".to_owned())); // Codex isn't logged in: what was heard is still shown, then the error. let rig = Rig::new("no-login", Ok("hi"), Arc::new(std::sync::Mutex::new(Default::default()))); - rig.feed(&voice_lines(1, &sine(12_000), 1152)).unwrap().await.unwrap(); + rig.feed(&voice_lines(1, &sine(12_000))).unwrap().await.unwrap(); assert_eq!(rig.names(), ["voice.heard", "voice.error"]); assert_eq!(rig.error().0, "unavailable"); } @@ -666,22 +669,26 @@ pub mod tests { let hold = Arc::new(tokio::sync::Notify::new()); script.lock().unwrap().hold = Some(hold.clone()); let rig = Rig::new("busy", Ok("first"), script); - let first = rig.feed(&voice_lines(1, &sine(12_000), 1152)).unwrap(); - assert!(rig.feed(&voice_lines(2, &sine(12_000), 1152)).is_none()); - let events = rig.transport.events(); - let busy = events.iter().find(|event| event.event == "voice.error").unwrap(); - assert_eq!((busy.extra["turn"].as_u64(), busy.extra["reason"].as_str()), (Some(2), Some("busy"))); + let first = rig.feed(&voice_lines(1, &sine(12_000))).unwrap(); + let second = voice_lines(2, &sine(12_000)); + // Refused as soon as it starts, so the box stops recording; the rest of it gets no second answer. + assert!(rig.feed(&second[..1]).is_none()); + let errors = || rig.transport.events().into_iter().filter(|event| event.event == "voice.error").collect::>(); + assert_eq!(errors().len(), 1); + assert_eq!((errors()[0].extra["turn"].as_u64(), errors()[0].extra["reason"].as_str()), (Some(2), Some("busy"))); + assert!(rig.feed(&second[1..]).is_none()); + assert_eq!(errors().len(), 1); hold.notify_one(); first.await.unwrap(); // Once it is over, the next turn runs. hold.notify_one(); - rig.feed(&voice_lines(3, &sine(12_000), 1152)).expect("not busy any more").await.unwrap(); + rig.feed(&voice_lines(3, &sine(12_000))).expect("not busy any more").await.unwrap(); } #[tokio::test] async fn a_cancelled_turn_is_dropped_and_an_end_without_audio_is_not_heard() { let rig = Rig::new("cancel", Ok("hi"), logged_in()); - let mut lines = voice_lines(5, &sine(6_000), 1152); + let mut lines = voice_lines(5, &sine(6_000)); lines.pop(); lines.push("VOICE CANCEL 5".to_owned()); assert!(rig.feed(&lines).is_none()); @@ -757,7 +764,7 @@ pub mod tests { let transport: Arc = timed.clone(); // 1152 samples are 576 bytes, 768 base64 characters: the most a line may carry. - let lines = voice_lines(1, &left, 1152); + let lines = voice_lines(1, &left); assert!(lines.iter().all(|line| line.len() < 1024)); println!("{:.2} s of audio in {} lines", left.len() as f32 / SAMPLE_RATE as f32, lines.len()); let (end, data) = lines.split_last().unwrap(); @@ -790,7 +797,7 @@ pub mod tests { let voice = Voice::new(None, on()); let transport = Arc::new(RecordingTransport::default()); let shared: Arc = transport.clone(); - for line in voice_lines(1, &sine(12_000), 1152) { + for line in voice_lines(1, &sine(12_000)) { voice.handle_line(&line, &shared); } assert_eq!(transport.events()[0].extra["reason"], "unavailable"); diff --git a/docs/resident-session.md b/docs/resident-session.md index 96c57a76..a155bccf 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -34,12 +34,12 @@ vibebuddy resident # prints codex://threads/, to op The box side of push-to-talk is in [`protocol.md`](protocol.md), "Voice commands (experimental)"; `daemon/src/voice.rs` is the computer side and `daemon/src/speech.rs` the recognizers. -- **Offered.** Voice is on when the resident session is on and a speech recognizer is configured. The daemon then sends `voice.available` `on: true` every time the box reports its build (after `device.hello` on each new link, and after a reboot). Never over the Relay: there the box will talk to the voice service itself. Nothing switches either condition at run time yet, so `on: false` is never sent. +- **Offered.** Voice is on when the resident session is on and a speech recognizer is configured. The daemon then sends `voice.available` `on: true` every time the box reports its build (after `device.hello` on each new link, and after a reboot). Never over the Relay, where the box will talk to the voice service itself, and never over the UART bridge, which is too slow for the audio and gets none from the box (use the box's native USB port); the log says which kept voice off. Nothing switches either condition at run time yet, so `on: false` is never sent. - **Recording.** `VOICE DATA` lines are decoded as they arrive, each from the IMA ADPCM state it carries (the same decoder as `firmware-rs/core/src/adpcm.rs`, checked against it in the tests). On `VOICE END` the lines are put in seq order; a lost line becomes silence as long as a line, audio lost at the end is filled up to the sample count `END` gives, and the missing seqs are logged. A turn's audio is kept only in memory and only until it is recognised; `VOICE DATA` lines stay out of the log. -- **A turn.** The audio goes to the recognizer as a 24 kHz 16-bit mono WAV file; the text goes to the box as `voice.heard` and to the resident session as a voice command; each `speak` goes to the box as `voice.reply` as soon as it is called; `voice.done` follows `turn/completed`. Text is cut at a character, with an ellipsis, to fit the 1024-byte line. Errors: `not_heard` (no audio arrived, or the recognizer heard nothing), `unavailable` (voice is off, recognition failed, or the resident session failed, with the reason as `message`), `busy` (a turn ended while another is still being answered; unlike `vibebuddy say`, a voice turn doesn't wait). A typed command and a voice turn still queue behind each other in the resident session. +- **A turn.** The audio goes to the recognizer as a 24 kHz 16-bit mono WAV file; the text goes to the box as `voice.heard` and to the resident session as a voice command; each `speak` goes to the box as `voice.reply` as soon as it is called; `voice.done` follows `turn/completed`. Text is cut at a character, with an ellipsis, to fit the 1024-byte line. Errors: `not_heard` (no audio arrived, or the recognizer heard nothing), `unavailable` (voice is off, recognition failed, or the resident session failed, with the reason as `message`), `busy` (a turn started while another is still being answered: sent right after its `VOICE START`, so the box stops recording, and the rest of that turn is dropped; unlike `vibebuddy say`, a voice turn doesn't wait). A typed command and a voice turn still queue behind each other in the resident session. - **Timing.** A 5 s recording, checked end to end on 2026-10-13 (recognition by Workers AI, codex-cli 0.162.0, app-server started cold): `voice.heard` 2.2 s after `VOICE END`, the spoken reply at 10.1 s, `voice.done` at 14.0 s. -The end-to-end check is an ignored test, `e2e_a_real_recording_through_the_real_recognizer_and_resident_session` in `voice.rs`: it encodes the left channel of a WAV file as the box would and feeds it through the real recognizer and the real resident session, in a temporary state directory. Its doc comment says how to run it; give it a `VIBEBUDDY_CODEX` that adds `-c features.hooks=false`, or the turn's hooks reach the daemon on 7331 and show up on the box. +The end-to-end check is an ignored test, `e2e_a_real_recording_through_the_real_recognizer_and_resident_session` in `voice.rs`: it turns the left channel of a WAV file into `VOICE` lines with firmware-core's own voice command code (as do the other voice tests, so they hold the daemon to what the box writes) and feeds them through the real recognizer and the real resident session, in a temporary state directory. Its doc comment says how to run it; give it a `VIBEBUDDY_CODEX` that adds `-c features.hooks=false`, or the turn's hooks reach the daemon on 7331 and show up on the box. ### Speech recognition From 452e98b664be9f7eb7411f92163c716b524f582e Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 18:24:11 +0800 Subject: [PATCH 20/51] docs: spoken reply audio as MP3 chunks over the link Co-Authored-By: Claude Opus 5.5 --- docs/protocol.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/protocol.md b/docs/protocol.md index 950b4820..4bf7c5fb 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -219,7 +219,12 @@ The computer answers with JSON events carrying the same `turn`: The box ignores answers for any turn but the one on its screen: after K2 closed it, after a new turn started, or after a minute without a word from the computer, when it closes the overlay on its own. -Spoken replies are shown as text for now; their audio will follow as `voice.audio` chunks once there is speech synthesis. +The computer also sends each spoken reply's audio, synthesized in the Character's voice, right after its `voice.reply`: + +- `voice.audio` with `reply` (0 for the turn's first `voice.reply`, counting up), `seq` (from 0, without gaps) and `data`: base64 of the next bytes of an MP3 stream (MPEG-2 Layer III, 24 kHz mono, 48 kbps, as the voice service returns it), at most 768 base64 characters. Chunks don't follow MP3 frame boundaries; the box buffers and decodes frame by frame. +- `voice.audio` with `reply`, `seq` and `"end":true` (no `data`): that reply's audio is complete. + +The box plays replies in order, one after another, and starts a reply as soon as it has enough frames, without waiting for its end. If synthesis fails the reply stays text only. While muted the box shows replies without playing them. ## Stage 1 error output From 13a34d506e91bc6f06f7f1aa3e19ca04043f7028 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 18:26:49 +0800 Subject: [PATCH 21/51] docs: mark a cut-short spoken reply Co-Authored-By: Claude Opus 5.5 --- docs/protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/protocol.md b/docs/protocol.md index 4bf7c5fb..8dfc5961 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -222,7 +222,7 @@ The box ignores answers for any turn but the one on its screen: after K2 closed The computer also sends each spoken reply's audio, synthesized in the Character's voice, right after its `voice.reply`: - `voice.audio` with `reply` (0 for the turn's first `voice.reply`, counting up), `seq` (from 0, without gaps) and `data`: base64 of the next bytes of an MP3 stream (MPEG-2 Layer III, 24 kHz mono, 48 kbps, as the voice service returns it), at most 768 base64 characters. Chunks don't follow MP3 frame boundaries; the box buffers and decodes frame by frame. -- `voice.audio` with `reply`, `seq` and `"end":true` (no `data`): that reply's audio is complete. +- `voice.audio` with `reply`, `seq` and `"end":true` (no `data`): that reply's audio is over. With `"error":"synthesis"` as well, synthesis failed or was cut short: the box plays what it has, keeps the text, and logs the failure instead of passing it off as a whole reply. The box plays replies in order, one after another, and starts a reply as soon as it has enough frames, without waiting for its end. If synthesis fails the reply stays text only. While muted the box shows replies without playing them. From 1942ce73dace38ef3ca14141cd6e9689a5d7941e Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 18:33:49 +0800 Subject: [PATCH 22/51] Speak voice replies: stream synthesized audio to the box as voice.audio Each spoken reply is synthesized by the voice service (POST {base}/v0/voice/speak, staging outside releases, VIBEBUDDY_TTS_BASE_URL to set or switch off) behind a Synthesizer trait, and its MP3 is forwarded to the box as it arrives, in pieces of at most 768 base64 characters, then an end; a failed or cut-short stream ends early with "error":"synthesis". Replies are spoken in order on their own task so the text never waits for audio, and voice.done follows the last reply's end. Replies over 1000 characters are split at sentences. A dev-only table gives each Character an Edge voice, with a Chinese voice for Chinese text. Co-Authored-By: Claude Opus 5.5 --- daemon/src/main.rs | 14 +- daemon/src/speech.rs | 15 +- daemon/src/synthesis.rs | 305 ++++++++++++++++++++++++++++++++++ daemon/src/voice.rs | 351 +++++++++++++++++++++++++++++++++++++-- docs/resident-session.md | 21 ++- 5 files changed, 683 insertions(+), 23 deletions(-) create mode 100644 daemon/src/synthesis.rs diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 4740bc06..556aa131 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -19,6 +19,7 @@ mod session_titles; mod source_opener; mod speech; mod status; +mod synthesis; mod updates; mod voice; mod voice_writer; @@ -263,7 +264,7 @@ async fn main() { } Arc::new(resident::Resident::new(enabled, dir, resident::Launcher::Codex, state.activities.clone())) }); - state.voice = Arc::new(voice::Voice::new(speech::from_env(), state.resident.clone())); + state.voice = Arc::new(voice::Voice::new(speech::from_env(), state.resident.clone()).with_synthesizer(synthesis::from_env())); state.device.lock().await.pin = pin; state.updates = Arc::new(Mutex::new(Updates::new( updates::Source::configured(), @@ -401,9 +402,9 @@ async fn publish_device_message(state: &AppState, message: DeviceMessage) { /// over the UART bridge, which is too slow for it, and over the Relay it will talk to the voice service itself, so /// voice isn't offered on either, and voice lines over the Relay are ignored. async fn handle_voice_line(state: &AppState, line: &str) -> Option> { - let (over_relay, on_bridge) = { + let (over_relay, on_bridge, character) = { let device = state.device.lock().await; - (device.relay, device.bridge) + (device.relay, device.bridge, device.voice.clone()) }; if line.contains(serial_transport::BUILD_MARKER) && state.voice.available() { if over_relay { @@ -421,7 +422,12 @@ async fn handle_voice_line(state: &AppState, line: &str) -> Option Some(character), + None => state.config.lock().await.voice.clone(), + }; + state.voice.handle_line(line, &state.transport, character.as_deref()) } async fn open_k2_source(state: &AppState) { diff --git a/daemon/src/speech.rs b/daemon/src/speech.rs index 9dfdc309..4eb08410 100644 --- a/daemon/src/speech.rs +++ b/daemon/src/speech.rs @@ -156,15 +156,20 @@ impl Recognizer for OpenAiCompatible { } } -fn send(request: HttpRequest) -> Result<(u16, String), String> { - let agent: ureq::Agent = ureq::Agent::config_builder() - .timeout_global(Some(TIMEOUT)) +/// An HTTP client on the system's certificate store that hands back every status as an answer: the recognizers +/// here and the voice service in [`crate::synthesis`] share it. +pub fn agent(timeout: Duration) -> ureq::Agent { + ureq::Agent::config_builder() + .timeout_global(Some(timeout)) .http_status_as_error(false) .tls_config(ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::PlatformVerifier).build()) .user_agent(concat!("vibebuddyd/", env!("CARGO_PKG_VERSION"))) .build() - .into(); - let mut builder = agent.post(&request.url); + .into() +} + +fn send(request: HttpRequest) -> Result<(u16, String), String> { + let mut builder = agent(TIMEOUT).post(&request.url); for (name, value) in &request.headers { builder = builder.header(name, value); } diff --git a/daemon/src/synthesis.rs b/daemon/src/synthesis.rs new file mode 100644 index 00000000..f493df0a --- /dev/null +++ b/daemon/src/synthesis.rs @@ -0,0 +1,305 @@ +//! Speech synthesis for spoken replies (ADR-0017): a reply's text in, an MP3 stream out, piece by piece as the +//! service makes it. +//! +//! Behind [`Synthesizer`] so the backend can change. There is one: the voice service in `vibebuddy-cloud`, +//! `POST {base}/v0/voice/speak` with `{"text","voice"}`, answering a chunked `audio/mpeg` stream (MPEG-2 Layer III, +//! 24 kHz mono, 48 kbps) that starts as soon as there is audio. Builds other than releases use staging; a release +//! has none until the production service is deployed. `VIBEBUDDY_TTS_BASE_URL` sets the base by hand, or `off`. + +use std::io::Read; +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tracing::{info, warn}; + +/// The voice service's base URL, or `off`. Unset: staging, except in a release. +pub const BASE_URL_VARIABLE: &str = "VIBEBUDDY_TTS_BASE_URL"; +pub const STAGING_URL: &str = "https://api-staging.korekore.ai"; +/// The most text the service takes in one request, in UTF-16 code units (JavaScript's `length`). +pub const MAX_TEXT: usize = 1000; +/// A request gets this long for its whole answer; 1000 characters is about a minute of speech, made faster than +/// it plays. +const TIMEOUT: Duration = Duration::from_secs(120); +/// No reply needs more: ten minutes at 48 kbps. +const MAX_AUDIO: usize = 4 * 1024 * 1024; + +/// Turns one reply into speech. Blocking: it is called on a blocking thread. +pub trait Synthesizer: Send + Sync { + /// Calls `on_audio` with each piece of the MP3 as it arrives (not on frame boundaries); `on_audio` returns false + /// to stop. An error after some audio means the stream was cut short. + fn synthesize(&self, text: &str, voice: &str, on_audio: &mut dyn FnMut(&[u8]) -> bool) -> Result<(), String>; +} + +/// The voice service. +pub struct VoiceService { + base_url: String, +} + +impl VoiceService { + fn url(&self) -> String { + format!("{}/v0/voice/speak", self.base_url.trim_end_matches('/')) + } + + fn speak(&self, text: &str, voice: &str, on_audio: &mut dyn FnMut(&[u8]) -> bool) -> Result { + let url = self.url(); + let host = url.split('/').nth(2).unwrap_or_default().to_owned(); + let body = json!({"text": text, "voice": voice}).to_string(); + // The seam for the account session: its Authorization header goes here once the service asks for one. + let mut response = crate::speech::agent(TIMEOUT) + .post(&url) + .header("Content-Type", "application/json") + .send(body.as_bytes()) + .map_err(|error| format!("calling {host}: {error}"))?; + let status = response.status().as_u16(); + if status != 200 { + let answer = response.body_mut().with_config().limit(1024).read_to_string().unwrap_or_default(); + return Err(format!("the voice service answered {status}: {}", answer.trim())); + } + let mut reader = response.into_body().into_reader(); + let mut buffer = [0u8; 4096]; + let mut total = 0; + loop { + // A connection cut before the end of the chunked body is an error here, not an end. + let read = reader.read(&mut buffer).map_err(|error| format!("reading audio from {host}: {error}"))?; + if read == 0 { + return Ok(true); + } + total += read; + if total > MAX_AUDIO { + return Err(format!("{host} sent more audio than any reply needs")); + } + if !on_audio(&buffer[..read]) { + return Ok(false); + } + } + } +} + +impl Synthesizer for VoiceService { + fn synthesize(&self, text: &str, voice: &str, on_audio: &mut dyn FnMut(&[u8]) -> bool) -> Result<(), String> { + // MP3 frames stand alone, so the pieces of a long reply play as one stream. + for piece in split(text, MAX_TEXT) { + if !self.speak(piece, voice, on_audio)? { + break; + } + } + Ok(()) + } +} + +/// `text` in pieces of at most `max` UTF-16 code units: at the end of a sentence where one fits, else at a comma or +/// a space, else at a character. Pieces with nothing to say are left out. +pub fn split(text: &str, max: usize) -> Vec<&str> { + let mut pieces = Vec::new(); + let mut rest = text.trim(); + while !rest.is_empty() { + let mut units = 0; + let mut fits = rest.len(); + let (mut sentence, mut pause) = (None, None); + for (at, character) in rest.char_indices() { + units += character.len_utf16(); + if units > max { + fits = at; + break; + } + let after = at + character.len_utf8(); + if matches!(character, '.' | '!' | '?' | '。' | '!' | '?' | '…' | '\n') { + sentence = Some(after); + } else if matches!(character, ',' | ';' | ':' | ',' | ';' | ':' | '、' | ' ') { + pause = Some(after); + } + } + let cut = match fits { + _ if fits == rest.len() => fits, + // Not even one character fits: it goes alone rather than never. + 0 => rest.chars().next().map_or(0, char::len_utf8), + _ => sentence.or(pause).unwrap_or(fits), + }; + let (piece, after) = rest.split_at(cut); + if piece.chars().any(char::is_alphanumeric) { + pieces.push(piece.trim()); + } + rest = after.trim_start(); + } + pieces +} + +/// DEVELOPMENT ONLY: an Edge voice for each Character, picked to roughly match its persona (docs/characters.md) +/// while the voice service speaks with Edge voices. The voices spoken replies use in production are still to be +/// decided; this table is the one place to change. +const CHARACTER_VOICES: &[(&str, &str)] = &[ + // A soft Taiwanese accent. + ("wanwanxiaohe", "zh-TW-HsiaoChenNeural"), + // A witty Londoner. + ("ada", "en-GB-SoniaNeural"), + // Calm late-night lofi. + ("luna", "en-US-AvaNeural"), + // Upbeat, from the Bay Area. + ("mei", "en-US-AriaNeural"), + // A greybeard in his fifties: low and dry. + ("hank", "en-US-ChristopherNeural"), + // A laid-back San Diego surfer. + ("kai", "en-US-AndrewNeural"), + // Fast and punchy; there is no Danish-accented English voice, so a European one. + ("dave", "en-GB-RyanNeural"), + // The built-in default Character, whose voice is Jessica. + ("robot", "en-US-JennyNeural"), +]; +/// No Character pack, or one this table doesn't know: the built-in default Character's. +const DEFAULT_VOICE: &str = "en-US-JennyNeural"; +/// A reply in Chinese from a Character whose voice is English: an English voice can't read it. +const CHINESE_VOICE: &str = "zh-CN-XiaoxiaoNeural"; + +/// The voice to speak `text` in for `character` (the id the box reports in `VOICES`). A Character speaks one +/// language, but the resident session answers in the user's: Chinese text gets a Chinese voice whatever the +/// Character. +pub fn voice_for(character: Option<&str>, text: &str) -> &'static str { + let voice = character + .and_then(|character| CHARACTER_VOICES.iter().find(|(id, _)| *id == character)) + .map_or(DEFAULT_VOICE, |(_, voice)| voice); + let chinese = text.chars().any(|character| ('\u{4e00}'..='\u{9fff}').contains(&character)); + if chinese && !voice.starts_with("zh-") { CHINESE_VOICE } else { voice } +} + +/// The synthesizer the environment configures, if any. +pub fn from_env() -> Option> { + configure(std::env::var(BASE_URL_VARIABLE).ok(), crate::relay::release()) +} + +fn configure(base_url: Option, release: bool) -> Option> { + let base_url = match base_url.map(|url| url.trim().to_owned()).filter(|url| !url.is_empty()) { + Some(url) if url == "off" => return None, + Some(url) => url, + None if release => return None, + None => STAGING_URL.to_owned(), + }; + if !base_url.starts_with("https://") && !base_url.starts_with("http://") { + warn!(%base_url, "the voice service's base URL is not http(s); spoken replies stay text only"); + return None; + } + info!(%base_url, "speech synthesis: the voice service"); + Some(Arc::new(VoiceService { base_url })) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + use std::net::TcpListener; + + use super::*; + + #[test] + fn each_character_has_its_voice_and_chinese_text_gets_a_chinese_one() { + assert_eq!(voice_for(Some("wanwanxiaohe"), "你好"), "zh-TW-HsiaoChenNeural"); + assert_eq!(voice_for(Some("wanwanxiaohe"), "OK, done."), "zh-TW-HsiaoChenNeural"); + assert_eq!(voice_for(Some("ada"), "Two agents are working."), "en-GB-SoniaNeural"); + assert_eq!(voice_for(Some("hank"), "Done."), "en-US-ChristopherNeural"); + assert_eq!(voice_for(Some("robot"), "Hi"), DEFAULT_VOICE); + assert_eq!(voice_for(None, "Hi"), DEFAULT_VOICE, "no Character pack"); + assert_eq!(voice_for(Some("hsiaoyu"), "Hi"), DEFAULT_VOICE, "an old voice pack"); + assert_eq!(voice_for(Some("ada"), "两个智能体在工作"), CHINESE_VOICE); + assert_eq!(voice_for(None, "Codex 在跑测试"), CHINESE_VOICE); + for (id, voice) in CHARACTER_VOICES { + assert!(voice.ends_with("Neural"), "{id}"); + } + } + + #[test] + fn long_text_is_split_at_sentences_then_pauses_then_characters() { + assert_eq!(split(" Short one. ", 1000), ["Short one."]); + assert_eq!(split("One. Two! Three?", 10), ["One. Two!", "Three?"]); + assert_eq!(split("第一句。第二句。第三句。", 8), ["第一句。第二句。", "第三句。"]); + assert_eq!(split("one two three four", 9), ["one two", "three", "four"]); + assert_eq!(split("abcdefghij", 4), ["abcd", "efgh", "ij"]); + // A character outside the BMP is two units in JavaScript, which is how the service counts. + assert_eq!(split("𠀀𠀀𠀀", 4), ["𠀀𠀀", "𠀀"]); + assert!(split("😀", 4).is_empty(), "nothing to say"); + assert!(split(" ... ", 10).is_empty(), "nothing to say"); + let long = "This sentence is about forty characters. ".repeat(60); + let pieces = split(&long, MAX_TEXT); + assert!(pieces.len() > 1); + assert!(pieces.iter().all(|piece| piece.encode_utf16().count() <= MAX_TEXT && piece.ends_with('.'))); + } + + #[test] + fn the_service_is_staging_outside_a_release_and_can_be_set_or_switched_off() { + let base = |url: Option<&str>, release| { + configure(url.map(str::to_owned), release).map(|_| ()).is_some() + }; + assert!(base(None, false)); + assert!(!base(None, true), "no production voice service yet"); + assert!(base(Some("https://api.korekore.ai"), true)); + assert!(!base(Some("off"), false)); + assert!(!base(Some("api.korekore.ai"), false)); + } + + /// Serves one canned HTTP answer on a local port; returns the base URL and the request it got. + fn serve(answer: Vec) -> (String, std::thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let handle = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0u8; 4096]; + while !String::from_utf8_lossy(&request).contains("}") { + let read = socket.read(&mut buffer).unwrap(); + request.extend_from_slice(&buffer[..read]); + } + socket.write_all(&answer).unwrap(); + String::from_utf8_lossy(&request).into_owned() + }); + (base, handle) + } + + fn chunked(pieces: &[&[u8]], finished: bool) -> Vec { + let mut out = b"HTTP/1.1 200 OK\r\nContent-Type: audio/mpeg\r\nTransfer-Encoding: chunked\r\n\r\n".to_vec(); + for piece in pieces { + out.extend_from_slice(format!("{:x}\r\n", piece.len()).as_bytes()); + out.extend_from_slice(piece); + out.extend_from_slice(b"\r\n"); + } + if finished { + out.extend_from_slice(b"0\r\n\r\n"); + } + out + } + + fn collect(base: String) -> (Result<(), String>, Vec) { + let service = VoiceService { base_url: base }; + let mut audio = Vec::new(); + let result = service.synthesize("Hello there.", "en-US-JennyNeural", &mut |piece| { + audio.extend_from_slice(piece); + true + }); + (result, audio) + } + + #[test] + fn the_request_is_json_and_the_audio_comes_through() { + let (base, server) = serve(chunked(&[b"ID3abc", b"\xff\xf3def"], true)); + let (result, audio) = collect(format!("{base}/")); + assert_eq!(result, Ok(())); + assert_eq!(audio, b"ID3abc\xff\xf3def"); + let request = server.join().unwrap(); + assert!(request.starts_with("POST /v0/voice/speak HTTP/1.1\r\n"), "{request}"); + assert!(request.to_ascii_lowercase().contains("content-type: application/json")); + assert!(request.ends_with(r#"{"text":"Hello there.","voice":"en-US-JennyNeural"}"#), "{request}"); + } + + #[test] + fn a_stream_cut_short_or_an_error_status_is_a_failure() { + // The connection drops before the chunked body's end: what came is kept, but it failed. + let (base, server) = serve(chunked(&[b"ID3abc"], false)); + let (result, audio) = collect(base); + server.join().unwrap(); + assert!(result.is_err(), "{result:?}"); + assert_eq!(audio, b"ID3abc"); + + let (base, server) = serve(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 15\r\n\r\nupstream failed".to_vec()); + let (result, audio) = collect(base); + server.join().unwrap(); + assert_eq!(result.unwrap_err(), "the voice service answered 502: upstream failed"); + assert!(audio.is_empty()); + } +} diff --git a/daemon/src/voice.rs b/daemon/src/voice.rs index ad082ef3..fb39a8fb 100644 --- a/daemon/src/voice.rs +++ b/daemon/src/voice.rs @@ -6,6 +6,11 @@ //! recognised ([`crate::speech`]), sends `voice.heard`, hands the text to the resident session and sends each spoken //! reply as `voice.reply` the moment the session says it, then `voice.done`. One turn at a time: a turn that ends //! while another is still running gets `voice.error` `busy`. +//! +//! With a synthesizer ([`crate::synthesis`]), each reply is also spoken: its audio follows its `voice.reply` as +//! `voice.audio` pieces, streamed as the service makes them. Replies are synthesized one after another, in order, +//! on their own task, so a reply's text never waits for an earlier reply's audio; `voice.done` waits for the last +//! reply's audio `end`. use std::collections::BTreeMap; use std::sync::Arc; @@ -20,8 +25,11 @@ use vibebuddy_protocol::{Event, MAX_MESSAGE_BYTES}; use crate::resident::Resident; use crate::serial_transport::Transport; use crate::speech::Recognizer; +use crate::synthesis::{self, Synthesizer}; pub const SAMPLE_RATE: u32 = 24_000; +/// The most MP3 bytes one `voice.audio` carries: 768 base64 characters. +const AUDIO_BYTES: usize = 768 / 4 * 3; /// The box stops at 30 s; a little more is kept in case its clock and ours disagree, nothing beyond. const MAX_SAMPLES: usize = SAMPLE_RATE as usize * 31; const LINE_PREFIX: &str = "VOICE "; @@ -209,6 +217,13 @@ fn error_event(turn: u32, reason: &str, message: &str) -> Event { }) } +fn audio_event(turn: u32, reply: u32, seq: u32) -> Event { + let mut event = turn_event("voice.audio", turn); + event.extra.insert("reply".to_owned(), reply.into()); + event.extra.insert("seq".to_owned(), seq.into()); + event +} + fn send(transport: &dyn Transport, event: Event) { match event.to_ndjson() { Ok(frame) => { @@ -232,13 +247,19 @@ impl Drop for Running { pub struct Voice { recognizer: Option>, resident: Option>, + /// Speaks the replies; without one they stay text. + synthesizer: Option>, recording: std::sync::Mutex>, running: Arc, } impl Voice { pub fn new(recognizer: Option>, resident: Option>) -> Self { - Self { recognizer, resident, recording: std::sync::Mutex::new(None), running: Arc::default() } + Self { recognizer, resident, synthesizer: None, recording: std::sync::Mutex::new(None), running: Arc::default() } + } + + pub fn with_synthesizer(self, synthesizer: Option>) -> Self { + Self { synthesizer, ..self } } /// Voice is offered when the resident session is on and speech recognition is configured. @@ -246,8 +267,9 @@ impl Voice { self.recognizer.is_some() && self.resident.as_ref().is_some_and(|resident| resident.enabled()) } - /// Takes one `VOICE` line from the box. A turn that ends starts a task, returned so tests can wait for it. - pub fn handle_line(&self, line: &str, transport: &Arc) -> Option> { + /// Takes one `VOICE` line from the box. A turn that ends starts a task, returned so tests can wait for it; + /// its replies are spoken in the voice of `character`, the Character the box wears. + pub fn handle_line(&self, line: &str, transport: &Arc, character: Option<&str>) -> Option> { let parsed = match parse_line(line) { None => return None, Some(Err(error)) => { @@ -302,13 +324,21 @@ impl Voice { if taken.as_ref().is_some_and(|taken| taken.refused) { return None; } - return self.end(turn, samples as usize, taken, transport.clone()); + let speaker = self.synthesizer.clone().map(|synthesizer| (synthesizer, character.map(str::to_owned))); + return self.end(turn, samples as usize, taken, transport.clone(), speaker); } } None } - fn end(&self, turn: u32, samples: usize, recording: Option, transport: Arc) -> Option> { + fn end( + &self, + turn: u32, + samples: usize, + recording: Option, + transport: Arc, + speaker: Option<(Arc, Option)>, + ) -> Option> { let (Some(recognizer), Some(resident)) = (self.recognizer.clone(), self.resident.clone().filter(|resident| resident.enabled())) else { send(&*transport, error_event(turn, "unavailable", "voice commands are off on this computer")); @@ -331,12 +361,115 @@ impl Voice { } Some(tokio::spawn(async move { let _running = running; - run_turn(turn, audio, recognizer, resident, transport).await; + run_turn(turn, audio, recognizer, resident, transport, speaker).await; })) } } -async fn run_turn(turn: u32, audio: Vec, recognizer: Arc, resident: Arc, transport: Arc) { +/// Speaks a turn's replies one after another, in the order they were said, streaming each one's audio to the box +/// as it arrives, on a task of its own so the text never waits for it. +struct Speaker { + replies: tokio::sync::mpsc::UnboundedSender, + stopped: Arc, + task: JoinHandle<()>, +} + +impl Speaker { + fn start(turn: u32, synthesizer: Arc, character: Option, transport: Arc) -> Self { + let (replies, mut texts) = tokio::sync::mpsc::unbounded_channel::(); + let stopped = Arc::new(AtomicBool::new(false)); + let task = tokio::spawn({ + let stopped = stopped.clone(); + async move { + let mut reply = 0; + while let Some(text) = texts.recv().await { + let (synthesizer, character, transport, stopped) = + (synthesizer.clone(), character.clone(), transport.clone(), stopped.clone()); + let spoken = tokio::task::spawn_blocking(move || { + speak(turn, reply, &text, character.as_deref(), &*synthesizer, &*transport, &stopped) + }); + if let Err(error) = spoken.await { + warn!(turn, reply, %error, "speech synthesis panicked"); + } + reply += 1; + } + } + }); + Self { replies, stopped, task } + } + + fn say(&self, text: &str) { + let _ = self.replies.send(text.to_owned()); + } + + /// Waits until every reply's audio has been sent. + async fn finish(self) { + drop(self.replies); + let _ = self.task.await; + } + + /// Sends no more audio: the reply being spoken stops at its next piece, and the rest are never started. + async fn stop(self) { + self.stopped.store(true, Ordering::Release); + self.finish().await; + } +} + +/// One reply's audio, as `voice.audio` pieces and then its `end`; a failure ends it early, marked as one, and the +/// box keeps the text. Blocking. +fn speak( + turn: u32, + reply: u32, + text: &str, + character: Option<&str>, + synthesizer: &dyn Synthesizer, + transport: &dyn Transport, + stopped: &AtomicBool, +) { + if stopped.load(Ordering::Acquire) { + return; + } + let voice = synthesis::voice_for(character, text); + let started = Instant::now(); + let (mut seq, mut bytes, mut first_byte_ms) = (0, 0, None); + let result = synthesizer.synthesize(text, voice, &mut |piece| { + if stopped.load(Ordering::Acquire) { + return false; + } + first_byte_ms.get_or_insert(started.elapsed().as_millis() as u64); + bytes += piece.len(); + for chunk in piece.chunks(AUDIO_BYTES) { + let mut event = audio_event(turn, reply, seq); + event.extra.insert("data".to_owned(), base64::engine::general_purpose::STANDARD.encode(chunk).into()); + send(transport, event); + seq += 1; + } + true + }); + if stopped.load(Ordering::Acquire) { + return; + } + let total_ms = started.elapsed().as_millis() as u64; + let mut end = audio_event(turn, reply, seq); + end.extra.insert("end".to_owned(), true.into()); + match result { + Ok(()) => info!(turn, reply, voice, first_byte_ms, total_ms, bytes, "spoken reply synthesized"), + Err(error) => { + warn!(turn, reply, voice, %error, bytes, "speech synthesis failed; the reply stays text"); + end.extra.insert("error".to_owned(), "synthesis".into()); + } + } + send(transport, end); +} + +async fn run_turn( + turn: u32, + audio: Vec, + recognizer: Arc, + resident: Arc, + transport: Arc, + speaker: Option<(Arc, Option)>, +) { let started = Instant::now(); let seconds = audio.len() as f32 / SAMPLE_RATE as f32; let wav = wav(&audio); @@ -359,20 +492,31 @@ async fn run_turn(turn: u32, audio: Vec, recognizer: Arc, r } info!(turn, seconds, recognised_ms, language = transcript.language.as_deref().unwrap_or("unknown"), chars = transcript.text.chars().count(), "voice turn heard"); send(&*transport, text_event("voice.heard", turn, &transcript.text)); + let speaker = speaker.map(|(synthesizer, character)| Speaker::start(turn, synthesizer, character, transport.clone())); let mut replies = 0; let answered = resident .say_streaming(&transcript.text, &mut |reply| { replies += 1; info!(turn, elapsed_ms = started.elapsed().as_millis() as u64, "voice reply"); send(&*transport, text_event("voice.reply", turn, reply)); + if let Some(speaker) = &speaker { + speaker.say(reply); + } }) .await; match answered { Ok(_) => { + // The turn is over once the last reply has been heard, not just said. + if let Some(speaker) = speaker { + speaker.finish().await; + } info!(turn, replies, elapsed_ms = started.elapsed().as_millis() as u64, "voice turn done"); send(&*transport, turn_event("voice.done", turn)); } Err(error) => { + if let Some(speaker) = speaker { + speaker.stop().await; + } warn!(turn, %error, "the resident session failed the voice turn"); send(&*transport, error_event(turn, "unavailable", &error.to_string())); } @@ -538,27 +682,62 @@ pub mod tests { use crate::resident::tests::{Shared, logged_in, resident, temp_dir, tool_call}; + /// Answers every reply with the same pieces of "audio", failing after them for texts that start with "fail", + /// and keeps the text and voice of each call. With a gate, its first call waits until the gate opens. + #[derive(Default)] + struct FakeSynthesizer { + pieces: Vec>, + calls: std::sync::Mutex>, + gate: std::sync::Mutex>>, + } + + impl Synthesizer for FakeSynthesizer { + fn synthesize(&self, text: &str, voice: &str, on_audio: &mut dyn FnMut(&[u8]) -> bool) -> Result<(), String> { + self.calls.lock().unwrap().push((text.to_owned(), voice.to_owned())); + if let Some(gate) = self.gate.lock().unwrap().take() { + let _ = gate.recv(); + } + for piece in &self.pieces { + if !on_audio(piece) { + return Ok(()); + } + } + if text.starts_with("fail") { Err("the connection was cut".to_owned()) } else { Ok(()) } + } + } + struct Rig { voice: Voice, transport: Arc, recognizer: Arc, script: Shared, dir: std::path::PathBuf, + character: Option<&'static str>, } impl Rig { fn new(name: &str, answer: Result<&'static str, &'static str>, script: Shared) -> Self { + Self::speaking(name, answer, script, None, None) + } + + fn speaking( + name: &str, + answer: Result<&'static str, &'static str>, + script: Shared, + synthesizer: Option>, + character: Option<&'static str>, + ) -> Self { let dir = temp_dir(&format!("voice-{name}")); let recognizer = Arc::new(FakeRecognizer { answer, heard: Default::default() }); let resident = Arc::new(resident(&dir, &script, Arc::default())); - let voice = Voice::new(Some(recognizer.clone()), Some(resident)); - Self { voice, transport: Arc::default(), recognizer, script, dir } + let voice = Voice::new(Some(recognizer.clone()), Some(resident)).with_synthesizer(synthesizer); + Self { voice, transport: Arc::default(), recognizer, script, dir, character } } /// Feeds the lines; returns the task the last one started, if any. fn feed(&self, lines: &[String]) -> Option> { let transport: Arc = self.transport.clone(); - lines.iter().filter_map(|line| self.voice.handle_line(line, &transport)).last() + lines.iter().filter_map(|line| self.voice.handle_line(line, &transport, self.character)).last() } fn names(&self) -> Vec { @@ -611,6 +790,114 @@ pub mod tests { assert_eq!(turn_text, "what are my agents doing"); } + /// One `voice.audio`: reply, seq, its bytes (None for its end) and its error. + type Piece = (u64, u64, Option>, Option); + + /// Each reply's audio, in the order it was sent. + fn audio(events: &[Event]) -> Vec { + events + .iter() + .filter(|event| event.event == "voice.audio") + .map(|event| { + let data = event.extra.get("data").map(|data| { + let data = data.as_str().unwrap(); + assert!(data.len() <= 768, "{} base64 characters", data.len()); + base64::engine::general_purpose::STANDARD.decode(data).unwrap() + }); + assert_eq!(data.is_none(), event.extra.get("end") == Some(&json!(true))); + let error = event.extra.get("error").map(|error| error.as_str().unwrap().to_owned()); + (event.extra["reply"].as_u64().unwrap(), event.extra["seq"].as_u64().unwrap(), data, error) + }) + .collect() + } + + #[tokio::test(flavor = "multi_thread")] + async fn each_reply_is_spoken_in_order_without_holding_up_the_text_and_done_comes_after_the_audio() { + let script = logged_in(); + let hold = Arc::new(tokio::sync::Notify::new()); + script.lock().unwrap().hold = Some(hold.clone()); + script.lock().unwrap().turn_requests = + vec![tool_call("speak", json!({"text": "Two agents are working."})), tool_call("speak", json!({"text": "One needs you."}))]; + let (open, gate) = std::sync::mpsc::channel(); + let pieces = vec![(0..=255).cycle().take(1300).collect(), vec![7; 10]]; + let synthesizer = Arc::new(FakeSynthesizer { pieces, gate: std::sync::Mutex::new(Some(gate)), ..Default::default() }); + let rig = Rig::speaking("speak", Ok("what are my agents doing"), script, Some(synthesizer.clone()), Some("ada")); + + let task = rig.feed(&voice_lines(4, &sine(30_000))).expect("the turn runs"); + // The first reply's audio is held up, and both replies' text still reach the box. + while rig.names().len() < 3 { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + assert_eq!(rig.names(), ["voice.heard", "voice.reply", "voice.reply"]); + // The turn has completed in the session, but isn't done until the audio is. + hold.notify_one(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert!(!task.is_finished()); + open.send(()).unwrap(); + task.await.unwrap(); + + let events = rig.transport.events(); + assert!(events.iter().all(|event| event.extra["turn"] == 4)); + assert!(events.iter().all(|event| event.to_ndjson().unwrap().len() <= MAX_MESSAGE_BYTES + 1)); + assert_eq!(rig.names().last().map(String::as_str), Some("voice.done")); + let audio = audio(&events); + let order: Vec<(u64, u64, bool)> = audio.iter().map(|(reply, seq, data, _)| (*reply, *seq, data.is_none())).collect(); + // 1300 bytes are three pieces of at most 576; then the 10, then the end. + let expected: Vec<(u64, u64, bool)> = [0, 1].into_iter().flat_map(|reply| (0..5).map(move |seq| (reply, seq, seq == 4))).collect(); + assert_eq!(order, expected); + for reply in [0, 1] { + let bytes: Vec = audio.iter().filter(|(r, ..)| *r == reply).filter_map(|(_, _, data, _)| data.clone()).flatten().collect(); + assert_eq!(bytes.len(), 1310); + assert_eq!(bytes[..1300], (0..=255).cycle().take(1300).collect::>()[..]); + } + assert!(audio.iter().all(|(.., error)| error.is_none())); + let calls = synthesizer.calls.lock().unwrap().clone(); + let voice = "en-GB-SoniaNeural".to_owned(); + assert_eq!(calls, [("Two agents are working.".to_owned(), voice.clone()), ("One needs you.".to_owned(), voice)]); + } + + #[tokio::test] + async fn a_failed_synthesis_ends_its_reply_early_marked_and_the_next_one_is_still_spoken() { + let script = logged_in(); + script.lock().unwrap().turn_requests = + vec![tool_call("speak", json!({"text": "fail: cut short"})), tool_call("speak", json!({"text": "还在。"}))]; + let synthesizer = Arc::new(FakeSynthesizer { pieces: vec![vec![1; 100]], ..Default::default() }); + let rig = Rig::speaking("speak-fail", Ok("hi"), script, Some(synthesizer.clone()), None); + rig.feed(&voice_lines(1, &sine(12_000))).unwrap().await.unwrap(); + + let audio = audio(&rig.transport.events()); + assert_eq!( + audio, + [ + (0, 0, Some(vec![1; 100]), None), + (0, 1, None, Some("synthesis".to_owned())), + (1, 0, Some(vec![1; 100]), None), + (1, 1, None, None), + ] + ); + assert_eq!(rig.names().last().map(String::as_str), Some("voice.done")); + // No Character pack: the built-in Character's voice, and a Chinese one for Chinese. + let voices: Vec = synthesizer.calls.lock().unwrap().iter().map(|(_, voice)| voice.clone()).collect(); + assert_eq!(voices, ["en-US-JennyNeural", "zh-CN-XiaoxiaoNeural"]); + } + + #[test] + fn a_stopped_speaker_sends_nothing_more() { + let synthesizer = FakeSynthesizer { pieces: vec![vec![1; 10]], ..Default::default() }; + let transport = RecordingTransport::default(); + speak(1, 0, "hello", None, &synthesizer, &transport, &AtomicBool::new(true)); + assert!(transport.events().is_empty()); + assert!(synthesizer.calls.lock().unwrap().is_empty()); + } + + #[test] + fn the_largest_audio_piece_fits_a_line() { + let mut event = audio_event(u32::MAX, u32::MAX, u32::MAX); + event.extra.insert("data".to_owned(), base64::engine::general_purpose::STANDARD.encode([255u8; AUDIO_BYTES]).into()); + assert_eq!(event.extra["data"].as_str().unwrap().len(), 768); + assert!(event.to_ndjson().unwrap().len() <= MAX_MESSAGE_BYTES + 1); + } + #[tokio::test] async fn lost_lines_still_make_a_turn_of_the_recorded_length() { let rig = Rig::new("gaps", Ok("hello"), logged_in()); @@ -769,10 +1056,10 @@ pub mod tests { println!("{:.2} s of audio in {} lines", left.len() as f32 / SAMPLE_RATE as f32, lines.len()); let (end, data) = lines.split_last().unwrap(); for line in data { - assert!(voice.handle_line(line, &transport).is_none()); + assert!(voice.handle_line(line, &transport, None).is_none()); } *timed.end.lock().unwrap() = Some(Instant::now()); - voice.handle_line(end, &transport).expect("the turn runs").await.unwrap(); + voice.handle_line(end, &transport, None).expect("the turn runs").await.unwrap(); let events = timed.events.lock().unwrap(); let names: Vec<&str> = events.iter().map(|(_, event)| event.event.as_str()).collect(); @@ -782,6 +1069,44 @@ pub mod tests { let _ = std::fs::remove_dir_all(dir); } + /// Two replies through the real voice service, as the box would get them: the `voice.audio` pieces put back + /// together into `

/reply-zh.mp3` and `reply-en.mp3`. Run by hand: + /// + /// ```sh + /// VIBEBUDDY_E2E_OUT=/some/dir cargo test -p vibebuddyd e2e_spoken -- --ignored --nocapture + /// ``` + /// + /// `VIBEBUDDY_TTS_BASE_URL` picks the service as it does for the daemon; unset, staging. + #[test] + #[ignore = "uses the network and the voice service"] + fn e2e_spoken_replies_through_the_real_voice_service() { + let out = std::path::PathBuf::from(std::env::var("VIBEBUDDY_E2E_OUT").expect("VIBEBUDDY_E2E_OUT names a directory")); + let synthesizer = synthesis::from_env().expect("a voice service"); + for (name, character, text) in [ + ("zh", Some("wanwanxiaohe"), "两个智能体正在工作,有一个在等你回复。"), + ("en", Some("ada"), "Two agents are working, and one of them is waiting for you."), + ] { + let timed = TimedTransport { end: std::sync::Mutex::new(Some(Instant::now())), events: Default::default() }; + speak(1, 0, text, character, &*synthesizer, &timed, &AtomicBool::new(false)); + let events = timed.events.lock().unwrap(); + let (first_ms, _) = events.first().expect("some audio"); + let (total_ms, last) = events.last().unwrap(); + assert_eq!(last.extra.get("end"), Some(&json!(true))); + assert!(!last.extra.contains_key("error"), "synthesis failed"); + let pieces: Vec = events.iter().map(|(_, event)| event.clone()).collect(); + let bytes: Vec = audio(&pieces).into_iter().filter_map(|(_, _, data, _)| data).flatten().collect(); + let path = out.join(format!("reply-{name}.mp3")); + std::fs::write(&path, &bytes).unwrap(); + println!( + "{name} {}: first audio after {first_ms} ms, end after {total_ms} ms, {} bytes in {} lines -> {}", + synthesis::voice_for(character, text), + bytes.len(), + events.len(), + path.display() + ); + } + } + #[tokio::test] async fn voice_is_offered_only_with_a_recognizer_and_the_resident_session_on() { let dir = temp_dir("voice-offer"); @@ -798,7 +1123,7 @@ pub mod tests { let transport = Arc::new(RecordingTransport::default()); let shared: Arc = transport.clone(); for line in voice_lines(1, &sine(12_000)) { - voice.handle_line(&line, &shared); + voice.handle_line(&line, &shared, None); } assert_eq!(transport.events()[0].extra["reason"], "unavailable"); let _ = std::fs::remove_dir_all(dir); diff --git a/docs/resident-session.md b/docs/resident-session.md index a155bccf..3bb0cb46 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -36,7 +36,7 @@ The box side of push-to-talk is in [`protocol.md`](protocol.md), "Voice commands - **Offered.** Voice is on when the resident session is on and a speech recognizer is configured. The daemon then sends `voice.available` `on: true` every time the box reports its build (after `device.hello` on each new link, and after a reboot). Never over the Relay, where the box will talk to the voice service itself, and never over the UART bridge, which is too slow for the audio and gets none from the box (use the box's native USB port); the log says which kept voice off. Nothing switches either condition at run time yet, so `on: false` is never sent. - **Recording.** `VOICE DATA` lines are decoded as they arrive, each from the IMA ADPCM state it carries (the same decoder as `firmware-rs/core/src/adpcm.rs`, checked against it in the tests). On `VOICE END` the lines are put in seq order; a lost line becomes silence as long as a line, audio lost at the end is filled up to the sample count `END` gives, and the missing seqs are logged. A turn's audio is kept only in memory and only until it is recognised; `VOICE DATA` lines stay out of the log. -- **A turn.** The audio goes to the recognizer as a 24 kHz 16-bit mono WAV file; the text goes to the box as `voice.heard` and to the resident session as a voice command; each `speak` goes to the box as `voice.reply` as soon as it is called; `voice.done` follows `turn/completed`. Text is cut at a character, with an ellipsis, to fit the 1024-byte line. Errors: `not_heard` (no audio arrived, or the recognizer heard nothing), `unavailable` (voice is off, recognition failed, or the resident session failed, with the reason as `message`), `busy` (a turn started while another is still being answered: sent right after its `VOICE START`, so the box stops recording, and the rest of that turn is dropped; unlike `vibebuddy say`, a voice turn doesn't wait). A typed command and a voice turn still queue behind each other in the resident session. +- **A turn.** The audio goes to the recognizer as a 24 kHz 16-bit mono WAV file; the text goes to the box as `voice.heard` and to the resident session as a voice command; each `speak` goes to the box as `voice.reply` as soon as it is called, and its audio follows as `voice.audio` (see "Speech synthesis" below); `voice.done` follows `turn/completed` and the last reply's audio `end`. Text is cut at a character, with an ellipsis, to fit the 1024-byte line. Errors: `not_heard` (no audio arrived, or the recognizer heard nothing), `unavailable` (voice is off, recognition failed, or the resident session failed, with the reason as `message`), `busy` (a turn started while another is still being answered: sent right after its `VOICE START`, so the box stops recording, and the rest of that turn is dropped; unlike `vibebuddy say`, a voice turn doesn't wait). A typed command and a voice turn still queue behind each other in the resident session. - **Timing.** A 5 s recording, checked end to end on 2026-10-13 (recognition by Workers AI, codex-cli 0.162.0, app-server started cold): `voice.heard` 2.2 s after `VOICE END`, the spoken reply at 10.1 s, `voice.done` at 14.0 s. The end-to-end check is an ignored test, `e2e_a_real_recording_through_the_real_recognizer_and_resident_session` in `voice.rs`: it turns the left channel of a WAV file into `VOICE` lines with firmware-core's own voice command code (as do the other voice tests, so they hold the daemon to what the box writes) and feeds them through the real recognizer and the real resident session, in a temporary state directory. Its doc comment says how to run it; give it a `VIBEBUDDY_CODEX` that adds `-c features.hooks=false`, or the turn's hooks reach the daemon on 7331 and show up on the box. @@ -54,6 +54,23 @@ Behind the `Recognizer` trait, so the planned voice service in `vibebuddy-cloud` Each setting is taken from the environment, else from `speech.json` in the config directory (`{"base_url","api_key","model","language"}`, the user's own recognizer), else from a `KEY=VALUE` file named by `VIBEBUDDY_STT_ENV_FILE` (development credentials, so they stay out of the daemon's environment and out of the repository). All are read when the daemon starts. `speech.json` is a file of its own, not part of `config.json`, because `PUT /v1/config` replaces the whole config and an App that doesn't know the field would wipe the key; whoever writes it (an App setting, later) writes it with mode 0600. Credentials are never logged, and errors name only the recognizer's host. +### Speech synthesis + +Each spoken reply is also synthesized and streamed to the box (`daemon/src/synthesis.rs`, `Speaker` in `voice.rs`), behind the `Synthesizer` trait so the backend can change. There is one backend, the voice service in `vibebuddy-cloud`: `POST {base}/v0/voice/speak` with `{"text","voice"}` (text 1 to 1000 characters, `voice` an Edge voice name), answering a chunked `audio/mpeg` stream (MPEG-2 Layer III, 24 kHz mono, 48 kbps) that starts as soon as there is audio. v0 takes no credentials; the account session's header goes in `VoiceService::speak` when it does. + +| Setting | Effect | +| --- | --- | +| `VIBEBUDDY_TTS_BASE_URL` unset | Staging (`https://api-staging.korekore.ai`) in every build but a release, as for the Relay; a release has no voice service until production's is deployed, so its replies stay text | +| `VIBEBUDDY_TTS_BASE_URL=` | That service | +| `VIBEBUDDY_TTS_BASE_URL=off` | No synthesis: replies stay text | + +- **Order.** Replies are synthesized one after another, in the order they were said, on a task of their own: a reply's `voice.reply` goes out the moment it is said and never waits for an earlier reply's audio, so the text of reply 1 may come between reply 0's audio pieces. Each reply's audio is forwarded as the bytes arrive, cut into pieces of at most 576 bytes (768 base64 characters), `seq` from 0, then `end`. `voice.done` waits for the last reply's `end`, so the turn is over only once everything has been sent; it is still the last message of the turn. +- **Failures.** A status other than 200, or a stream cut before its end (the service closes the connection when it fails partway), ends that reply's audio at once with `"error":"synthesis"`, keeping what was sent; the box keeps the text. The next reply is still tried. When the resident session fails the turn, the reply being synthesized stops at its next piece, the rest are dropped, and `voice.error` follows. +- **Long replies.** Over 1000 characters (counted in UTF-16, as the service counts), a reply is split at the end of a sentence, else at a comma or space; the pieces are requested one after another and stream as one reply, since MP3 frames stand alone. +- **Voices.** For development, `CHARACTER_VOICES` in `synthesis.rs` gives each Character an Edge voice roughly matching its persona (Xiaohe `zh-TW-HsiaoChenNeural`, Ada `en-GB-SoniaNeural`, Luna `en-US-AvaNeural`, Mei `en-US-AriaNeural`, Hank `en-US-ChristopherNeural`, Kai `en-US-AndrewNeural`, Dave `en-GB-RyanNeural`, the robot and no pack `en-US-JennyNeural`). The Character is the one the box reports (`VOICES`), else the one last written to it. A Character speaks one language but the resident session answers in the user's, so a reply with Chinese in it gets `zh-CN-XiaoxiaoNeural` when the Character's voice isn't Chinese. Production voices are still to be decided; that table is the one place to change. +- **Links.** Only where voice is offered, native USB and the local network: nothing goes over the Relay or the UART bridge. +- **Timing.** Checked on 2026-10-13 against staging, from this computer through the daemon's own client (a new TLS connection each time): a Chinese sentence, first audio after 1.3 to 1.4 s and its `end` after 1.5 to 1.6 s for 4.5 s of speech; an English one, 0.5 to 1.8 s and 0.6 to 2.0 s for 3.8 s. `e2e_spoken_replies_through_the_real_voice_service` in `voice.rs` (ignored) repeats it and saves the reassembled MP3s. + ## Never an Activity The user's Codex hooks fire for the resident thread too: its `UserPromptSubmit`, `PostToolUse` and `Stop` reach `/v1/codex-hooks` like any session's. The Codex adapter drops a hook whose session or thread is the resident thread, or whose `cwd` is inside the resident directory (which also covers the thread opened in the Codex App), before anything is recorded: no card, announcement, stats, CI watch or K2 target. This works even with the resident session switched off. @@ -67,3 +84,5 @@ app-server and `dynamicTools` are experimental. This was built and checked again `list_activities` sends the task-card titles and project names the daemon holds, with each Activity's agent, state, host app and ages, to the model provider the user's Codex uses, as part of the resident thread. Until now these stayed on the computer and the box. Prompts and replies are not sent: the daemon never holds them. A voice command's audio leaves the computer for the speech recognizer: in development that is Cloudflare Workers AI, under the developer's own account; with the user's own recognizer, only that endpoint (and its key goes nowhere else). The recognised text then goes to the user's Codex model provider as the voice command. The daemon keeps neither the audio nor the text once the turn is over, and logs neither. + +Each spoken reply's text, which may name what the Agents are doing, goes to the voice service to be synthesized (in development, staging), with the voice name; the service passes it on to the speech engine behind it (Edge voices for now). The audio it returns goes only to the box. The daemon logs the voice, timings and byte counts, never the text. From 5e2d1d1d2247aa783603d6fe2874124bedf142b0 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 18:34:32 +0800 Subject: [PATCH 23/51] docs: voice.done follows the last reply's audio Co-Authored-By: Claude Opus 5.5 --- docs/protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/protocol.md b/docs/protocol.md index 8dfc5961..e00e2138 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -212,7 +212,7 @@ The computer answers with JSON events carrying the same `turn`: - `voice.heard` with `text`: what was recognised (cut to fit the line), so the box can show it. - `voice.reply` with `text`: one spoken reply, sent the moment the resident session says it; a turn may have several. -- `voice.done`: the turn is over. The box leaves what it heard and the replies up for 15 s to be read (K2 closes them sooner), then goes back to what it showed before; with nothing to show, it goes back at once. +- `voice.done`: the turn is over; it comes after the last reply's audio `end`. The box leaves what it heard and the replies up for 15 s to be read (K2 closes them sooner), then goes back to what it showed before; with nothing to show, it goes back at once. - `voice.error` with `reason` (`not_heard` when recognition returned nothing or no audio arrived, `unavailable` when recognition or the resident session failed, `busy` when another turn is still running) and an optional `message`; the turn is over. It may come while the box is still recording (`busy` right after `START`): the box stops recording and sends nothing more for that turn. The box shows `message` if there is one, otherwise a short text of its own, for 6 s. `voice.error` is also the name of the box's reply to a failed voice pack write ("Device maintenance" above). The two never meet: that one only goes from the box to the computer, this one only from the computer to the box, and it carries `turn`. From 32e392b7882b0200969f21b81858588960ccf227 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 18:45:48 +0800 Subject: [PATCH 24/51] Draw Chinese on the box with a 12 px pixel font The screen only had the 5x7 ASCII font, so voice transcripts, spoken replies and Chinese session titles showed as placeholders. font.rs adds 3,678 glyphs from Fusion Pixel Font 12 px (OFL 1.1): the 3,500 characters of level one of the 2013 Table of General Standard Chinese Characters, CJK punctuation and the full-width forms. Ark Pixel alone lacks over a hundred level-one characters at 12 px; Fusion fills them from Cubic 11. tools/make-cjk-font.py builds the table from pinned downloads (the font and Unihan's kTGH field): a sorted u16 index and one packed 12x12 bitmap per glyph, 73,562 bytes, included as read-only data so it stays in flash. The main stack is unchanged at 42,128 bytes. Canvas::draw_str draws UTF-8 with ASCII in the old font and Chinese centred on the same line, cuts at whole characters, and draws a dotted box for anything the table lacks. font::wrap breaks between Chinese characters, keeps ASCII words whole and hangs closing punctuation. Task card titles, projects, the title line and the voice overlay use it; text with Chinese goes to small type in the overlay. English screens are unchanged: compare-display.sh finds all 826 frames identical, and the voice and menu previews match byte for byte. Co-Authored-By: Claude Opus 5.5 --- LICENSE-ASSETS | 7 + docs/device-menu.md | 2 +- docs/firmware-bringup.md | 16 + docs/images/device-cards-zh.png | Bin 0 -> 3537 bytes docs/images/voice-reply-zh-small.png | Bin 0 -> 5206 bytes docs/images/voice-reply-zh.png | Bin 0 -> 3059 bytes firmware-rs/core/examples/voice_preview.rs | 20 +- .../core/fonts/LICENSES/ark-pixel/OFL.txt | 96 +++++ .../core/fonts/LICENSES/cubic-11/OFL.txt | 108 ++++++ .../core/fonts/LICENSES/galmuri/LICENSE.txt | 93 +++++ firmware-rs/core/fonts/OFL.txt | 96 +++++ firmware-rs/core/fonts/cjk-12px.bin | Bin 0 -> 73562 bytes firmware-rs/core/src/canvas.rs | 136 ++++++- firmware-rs/core/src/display.rs | 44 ++- firmware-rs/core/src/font.rs | 343 ++++++++++++++++++ firmware-rs/core/src/lib.rs | 1 + firmware-rs/core/src/text.rs | 21 +- firmware-rs/core/src/voice_command.rs | 71 +--- tools/make-cjk-font.py | 186 ++++++++++ tools/package-licenses.py | 5 + 20 files changed, 1157 insertions(+), 88 deletions(-) create mode 100644 docs/images/device-cards-zh.png create mode 100644 docs/images/voice-reply-zh-small.png create mode 100644 docs/images/voice-reply-zh.png create mode 100644 firmware-rs/core/fonts/LICENSES/ark-pixel/OFL.txt create mode 100644 firmware-rs/core/fonts/LICENSES/cubic-11/OFL.txt create mode 100644 firmware-rs/core/fonts/LICENSES/galmuri/LICENSE.txt create mode 100644 firmware-rs/core/fonts/OFL.txt create mode 100644 firmware-rs/core/fonts/cjk-12px.bin create mode 100644 firmware-rs/core/src/font.rs create mode 100755 tools/make-cjk-font.py diff --git a/LICENSE-ASSETS b/LICENSE-ASSETS index 568367aa..760bd5ec 100644 --- a/LICENSE-ASSETS +++ b/LICENSE-ASSETS @@ -31,6 +31,13 @@ terms of the service that produced it: - characters/*/pack.bin, the Characters' synthesized lines (Volcano Engine Doubao TTS) +Not covered, because it is a third-party font under its own license: + +- firmware-rs/core/fonts/cjk-12px.bin, the firmware's Chinese glyphs: a + subset of Fusion Pixel Font 12 px by TakWolf, built from Ark Pixel Font + and Cubic 11, under the SIL Open Font License 1.1. Its license files are + next to it (OFL.txt, LICENSES/) and ship with the firmware as FONTS.txt. + Neither license grants rights to the name "VibeBuddy" or to the buddy character as a trademark. diff --git a/docs/device-menu.md b/docs/device-menu.md index 28f77d6d..ec7d9ab3 100644 --- a/docs/device-menu.md +++ b/docs/device-menu.md @@ -110,7 +110,7 @@ A voice command has an overlay of its own, drawn like the menu's panel and, like - After the computer says the turn is done, the text stays up 15 s to be read. An error shows a short message for 6 s: NOT HEARD, UNAVAILABLE, BUSY, or NO MICROPHONE when the box couldn't start recording. With nothing from the computer for a minute, the overlay closes on its own. - K2 closes the overlay; a long K0 starts the next command; any other key closes it and does its own job. - **Events that need the user win**, as with the menu: input required, failed, or a Pomodoro phase ending closes the overlay, and a recording in progress is given up (`VOICE CANCEL`). -- **Text only.** Spoken replies are text until there is speech synthesis, so mute changes nothing here. The firmware's 5×7 font is ASCII only: there are no CJK glyphs, so Chinese shows as one placeholder per character. Curly quotes become straight ones. +- **Text only.** Spoken replies are text until there is speech synthesis, so mute changes nothing here. Chinese draws in a 12 px pixel font beside the 5×7 ASCII one, and text with Chinese in it uses small type throughout ([`firmware-bringup.md`](firmware-bringup.md), "Fonts"). ## Constraints from the hardware diff --git a/docs/firmware-bringup.md b/docs/firmware-bringup.md index 41029d88..3fc6fe7b 100644 --- a/docs/firmware-bringup.md +++ b/docs/firmware-bringup.md @@ -45,6 +45,22 @@ The review fixes applied before bring-up (see the PR) were in place, so it can't - Settings (today's pomodoro tally, the volume) use a new storage format, so they reset once when the Rust firmware is first flashed. - `voice.begin` interrupts the line currently playing before erasing the partition; the C firmware waited up to 10 seconds for it to finish. - JSON parsing is stricter than cJSON: invalid UTF-8, `"version":1.0` and a non-string `message` get `ERROR invalid_message` (or `invalid_json`). The Mac never sends these. When `device.echo` is used to hunt corrupted bytes, a corrupted line now gets `ERROR invalid_json` instead of a CRC reply, which says the same thing. +- Task titles, project names and the voice overlay draw Chinese (see Fonts below); the C firmware draws a placeholder for every byte of it. ASCII is unchanged: `tools/compare-display.sh` still finds every English screen identical. + +## Fonts + +ASCII is the 5×7 bitmap font in `core/src/canvas.rs`, uppercase only, scaled 1×, 2× or 3×. Everything else comes from a 12 px Chinese font in `core/src/font.rs`: + +- **What's in it**: the 3,500 characters of level one of the Table of General Standard Chinese Characters (通用规范汉字表 一级, 2013), CJK punctuation (、。《》「」【】…), the full-width forms (,!?:;()) and a few more marks (“”‘’…—·→): 3,678 glyphs. A character outside it, an emoji say, draws as a dotted box the size of a Chinese character, so a gap is visible rather than silent. +- **Where it comes from**: [Fusion Pixel Font](https://github.com/TakWolf/fusion-pixel-font) 12 px monospaced, zh_Hans shapes, under the SIL Open Font License 1.1. Fusion is Ark Pixel Font with the characters Ark hasn't drawn at 12 px yet (over a hundred of level one, among them 热, 然 and 紧) filled in from Cubic 11. It was chosen because it is a pixel font drawn for 12 px, not an outline font rasterized down: strokes land on whole pixels and every glyph keeps a blank row and column for spacing, so a line of Chinese stays legible on the 320×240 panel (Muse's 16 px Unifont captions, which fill their whole cell, ran together). 12 px is also the smallest size that keeps level-one characters readable, and close to 2× ASCII's 14 px. +- **How it's built**: `tools/make-cjk-font.py` downloads the font and Unicode's Unihan database (whose `kTGH` field numbers the 2013 table) pinned by SHA-256, and writes `core/fonts/cjk-12px.bin`: a sorted index of 16-bit codepoints, looked up by binary search, then one packed 1-bit 12×12 bitmap per glyph, 18 bytes. 73,562 bytes in all, committed, plus the font's license files beside it, which the firmware zip carries as `FONTS.txt`. The table is `include_bytes!`, read-only data that stays in flash: it adds about 75 KB to the app image and nothing to RAM or the main stack. +- **Layout**: a Chinese character advances 12 px and is centred on the ASCII line, which at 1× puts the font's own baseline on the ASCII baseline. It stays 12 px next to 2× ASCII (titles, large type). Width is measured per glyph (`font::width`, `font::fit`); text is cut at whole characters. `font::wrap` breaks between any two Chinese characters but never inside an ASCII word unless the word is longer than a row, and lets closing punctuation (,。!? and the like) hang past the row's end rather than start a row. Curly single quotes next to English letters draw as the ASCII apostrophe. +- **Where it's used**: task card titles and project names, the title under the buddy, the agent line in Pomodoro mode, and the voice overlay. Menus and labels stay ASCII. The daemon still squashes session titles to uppercase ASCII before sending them ([`pet.md`](pet.md)), so Chinese titles reach the cards only once it stops doing that for firmware that can draw them; voice text already arrives as it was spoken. +- **Voice overlay**: text with Chinese in it goes straight to small type (rows 13 px apart, 10 rows); large type, with doubled ASCII beside 12 px Chinese, looked shouty. English-only text keeps large then small type as before. + +![Chinese task titles](images/device-cards-zh.png) ![A Chinese reply](images/voice-reply-zh.png) ![A longer one, mixed with English](images/voice-reply-zh-small.png) + +The pictures come from `cargo run -p vibebuddy-firmware-core --example voice_preview -- `. ## Steps diff --git a/docs/images/device-cards-zh.png b/docs/images/device-cards-zh.png new file mode 100644 index 0000000000000000000000000000000000000000..a74c8d5271c6b8429d703e38aef5eec56123674e GIT binary patch literal 3537 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 U=;74q$!+UyWo~aqF!j0mF97_PBme*a literal 0 HcmV?d00001 diff --git a/firmware-rs/core/examples/voice_preview.rs b/firmware-rs/core/examples/voice_preview.rs index 7f44b890..e6bbf686 100644 --- a/firmware-rs/core/examples/voice_preview.rs +++ b/firmware-rs/core/examples/voice_preview.rs @@ -1,6 +1,7 @@ //! Renders a voice command's overlay (docs/device-menu.md, "Voice commands") to PPM on the Mac: the real //! firmware on a fake board, with a fake microphone and the computer's answers fed in as lines, so the -//! pictures in the docs are what the box shows. +//! pictures in the docs are what the box shows. Also the Chinese text pictures (docs/firmware-bringup.md, +//! "Fonts"). //! //! Usage: cargo run -p vibebuddy-firmware-core --example voice_preview -- @@ -171,4 +172,21 @@ fn main() { preview.release_k0(); preview.send(r#"{"version":1,"event":"voice.error","turn":2,"reason":"not_heard"}"#); preview.snapshot("voice-error"); + + // Chinese (font.rs): session titles on the task cards, then a voice turn in large and in small type. + let mut preview = Preview::new(&directory); + preview.send( + r#"{"version":1,"event":"task.start","title":"CC:给盒子加中文字体","tasks":[{"title":"CC:给盒子加中文字体","status":"working","elapsed_s":420,"project":"VIBE-BUDDY"},{"title":"CX:修好语音回复的播放","status":"input_required","elapsed_s":95,"project":"VIBE-BUDDY"},{"title":"CC:整理发布说明","status":"done","elapsed_s":1500,"project":"官网"}]}"#, + ); + preview.snapshot("device-cards-zh"); + preview.send(r#"{"version":1,"event":"voice.available","on":true}"#); + preview.hold_k0(2500, 4000.0); + preview.release_k0(); + preview.send(r#"{"version":1,"event":"voice.heard","turn":1,"text":"Codex 现在在忙什么?"}"#); + preview.send(r#"{"version":1,"event":"voice.reply","turn":1,"text":"它在写 v0.5.1 的发布说明,大概还要五分钟。写完我提醒你。"}"#); + preview.snapshot("voice-reply-zh"); + preview.send( + r#"{"version":1,"event":"voice.reply","turn":1,"text":"另外,Claude Code 那边在等你确认:要不要把字体表放进 flash 的只读数据里?它估计会多占七十多 KB,但完全不占内存。你说“可以”,我就让它继续。"}"#, + ); + preview.snapshot("voice-reply-zh-small"); } diff --git a/firmware-rs/core/fonts/LICENSES/ark-pixel/OFL.txt b/firmware-rs/core/fonts/LICENSES/ark-pixel/OFL.txt new file mode 100644 index 00000000..264873a2 --- /dev/null +++ b/firmware-rs/core/fonts/LICENSES/ark-pixel/OFL.txt @@ -0,0 +1,96 @@ +Ark Pixel Font +https://github.com/TakWolf/ark-pixel-font + +Copyright (c) 2021, TakWolf (https://takwolf.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/firmware-rs/core/fonts/LICENSES/cubic-11/OFL.txt b/firmware-rs/core/fonts/LICENSES/cubic-11/OFL.txt new file mode 100644 index 00000000..4e863313 --- /dev/null +++ b/firmware-rs/core/fonts/LICENSES/cubic-11/OFL.txt @@ -0,0 +1,108 @@ +[Cubic 11] +These fonts are free software. +Unlimited permission is granted to use, copy, and distribute them, with or without modification, either commercially or noncommercially. +THESE FONTS ARE PROVIDED "AS IS" WITHOUT WARRANTY. +此字型是免費的。 +無論您是否進行對本字型進行商業或非商業性修改,均可無限制地使用,複製和分發它們。 +本字型的衍生品之授權必須與此字型相同,且不作任何擔保。 +[JF Dot M+H 12] +Copyright(c) 2005 M+ FONTS PROJECT +[M+ BITMAP FONTS] +Copyright (C) 2002-2004 COZ +These fonts are free software. +Unlimited permission is granted to use, copy, and distribute it, with or without modification, either commercially and noncommercially. +THESE FONTS ARE PROVIDED "AS IS" WITHOUT WARRANTY. +これらのフォントはフリー(自由な)ソフトウエアです。 +あらゆる改変の有無に関わらず、また商業的な利用であっても、自由にご利用、複製、再配布することができますが、全て無保証とさせていただきます。 + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/firmware-rs/core/fonts/LICENSES/galmuri/LICENSE.txt b/firmware-rs/core/fonts/LICENSES/galmuri/LICENSE.txt new file mode 100644 index 00000000..ae34182f --- /dev/null +++ b/firmware-rs/core/fonts/LICENSES/galmuri/LICENSE.txt @@ -0,0 +1,93 @@ +Copyright (c) 2019–2025 Lee Minseo (quiple@quiple.dev) + +This font software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/firmware-rs/core/fonts/OFL.txt b/firmware-rs/core/fonts/OFL.txt new file mode 100644 index 00000000..0427d1db --- /dev/null +++ b/firmware-rs/core/fonts/OFL.txt @@ -0,0 +1,96 @@ +Fusion Pixel Font +https://github.com/TakWolf/fusion-pixel-font + +Copyright (c) 2022, TakWolf (https://takwolf.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/firmware-rs/core/fonts/cjk-12px.bin b/firmware-rs/core/fonts/cjk-12px.bin new file mode 100644 index 0000000000000000000000000000000000000000..fb877d4f99c7a44e1b700b2c25ab6d6cb4a5a0e5 GIT binary patch literal 73562 zcmZsC3tZA?_xM-DL^363TWhV5FwtC6Yg=2*kg$-9yBE>~!E53r%ew(0DvAg?Z&@>S zT1KV2ynA;qbJkjGZR>6Ky_*>|bKTZ@w{iym^L$Zjzt7*#(>FLgmvev4bI$Wk@_7gI zr!JsAPF+ZSlDe4sG&PXAoVt!`4QLE#3V0;|W4bX3Om`-c>A{@C^kkBlUQ9A`F4LPi zkLknoWm1^)nSRX2nEuQK%*UBeFc&hPWG-Sp#azsMnz@9zlu2a=lAKcD{?-=F_Dep7Jf8;E1%C7@P&LaU&5F2WBKuX8Gk!Ji7)3X_{sb< zeja}(e-~fFujU*1wR{u5p5MT?@EiF}{APX&zk|Pz-_7sg_wxJr2l7iWdcj7)Cc#z#Umy?&1tNh&5F?NY5(J3?g+L|96zmdc1qOjpU=o-G`vk8Fx&*HY z`UD3BhXnnC0l}c)ZNUk_$AS^TS-}^AF9qKUz7zaca7l1Ya6@oYa7*yJU{Wwo=qL0S zE)+f~TqIm73=l3Cz9d{P3>R(|@`VE7HesSLRahu25^9B2LbI@6cvSeN@B`r&!XJdc z3jZhkTj(d+Ac_!e6mdk6qD>;MC`z*C0!$ZPP$h5qI8{fy)<0Pl5Uc6r97!n8Y7LD zCP)*dN@<34m$X1yCM}olks72XX}#1UwMv_$Ez(x$Ug;reztko@Dt$xxmh@feN75nb zN$H66wDc3{8R@6e&!y+3UrE1}{vy31y(%4-UXxBpZ%A)S??@-5Q&P8>IWe9wUNMvy zY79LlC?+&!WlUJis+jPYjWL{FNhC_r^koHGvl9&kB%3`OXK6? zlj5`DE90x;Uy0uv-x+@(zCZqO{KeX@Spfb2cl2eQ+$v$D@*U&_9c z{YQ3D_Jiyv*%jHi>~EP<=C;j!8*!WGwuRdkZ(F)8aNDYF&u`nbEo$4gZ9BH@-d4Cx zyRB(k$F{e&o!mCO?bB`Nwq4sMOpqqTB*Z1eC&&`EC&&|22`LF_37H86340PM5-Jm_ z5^55R3AG8Pg!%+aLR&(6!rp|AgnbG76JAZ|Oz2AJPUuN^EulBzV1g~-NW$w0ZzcRI z;q8QfC%l*NQNrAY!7Y)PI4997k(}t8I6u)p@$tlkiHj1SPNXLWC59%hN?en;Hu0sz^@%Shh9_=F zWFYu{6s;bC^056DN&J_oS2%Jo47NvBvG4Kk*H5>PHatVOWc=u zIPqBGTZ!)`UP`>0IG%VT@pj_hiJseiw)<~?eEU<|gSS7kJ$n18?VoS|@AjX!U)nyl z{mS;M+kf3YzWv(v-?rb}{`>Yj+yB^pcl%%49oyZK2ubcq9!YbONJ-vFl%xeok0&im zT9mXn>FK29NzWv$OnM<{ZPLq0kx9`>F-gj#{G?q;yORo&ij&Hc%9HjaX_E{|)k!r; zbxD>aYtk!8%}FguZAp8RI+FG$btWB1I+%1Q=~&X+N&ilIC+Xd!Pm|6j{U_l>D^( zocwe7xAO1g-^)kkzsi4;|1STZ{DIsl#}sY~ABCU7U$H>(xMH#5X$4ggpa@h1DHw_n zMVMlpVxwYY>8qqD=PUh`k1H1{7b!!O&nnj_Ur=sPvXl|Z&B`sxtxCR9 zq!cS9N~tnlnV?iD)0LUZETvkRt;|v8D)W@Pl!eL?Ww}zL)GBpKgR)N9sBBWcqHIyN zD%+Ip$`0i|WtXyB*`w@L_9>4jk1F3#zO8&u`JwV7<*;%@c}jU&`HAvV6CseDxPRgbF{sg|m!ssPn86afbDdR_I7>V4Ixs?SwlsJ>KPP+d}8R$Ws~sBWn4svfAY6t@&YibslX%3~@1 zDT`BaLUn?V<`hE zucy47@?pww%BhskQ_iPcNV%GVrG}+GpZa3zy43L04XKf-+|=k)L8>TKmYR^7l&VZs zrKY8-Q}a^uQ+KBprPidHQtMM2Q`=JartVKYklLHtmwG7maO$zt!PM7N-%I@{^Yu59rB0?2(&nUjr}?D$rahMCpSCoO zniiNAl*UMVCM_&&eOh?h#WpeOgUg zV_IukTiQU{ztX->`)}Iqv^!~c)9$BDr8&}^X;?Zb-79@=`n>cf(w|CyI(=#S@^nUe zNcxKO(DbnMRq1QepG$u!{pEC4Iy-%1`lj@#bbh)ZU6?LPk4;ZZm!~VzlhaevGt#ru zbJO$EHR;;)igZJIZMrqRCA}@ZBYl7RtLZ)Iuch14kE9Q#|2uso{fqQ3)4xi;kp7?a zi|Ie5kEUNqzn=bk`XA|c)1B!a8J-!WjJX-U8S^t9%XmEFiHv}Zkc<~H)@Hnzu|DJF z40guGj7=HbjOYwuhA2atp~^_jNXy8`$jm6rsK}_wsLp81=*c*kaVVod<5+7uVvwqCFl=WNItt?;leDz~$fAv#pnmS0$P_I;nsn@92 zsw32F^=5UHI!>LS-mXqk%hd{XvN}b*L!GBCQSVV#s4LY*b*_0Q@{>M`|Y^%eEFdP03$ z{fGL0>c7=f>IZ7K?77)~*-vCYmAxc8FgqlBb@saK_1Q0Hhi6A*M`mxz-kiS%W~*B!8u_$t8>=mtj&2TXJbxmPJE6gN1IcbQH{s=f>wI=4R*S zBeVBJLZ#eHv-gkN5=lz)XOP)RNYTmDTzvbP?yPxN^le}}m&P6+y z?_}Pi>^!>jDgS!@jr^PWxAT9`zn4Fi@3ZUiT~F;=yeoKD$gUN;LU*m)wPsiN zt_`~kyJ~jT?lSFa-L-Gm;ax{|4eok<*PFZE+4aG$v%5auHM;A@F8AFYyXWlo+D+L_ z+x_hBHM`gEmhDd3E#IBDyK?uT-9POf+kJVrefQPffA0Qk_riij1&a%o76cYB3W5t( z6fg@`6|63Jw&1yf7Yf!EtSg8th%OKmNDE{Ii3OFind{^*e!C1jW!R>;(1@{Y<7Saos7X}xG z6s{;-TNqv#U8pK7E-WkDQ>ZD_7a9ty3u_8rDQqnqD14*vMB(YeGlgdhzbO2&@T=Mb8(#P_({? zRTNPqEJ`R!E>abx6=fBvi%NX*x?FUn z=vvWlMK_9W7X4A=C~_9L6%&fRi{};l7e8LSsF+#2rg&{}crmLuvN)!AdvR8=x;Uq} zzSvycP~2GDR@`2Ep!iVn>&0&rzg2v^_+;^^;yhKrwT9Q$cT~bt1T%s$fC^3{&l~_vNEBT=0qmtt#LnS9mJ}x<3@@dIuC7+jk zRdS)^yOLi@>?K!Ael59Ha;xNa$-R=lOCFS9rE^Q?mHLz}Dy5b_Te_}ved)`k8%j5o z#+B|aEi5f6EiKiSR+Ltj8cNNjjipVct)*?H?WOxmUoAaQ+E;p_^s~}$O204tx%8LP z(bCJMS4wY`-YUIc>MWgGHowfj?8&mH%9fWg%0kQ5mqnFrDHD_l%S2`3GHF?CSwh+N zvZAt*GEJGTtfj22th?-R*^#niWp9*yQud#+OJz69elPp0%(FbGd~NxL@~!2&%FD|4 zl-HIwmA93*mmetaE$=J0l@FA^QT|@}@$$3fUzdMV{(bo`<)h`d%Kt7W@1gFY?Frhm zVo%tfoIMemE=`Z7S94HvNMq9+(HzwbYTnSisd-EDw&oqpyPEej?`uBNoX~u%8PS~5 zoYs7zIioqN`CM~e^M&Rs%>~VWG+%4J(|oV_LGzR5znY&lzi7rZmo>j?u4yJTH#E03 ze`xM%{-^m%b5Ap+d7yD>Fs+Anj@C=-qkT;4uU(*hT)R-aNc*&ws- z+LyFKtwuPkhI+Lzm*Q#sRb?El#Ue$H!x^+FeUR|H=u+F9%&<*O|)cs5MzV5j0glpgsQ9DeZpBmuruWd#(R=IX>HYNn`X&0M`Vf7no~d7@e@_3r{zd&}eY`$b zpQq2)@6wm*HF~|?q;Jyi)$iB8s_)Yu)F0I!(+}$3(7&aBPydnr6aDAL{;K|W{e8V#C85&2l33|c=~d}d8C1EVGPLr!%I7QBR&J_{suWg=E2Wjm zm8!~=%Jj;t%G}DL%G$~!m2XtOS@}-odzJ53epLBM<=M(}l|NOER$i<8z4A^a(co>E zXYesRZJ-+h4M7Hm;blXdA;FMrP#LleYQs)LfkAID8TK0X8{Ra$YZx(nYWT0=x?z6R zV^vR7EvgEr3awgS^>S5I6|ZV{m8Qy2Ra3RE%2qX4^=Z`?RbN(pU1hKOyJ~(lzgkc& ztQJ*otInz}soqnqsn%6jRO_qjs_Uy?scxx$t-80`Ry|lfRQ*ZyXVu?V|5$CWzFF<4 z#%c&Pq#D1P$7&YTJW;c-W^qkG&AOV+HBmLuHT;^m8d;5^W_L|tO=XR_=KY$Xn$tC( z)!eUHV5AvY#z@0s2=eQ5f~ zblh~pG-Nty8Zn(VoiTlCI&1pe^p)vr)3>G{O#e0gY`SC`GhH!VHT`C~Zn|N*W%}K8 z$8^{9Khs~Pd!|X#-=+s9hsmwpz22jKPCcofTtBydUcFB}rT(${MfHp8Y4ywM>GjL& z8TFy{&(yD~UsL}=eMJ4H`sn&?^~v=+>a*%g>PzeE>z_6+F)uX-m}%x^X1Y1hyxbgQ zW|)J`A?6k4Q1dh9mF6%r)4a;O#{8W51@l_-i{_Wi>&)xT;pPqI2s7Kf(abS#F-M#E zW`S8~mYQSD@#byj1ap#EVNNzH%_?(>InA79&NJ^e7nlppMdk`~mATGrG9NI%W`4{3 zFY|Hp$L7z>=geQ3e>DHs{EOLc9yd>zZ<+rv|IhrFdD2X7@NJ+q1UG~3eU|-}PRjwyYnFb?VapNA>y|ex?^!;w9Jidb3|mGlr!1dX z&REV{zO;O0xoG*$^1bCp%TJd7T7I_tVi~hcSbnqIu-vx%Ww~#8V8N_zR)W>tI>+i| zC0l*16sw=r-};1gk@YF-)7B-{rBmBt*=;LvmUhCtOM2$tS7B!t>>*@S;wt6tPiY&#wQ!28lxMxHi{eL8n-v9 z8dDp0G^!hSHWoA%H5NB&8*3VC8%>Q(jh{50Z9LcbdE@!UFB-pU+}&hsu5Es$d2jQp z&7I8$n!B2Nn){j$HXmv}+&s|yM)O|Ji)6 zd9vBjjJ3G65L-N2NG)?)yjv(OPqr*-320f?LT?FdS>Cd;C9H+n@?y)n7FNsV7GaC1 zC9x&1rLaZYGTt)La--#D%dM8%Eq7Y(w*1v{ujPKrWXsw& zTB}=4t(I16YjbOR>)zJ=t%q9w-Fm!rsP$y)Nb9NA)2*Mho@@Q0_3PI0)``|XTK{bQ ztMz`XqZMl-wt2RBwashuXbUZ{gn3z2$qgdn@)< z?XBKx+-urv-fP+0w6}Hd-o5>M)g3t&)xa zb{_2fpmU`2eCJo47dyY}yw&M*;IRYL1M3b54`>e<4(vT};DDQx;BE$Fl=Q_Qe^PE0TUnj*m-|6Rk%<1o3;C$TqgmaN!J?faVOT2`ug-fZ30$Di_Xvc71@n$t;A!Ce6pXIIOPu z1QLfe4W<$?ryZkCe`EQ$35ghMwoQX|MCT}=>iYKZSE8HH=l>u(J>Zf5h)~OYc%2HO`t{+ zjlxZ?wMSPOh%7Ea#~`pcFlrQWl?CG{ZWsmAkq|WiAYfGZ?uJo;y+oLZ4lE?<5CVV> zfknq${{b>?RM&49wjKt}G}qeb2~$ruPaTGp6IqEKm=K!h!!FojH2>N*H z@;osG$kr3f^VF4NoFl zT`rE@5=&2_=&T+X)dMp!qd{Mc^9j^&%u3M{sca%e z_kU-`t#xK0UkAd?b832Z{HseS2;w=#Vw0#9NDrU#@p+UAjEHps=UvbL&kz3qJ@Vy| z0sr&?&<8XF`#(TZa?~<_y8dPrSw{kG079o#o8v*PBUK;>QjBTs2OWm~@P{l#&LtdR zusIwKvJ_HJU7)`R=+cu!k^Xgzo>|&w`H#8S?(3N4KSm%Swp$$NY2b2bQkYkyr*w z1d$rO5b?*?;YOeYxKXDihy;!PK=;re5;qHTowqc)F#tsx+Sc-|xFjL@_dBnmVOT1z{mBl$Wg?o(KWbq*Mg z`#q4t20*_OeV{@g0#a}17NSA7bEexO$vYo%0gxD}V!wZr~UKhA=lG1=PUM^v5tM!uL4dXMH(Bl0^h& zx>2Y#cL4|!IFbW{h!G!>PKr@eUIGd+jJ)NU0j6kK4!~4j_F0$ir zNK-HkD8(qgx959MR=H7v3E~LG63l&y%I476MCTZh$^qB77$btOXE{gN$Wpoq)KIV~ zoog+yafA}iRm}G|n8RlwHh9(rAH;|Ty2UY{z#UQDb9Domf+**^EdfbTX#^HyF4AJ&gI3l(Fuqn`O@XsI}4x2lTIkJMqj+v?ExakyLpks?QM2tfMVrQg` z>(7Q^qyROC*OM$JMn9pD2qgL+V?=Ep(YK#prIm}Eg%;sST^Mc_QXy7Q9se-+}p zqodE&Z^C@Yv}TGZa1cR15d?E>-0%VZ zkneJ-7E(MG(lO`*Gs92Fb-;4?VxrNa>akhHi5gFeW!;kriw z>Mgi+yPPxr4DhDFD4=@2mtQ0J_K0Z@APT%Fs`A^u(g*wuVi&ISRM0C|0~~M&=5`y6 zb@K}dU4rNu#UNJvO3^i=t1Lh`z;l!sVZ4MXo@{VwFIr9%>DNdDZOOwhs`e6*RO3K2 zK|?+P*%x3An(s1UoMR+#yjmns$21Q|G3^ME1q=i$OCFl-;~XcP=$hp-8U>K0pqV&^ z+F=s7IAjGdN`Xe<%XWd!0n8yR#0XPV4k^ML_J0TuVQbMKe+bTVj-wl%c*6)-*a*1w z>A`Y4h7lYLaJ!*^GKX!TunnhYMlI}S`^}7EfeV$_b2+dH8tS-x5EZb-iiyn*&P(dQbajHwtWd(=OUIj}oKmnd=EeY6OZ&(99~d zH7wI($Wo^1q6qA;#X-x%2$W!A>y~2|qUfzrE=s|YsvN9(BiA5|BvK2v}&6nO4xusZ9-D}JJ9{6v9%F@ddsHom$8U)_OI0T632(IN7tM0m_XC^Q2n z9+w4hj>9ov1n)2)*_$GtrhN({KqzA1ZY2mtPzP@0Lck}$!@==pZJve9P(de@(;*}V zog_@rICS}Ski--a%kj`52!x8>Qy?3dh!Y9+i2w-)J_iyh^6RvX^bk%j8|fkRAv$n*IdG8ye<;91;By3V z3fFSrGbNZVjqpaIaWQ{pW(o`R3ayUj2_Pbw3hba!s3%wxl|IP|o#gh`aeF6Obnw0O zU`{hP+Q2pRZ{;)tlF$eAXJ!-{2(n;+D5IyBW_bVv$&}>{y32uIKtpg%uts@X1$Haf zP{&KT;OgVT8EF(g3Zw&?z#$&z8jM1N#zhy~8+^JZIuN)4kZB3>4E^Fwlb{R;XAV&i z-5kYf7C_{{Sq$+rxRhTM=qxbpZ9wa)9GqhgDLi|5+$)IlbfShgkGjzg4ksL+M;B(- zi$W=p9BEXbUj&iqqEv7l(TI#L@NtMzn2~p-Tkpn1N@F5-OVfZGAX7@jJn*6v0enDI zP{QmK*Qgm=b_9UZMo$8n$O+(mmf45d(F^&+@Mt9XQTsi5e5>3 zfq)^EmU53By@;QJ3G56`AJ{u30O71hWYQTNjvn(I>#@!J_3N8MR$_$3D~VM3by$}N zGUZkQ4|QQzFu}Rrs7MD7m~SUygmP-6gX`@OA+4KUoywVFZFLCgpdQ?f9_N9pxYhwn z9kcwweK;U&jj~(gJb?Dl&|WUA#p&IV2rASI+CryCI@tBR_>}e0Fi+KKjL;OwK{yNS zsB!6F-K*Erd7NXDoZhqiJ~4;R2aYi!S99tO`0AjIU>D$#dLpBz;J|^~MV?E01?P}l zHX5WdI#_{Ad#6}nr^ujSlmiY(g6lS3uWf6JYb}?Zma||sj+9jeTxA7~oRQ2sFY-tc zRiz-A5-1z4VB)Rxm@6YOkSfl;^wH;fp?0*5$q3L2Zf1W9lN<3@8~ zcN2`@IJ@7W_tuGz{Q#;fr?WQ)HUK^hx`SH}_<*I@czxls+}P3!!1r-(AX!M~X=EN0 z`4wEIxMs5JIdVHufQ$t^05@!e%Q1sGAu@4F!Ilw*xVC`80A0Xm2l85+NvRU*YWM(TkPE4 zCfvTj|2SZ+C}Q1=_UZ-Kbpkt3;o$X!pk2n}XC7F0TPrlP>d_4uuXc}VkJ2yCAE|Mhv(Zy+2aa_AO^Zp9?TS{o{+BVl=_-+^GiUliEPDAM0! zq`${|>cWnTBBipZ<#77LQ;m;Ao-;8U?Aoql>@3G%W~u=tE%B%leKt~OVt$C0T}K88 z)qqh9@Wu#d4E`W=hd+p0+$aD6Ub#;9R5>9zkAczC7(zz*4PYiNQ1IVQJH9V_eh9cB^z7)#i#i7+!yb*+n(YJgte8YH zouLl;KtdDF5r~a0%>+m=VW}S5Fhs-zchQLq_0eljVszN?VNQs?K;OpM#sC(56Qdh; zfsF*zCy3}iN}P=J7e;yugHhI~YuW7xU;Ml?^g7Zg=rgl4%!9ZJ$2vXfE6B*zpLTc0 zys@>I*FBa2<%oRk1Xkdr4)O_b3?|anvHnGdm?VeARpcm!mCH4x_In}59rLs}Tkk46g#qS+9d^_b6?D}r>Q|9Wge zIhYqs+D&$JlVL?lH*muTBN8Nyz0uY0!DVkkUiu4B9AYe!>#SpLh^7hw+65Cz=k^T45f~G4`Pe zINE3q+IHA_1{e@Wap*NuUXZ=1oI2BGq*Mc+*}(yR!J%S6U{#>LIRHEW0T4!s%fma? z_OY8_9)O^=fL$RYUHUblD-ooZ$LJW(-dF4Nh1GCsz02tGj6L4{@M| z^APvXnCHdF&8TrW=VrM|u!Ttb*gj#?I0M?$Lld^%o+cXNyrzrH?%_GR$eLanJLo3n zIZQlsmIy3893p*9FI}+`QQ8}#0ZI|Ew4N0=T^w`t@xFlueS!gJlv>=wk?!F{W4==q z{zBj(1_C{F76XnAjx~kdM06bvDeopjbGZ2I!S3(lmE+Cfn#a?)<=5uTzZ#ERHX>~Y zoROsa;@!gfnC&4ID!kbFmLgJE~`bePNUmOyd^T6hQZj3htSp zMq{4VD59eTjy6GXx*59z5CZo1SWnbO(q{??Y4A~CK)%PpG(gRw=sg`PfhS*9_#W>L z@%tzQVp#D(I_y?fFE>rVGfil68ttXfXvD)y6i9QCAl>JxUl1sN5BopIr-Pz80mDrT zF*lNpx^vu%ONA}1&(VozUxh|PFia?KGnyQ9=Z@rJIE!0N3zyJ9VD!& z7W9rr6En_X9+dkYnw>Pi`yoyDX|zdNG)A;OARY(0pgjnt*}_175IzuD!5+=oGD)fd zeMWsA1dAU8_(6MmCB(fbN6ljBAqz{O;{FGsuQAvo^^^d=fS?KFk?=lIj3%!DKd@-z zZSX#AVn^8I>dM%f8Hfjn*>PkCq(&5S}H z?~TNvJoJIc+6ZSWaD{YsJ#&*s7i^>G1cYk4v^x=Np~Aa*9?dUc3Fg~X=i5^!c$eVY zLtD`~3ORGwsYwevy)diSRHGNA+ZTt-o$A>#1njA(W z>%_S3z**Q+(SG3y>d+H!uG~9(cBO;h1BRW+!|O`UOQ^a;ttSCDv?DCTKl)5(EvOLG z=}9c8*}+c4=FVbW=3HwPdp9>g@L#i@O&8iL3Vd0jzT|OqVd>N=csTY$|R$nP+h89F=>E? ze4Rg}HE!9P(76dED?(v)l>Nqr=3+o3`V6Cp7*shBb?8_DJ0pUW0|aLCz_>&f3m~32 zKJY92#=@&qE{Mn#6`)v@RE`RB*RgK&VMzlC>=YE>0V%$UD}LZbY4rUPq5=r3@U|bV z4pMg0T^etyWuUfqW$XMo5cldXkJI#FC|TICawXyqM0x~@b^;628-ooxE<@ugElomQ zDXa&oR<`S1&}aUy7huoDy+mt^k8pbCkdD0>Gcyyo3jhJg?YrN*q|8G(VIsEY8or|i zUVzi+b|1(v+&tSq0h{80t!|=cV5`xHxjU$UgsZt|_k?pFim4!gM2(aO-jxMj6;CHZ zev`WbMGh=a2bZYB58Xr|Z{c(wNYso_*#Q8Ong_nCtXoNl1b0@+lEt7`7LcnG<45a>e) z;mpUXA-5V-@c~5w8+llvLl6VRP!A!#kcX$x2K|@ zh!`4PVHa_GkmZ61Jg0;lBV?%1D58~X)B$cw^)vGjuw|EfV6&Q}`DgOD0h^Q%OmF!W zgC2xQFu?%2L>P!Y%0oEFxzjLm>+HV|jd$439Y;txKd>$Q9E#Gr09g3jiyhS#vri43rQXkRXm zz2Xkc9k2|llb|h#W9alZII-NJCYz(?T&G4K!QEQ}HE z%=Wpp8fh&o4J8m1+|F7z7o~ad*3UPPB4Cu}w#)g#(*kyhPHlj1IP4&7dN?w8*t?+^ zLt+Ugv7p&`@KYodq=r}wH{Eh?;2~a)2MuwbQt*gW*g-JL?_l^|4)6nwz93r)IvvAd z87fd9GUR^p&{|f z-gup{-_;r*-h)x)m&g!f;sLA=tOa#6)~^$~*U3{|=yw*0szIIv?rESi;~Y4!!$}sy zsAF(w;Tcbmsl9xTSN9lvg-){Q45DBm%Uv<43%&#US7Y!U z{FIHZU1jKm1UCnM$^zYRX)m&9C}0Sqkf#M7JAD`@uon7gW;j+p!!a18qrl|yfagwj>#=vqkxTrkZ4QV3!)vdjihbUn~cz=oiN4Oy3I{$P%4_%UU0 zLf7vDj7D_bLQycBT^!vY9VBJYLqq=-!r)m>FVaJZ7y*+_1aK)j6e%G-W8SyOx_+_) z3AzCtxIkW)<~q8_#Yll0c%*Ka2PDYp-!l0q1fnMDG6%#wOgP6vJ^`b^D5M)_4gGws zyBl~vm&IZD_S%KtBA{F%Hv@)mko~&&aF1XJ!=~)OL&y|{DEs(EuLvc`%TCtxukeSd zdKwtS2)zqpI}*|Zx!#pc1c+eW#>a5uI%qlr7!teDTCQ@lbEZBE`V2Go^Bmng{~kVP zA#2tHAWd6<>aeU?43UNmAvnYm+B5ivhf)Td(E$iHtfM)==W1j-PXQ}VOi*5HkUj~?5I4stk2uI{1_TEAF26C2;owBm;gt^L z0WhCiWON=v$H5sp1&jMdft8H_M!ls&RG9giOI4uf$|f%=NDy(}HkbO^1hvN}97P4# z(9TQKr%_q3c>X1Dz0A?N+ubXE=(9-=^PA>VnHa{g^N#m#G1vel&@om$hG)9UhA=#B z<;qM>>qa+u3i(48Xq|5SHxvV)zX2P$I|u3N7Dpdh*awHdhNigze9UcZ+*UVWv%P7v z^$vi425zu^9c}VrGdafU8Q%g_xK7n;`}j>b?O+8MoX-FC(6dp-K)un?Q$KhUB#3nN z)&#YecdwgFI|_Qp8U~AV3xb6Cg^0k5(yFr&8K8&YA)COI4u=4Sr}0tXNif&Cek<`? zL%63!qQ~#yK$aCzVR9fdF*&+?Ae}{kwJuK#_wW`Dki(Czq`T=x0rKlvbW-mkQx2U6 zFN*MSX$#yD>R*Xt2p2*k{2-0v=6MnFV|x@d)CW=VKF+cCaXsYRn&5Pi!3BYrnD*gg zXfJ2jY-+F_f(~Y8jM<`()$w{KD1$fg-2oF`A$QtBhWkS51_t0d3!Lm6u06;(hK@7% zP853G#yv5??X~Urkk27@UBIxWD?IQ>dejgPkrjacAeBSj#^L4$apuhlo9(#msO`vv zZD7DppE08eTy*DT`W_MO7LRcP4)i7?8U^?92;P4YSbvRiGjy|_%^&<2%#7Mi(9Bk# z9VqSPm3NWLyBdHB&^nion(BhB#|I)_&?q|SPfuOQ(H-lL^0=2PPNV1x;x|u+D3TkW zI)(2AR6B37mt2lgBR3hyufjbLJA{jLE#wbRx%LahRUos6?C4nuc`At6sF&-wgJ+S& zoq$ouBOyvbGi|nse)xCkI}BTldx`ynA)Wm`?~=PA`$W}Vr)o#=W_Nj7p}o%WZXNQp zgEt|G3eqXJ^T0&_T_AVJl>x;2SHNvT3ayV1{3!?8ubs7?n83iV4xTqa(h@j-iL88& z*M87+u5Vz+LLG1e-(`FqkmCCVN&t*G2t)9f6K4^QH^*>!F~;g{f^G~G02EKuFv`d0Lbts|I$Cr8xfsX?Hfv=9PxsJm= zb!4zXl#@Yz7?SNMXO8wJy~9>_97s4a(LXpGLT$jXMDR9;=XToaj7M%7y-=J<9<>2W zK@bx*zMa&)8gvzA29|<}A>Yykx{AjD!%!$e34DCdof_v&_4B5B$&NQx+I+i!W1wS@ z(!-NL{6s(xhe!co5Ku241ThU6#MJ|QCLT30VTXOkhJi8+4A^|A2XU_AW-{Rq7Kir% zDMLQY*2}Yj&U>($P#+up#Kf7mLmd5lm-c=*zJ=Cny9eB`4WyB~3-Q%K6Ks8C`rrV? zgY_ncS)Jt06CpO6X}sTZF&NQ~>;lZ>h^^_!fNd^N3b80e*Om8B?ZfvtFBs$neR^#? zhmCiA;O4)`5p*DA9M(mAJ>K8o@b6B-F#b&&Zvd@$Oe5c zfdqS;XZr`hXV`2f2FT=YfKZ$hwnJx6zXNHr^AxX$)zdhGqMqfRxara#kn%SmWqcy`L< z5W-8ykh^L(VJN?WN0@Mfbjc7nQ5*uu3HQ8Qzbe@J_ac@5xK~V0(Ac*u&MyGxQ1ZjW|nkV=U zdiHOF+ouS^V4vt`<0##ko(Fzqu;JEp6$Ht6A0y+=8;r5v$A>F}_0>28eu7B=AJhh? zDFup7*m%ADJ#XNUmKBr6Fh}Exi3^!ZD?s3sf*-8V6Phooi=pG2`$CAIrlY3mQ_1Xg zHHf1qQN-5Y`YymNuS{>)FEkht-8vY9OnxsfGt>`7aYU2@v>V`#nKCsRkNxn&{xxD} z6w#XL4f#eCk8QT=Z_||#bb{jOYHyT7qe68FxrY%>N;DqRqQa8qF0C2r0ITb9{|vWc z^pxKTYXWIatGr#i9*Yh)7pkNU)ES)dc}9uF90NC{9*=TS(spVLk_(XzrMHj-y5Xj2 z4yHHOrctn&QE zs5M=IiywAMvWu>NQi^;OTs`6tZ$YHVY0ixXi7j^J;*e7vL-*kU0mMV1W0VN&rz@iw zm=2^2_f-2IBPc(`Jut!$uctVAcpS!@X`C^<{LO}n>hT#W5clj=4UnSAy}s5Xq6s7V z2#C6I;0f{gj?f-*KsbtuH>1XBU=lCiLo*X7f4Q>6%gHnIKwXbJ9$CuraHM#{mEnar zrN9kzPbSry>fzP^IKwRkJFz?pt%dU$u5ZzsZrOqJj%05<0wJQB-MC=_>i#>T@x1^b zeU4Eak`qmw(+wC>;lj)_hum}^Ee+)a6XJ8Ix?f%$p}If^wP(wpmh8513NOh+>(2aOL`4zQpXPqj7M(OQNL)P zrfW9$eu+Xi|A})9){m= z? z5LH_7QBVf~U+uc+3z;Hn8f>X(xyqhKU=7 zbW1-W`T7D^k!`w+g$kV^KL+O+w<$UL1NK|$F$in49tEbxb{*?EXF_pu={2sbFeL&~{q*whfEfZL<;LTkDD zjDw}XPZ%NmJ`|+jxUN7;JjAem(*_-j3MC9k|GQCNxk6j8%==p$jp;sPB<)$k20cp9 z;rU*Od%NKJig?o$OW>G~-rGV{j9

f=M<{b)hVuPJ2qq-#itwDSCD-NbNbHn4+gV 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 G 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 literal 0 HcmV?d00001 diff --git a/firmware-rs/core/src/canvas.rs b/firmware-rs/core/src/canvas.rs index c110c573..1bccf34e 100644 --- a/firmware-rs/core/src/canvas.rs +++ b/firmware-rs/core/src/canvas.rs @@ -1,4 +1,5 @@ -//! A 320×240 RGB565 frame buffer and the most basic drawing: filled rectangles, thick lines, 5×7 bitmap text. +//! A 320×240 RGB565 frame buffer and the most basic drawing: filled rectangles, thick lines, 5×7 bitmap text, +//! and UTF-8 text that adds Chinese from the 12 px font in font.rs. //! //! The buffer is in the byte order the panel wants: high byte first for each pixel. The C firmware stores //! little-endian u16 in memory and has the LCD driver swap bytes on send; here it is stored as it goes on @@ -8,6 +9,8 @@ pub const WIDTH: i32 = 320; pub const HEIGHT: i32 = 240; pub const FRAME_BYTES: usize = (WIDTH * HEIGHT * 2) as usize; +use crate::font::{self, Glyph}; + const LETTER_GLYPHS: [[u8; 7]; 26] = [ [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11], [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e], @@ -144,21 +147,62 @@ impl<'a> Canvas<'a> { if character == 0 { break; } - for row in 0..7 { - let bits = glyph_row(character, row); - for column in 0..5 { - if bits & (1 << (4 - column)) != 0 { - self.fill_rect( - x + index as i32 * 6 * scale + column * scale, - y + row as i32 * scale, - scale, - scale, - color, - ); + self.draw_ascii(x + index as i32 * 6 * scale, y, character, scale, color); + } + } + + fn draw_ascii(&mut self, x: i32, y: i32, character: u8, scale: i32, color: u16) { + for row in 0..7 { + let bits = glyph_row(character, row); + for column in 0..5 { + if bits & (1 << (4 - column)) != 0 { + self.fill_rect(x + column * scale, y + row as i32 * scale, scale, scale, color); + } + } + } + } + + /// Draws UTF-8 text from (x, y), as many whole characters as fit in `max_width` pixels; stops at NUL. + /// ASCII is the 5×7 font exactly as `draw_text` draws it, with (x, y) its top left; Chinese is the 12 px + /// font, centred on the ASCII line (font.rs). Returns the width drawn. + pub fn draw_str(&mut self, x: i32, y: i32, text: &[u8], scale: i32, color: u16, max_width: i32) -> i32 { + let text = &text[..font::fit(text, scale, max_width)]; + let wide_scale = font::wide_scale(scale); + let top = font::wide_top(y, scale); + let mut left = x; + for (glyph, _) in font::glyphs(text) { + match glyph { + Glyph::Ascii(character) => self.draw_ascii(left, y, character, scale, color), + Glyph::Wide(index) => { + for row in 0..font::CELL { + let bits = font::row(index, row as usize); + for column in 0..font::CELL { + if bits & (0x800 >> column) != 0 { + self.fill_rect(left + column * wide_scale, top + row * wide_scale, wide_scale, wide_scale, color); + } + } + } + } + // A dotted box over the ink area, so it can't pass for 口. + Glyph::Missing => { + for step in (0..11).step_by(2) { + for (column, row) in [(step, 1), (step, 11), (0, 1 + step), (10, 1 + step)] { + self.fill_rect(left + column * wide_scale, top + row * wide_scale, wide_scale, wide_scale, color); + } } } } + left += font::advance(glyph, scale); } + font::width(text, scale) + } + + /// `draw_str` centred across the screen. + pub fn draw_str_centered(&mut self, y: i32, text: &[u8], scale: i32, color: u16) { + // As much as `draw_text_centered` allows, so ASCII lands where it always has. + let max_width = WIDTH / (6 * scale) * 6 * scale - scale; + let text = &text[..font::fit(text, scale, max_width)]; + self.draw_str((WIDTH - font::width(text, scale)) / 2, y, text, scale, color, max_width); } pub fn draw_text_centered(&mut self, y: i32, text: &[u8], scale: i32, color: u16) { @@ -176,3 +220,71 @@ impl<'a> Canvas<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + use alloc::vec::Vec; + + fn render(draw: impl FnOnce(&mut Canvas)) -> Vec { + let mut bytes = vec![0; FRAME_BYTES]; + draw(&mut Canvas::new(&mut bytes)); + bytes + } + + /// The lit area as (top, bottom, left, right), inclusive. + fn ink(bytes: &[u8]) -> (i32, i32, i32, i32) { + let lit: Vec<(i32, i32)> = + (0..WIDTH * HEIGHT).filter(|&index| bytes[index as usize * 2] != 0).map(|index| (index / WIDTH, index % WIDTH)).collect(); + let rows = || lit.iter().map(|&(row, _)| row); + let columns = || lit.iter().map(|&(_, column)| column); + (rows().min().unwrap(), rows().max().unwrap(), columns().min().unwrap(), columns().max().unwrap()) + } + + #[test] + fn ascii_draws_the_same_through_either_call() { + let text = b"Ready: 3 TASKS, 12m_ok?\x01~"; + for scale in 1..=3 { + let old = render(|canvas| canvas.draw_text(4, 30, text, scale, 0xffff, 40)); + let new = render(|canvas| { + canvas.draw_str(4, 30, text, scale, 0xffff, 40 * 6 * scale - scale); + }); + assert!(old == new, "scale {scale}"); + let old = render(|canvas| canvas.draw_text_centered(30, text, scale, 0xffff)); + let new = render(|canvas| canvas.draw_str_centered(30, text, scale, 0xffff)); + assert!(old == new, "centred, scale {scale}"); + } + } + + #[test] + fn chinese_sits_on_the_ascii_line() { + // Scale 1: the 5x7 glyph covers rows 100 to 106; the 12 px cell rows 97 to 108, ink from its second. + let (top, bottom, _, _) = ink(&render(|canvas| { + canvas.draw_str(10, 100, "国".as_bytes(), 1, 0xffff, 100); + })); + assert!(top >= 98 && (106..=108).contains(&bottom), "{top}..{bottom}"); + // Scale 2: still 12 px, centred on the 14 px ASCII. + let (top, bottom, left, right) = ink(&render(|canvas| { + canvas.draw_str(10, 100, "国".as_bytes(), 2, 0xffff, 100); + })); + assert!(top >= 102 && bottom <= 112 && left >= 10 && right <= 20, "{top}..{bottom} {left}..{right}"); + } + + #[test] + fn mixed_text_advances_by_each_font_and_stops_at_the_width() { + let whole = render(|canvas| assert_eq!(canvas.draw_str(0, 50, "A你B".as_bytes(), 1, 0xffff, 100), 6 + 12 + 5)); + assert_eq!(ink(&whole).3, 22); + // Whole characters only: 你 doesn't fit in 15 px after A. + let cut = render(|canvas| assert_eq!(canvas.draw_str(0, 50, "A你B".as_bytes(), 1, 0xffff, 15), 5)); + assert_eq!(ink(&cut).3, 4); + } + + #[test] + fn a_character_the_font_lacks_is_a_box() { + let lacking = render(|canvas| { + canvas.draw_str(0, 50, "\u{1F600}".as_bytes(), 1, 0xffff, 100); + }); + assert_eq!(ink(&lacking), (48, 58, 0, 10)); + } +} diff --git a/firmware-rs/core/src/display.rs b/firmware-rs/core/src/display.rs index 21904e5c..919bab19 100644 --- a/firmware-rs/core/src/display.rs +++ b/firmware-rs/core/src/display.rs @@ -9,6 +9,7 @@ use alloc::boxed::Box; use alloc::vec::Vec; use crate::canvas::{Canvas, FRAME_BYTES, HEIGHT, WIDTH, half_bright}; +use crate::font; use crate::leisure::{self, Skit}; use crate::look::{self, Look}; use crate::pomodoro::{self, Phase, Run}; @@ -138,9 +139,30 @@ pub enum PanelKind { Text(u8), } -/// Columns and rows a `Text` panel holds at scale 2 and at scale 1. -pub const VOICE_TEXT_LARGE: (usize, usize) = (19, 7); -pub const VOICE_TEXT_SMALL: (usize, usize) = (38, 13); +/// How wide a `Text` panel's rows are in pixels: 19 ASCII characters in large type, 38 in small, and 19 +/// Chinese characters in either (font.rs). +pub const VOICE_TEXT_WIDTH: i32 = 228; +const VOICE_TEXT_HEIGHT: i32 = 130; + +/// Row pitch of a `Text` panel: large type, small type, or small type with Chinese in it, whose 12 px +/// glyphs need taller rows than 5×7 ASCII. +fn voice_text_pitch(scale: u8, wide: bool) -> i32 { + match (scale >= 2, wide) { + (true, _) => 18, + (false, false) => 10, + (false, true) => 13, + } +} + +/// Rows a `Text` panel holds: 7 in large type, 13 in small, 10 in small with Chinese. Large type holds no +/// Chinese: the 12 px glyphs stay 12 px, and doubled ASCII next to them looks shouty, so text with Chinese +/// in it goes straight to small type. +pub fn voice_text_rows(scale: u8, wide: bool) -> usize { + if scale >= 2 && wide { + return 0; + } + (VOICE_TEXT_HEIGHT / voice_text_pitch(scale, wide)) as usize +} /// A panel over the whole screen (the device menu, a voice command): what it says is the caller's, drawing /// it is the display's. @@ -871,12 +893,12 @@ impl Display { canvas.fill_rect(x, y, width, 32, COLOR_MUTED); canvas.fill_rect(x + 2, y + 2, width - 4, 28, card_color); canvas.fill_rect(x + 2, y + 2, 4, 28, color); - canvas.draw_text(x + 12, y + 5, &task.title, 1, COLOR_TEXT, 26); + canvas.draw_str(x + 12, y + 5, &task.title, 1, COLOR_TEXT, 26 * 6 - 1); canvas.draw_text(x + 12, y + 17, short_state_label(task.state), 1, color, 4); // When the first line is a session name, the project name moves to the second line; if the title is the // project name, don't repeat it. if !task.project.is_empty() && !contains(&task.title, &task.project) { - canvas.draw_text(x + 42, y + 17, &task.project, 1, COLOR_MUTED, 16); + canvas.draw_str(x + 42, y + 17, &task.project, 1, COLOR_MUTED, 16 * 6 - 1); } let elapsed = format_elapsed(self.task_elapsed_seconds(task, now_ms)); let elapsed_width = elapsed.len() as i32 * 6 - 1; @@ -912,7 +934,7 @@ impl Display { } let slot = (self.animation_frame / IDLE_ROTATE_FRAMES) as usize % count; let color = if has_title && slot == 0 { COLOR_TEXT } else { COLOR_MUTED }; - canvas.draw_text_centered(195, lines[slot], 2, color); + canvas.draw_str_centered(195, lines[slot], 2, color); } /// Whether the alarm is still ringing. After the end, any change of view means the user acted: starting the @@ -961,7 +983,7 @@ impl Display { canvas.draw_text(PANEL_X, 176, b"AGENT", 1, COLOR_MUTED, 18); canvas.draw_text(PANEL_X, 188, label, 1, status_color, 18); if let Some(task) = self.tasks.first() { - canvas.draw_text(PANEL_X, 200, &task.title, 1, if self.link_lost { COLOR_MUTED } else { COLOR_TEXT }, 18); + canvas.draw_str(PANEL_X, 200, &task.title, 1, if self.link_lost { COLOR_MUTED } else { COLOR_TEXT }, 18 * 6 - 1); } } @@ -1131,7 +1153,7 @@ impl Display { // No rotation while the link is lost: a moving screen looks alive, the exact opposite of NO LINK. self.draw_idle_line(canvas, scene); } else if !self.title.is_empty() { - canvas.draw_text_centered(195, &self.title, 2, COLOR_TEXT); + canvas.draw_str_centered(195, &self.title, 2, COLOR_TEXT); } self.draw_pomodoro_badge(canvas, scene); } @@ -1494,8 +1516,10 @@ fn draw_panel(canvas: &mut Canvas, panel: &Panel) { } PanelKind::Meter(_) => canvas.draw_text_centered(150 + index * 18, label, 2, tone_color(*tone)), PanelKind::Text(scale) => { - let (scale, (columns, _)) = if scale >= 2 { (2, VOICE_TEXT_LARGE) } else { (1, VOICE_TEXT_SMALL) }; - canvas.draw_text(PANEL_LEFT, 56 + index * if scale == 2 { 18 } else { 10 }, label, scale, tone_color(*tone), columns); + let pitch = voice_text_pitch(scale, panel.lines.iter().any(|(label, _, _)| font::has_wide(label))); + // Room for one hanging closing mark past the row's width (font::wrap). + let max_width = VOICE_TEXT_WIDTH + font::CELL; + canvas.draw_str(PANEL_LEFT, 56 + index * pitch, label, if scale >= 2 { 2 } else { 1 }, tone_color(*tone), max_width); } } } diff --git a/firmware-rs/core/src/font.rs b/firmware-rs/core/src/font.rs new file mode 100644 index 00000000..629f5678 --- /dev/null +++ b/firmware-rs/core/src/font.rs @@ -0,0 +1,343 @@ +//! Text beyond ASCII: Chinese from a 12 px pixel font, next to the 5×7 ASCII font in canvas.rs. +//! +//! The glyphs are the 3,500 characters of level one of the Table of General Standard Chinese Characters +//! (通用规范汉字表 一级) plus CJK punctuation and the full-width forms, from Fusion Pixel Font 12 px +//! (SIL OFL 1.1, fonts/OFL.txt). `tools/make-cjk-font.py` builds the table: a sorted index of codepoints +//! and one packed 12×12 bitmap per character, 18 bytes each. It is included as read-only data, so it +//! stays in flash and costs no RAM. +//! +//! ASCII keeps the 5×7 font, so English looks as it always has. A Chinese character is one 12 px cell at +//! scales 1 and 2 (two cells at 4), vertically centred on the ASCII line; at scale 1 that puts the font's +//! own baseline exactly on the ASCII baseline. A character the table lacks draws as a dotted box. + +use alloc::vec::Vec; + +static TABLE: &[u8] = include_bytes!("../fonts/cjk-12px.bin"); + +/// A wide glyph's cell: 12 pixels square, ink in rows 1 to 11 and columns 0 to 10. +pub const CELL: i32 = 12; +const GLYPH_BYTES: usize = 18; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Glyph { + /// A byte for the 5×7 font, which draws its own placeholder for bytes it has no glyph for. + Ascii(u8), + /// An index into the table. + Wide(usize), + /// Not in the table, or not UTF-8: a box the width of a wide glyph. + Missing, +} + +fn count() -> usize { + u16::from_le_bytes([TABLE[0], TABLE[1]]) as usize +} + +fn codepoint(index: usize) -> u32 { + u16::from_le_bytes([TABLE[2 + index * 2], TABLE[3 + index * 2]]) as u32 +} + +/// The table index of a character, by binary search. +pub fn lookup(character: char) -> Option { + let wanted = character as u32; + let (mut low, mut high) = (0, count()); + while low < high { + let middle = (low + high) / 2; + match codepoint(middle).cmp(&wanted) { + core::cmp::Ordering::Less => low = middle + 1, + core::cmp::Ordering::Greater => high = middle, + core::cmp::Ordering::Equal => return Some(middle), + } + } + None +} + +/// One row of a wide glyph, 12 bits, the leftmost pixel in bit 11. +pub fn row(index: usize, row: usize) -> u16 { + let bitmap = &TABLE[2 + count() * 2 + index * GLYPH_BYTES..][..GLYPH_BYTES]; + let at = row * 3 / 2; + let (first, second) = (bitmap[at] as u16, bitmap[at + 1] as u16); + if row.is_multiple_of(2) { first << 4 | second >> 4 } else { (first & 0x0f) << 8 | second } +} + +/// A wide glyph's pixel size at a text scale: one pixel up to scale 3, so Chinese stays 12 px next to both +/// small and large ASCII. +pub fn wide_scale(scale: i32) -> i32 { + (scale / 2).max(1) +} + +/// Where a wide glyph's cell starts for ASCII drawn at `y`: centred on the 7-row ASCII glyph. +pub fn wide_top(y: i32, scale: i32) -> i32 { + y + (7 * scale - CELL * wide_scale(scale)).div_euclid(2) +} + +pub fn advance(glyph: Glyph, scale: i32) -> i32 { + match glyph { + Glyph::Ascii(_) => 6 * scale, + _ => CELL * wide_scale(scale), + } +} + +/// The blank column at the right of every glyph, which a measured width leaves off the last one. +fn gap(glyph: Glyph, scale: i32) -> i32 { + match glyph { + Glyph::Ascii(_) => scale, + _ => wide_scale(scale), + } +} + +/// The glyphs of UTF-8 text, each with its length in bytes; stops at NUL like the 5×7 drawing does. +pub fn glyphs(text: &[u8]) -> Glyphs<'_> { + Glyphs { text, at: 0, previous_alphanumeric: false } +} + +pub struct Glyphs<'a> { + text: &'a [u8], + at: usize, + previous_alphanumeric: bool, +} + +impl Iterator for Glyphs<'_> { + type Item = (Glyph, usize); + + fn next(&mut self) -> Option { + let byte = *self.text.get(self.at)?; + if byte == 0 { + return None; + } + let (glyph, length) = if byte.is_ascii() { + (Glyph::Ascii(byte), 1) + } else { + let length = match byte { + 0xc2..=0xdf => 2, + 0xe0..=0xef => 3, + 0xf0..=0xf4 => 4, + _ => 0, + }; + let character = self.text.get(self.at..self.at + length).and_then(|bytes| core::str::from_utf8(bytes).ok()?.chars().next()); + match character { + Some(character) => { + let next_alphanumeric = self.text.get(self.at + length).is_some_and(u8::is_ascii_alphanumeric); + (self.wide(character, next_alphanumeric), length) + } + None => { + // One box for a broken sequence, not one per byte. + let mut length = 1; + while self.text.get(self.at + length).is_some_and(|&byte| byte & 0xc0 == 0x80) { + length += 1; + } + (Glyph::Missing, length) + } + } + }; + self.at += length; + self.previous_alphanumeric = byte.is_ascii_alphanumeric(); + Some((glyph, length)) + } +} + +impl Glyphs<'_> { + fn wide(&self, character: char, next_alphanumeric: bool) -> Glyph { + // Curly single quotes against English letters are apostrophes ("it’s"): the ASCII one fits there, + // a full-width quote would leave a gap. + if matches!(character, '\u{2018}' | '\u{2019}') && (self.previous_alphanumeric || next_alphanumeric) { + return Glyph::Ascii(b'\''); + } + lookup(character).map_or(Glyph::Missing, Glyph::Wide) + } +} + +/// Pixel width of text as drawn at `scale`: up to the last glyph's ink, without its trailing gap. +pub fn width(text: &[u8], scale: i32) -> i32 { + let mut width = 0; + let mut last_gap = 0; + for (glyph, _) in glyphs(text) { + width += advance(glyph, scale); + last_gap = gap(glyph, scale); + } + width - last_gap +} + +/// How many bytes of `text` fit in `max_width` pixels, whole characters only. +pub fn fit(text: &[u8], scale: i32, max_width: i32) -> usize { + let mut used = 0; + let mut bytes = 0; + for (glyph, length) in glyphs(text) { + if used + advance(glyph, scale) - gap(glyph, scale) > max_width { + break; + } + used += advance(glyph, scale); + bytes += length; + } + bytes +} + +/// Whether any of the text draws in wide glyphs, which need taller rows than ASCII alone. +pub fn has_wide(text: &[u8]) -> bool { + glyphs(text).any(|(glyph, _)| !matches!(glyph, Glyph::Ascii(_))) +} + +/// Punctuation that must not start a row: rather than move it down alone, it hangs past the row's end. +fn closing(text: &[u8]) -> bool { + core::str::from_utf8(text) + .is_ok_and(|text| matches!(text, "," | "。" | "、" | ";" | ":" | "!" | "?" | ")" | "》" | "〉" | "」" | "』" | "】" | "〕" | "”" | "’" | "…")) +} + +/// Wraps text to rows of at most `max_width` pixels at `scale`. Breaks at spaces, and between any two +/// Chinese characters or a Chinese character and a word; an ASCII word is only cut when it is longer than +/// a row. Runs of whitespace become one space. Closing Chinese punctuation that doesn't fit hangs one +/// glyph past `max_width` instead of starting a row. +pub fn wrap(text: &str, max_width: i32, scale: i32) -> Vec> { + let bytes = text.as_bytes(); + let mut rows: Vec> = Vec::new(); + let mut row: Vec = Vec::new(); + let mut at = 0; + let mut space = false; + let place = |unit: &[u8], space: bool, rows: &mut Vec>, row: &mut Vec| { + let mut unit = unit; + if !row.is_empty() { + let mut candidate = row.clone(); + if space { + candidate.push(b' '); + } + candidate.extend_from_slice(unit); + if width(&candidate, scale) <= max_width || (!space && closing(unit)) { + *row = candidate; + return; + } + rows.push(core::mem::take(row)); + } + while width(unit, scale) > max_width { + let first = glyphs(unit).next().map_or(unit.len(), |(_, length)| length); + let cut = fit(unit, scale, max_width).max(first); + rows.push(unit[..cut].to_vec()); + unit = &unit[cut..]; + } + *row = unit.to_vec(); + }; + let mut iterator = glyphs(bytes).peekable(); + while let Some((glyph, length)) = iterator.next() { + let start = at; + at += length; + match glyph { + Glyph::Ascii(byte) if byte.is_ascii_whitespace() => { + space = true; + continue; + } + Glyph::Ascii(_) => { + // A word: ASCII up to the next space or wide glyph. + while let Some(&(Glyph::Ascii(byte), length)) = iterator.peek() { + if byte.is_ascii_whitespace() { + break; + } + at += length; + iterator.next(); + } + } + _ => {} + } + place(&bytes[start..at], space, &mut rows, &mut row); + space = false; + } + if !row.is_empty() { + rows.push(row); + } + rows +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::String; + use alloc::vec; + + fn rows(text: &str, max_width: i32, scale: i32) -> Vec { + wrap(text, max_width, scale).into_iter().map(|row| String::from_utf8(row).unwrap()).collect() + } + + #[test] + fn the_table_holds_level_one_and_punctuation_in_order() { + assert!(count() > 3500); + assert_eq!(TABLE.len(), 2 + count() * (2 + GLYPH_BYTES)); + assert!((1..count()).all(|index| codepoint(index - 1) < codepoint(index))); + for character in "你好世界的一了是我龟鼠,。、!?:;“”‘’()《》…—".chars() { + assert!(lookup(character).is_some(), "{character}"); + } + // Level two and beyond, and other scripts, are not in it. + assert_eq!(lookup('龘'), None); + assert_eq!(lookup('é'), None); + assert_eq!(lookup('A'), None); + } + + #[test] + fn a_glyph_keeps_its_spacing_row_and_column_blank() { + let index = lookup('国').unwrap(); + assert_eq!(row(index, 0), 0, "the top row is the line spacing"); + assert!((0..12).all(|line| row(index, line) & 1 == 0), "the right column is the character spacing"); + assert!((1..12).filter(|&line| row(index, line) != 0).count() >= 9); + // 一 is a single stroke across the middle. + let one = lookup('一').unwrap(); + assert_eq!((0..12).filter(|&line| row(one, line) != 0).count(), 1); + } + + #[test] + fn utf8_turns_into_glyphs_with_their_byte_lengths() { + let found: Vec<(Glyph, usize)> = glyphs("a你\u{1F600}".as_bytes()).collect(); + assert_eq!(found, [(Glyph::Ascii(b'a'), 1), (Glyph::Wide(lookup('你').unwrap()), 3), (Glyph::Missing, 4)]); + // A broken sequence is one box; NUL ends the text. + assert_eq!(glyphs(&[0xe4, 0xbd, b'x', 0, b'y']).collect::>(), [(Glyph::Missing, 2), (Glyph::Ascii(b'x'), 1)]); + assert_eq!(glyphs(&[0xff]).collect::>(), [(Glyph::Missing, 1)]); + } + + #[test] + fn curly_quotes_by_english_letters_are_apostrophes() { + assert_eq!(glyphs("it’s".as_bytes()).nth(2), Some((Glyph::Ascii(b'\''), 3))); + assert_eq!(glyphs("‘ok".as_bytes()).next(), Some((Glyph::Ascii(b'\''), 3))); + assert_eq!(glyphs("说‘好’".as_bytes()).nth(1), Some((Glyph::Wide(lookup('‘').unwrap()), 3))); + } + + #[test] + fn mixed_text_measures_each_font_by_its_own_advance() { + assert_eq!(width(b"", 1), 0); + assert_eq!(width(b"ABC", 1), 17, "the 5x7 font: 6 px a character, less the last gap"); + assert_eq!(width(b"ABC", 2), 34); + assert_eq!(width("你好".as_bytes(), 1), 23); + assert_eq!(width("你好".as_bytes(), 2), 23, "12 px next to large ASCII too"); + assert_eq!(width("OK了".as_bytes(), 1), 12 + 11); + assert_eq!(width("\u{1F600}".as_bytes(), 1), 11, "a missing character is as wide as a Chinese one"); + assert_eq!(fit("你好吗".as_bytes(), 1, 30), 6); + assert_eq!(fit("你好吗".as_bytes(), 1, 35), 9); + assert_eq!(fit(b"ABCDE", 1, 17), 3); + assert!(has_wide("ok,".as_bytes())); + assert!(!has_wide("it’s ok".as_bytes())); + } + + #[test] + fn ascii_wraps_at_spaces_and_cuts_only_long_words() { + let ascii = |text: &str, columns: i32| rows(text, columns * 6 - 1, 1); + assert_eq!(ascii("a bb ccc", 4), ["a bb", "ccc"]); + assert_eq!(ascii("abcdefghij", 4), ["abcd", "efgh", "ij"]); + assert_eq!(ascii("x abcdefghij y", 4), ["x", "abcd", "efgh", "ij y"]); + assert_eq!(ascii(" spaced\nout ", 20), ["spaced out"]); + assert_eq!(ascii("it’s", 10), ["it’s"]); + assert!(wrap("", 100, 1).is_empty()); + } + + #[test] + fn chinese_breaks_between_any_two_characters_but_keeps_words_whole() { + // Four characters to a row: 4 * 12 - 1. + assert_eq!(rows("一二三四五六七八九", 47, 1), ["一二三四", "五六七八", "九"]); + assert_eq!(rows("打开Codex看看", 47, 1), ["打开", "Codex看", "看"]); + assert_eq!(rows("你好 world", 47, 1), ["你好", "world"]); + // Closing punctuation hangs past the end rather than starting a row. + assert_eq!(rows("一二三四。五", 47, 1), ["一二三四。", "五"]); + assert_eq!(rows("一二三四 。", 47, 1), ["一二三四", "。"], "unless a space stands before it"); + // A missing character still takes a cell and wraps like one. + assert_eq!(rows("一二三\u{1F600}四", 47, 1), ["一二三\u{1F600}", "四"]); + } + + #[test] + fn a_row_narrower_than_one_glyph_still_moves_on() { + assert_eq!(rows("你好", 5, 1), ["你", "好"]); + assert_eq!(wrap("ab", 3, 1), vec![b"a".to_vec(), b"b".to_vec()]); + } +} diff --git a/firmware-rs/core/src/lib.rs b/firmware-rs/core/src/lib.rs index d9cc952a..49b32173 100644 --- a/firmware-rs/core/src/lib.rs +++ b/firmware-rs/core/src/lib.rs @@ -19,6 +19,7 @@ pub mod canvas; pub mod character_pack; pub mod display; pub mod firmware; +pub mod font; pub mod lan; pub mod lcd; pub mod leisure; diff --git a/firmware-rs/core/src/text.rs b/firmware-rs/core/src/text.rs index e0755c2b..fdee1769 100644 --- a/firmware-rs/core/src/text.rs +++ b/firmware-rs/core/src/text.rs @@ -55,8 +55,25 @@ macro_rules! text { }}; } -/// Copies bytes truncated to at most `limit` bytes, matching C's strncpy into a fixed-size array. +/// Copies bytes truncated to at most `limit` bytes, matching C's strncpy into a fixed-size array. A cut never +/// falls inside a UTF-8 character: the partial character goes too. pub fn truncated(bytes: &[u8], limit: usize) -> alloc::vec::Vec { - let end = bytes.iter().take(limit).position(|&byte| byte == 0).unwrap_or(bytes.len().min(limit)); + let mut end = bytes.iter().take(limit).position(|&byte| byte == 0).unwrap_or(bytes.len().min(limit)); + while end > 0 && bytes.get(end).is_some_and(|&byte| byte & 0xc0 == 0x80) { + end -= 1; + } bytes[..end].to_vec() } + +#[cfg(test)] +mod tests { + use super::truncated; + + #[test] + fn truncation_keeps_whole_characters() { + assert_eq!(truncated(b"abcdef", 4), b"abcd"); + assert_eq!(truncated(b"ab\0cd", 4), b"ab"); + assert_eq!(truncated("你好".as_bytes(), 4), "你".as_bytes()); + assert_eq!(truncated("你好".as_bytes(), 6), "你好".as_bytes()); + } +} diff --git a/firmware-rs/core/src/voice_command.rs b/firmware-rs/core/src/voice_command.rs index 1816cf23..cccfce94 100644 --- a/firmware-rs/core/src/voice_command.rs +++ b/firmware-rs/core/src/voice_command.rs @@ -11,7 +11,8 @@ use alloc::string::String; use alloc::vec::Vec; use crate::adpcm::Encoder; -use crate::display::{Panel, PanelKind, Tone, VOICE_TEXT_LARGE, VOICE_TEXT_SMALL}; +use crate::display::{Panel, PanelKind, Tone, VOICE_TEXT_WIDTH, voice_text_rows}; +use crate::font; use crate::mic::encode_base64; /// The microphone runs at the speaker's rate (docs/firmware-bringup.md, "Microphone"). @@ -404,71 +405,28 @@ impl VoiceCommand { /// The heard text dimmed, then the replies, wrapped to the panel: large type while it fits, small type /// after that, and past what small type holds, the oldest rows give way. fn text_panel(title: &[u8], heard: Option<&str>, replies: &[String], hints: Vec<(&'static [u8], &'static [u8])>) -> Panel { - let layout = |(columns, rows): (usize, usize)| { + let layout = |scale: u8| { let mut lines: Vec<(Vec, Vec, Tone)> = Vec::new(); if let Some(heard) = heard { - lines.extend(wrap(heard, columns).into_iter().map(|row| (row, Vec::new(), Tone::Dim))); + lines.extend(font::wrap(heard, VOICE_TEXT_WIDTH, scale as i32).into_iter().map(|row| (row, Vec::new(), Tone::Dim))); } for reply in replies { - lines.extend(wrap(reply, columns).into_iter().map(|row| (row, Vec::new(), Tone::Plain))); + lines.extend(font::wrap(reply, VOICE_TEXT_WIDTH, scale as i32).into_iter().map(|row| (row, Vec::new(), Tone::Plain))); } + let rows = voice_text_rows(scale, lines.iter().any(|(row, _, _)| font::has_wide(row))); let fits = lines.len() <= rows; if !fits { lines.drain(..lines.len() - rows); } (lines, fits) }; - let (lines, scale) = match layout(VOICE_TEXT_LARGE) { + let (lines, scale) = match layout(2) { (lines, true) => (lines, 2), - _ => (layout(VOICE_TEXT_SMALL).0, 1), + _ => (layout(1).0, 1), }; Panel { kind: PanelKind::Text(scale), title: title.to_vec(), lines, selected: None, hints } } -/// What the 5x7 font can draw: ASCII as it is, and one placeholder glyph for each other character (the -/// font has no CJK, so Chinese shows as a row of placeholders). -fn drawable(text: &str) -> Vec { - text.chars() - .map(|character| match character { - ' '..='~' => character as u8, - '\n' | '\t' => b' ', - '\u{2018}' | '\u{2019}' => b'\'', - '\u{201C}' | '\u{201D}' => b'"', - _ => 0x7F, - }) - .collect() -} - -/// Word wrap to `columns`; a word longer than a row is cut. -pub fn wrap(text: &str, columns: usize) -> Vec> { - let mut rows: Vec> = Vec::new(); - let mut row: Vec = Vec::new(); - for word in drawable(text).split(|&byte| byte == b' ').filter(|word| !word.is_empty()) { - let mut word = word; - if !row.is_empty() && row.len() + 1 + word.len() > columns { - rows.push(core::mem::take(&mut row)); - } - while word.len() > columns { - if !row.is_empty() { - rows.push(core::mem::take(&mut row)); - } - rows.push(word[..columns].to_vec()); - word = &word[columns..]; - } - if word.is_empty() { - continue; - } - if !row.is_empty() { - row.push(b' '); - } - row.extend_from_slice(word); - } - if !row.is_empty() { - rows.push(row); - } - rows -} - #[cfg(test)] mod tests { use super::*; @@ -719,18 +677,7 @@ mod tests { } voice.event("voice.reply", 1, Some("the end"), None, 1000); let panel = voice.panel(1000).unwrap(); - assert_eq!(panel.lines.len(), VOICE_TEXT_SMALL.1); + assert_eq!(panel.lines.len(), voice_text_rows(1, false)); assert_eq!(panel.lines.last().unwrap().0, b"the end"); } - - #[test] - fn wrapping_cuts_long_words_and_marks_what_the_font_lacks() { - assert_eq!(wrap("a bb ccc", 4), [b"a bb".to_vec(), b"ccc".to_vec()]); - assert_eq!(wrap("abcdefghij", 4), [b"abcd".to_vec(), b"efgh".to_vec(), b"ij".to_vec()]); - assert_eq!(wrap(" spaced\nout ", 20), [b"spaced out".to_vec()]); - // Chinese has no glyphs: one placeholder per character, not per byte. - assert_eq!(wrap("你好 ok", 10), [[0x7F, 0x7F, b' ', b'o', b'k'].to_vec()]); - assert_eq!(wrap("it\u{2019}s", 10), [b"it's".to_vec()]); - assert!(wrap("", 10).is_empty()); - } } diff --git a/tools/make-cjk-font.py b/tools/make-cjk-font.py new file mode 100755 index 00000000..55442d30 --- /dev/null +++ b/tools/make-cjk-font.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Build the firmware's Chinese glyph table, firmware-rs/core/fonts/cjk-12px.bin. + +The box draws ASCII with its own 5x7 font (canvas.rs); everything else it can draw comes from this table: +the 3,500 characters of level one of the Table of General Standard Chinese Characters (通用规范汉字表 一级, +2013), the CJK punctuation and the full-width forms, rasterized by Fusion Pixel Font 12 px (SIL OFL 1.1). + +Both inputs are pinned by URL and SHA-256, so the output is the same on every machine: + - Fusion Pixel Font, 12 px monospaced, BDF, its zh_Hans glyph shapes. Fusion is Ark Pixel Font with the + characters Ark Pixel hasn't drawn yet at 12 px filled in from Cubic 11 (both OFL). + - Unicode's Unihan database, whose kTGH field gives each character's number in the 2013 table; + numbers 1 to 3500 are level one. + +Downloads are cached in ~/.cache/vibebuddy-fonts. Pass --fusion and --unihan to use local copies. + +Output layout (little-endian), read by firmware-rs/core/src/font.rs: + u16 count + u16 codepoint[count], ascending (all in the Basic Multilingual Plane) + 18-byte bitmap[count]: the 12x12 cell, 12 rows of 12 pixels, row-major, most significant bit first. + The cell's baseline is under row 9 (ascent 10, descent 2). + +The font's license files (OFL.txt, LICENSES/) are written next to the table. + +Usage: tools/make-cjk-font.py [--fusion ZIP] [--unihan ZIP] [--output PATH] +""" +import argparse +import hashlib +import io +import struct +import sys +import urllib.request +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT = ROOT / "firmware-rs/core/fonts/cjk-12px.bin" +CACHE = Path.home() / ".cache/vibebuddy-fonts" + +FUSION_VERSION = "2026.09.25" +FUSION_URL = ("https://github.com/TakWolf/fusion-pixel-font/releases/download/" + f"{FUSION_VERSION}/fusion-pixel-font-12px-monospaced-bdf-v{FUSION_VERSION}.zip") +FUSION_SHA256 = "d75f5262f108757edb0f47ee8e3d2dfdfbecfd94558faf5ffb0c06dc5866fb3b" +FUSION_MEMBER = "fusion-pixel-12px-monospaced-zh_hans.bdf" +UNIHAN_URL = "https://www.unicode.org/Public/16.0.0/ucd/Unihan.zip" +UNIHAN_SHA256 = "b8f000df69de7828d21326a2ffea462b04bc7560022989f7cc704f10521ef3e0" +UNIHAN_MEMBER = "Unihan_OtherMappings.txt" + +LEVEL_ONE = 3500 +CELL = 12 +ASCENT = 10 +GLYPH_BYTES = CELL * CELL // 8 + +# Besides the level-one characters: what Chinese text needs around them. Ranges are inclusive; a codepoint +# the font has no full-width glyph for is skipped (the box draws a box for it). +EXTRA_RANGES = [ + (0x3000, 0x303F), # CJK symbols and punctuation: 、。〈〉《》「」『』【】〔〕〜 and the ideographic space + (0xFF01, 0xFF5E), # full-width ASCII forms: ,!?:;()0-9 A-Z + (0xFFE0, 0xFFE6), # full-width ¢£¬ ̄¦¥₩ +] +EXTRA_CHARACTERS = "‘’“”…—―‖‥※·→←↑↓" + + +def fetch(url, sha256, override): + if override: + data = Path(override).read_bytes() + else: + cached = CACHE / url.rsplit("/", 1)[1] + if not cached.exists(): + CACHE.mkdir(parents=True, exist_ok=True) + print(f"downloading {url}", file=sys.stderr) + with urllib.request.urlopen(url) as response: + cached.write_bytes(response.read()) + data = cached.read_bytes() + digest = hashlib.sha256(data).hexdigest() + if digest != sha256: + sys.exit(f"{override or url}: SHA-256 {digest}, expected {sha256}") + return zipfile.ZipFile(io.BytesIO(data)) + + +def level_one(unihan): + characters = [] + for line in unihan.read(UNIHAN_MEMBER).decode().splitlines(): + if not line.startswith("U+"): + continue + codepoint, field, value = line.split("\t") + if field == "kTGH" and int(value.split(":")[1]) <= LEVEL_ONE: + characters.append(int(codepoint[2:], 16)) + if len(characters) != LEVEL_ONE: + sys.exit(f"Unihan lists {len(characters)} level-one characters, expected {LEVEL_ONE}") + return characters + + +def parse_bdf(text): + """codepoint -> (advance, 12x12 cell as rows of 0/1, or None when the glyph reaches outside the cell).""" + lines = iter(text.splitlines()) + glyphs = {} + for line in lines: + if line.startswith("FONT_ASCENT ") and int(line.split()[1]) != ASCENT: + sys.exit(f"expected a font ascent of {ASCENT}: {line}") + if not line.startswith("STARTCHAR"): + continue + codepoint = advance = box = None + for line in lines: + key, _, value = line.partition(" ") + if key == "ENCODING": + codepoint = int(value) + elif key == "DWIDTH": + advance = int(value.split()[0]) + elif key == "BBX": + box = [int(number) for number in value.split()] + elif key == "BITMAP": + break + width, height, x_offset, y_offset = box + stride = (width + 7) // 8 * 8 + cell = [[0] * CELL for _ in range(CELL)] + for row in range(height): + bits = int(next(lines), 16) + for column in range(width): + if bits >> (stride - 1 - column) & 1: + # BDF rows run down from the box's top, which is y_offset + height above the baseline. + cell_row = ASCENT - (y_offset + height) + row + cell_column = x_offset + column + if cell is not None and 0 <= cell_row < CELL and 0 <= cell_column < CELL: + cell[cell_row][cell_column] = 1 + else: + cell = None + if next(lines) != "ENDCHAR": + sys.exit(f"U+{codepoint:04X}: malformed BDF") + glyphs[codepoint] = (advance, cell) + return glyphs + + +def pack(cell): + bits = 0 + for row in cell: + for pixel in row: + bits = bits << 1 | pixel + return bits.to_bytes(GLYPH_BYTES, "big") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--fusion", help="local copy of the Fusion Pixel Font BDF zip") + parser.add_argument("--unihan", help="local copy of Unihan.zip") + parser.add_argument("--output", type=Path, default=OUTPUT) + arguments = parser.parse_args() + + fusion = fetch(FUSION_URL, FUSION_SHA256, arguments.fusion) + glyphs = parse_bdf(fusion.read(FUSION_MEMBER).decode()) + hanzi = level_one(fetch(UNIHAN_URL, UNIHAN_SHA256, arguments.unihan)) + unusable = [codepoint for codepoint in hanzi + if codepoint not in glyphs or glyphs[codepoint][0] != CELL or glyphs[codepoint][1] is None] + if unusable: + sys.exit("no full-width 12x12 glyph for level-one characters: " + "".join(map(chr, unusable))) + + extras = [codepoint for first, last in EXTRA_RANGES for codepoint in range(first, last + 1)] + extras += [ord(character) for character in EXTRA_CHARACTERS] + # Only full-width glyphs inside the cell: every glyph in the table advances 12 px. The rest are left to + # the fallback box. + extras = [codepoint for codepoint in extras + if codepoint in glyphs and glyphs[codepoint][0] == CELL and glyphs[codepoint][1] is not None] + codepoints = sorted(set(hanzi) | set(extras)) + if codepoints[-1] > 0xFFFF: + sys.exit("the table's index holds 16-bit codepoints only") + + table = bytearray(struct.pack(" {arguments.output}") + if skipped: + print("not full-width in the font, left to the fallback box: " + "".join(map(chr, skipped))) + + +if __name__ == "__main__": + main() diff --git a/tools/package-licenses.py b/tools/package-licenses.py index 3fec7b5d..7d8cc392 100644 --- a/tools/package-licenses.py +++ b/tools/package-licenses.py @@ -59,6 +59,11 @@ def main(): (licenses / "THIRD-PARTY-FIRMWARE.txt").write_text( dependency_notices("firmware-rs/device/Cargo.toml", "xtensa-esp32s3-none-elf")) shutil.copyfile(ROOT / "packaging/BOOTLOADER-NOTICES.txt", licenses / "BOOTLOADER-NOTICES.txt") + # The firmware's Chinese glyphs come from Fusion Pixel Font (OFL), whose licenses travel with them. + fonts = ROOT / "firmware-rs/core/fonts" + (licenses / "FONTS.txt").write_text("\n\n".join( + f"{path.relative_to(fonts)}\n{'=' * len(str(path.relative_to(fonts)))}\n\n{path.read_text()}" + for path in sorted(fonts.rglob("*.txt")))) else: (licenses / "THIRD-PARTY-APP.txt").write_text(dependency_notices("Cargo.toml", app_target)) revision = run("git", "rev-parse", "HEAD") From 91b38cc8c5e9816c0851d14578a3bb4625d47238 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 18:49:16 +0800 Subject: [PATCH 25/51] Play spoken replies' MP3 on the box voice.audio chunks for the turn the box recorded are base64-decoded and handed to a ReplyPlayer (core spoken_reply.rs), which the audio task pulls from whenever no announcement is queued: same I2S stream, same volume. Each reply is buffered on the heap and decoded frame by frame with nanomp3-core; the 22 KB decoder is built zeroed on the heap when a reply starts and freed when nothing is left. A reply starts with 300 ms buffered, goes quiet (not stutter) when it runs dry, and replies play in order; a gap or out-of-order chunk drops that reply. An end with "error":"synthesis" plays what came and logs VOICE AUDIO CUT SHORT. Announcements and the Pomodoro chime win: the reply stops and the rest of its turn is ignored. So do mute, a new K0 push-to-talk, K2 and the overlay closing; the overlay stays up while a reply is still speaking. nanomp3-core is vendored with l3_reorder marked #[inline(never)]: inlined, LSR leaves an address the esp Xtensa backend can't select ("Cannot select: i32 = Constant<4608>") at opt-level s. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 5 + Cargo.toml | 5 +- docs/protocol.md | 2 +- firmware-rs/core/Cargo.toml | 3 + firmware-rs/core/src/firmware.rs | 69 +- .../core/src/fixtures/spoken_reply.mp3 | Bin 0 -> 28800 bytes firmware-rs/core/src/lib.rs | 1 + firmware-rs/core/src/spoken_reply.rs | 468 ++ firmware-rs/core/src/voice_command.rs | 5 + firmware-rs/core/tests/firmware.rs | 212 + firmware-rs/device/Cargo.lock | 5 + firmware-rs/device/Cargo.toml | 4 + firmware-rs/device/src/audio.rs | 10 + firmware-rs/device/src/main.rs | 11 + firmware-rs/vendor/nanomp3-core/Cargo.toml | 24 + .../vendor/nanomp3-core/LICENSE-APACHE | 202 + firmware-rs/vendor/nanomp3-core/LICENSE-MIT | 25 + firmware-rs/vendor/nanomp3-core/README.md | 5 + firmware-rs/vendor/nanomp3-core/src/lib.rs | 227 + .../vendor/nanomp3-core/src/minimp3.rs | 1525 +++++++ .../vendor/nanomp3-core/src/minimp3/lanes.rs | 178 + .../nanomp3-core/src/minimp3/layer12.rs | 244 + .../vendor/nanomp3-core/src/minimp3/tables.rs | 3972 +++++++++++++++++ 23 files changed, 7197 insertions(+), 5 deletions(-) create mode 100644 firmware-rs/core/src/fixtures/spoken_reply.mp3 create mode 100644 firmware-rs/core/src/spoken_reply.rs create mode 100644 firmware-rs/vendor/nanomp3-core/Cargo.toml create mode 100644 firmware-rs/vendor/nanomp3-core/LICENSE-APACHE create mode 100644 firmware-rs/vendor/nanomp3-core/LICENSE-MIT create mode 100644 firmware-rs/vendor/nanomp3-core/README.md create mode 100644 firmware-rs/vendor/nanomp3-core/src/lib.rs create mode 100644 firmware-rs/vendor/nanomp3-core/src/minimp3.rs create mode 100644 firmware-rs/vendor/nanomp3-core/src/minimp3/lanes.rs create mode 100644 firmware-rs/vendor/nanomp3-core/src/minimp3/layer12.rs create mode 100644 firmware-rs/vendor/nanomp3-core/src/minimp3/tables.rs diff --git a/Cargo.lock b/Cargo.lock index 9f101539..ab158b91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2583,6 +2583,10 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "nanomp3-core" +version = "0.2.0" + [[package]] name = "native-tls" version = "0.2.18" @@ -5135,6 +5139,7 @@ dependencies = [ "clatter", "ed25519-dalek", "libm", + "nanomp3-core", "rand_core 0.6.4", "serde_json", "tungstenite", diff --git a/Cargo.toml b/Cargo.toml index b3134687..c3f3c49a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] members = ["daemon", "desktop", "firmware-rs/core", "hook", "manifest", "protocol"] # The device firmware builds separately with the Xtensa toolchain; see firmware-rs/device. -exclude = ["firmware-rs/device"] +exclude = ["firmware-rs/device", "firmware-rs/vendor/nanomp3-core"] resolver = "3" [workspace.package] @@ -25,3 +25,6 @@ tokio-serial = "5" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +# The MP3 decoder for spoken replies, patched for the Xtensa backend (see its Cargo.toml). +[patch.crates-io] +nanomp3-core = { path = "firmware-rs/vendor/nanomp3-core" } diff --git a/docs/protocol.md b/docs/protocol.md index 8dfc5961..e7fdffcf 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -224,7 +224,7 @@ The computer also sends each spoken reply's audio, synthesized in the Character' - `voice.audio` with `reply` (0 for the turn's first `voice.reply`, counting up), `seq` (from 0, without gaps) and `data`: base64 of the next bytes of an MP3 stream (MPEG-2 Layer III, 24 kHz mono, 48 kbps, as the voice service returns it), at most 768 base64 characters. Chunks don't follow MP3 frame boundaries; the box buffers and decodes frame by frame. - `voice.audio` with `reply`, `seq` and `"end":true` (no `data`): that reply's audio is over. With `"error":"synthesis"` as well, synthesis failed or was cut short: the box plays what it has, keeps the text, and logs the failure instead of passing it off as a whole reply. -The box plays replies in order, one after another, and starts a reply as soon as it has enough frames, without waiting for its end. If synthesis fails the reply stays text only. While muted the box shows replies without playing them. +The box plays replies in order, one after another, and starts a reply as soon as it has enough frames, without waiting for its end. If synthesis fails the reply stays text only. While muted the box shows replies without playing them. The box plays audio only for the turn it recorded last (`voice.audio` carries `turn` like the other answers), and stops a turn's replies for good, ignoring the rest of its audio, when an announcement or the Pomodoro chime is about to play (they win), when it is muted, when a new K0 push-to-talk starts, or when the overlay closes. The overlay stays up past its read time while a reply is still being spoken. A chunk missing or out of order drops that reply's audio and the next reply plays. ## Stage 1 error output diff --git a/firmware-rs/core/Cargo.toml b/firmware-rs/core/Cargo.toml index 60da2a1b..b5f0f26a 100644 --- a/firmware-rs/core/Cargo.toml +++ b/firmware-rs/core/Cargo.toml @@ -16,6 +16,9 @@ fast-clock = [] # The box's pairing key (link.rs). No default features: no std, no OS randomness; the seed comes from the device. ed25519-dalek = { version = "2", default-features = false } libm = "0.2" +# Spoken replies' MP3 (spoken_reply.rs). No `wide` (SIMD the Xtensa core lacks) and no Layer I/II. Pinned: the +# player builds the decoder zeroed on the heap, which relies on this version's layout. +nanomp3-core = { version = "=0.2.0", default-features = false } # Noise sessions on links off the cable (noise.rs, ADR-0015): no std and no getrandom; the device supplies the randomness. clatter = { version = "2.3", default-features = false, features = ["use-25519", "use-chacha20poly1305", "use-sha"] } rand_core = { version = "0.6", default-features = false } diff --git a/firmware-rs/core/src/firmware.rs b/firmware-rs/core/src/firmware.rs index 351da01d..cef3ebc0 100644 --- a/firmware-rs/core/src/firmware.rs +++ b/firmware-rs/core/src/firmware.rs @@ -21,7 +21,8 @@ use crate::pomodoro::{Phase, Pomodoro, Run, Transition}; use crate::relay_box; use crate::storage::{Flash, Settings, SettingsStore, find_partition}; use crate::text; -use crate::voice_pack::crc32; +use crate::spoken_reply::ReplyAudio; +use crate::voice_pack::{crc32, decode_base64}; use crate::lines::LinePicker; use crate::lan::{Conn, Lan, Output as LanOutput}; use crate::noise::Sealer; @@ -142,6 +143,13 @@ pub trait Board: Screen { fn write_stream(&mut self, line: &[u8]) { self.write(line); } + /// Hands a spoken reply's audio to the player ([`crate::spoken_reply`]), which the speaker plays from while + /// no announcement is playing, through the same output and at the same volume. + fn reply_audio(&mut self, _audio: ReplyAudio) {} + /// A spoken reply is making sound right now. + fn reply_speaking(&self) -> bool { + false + } } /// The codec's registers through the board's I2C, for register sequences in core. @@ -243,6 +251,9 @@ pub struct Firmware { voice: VoiceCommand, /// The microphone is running for a voice command. voice_mic: bool, + /// The turn whose spoken replies are played; None once they were stopped (an announcement, mute, K2, the + /// overlay closing), and the rest of that turn's audio is then ignored. + reply_turn: Option, } /// Borrows the Firmware's pomodoro and leisure director to build the context for one frame. @@ -357,6 +368,7 @@ impl Firmware { mic: MicState::Idle, voice: VoiceCommand::new(), voice_mic: false, + reply_turn: None, } } @@ -817,6 +829,10 @@ impl Firmware { } }, }; + // An announcement or the Pomodoro chime wins over a spoken reply: the reply stops and the rest of its turn + // stays text only. The announcement is the box's own news and is short; queued behind a reply it + // could come half a minute late, and two voices taking turns would be one too many. + self.stop_reply(board); let queued = self.audio_ready && board.play(first).is_ok() && then.is_none_or(|sound| board.play(sound).is_ok()); if queued { Self::write_value_line(board, "AUDIO QUEUED ", label.as_bytes()); @@ -966,6 +982,10 @@ impl Firmware { /// K2 long, or MUTE in the menu. fn toggle_mute(&mut self, board: &mut B) { self.muted = !self.muted; + // Muting silences a reply at once; unmuting doesn't bring back the rest of that turn. + if self.muted { + self.stop_reply(board); + } let now = board.now_ms(); let muted = self.muted; self.display.set_muted(board, &scene!(self, now), muted); @@ -1059,14 +1079,18 @@ impl Firmware { let out = self.voice.set_available(on); Self::write_voice(board, out); self.stop_voice_mic(board); + if !on { + self.stop_reply(board); + } self.show_overlay(board); } /// K0 held while voice is offered: the microphone at voice gain, any sound stopped so the box doesn't - /// hear itself, and a new turn. + /// hear itself (a spoken reply too: a new command interrupts the last one's answer), and a new turn. fn start_recording(&mut self, board: &mut B, now: u32) { self.voice.cancel(); self.stop_voice_mic(board); + self.stop_reply(board); // A box without the ES8311 (the NS4168 variant) has no gain to set; its microphone, if any, is untested. let _ = mic::set_es8311_mic_gain(&mut BoardI2c { board: &mut *board, address: ES8311_ADDRESS }, voice_command::GAIN_DB); let started = matches!(self.mic, MicState::Idle) && board.voice_start().is_ok(); @@ -1075,6 +1099,7 @@ impl Firmware { board.stop_audio(); let start = self.voice.start(now); Self::write_voice(board, [start]); + self.reply_turn = Some(self.voice.turn()); } else { self.voice.no_microphone(now); } @@ -1102,9 +1127,43 @@ impl Firmware { let out = self.voice.cancel(); Self::write_voice(board, out); self.stop_voice_mic(board); + self.stop_reply(board); self.show_overlay(board); } + /// Stops the turn's spoken replies, and ignores the rest of its audio. + fn stop_reply(&mut self, board: &mut B) { + if self.reply_turn.take().is_some() { + board.reply_audio(ReplyAudio::Stop); + } + } + + /// `voice.audio`: a chunk of a spoken reply's MP3, or its end, for the turn being played. While muted the + /// replies are only shown. A chunk that doesn't decode is a chunk lost: the next one's `seq` gives it away. + fn on_voice_audio(&mut self, board: &mut B, fields: &BTreeMap) { + let field = |key| number(fields, key).filter(|value| *value >= 0.0).map(|value| value as u32); + let (Some(turn), Some(reply), Some(seq)) = (field("turn"), field("reply"), field("seq")) else { return }; + if self.reply_turn != Some(turn) { + return; + } + if fields.get("end") == Some(&Value::Bool(true)) { + // Synthesis failed or was cut short: what came plays, and the log says it isn't the whole reply. + if fields.get("error").is_some() { + let line = text!(48, "VOICE AUDIO CUT SHORT {} {}\n", turn, reply); + board.write(line.as_bytes()); + } + if !self.muted { + board.reply_audio(ReplyAudio::End { reply, seq }); + } + return; + } + let Some(data) = string(fields, "data").filter(|_| !self.muted) else { return }; + let mut bytes = alloc::vec![0u8; data.len() / 4 * 3 + 3]; + let Some(count) = decode_base64(data.as_bytes(), &mut bytes) else { return }; + bytes.truncate(count); + board.reply_audio(ReplyAudio::Chunk { reply, seq, data: bytes }); + } + fn stop_voice_mic(&mut self, board: &mut B) { if self.voice_mic && !self.voice.listening() { board.voice_stop(); @@ -1122,7 +1181,9 @@ impl Firmware { } self.show_overlay(board); } - if self.voice.expire(now) { + // The text stays up while a reply is still being spoken; when it goes, so does what is left to say. + if !board.reply_speaking() && self.voice.expire(now) { + self.stop_reply(board); self.show_overlay(board); } self.stop_voice_mic(board); @@ -1581,6 +1642,7 @@ impl Firmware { // Voice commands (docs/protocol.md): the computer offers them, then answers each turn. "voice.available" => self.set_voice_available(board, fields.get("on") == Some(&Value::Bool(true))), "voice.heard" | "voice.reply" | "voice.done" | "voice.error" => self.on_voice_turn(board, fields, event), + "voice.audio" => self.on_voice_audio(board, fields), _ if event.starts_with("voice.") => self.handle_voice_event(board, fields, event), // Pairing is the computer at the other end of the cable telling the box who it is (ADR-0012). // Only over the cable: whoever is at the box decides who may link and which network it joins. @@ -1816,6 +1878,7 @@ impl Firmware { // stalled, and DMA replays whatever is in its buffer over and over, so it must be // silence first. The wait continues in poll. board.stop_audio(); + self.stop_reply(board); self.pending_voice_begin = Some((size as u32, board.now_ms())); self.continue_voice_begin(board); } diff --git a/firmware-rs/core/src/fixtures/spoken_reply.mp3 b/firmware-rs/core/src/fixtures/spoken_reply.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..1f7ac928b649816472fc6a5f08d813581451dfcc GIT binary patch literal 28800 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 }, + /// The reply's audio is over; `seq` follows its last chunk. + End { reply: u32, seq: u32 }, + /// Stop playing and forget every reply. + Stop, +} + +struct Reply { + index: u32, + next_seq: u32, + bytes: Vec, + /// Decoded up to here. + read: usize, + ended: bool, + dropped: bool, +} + +impl Reply { + fn ahead(&self) -> usize { + self.bytes.len() - self.read + } + + fn drop_audio(&mut self) { + self.dropped = true; + self.bytes = Vec::new(); + self.read = 0; + } +} + +pub struct ReplyPlayer { + /// Replies with audio, in their order; the first is the one playing or next to. + replies: VecDeque, + /// Replies numbered below this are over (played, dropped, or passed by because a later one started); their + /// chunks are ignored. + floor: u32, + /// The playing reply's decoder; each reply is its own stream. + decoder: Option>, + /// The last decoded frame, and how much of it is handed out. + pcm: Vec, + pcm_read: usize, + pcm_len: usize, + /// The first reply had enough buffered to start and hasn't run dry since. + sounding: bool, +} + +impl Default for ReplyPlayer { + fn default() -> Self { + Self::new() + } +} + +impl ReplyPlayer { + pub const fn new() -> Self { + Self { replies: VecDeque::new(), floor: 0, decoder: None, pcm: Vec::new(), pcm_read: 0, pcm_len: 0, sounding: false } + } + + pub fn apply(&mut self, audio: ReplyAudio) { + match audio { + ReplyAudio::Chunk { reply, seq, data } => self.chunk(reply, seq, Some(&data)), + ReplyAudio::End { reply, seq } => self.chunk(reply, seq, None), + // Everything goes, the memory too. + ReplyAudio::Stop => *self = Self::new(), + } + } + + /// Something is still to be played: a reply buffered or playing. + pub fn pending(&self) -> bool { + !self.replies.is_empty() || self.pcm_read < self.pcm_len + } + + /// A reply is making sound right now (not waiting for its first bytes or after running dry). + pub fn speaking(&self) -> bool { + self.sounding || self.pcm_read < self.pcm_len + } + + /// A chunk of a reply, or with no data its end. + fn chunk(&mut self, index: u32, seq: u32, data: Option<&[u8]>) { + if index < self.floor { + return; + } + let at = match self.replies.iter().position(|reply| reply.index >= index) { + Some(at) if self.replies[at].index == index => at, + found => { + let at = found.unwrap_or(self.replies.len()); + let reply = Reply { index, next_seq: 0, bytes: Vec::new(), read: 0, ended: false, dropped: false }; + self.replies.insert(at, reply); + at + } + }; + let reply = &mut self.replies[at]; + if reply.dropped || reply.ended { + return; + } + if seq != reply.next_seq { + return self.drop_reply(at); + } + reply.next_seq += 1; + let Some(data) = data else { + reply.ended = true; + return; + }; + if reply.bytes.len() + data.len() > MAX_REPLY_BYTES || reply.bytes.try_reserve(data.len()).is_err() { + return self.drop_reply(at); + } + reply.bytes.extend_from_slice(data); + } + + fn drop_reply(&mut self, at: usize) { + self.replies[at].drop_audio(); + // The one playing stops at once, with what it had decoded. + if at == 0 { + self.pcm_len = 0; + self.pcm_read = 0; + } + } + + /// Fills `out` with the next 24 kHz stereo 16-bit PCM and returns how many bytes (a multiple of 4) were + /// written; fewer than asked, or none, while there is nothing to play right now. The caller pads with silence. + pub fn fill(&mut self, out: &mut [u8]) -> usize { + let mut written = 0; + while written + 4 <= out.len() { + if self.pcm_read == self.pcm_len && !self.decode_next() { + break; + } + let [low, high] = self.pcm[self.pcm_read].to_le_bytes(); + out[written..written + 4].copy_from_slice(&[low, high, low, high]); + self.pcm_read += 1; + written += 4; + } + written + } + + /// Decodes the next frame of the first reply; false when there is nothing to play right now. + fn decode_next(&mut self) -> bool { + loop { + let Some(reply) = self.replies.front_mut() else { + // Nothing left: the decoder and its buffers go back to the heap. + self.decoder = None; + self.pcm = Vec::new(); + self.sounding = false; + return false; + }; + if reply.dropped || (reply.ended && reply.ahead() == 0) { + self.floor = self.floor.max(reply.index + 1); + self.replies.pop_front(); + self.decoder = None; + self.sounding = false; + continue; + } + if !self.sounding { + if reply.ahead() < START_BYTES && !reply.ended { + return false; + } + self.sounding = true; + // Once a reply starts, an earlier one that shows up late is passed by. + self.floor = self.floor.max(reply.index); + } + if reply.ahead() < LOOKAHEAD && !reply.ended { + // Ran dry: quiet until it has enough to start again. + self.sounding = false; + return false; + } + if self.decoder.is_none() { + self.decoder = new_decoder(); + } + let Some(decoder) = self.decoder.as_mut() else { + reply.drop_audio(); + continue; + }; + if self.pcm.is_empty() { + self.pcm = alloc::vec![0; MAX_SAMPLES_PER_FRAME]; + } + let (used, result) = decoder.decode(&reply.bytes[reply.read..], &mut self.pcm); + // Nothing consumed only happens on an empty input; with the reply over, the rest is junk. + reply.read += if used == 0 { reply.ahead() } else { used }; + if reply.read >= COMPACT_BYTES { + reply.bytes.drain(..reply.read); + reply.read = 0; + } + match result { + Ok(frame) if frame.sample_rate != SAMPLE_RATE => reply.drop_audio(), + Ok(frame) if frame.samples_produced > 0 => { + // Mono as the service sends it; of a stereo stream, the left channel. + let channels = frame.channels.num() as usize; + for sample in 1..frame.samples_produced { + self.pcm[sample] = self.pcm[sample * channels]; + } + self.pcm_read = 0; + self.pcm_len = frame.samples_produced; + return true; + } + // The first frames after a start lean on a bit reservoir that isn't there; a corrupt frame is skipped. + _ => {} + } + } + } +} + +/// The decoder, built straight on the heap: 22 KB must not pass through the stack on its way there. +fn new_decoder() -> Option> { + let layout = core::alloc::Layout::new::(); + // SAFETY: `Decoder::new()` is all zero bytes in nanomp3-core 0.2.0 (pinned with `=` for this): zeroed + // float arrays, zero counters and a `false`. The test `a_zeroed_decoder_decodes_like_a_new_one` holds it + // to that. A non-null pointer from `alloc_zeroed` with Decoder's layout is what `Box::from_raw` takes. + unsafe { + let raw = alloc::alloc::alloc_zeroed(layout).cast::(); + (!raw.is_null()).then(|| Box::from_raw(raw)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::vec; + + const FIXTURE: &[u8] = include_bytes!("fixtures/spoken_reply.mp3"); + + /// The fixture decoded in one go with a decoder on the stack: the reference, as stereo bytes. + fn one_shot(mp3: &[u8], mut decoder: Decoder) -> Vec { + let mut pcm = [0i16; MAX_SAMPLES_PER_FRAME]; + let mut out = Vec::new(); + let mut rest = mp3; + while !rest.is_empty() { + let (used, result) = decoder.decode(rest, &mut pcm); + rest = &rest[used.max(1).min(rest.len())..]; + if let Ok(frame) = result { + assert_eq!((frame.sample_rate, frame.channels.num()), (24_000, 1)); + for &sample in &pcm[..frame.samples_produced] { + out.extend_from_slice(&sample.to_le_bytes()); + out.extend_from_slice(&sample.to_le_bytes()); + } + } + } + out + } + + fn chunks(mp3: &[u8], sizes: &[usize]) -> Vec> { + let mut out = Vec::new(); + let mut at = 0; + for size in sizes.iter().cycle() { + if at == mp3.len() { + break; + } + let end = (at + size).min(mp3.len()); + out.push(mp3[at..end].to_vec()); + at = end; + } + out + } + + fn send(player: &mut ReplyPlayer, reply: u32, pieces: &[Vec]) { + for (seq, data) in pieces.iter().enumerate() { + player.apply(ReplyAudio::Chunk { reply, seq: seq as u32, data: data.clone() }); + } + player.apply(ReplyAudio::End { reply, seq: pieces.len() as u32 }); + } + + /// Everything the player has to give, pulled in pieces the size the audio task uses. + fn drain(player: &mut ReplyPlayer, piece: usize) -> Vec { + let mut out = Vec::new(); + let mut buffer = vec![0u8; piece]; + loop { + let count = player.fill(&mut buffer); + out.extend_from_slice(&buffer[..count]); + if count == 0 { + return out; + } + } + } + + #[test] + fn the_fixture_is_the_services_format() { + let pcm = one_shot(FIXTURE, Decoder::new()); + // Stereo 16-bit bytes at 24 kHz: a few seconds of speech. + let seconds = pcm.len() as f32 / 4.0 / 24_000.0; + assert!((3.0..6.0).contains(&seconds), "{seconds} s"); + } + + #[test] + fn a_zeroed_decoder_decodes_like_a_new_one() { + let zeroed = new_decoder().unwrap(); + assert_eq!(one_shot(FIXTURE, (*zeroed).clone()), one_shot(FIXTURE, Decoder::new())); + } + + #[test] + fn odd_chunks_pulled_as_they_come_give_the_same_pcm_as_one_go() { + let expected = one_shot(FIXTURE, Decoder::new()); + for (sizes, piece) in [(&[1usize, 7, 333, 577][..], 1000usize), (&[576], 4), (&[97, 1500], 1004), (&[FIXTURE.len()], 3000)] { + let mut player = ReplyPlayer::new(); + let pieces = chunks(FIXTURE, sizes); + let mut out = Vec::new(); + let mut buffer = vec![0u8; piece]; + // The audio task pulls between chunks, so the player runs dry and waits along the way. + for (seq, data) in pieces.iter().enumerate() { + player.apply(ReplyAudio::Chunk { reply: 0, seq: seq as u32, data: data.clone() }); + let count = player.fill(&mut buffer); + out.extend_from_slice(&buffer[..count]); + } + player.apply(ReplyAudio::End { reply: 0, seq: pieces.len() as u32 }); + out.extend(drain(&mut player, piece)); + assert!(out == expected, "chunks {sizes:?}, pulled {piece} at a time: {} bytes, expected {}", out.len(), expected.len()); + assert!(!player.pending() && player.decoder.is_none() && player.pcm.is_empty(), "everything is let go of"); + } + } + + #[test] + fn a_reply_starts_with_300_ms_buffered_without_waiting_for_its_end() { + let mut player = ReplyPlayer::new(); + let mut buffer = [0u8; 1000]; + player.apply(ReplyAudio::Chunk { reply: 0, seq: 0, data: FIXTURE[..START_BYTES - 1].to_vec() }); + assert_eq!(player.fill(&mut buffer), 0, "not yet"); + assert!(!player.speaking() && player.pending()); + player.apply(ReplyAudio::Chunk { reply: 0, seq: 1, data: FIXTURE[START_BYTES - 1..START_BYTES].to_vec() }); + assert_eq!(player.fill(&mut buffer), 1000); + assert!(player.speaking()); + // It plays until less than a frame's lookahead is left, then waits quietly instead of stuttering. + let played = drain(&mut player, 1000).len() + 1000; + assert!(played < one_shot(&FIXTURE[..START_BYTES], Decoder::new()).len() && !player.speaking(), "{played}"); + player.apply(ReplyAudio::Chunk { reply: 0, seq: 2, data: FIXTURE[START_BYTES..START_BYTES + 100].to_vec() }); + assert_eq!(player.fill(&mut buffer), 0, "a little more isn't enough to start again"); + // A short reply that ends below the threshold plays at once. + let mut short = ReplyPlayer::new(); + send(&mut short, 0, &[FIXTURE[..600].to_vec()]); + assert!(short.fill(&mut buffer) > 0); + } + + #[test] + fn replies_play_in_order_whatever_order_their_audio_comes_in() { + let first = &FIXTURE[..FIXTURE.len() / 2]; + let expected_first = one_shot(first, Decoder::new()); + let expected_whole = one_shot(FIXTURE, Decoder::new()); + let mut player = ReplyPlayer::new(); + // Reply 1 comes in whole before reply 0's end, but waits its turn. + player.apply(ReplyAudio::Chunk { reply: 0, seq: 0, data: first[..100].to_vec() }); + send(&mut player, 1, &chunks(FIXTURE, &[768])); + player.apply(ReplyAudio::Chunk { reply: 0, seq: 1, data: first[100..].to_vec() }); + player.apply(ReplyAudio::End { reply: 0, seq: 2 }); + let out = drain(&mut player, 1000); + assert!(out[..expected_first.len()] == expected_first[..], "reply 0 first"); + assert!(out[expected_first.len()..] == expected_whole[..], "then reply 1, from a fresh decoder"); + } + + #[test] + fn a_reply_without_audio_is_passed_by_and_a_late_earlier_one_is_ignored() { + let mut player = ReplyPlayer::new(); + // Reply 0 never got audio; reply 2 starts, so reply 1 turning up afterwards is too late. + send(&mut player, 2, &chunks(FIXTURE, &[768])); + let mut buffer = [0u8; 1000]; + assert!(player.fill(&mut buffer) > 0); + send(&mut player, 1, &chunks(FIXTURE, &[768])); + let out = drain(&mut player, 1000).len() + 1000; + assert_eq!(out, one_shot(FIXTURE, Decoder::new()).len()); + } + + #[test] + fn a_gap_or_a_chunk_out_of_order_drops_that_reply_only() { + let pieces = chunks(FIXTURE, &[768]); + let expected = one_shot(FIXTURE, Decoder::new()); + // A missing chunk before the reply starts: none of it plays, the next reply does. + let mut player = ReplyPlayer::new(); + player.apply(ReplyAudio::Chunk { reply: 0, seq: 0, data: pieces[0].clone() }); + player.apply(ReplyAudio::Chunk { reply: 0, seq: 2, data: pieces[2].clone() }); + player.apply(ReplyAudio::Chunk { reply: 0, seq: 3, data: pieces[3].clone() }); + player.apply(ReplyAudio::End { reply: 0, seq: 4 }); + send(&mut player, 1, &pieces); + assert!(drain(&mut player, 1000) == expected); + + // Out of order mid-reply, while it plays: it stops there and the next one plays whole. + let mut player = ReplyPlayer::new(); + for (seq, data) in pieces[..5].iter().enumerate() { + player.apply(ReplyAudio::Chunk { reply: 0, seq: seq as u32, data: data.clone() }); + } + let mut buffer = [0u8; 1000]; + assert_eq!(player.fill(&mut buffer), 1000); + player.apply(ReplyAudio::Chunk { reply: 0, seq: 6, data: pieces[6].clone() }); + player.apply(ReplyAudio::Chunk { reply: 0, seq: 5, data: pieces[5].clone() }); + send(&mut player, 1, &pieces); + assert!(drain(&mut player, 1000) == expected); + + // An end with the wrong count means the last chunk went missing: dropped too. + let mut player = ReplyPlayer::new(); + for (seq, data) in pieces.iter().enumerate().take(pieces.len() - 1) { + player.apply(ReplyAudio::Chunk { reply: 0, seq: seq as u32, data: data.clone() }); + } + player.apply(ReplyAudio::End { reply: 0, seq: pieces.len() as u32 }); + let played = drain(&mut player, 1000).len(); + assert!(played < expected.len() / 2, "{played}"); + assert!(!player.pending()); + } + + #[test] + fn a_reply_cut_short_plays_what_it_has() { + // Synthesis stopped partway: the end arrives in order after fewer bytes. + let part = &FIXTURE[..FIXTURE.len() / 3]; + let mut player = ReplyPlayer::new(); + send(&mut player, 0, &chunks(part, &[768])); + assert!(drain(&mut player, 1000) == one_shot(part, Decoder::new())); + } + + #[test] + fn stop_forgets_everything_and_garbage_is_not_played() { + let mut player = ReplyPlayer::new(); + send(&mut player, 0, &chunks(FIXTURE, &[768])); + let mut buffer = [0u8; 1000]; + assert_eq!(player.fill(&mut buffer), 1000); + player.apply(ReplyAudio::Stop); + assert!(!player.pending() && player.decoder.is_none()); + assert_eq!(player.fill(&mut buffer), 0); + // After a stop, a new turn's replies count from 0 again. + send(&mut player, 0, &[vec![0x55; 3000]]); + assert_eq!(drain(&mut player, 1000).len(), 0, "no frames, no sound"); + assert!(!player.pending()); + } + + #[test] + fn a_reply_too_long_to_hold_is_dropped() { + let mut player = ReplyPlayer::new(); + let piece = vec![0u8; 64 * 1024]; + for seq in 0..5 { + player.apply(ReplyAudio::Chunk { reply: 0, seq, data: piece.clone() }); + } + assert!(player.replies[0].dropped && player.replies[0].bytes.capacity() == 0); + } +} diff --git a/firmware-rs/core/src/voice_command.rs b/firmware-rs/core/src/voice_command.rs index 1816cf23..90a36521 100644 --- a/firmware-rs/core/src/voice_command.rs +++ b/firmware-rs/core/src/voice_command.rs @@ -240,6 +240,11 @@ impl VoiceCommand { if on { None } else { self.cancel() } } + /// The last turn's number. + pub fn turn(&self) -> u32 { + self.turn + } + pub fn listening(&self) -> bool { matches!(self.stage, Stage::Listening(_)) } diff --git a/firmware-rs/core/tests/firmware.rs b/firmware-rs/core/tests/firmware.rs index 0a597dd6..6a2b64e3 100644 --- a/firmware-rs/core/tests/firmware.rs +++ b/firmware-rs/core/tests/firmware.rs @@ -10,6 +10,7 @@ use vibebuddy_firmware_core::canvas::FRAME_BYTES; use vibebuddy_firmware_core::display::Screen; use vibebuddy_firmware_core::firmware::{AudioStatus, Board, Firmware, FrameAction, VolumeError}; use vibebuddy_firmware_core::noise::Sealer; +use vibebuddy_firmware_core::spoken_reply::{ReplyAudio, ReplyPlayer}; use vibebuddy_firmware_core::storage::{Flash, FlashError}; use vibebuddy_firmware_core::voice_pack; @@ -99,6 +100,9 @@ struct FakeBoard { voice_running: bool, voice_samples: Vec, stream: Vec, + /// What the spoken reply player was handed, and whether it says a reply is sounding. + reply_audio: Vec, + speaking: bool, } impl FakeBoard { @@ -131,6 +135,8 @@ impl FakeBoard { voice_running: false, voice_samples: Vec::new(), stream: Vec::new(), + reply_audio: Vec::new(), + speaking: false, } } @@ -253,6 +259,12 @@ impl Board for FakeBoard { fn write_stream(&mut self, line: &[u8]) { self.stream.push(String::from_utf8(line.to_vec()).unwrap()); } + fn reply_audio(&mut self, audio: ReplyAudio) { + self.reply_audio.push(audio); + } + fn reply_speaking(&self) -> bool { + self.speaking + } /// The left slot counts up, the right slot is silent. fn mic_take(&mut self) -> Option, &'static str>> { let bytes = self.mic_capture.take()?; @@ -1526,3 +1538,203 @@ fn the_diagnostic_and_a_voice_command_share_the_microphone() { let lines = send(&mut firmware, &mut board, r#"{"version":1,"event":"device.mic.record"}"#); assert_eq!(lines, ["MIC BUSY"]); } + +const REPLY_MP3: &[u8] = include_bytes!("../src/fixtures/spoken_reply.mp3"); +const K2_PRESS: &str = r#"{"version":1,"event":"button","button":"K2","action":"press"}"#; + +/// A turn recorded and answered with a reply's text: the box is waiting on turn 1's audio. +fn answered_turn(firmware: &mut Firmware, board: &mut FakeBoard) { + send(firmware, board, VOICE_ON); + hold_k0_talking(firmware, board, 1800); + send(firmware, board, r#"{"version":1,"event":"voice.reply","turn":1,"text":"Writing tests."}"#); + board.reply_audio.clear(); +} + +/// A reply's MP3 as the computer sends it: 576 bytes (768 base64 characters) a line, then the end. +fn audio_lines(turn: u32, reply: u32, mp3: &[u8], end: &str) -> Vec { + let mut lines: Vec = mp3 + .chunks(576) + .enumerate() + .map(|(seq, piece)| format!(r#"{{"version":1,"event":"voice.audio","turn":{turn},"reply":{reply},"seq":{seq},"data":"{}"}}"#, encode_base64(piece))) + .collect(); + lines.push(format!(r#"{{"version":1,"event":"voice.audio","turn":{turn},"reply":{reply},"seq":{},"end":true{end}}}"#, mp3.len().div_ceil(576))); + lines +} + +/// What a player makes of what the board was handed. +fn pcm_of(audio: &[ReplyAudio]) -> Vec { + let mut player = ReplyPlayer::new(); + for audio in audio { + player.apply(audio.clone()); + } + let mut out = Vec::new(); + let mut buffer = [0u8; 1000]; + loop { + let count = player.fill(&mut buffer); + if count == 0 { + return out; + } + out.extend_from_slice(&buffer[..count]); + } +} + +#[test] +fn a_spoken_replys_audio_reaches_the_player_whole() { + let (mut firmware, mut board, _) = booted(blank_flash()); + answered_turn(&mut firmware, &mut board); + for line in audio_lines(1, 0, REPLY_MP3, "") { + assert!(line.len() < 1024); + assert!(send(&mut firmware, &mut board, &line).is_empty(), "{line}"); + } + let chunks = REPLY_MP3.len().div_ceil(576); + assert_eq!(board.reply_audio.len(), chunks + 1); + assert_eq!(board.reply_audio.last(), Some(&ReplyAudio::End { reply: 0, seq: chunks as u32 })); + let received: Vec = board + .reply_audio + .iter() + .flat_map(|audio| match audio { + ReplyAudio::Chunk { data, .. } => data.clone(), + _ => Vec::new(), + }) + .collect(); + assert!(received == REPLY_MP3, "the bytes arrive intact"); + assert!(pcm_of(&board.reply_audio).len() > 24_000 * 4 * 3, "seconds of speech"); + // Another turn's audio, or audio without a turn, goes nowhere. + board.reply_audio.clear(); + send(&mut firmware, &mut board, &audio_lines(7, 0, &REPLY_MP3[..576], "")[0]); + send(&mut firmware, &mut board, r#"{"version":1,"event":"voice.audio","reply":0,"seq":0,"data":"AAAA"}"#); + assert!(board.reply_audio.is_empty()); +} + +#[test] +fn a_reply_cut_short_by_synthesis_plays_what_came_and_says_so() { + let (mut firmware, mut board, _) = booted(blank_flash()); + answered_turn(&mut firmware, &mut board); + let mut printed = Vec::new(); + for line in audio_lines(1, 0, &REPLY_MP3[..576 * 5], r#","error":"synthesis""#) { + printed.extend(send(&mut firmware, &mut board, &line)); + } + assert_eq!(printed, ["VOICE AUDIO CUT SHORT 1 0"]); + assert_eq!(board.reply_audio.last(), Some(&ReplyAudio::End { reply: 0, seq: 5 })); + assert!(!pcm_of(&board.reply_audio).is_empty(), "what came still plays"); + // The text stays up to be read. + send(&mut firmware, &mut board, r#"{"version":1,"event":"voice.done","turn":1}"#); + assert!(press(&mut firmware, &mut board, Key::K2, 50).is_empty(), "K2 closes the overlay"); +} + +#[test] +fn while_muted_replies_are_shown_and_not_played() { + let (mut firmware, mut board, _) = booted(blank_flash()); + answered_turn(&mut firmware, &mut board); + let lines = audio_lines(1, 0, &REPLY_MP3[..576 * 4], ""); + send(&mut firmware, &mut board, &lines[0]); + assert_eq!(board.reply_audio.len(), 1); + // Muting mid-reply (K2 long): the reply stops at once, and the rest of the turn stays silent. + board.reply_audio.clear(); + assert_eq!(press(&mut firmware, &mut board, Key::K2, 1100), ["MUTE ON"]); + assert_eq!(board.reply_audio, [ReplyAudio::Stop]); + for line in &lines[1..] { + send(&mut firmware, &mut board, line); + } + assert_eq!(board.reply_audio, [ReplyAudio::Stop], "nothing more reaches the player"); + + // Muted before the turn: nothing reaches the player at all, and the text still shows. + let (mut firmware, mut board, _) = booted(blank_flash()); + assert_eq!(press(&mut firmware, &mut board, Key::K2, 1100), ["MUTE ON"]); + answered_turn(&mut firmware, &mut board); + for line in audio_lines(1, 0, &REPLY_MP3[..576 * 4], r#","error":"synthesis""#) { + send(&mut firmware, &mut board, &line); + } + assert!(board.reply_audio.is_empty()); + assert!(press(&mut firmware, &mut board, Key::K2, 50).is_empty(), "the overlay is up"); +} + +#[test] +fn an_announcement_stops_a_reply_and_the_rest_of_its_turn() { + let (mut firmware, mut board, _) = booted(blank_flash()); + answered_turn(&mut firmware, &mut board); + let lines = audio_lines(1, 0, &REPLY_MP3[..576 * 6], ""); + for line in &lines[..3] { + send(&mut firmware, &mut board, line); + } + board.reply_audio.clear(); + board.played.clear(); + // A greeting with no line says nothing, so it leaves the reply alone. + send(&mut firmware, &mut board, r#"{"version":1,"event":"buddy.say","occasion":"greeting_morning"}"#); + assert!(board.reply_audio.is_empty()); + send( + &mut firmware, + &mut board, + r#"{"version":1,"event":"task.done","title":"CC:VIBE","tasks":[{"title":"CC:VIBE","status":"done","elapsed_s":3}]}"#, + ); + assert_eq!(board.reply_audio, [ReplyAudio::Stop], "the announcement wins"); + assert_eq!(board.played, [Sound::Builtin(Prompt::Done)]); + for line in &lines[3..] { + send(&mut firmware, &mut board, line); + } + send(&mut firmware, &mut board, &audio_lines(1, 1, &REPLY_MP3[..576], "")[0]); + assert_eq!(board.reply_audio, [ReplyAudio::Stop], "the turn's later audio is ignored"); +} + +#[test] +fn the_pomodoro_chime_stops_a_reply_too() { + let (mut firmware, mut board, _) = booted(blank_flash()); + press(&mut firmware, &mut board, Key::K0, 50); + answered_turn(&mut firmware, &mut board); + send(&mut firmware, &mut board, &audio_lines(1, 0, &REPLY_MP3[..576], "")[0]); + board.reply_audio.clear(); + board.advance(25 * 60 * 1000); + firmware.poll(&mut board); + assert!(board.take_lines().contains(&"POMODORO FOCUS END".to_owned())); + assert_eq!(board.reply_audio, [ReplyAudio::Stop]); +} + +#[test] +fn a_new_push_to_talk_stops_the_reply_and_plays_the_new_turns() { + let (mut firmware, mut board, _) = booted(blank_flash()); + answered_turn(&mut firmware, &mut board); + send(&mut firmware, &mut board, &audio_lines(1, 0, &REPLY_MP3[..576], "")[0]); + board.reply_audio.clear(); + board.advance(100); + board.k0 = true; + firmware.poll(&mut board); + board.advance(1100); + firmware.poll(&mut board); + assert!(board.voice_running); + assert_eq!(board.reply_audio, [ReplyAudio::Stop], "the reply stops before the box listens"); + board.advance(1000); + board.k0 = false; + firmware.poll(&mut board); + board.take_lines(); + send(&mut firmware, &mut board, &audio_lines(1, 1, &REPLY_MP3[..576], "")[0]); + assert_eq!(board.reply_audio, [ReplyAudio::Stop], "the old turn's audio is ignored"); + send(&mut firmware, &mut board, &audio_lines(2, 0, &REPLY_MP3[..576], "")[0]); + assert_eq!(board.reply_audio.len(), 2, "the new turn's plays"); +} + +#[test] +fn the_text_stays_up_while_a_reply_is_spoken_and_k2_stops_it() { + let (mut firmware, mut board, _) = booted(blank_flash()); + answered_turn(&mut firmware, &mut board); + send(&mut firmware, &mut board, &audio_lines(1, 0, &REPLY_MP3[..576], "")[0]); + send(&mut firmware, &mut board, r#"{"version":1,"event":"voice.done","turn":1}"#); + board.speaking = true; + // The link stays up past the 15 s read time. + board.advance(10_000); + send(&mut firmware, &mut board, r#"{"version":1,"event":"device.heartbeat"}"#); + board.advance(6_000); + firmware.poll(&mut board); + assert_eq!(board.reply_audio.last(), Some(&ReplyAudio::Chunk { reply: 0, seq: 0, data: REPLY_MP3[..576].to_vec() }), "past the read time, still speaking"); + board.speaking = false; + board.advance(10); + firmware.poll(&mut board); + assert_eq!(board.reply_audio.last(), Some(&ReplyAudio::Stop), "the overlay closes, and what was left to say goes with it"); + assert_eq!(press(&mut firmware, &mut board, Key::K2, 50), [K2_PRESS]); + + let (mut firmware, mut board, _) = booted(blank_flash()); + answered_turn(&mut firmware, &mut board); + send(&mut firmware, &mut board, &audio_lines(1, 0, &REPLY_MP3[..576], "")[0]); + board.reply_audio.clear(); + assert!(press(&mut firmware, &mut board, Key::K2, 50).is_empty()); + assert_eq!(board.reply_audio, [ReplyAudio::Stop]); +} diff --git a/firmware-rs/device/Cargo.lock b/firmware-rs/device/Cargo.lock index bdff8552..e58458ae 100644 --- a/firmware-rs/device/Cargo.lock +++ b/firmware-rs/device/Cargo.lock @@ -2256,6 +2256,10 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "nanomp3-core" +version = "0.2.0" + [[package]] name = "nb" version = "0.1.3" @@ -3319,6 +3323,7 @@ dependencies = [ "clatter", "ed25519-dalek", "libm", + "nanomp3-core", "rand_core 0.6.4", "serde_json", "vibebuddy-protocol", diff --git a/firmware-rs/device/Cargo.toml b/firmware-rs/device/Cargo.toml index ea9aef74..a91be2a1 100644 --- a/firmware-rs/device/Cargo.toml +++ b/firmware-rs/device/Cargo.toml @@ -72,3 +72,7 @@ codegen-units = 1 debug = 2 lto = "fat" opt-level = "s" + +# The MP3 decoder for spoken replies, patched for the Xtensa backend (see its Cargo.toml). +[patch.crates-io] +nanomp3-core = { path = "../vendor/nanomp3-core" } diff --git a/firmware-rs/device/src/audio.rs b/firmware-rs/device/src/audio.rs index d7384a80..b89f25e2 100644 --- a/firmware-rs/device/src/audio.rs +++ b/firmware-rs/device/src/audio.rs @@ -12,12 +12,14 @@ use core::sync::atomic::{AtomicBool, Ordering}; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::Channel; +use embassy_sync::mutex::Mutex; use embassy_time::Timer; use esp_hal::Async; use esp_hal::dma::DmaTxStreamBuf; use esp_hal::i2s::master::I2sTx; use vibebuddy_firmware_core::adpcm::LineStream; use vibebuddy_firmware_core::audio::{Chime, Codec, Prompt, Sound}; +use vibebuddy_firmware_core::spoken_reply::ReplyPlayer; use crate::storage; @@ -35,6 +37,11 @@ pub static QUEUE: Channel = Channel::ne pub static PLAYING: AtomicBool = AtomicBool::new(false); /// The main loop asks for an immediate stop: drop what is playing; the requester clears the queue. pub static STOP: AtomicBool = AtomicBool::new(false); +/// Spoken replies (core's spoken_reply.rs): the main loop hands in their MP3, and this task decodes and plays +/// them whenever no line is queued or playing, so an announcement always goes first. Both sides run on the +/// same executor and neither holds the lock across an await, so `try_lock` always gets it. The player holds +/// only empty containers here; its buffers and decoder live on the heap, and only while there is a reply. +pub static REPLIES: Mutex = Mutex::new(ReplyPlayer::new()); static INPUT_REQUIRED: &[u8] = include_bytes!("../../../firmware/main/assets/input_required.pcm"); static DONE: &[u8] = include_bytes!("../../../firmware/main/assets/done.pcm"); @@ -206,6 +213,9 @@ pub async fn audio_task(tx: I2sTx<'static, Async>, buffer: DmaTxStreamBuf) { if cursor.remaining() == 0 { current = None; } + } else if let Ok(mut replies) = REPLIES.try_lock() { + // A reply's frames are decoded here, on this task's poll, about 25 ms of audio at a time. + filled = replies.fill(&mut chunk); } chunk[filled..].fill(0); transfer.push(&chunk); diff --git a/firmware-rs/device/src/main.rs b/firmware-rs/device/src/main.rs index d71ae86d..812e25b3 100644 --- a/firmware-rs/device/src/main.rs +++ b/firmware-rs/device/src/main.rs @@ -52,6 +52,7 @@ use vibebuddy_firmware_core::display::Screen; use vibebuddy_firmware_core::firmware::{AudioStatus, Board, Firmware, FrameAction, VolumeError}; use vibebuddy_firmware_core::noise::{self, SealedLines, Sealer}; use vibebuddy_firmware_core::relay_box::{self, RelayBox}; +use vibebuddy_firmware_core::spoken_reply::ReplyAudio; use vibebuddy_firmware_core::storage::Flash; use crate::audio::{PLAYING, PlayCommand, QUEUE, STOP, STREAM_BYTES, STREAM_CHUNK}; @@ -366,6 +367,16 @@ impl Board for DeviceBoard { fn voice_stop(&mut self) { mic::stop_stream(); } + + fn reply_audio(&mut self, audio: ReplyAudio) { + if let Ok(mut replies) = audio::REPLIES.try_lock() { + replies.apply(audio); + } + } + + fn reply_speaking(&self) -> bool { + audio::REPLIES.try_lock().is_ok_and(|replies| replies.speaking()) + } } #[allow(clippy::large_stack_frames, reason = "main has to hold a lot of peripherals and buffers anyway")] diff --git a/firmware-rs/vendor/nanomp3-core/Cargo.toml b/firmware-rs/vendor/nanomp3-core/Cargo.toml new file mode 100644 index 00000000..eb011b8d --- /dev/null +++ b/firmware-rs/vendor/nanomp3-core/Cargo.toml @@ -0,0 +1,24 @@ +# nanomp3-core 0.2.0 from crates.io (https://github.com/robbie01/nanomp3), MIT OR Apache-2.0, with one change so +# the Xtensa LLVM backend in the esp toolchain can compile it at opt-level "s": `l3_reorder` is `#[inline(never)]` +# (src/minimp3.rs, marked "VibeBuddy"). Without it the firmware build stops at "Cannot select: i32 = +# Constant<4608>" in mp3dec_decode_frame. Its tests and test MP3 are left out. Both workspaces use this copy +# through [patch.crates-io]; drop it once the backend is fixed. +[package] +edition = "2021" +rust-version = "1.89" +name = "nanomp3-core" +version = "0.2.0" +authors = ["Robert B. Langer "] +description = "The no_std, allocation-free MPEG audio decoder behind nanomp3 (a bit-exact, safe Rust port of minimp3)" +readme = "README.md" +license = "MIT OR Apache-2.0" +repository = "https://github.com/robbie01/nanomp3" +publish = false + +[features] +default = ["wide", "layer12"] +layer12 = [] +wide = ["dep:wide"] + +[dependencies] +wide = { version = "1", default-features = false, optional = true } diff --git a/firmware-rs/vendor/nanomp3-core/LICENSE-APACHE b/firmware-rs/vendor/nanomp3-core/LICENSE-APACHE new file mode 100644 index 00000000..fef8c29f --- /dev/null +++ b/firmware-rs/vendor/nanomp3-core/LICENSE-APACHE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/firmware-rs/vendor/nanomp3-core/LICENSE-MIT b/firmware-rs/vendor/nanomp3-core/LICENSE-MIT new file mode 100644 index 00000000..e0e6608f --- /dev/null +++ b/firmware-rs/vendor/nanomp3-core/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) 2025 Robert B. Langer + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/firmware-rs/vendor/nanomp3-core/README.md b/firmware-rs/vendor/nanomp3-core/README.md new file mode 100644 index 00000000..417af2f2 --- /dev/null +++ b/firmware-rs/vendor/nanomp3-core/README.md @@ -0,0 +1,5 @@ +# nanomp3-core + +The frame decoder behind [nanomp3](https://crates.io/crates/nanomp3): a `no_std`, allocation-free, `#![forbid(unsafe_code)]` Rust port of [minimp3](https://github.com/lieff/minimp3) whose output is bit-identical to upstream's. + +Most users want `nanomp3`, which re-exports everything here and adds tag skipping, VBR tag parsing (duration, gapless playback), frame iteration, and a sample-accurate seekable reader. Use this crate directly if you only need the frame decoder. diff --git a/firmware-rs/vendor/nanomp3-core/src/lib.rs b/firmware-rs/vendor/nanomp3-core/src/lib.rs new file mode 100644 index 00000000..33b22eb1 --- /dev/null +++ b/firmware-rs/vendor/nanomp3-core/src/lib.rs @@ -0,0 +1,227 @@ +//! The MPEG audio frame decoder behind [nanomp3](https://docs.rs/nanomp3): a +//! safe Rust port of [minimp3](https://github.com/lieff/minimp3) whose output +//! is bit-identical to upstream's. +//! +//! It's `no_std`, allocates nothing and does no buffering: [`Decoder::decode`] +//! finds and decodes one frame at a time from the bytes you give it. Because a +//! frame only counts once the frames after it are seen to be consistent, keep +//! several frames of input available (16 KiB is plenty) and refill as it +//! drains. +//! +//! ``` +//! # let data: &[u8] = &[]; +//! use nanomp3_core::{DecodeError, Decoder, MAX_SAMPLES_PER_FRAME}; +//! +//! let mut decoder = Decoder::new(); +//! let mut pcm = [0f32; MAX_SAMPLES_PER_FRAME]; // or i16, u8, i32, ... +//! let mut mp3 = data; +//! while !mp3.is_empty() { +//! let (consumed, result) = decoder.decode(mp3, &mut pcm); +//! mp3 = &mp3[consumed..]; +//! match result { +//! Ok(info) => { +//! let samples = &pcm[..info.samples_produced * info.channels.num() as usize]; +//! // ... interleaved samples at info.sample_rate +//! } +//! // Normal while starting up or after a seek; later frames decode. +//! Err(DecodeError::ReservoirUnavailable(_)) => {} +//! Err(e) => eprintln!("skipped: {e}"), +//! } +//! } +//! ``` +//! +//! Most users want the `nanomp3` crate, which re-exports this one and adds a +//! seekable reader with its own buffering, gapless playback, and tag handling. +//! +//! # Features +//! +//! - `wide` (default): explicit SIMD via the `wide` crate. Without it, the +//! same kernels run on plain arrays and rely on LLVM's auto-vectorizer. +//! - `layer12` (default): decode MPEG Layer I and II frames too. Without it +//! they are reported as [`DecodeError::UnsupportedLayer`], like minimp3 +//! built with `MINIMP3_ONLY_MP3`. + +#![no_std] +#![forbid(unsafe_code)] + +mod minimp3; + +pub use minimp3::Sample; + +#[doc(hidden)] +pub mod __private { + //! Internals shared with the `nanomp3` crate, which pins an exact version + //! of this one. Not public API: no semver guarantees. + pub use crate::minimp3::{l3_side_info, mp3d_find_frame as find_frame, FrameInfo, Header}; + use crate::{minimp3, Decoder, Sample}; + + /// `mp3dec_decode_frame` with C's `info` semantics: when no frame is + /// found, only `frame_bytes` is written. + pub fn decode_frame( + dec: &mut Decoder, + mp3: &[u8], + pcm: Option<&mut [S]>, + info: &mut FrameInfo, + ) -> usize { + minimp3::mp3dec_decode_frame(&mut dec.dec, mp3, pcm, info, dec.minimp3_compat) + } + + /// `mp3dec_init`. + pub fn init(dec: &mut Decoder) { + minimp3::mp3dec_init(&mut dec.dec); + } +} + +#[cfg(test)] +mod tests; + +/// The minimum length of the PCM output buffer, in samples. +pub const MAX_SAMPLES_PER_FRAME: usize = 1152*2; + +/// The core MP3 decoder, with no internal buffering. +/// +/// The decoder is about 22 KiB: it holds the filterbank state between frames +/// and the working memory for decoding one. Nothing is allocated. +#[derive(Clone)] +pub struct Decoder { + dec: minimp3::Mp3Dec, + minimp3_compat: bool, +} + + +/// The channel formats that may be encoded in an MP3 frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Channels { + Mono = 1, + Stereo +} + +impl Channels { + /// Returns the corresponding number of channels for `self`. + pub fn num(self) -> u8 { + self as u8 + } +} + +/// Information about a frame found by [`Decoder::decode`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct FrameInfo { + /// The number of PCM samples produced per channel (0 when the frame + /// produced no audio). + pub samples_produced: usize, + /// The number of channels in this frame. + pub channels: Channels, + /// Sample rate of this frame, in Hz. + pub sample_rate: u32, + /// The frame's bitrate, in kilobits per second (0 for free-format streams). + pub bitrate: u32, + /// The MPEG layer: 1, 2 or 3 (MP1, MP2 or MP3). + pub layer: u8, +} + +/// Why [`Decoder::decode`] produced no audio. +/// +/// Every variant except `NoFrame` carries the frame's header information. +/// `NoFrame` at the end of the input and `ReservoirUnavailable` right after +/// starting or seeking are part of normal decoding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum DecodeError { + /// The input holds no complete frame, so the consumed bytes (all of the + /// input, unless it was empty) were skipped as tags or junk. At the end of + /// a stream this means there's nothing left; mid-stream, the input was + /// cut short or corrupt. + NoFrame, + /// The frame uses bit-reservoir data from earlier frames that weren't + /// decoded, as happens for the first frame or two after starting in the + /// middle of a stream or seeking. Later frames decode normally. + ReservoirUnavailable(FrameInfo), + /// The frame is corrupt and was skipped. + Corrupt(FrameInfo), + /// The frame is Layer I or II and the `layer12` feature is disabled. + UnsupportedLayer(FrameInfo), +} + +impl DecodeError { + /// The header information of the skipped frame, if there was one. + pub fn frame_info(&self) -> Option<&FrameInfo> { + match self { + DecodeError::NoFrame => None, + DecodeError::ReservoirUnavailable(info) + | DecodeError::Corrupt(info) + | DecodeError::UnsupportedLayer(info) => Some(info), + } + } +} + +impl core::fmt::Display for DecodeError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(match self { + DecodeError::NoFrame => "no complete MPEG audio frame in the input", + DecodeError::ReservoirUnavailable(_) => "frame depends on bit reservoir data that wasn't decoded", + DecodeError::Corrupt(_) => "corrupt frame", + DecodeError::UnsupportedLayer(_) => "Layer I/II frame (the layer12 feature is disabled)", + }) + } +} + +impl core::error::Error for DecodeError {} + +impl Decoder { + /// Instantiates a `Decoder`. + pub const fn new() -> Self { + Self { dec: minimp3::Mp3Dec::new(), minimp3_compat: false } + } + + /// Instantiates a `Decoder` whose output is bit-identical to minimp3's, + /// including quirks nanomp3 otherwise corrects. Currently that only + /// affects `i16` output, where minimp3 rounds samples in (−1.5, −0.5] to + /// 0 instead of −1 (see [`Sample`]). + pub const fn new_minimp3_compat() -> Self { + Self { dec: minimp3::Mp3Dec::new(), minimp3_compat: true } + } + + /// Decodes the next frame of `mp3` into `pcm`. Returns how many bytes of + /// `mp3` were consumed (always advance past them, whatever the result) and + /// either the decoded frame's information or why no audio came out. + /// + /// `mp3` should hold several frames' worth of data (16 KiB is plenty): + /// frames are only recognized once the frames after them are seen, and a + /// frame cut off at the end of the input is skipped as junk. + /// + /// Samples are interleaved. `pcm` can hold `f32` samples (nominally in + /// `[-1.0, 1.0]`, not clipped) or signed or unsigned 8-, 16- or 32-bit + /// integers; see [`Sample`] for the conversions. + /// + /// # Panics + /// + /// Panics if `pcm` is less than [`MAX_SAMPLES_PER_FRAME`] long. + pub fn decode(&mut self, mp3: &[u8], pcm: &mut [S]) -> (usize, Result) { + assert!(pcm.len() >= MAX_SAMPLES_PER_FRAME, "pcm buffer too small"); + + let mut info = minimp3::FrameInfo::default(); + let samples = minimp3::mp3dec_decode_frame(&mut self.dec, mp3, Some(pcm), &mut info, self.minimp3_compat); + let frame = FrameInfo { + samples_produced: samples, + channels: if info.channels == 1 { Channels::Mono } else { Channels::Stereo }, + sample_rate: info.hz, + bitrate: info.bitrate_kbps, + layer: info.layer, + }; + let result = match info.status { + minimp3::Status::Decoded => Ok(frame), + minimp3::Status::NoFrame | minimp3::Status::HeaderOnly => Err(DecodeError::NoFrame), + minimp3::Status::Corrupt => Err(DecodeError::Corrupt(frame)), + minimp3::Status::ReservoirUnavailable => Err(DecodeError::ReservoirUnavailable(frame)), + minimp3::Status::UnsupportedLayer => Err(DecodeError::UnsupportedLayer(frame)), + }; + (info.frame_bytes, result) + } +} + +impl Default for Decoder { + fn default() -> Self { + Self::new() + } +} \ No newline at end of file diff --git a/firmware-rs/vendor/nanomp3-core/src/minimp3.rs b/firmware-rs/vendor/nanomp3-core/src/minimp3.rs new file mode 100644 index 00000000..b0a9fa5b --- /dev/null +++ b/firmware-rs/vendor/nanomp3-core/src/minimp3.rs @@ -0,0 +1,1525 @@ +//! Safe Rust port of the Layer III decoder from [minimp3](https://github.com/lieff/minimp3). +//! +//! The code deliberately mirrors the structure of `minimp3.h` (function names +//! are snake_case versions of the C ones) so the two can be read side by side. +//! Every floating-point expression is evaluated in the same order as in C, which +//! keeps the output bit-identical to upstream built with `MINIMP3_ONLY_MP3`, +//! `MINIMP3_FLOAT_OUTPUT` and `MINIMP3_NO_SIMD`; `parity/` checks this. + +// Float literals are spelled exactly as in minimp3.h. Rounding them or swapping +// in `core::f32::consts` could change results and break bit-exactness. +#![allow(clippy::excessive_precision, clippy::approx_constant)] +// Index loops mirror the C and keep parallel arrays visibly in step. +#![allow(clippy::needless_range_loop)] + +mod lanes; +#[cfg(feature = "layer12")] +mod layer12; +mod tables; +use lanes::{Lanes, F4}; +use tables::*; + +const HDR_SIZE: usize = 4; +const MAX_FREE_FORMAT_FRAME_SIZE: usize = 2304; +const MAX_FRAME_SYNC_MATCHES: usize = 10; +const MAX_BITRESERVOIR_BYTES: usize = 511; +const MAX_L3_FRAME_PAYLOAD_BYTES: usize = MAX_FREE_FORMAT_FRAME_SIZE; +const SHORT_BLOCK_TYPE: u8 = 2; +const STOP_BLOCK_TYPE: u8 = 3; +/// `MAX_SCFI` in C: `(255 + BITS_DEQUANTIZER_OUT*4 - 210 + 3) & ~3` with `BITS_DEQUANTIZER_OUT = -1`. +const MAX_SCFI: i32 = (255 - 4 - 210 + 3) & !3; + +/// A PCM output sample format: `f32`, `i8`, `u8`, `i16`, `u16`, `i32` or `u32`. +/// +/// The decoder synthesizes floating-point samples at 16-bit scale (full scale +/// is ±32768) and converts them as follows: +/// +/// | type | conversion | +/// |---|---| +/// | `f32` | divided by 32768, so nominally in `[-1.0, 1.0]`; not clipped | +/// | `i16` | rounded half away from zero and clipped | +/// | `i8` | divided by 256, rounded half away from zero and clipped | +/// | `i32` | multiplied by 65536, rounded half away from zero and clipped | +/// | `u8`, `u16`, `u32` | as the signed type, offset by half the range (sign bit flipped) | +/// +/// Integer formats carry at most about 24 significant bits, since that's the +/// precision of the synthesis. +/// +/// With [`Decoder::new_minimp3_compat`](crate::Decoder::new_minimp3_compat), +/// `i16` uses minimp3's exact rounding instead, which maps samples in +/// (−1.5, −0.5] to 0 rather than −1. `f32` output is identical to minimp3's +/// either way; the other formats have no minimp3 counterpart. +pub trait Sample: Copy + sealed::Sealed { + #[doc(hidden)] + fn from_synth(x: f32, minimp3_compat: bool) -> Self; +} + +mod sealed { + pub trait Sealed {} + impl Sealed for f32 {} + impl Sealed for i8 {} + impl Sealed for u8 {} + impl Sealed for i16 {} + impl Sealed for u16 {} + impl Sealed for i32 {} + impl Sealed for u32 {} +} + +/// Rounds half away from zero, saturating to the `i32` range (NaN becomes 0). +/// `f32::round` isn't available in `core`, and the usual `(x + 0.5) as i32` +/// misrounds 0.49999997 (the sum rounds up to 1.0). +#[inline(always)] +fn round_i32(x: f32) -> i32 { + let t = x as i32; // truncates toward zero and saturates + let frac = x - t as f32; // exact: |x| < 2^24 or x is integral + if frac >= 0.5 { + t.saturating_add(1) + } else if frac <= -0.5 { + t.saturating_sub(1) + } else { + t + } +} + +impl Sample for f32 { + #[inline(always)] + fn from_synth(x: f32, _: bool) -> f32 { + x * (1.0 / 32768.0) + } +} + +impl Sample for i16 { + #[inline(always)] + fn from_synth(x: f32, minimp3_compat: bool) -> i16 { + if minimp3_compat { + // minimp3's mp3d_scale_pcm, quirk included. + if x >= 32766.5 { + return 32767; + } + if x <= -32767.5 { + return -32768; + } + let s = (x + 0.5) as i16; + s - (s < 0) as i16 + } else { + round_i32(x).clamp(i16::MIN.into(), i16::MAX.into()) as i16 + } + } +} + +impl Sample for i8 { + #[inline(always)] + fn from_synth(x: f32, _: bool) -> i8 { + round_i32(x * (1.0 / 256.0)).clamp(i8::MIN.into(), i8::MAX.into()) as i8 + } +} + +impl Sample for i32 { + #[inline(always)] + fn from_synth(x: f32, _: bool) -> i32 { + round_i32(x * 65536.0) + } +} + +impl Sample for u8 { + #[inline(always)] + fn from_synth(x: f32, c: bool) -> u8 { + i8::from_synth(x, c) as u8 ^ 0x80 + } +} + +impl Sample for u16 { + #[inline(always)] + fn from_synth(x: f32, _: bool) -> u16 { + i16::from_synth(x, false) as u16 ^ 0x8000 + } +} + +impl Sample for u32 { + #[inline(always)] + fn from_synth(x: f32, c: bool) -> u32 { + i32::from_synth(x, c) as u32 ^ 0x8000_0000 + } +} + +/// Information about a decoded frame (`mp3dec_frame_info_t`). +#[derive(Copy, Clone, Default, Debug)] +pub struct FrameInfo { + pub frame_bytes: usize, + pub frame_offset: usize, + pub channels: u8, + pub hz: u32, + pub layer: u8, + pub bitrate_kbps: u32, + /// Why the last call produced the samples it did. Not part of C's struct. + pub status: Status, +} + +/// The outcome of `mp3dec_decode_frame`, which C only conveys as "0 samples". +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] +pub enum Status { + /// Samples were decoded. + Decoded, + /// No frame was found; `frame_bytes` bytes of junk were skipped. + #[default] + NoFrame, + /// Only the header was parsed (`pcm == None`). + HeaderOnly, + /// The frame's side info or bitstream is invalid; the decoder resyncs. + Corrupt, + /// The frame needs bit-reservoir bytes from frames that weren't decoded. + ReservoirUnavailable, + /// A Layer I/II frame, and the `layer12` feature is off. + UnsupportedLayer, +} + +/// Persistent decoder state (`mp3dec_t`). +#[derive(Clone)] +struct State { + mdct_overlap: [[f32; 9 * 32]; 2], + qmf_state: [f32; 15 * 2 * 32], + reserv: i32, + free_format_bytes: usize, + header: [u8; 4], + reserv_buf: [u8; MAX_BITRESERVOIR_BYTES], +} + +impl State { + const fn new() -> Self { + Self { + mdct_overlap: [[0.; 9 * 32]; 2], + qmf_state: [0.; 15 * 2 * 32], + reserv: 0, + free_format_bytes: 0, + header: [0; 4], + reserv_buf: [0; MAX_BITRESERVOIR_BYTES], + } + } +} + +/// Per-frame working memory (`mp3dec_scratch_t`). Unlike C, it lives in the +/// decoder rather than on the stack so it doesn't have to be re-initialized for +/// every frame; only the parts C might read before writing are cleared. +#[derive(Clone)] +struct Scratch { + grbuf: [[f32; 576]; 2], + scf: [f32; 40], + syn: [[f32; 4]; SYN_ROWS], + ist_pos: [[u8; 39]; 2], +} + +/// The decoder: persistent state plus scratch memory. +#[derive(Clone)] +pub struct Mp3Dec { + st: State, + scratch: Scratch, + maindata: [u8; MAX_BITRESERVOIR_BYTES + MAX_L3_FRAME_PAYLOAD_BYTES], +} + +impl Mp3Dec { + pub const fn new() -> Self { + Self { + st: State::new(), + scratch: Scratch { + grbuf: [[0.; 576]; 2], + scf: [0.; 40], + syn: [[0.; 4]; SYN_ROWS], + ist_pos: [[0; 39]; 2], + }, + maindata: [0; MAX_BITRESERVOIR_BYTES + MAX_L3_FRAME_PAYLOAD_BYTES], + } + } +} + +#[derive(Copy, Clone, Default)] +struct GrInfo { + sfbtab: &'static [u8], + part_23_length: u16, + big_values: u16, + scalefac_compress: u16, + global_gain: u8, + block_type: u8, + mixed_block_flag: u8, + n_long_sfb: u8, + n_short_sfb: u8, + table_select: [u8; 3], + region_count: [u8; 3], + subblock_gain: [u8; 3], + preflag: u8, + scalefac_scale: u8, + count1_table: u8, + scfsi: u8, +} + +/// Bit reader (`bs_t`). Positions are signed like in C: the limit of a +/// malformed tiny frame can be negative, and `pos` keeps advancing past the +/// limit so callers can detect overruns. +struct Bs<'a> { + buf: &'a [u8], + pos: i32, + limit: i32, +} + +impl<'a> Bs<'a> { + fn new(buf: &'a [u8], bytes: i32) -> Self { + Self { buf, pos: 0, limit: bytes * 8 } + } + + fn get_bits(&mut self, n: u32) -> u32 { + let s = (self.pos & 7) as u32; + let mut p = (self.pos >> 3) as usize; + self.pos += n as i32; + if self.pos > self.limit { + return 0; + } + // In bounds: every byte read lies below `limit`, and `limit` never + // exceeds the buffer. + let mut shl = (n + s) as i32; + let mut next = u32::from(self.buf[p] & (255 >> s)); + p += 1; + let mut cache = 0; + loop { + shl -= 8; + if shl <= 0 { + break; + } + cache |= next << shl; + next = u32::from(self.buf[p]); + p += 1; + } + cache | (next >> -shl) + } +} + +/// A 4-byte frame header. +#[derive(Copy, Clone)] +pub struct Header(pub [u8; 4]); + +impl Header { + pub fn read(buf: &[u8]) -> Self { + Self([buf[0], buf[1], buf[2], buf[3]]) + } + pub fn is_mono(self) -> bool { + self.0[3] & 0xC0 == 0xC0 + } + fn is_ms_stereo(self) -> bool { + self.0[3] & 0xE0 == 0x60 + } + fn is_free_format(self) -> bool { + self.0[2] & 0xF0 == 0 + } + pub fn is_crc(self) -> bool { + self.0[1] & 1 == 0 + } + fn test_padding(self) -> bool { + self.0[2] & 0x2 != 0 + } + fn test_mpeg1(self) -> bool { + self.0[1] & 0x8 != 0 + } + fn test_not_mpeg25(self) -> bool { + self.0[1] & 0x10 != 0 + } + fn test_i_stereo(self) -> bool { + self.0[3] & 0x10 != 0 + } + fn test_ms_stereo(self) -> bool { + self.0[3] & 0x20 != 0 + } + pub fn get_layer(self) -> u8 { + (self.0[1] >> 1) & 3 + } + fn get_bitrate(self) -> u8 { + self.0[2] >> 4 + } + fn get_sample_rate(self) -> u8 { + (self.0[2] >> 2) & 3 + } + fn get_my_sample_rate(self) -> usize { + let h1 = self.0[1]; + (self.get_sample_rate() + (((h1 >> 3) & 1) + ((h1 >> 4) & 1)) * 3) as usize + } + fn is_frame_576(self) -> bool { + self.0[1] & 14 == 2 + } + fn is_layer_1(self) -> bool { + self.0[1] & 6 == 6 + } + + pub fn valid(self) -> bool { + let h = self.0; + h[0] == 0xff + && (h[1] & 0xF0 == 0xf0 || h[1] & 0xFE == 0xe2) + && self.get_layer() != 0 + && self.get_bitrate() != 15 + && self.get_sample_rate() != 3 + } + + /// `hdr_compare(self, other)`: is `other` a valid header for the same stream? + pub fn compare(self, other: Header) -> bool { + let (h1, h2) = (self.0, other.0); + other.valid() + && (h1[1] ^ h2[1]) & 0xFE == 0 + && (h1[2] ^ h2[2]) & 0x0C == 0 + && self.is_free_format() == other.is_free_format() + } + + pub fn bitrate_kbps(self) -> u32 { + 2 * u32::from( + HDR_BITRATE_KBPS_HALFRATE[self.test_mpeg1() as usize][self.get_layer() as usize - 1] + [self.get_bitrate() as usize], + ) + } + + pub fn sample_rate_hz(self) -> u32 { + HDR_SAMPLE_RATE_HZ_G_HZ[self.get_sample_rate() as usize] + >> !self.test_mpeg1() as u32 + >> !self.test_not_mpeg25() as u32 + } + + pub fn frame_samples(self) -> u32 { + if self.is_layer_1() { + 384 + } else { + 1152 >> self.is_frame_576() as u32 + } + } + + pub fn frame_bytes(self, free_format_size: usize) -> usize { + let mut frame_bytes = + (self.frame_samples() * self.bitrate_kbps() * 125 / self.sample_rate_hz()) as usize; + if self.is_layer_1() { + frame_bytes &= !3; // slot align + } + if frame_bytes != 0 { + frame_bytes + } else { + free_format_size + } + } + + pub fn padding(self) -> usize { + if self.test_padding() { + if self.is_layer_1() { + 4 + } else { + 1 + } + } else { + 0 + } + } +} + +fn l3_read_side_info(bs: &mut Bs, gr: &mut [GrInfo; 4], hdr: Header) -> i32 { + let mut scfsi: u32 = 0; + let mut part_23_sum: i32 = 0; + let mut sr_idx = hdr.get_my_sample_rate(); + sr_idx -= (sr_idx != 0) as usize; + let mut gr_count = if hdr.is_mono() { 1 } else { 2 }; + let main_data_begin; + if hdr.test_mpeg1() { + gr_count *= 2; + main_data_begin = bs.get_bits(9) as i32; + scfsi = bs.get_bits(7 + gr_count); + } else { + main_data_begin = (bs.get_bits(8 + gr_count) >> gr_count) as i32; + } + + for gr in &mut gr[..gr_count as usize] { + if hdr.is_mono() { + scfsi <<= 4; + } + gr.part_23_length = bs.get_bits(12) as u16; + part_23_sum += i32::from(gr.part_23_length); + gr.big_values = bs.get_bits(9) as u16; + if gr.big_values > 288 { + return -1; + } + gr.global_gain = bs.get_bits(8) as u8; + gr.scalefac_compress = bs.get_bits(if hdr.test_mpeg1() { 4 } else { 9 }) as u16; + gr.sfbtab = &L3_READ_SIDE_INFO_G_SCF_LONG[sr_idx]; + gr.n_long_sfb = 22; + gr.n_short_sfb = 0; + let tables; + if bs.get_bits(1) != 0 { + gr.block_type = bs.get_bits(2) as u8; + if gr.block_type == 0 { + return -1; + } + gr.mixed_block_flag = bs.get_bits(1) as u8; + gr.region_count[0] = 7; + gr.region_count[1] = 255; + if gr.block_type == SHORT_BLOCK_TYPE { + scfsi &= 0x0F0F; + if gr.mixed_block_flag == 0 { + gr.region_count[0] = 8; + gr.sfbtab = &L3_READ_SIDE_INFO_G_SCF_SHORT[sr_idx]; + gr.n_long_sfb = 0; + gr.n_short_sfb = 39; + } else { + gr.sfbtab = &L3_READ_SIDE_INFO_G_SCF_MIXED[sr_idx]; + gr.n_long_sfb = if hdr.test_mpeg1() { 8 } else { 6 }; + gr.n_short_sfb = 30; + } + } + tables = bs.get_bits(10) << 5; + gr.subblock_gain[0] = bs.get_bits(3) as u8; + gr.subblock_gain[1] = bs.get_bits(3) as u8; + gr.subblock_gain[2] = bs.get_bits(3) as u8; + } else { + gr.block_type = 0; + gr.mixed_block_flag = 0; + tables = bs.get_bits(15); + gr.region_count[0] = bs.get_bits(4) as u8; + gr.region_count[1] = bs.get_bits(3) as u8; + gr.region_count[2] = 255; + } + gr.table_select[0] = (tables >> 10) as u8; + gr.table_select[1] = ((tables >> 5) & 31) as u8; + gr.table_select[2] = (tables & 31) as u8; + gr.preflag = if hdr.test_mpeg1() { + bs.get_bits(1) as u8 + } else { + (gr.scalefac_compress >= 500) as u8 + }; + gr.scalefac_scale = bs.get_bits(1) as u8; + gr.count1_table = bs.get_bits(1) as u8; + gr.scfsi = ((scfsi >> 12) & 15) as u8; + scfsi <<= 4; + } + + if part_23_sum + bs.pos > bs.limit + main_data_begin * 8 { + return -1; + } + main_data_begin +} + +fn l3_read_scalefactors( + scf: &mut [u8; 40], + ist_pos: &mut [u8; 39], + scf_size: &[u8; 4], + scf_count: &[u8], + bitbuf: &mut Bs, + mut scfsi: i32, +) { + let mut pos = 0; + for i in 0..4 { + let cnt = scf_count[i] as usize; + if cnt == 0 { + break; + } + let scf = &mut scf[pos..pos + cnt]; + let ist_pos = &mut ist_pos[pos..pos + cnt]; + if scfsi & 8 != 0 { + scf.copy_from_slice(ist_pos); + } else { + let bits = u32::from(scf_size[i]); + if bits == 0 { + scf.fill(0); + ist_pos.fill(0); + } else { + let max_scf = if scfsi < 0 { (1 << bits) - 1 } else { -1 }; + for (scf, ist_pos) in scf.iter_mut().zip(ist_pos) { + let s = bitbuf.get_bits(bits) as i32; + *ist_pos = if s == max_scf { -1i32 as u8 } else { s as u8 }; + *scf = s as u8; + } + } + } + pos += cnt; + scfsi *= 2; + } + scf[pos..pos + 3].fill(0); +} + +fn l3_ldexp_q2(mut y: f32, mut exp_q2: i32) -> f32 { + loop { + let e = (30 * 4).min(exp_q2); + y *= L3_LDEXP_Q2_G_EXPFRAC[(e & 3) as usize] * ((1 << 30 >> (e >> 2)) as f32); + exp_q2 -= e; + if exp_q2 <= 0 { + return y; + } + } +} + +fn l3_decode_scalefactors( + hdr: Header, + ist_pos: &mut [u8; 39], + bs: &mut Bs, + gr: &GrInfo, + scf: &mut [f32; 40], + ch: usize, +) { + let mut scf_partition: &[u8] = &L3_DECODE_SCALEFACTORS_G_SCM_PARTITIONS + [(gr.n_short_sfb != 0) as usize + (gr.n_long_sfb == 0) as usize]; + let mut scf_size = [0u8; 4]; + let mut iscf = [0u8; 40]; + let scf_shift = u32::from(gr.scalefac_scale) + 1; + let mut scfsi = i32::from(gr.scfsi); + + if hdr.test_mpeg1() { + let part = L3_DECODE_SCALEFACTORS_G_SCFC_DECODE[gr.scalefac_compress as usize]; + scf_size[0] = part >> 2; + scf_size[1] = scf_size[0]; + scf_size[2] = part & 3; + scf_size[3] = scf_size[2]; + } else { + let ist = (hdr.test_i_stereo() && ch != 0) as usize; + let mut sfc = i32::from(gr.scalefac_compress) >> ist; + let mut k = ist * 3 * 4; + while sfc >= 0 { + let mut modprod = 1; + for i in (0..4).rev() { + let m = i32::from(L3_DECODE_SCALEFACTORS_G_MOD[k + i]); + scf_size[i] = (sfc / modprod % m) as u8; + modprod *= m; + } + sfc -= modprod; + k += 4; + } + scf_partition = &scf_partition[k..]; + scfsi = -16; + } + l3_read_scalefactors(&mut iscf, ist_pos, &scf_size, scf_partition, bs, scfsi); + + let n_long = gr.n_long_sfb as usize; + let n_short = gr.n_short_sfb as usize; + if n_short != 0 { + let sh = 3 - scf_shift; + for i in (0..n_short).step_by(3) { + for j in 0..3 { + let v = &mut iscf[n_long + i + j]; + *v = v.wrapping_add(gr.subblock_gain[j] << sh); + } + } + } else if gr.preflag != 0 { + for (v, &p) in iscf[11..21].iter_mut().zip(&L3_DECODE_SCALEFACTORS_G_PREAMP) { + *v = v.wrapping_add(p); + } + } + + let gain_exp = i32::from(gr.global_gain) - 4 - 210 - if hdr.is_ms_stereo() { 2 } else { 0 }; + let gain = l3_ldexp_q2((1 << (MAX_SCFI / 4)) as f32, MAX_SCFI - gain_exp); + for (scf, &iscf) in scf.iter_mut().zip(&iscf).take(n_long + n_short) { + *scf = l3_ldexp_q2(gain, i32::from(iscf) << scf_shift); + } +} + +fn l3_pow_43(mut x: i32) -> f32 { + let mut mult = 256; + if x < 129 { + return G_POW43[(16 + x) as usize]; + } + if x < 1024 { + mult = 16; + x <<= 3; + } + let sign = (2 * x) & 64; + let frac = ((x & 63) - sign) as f32 / ((x & !63) + sign) as f32; + G_POW43[(16 + ((x + sign) >> 6)) as usize] + * (1.0 + frac * ((4.0 / 3.0) + frac * (2.0 / 9.0))) + * mult as f32 +} + +/// The Huffman decoder's 32-bit look-ahead cache. Bytes past the end of the +/// buffer read as zero: a corrupt `big_values` can make the decoder run past +/// the granule's data, which C tolerates by reading neighbouring memory. +struct BitCache<'a> { + buf: &'a [u8], + next: usize, + cache: u32, + sh: i32, +} + +impl BitCache<'_> { + #[inline(always)] + fn byte(&self, i: usize) -> u32 { + self.buf.get(i).copied().map_or(0, u32::from) + } + + #[inline(always)] + fn peek(&self, n: i32) -> u32 { + self.cache >> (32 - n) + } + + #[inline(always)] + fn flush(&mut self, n: i32) { + self.cache <<= n; + self.sh += n; + } + + #[inline(always)] + fn check(&mut self) { + while self.sh >= 0 { + self.cache |= self.byte(self.next) << self.sh; + self.next += 1; + self.sh -= 8; + } + } + + /// Current bit position (`BSPOS` in C). + fn pos(&self) -> i32 { + self.next as i32 * 8 - 24 + self.sh + } +} + +/// Decodes Huffman pairs for one scalefactor band of the big-values region. +#[inline(always)] +fn l3_huffman_pairs( + out: &mut [f32], + br: &mut BitCache, + codebook: &[i16], + linbits: i32, + one: f32, +) { + for pair in out.as_chunks_mut::<2>().0 { + let mut w = 5; + let mut leaf = i32::from(codebook[br.peek(w) as usize]); + while leaf < 0 { + br.flush(w); + w = leaf & 7; + leaf = i32::from(codebook[br.peek(w).wrapping_sub((leaf >> 3) as u32) as usize]); + } + br.flush(leaf >> 8); + + for v in pair { + let mut lsb = leaf & 0x0F; + if LINBITS && lsb == 15 { + lsb += br.peek(linbits) as i32; + br.flush(linbits); + br.check(); + *v = one * l3_pow_43(lsb) * if (br.cache as i32) < 0 { -1.0 } else { 1.0 }; + } else { + *v = G_POW43[(16 + lsb) as usize - 16 * (br.cache >> 31) as usize] * one; + } + br.flush((lsb != 0) as i32); + leaf >>= 4; + } + br.check(); + } +} + +fn l3_huffman(dst: &mut [f32; 576], bs: &mut Bs, gr_info: &GrInfo, scf: &[f32; 40], layer3gr_limit: i32) { + let mut one = 0.0f32; + let mut ireg = 0; + let mut big_val_cnt = i32::from(gr_info.big_values); + let sfbtab = gr_info.sfbtab; + let mut sfb = 0; + let mut scf_idx = 0; + let mut d = 0; + + let start = (bs.pos / 8) as usize; + let mut br = BitCache { buf: bs.buf, next: start + 4, cache: 0, sh: (bs.pos & 7) - 8 }; + br.cache = (((br.byte(start) * 256 + br.byte(start + 1)) * 256 + br.byte(start + 2)) * 256 + + br.byte(start + 3)) + << (bs.pos & 7); + + while big_val_cnt > 0 { + let tab_num = gr_info.table_select[ireg] as usize; + let mut sfb_cnt = i32::from(gr_info.region_count[ireg]); + ireg += 1; + let codebook = &L3_HUFFMAN_TABS[L3_HUFFMAN_TABINDEX[tab_num] as usize..]; + let linbits = i32::from(L3_HUFFMAN_G_LINBITS[tab_num]); + loop { + let np = i32::from(sfbtab[sfb]) / 2; + sfb += 1; + let pairs_to_decode = big_val_cnt.min(np) as usize; + one = scf[scf_idx]; + scf_idx += 1; + // C's do/while always decodes at least one pair; np is never 0 here + // because big_values <= 288 and every table covers 288 pairs. + let out = &mut dst[d..d + 2 * pairs_to_decode.max(1)]; + if linbits != 0 { + l3_huffman_pairs::(out, &mut br, codebook, linbits, one); + } else { + l3_huffman_pairs::(out, &mut br, codebook, linbits, one); + } + d += out.len(); + big_val_cnt -= np; + if big_val_cnt <= 0 { + break; + } + sfb_cnt -= 1; + if sfb_cnt < 0 { + break; + } + } + } + + let codebook_count1: &[u8] = + if gr_info.count1_table != 0 { &L3_HUFFMAN_TAB33 } else { &L3_HUFFMAN_TAB32 }; + let mut np = 1 - big_val_cnt; + 'count1: loop { + let mut leaf = i32::from(codebook_count1[br.peek(4) as usize]); + if leaf & 8 == 0 { + leaf = i32::from( + codebook_count1[(leaf >> 3) as usize + (br.cache << 4 >> (32 - (leaf & 3))) as usize], + ); + } + br.flush(leaf & 7); + if br.pos() > layer3gr_limit { + break; + } + for half in 0..2 { + np -= 1; + if np == 0 { + np = i32::from(sfbtab[sfb]) / 2; + sfb += 1; + if np == 0 { + break 'count1; + } + one = scf[scf_idx]; + scf_idx += 1; + } + for s in 2 * half..2 * half + 2 { + if leaf & (128 >> s) != 0 { + dst[d + s] = if (br.cache as i32) < 0 { -one } else { one }; + br.flush(1); + } + } + } + br.check(); + d += 4; + } + bs.pos = layer3gr_limit; +} + +fn l3_midside_stereo(buf: &mut [f32; 1152], start: usize, n: usize) { + let (left, right) = buf.split_at_mut(576); + for (l, r) in left[start..start + n].iter_mut().zip(&mut right[start..start + n]) { + let a = *l; + let b = *r; + *l = a + b; + *r = a - b; + } +} + +fn l3_intensity_stereo_band(buf: &mut [f32; 1152], start: usize, n: usize, kl: f32, kr: f32) { + let (left, right) = buf.split_at_mut(576); + for (l, r) in left[start..start + n].iter_mut().zip(&mut right[start..start + n]) { + *r = *l * kr; + *l *= kl; + } +} + +fn l3_stereo_top_band(right: &[f32], sfb: &[u8], nbands: usize, max_band: &mut [i32; 3]) { + *max_band = [-1; 3]; + let mut off = 0; + for i in 0..nbands { + let n = sfb[i] as usize; + let band = &right[off..off + n]; + if band.as_chunks::<2>().0.iter().any(|p| p[0] != 0.0 || p[1] != 0.0) { + max_band[i % 3] = i as i32; + } + off += n; + } +} + +fn l3_stereo_process( + buf: &mut [f32; 1152], + ist_pos: &[u8; 39], + sfb: &[u8], + hdr: Header, + max_band: &[i32; 3], + mpeg2_sh: u32, +) { + let max_pos = if hdr.test_mpeg1() { 7 } else { 64 }; + let mut off = 0; + for (i, &n) in sfb.iter().take_while(|&&n| n != 0).enumerate() { + let n = n as usize; + let ipos = u32::from(ist_pos[i]); + if i as i32 > max_band[i % 3] && ipos < max_pos { + let s = if hdr.test_ms_stereo() { 1.41421356 } else { 1.0 }; + let (kl, kr); + if hdr.test_mpeg1() { + kl = L3_STEREO_PROCESS_G_PAN[2 * ipos as usize]; + kr = L3_STEREO_PROCESS_G_PAN[2 * ipos as usize + 1]; + } else { + let k = l3_ldexp_q2(1.0, (((ipos + 1) >> 1) << mpeg2_sh) as i32); + (kl, kr) = if ipos & 1 != 0 { (k, 1.0) } else { (1.0, k) }; + } + l3_intensity_stereo_band(buf, off, n, kl * s, kr * s); + } else if hdr.test_ms_stereo() { + l3_midside_stereo(buf, off, n); + } + off += n; + } +} + +fn l3_intensity_stereo(buf: &mut [f32; 1152], ist_pos: &mut [u8; 39], gr: &[GrInfo], hdr: Header) { + let mut max_band = [0i32; 3]; + let n_sfb = gr[0].n_long_sfb as usize + gr[0].n_short_sfb as usize; + let max_blocks = if gr[0].n_short_sfb != 0 { 3 } else { 1 }; + + l3_stereo_top_band(&buf[576..], gr[0].sfbtab, n_sfb, &mut max_band); + if gr[0].n_long_sfb != 0 { + max_band = [max_band[0].max(max_band[1]).max(max_band[2]); 3]; + } + for i in 0..max_blocks { + let default_pos = if hdr.test_mpeg1() { 3 } else { 0 }; + let itop = n_sfb - max_blocks + i; + let prev = itop - max_blocks; + ist_pos[itop] = if max_band[i] >= prev as i32 { default_pos } else { ist_pos[prev] }; + } + l3_stereo_process(buf, ist_pos, gr[0].sfbtab, hdr, &max_band, u32::from(gr[1].scalefac_compress & 1)); +} + +// VibeBuddy: inlined into mp3dec_decode_frame, this loop comes out of Loop Strength Reduction addressed from +// the decoder's base plus 4608, which the Xtensa backend can't select ("Cannot select: i32 = Constant<4608>"). +#[inline(never)] +fn l3_reorder(grbuf: &mut [f32], scratch: &mut [f32], sfb: &[u8]) { + let mut src = 0; + let mut dst = 0; + for &len in sfb.iter().step_by(3).take_while(|&&len| len != 0) { + let len = len as usize; + for i in 0..len { + scratch[dst] = grbuf[src + i]; + scratch[dst + 1] = grbuf[src + i + len]; + scratch[dst + 2] = grbuf[src + i + 2 * len]; + dst += 3; + } + src += 3 * len; + } + grbuf[..dst].copy_from_slice(&scratch[..dst]); +} + +fn l3_antialias(grbuf: &mut [f32; 576], nbands: usize) { + let aa = |k: usize, i: usize| F4::load(&L3_ANTIALIAS_G_AA[k][i..]); + for b in 0..nbands { + let (lo, hi) = grbuf[18 * b..18 * b + 36].split_at_mut(18); + for i in [0, 4] { + // hi[i..i+4] against lo[17-i..=14-i] (reversed). + let u = F4::load(&hi[i..]); + let d = F4::load(&lo[14 - i..]).rev(); + (u * aa(0, i) - d * aa(1, i)).store(&mut hi[i..]); + (u * aa(1, i) + d * aa(0, i)).rev().store(&mut lo[14 - i..]); + } + } +} + +#[inline(always)] +fn l3_dct3_9(y: &mut [T; 9]) { + let c = T::splat; + let mut s0 = y[0]; + let mut s2 = y[2]; + let mut s4 = y[4]; + let mut s6 = y[6]; + let mut s8 = y[8]; + let mut t0 = s0 + s6 * c(0.5); + s0 -= s6; + let mut t4 = (s4 + s2) * c(0.93969262); + let mut t2 = (s8 + s2) * c(0.76604444); + s6 = (s4 - s8) * c(0.17364818); + s4 += s8 - s2; + + s2 = s0 - s4 * c(0.5); + y[4] = s4 + s0; + s8 = t0 - t2 + s6; + s0 = t0 - t4 + t2; + s4 = t0 + t4 - s6; + + let mut s1 = y[1]; + let mut s3 = y[3]; + let mut s5 = y[5]; + let mut s7 = y[7]; + + s3 = s3 * c(0.86602540); + t0 = (s5 + s1) * c(0.98480775); + t4 = (s5 - s7) * c(0.34202014); + t2 = (s1 + s7) * c(0.64278761); + s1 = (s1 - s5 - s7) * c(0.86602540); + + s5 = t0 - s3 - t2; + s7 = t4 - s3 - t0; + s3 = t4 + s3 - t2; + + y[0] = s4 - s7; + y[1] = s2 + s1; + y[2] = s0 - s3; + y[3] = s8 + s5; + y[5] = s8 - s5; + y[6] = s0 + s3; + y[7] = s2 - s1; + y[8] = s4 + s7; +} + +/// IMDCT-36 of `T::N` adjacent bands at once (one band per lane). +#[inline(always)] +fn l3_imdct36_bands(grbuf: &mut [f32], overlap: &mut [f32], window: &[f32; 18]) { + let c = T::splat; + let g = |grbuf: &[f32], o: usize| T::gather(&grbuf[o..], 18); + let mut co = [c(0.0); 9]; + let mut si = [c(0.0); 9]; + co[0] = -g(grbuf, 0); + si[0] = g(grbuf, 17); + for i in 0..4 { + si[8 - 2 * i] = g(grbuf, 4 * i + 1) - g(grbuf, 4 * i + 2); + co[1 + 2 * i] = g(grbuf, 4 * i + 1) + g(grbuf, 4 * i + 2); + si[7 - 2 * i] = g(grbuf, 4 * i + 4) - g(grbuf, 4 * i + 3); + co[2 + 2 * i] = -(g(grbuf, 4 * i + 3) + g(grbuf, 4 * i + 4)); + } + l3_dct3_9(&mut co); + l3_dct3_9(&mut si); + + si[1] = -si[1]; + si[3] = -si[3]; + si[5] = -si[5]; + si[7] = -si[7]; + + for i in 0..9 { + let ovl = T::gather(&overlap[i..], 9); + let sum = co[i] * c(L3_IMDCT36_G_TWID9[9 + i]) + si[i] * c(L3_IMDCT36_G_TWID9[i]); + (co[i] * c(L3_IMDCT36_G_TWID9[i]) - si[i] * c(L3_IMDCT36_G_TWID9[9 + i])).scatter(&mut overlap[i..], 9); + (ovl * c(window[i]) - sum * c(window[9 + i])).scatter(&mut grbuf[i..], 18); + (ovl * c(window[9 + i]) + sum * c(window[i])).scatter(&mut grbuf[17 - i..], 18); + } +} + +fn l3_imdct36(grbuf: &mut [f32], overlap: &mut [f32], window: &[f32; 18], nbands: usize) { + let mut j = 0; + while j + 4 <= nbands { + l3_imdct36_bands::(&mut grbuf[18 * j..], &mut overlap[9 * j..], window); + j += 4; + } + while j < nbands { + l3_imdct36_bands::(&mut grbuf[18 * j..], &mut overlap[9 * j..], window); + j += 1; + } +} + +fn l3_idct3(x0: f32, x1: f32, x2: f32, dst: &mut [f32; 3]) { + let m1 = x1 * 0.86602540; + let a1 = x0 - x2 * 0.5; + dst[1] = x0 + x2; + dst[0] = a1 + m1; + dst[2] = a1 - m1; +} + +fn l3_imdct12(x: &[f32], dst: &mut [f32], overlap: &mut [f32]) { + let mut co = [0f32; 3]; + let mut si = [0f32; 3]; + l3_idct3(-x[0], x[6] + x[3], x[12] + x[9], &mut co); + l3_idct3(x[15], x[12] - x[9], x[6] - x[3], &mut si); + si[1] = -si[1]; + + for i in 0..3 { + let ovl = overlap[i]; + let sum = co[i] * L3_IMDCT12_G_TWID3[3 + i] + si[i] * L3_IMDCT12_G_TWID3[i]; + overlap[i] = co[i] * L3_IMDCT12_G_TWID3[i] - si[i] * L3_IMDCT12_G_TWID3[3 + i]; + dst[i] = ovl * L3_IMDCT12_G_TWID3[2 - i] - sum * L3_IMDCT12_G_TWID3[5 - i]; + dst[5 - i] = ovl * L3_IMDCT12_G_TWID3[5 - i] + sum * L3_IMDCT12_G_TWID3[2 - i]; + } +} + +fn l3_imdct_short(grbuf: &mut [f32], overlap: &mut [f32], nbands: usize) { + for (grbuf, overlap) in grbuf.as_chunks_mut::<18>().0.iter_mut().zip(overlap.as_chunks_mut::<9>().0).take(nbands) { + let mut tmp = [0f32; 18]; + tmp.copy_from_slice(grbuf); + grbuf[..6].copy_from_slice(&overlap[..6]); + let (ov_out, ov_state) = overlap.split_at_mut(6); + l3_imdct12(&tmp, &mut grbuf[6..12], ov_state); + l3_imdct12(&tmp[1..], &mut grbuf[12..18], ov_state); + l3_imdct12(&tmp[2..], ov_out, ov_state); + } +} + +fn l3_change_sign(grbuf: &mut [f32; 576]) { + for band in grbuf.as_chunks_mut::<18>().0.iter_mut().skip(1).step_by(2) { + for x in band.iter_mut().skip(1).step_by(2) { + *x = -*x; + } + } +} + +fn l3_imdct_gr(grbuf: &mut [f32; 576], overlap: &mut [f32; 288], block_type: u8, n_long_bands: usize) { + let (long_buf, rest_buf) = grbuf.split_at_mut(18 * n_long_bands); + let (long_ovl, rest_ovl) = overlap.split_at_mut(9 * n_long_bands); + if n_long_bands != 0 { + l3_imdct36(long_buf, long_ovl, &L3_IMDCT_GR_G_MDCT_WINDOW[0], n_long_bands); + } + if block_type == SHORT_BLOCK_TYPE { + l3_imdct_short(rest_buf, rest_ovl, 32 - n_long_bands); + } else { + let window = &L3_IMDCT_GR_G_MDCT_WINDOW[(block_type == STOP_BLOCK_TYPE) as usize]; + l3_imdct36(rest_buf, rest_ovl, window, 32 - n_long_bands); + } +} + +fn l3_save_reservoir(h: &mut State, bs: &Bs) { + let mut pos = ((bs.pos + 7) as u32 / 8) as i32; + let mut remains = (bs.limit as u32 / 8).wrapping_sub(pos as u32) as i32; + if remains > MAX_BITRESERVOIR_BYTES as i32 { + pos += remains - MAX_BITRESERVOIR_BYTES as i32; + remains = MAX_BITRESERVOIR_BYTES as i32; + } + if remains > 0 { + let (pos, remains) = (pos as usize, remains as usize); + h.reserv_buf[..remains].copy_from_slice(&bs.buf[pos..pos + remains]); + } + h.reserv = remains; +} + +fn l3_restore_reservoir<'a>( + h: &State, + bs: &Bs, + maindata: &'a mut [u8; MAX_BITRESERVOIR_BYTES + MAX_L3_FRAME_PAYLOAD_BYTES], + main_data_begin: i32, +) -> (Bs<'a>, bool) { + let frame_bytes = ((bs.limit - bs.pos) / 8) as usize; + let bytes_have = h.reserv.min(main_data_begin) as usize; + let reserv_off = (h.reserv - main_data_begin).max(0) as usize; + maindata[..bytes_have].copy_from_slice(&h.reserv_buf[reserv_off..reserv_off + bytes_have]); + let frame_start = (bs.pos / 8) as usize; + maindata[bytes_have..bytes_have + frame_bytes] + .copy_from_slice(&bs.buf[frame_start..frame_start + frame_bytes]); + // Bounding the reader to the valid bytes makes Huffman over-reads on corrupt + // streams see zeros without having to clear the buffer. + let len = bytes_have + frame_bytes; + (Bs::new(&maindata[..len], len as i32), h.reserv >= main_data_begin) +} + +fn l3_decode(h: &mut State, s: &mut Scratch, bs: &mut Bs, gr_info: &[GrInfo], nch: usize) { + let hdr = Header(h.header); + for ch in 0..nch { + let layer3gr_limit = bs.pos + i32::from(gr_info[ch].part_23_length); + l3_decode_scalefactors(hdr, &mut s.ist_pos[ch], bs, &gr_info[ch], &mut s.scf, ch); + l3_huffman(&mut s.grbuf[ch], bs, &gr_info[ch], &s.scf, layer3gr_limit); + } + + let stereo: &mut [f32; 1152] = s.grbuf.as_flattened_mut().try_into().unwrap(); + if hdr.test_i_stereo() { + l3_intensity_stereo(stereo, &mut s.ist_pos[1], gr_info, hdr); + } else if hdr.is_ms_stereo() { + l3_midside_stereo(stereo, 0, 576); + } + + for ch in 0..nch { + let gr = &gr_info[ch]; + let mut aa_bands = 31; + let n_long_bands: usize = + (if gr.mixed_block_flag != 0 { 2 } else { 0 }) << (hdr.get_my_sample_rate() == 2) as u32; + + if gr.n_short_sfb != 0 { + aa_bands = n_long_bands.saturating_sub(1); + l3_reorder( + &mut s.grbuf[ch][n_long_bands * 18..], + s.syn.as_flattened_mut(), + &gr.sfbtab[gr.n_long_sfb as usize..], + ); + } + + l3_antialias(&mut s.grbuf[ch], aa_bands); + l3_imdct_gr(&mut s.grbuf[ch], &mut h.mdct_overlap[ch], gr.block_type, n_long_bands); + l3_change_sign(&mut s.grbuf[ch]); + } +} + +/// DCT-II over columns `k..k + T::N` of the 18x32 subband matrix. +#[inline(always)] +fn mp3d_dct_ii_cols(grbuf: &mut [f32; 576], k: usize) { + let c = T::splat; + let mut t = [[c(0.0); 8]; 4]; + for i in 0..8 { + let x0 = T::load(&grbuf[k + i * 18..]); + let x1 = T::load(&grbuf[k + (15 - i) * 18..]); + let x2 = T::load(&grbuf[k + (16 + i) * 18..]); + let x3 = T::load(&grbuf[k + (31 - i) * 18..]); + let t0 = x0 + x3; + let t1 = x1 + x2; + let t2 = (x1 - x2) * c(MP3D_DCT_II_G_SEC[3 * i]); + let t3 = (x0 - x3) * c(MP3D_DCT_II_G_SEC[3 * i + 1]); + t[0][i] = t0 + t1; + t[1][i] = (t0 - t1) * c(MP3D_DCT_II_G_SEC[3 * i + 2]); + t[2][i] = t3 + t2; + t[3][i] = (t3 - t2) * c(MP3D_DCT_II_G_SEC[3 * i + 2]); + } + for x in &mut t { + let [mut x0, mut x1, mut x2, mut x3, mut x4, mut x5, mut x6, mut x7] = *x; + let mut xt = x0 - x7; + x0 += x7; + x7 = x1 - x6; + x1 += x6; + x6 = x2 - x5; + x2 += x5; + x5 = x3 - x4; + x3 += x4; + x4 = x0 - x3; + x0 += x3; + x3 = x1 - x2; + x1 += x2; + x[0] = x0 + x1; + x[4] = (x0 - x1) * c(0.70710677); + x5 += x6; + x6 = (x6 + x7) * c(0.70710677); + x7 += xt; + x3 = (x3 + x4) * c(0.70710677); + x5 -= x7 * c(0.198912367); // rotate by PI/8 + x7 += x5 * c(0.382683432); + x5 -= x7 * c(0.198912367); + x0 = xt - x6; + xt += x6; + x[1] = (xt + x7) * c(0.50979561); + x[2] = (x4 + x3) * c(0.54119611); + x[3] = (x0 - x5) * c(0.60134488); + x[5] = (x0 + x5) * c(0.89997619); + x[6] = (x4 - x3) * c(1.30656302); + x[7] = (xt - x7) * c(2.56291556); + } + for i in 0..7 { + let y = k + i * 4 * 18; + t[0][i].store(&mut grbuf[y..]); + (t[2][i] + t[3][i] + t[3][i + 1]).store(&mut grbuf[y + 18..]); + (t[1][i] + t[1][i + 1]).store(&mut grbuf[y + 2 * 18..]); + (t[2][i + 1] + t[3][i] + t[3][i + 1]).store(&mut grbuf[y + 3 * 18..]); + } + let y = k + 7 * 4 * 18; + t[0][7].store(&mut grbuf[y..]); + (t[2][7] + t[3][7]).store(&mut grbuf[y + 18..]); + t[1][7].store(&mut grbuf[y + 2 * 18..]); + t[3][7].store(&mut grbuf[y + 3 * 18..]); +} + +fn mp3d_dct_ii(grbuf: &mut [f32; 576], n: usize) { + let mut k = 0; + while k + 4 <= n { + mp3d_dct_ii_cols::(grbuf, k); + k += 4; + } + while k < n { + mp3d_dct_ii_cols::(grbuf, k); + k += 1; + } +} + +/// `mp3d_synth_pair`: the two samples C writes to `pcm[0]` and `pcm[16*nch]`, +/// read from `lane` (and `lane + 2`) of every 16th row starting at `row`. +#[inline(always)] +fn mp3d_synth_pair(z: &[[f32; 4]; SYNTH_ROWS], row: usize, lane: usize) -> (f32, f32) { + let x = |m: usize| z[row + 16 * m][lane]; + let mut a; + a = (x(14) - x(0)) * 29.0; + a += (x(1) + x(13)) * 213.0; + a += (x(12) - x(2)) * 459.0; + a += (x(3) + x(11)) * 2037.0; + a += (x(10) - x(4)) * 5153.0; + a += (x(5) + x(9)) * 6574.0; + a += (x(8) - x(6)) * 37489.0; + a += x(7) * 75038.0; + let first = a; + + let x = |m: usize| z[row + 16 * m][lane + 2]; + a = x(14) * 104.0; + a += x(12) * 1567.0; + a += x(10) * 9727.0; + a += x(8) * 64019.0; + a += x(6) * -9975.0; + a += x(4) * -45.0; + a += x(2) * 146.0; + a += x(0) * -5.0; + (first, a) +} + +// The polyphase filterbank history (`lins` in C) is viewed as rows of 4 floats: +// [left even, right even, left odd, right odd]. In C terms, row `r` lane `l` is +// `lins[4*r + l]`, and `zlin = lins + 15*64` starts at row `ZLIN`. +const ZLIN: usize = 15 * 16; +/// Rows of `lins` touched by one call to `mp3d_synth`. +const SYNTH_ROWS: usize = ZLIN + 2 * 16; + +fn mp3d_synth( + xl: &[f32], + xr: &[f32], + dst: &mut [S], + lins: &mut [[f32; 4]; SYNTH_ROWS], + compat: bool, +) { + let xl: &[f32; 560] = xl[..560].try_into().unwrap(); + let xr: &[f32; 560] = xr[..560].try_into().unwrap(); + let dst = &mut dst[..64 * NCH]; + let r = NCH - 1; + + lins[ZLIN + 15] = [xl[18 * 16], xr[18 * 16], xl[0], xr[0]]; + lins[ZLIN + 31] = [xl[1 + 18 * 16], xr[1 + 18 * 16], xl[1], xr[1]]; + + // For mono, C also computes "right" samples into the same slots and then + // overwrites them with the left ones; skipping them gives identical output. + let mut pair = |at: usize, row: usize, lane: usize| { + let (a, b) = mp3d_synth_pair(lins, row, lane); + dst[at] = S::from_synth(a, compat); + dst[at + 16 * NCH] = S::from_synth(b, compat); + }; + if NCH == 2 { + pair(r, 15, 1); + pair(r + 32 * NCH, 31, 1); + } + pair(0, 15, 0); + pair(32 * NCH, 31, 0); + + for i in (0..15).rev() { + lins[ZLIN + i] = [xl[18 * (31 - i)], xr[18 * (31 - i)], xl[1 + 18 * (31 - i)], xr[1 + 18 * (31 - i)]]; + lins[ZLIN + 16 + i][0] = xl[1 + 18 * (1 + i)]; + lins[ZLIN + 16 + i][1] = xr[1 + 18 * (1 + i)]; + lins[ZLIN - 16 + i][2] = xl[18 * (1 + i)]; + lins[ZLIN - 16 + i][3] = xr[18 * (1 + i)]; + + let w: &[f32; 16] = MP3D_SYNTH_G_WIN[(14 - i) * 16..][..16].try_into().unwrap(); + let vz = |k: usize| F4::load(&lins[ZLIN + i - 16 * k]); + let vy = |k: usize| F4::load(&lins[i + 16 * k]); + let w0 = |k: usize| F4::splat(w[2 * k]); + let w1 = |k: usize| F4::splat(w[2 * k + 1]); + let mut b = vz(0) * w1(0) + vy(0) * w0(0); + let mut a = vz(0) * w0(0) - vy(0) * w1(0); + for k in [1, 3, 5, 7] { + b += vz(k) * w1(k) + vy(k) * w0(k); + a += vy(k) * w1(k) - vz(k) * w0(k); + if k < 7 { + let k = k + 1; + b += vz(k) * w1(k) + vy(k) * w0(k); + a += vz(k) * w0(k) - vy(k) * w1(k); + } + } + let (a, b) = (a.to_array(), b.to_array()); + + if NCH == 2 { + dst[r + (15 - i) * NCH] = S::from_synth(a[1], compat); + dst[r + (17 + i) * NCH] = S::from_synth(b[1], compat); + } + dst[(15 - i) * NCH] = S::from_synth(a[0], compat); + dst[(17 + i) * NCH] = S::from_synth(b[0], compat); + if NCH == 2 { + dst[r + (47 - i) * NCH] = S::from_synth(a[3], compat); + dst[r + (49 + i) * NCH] = S::from_synth(b[3], compat); + } + dst[(47 - i) * NCH] = S::from_synth(a[2], compat); + dst[(49 + i) * NCH] = S::from_synth(b[2], compat); + } +} + +fn mp3d_synth_all( + grbuf: &[f32], + nbands: usize, + pcm: &mut [S], + lins: &mut [[f32; 4]; SYN_ROWS], + compat: bool, +) { + for i in (0..nbands).step_by(2) { + mp3d_synth::( + &grbuf[i..], + &grbuf[576 * (NCH - 1) + i..], + &mut pcm[32 * NCH * i..], + (&mut lins[16 * i..16 * i + SYNTH_ROWS]).try_into().unwrap(), + compat, + ); + } +} + +/// Rows in the whole synthesis buffer (`syn` in C: `(18 + 15) * 2 * 32` floats). +const SYN_ROWS: usize = (18 + 15) * 2 * 32 / 4; + +fn mp3d_synth_granule( + qmf_state: &mut [f32; 960], + grbuf: &mut [[f32; 576]; 2], + nbands: usize, + nch: usize, + pcm: &mut [S], + lins: &mut [[f32; 4]; SYN_ROWS], + compat: bool, +) { + for ch in &mut grbuf[..nch] { + mp3d_dct_ii(ch, nbands); + } + + lins.as_flattened_mut()[..15 * 64].copy_from_slice(qmf_state); + + let grbuf = grbuf.as_flattened(); + if nch == 1 { + mp3d_synth_all::(grbuf, nbands, pcm, lins, compat); + } else { + mp3d_synth_all::(grbuf, nbands, pcm, lins, compat); + } + + let tail = &lins.as_flattened()[nbands * 64..nbands * 64 + 15 * 64]; + if nch == 1 { + // Standard (not MINIMP3_NONSTANDARD_BUT_LOGICAL) behavior: a mono frame + // only advances the left channel's filterbank history, so a later + // switch to stereo starts the right channel from its old state. + for (q, l) in qmf_state.as_chunks_mut::<2>().0.iter_mut().zip(tail.as_chunks::<2>().0) { + q[0] = l[0]; + } + } else { + qmf_state.copy_from_slice(tail); + } +} + +fn mp3d_match_frame(hdr: &[u8], frame_bytes: usize) -> bool { + let first = Header::read(hdr); + let mut i = 0; + for nmatch in 0..MAX_FRAME_SYNC_MATCHES { + let h = Header::read(&hdr[i..]); + i += h.frame_bytes(frame_bytes) + h.padding(); + if i + HDR_SIZE > hdr.len() { + return nmatch > 0; + } + if !first.compare(Header::read(&hdr[i..])) { + return false; + } + } + true +} + +/// Returns `(offset, frame_bytes)`; `frame_bytes` is 0 when no frame was found. +pub fn mp3d_find_frame(mp3: &[u8], free_format_bytes: &mut usize) -> (usize, usize) { + let mp3_bytes = mp3.len(); + let mut i = 0; + while i + HDR_SIZE < mp3_bytes { + let buf = &mp3[i..]; + let h = Header::read(buf); + if h.valid() { + let mut frame_bytes = h.frame_bytes(*free_format_bytes); + let mut frame_and_padding = frame_bytes + h.padding(); + + let mut k = HDR_SIZE; + while frame_bytes == 0 && k < MAX_FREE_FORMAT_FRAME_SIZE && i + 2 * k + HDR_SIZE < mp3_bytes { + let next = Header::read(&buf[k..]); + if h.compare(next) { + let fb = k - h.padding(); + let nextfb = fb + next.padding(); + if i + k + nextfb + HDR_SIZE <= mp3_bytes && h.compare(Header::read(&buf[k + nextfb..])) { + frame_and_padding = k; + frame_bytes = fb; + *free_format_bytes = fb; + } + } + k += 1; + } + + if (frame_bytes != 0 && i + frame_and_padding <= mp3_bytes && mp3d_match_frame(buf, frame_bytes)) + || (i == 0 && frame_and_padding == mp3_bytes) + { + return (i, frame_and_padding); + } + *free_format_bytes = 0; + } + i += 1; + } + (mp3_bytes, 0) +} + +/// `mp3dec_init`: forces a full resynchronization (and state reset) on the +/// next decode. +pub fn mp3dec_init(dec: &mut Mp3Dec) { + dec.st.header[0] = 0; +} + +/// Parses the Layer III side info of `frame` (header included) as a frame of +/// `frame_size` bytes, like minimp3_ex does for VBR tags and seeking. Returns +/// the bit reader's `(pos, limit)` afterwards, relative to the end of the +/// header, or `None` if the side info is invalid. +pub fn l3_side_info(frame: &[u8], frame_size: usize) -> Option<(i32, i32)> { + let hdr = Header::read(frame); + let body = &frame[HDR_SIZE..]; + // C trusts frame_size; clamp it so a truncated frame can't be over-read. + let mut bs = Bs::new(body, (frame_size as i32 - HDR_SIZE as i32).min(body.len() as i32)); + if hdr.is_crc() { + bs.get_bits(16); + } + let mut gr_info = [GrInfo::default(); 4]; + (l3_read_side_info(&mut bs, &mut gr_info, hdr) >= 0).then_some((bs.pos, bs.limit)) +} + +/// `mp3dec_decode_frame`. With `pcm == None`, only parses the frame header and +/// returns the number of samples the frame would produce (per channel). +/// `compat` selects minimp3's exact sample conversion (see [`Sample`]). +pub fn mp3dec_decode_frame( + dec: &mut Mp3Dec, + mp3: &[u8], + pcm: Option<&mut [S]>, + info: &mut FrameInfo, + compat: bool, +) -> usize { + let Mp3Dec { st: dec, scratch, maindata } = dec; + let mp3_bytes = mp3.len(); + let mut i = 0; + let mut frame_size = 0; + + if mp3_bytes > 4 && dec.header[0] == 0xff && Header(dec.header).compare(Header::read(mp3)) { + let h = Header::read(mp3); + frame_size = h.frame_bytes(dec.free_format_bytes) + h.padding(); + if frame_size != mp3_bytes + && (frame_size + HDR_SIZE > mp3_bytes || !h.compare(Header::read(&mp3[frame_size..]))) + { + frame_size = 0; + } + } + if frame_size == 0 { + *dec = State::new(); + (i, frame_size) = mp3d_find_frame(mp3, &mut dec.free_format_bytes); + if frame_size == 0 || i + frame_size > mp3_bytes { + info.frame_bytes = i; + info.status = Status::NoFrame; + return 0; + } + } + + let hdr = Header::read(&mp3[i..]); + dec.header = hdr.0; + info.frame_bytes = i + frame_size; + info.frame_offset = i; + info.channels = if hdr.is_mono() { 1 } else { 2 }; + info.hz = hdr.sample_rate_hz(); + info.layer = 4 - hdr.get_layer(); + info.bitrate_kbps = hdr.bitrate_kbps(); + + let Some(pcm) = pcm else { + info.status = Status::HeaderOnly; + return hdr.frame_samples() as usize; + }; + + let frame = mp3.get(i + HDR_SIZE..i + frame_size).unwrap_or(&[]); + let mut bs_frame = Bs::new(frame, frame_size as i32 - HDR_SIZE as i32); + if hdr.is_crc() { + bs_frame.get_bits(16); + } + + if info.layer != 3 { + #[cfg(feature = "layer12")] + { + let n = layer12::decode_frame(dec, scratch, hdr, &mut bs_frame, pcm, info.channels as usize, compat); + info.status = if n == 0 { Status::Corrupt } else { Status::Decoded }; + return n; + } + #[cfg(not(feature = "layer12"))] + { + info.status = Status::UnsupportedLayer; + return 0; + } + } + + let nch = info.channels as usize; + let mut gr_info = [GrInfo::default(); 4]; + let main_data_begin = l3_read_side_info(&mut bs_frame, &mut gr_info, hdr); + if main_data_begin < 0 || bs_frame.pos > bs_frame.limit { + dec.header[0] = 0; // mp3dec_init + info.status = Status::Corrupt; + return 0; + } + + let (mut bs, success) = l3_restore_reservoir(dec, &bs_frame, maindata, main_data_begin); + if success { + // C leaves these uninitialized and corrupt streams can read them before + // writing; clearing them keeps results deterministic. + scratch.scf = [0.; 40]; + scratch.ist_pos = [[0; 39]; 2]; + let granules = if hdr.test_mpeg1() { 2 } else { 1 }; + for (igr, pcm) in pcm.chunks_exact_mut(576 * nch).take(granules).enumerate() { + scratch.grbuf = [[0.; 576]; 2]; + l3_decode(dec, scratch, &mut bs, &gr_info[igr * nch..], nch); + mp3d_synth_granule(&mut dec.qmf_state, &mut scratch.grbuf, 18, nch, pcm, &mut scratch.syn, compat); + } + } + l3_save_reservoir(dec, &bs); + info.status = if success { Status::Decoded } else { Status::ReservoirUnavailable }; + success as usize * Header(dec.header).frame_samples() as usize +} diff --git a/firmware-rs/vendor/nanomp3-core/src/minimp3/lanes.rs b/firmware-rs/vendor/nanomp3-core/src/minimp3/lanes.rs new file mode 100644 index 00000000..7c9419d6 --- /dev/null +++ b/firmware-rs/vendor/nanomp3-core/src/minimp3/lanes.rs @@ -0,0 +1,178 @@ +//! A minimal abstraction over "one float" and "four floats" so each DSP kernel +//! is written once and instantiated for both a SIMD body and a scalar tail. +//! +//! Every operation acts lane-wise with exactly the scalar semantics (no fused +//! multiply-add, no reassociation), so vectorized kernels produce bit-identical +//! results to the scalar reference. + +use core::ops::{Add, AddAssign, Mul, Neg, Sub, SubAssign}; + +pub(crate) trait Lanes: + Copy + + Add + + Sub + + Mul + + Neg + + AddAssign + + SubAssign +{ + fn splat(x: f32) -> Self; + /// Loads the first `N` floats of `s`. + fn load(s: &[f32]) -> Self; + /// Stores into the first `N` floats of `s`. + fn store(self, s: &mut [f32]); + /// Loads `s[0], s[stride], s[2*stride], ...`. + fn gather(s: &[f32], stride: usize) -> Self; + /// Stores to `s[0], s[stride], s[2*stride], ...`. + fn scatter(self, s: &mut [f32], stride: usize); + /// Reverses the lane order. + fn rev(self) -> Self; +} + +impl Lanes for f32 { + #[inline(always)] + fn splat(x: f32) -> Self { + x + } + #[inline(always)] + fn load(s: &[f32]) -> Self { + s[0] + } + #[inline(always)] + fn store(self, s: &mut [f32]) { + s[0] = self; + } + #[inline(always)] + fn gather(s: &[f32], _stride: usize) -> Self { + s[0] + } + #[inline(always)] + fn scatter(self, s: &mut [f32], _stride: usize) { + s[0] = self; + } + #[inline(always)] + fn rev(self) -> Self { + self + } +} + +pub(crate) use f32x4::F4; + +#[cfg(feature = "wide")] +mod f32x4 { + pub(crate) type F4 = wide::f32x4; + + impl super::Lanes for F4 { + #[inline(always)] + fn splat(x: f32) -> Self { + F4::splat(x) + } + #[inline(always)] + fn load(s: &[f32]) -> Self { + F4::new(s[..4].try_into().unwrap()) + } + #[inline(always)] + fn store(self, s: &mut [f32]) { + s[..4].copy_from_slice(&self.to_array()); + } + #[inline(always)] + fn gather(s: &[f32], stride: usize) -> Self { + F4::new([s[0], s[stride], s[2 * stride], s[3 * stride]]) + } + #[inline(always)] + fn scatter(self, s: &mut [f32], stride: usize) { + let a = self.to_array(); + s[3 * stride] = a[3]; + s[2 * stride] = a[2]; + s[stride] = a[1]; + s[0] = a[0]; + } + #[inline(always)] + fn rev(self) -> Self { + let [a, b, c, d] = self.to_array(); + F4::new([d, c, b, a]) + } + } +} + +#[cfg(not(feature = "wide"))] +mod f32x4 { + use core::ops::{Add, AddAssign, Mul, Neg, Sub, SubAssign}; + + /// Four floats; LLVM turns these element-wise loops into SIMD on its own. + #[derive(Copy, Clone)] + #[repr(align(16))] + pub(crate) struct F4([f32; 4]); + + impl F4 { + #[inline(always)] + pub(crate) fn to_array(self) -> [f32; 4] { + self.0 + } + } + + macro_rules! binop { + ($trait:ident, $f:ident, $op:tt, $atrait:ident, $af:ident) => { + impl $trait for F4 { + type Output = F4; + #[inline(always)] + fn $f(self, o: F4) -> F4 { + F4(core::array::from_fn(|i| self.0[i] $op o.0[i])) + } + } + impl $atrait for F4 { + #[inline(always)] + fn $af(&mut self, o: F4) { + *self = *self $op o; + } + } + }; + } + binop!(Add, add, +, AddAssign, add_assign); + binop!(Sub, sub, -, SubAssign, sub_assign); + impl Mul for F4 { + type Output = F4; + #[inline(always)] + fn mul(self, o: F4) -> F4 { + F4(core::array::from_fn(|i| self.0[i] * o.0[i])) + } + } + impl Neg for F4 { + type Output = F4; + #[inline(always)] + fn neg(self) -> F4 { + F4(self.0.map(|x| -x)) + } + } + + impl super::Lanes for F4 { + #[inline(always)] + fn splat(x: f32) -> Self { + F4([x; 4]) + } + #[inline(always)] + fn load(s: &[f32]) -> Self { + F4(s[..4].try_into().unwrap()) + } + #[inline(always)] + fn store(self, s: &mut [f32]) { + s[..4].copy_from_slice(&self.0); + } + #[inline(always)] + fn gather(s: &[f32], stride: usize) -> Self { + F4([s[0], s[stride], s[2 * stride], s[3 * stride]]) + } + #[inline(always)] + fn scatter(self, s: &mut [f32], stride: usize) { + s[3 * stride] = self.0[3]; + s[2 * stride] = self.0[2]; + s[stride] = self.0[1]; + s[0] = self.0[0]; + } + #[inline(always)] + fn rev(self) -> Self { + let [a, b, c, d] = self.0; + F4([d, c, b, a]) + } + } +} diff --git a/firmware-rs/vendor/nanomp3-core/src/minimp3/layer12.rs b/firmware-rs/vendor/nanomp3-core/src/minimp3/layer12.rs new file mode 100644 index 00000000..2e18dfd6 --- /dev/null +++ b/firmware-rs/vendor/nanomp3-core/src/minimp3/layer12.rs @@ -0,0 +1,244 @@ +//! MPEG Layer I and Layer II decoding (the `#ifndef MINIMP3_ONLY_MP3` parts of +//! minimp3.h). Enabled by the `layer12` feature. + +use super::{mp3d_synth_granule, Bs, Header, Sample, Scratch, State}; + +const MODE_MONO: u8 = 3; +const MODE_JOINT_STEREO: u8 = 1; + +struct ScaleInfo { + scf: [f32; 3 * 64], + total_bands: usize, + stereo_bands: usize, + bitalloc: [u8; 64], + scfcod: [u8; 64], +} + +struct SubbandAlloc { + tab_offset: u8, + code_tab_width: u8, + band_count: u8, +} + +const fn alloc(tab_offset: u8, code_tab_width: u8, band_count: u8) -> SubbandAlloc { + SubbandAlloc { tab_offset, code_tab_width, band_count } +} + +static G_ALLOC_L1: [SubbandAlloc; 1] = [alloc(76, 4, 32)]; +static G_ALLOC_L2M2: [SubbandAlloc; 3] = [alloc(60, 4, 4), alloc(44, 3, 7), alloc(44, 2, 19)]; +static G_ALLOC_L2M1: [SubbandAlloc; 4] = [alloc(0, 4, 3), alloc(16, 4, 8), alloc(32, 3, 12), alloc(40, 2, 7)]; +static G_ALLOC_L2M1_LOWRATE: [SubbandAlloc; 2] = [alloc(44, 4, 2), alloc(44, 3, 10)]; + +#[rustfmt::skip] +static G_BITALLOC_CODE_TAB: [u8; 92] = [ + 0,17, 3, 4, 5,6,7, 8,9,10,11,12,13,14,15,16, + 0,17,18, 3,19,4,5, 6,7, 8, 9,10,11,12,13,16, + 0,17,18, 3,19,4,5,16, + 0,17,18,16, + 0,17,18,19, 4,5,6, 7,8, 9,10,11,12,13,14,15, + 0,17,18, 3,19,4,5, 6,7, 8, 9,10,11,12,13,14, + 0, 2, 3, 4, 5,6,7, 8,9,10,11,12,13,14,15,16, +]; + +/// `DQ(x)` from C: three dequantizer steps per bit allocation, divided by `x`. +const fn dq(x: f32) -> [f32; 3] { + [9.53674316e-07 / x, 7.56931807e-07 / x, 6.00777173e-07 / x] +} + +static G_DEQ_L12: [[f32; 3]; 18] = [ + dq(3.), + dq(7.), + dq(15.), + dq(31.), + dq(63.), + dq(127.), + dq(255.), + dq(511.), + dq(1023.), + dq(2047.), + dq(4095.), + dq(8191.), + dq(16383.), + dq(32767.), + dq(65535.), + dq(3.), + dq(5.), + dq(9.), +]; + +fn l12_subband_alloc_table(hdr: Header, sci: &mut ScaleInfo) -> &'static [SubbandAlloc] { + let mode = hdr.0[3] >> 6; + let stereo_bands = if mode == MODE_MONO { + 0 + } else if mode == MODE_JOINT_STEREO { + ((((hdr.0[3] >> 4) & 3) as usize) << 2) + 4 + } else { + 32 + }; + + let (alloc, nbands): (&'static [SubbandAlloc], usize) = if hdr.is_layer_1() { + (&G_ALLOC_L1, 32) + } else if !hdr.test_mpeg1() { + (&G_ALLOC_L2M2, 30) + } else { + let sample_rate_idx = hdr.get_sample_rate(); + let mut kbps = hdr.bitrate_kbps() >> (mode != MODE_MONO) as u32; + if kbps == 0 { + kbps = 192; // free-format + } + if kbps < 56 { + (&G_ALLOC_L2M1_LOWRATE, if sample_rate_idx == 2 { 12 } else { 8 }) + } else if kbps >= 96 && sample_rate_idx != 1 { + (&G_ALLOC_L2M1, 30) + } else { + (&G_ALLOC_L2M1, 27) + } + }; + + sci.total_bands = nbands; + sci.stereo_bands = stereo_bands.min(nbands); + alloc +} + +fn l12_read_scalefactors(bs: &mut Bs, sci: &mut ScaleInfo, bands: usize) { + for i in 0..bands { + let mut s = 0.0; + let ba = sci.bitalloc[i] as usize; + let mask = if ba != 0 { 4 + ((19 >> sci.scfcod[i]) & 3) } else { 0 }; + for (j, m) in [4, 2, 1].into_iter().enumerate() { + if mask & m != 0 { + let b = bs.get_bits(6) as usize; + s = G_DEQ_L12[ba - 2][b % 3] * (1 << 21 >> (b / 3)) as f32; + } + sci.scf[3 * i + j] = s; + } + } +} + +fn l12_read_scale_info(hdr: Header, bs: &mut Bs, sci: &mut ScaleInfo) { + let mut subband_alloc = l12_subband_alloc_table(hdr, sci).iter(); + let mut k = 0; + let mut ba_bits = 0; + let mut ba_code_tab: &[u8] = &G_BITALLOC_CODE_TAB; + + for i in 0..sci.total_bands { + if i == k { + let a = subband_alloc.next().unwrap(); + k += a.band_count as usize; + ba_bits = u32::from(a.code_tab_width); + ba_code_tab = &G_BITALLOC_CODE_TAB[a.tab_offset as usize..]; + } + let mut ba = ba_code_tab[bs.get_bits(ba_bits) as usize]; + sci.bitalloc[2 * i] = ba; + if i < sci.stereo_bands { + ba = ba_code_tab[bs.get_bits(ba_bits) as usize]; + } + sci.bitalloc[2 * i + 1] = if sci.stereo_bands != 0 { ba } else { 0 }; + } + + for i in 0..2 * sci.total_bands { + sci.scfcod[i] = if sci.bitalloc[i] == 0 { + 6 + } else if hdr.is_layer_1() { + 2 + } else { + bs.get_bits(2) as u8 + }; + } + + l12_read_scalefactors(bs, sci, sci.total_bands * 2); + + for i in sci.stereo_bands..sci.total_bands { + sci.bitalloc[2 * i + 1] = 0; + } +} + +/// Dequantizes one group of samples into `grbuf[base..]`; returns how many +/// samples per subband it produced. +fn l12_dequantize_granule( + grbuf: &mut [f32; 1152], + base: usize, + bs: &mut Bs, + sci: &ScaleInfo, + group_size: usize, +) -> usize { + for j in 0..4 { + for i in 0..2 * sci.total_bands { + // C walks `dst` alternately +576 and -558: left band, right band, + // next left band, ... + let dst = &mut grbuf[base + group_size * j + (i / 2) * 18 + (i % 2) * 576..][..group_size]; + let ba = u32::from(sci.bitalloc[i]); + if ba == 0 { + continue; + } + if ba < 17 { + let half = (1 << (ba - 1)) - 1; + for d in dst { + *d = (bs.get_bits(ba) as i32 - half) as f32; + } + } else { + let m = (2 << (ba - 17)) + 1; // 3, 5, 9 + let mut code = bs.get_bits(m + 2 - (m >> 3)); // 5, 7, 10 bits + for d in dst { + *d = (code % m).wrapping_sub(m / 2) as i32 as f32; + code /= m; + } + } + } + } + group_size * 4 +} + +fn l12_apply_scf_384(sci: &ScaleInfo, igr: usize, grbuf: &mut [f32; 1152]) { + let (left, right) = grbuf.split_at_mut(576); + let (from, to) = (sci.stereo_bands * 18, sci.total_bands * 18); + right[from..to].copy_from_slice(&left[from..to]); + for b in 0..sci.total_bands { + let scf = &sci.scf[6 * b + igr..]; + for k in 0..12 { + left[18 * b + k] *= scf[0]; + right[18 * b + k] *= scf[3]; + } + } +} + +/// The Layer I/II branch of `mp3dec_decode_frame`. +pub(super) fn decode_frame( + st: &mut State, + scratch: &mut Scratch, + hdr: Header, + bs: &mut Bs, + pcm: &mut [S], + nch: usize, + compat: bool, +) -> usize { + let mut sci = ScaleInfo { + scf: [0.; 3 * 64], + total_bands: 0, + stereo_bands: 0, + bitalloc: [0; 64], + scfcod: [0; 64], + }; + l12_read_scale_info(hdr, bs, &mut sci); + + let group_size = if hdr.is_layer_1() { 1 } else { 3 }; + scratch.grbuf = [[0.; 576]; 2]; + let mut i = 0; + let mut out = 0; + for igr in 0..3 { + let grbuf: &mut [f32; 1152] = scratch.grbuf.as_flattened_mut().try_into().unwrap(); + i += l12_dequantize_granule(grbuf, i, bs, &sci, group_size); + if i == 12 { + i = 0; + l12_apply_scf_384(&sci, igr, grbuf); + mp3d_synth_granule(&mut st.qmf_state, &mut scratch.grbuf, 12, nch, &mut pcm[out..], &mut scratch.syn, compat); + scratch.grbuf = [[0.; 576]; 2]; + out += 384 * nch; + } + if bs.pos > bs.limit { + st.header[0] = 0; // mp3dec_init + return 0; + } + } + hdr.frame_samples() as usize +} diff --git a/firmware-rs/vendor/nanomp3-core/src/minimp3/tables.rs b/firmware-rs/vendor/nanomp3-core/src/minimp3/tables.rs new file mode 100644 index 00000000..e67d10f4 --- /dev/null +++ b/firmware-rs/vendor/nanomp3-core/src/minimp3/tables.rs @@ -0,0 +1,3972 @@ +pub static HDR_BITRATE_KBPS_HALFRATE: [[[u8; 15]; 3]; 2] = [ + [ + [ + 0, + 4, + 8, + 12, + 16, + 20, + 24, + 28, + 32, + 40, + 48, + 56, + 64, + 72, + 80, + ], + [ + 0, + 4, + 8, + 12, + 16, + 20, + 24, + 28, + 32, + 40, + 48, + 56, + 64, + 72, + 80, + ], + [ + 0, + 16, + 24, + 28, + 32, + 40, + 48, + 56, + 64, + 72, + 80, + 88, + 96, + 112, + 128, + ], + ], + [ + [ + 0, + 16, + 20, + 24, + 28, + 32, + 40, + 48, + 56, + 64, + 80, + 96, + 112, + 128, + 160, + ], + [ + 0, + 16, + 24, + 28, + 32, + 40, + 48, + 56, + 64, + 80, + 96, + 112, + 128, + 160, + 192, + ], + [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + ], + ], +]; + +pub static L3_READ_SIDE_INFO_G_SCF_LONG: [[u8; 23]; 8] = [ + [ + 6, + 6, + 6, + 6, + 6, + 6, + 8, + 10, + 12, + 14, + 16, + 20, + 24, + 28, + 32, + 38, + 46, + 52, + 60, + 68, + 58, + 54, + 0, + ], + [ + 12, + 12, + 12, + 12, + 12, + 12, + 16, + 20, + 24, + 28, + 32, + 40, + 48, + 56, + 64, + 76, + 90, + 2, + 2, + 2, + 2, + 2, + 0, + ], + [ + 6, + 6, + 6, + 6, + 6, + 6, + 8, + 10, + 12, + 14, + 16, + 20, + 24, + 28, + 32, + 38, + 46, + 52, + 60, + 68, + 58, + 54, + 0, + ], + [ + 6, + 6, + 6, + 6, + 6, + 6, + 8, + 10, + 12, + 14, + 16, + 18, + 22, + 26, + 32, + 38, + 46, + 54, + 62, + 70, + 76, + 36, + 0, + ], + [ + 6, + 6, + 6, + 6, + 6, + 6, + 8, + 10, + 12, + 14, + 16, + 20, + 24, + 28, + 32, + 38, + 46, + 52, + 60, + 68, + 58, + 54, + 0, + ], + [ + 4, + 4, + 4, + 4, + 4, + 4, + 6, + 6, + 8, + 8, + 10, + 12, + 16, + 20, + 24, + 28, + 34, + 42, + 50, + 54, + 76, + 158, + 0, + ], + [ + 4, + 4, + 4, + 4, + 4, + 4, + 6, + 6, + 6, + 8, + 10, + 12, + 16, + 18, + 22, + 28, + 34, + 40, + 46, + 54, + 54, + 192, + 0, + ], + [ + 4, + 4, + 4, + 4, + 4, + 4, + 6, + 6, + 8, + 10, + 12, + 16, + 20, + 24, + 30, + 38, + 46, + 56, + 68, + 84, + 102, + 26, + 0, + ], +]; + +pub static L3_READ_SIDE_INFO_G_SCF_SHORT: [[u8; 40]; 8] = [ + [ + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 6, + 6, + 6, + 8, + 8, + 8, + 10, + 10, + 10, + 12, + 12, + 12, + 14, + 14, + 14, + 18, + 18, + 18, + 24, + 24, + 24, + 30, + 30, + 30, + 40, + 40, + 40, + 18, + 18, + 18, + 0, + ], + [ + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 12, + 12, + 12, + 16, + 16, + 16, + 20, + 20, + 20, + 24, + 24, + 24, + 28, + 28, + 28, + 36, + 36, + 36, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 26, + 26, + 26, + 0, + ], + [ + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 6, + 6, + 6, + 6, + 6, + 6, + 8, + 8, + 8, + 10, + 10, + 10, + 14, + 14, + 14, + 18, + 18, + 18, + 26, + 26, + 26, + 32, + 32, + 32, + 42, + 42, + 42, + 18, + 18, + 18, + 0, + ], + [ + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 6, + 6, + 6, + 8, + 8, + 8, + 10, + 10, + 10, + 12, + 12, + 12, + 14, + 14, + 14, + 18, + 18, + 18, + 24, + 24, + 24, + 32, + 32, + 32, + 44, + 44, + 44, + 12, + 12, + 12, + 0, + ], + [ + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 6, + 6, + 6, + 8, + 8, + 8, + 10, + 10, + 10, + 12, + 12, + 12, + 14, + 14, + 14, + 18, + 18, + 18, + 24, + 24, + 24, + 30, + 30, + 30, + 40, + 40, + 40, + 18, + 18, + 18, + 0, + ], + [ + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 6, + 6, + 6, + 8, + 8, + 8, + 10, + 10, + 10, + 12, + 12, + 12, + 14, + 14, + 14, + 18, + 18, + 18, + 22, + 22, + 22, + 30, + 30, + 30, + 56, + 56, + 56, + 0, + ], + [ + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 6, + 6, + 6, + 6, + 6, + 6, + 10, + 10, + 10, + 12, + 12, + 12, + 14, + 14, + 14, + 16, + 16, + 16, + 20, + 20, + 20, + 26, + 26, + 26, + 66, + 66, + 66, + 0, + ], + [ + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 4, + 6, + 6, + 6, + 8, + 8, + 8, + 12, + 12, + 12, + 16, + 16, + 16, + 20, + 20, + 20, + 26, + 26, + 26, + 34, + 34, + 34, + 42, + 42, + 42, + 12, + 12, + 12, + 0, + ], +]; + +pub static L3_READ_SIDE_INFO_G_SCF_MIXED: [[u8; 40]; 8] = [ + [ + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 8, + 8, + 8, + 10, + 10, + 10, + 12, + 12, + 12, + 14, + 14, + 14, + 18, + 18, + 18, + 24, + 24, + 24, + 30, + 30, + 30, + 40, + 40, + 40, + 18, + 18, + 18, + 0, + 0, + 0, + 0, + ], + [ + 12, + 12, + 12, + 4, + 4, + 4, + 8, + 8, + 8, + 12, + 12, + 12, + 16, + 16, + 16, + 20, + 20, + 20, + 24, + 24, + 24, + 28, + 28, + 28, + 36, + 36, + 36, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 26, + 26, + 26, + 0, + ], + [ + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 8, + 8, + 8, + 10, + 10, + 10, + 14, + 14, + 14, + 18, + 18, + 18, + 26, + 26, + 26, + 32, + 32, + 32, + 42, + 42, + 42, + 18, + 18, + 18, + 0, + 0, + 0, + 0, + ], + [ + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 8, + 8, + 8, + 10, + 10, + 10, + 12, + 12, + 12, + 14, + 14, + 14, + 18, + 18, + 18, + 24, + 24, + 24, + 32, + 32, + 32, + 44, + 44, + 44, + 12, + 12, + 12, + 0, + 0, + 0, + 0, + ], + [ + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 8, + 8, + 8, + 10, + 10, + 10, + 12, + 12, + 12, + 14, + 14, + 14, + 18, + 18, + 18, + 24, + 24, + 24, + 30, + 30, + 30, + 40, + 40, + 40, + 18, + 18, + 18, + 0, + 0, + 0, + 0, + ], + [ + 4, + 4, + 4, + 4, + 4, + 4, + 6, + 6, + 4, + 4, + 4, + 6, + 6, + 6, + 8, + 8, + 8, + 10, + 10, + 10, + 12, + 12, + 12, + 14, + 14, + 14, + 18, + 18, + 18, + 22, + 22, + 22, + 30, + 30, + 30, + 56, + 56, + 56, + 0, + 0, + ], + [ + 4, + 4, + 4, + 4, + 4, + 4, + 6, + 6, + 4, + 4, + 4, + 6, + 6, + 6, + 6, + 6, + 6, + 10, + 10, + 10, + 12, + 12, + 12, + 14, + 14, + 14, + 16, + 16, + 16, + 20, + 20, + 20, + 26, + 26, + 26, + 66, + 66, + 66, + 0, + 0, + ], + [ + 4, + 4, + 4, + 4, + 4, + 4, + 6, + 6, + 4, + 4, + 4, + 6, + 6, + 6, + 8, + 8, + 8, + 12, + 12, + 12, + 16, + 16, + 16, + 20, + 20, + 20, + 26, + 26, + 26, + 34, + 34, + 34, + 42, + 42, + 42, + 12, + 12, + 12, + 0, + 0, + ], +]; + +pub static L3_DECODE_SCALEFACTORS_G_SCM_PARTITIONS: [[u8; 28]; 3] = [ + [ + 6, + 5, + 5, + 5, + 6, + 5, + 5, + 5, + 6, + 5, + 7, + 3, + 11, + 10, + 0, + 0, + 7, + 7, + 7, + 0, + 6, + 6, + 6, + 3, + 8, + 8, + 5, + 0, + ], + [ + 8, + 9, + 6, + 12, + 6, + 9, + 9, + 9, + 6, + 9, + 12, + 6, + 15, + 18, + 0, + 0, + 6, + 15, + 12, + 0, + 6, + 12, + 9, + 6, + 6, + 18, + 9, + 0, + ], + [ + 9, + 9, + 6, + 12, + 9, + 9, + 9, + 9, + 9, + 9, + 12, + 6, + 18, + 18, + 0, + 0, + 12, + 12, + 12, + 0, + 12, + 9, + 9, + 6, + 15, + 12, + 9, + 0, + ], +]; + +pub static L3_DECODE_SCALEFACTORS_G_SCFC_DECODE: [u8; 16] = [ + 0, + 1, + 2, + 3, + 12, + 5, + 6, + 7, + 9, + 10, + 11, + 13, + 14, + 15, + 18, + 19, +]; + +pub static L3_DECODE_SCALEFACTORS_G_MOD: [u8; 24] = [ + 5, + 5, + 4, + 4, + 5, + 5, + 4, + 1, + 4, + 3, + 1, + 1, + 5, + 6, + 6, + 1, + 4, + 4, + 4, + 1, + 4, + 3, + 1, + 1, +]; + +pub static L3_DECODE_SCALEFACTORS_G_PREAMP: [u8; 10] = [ + 1, + 1, + 1, + 1, + 2, + 2, + 3, + 3, + 3, + 2, +]; + +pub static G_POW43: [f32; 145] = [ + 0f32, + -1f32, + -2.519842f32, + -4.326749f32, + -6.349604f32, + -8.549880f32, + -10.902724f32, + -13.390518f32, + -16.000000f32, + -18.720754f32, + -21.544347f32, + -24.463781f32, + -27.473142f32, + -30.567351f32, + -33.741992f32, + -36.993181f32, + 0f32, + 1f32, + 2.519842f32, + 4.326749f32, + 6.349604f32, + 8.549880f32, + 10.902724f32, + 13.390518f32, + 16.000000f32, + 18.720754f32, + 21.544347f32, + 24.463781f32, + 27.473142f32, + 30.567351f32, + 33.741992f32, + 36.993181f32, + 40.317474f32, + 43.711787f32, + 47.173345f32, + 50.699631f32, + 54.288352f32, + 57.937408f32, + 61.644865f32, + 65.408941f32, + 69.227979f32, + 73.100443f32, + 77.024898f32, + 81.000000f32, + 85.024491f32, + 89.097188f32, + 93.216975f32, + 97.382800f32, + 101.593667f32, + 105.848633f32, + 110.146801f32, + 114.487321f32, + 118.869381f32, + 123.292209f32, + 127.755065f32, + 132.257246f32, + 136.798076f32, + 141.376907f32, + 145.993119f32, + 150.646117f32, + 155.335327f32, + 160.060199f32, + 164.820202f32, + 169.614826f32, + 174.443577f32, + 179.305980f32, + 184.201575f32, + 189.129918f32, + 194.090580f32, + 199.083145f32, + 204.107210f32, + 209.162385f32, + 214.248292f32, + 219.364564f32, + 224.510845f32, + 229.686789f32, + 234.892058f32, + 240.126328f32, + 245.389280f32, + 250.680604f32, + 256.000000f32, + 261.347174f32, + 266.721841f32, + 272.123723f32, + 277.552547f32, + 283.008049f32, + 288.489971f32, + 293.998060f32, + 299.532071f32, + 305.091761f32, + 310.676898f32, + 316.287249f32, + 321.922592f32, + 327.582707f32, + 333.267377f32, + 338.976394f32, + 344.709550f32, + 350.466646f32, + 356.247482f32, + 362.051866f32, + 367.879608f32, + 373.730522f32, + 379.604427f32, + 385.501143f32, + 391.420496f32, + 397.362314f32, + 403.326427f32, + 409.312672f32, + 415.320884f32, + 421.350905f32, + 427.402579f32, + 433.475750f32, + 439.570269f32, + 445.685987f32, + 451.822757f32, + 457.980436f32, + 464.158883f32, + 470.357960f32, + 476.577530f32, + 482.817459f32, + 489.077615f32, + 495.357868f32, + 501.658090f32, + 507.978156f32, + 514.317941f32, + 520.677324f32, + 527.056184f32, + 533.454404f32, + 539.871867f32, + 546.308458f32, + 552.764065f32, + 559.238575f32, + 565.731879f32, + 572.243870f32, + 578.774440f32, + 585.323483f32, + 591.890898f32, + 598.476581f32, + 605.080431f32, + 611.702349f32, + 618.342238f32, + 625.000000f32, + 631.675540f32, + 638.368763f32, + 645.079578f32, +]; + +pub static L3_HUFFMAN_TABS: [i16; 2164] = [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 785, + 785, + 785, + 785, + 784, + 784, + 784, + 784, + 513, + 513, + 513, + 513, + 513, + 513, + 513, + 513, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + -255, + 1313, + 1298, + 1282, + 785, + 785, + 785, + 785, + 784, + 784, + 784, + 784, + 769, + 769, + 769, + 769, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 290, + 288, + -255, + 1313, + 1298, + 1282, + 769, + 769, + 769, + 769, + 529, + 529, + 529, + 529, + 529, + 529, + 529, + 529, + 528, + 528, + 528, + 528, + 528, + 528, + 528, + 528, + 512, + 512, + 512, + 512, + 512, + 512, + 512, + 512, + 290, + 288, + -253, + -318, + -351, + -367, + 785, + 785, + 785, + 785, + 784, + 784, + 784, + 784, + 769, + 769, + 769, + 769, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 819, + 818, + 547, + 547, + 275, + 275, + 275, + 275, + 561, + 560, + 515, + 546, + 289, + 274, + 288, + 258, + -254, + -287, + 1329, + 1299, + 1314, + 1312, + 1057, + 1057, + 1042, + 1042, + 1026, + 1026, + 784, + 784, + 784, + 784, + 529, + 529, + 529, + 529, + 529, + 529, + 529, + 529, + 769, + 769, + 769, + 769, + 768, + 768, + 768, + 768, + 563, + 560, + 306, + 306, + 291, + 259, + -252, + -413, + -477, + -542, + 1298, + -575, + 1041, + 1041, + 784, + 784, + 784, + 784, + 769, + 769, + 769, + 769, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + -383, + -399, + 1107, + 1092, + 1106, + 1061, + 849, + 849, + 789, + 789, + 1104, + 1091, + 773, + 773, + 1076, + 1075, + 341, + 340, + 325, + 309, + 834, + 804, + 577, + 577, + 532, + 532, + 516, + 516, + 832, + 818, + 803, + 816, + 561, + 561, + 531, + 531, + 515, + 546, + 289, + 289, + 288, + 258, + -252, + -429, + -493, + -559, + 1057, + 1057, + 1042, + 1042, + 529, + 529, + 529, + 529, + 529, + 529, + 529, + 529, + 784, + 784, + 784, + 784, + 769, + 769, + 769, + 769, + 512, + 512, + 512, + 512, + 512, + 512, + 512, + 512, + -382, + 1077, + -415, + 1106, + 1061, + 1104, + 849, + 849, + 789, + 789, + 1091, + 1076, + 1029, + 1075, + 834, + 834, + 597, + 581, + 340, + 340, + 339, + 324, + 804, + 833, + 532, + 532, + 832, + 772, + 818, + 803, + 817, + 787, + 816, + 771, + 290, + 290, + 290, + 290, + 288, + 258, + -253, + -349, + -414, + -447, + -463, + 1329, + 1299, + -479, + 1314, + 1312, + 1057, + 1057, + 1042, + 1042, + 1026, + 1026, + 785, + 785, + 785, + 785, + 784, + 784, + 784, + 784, + 769, + 769, + 769, + 769, + 768, + 768, + 768, + 768, + -319, + 851, + 821, + -335, + 836, + 850, + 805, + 849, + 341, + 340, + 325, + 336, + 533, + 533, + 579, + 579, + 564, + 564, + 773, + 832, + 578, + 548, + 563, + 516, + 321, + 276, + 306, + 291, + 304, + 259, + -251, + -572, + -733, + -830, + -863, + -879, + 1041, + 1041, + 784, + 784, + 784, + 784, + 769, + 769, + 769, + 769, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + -511, + -527, + -543, + 1396, + 1351, + 1381, + 1366, + 1395, + 1335, + 1380, + -559, + 1334, + 1138, + 1138, + 1063, + 1063, + 1350, + 1392, + 1031, + 1031, + 1062, + 1062, + 1364, + 1363, + 1120, + 1120, + 1333, + 1348, + 881, + 881, + 881, + 881, + 375, + 374, + 359, + 373, + 343, + 358, + 341, + 325, + 791, + 791, + 1123, + 1122, + -703, + 1105, + 1045, + -719, + 865, + 865, + 790, + 790, + 774, + 774, + 1104, + 1029, + 338, + 293, + 323, + 308, + -799, + -815, + 833, + 788, + 772, + 818, + 803, + 816, + 322, + 292, + 307, + 320, + 561, + 531, + 515, + 546, + 289, + 274, + 288, + 258, + -251, + -525, + -605, + -685, + -765, + -831, + -846, + 1298, + 1057, + 1057, + 1312, + 1282, + 785, + 785, + 785, + 785, + 784, + 784, + 784, + 784, + 769, + 769, + 769, + 769, + 512, + 512, + 512, + 512, + 512, + 512, + 512, + 512, + 1399, + 1398, + 1383, + 1367, + 1382, + 1396, + 1351, + -511, + 1381, + 1366, + 1139, + 1139, + 1079, + 1079, + 1124, + 1124, + 1364, + 1349, + 1363, + 1333, + 882, + 882, + 882, + 882, + 807, + 807, + 807, + 807, + 1094, + 1094, + 1136, + 1136, + 373, + 341, + 535, + 535, + 881, + 775, + 867, + 822, + 774, + -591, + 324, + 338, + -671, + 849, + 550, + 550, + 866, + 864, + 609, + 609, + 293, + 336, + 534, + 534, + 789, + 835, + 773, + -751, + 834, + 804, + 308, + 307, + 833, + 788, + 832, + 772, + 562, + 562, + 547, + 547, + 305, + 275, + 560, + 515, + 290, + 290, + -252, + -397, + -477, + -557, + -622, + -653, + -719, + -735, + -750, + 1329, + 1299, + 1314, + 1057, + 1057, + 1042, + 1042, + 1312, + 1282, + 1024, + 1024, + 785, + 785, + 785, + 785, + 784, + 784, + 784, + 784, + 769, + 769, + 769, + 769, + -383, + 1127, + 1141, + 1111, + 1126, + 1140, + 1095, + 1110, + 869, + 869, + 883, + 883, + 1079, + 1109, + 882, + 882, + 375, + 374, + 807, + 868, + 838, + 881, + 791, + -463, + 867, + 822, + 368, + 263, + 852, + 837, + 836, + -543, + 610, + 610, + 550, + 550, + 352, + 336, + 534, + 534, + 865, + 774, + 851, + 821, + 850, + 805, + 593, + 533, + 579, + 564, + 773, + 832, + 578, + 578, + 548, + 548, + 577, + 577, + 307, + 276, + 306, + 291, + 516, + 560, + 259, + 259, + -250, + -2107, + -2507, + -2764, + -2909, + -2974, + -3007, + -3023, + 1041, + 1041, + 1040, + 1040, + 769, + 769, + 769, + 769, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + -767, + -1052, + -1213, + -1277, + -1358, + -1405, + -1469, + -1535, + -1550, + -1582, + -1614, + -1647, + -1662, + -1694, + -1726, + -1759, + -1774, + -1807, + -1822, + -1854, + -1886, + 1565, + -1919, + -1935, + -1951, + -1967, + 1731, + 1730, + 1580, + 1717, + -1983, + 1729, + 1564, + -1999, + 1548, + -2015, + -2031, + 1715, + 1595, + -2047, + 1714, + -2063, + 1610, + -2079, + 1609, + -2095, + 1323, + 1323, + 1457, + 1457, + 1307, + 1307, + 1712, + 1547, + 1641, + 1700, + 1699, + 1594, + 1685, + 1625, + 1442, + 1442, + 1322, + 1322, + -780, + -973, + -910, + 1279, + 1278, + 1277, + 1262, + 1276, + 1261, + 1275, + 1215, + 1260, + 1229, + -959, + 974, + 974, + 989, + 989, + -943, + 735, + 478, + 478, + 495, + 463, + 506, + 414, + -1039, + 1003, + 958, + 1017, + 927, + 942, + 987, + 957, + 431, + 476, + 1272, + 1167, + 1228, + -1183, + 1256, + -1199, + 895, + 895, + 941, + 941, + 1242, + 1227, + 1212, + 1135, + 1014, + 1014, + 490, + 489, + 503, + 487, + 910, + 1013, + 985, + 925, + 863, + 894, + 970, + 955, + 1012, + 847, + -1343, + 831, + 755, + 755, + 984, + 909, + 428, + 366, + 754, + 559, + -1391, + 752, + 486, + 457, + 924, + 997, + 698, + 698, + 983, + 893, + 740, + 740, + 908, + 877, + 739, + 739, + 667, + 667, + 953, + 938, + 497, + 287, + 271, + 271, + 683, + 606, + 590, + 712, + 726, + 574, + 302, + 302, + 738, + 736, + 481, + 286, + 526, + 725, + 605, + 711, + 636, + 724, + 696, + 651, + 589, + 681, + 666, + 710, + 364, + 467, + 573, + 695, + 466, + 466, + 301, + 465, + 379, + 379, + 709, + 604, + 665, + 679, + 316, + 316, + 634, + 633, + 436, + 436, + 464, + 269, + 424, + 394, + 452, + 332, + 438, + 363, + 347, + 408, + 393, + 448, + 331, + 422, + 362, + 407, + 392, + 421, + 346, + 406, + 391, + 376, + 375, + 359, + 1441, + 1306, + -2367, + 1290, + -2383, + 1337, + -2399, + -2415, + 1426, + 1321, + -2431, + 1411, + 1336, + -2447, + -2463, + -2479, + 1169, + 1169, + 1049, + 1049, + 1424, + 1289, + 1412, + 1352, + 1319, + -2495, + 1154, + 1154, + 1064, + 1064, + 1153, + 1153, + 416, + 390, + 360, + 404, + 403, + 389, + 344, + 374, + 373, + 343, + 358, + 372, + 327, + 357, + 342, + 311, + 356, + 326, + 1395, + 1394, + 1137, + 1137, + 1047, + 1047, + 1365, + 1392, + 1287, + 1379, + 1334, + 1364, + 1349, + 1378, + 1318, + 1363, + 792, + 792, + 792, + 792, + 1152, + 1152, + 1032, + 1032, + 1121, + 1121, + 1046, + 1046, + 1120, + 1120, + 1030, + 1030, + -2895, + 1106, + 1061, + 1104, + 849, + 849, + 789, + 789, + 1091, + 1076, + 1029, + 1090, + 1060, + 1075, + 833, + 833, + 309, + 324, + 532, + 532, + 832, + 772, + 818, + 803, + 561, + 561, + 531, + 560, + 515, + 546, + 289, + 274, + 288, + 258, + -250, + -1179, + -1579, + -1836, + -1996, + -2124, + -2253, + -2333, + -2413, + -2477, + -2542, + -2574, + -2607, + -2622, + -2655, + 1314, + 1313, + 1298, + 1312, + 1282, + 785, + 785, + 785, + 785, + 1040, + 1040, + 1025, + 1025, + 768, + 768, + 768, + 768, + -766, + -798, + -830, + -862, + -895, + -911, + -927, + -943, + -959, + -975, + -991, + -1007, + -1023, + -1039, + -1055, + -1070, + 1724, + 1647, + -1103, + -1119, + 1631, + 1767, + 1662, + 1738, + 1708, + 1723, + -1135, + 1780, + 1615, + 1779, + 1599, + 1677, + 1646, + 1778, + 1583, + -1151, + 1777, + 1567, + 1737, + 1692, + 1765, + 1722, + 1707, + 1630, + 1751, + 1661, + 1764, + 1614, + 1736, + 1676, + 1763, + 1750, + 1645, + 1598, + 1721, + 1691, + 1762, + 1706, + 1582, + 1761, + 1566, + -1167, + 1749, + 1629, + 767, + 766, + 751, + 765, + 494, + 494, + 735, + 764, + 719, + 749, + 734, + 763, + 447, + 447, + 748, + 718, + 477, + 506, + 431, + 491, + 446, + 476, + 461, + 505, + 415, + 430, + 475, + 445, + 504, + 399, + 460, + 489, + 414, + 503, + 383, + 474, + 429, + 459, + 502, + 502, + 746, + 752, + 488, + 398, + 501, + 473, + 413, + 472, + 486, + 271, + 480, + 270, + -1439, + -1455, + 1357, + -1471, + -1487, + -1503, + 1341, + 1325, + -1519, + 1489, + 1463, + 1403, + 1309, + -1535, + 1372, + 1448, + 1418, + 1476, + 1356, + 1462, + 1387, + -1551, + 1475, + 1340, + 1447, + 1402, + 1386, + -1567, + 1068, + 1068, + 1474, + 1461, + 455, + 380, + 468, + 440, + 395, + 425, + 410, + 454, + 364, + 467, + 466, + 464, + 453, + 269, + 409, + 448, + 268, + 432, + 1371, + 1473, + 1432, + 1417, + 1308, + 1460, + 1355, + 1446, + 1459, + 1431, + 1083, + 1083, + 1401, + 1416, + 1458, + 1445, + 1067, + 1067, + 1370, + 1457, + 1051, + 1051, + 1291, + 1430, + 1385, + 1444, + 1354, + 1415, + 1400, + 1443, + 1082, + 1082, + 1173, + 1113, + 1186, + 1066, + 1185, + 1050, + -1967, + 1158, + 1128, + 1172, + 1097, + 1171, + 1081, + -1983, + 1157, + 1112, + 416, + 266, + 375, + 400, + 1170, + 1142, + 1127, + 1065, + 793, + 793, + 1169, + 1033, + 1156, + 1096, + 1141, + 1111, + 1155, + 1080, + 1126, + 1140, + 898, + 898, + 808, + 808, + 897, + 897, + 792, + 792, + 1095, + 1152, + 1032, + 1125, + 1110, + 1139, + 1079, + 1124, + 882, + 807, + 838, + 881, + 853, + 791, + -2319, + 867, + 368, + 263, + 822, + 852, + 837, + 866, + 806, + 865, + -2399, + 851, + 352, + 262, + 534, + 534, + 821, + 836, + 594, + 594, + 549, + 549, + 593, + 593, + 533, + 533, + 848, + 773, + 579, + 579, + 564, + 578, + 548, + 563, + 276, + 276, + 577, + 576, + 306, + 291, + 516, + 560, + 305, + 305, + 275, + 259, + -251, + -892, + -2058, + -2620, + -2828, + -2957, + -3023, + -3039, + 1041, + 1041, + 1040, + 1040, + 769, + 769, + 769, + 769, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + 256, + -511, + -527, + -543, + -559, + 1530, + -575, + -591, + 1528, + 1527, + 1407, + 1526, + 1391, + 1023, + 1023, + 1023, + 1023, + 1525, + 1375, + 1268, + 1268, + 1103, + 1103, + 1087, + 1087, + 1039, + 1039, + 1523, + -604, + 815, + 815, + 815, + 815, + 510, + 495, + 509, + 479, + 508, + 463, + 507, + 447, + 431, + 505, + 415, + 399, + -734, + -782, + 1262, + -815, + 1259, + 1244, + -831, + 1258, + 1228, + -847, + -863, + 1196, + -879, + 1253, + 987, + 987, + 748, + -767, + 493, + 493, + 462, + 477, + 414, + 414, + 686, + 669, + 478, + 446, + 461, + 445, + 474, + 429, + 487, + 458, + 412, + 471, + 1266, + 1264, + 1009, + 1009, + 799, + 799, + -1019, + -1276, + -1452, + -1581, + -1677, + -1757, + -1821, + -1886, + -1933, + -1997, + 1257, + 1257, + 1483, + 1468, + 1512, + 1422, + 1497, + 1406, + 1467, + 1496, + 1421, + 1510, + 1134, + 1134, + 1225, + 1225, + 1466, + 1451, + 1374, + 1405, + 1252, + 1252, + 1358, + 1480, + 1164, + 1164, + 1251, + 1251, + 1238, + 1238, + 1389, + 1465, + -1407, + 1054, + 1101, + -1423, + 1207, + -1439, + 830, + 830, + 1248, + 1038, + 1237, + 1117, + 1223, + 1148, + 1236, + 1208, + 411, + 426, + 395, + 410, + 379, + 269, + 1193, + 1222, + 1132, + 1235, + 1221, + 1116, + 976, + 976, + 1192, + 1162, + 1177, + 1220, + 1131, + 1191, + 963, + 963, + -1647, + 961, + 780, + -1663, + 558, + 558, + 994, + 993, + 437, + 408, + 393, + 407, + 829, + 978, + 813, + 797, + 947, + -1743, + 721, + 721, + 377, + 392, + 844, + 950, + 828, + 890, + 706, + 706, + 812, + 859, + 796, + 960, + 948, + 843, + 934, + 874, + 571, + 571, + -1919, + 690, + 555, + 689, + 421, + 346, + 539, + 539, + 944, + 779, + 918, + 873, + 932, + 842, + 903, + 888, + 570, + 570, + 931, + 917, + 674, + 674, + -2575, + 1562, + -2591, + 1609, + -2607, + 1654, + 1322, + 1322, + 1441, + 1441, + 1696, + 1546, + 1683, + 1593, + 1669, + 1624, + 1426, + 1426, + 1321, + 1321, + 1639, + 1680, + 1425, + 1425, + 1305, + 1305, + 1545, + 1668, + 1608, + 1623, + 1667, + 1592, + 1638, + 1666, + 1320, + 1320, + 1652, + 1607, + 1409, + 1409, + 1304, + 1304, + 1288, + 1288, + 1664, + 1637, + 1395, + 1395, + 1335, + 1335, + 1622, + 1636, + 1394, + 1394, + 1319, + 1319, + 1606, + 1621, + 1392, + 1392, + 1137, + 1137, + 1137, + 1137, + 345, + 390, + 360, + 375, + 404, + 373, + 1047, + -2751, + -2767, + -2783, + 1062, + 1121, + 1046, + -2799, + 1077, + -2815, + 1106, + 1061, + 789, + 789, + 1105, + 1104, + 263, + 355, + 310, + 340, + 325, + 354, + 352, + 262, + 339, + 324, + 1091, + 1076, + 1029, + 1090, + 1060, + 1075, + 833, + 833, + 788, + 788, + 1088, + 1028, + 818, + 818, + 803, + 803, + 561, + 561, + 531, + 531, + 816, + 771, + 546, + 546, + 289, + 274, + 288, + 258, + -253, + -317, + -381, + -446, + -478, + -509, + 1279, + 1279, + -811, + -1179, + -1451, + -1756, + -1900, + -2028, + -2189, + -2253, + -2333, + -2414, + -2445, + -2511, + -2526, + 1313, + 1298, + -2559, + 1041, + 1041, + 1040, + 1040, + 1025, + 1025, + 1024, + 1024, + 1022, + 1007, + 1021, + 991, + 1020, + 975, + 1019, + 959, + 687, + 687, + 1018, + 1017, + 671, + 671, + 655, + 655, + 1016, + 1015, + 639, + 639, + 758, + 758, + 623, + 623, + 757, + 607, + 756, + 591, + 755, + 575, + 754, + 559, + 543, + 543, + 1009, + 783, + -575, + -621, + -685, + -749, + 496, + -590, + 750, + 749, + 734, + 748, + 974, + 989, + 1003, + 958, + 988, + 973, + 1002, + 942, + 987, + 957, + 972, + 1001, + 926, + 986, + 941, + 971, + 956, + 1000, + 910, + 985, + 925, + 999, + 894, + 970, + -1071, + -1087, + -1102, + 1390, + -1135, + 1436, + 1509, + 1451, + 1374, + -1151, + 1405, + 1358, + 1480, + 1420, + -1167, + 1507, + 1494, + 1389, + 1342, + 1465, + 1435, + 1450, + 1326, + 1505, + 1310, + 1493, + 1373, + 1479, + 1404, + 1492, + 1464, + 1419, + 428, + 443, + 472, + 397, + 736, + 526, + 464, + 464, + 486, + 457, + 442, + 471, + 484, + 482, + 1357, + 1449, + 1434, + 1478, + 1388, + 1491, + 1341, + 1490, + 1325, + 1489, + 1463, + 1403, + 1309, + 1477, + 1372, + 1448, + 1418, + 1433, + 1476, + 1356, + 1462, + 1387, + -1439, + 1475, + 1340, + 1447, + 1402, + 1474, + 1324, + 1461, + 1371, + 1473, + 269, + 448, + 1432, + 1417, + 1308, + 1460, + -1711, + 1459, + -1727, + 1441, + 1099, + 1099, + 1446, + 1386, + 1431, + 1401, + -1743, + 1289, + 1083, + 1083, + 1160, + 1160, + 1458, + 1445, + 1067, + 1067, + 1370, + 1457, + 1307, + 1430, + 1129, + 1129, + 1098, + 1098, + 268, + 432, + 267, + 416, + 266, + 400, + -1887, + 1144, + 1187, + 1082, + 1173, + 1113, + 1186, + 1066, + 1050, + 1158, + 1128, + 1143, + 1172, + 1097, + 1171, + 1081, + 420, + 391, + 1157, + 1112, + 1170, + 1142, + 1127, + 1065, + 1169, + 1049, + 1156, + 1096, + 1141, + 1111, + 1155, + 1080, + 1126, + 1154, + 1064, + 1153, + 1140, + 1095, + 1048, + -2159, + 1125, + 1110, + 1137, + -2175, + 823, + 823, + 1139, + 1138, + 807, + 807, + 384, + 264, + 368, + 263, + 868, + 838, + 853, + 791, + 867, + 822, + 852, + 837, + 866, + 806, + 865, + 790, + -2319, + 851, + 821, + 836, + 352, + 262, + 850, + 805, + 849, + -2399, + 533, + 533, + 835, + 820, + 336, + 261, + 578, + 548, + 563, + 577, + 532, + 532, + 832, + 772, + 562, + 562, + 547, + 547, + 305, + 275, + 560, + 515, + 290, + 290, + 288, + 258, +]; + +pub static L3_HUFFMAN_TAB32: [u8; 28] = [ + 130, + 162, + 193, + 209, + 44, + 28, + 76, + 140, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 190, + 254, + 222, + 238, + 126, + 94, + 157, + 157, + 109, + 61, + 173, + 205, +]; + +pub static L3_HUFFMAN_TAB33: [u8; 16] = [ + 252, + 236, + 220, + 204, + 188, + 172, + 156, + 140, + 124, + 108, + 92, + 76, + 60, + 44, + 28, + 12, +]; + +pub static L3_HUFFMAN_TABINDEX: [i16; 32] = [ + 0, + 32, + 64, + 98, + 0, + 132, + 180, + 218, + 292, + 364, + 426, + 538, + 648, + 746, + 0, + 1126, + 1460, + 1460, + 1460, + 1460, + 1460, + 1460, + 1460, + 1460, + 1842, + 1842, + 1842, + 1842, + 1842, + 1842, + 1842, + 1842, +]; + +pub static L3_HUFFMAN_G_LINBITS: [u8; 32] = [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 2, + 3, + 4, + 6, + 8, + 10, + 13, + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 13, +]; + +pub static L3_STEREO_PROCESS_G_PAN: [f32; 14] = [ + 0f32, + 1f32, + 0.21132487f32, + 0.78867513f32, + 0.36602540f32, + 0.63397460f32, + 0.5f32, + 0.5f32, + 0.63397460f32, + 0.36602540f32, + 0.78867513f32, + 0.21132487f32, + 1f32, + 0f32, +]; + +pub static L3_ANTIALIAS_G_AA: [[f32; 8]; 2] = [ + [ + 0.85749293f32, + 0.88174200f32, + 0.94962865f32, + 0.98331459f32, + 0.99551782f32, + 0.99916056f32, + 0.99989920f32, + 0.99999316f32, + ], + [ + 0.51449576f32, + 0.47173197f32, + 0.31337745f32, + 0.18191320f32, + 0.09457419f32, + 0.04096558f32, + 0.01419856f32, + 0.00369997f32, + ], +]; + +pub static L3_IMDCT36_G_TWID9: [f32; 18] = [ + 0.73727734f32, + 0.79335334f32, + 0.84339145f32, + 0.88701083f32, + 0.92387953f32, + 0.95371695f32, + 0.97629601f32, + 0.99144486f32, + 0.99904822f32, + 0.67559021f32, + 0.60876143f32, + 0.53729961f32, + 0.46174861f32, + 0.38268343f32, + 0.30070580f32, + 0.21643961f32, + 0.13052619f32, + 0.04361938f32, +]; + +pub static L3_IMDCT_GR_G_MDCT_WINDOW: [[f32; 18]; 2] = [ + [ + 0.99904822f32, + 0.99144486f32, + 0.97629601f32, + 0.95371695f32, + 0.92387953f32, + 0.88701083f32, + 0.84339145f32, + 0.79335334f32, + 0.73727734f32, + 0.04361938f32, + 0.13052619f32, + 0.21643961f32, + 0.30070580f32, + 0.38268343f32, + 0.46174861f32, + 0.53729961f32, + 0.60876143f32, + 0.67559021f32, + ], + [ + 1f32, + 1f32, + 1f32, + 1f32, + 1f32, + 1f32, + 0.99144486f32, + 0.92387953f32, + 0.79335334f32, + 0f32, + 0f32, + 0f32, + 0f32, + 0f32, + 0f32, + 0.13052619f32, + 0.38268343f32, + 0.60876143f32, + ], +]; + +pub static MP3D_DCT_II_G_SEC: [f32; 24] = [ + 10.19000816f32, + 0.50060302f32, + 0.50241929f32, + 3.40760851f32, + 0.50547093f32, + 0.52249861f32, + 2.05778098f32, + 0.51544732f32, + 0.56694406f32, + 1.48416460f32, + 0.53104258f32, + 0.64682180f32, + 1.16943991f32, + 0.55310392f32, + 0.78815460f32, + 0.97256821f32, + 0.58293498f32, + 1.06067765f32, + 0.83934963f32, + 0.62250412f32, + 1.72244716f32, + 0.74453628f32, + 0.67480832f32, + 5.10114861f32, +]; + +pub static MP3D_SYNTH_G_WIN: [f32; 240] = [ + -1f32, + 26f32, + -31f32, + 208f32, + 218f32, + 401f32, + -519f32, + 2063f32, + 2000f32, + 4788f32, + -5517f32, + 7134f32, + 5959f32, + 35640f32, + -39336f32, + 74992f32, + -1f32, + 24f32, + -35f32, + 202f32, + 222f32, + 347f32, + -581f32, + 2080f32, + 1952f32, + 4425f32, + -5879f32, + 7640f32, + 5288f32, + 33791f32, + -41176f32, + 74856f32, + -1f32, + 21f32, + -38f32, + 196f32, + 225f32, + 294f32, + -645f32, + 2087f32, + 1893f32, + 4063f32, + -6237f32, + 8092f32, + 4561f32, + 31947f32, + -43006f32, + 74630f32, + -1f32, + 19f32, + -41f32, + 190f32, + 227f32, + 244f32, + -711f32, + 2085f32, + 1822f32, + 3705f32, + -6589f32, + 8492f32, + 3776f32, + 30112f32, + -44821f32, + 74313f32, + -1f32, + 17f32, + -45f32, + 183f32, + 228f32, + 197f32, + -779f32, + 2075f32, + 1739f32, + 3351f32, + -6935f32, + 8840f32, + 2935f32, + 28289f32, + -46617f32, + 73908f32, + -1f32, + 16f32, + -49f32, + 176f32, + 228f32, + 153f32, + -848f32, + 2057f32, + 1644f32, + 3004f32, + -7271f32, + 9139f32, + 2037f32, + 26482f32, + -48390f32, + 73415f32, + -2f32, + 14f32, + -53f32, + 169f32, + 227f32, + 111f32, + -919f32, + 2032f32, + 1535f32, + 2663f32, + -7597f32, + 9389f32, + 1082f32, + 24694f32, + -50137f32, + 72835f32, + -2f32, + 13f32, + -58f32, + 161f32, + 224f32, + 72f32, + -991f32, + 2001f32, + 1414f32, + 2330f32, + -7910f32, + 9592f32, + 70f32, + 22929f32, + -51853f32, + 72169f32, + -2f32, + 11f32, + -63f32, + 154f32, + 221f32, + 36f32, + -1064f32, + 1962f32, + 1280f32, + 2006f32, + -8209f32, + 9750f32, + -998f32, + 21189f32, + -53534f32, + 71420f32, + -2f32, + 10f32, + -68f32, + 147f32, + 215f32, + 2f32, + -1137f32, + 1919f32, + 1131f32, + 1692f32, + -8491f32, + 9863f32, + -2122f32, + 19478f32, + -55178f32, + 70590f32, + -3f32, + 9f32, + -73f32, + 139f32, + 208f32, + -29f32, + -1210f32, + 1870f32, + 970f32, + 1388f32, + -8755f32, + 9935f32, + -3300f32, + 17799f32, + -56778f32, + 69679f32, + -3f32, + 8f32, + -79f32, + 132f32, + 200f32, + -57f32, + -1283f32, + 1817f32, + 794f32, + 1095f32, + -8998f32, + 9966f32, + -4533f32, + 16155f32, + -58333f32, + 68692f32, + -4f32, + 7f32, + -85f32, + 125f32, + 189f32, + -83f32, + -1356f32, + 1759f32, + 605f32, + 814f32, + -9219f32, + 9959f32, + -5818f32, + 14548f32, + -59838f32, + 67629f32, + -4f32, + 7f32, + -91f32, + 117f32, + 177f32, + -106f32, + -1428f32, + 1698f32, + 402f32, + 545f32, + -9416f32, + 9916f32, + -7154f32, + 12980f32, + -61289f32, + 66494f32, + -5f32, + 6f32, + -97f32, + 111f32, + 163f32, + -127f32, + -1498f32, + 1634f32, + 185f32, + 288f32, + -9585f32, + 9838f32, + -8540f32, + 11455f32, + -62684f32, + 65290f32, +]; + +pub static L3_LDEXP_Q2_G_EXPFRAC: [f32; 4] = [ + 9.31322575e-10f32, + 7.83145814e-10f32, + 6.58544508e-10f32, + 5.53767716e-10f32, +]; + +pub static L3_IMDCT12_G_TWID3: [f32; 6] = [ + 0.79335334f32, + 0.92387953f32, + 0.99144486f32, + 0.60876143f32, + 0.38268343f32, + 0.13052619f32, +]; + +pub static HDR_SAMPLE_RATE_HZ_G_HZ: [u32; 3] = [ + 44100, + 48000, + 32000, +]; \ No newline at end of file From 4ea17f4a77bf63af8f15c7ef6455deae23a8ab0c Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 19:01:29 +0800 Subject: [PATCH 26/51] Pace reply audio into the link, and keep THINKING moving The daemon queued a whole reply's audio at once and the link's 64-frame queue dropped most of it, so the box stopped mid-sentence: wait for room instead. THINKING now gains dots and counts seconds, since recognition and the resident session take ten seconds or more. Co-Authored-By: Claude Opus 5.5 --- daemon/src/voice.rs | 61 +++++++++++++++++++++++++-- firmware-rs/core/src/firmware.rs | 2 + firmware-rs/core/src/voice_command.rs | 40 ++++++++++++++++-- 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/daemon/src/voice.rs b/daemon/src/voice.rs index fb39a8fb..4828a65d 100644 --- a/daemon/src/voice.rs +++ b/daemon/src/voice.rs @@ -23,7 +23,7 @@ use tracing::{debug, info, warn}; use vibebuddy_protocol::{Event, MAX_MESSAGE_BYTES}; use crate::resident::Resident; -use crate::serial_transport::Transport; +use crate::serial_transport::{Transport, TransportError}; use crate::speech::Recognizer; use crate::synthesis::{self, Synthesizer}; @@ -441,7 +441,9 @@ fn speak( for chunk in piece.chunks(AUDIO_BYTES) { let mut event = audio_event(turn, reply, seq); event.extra.insert("data".to_owned(), base64::engine::general_purpose::STANDARD.encode(chunk).into()); - send(transport, event); + if !send_paced(transport, event, stopped) { + return false; + } seq += 1; } true @@ -459,7 +461,33 @@ fn speak( end.extra.insert("error".to_owned(), "synthesis".into()); } } - send(transport, end); + send_paced(transport, end, stopped); +} + +/// How long one piece of a reply's audio may wait for room in the link's send queue. +const AUDIO_QUEUE_WAIT: std::time::Duration = std::time::Duration::from_secs(5); + +/// Sends a piece of a reply's audio, waiting while the link's send queue is full: a reply arrives from the voice +/// service far faster than the link drains, and a dropped piece cuts the reply short on the box. Blocking. False when +/// it gave up (stopped, link closed, or no room for too long). +fn send_paced(transport: &dyn Transport, event: Event, stopped: &AtomicBool) -> bool { + let Ok(frame) = event.to_ndjson() else { + warn!(event = %event.event, "a voice event could not be encoded"); + return false; + }; + let started = Instant::now(); + loop { + match transport.send(frame.clone()) { + Ok(()) => return true, + Err(TransportError::QueueFull) if !stopped.load(Ordering::Acquire) && started.elapsed() < AUDIO_QUEUE_WAIT => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(error) => { + warn!(?error, event = %event.event, "a voice event could not be queued for the box"); + return false; + } + } + } } async fn run_turn( @@ -621,6 +649,33 @@ pub mod tests { assert_eq!(assemble(&lines, 3).0, [5, 5, 5]); } + #[test] + fn audio_waits_for_room_in_a_full_queue_instead_of_dropping() { + struct Busy { + refusals: std::sync::Mutex, + sent: std::sync::Mutex, + } + impl Transport for Busy { + fn send(&self, _frame: Vec) -> Result<(), crate::serial_transport::TransportError> { + let mut refusals = self.refusals.lock().unwrap(); + if *refusals > 0 { + *refusals -= 1; + return Err(crate::serial_transport::TransportError::QueueFull); + } + *self.sent.lock().unwrap() += 1; + Ok(()) + } + } + let busy = Busy { refusals: std::sync::Mutex::new(3), sent: std::sync::Mutex::new(0) }; + let stopped = AtomicBool::new(false); + assert!(send_paced(&busy, audio_event(1, 0, 0), &stopped)); + assert_eq!(*busy.sent.lock().unwrap(), 1); + // Stopped: gives up on a full queue at once. + *busy.refusals.lock().unwrap() = 1; + stopped.store(true, Ordering::Release); + assert!(!send_paced(&busy, audio_event(1, 0, 1), &stopped)); + } + #[test] fn the_wav_header_is_24_khz_16_bit_mono() { let file = wav(&[1, -1]); diff --git a/firmware-rs/core/src/firmware.rs b/firmware-rs/core/src/firmware.rs index cef3ebc0..842a875e 100644 --- a/firmware-rs/core/src/firmware.rs +++ b/firmware-rs/core/src/firmware.rs @@ -1180,6 +1180,8 @@ impl Firmware { self.end_recording(board, now); } self.show_overlay(board); + } else if self.voice.waiting() && self.menu.view().is_none() { + self.show_overlay(board); } // The text stays up while a reply is still being spoken; when it goes, so does what is left to say. if !board.reply_speaking() && self.voice.expire(now) { diff --git a/firmware-rs/core/src/voice_command.rs b/firmware-rs/core/src/voice_command.rs index 60b8e545..2a025cab 100644 --- a/firmware-rs/core/src/voice_command.rs +++ b/firmware-rs/core/src/voice_command.rs @@ -251,6 +251,11 @@ impl VoiceCommand { } /// The overlay is up. + /// Waiting for the computer's first reply: the THINKING panel animates, so it is redrawn each pass. + pub fn waiting(&self) -> bool { + matches!(&self.stage, Stage::Thinking { replies, .. } if replies.is_empty()) + } + pub fn is_open(&self) -> bool { !matches!(self.stage, Stage::Idle) } @@ -378,7 +383,10 @@ impl VoiceCommand { over } - /// What the overlay shows now; None when it is closed. + /// How often THINKING gains a dot while it waits. +const THINKING_DOT_MS: u32 = 400; + +/// What the overlay shows now; None when it is closed. pub fn panel(&self, now: u32) -> Option { let close = alloc::vec![(&b"K2"[..], &b"CLOSE"[..])]; match &self.stage { @@ -394,9 +402,18 @@ impl VoiceCommand { hints: alloc::vec![(b"K0", b"LET GO"), (b"K2", b"CANCEL")], }) } - Stage::Thinking { heard, replies, .. } => { - let title: &[u8] = if replies.is_empty() { b"THINKING" } else { b"REPLY" }; - Some(text_panel(title, heard.as_deref(), replies, close)) + Stage::Thinking { turn: _, heard, replies, quiet_since } => { + if !replies.is_empty() { + return Some(text_panel(b"REPLY", heard.as_deref(), replies, close)); + } + // Recognition and the resident session take seconds: the dots and the count keep the wait visibly + // alive. + let waited = now.wrapping_sub(*quiet_since); + let mut title = b"THINKING".to_vec(); + title.extend(core::iter::repeat_n(b'.', (waited / Self::THINKING_DOT_MS % 4) as usize)); + let mut panel = text_panel(&title, heard.as_deref(), replies, close); + panel.lines.push((crate::text!(8, "{} S", waited / 1000).as_bytes().to_vec(), Vec::new(), Tone::Dim)); + Some(panel) } Stage::Done { heard, replies, .. } => Some(text_panel(b"REPLY", heard.as_deref(), replies, close)), Stage::Failed { reason, message, .. } => { @@ -451,6 +468,21 @@ mod tests { (0..count).map(|index| if index % 2 == 0 { level } else { -level }).collect() } + #[test] + fn thinking_counts_dots_and_seconds_while_it_waits() { + let mut voice = VoiceCommand::new(); + voice.set_available(true); + voice.start(0); + voice.push(&tone(3000, SAMPLES_PER_LINE)); + voice.release(1000); + assert!(voice.waiting()); + let at = |now| voice.panel(now).unwrap(); + assert_eq!(at(1000).title, b"THINKING"); + assert_eq!(at(1000 + 2 * VoiceCommand::THINKING_DOT_MS).title, b"THINKING.."); + let panel = at(4500); + assert_eq!(panel.lines.last().unwrap().0, b"3 S"); + } + #[test] fn a_turn_streams_numbered_lines_and_ends_with_the_sample_count() { let mut voice = VoiceCommand::new(); From e8e523012b7266a85893adf61167f94fe3c39a4b Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 19:06:50 +0800 Subject: [PATCH 27/51] Keep a reply playing past a lost chunk, and UART bytes out of USB lines On the box a reply stopped after its first word: one voice.audio line arrived as invalid JSON and the gap dropped the rest of the reply. UART and USB fed one line parser, so a stray byte on the bridge could split a line coming over USB; UART lines are now held until whole. A missing chunk is skipped instead of ending the reply: the MP3 decoder finds the next frame. Co-Authored-By: Claude Opus 5.5 --- docs/protocol.md | 2 +- firmware-rs/core/src/spoken_reply.rs | 53 ++++++++++++++-------------- firmware-rs/device/src/main.rs | 13 ++++++- 3 files changed, 39 insertions(+), 29 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index a4452eb5..2c6ff186 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -224,7 +224,7 @@ The computer also sends each spoken reply's audio, synthesized in the Character' - `voice.audio` with `reply` (0 for the turn's first `voice.reply`, counting up), `seq` (from 0, without gaps) and `data`: base64 of the next bytes of an MP3 stream (MPEG-2 Layer III, 24 kHz mono, 48 kbps, as the voice service returns it), at most 768 base64 characters. Chunks don't follow MP3 frame boundaries; the box buffers and decodes frame by frame. - `voice.audio` with `reply`, `seq` and `"end":true` (no `data`): that reply's audio is over. With `"error":"synthesis"` as well, synthesis failed or was cut short: the box plays what it has, keeps the text, and logs the failure instead of passing it off as a whole reply. -The box plays replies in order, one after another, and starts a reply as soon as it has enough frames, without waiting for its end. If synthesis fails the reply stays text only. While muted the box shows replies without playing them. The box plays audio only for the turn it recorded last (`voice.audio` carries `turn` like the other answers), and stops a turn's replies for good, ignoring the rest of its audio, when an announcement or the Pomodoro chime is about to play (they win), when it is muted, when a new K0 push-to-talk starts, or when the overlay closes. The overlay stays up past its read time while a reply is still being spoken. A chunk missing or out of order drops that reply's audio and the next reply plays. +The box plays replies in order, one after another, and starts a reply as soon as it has enough frames, without waiting for its end. If synthesis fails the reply stays text only. While muted the box shows replies without playing them. The box plays audio only for the turn it recorded last (`voice.audio` carries `turn` like the other answers), and stops a turn's replies for good, ignoring the rest of its audio, when an announcement or the Pomodoro chime is about to play (they win), when it is muted, when a new K0 push-to-talk starts, or when the overlay closes. The overlay stays up past its read time while a reply is still being spoken. A missing chunk is skipped (the decoder finds the next frame, so the reply only loses a moment); a chunk arriving after a later one is ignored. ## Stage 1 error output diff --git a/firmware-rs/core/src/spoken_reply.rs b/firmware-rs/core/src/spoken_reply.rs index 494f92dc..a5401a39 100644 --- a/firmware-rs/core/src/spoken_reply.rs +++ b/firmware-rs/core/src/spoken_reply.rs @@ -13,8 +13,9 @@ //! its end. If it runs dry it goes quiet until that much is buffered again, rather than stutter. //! - Replies play in their order. One whose audio never comes (synthesis failed) is simply not there: the next //! one plays. -//! - A chunk out of order or missing drops that reply's audio, all of it, even while it plays; the next reply -//! follows. So does a reply too long to hold or in a sample rate the speaker doesn't run at. +//! - A missing chunk is skipped: the decoder finds the next frame, so a lost line costs a moment of the reply, +//! not the rest of it. A chunk that comes late (behind one already taken) is ignored. A reply too long to hold +//! or in a sample rate the speaker doesn't run at drops its audio, and the next reply follows. use alloc::boxed::Box; use alloc::collections::VecDeque; @@ -135,10 +136,10 @@ impl ReplyPlayer { if reply.dropped || reply.ended { return; } - if seq != reply.next_seq { - return self.drop_reply(at); + if seq < reply.next_seq { + return; } - reply.next_seq += 1; + reply.next_seq = seq + 1; let Some(data) = data else { reply.ended = true; return; @@ -397,39 +398,37 @@ mod tests { } #[test] - fn a_gap_or_a_chunk_out_of_order_drops_that_reply_only() { + fn a_missing_chunk_is_skipped_and_a_late_one_ignored() { let pieces = chunks(FIXTURE, &[768]); let expected = one_shot(FIXTURE, Decoder::new()); - // A missing chunk before the reply starts: none of it plays, the next reply does. - let mut player = ReplyPlayer::new(); - player.apply(ReplyAudio::Chunk { reply: 0, seq: 0, data: pieces[0].clone() }); - player.apply(ReplyAudio::Chunk { reply: 0, seq: 2, data: pieces[2].clone() }); - player.apply(ReplyAudio::Chunk { reply: 0, seq: 3, data: pieces[3].clone() }); - player.apply(ReplyAudio::End { reply: 0, seq: 4 }); - send(&mut player, 1, &pieces); - assert!(drain(&mut player, 1000) == expected); - - // Out of order mid-reply, while it plays: it stops there and the next one plays whole. + // A chunk lost in the middle: the reply plays on past it, only a little shorter, then the next one whole. let mut player = ReplyPlayer::new(); - for (seq, data) in pieces[..5].iter().enumerate() { - player.apply(ReplyAudio::Chunk { reply: 0, seq: seq as u32, data: data.clone() }); + for (seq, data) in pieces.iter().enumerate() { + if seq != 3 { + player.apply(ReplyAudio::Chunk { reply: 0, seq: seq as u32, data: data.clone() }); + } } - let mut buffer = [0u8; 1000]; - assert_eq!(player.fill(&mut buffer), 1000); - player.apply(ReplyAudio::Chunk { reply: 0, seq: 6, data: pieces[6].clone() }); - player.apply(ReplyAudio::Chunk { reply: 0, seq: 5, data: pieces[5].clone() }); + player.apply(ReplyAudio::End { reply: 0, seq: pieces.len() as u32 }); send(&mut player, 1, &pieces); - assert!(drain(&mut player, 1000) == expected); + let played = drain(&mut player, 1000); + assert!(played.ends_with(&expected), "the next reply plays whole"); + let first = played.len() - expected.len(); + assert!(first > expected.len() * 3 / 4 && first < expected.len(), "{first} of {}", expected.len()); - // An end with the wrong count means the last chunk went missing: dropped too. + // A chunk that comes after a later one was taken is ignored; the reply carries on. let mut player = ReplyPlayer::new(); - for (seq, data) in pieces.iter().enumerate().take(pieces.len() - 1) { + for (seq, data) in pieces.iter().enumerate() { + if seq == 5 { + continue; + } player.apply(ReplyAudio::Chunk { reply: 0, seq: seq as u32, data: data.clone() }); + if seq == 6 { + player.apply(ReplyAudio::Chunk { reply: 0, seq: 5, data: pieces[5].clone() }); + } } player.apply(ReplyAudio::End { reply: 0, seq: pieces.len() as u32 }); let played = drain(&mut player, 1000).len(); - assert!(played < expected.len() / 2, "{played}"); - assert!(!player.pending()); + assert!(played > expected.len() * 3 / 4 && played < expected.len(), "{played}"); } #[test] diff --git a/firmware-rs/device/src/main.rs b/firmware-rs/device/src/main.rs index 812e25b3..94f06a7f 100644 --- a/firmware-rs/device/src/main.rs +++ b/firmware-rs/device/src/main.rs @@ -505,13 +505,24 @@ async fn main(spawner: Spawner) -> ! { let mut relay_box = RelayBox::new(); let mut input = [0u8; 256]; + // UART and USB feed one line parser, so UART bytes are held here until their line is whole: a stray byte on + // the bridge must not land in the middle of a line arriving over USB. + let mut uart_line: alloc::vec::Vec = alloc::vec::Vec::new(); loop { loop { let count = serial::take_uart(&mut input); if count == 0 { break; } - firmware.receive(&mut board, &input[..count]); + for &byte in &input[..count] { + if uart_line.len() < 2048 { + uart_line.push(byte); + } + if byte == b'\n' { + firmware.receive(&mut board, &uart_line); + uart_line.clear(); + } + } } loop { let count = serial::take_usb(&mut input); From 8f7b9da1d0485287a5fb5991859c266b458feaa7 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 19:10:51 +0800 Subject: [PATCH 28/51] Never fork the resident thread when it is open elsewhere The Codex App takes a thread's writer lock when the user opens it, and the daemon then started a second resident thread. Replace the thread only when its rollout is gone; otherwise fail the command and try again. Co-Authored-By: Claude Opus 5.5 --- daemon/src/resident.rs | 37 +++++++++++++++++++++++++++++++++++-- docs/resident-session.md | 2 +- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/daemon/src/resident.rs b/daemon/src/resident.rs index bd3b006e..a4048627 100644 --- a/daemon/src/resident.rs +++ b/daemon/src/resident.rs @@ -218,12 +218,19 @@ impl Resident { std::fs::create_dir_all(&self.dir) .map_err(|error| ResidentError::Rpc(format!("cannot create {}: {error}", self.dir.display())))?; let thread_id = match self.thread_id() { + // There is one resident thread for good. Only a thread that no longer exists is replaced; one that + // can't be resumed for another reason (most often the Codex App has it open, and a thread has one + // writer at a time) fails this command and is tried again with the next. Some(saved) => match self.resume(&client, &saved).await { - Err(ResidentError::Rpc(message)) if !client.is_closed() => { + Err(ResidentError::Rpc(message)) if !client.is_closed() && thread_is_gone(&message) => { let thread = self.start_thread(&client).await?; - warn!(old_thread = %saved, new_thread = %thread, %message, "the resident thread could not be resumed; started a new one"); + warn!(old_thread = %saved, new_thread = %thread, %message, "the resident thread no longer exists; started a new one"); thread } + Err(ResidentError::Rpc(message)) if message.contains("active writer") => { + warn!(thread = %saved, "the resident thread is open in another Codex process, most likely the Codex App; close it there"); + return Err(ResidentError::Rpc(message)); + } other => other?, }, None => self.start_thread(&client).await?, @@ -565,6 +572,11 @@ fn save(path: &Path, saved: &Saved) -> std::io::Result<()> { std::fs::write(path, serde_json::to_string_pretty(saved).map_err(std::io::Error::other)?) } +/// The app-server's answer to resuming a thread whose rollout is gone (deleted, or a different CODEX_HOME). +fn thread_is_gone(message: &str) -> bool { + message.contains("no rollout found") +} + #[cfg(test)] pub(crate) mod tests { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -578,6 +590,7 @@ pub(crate) mod tests { pub(crate) logged_in: bool, /// thread/resume fails, as if the rollout were gone. lose_thread: bool, + busy_thread: bool, /// Ends its output instead of completing this turn, as if the process died; only once. pub(crate) die_on_turn: Option, /// Server-to-client requests made during each turn, before it completes. @@ -611,6 +624,7 @@ pub(crate) mod tests { script.lock().unwrap().received.push(message.clone()); let id = message["id"].clone(); let lose_thread = script.lock().unwrap().lose_thread; + let busy_thread = script.lock().unwrap().busy_thread; let result = match method.as_str() { "initialized" => continue, "initialize" => json!({"userAgent": "fake"}), @@ -620,6 +634,12 @@ pub(crate) mod tests { json!({"account": account, "requiresOpenaiAuth": true}) } "thread/start" => json!({"thread": {"id": "new-thread"}}), + "thread/resume" if busy_thread => { + let message = "thread old-thread already has an active writer"; + let error = json!({"id": id, "error": {"code": -32600, "message": message}}); + out.write_all(format!("{error}\n").as_bytes()).await.unwrap(); + continue; + } "thread/resume" if lose_thread => { let error = json!({"id": id, "error": {"code": -32600, "message": "no rollout found"}}); out.write_all(format!("{error}\n").as_bytes()).await.unwrap(); @@ -740,6 +760,19 @@ pub(crate) mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn a_thread_open_elsewhere_fails_the_command_and_is_never_replaced() { + let dir = temp_dir("busy"); + let saved = Saved { thread_id: Some("old-thread".to_owned()), codex_version: None }; + save(&dir.join("resident.json"), &saved).unwrap(); + let script = logged_in(); + script.lock().unwrap().busy_thread = true; + assert!(resident(&dir, &script, Arc::default()).say("hi").await.is_err()); + assert!(!methods(&script).contains(&"thread/start".to_owned()), "{:?}", methods(&script)); + assert_eq!(load(&dir.join("resident.json")).thread_id.as_deref(), Some("old-thread")); + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn without_a_login_nothing_starts_and_no_login_flow_is_begun() { let dir = temp_dir("login"); diff --git a/docs/resident-session.md b/docs/resident-session.md index 3bb0cb46..8f9208bd 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -23,7 +23,7 @@ vibebuddy resident # prints codex://threads/, to op `daemon/src/app_server.rs` is a newline-delimited JSON-RPC client for `codex app-server` on stdio; `daemon/src/resident.rs` is the session on top of it. - **Starting.** On the first voice command, and on the next one after the app-server dies, the daemon runs the `codex` it finds (`VIBEBUDDY_CODEX`, else PATH, else `~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, since launchd's PATH is bare). It sends `initialize` (`experimentalApi: true`) and `initialized`, then `account/read`: with no account it stops with "not logged in". It never starts a login. -- **One thread for good.** `thread/start` once, with `cwd` set to `resident/` in the daemon's state directory, `sandbox: "read-only"`, `approvalPolicy: "on-request"`, the developer instructions and the dynamic tools. The thread id is kept in `resident.json` beside it, and every later start sends `thread/resume` with the same settings (the tools survive a resume; the instructions are sent again). If the thread can't be resumed, a new one is started and saved, and the log names both the old and the new thread id. +- **One thread for good.** `thread/start` once, with `cwd` set to `resident/` in the daemon's state directory, `sandbox: "read-only"`, `approvalPolicy: "on-request"`, the developer instructions and the dynamic tools. The thread id is kept in `resident.json` beside it, and every later start sends `thread/resume` with the same settings (the tools survive a resume; the instructions are sent again). Only a thread that no longer exists (`no rollout found`) is replaced by a new one, saved, with both ids in the log. A thread has one writer at a time, and the Codex App becomes its writer as soon as the user opens it there (it holds `~/.codex/thread-writer-locks/.lock`): the resume then fails with `already has an active writer`, the voice command fails as `unavailable`, and the next one tries again. The thread is never replaced for that, so there stays one resident session. - **Only our tools.** The app-server is started with the user's own Codex config, minus everything that gives a thread tools of its own: `-c features.=false` for plugins, apps (connectors), the shell and exec tools, sub-agents, goals, memories, image generation and viewing, the browser and computer use, sleep, tool suggestions and skill search, plus `web_search="disabled"`. An override can't empty `mcp_servers` (it merges), so the daemon first asks `codex mcp list --json`, under the same overrides, which servers would be on, and adds `-c mcp_servers..enabled=false` for each. Overrides are `-c` rather than `--disable`, which refuses a feature name another Codex version doesn't know. The user's global `AGENTS.md` and hooks stay: the hooks are filtered (below). Checked on 0.162.0: `mcpServerStatus/list` goes from seven servers (one with 365 app tools) to two, both off with no tools, and the thread's tool list is `speak`, `list_activities` and what Codex always adds and has no switch for: `apply_patch` (refused by the read-only sandbox, its approval declined), a clock, and the MCP resource readers (no server is on to read from). - **Tools.** `speak({text})` is the spoken reply: the daemon hands it on the moment it is called (`Resident::say_streaming`), to the box as `voice.reply`, or collected for `POST /v1/resident/say`. `list_activities({})` returns the Activities the aggregator already holds, as JSON: agent, task-card title, project, `working`/`needs_input`, host, seconds in that state and since the last event. There are no tools that run commands or write files. - **Declining.** Every approval or question the session raises is declined: command and file-change approvals (`decline`, or `denied` on the legacy methods), permission requests (nothing granted), MCP elicitations (`decline`) and user-input requests (no answers). `currentTime/read` is answered; anything else gets a JSON-RPC "method not found". From 855bea43ac982a5a359543cb3d97f3ec00af9378 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 19:26:05 +0800 Subject: [PATCH 29/51] Give the resident session its own Codex home The ChatGPT App's own app-server takes the writer lock of every thread under ~/.codex it lists and keeps it, so `thread/resume` of the resident thread failed with "already has an active writer" while the App ran. The app-server now runs with CODEX_HOME=/resident-codex (0700), which nothing else lists: - Login: resident-codex/auth.json is a symlink to the user's auth.json, re-made on every start. Codex 0.162.0 reads it with a plain open, reloads before refreshing, and saves by truncating in place, so one file stays shared. cli_auth_credentials_store="file" is pinned; a keychain login can't be shared (its entry is keyed by the Codex home's path). - Memories, read-only: features.memories stays off, since it also starts the memory pipeline, which rewrites the memories folder of the home it runs in. The daemon puts the user's memory_summary.md in the developer instructions and offers a read-only search_memories tool over MEMORY.md, raw_memories.md and rollout_summaries/. - The old thread is not found in the new home, so one new thread starts (logged), as before for a lost rollout. - `vibebuddy resident [n]` prints the last n exchanges, read with thread/turns/list (GET /v1/resident/history), instead of a codex:// link the Codex App can no longer open. Co-Authored-By: Claude Opus 5.5 --- daemon/src/bin/vibebuddy.rs | 32 +- daemon/src/main.rs | 34 +- daemon/src/resident.rs | 434 ++++++++++++++++-- daemon/src/voice.rs | 6 +- ...sident-session-that-speaks-as-the-buddy.md | 2 +- docs/resident-session.md | 24 +- 6 files changed, 464 insertions(+), 68 deletions(-) diff --git a/daemon/src/bin/vibebuddy.rs b/daemon/src/bin/vibebuddy.rs index 969c5356..617d99c3 100644 --- a/daemon/src/bin/vibebuddy.rs +++ b/daemon/src/bin/vibebuddy.rs @@ -2,7 +2,9 @@ //! opens the serial port itself (docs/architecture.md, decision 2). //! //! - `vibebuddy say `: hands a voice command, typed, to the resident session and prints its spoken replies. -//! - `vibebuddy resident`: prints the resident thread as a `codex://threads/` link for the Codex App. +//! - `vibebuddy resident [n]`: prints the resident thread's id and its last `n` (default 5) voice commands with the +//! spoken replies, read from the thread. The thread lives in a Codex home of the daemon's own, so the Codex App +//! can't open it. //! //! The resident session is experimental: the daemon has it only when started with `VIBEBUDDY_RESIDENT_SESSION=1`. @@ -15,16 +17,20 @@ use serde_json::{Value, json}; /// A voice command waits for a whole Codex turn; the daemon gives up on it after two minutes. const SAY_TIMEOUT: Duration = Duration::from_secs(180); +/// Reading the history may first have to start the app-server and wait for a turn in progress. +const HISTORY_TIMEOUT: Duration = Duration::from_secs(180); const QUICK_TIMEOUT: Duration = Duration::from_secs(5); fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); let result = match args.first().map(String::as_str) { Some("say") if args.len() > 1 => say(&args[1..].join(" ")), - Some("resident") => resident(), + Some("resident") if args.len() == 1 => resident(5), + Some("resident") if let [_, count] = args.as_slice() + && let Ok(count) = count.parse() => resident(count), _ => { eprintln!("usage: vibebuddy say (a voice command for the resident session, typed)"); - eprintln!(" vibebuddy resident (prints the resident thread's codex:// link)"); + eprintln!(" vibebuddy resident [n] (prints the resident session's last n voice commands and replies)"); return ExitCode::from(2); } }; @@ -52,14 +58,22 @@ fn say(text: &str) -> Result<(), String> { Ok(()) } -fn resident() -> Result<(), String> { +fn resident(limit: usize) -> Result<(), String> { let info = call("GET", "/v1/resident", None, QUICK_TIMEOUT)?; - match info["url"].as_str() { - Some(url) => println!("{url}"), - None => println!("no resident thread yet; it starts with the first `vibebuddy say`"), - } if info["enabled"] != true { - eprintln!("the resident session is off; start vibebuddyd with VIBEBUDDY_RESIDENT_SESSION=1"); + return Err("the resident session is off; start vibebuddyd with VIBEBUDDY_RESIDENT_SESSION=1".to_owned()); + } + let Some(thread) = info["thread_id"].as_str() else { + println!("no resident thread yet; it starts with the first `vibebuddy say`"); + return Ok(()); + }; + println!("thread {thread}"); + let history = call("GET", &format!("/v1/resident/history?limit={limit}"), None, HISTORY_TIMEOUT)?; + for exchange in history["exchanges"].as_array().cloned().unwrap_or_default() { + println!("\n> {}", exchange["said"].as_str().unwrap_or_default()); + for reply in exchange["replies"].as_array().cloned().unwrap_or_default() { + println!(" {}", reply.as_str().unwrap_or_default()); + } } Ok(()) } diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 556aa131..4c9ad78a 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -553,6 +553,7 @@ fn app(state: AppState) -> Router { .route("/v1/updates/check", post(post_check_updates)) .route("/v1/resident", get(get_resident)) .route("/v1/resident/say", post(post_resident_say)) + .route("/v1/resident/history", get(get_resident_history)) .with_state(state) } @@ -1336,19 +1337,42 @@ struct SayRequest { struct ResidentInfo { enabled: bool, thread_id: Option, - /// Opens the resident thread in the Codex App. - url: Option, } async fn get_resident(State(state): State) -> Json { - let thread_id = state.resident.as_ref().and_then(|resident| resident.thread_id()); Json(ResidentInfo { enabled: state.resident.as_ref().is_some_and(|resident| resident.enabled()), - url: thread_id.as_ref().map(|id| format!("codex://threads/{id}")), - thread_id, + thread_id: state.resident.as_ref().and_then(|resident| resident.thread_id()), }) } +#[derive(serde::Deserialize)] +struct HistoryQuery { + limit: Option, +} + +/// The last voice commands and their spoken replies, read from the resident thread (default 5, at most 50). +async fn get_resident_history( + State(state): State, + axum::extract::Query(query): axum::extract::Query, +) -> axum::response::Response { + use axum::response::IntoResponse; + let Some(resident) = state.resident.clone() else { + return (StatusCode::SERVICE_UNAVAILABLE, Json(ApiResponse { accepted: false, message: "no state directory".to_owned() })) + .into_response(); + }; + match resident.history(query.limit.unwrap_or(5).clamp(1, 50)).await { + Ok(exchanges) => Json(serde_json::json!({"thread_id": resident.thread_id(), "exchanges": exchanges})).into_response(), + Err(error) => { + let status = match error { + resident::ResidentError::Disabled => StatusCode::NOT_FOUND, + _ => StatusCode::SERVICE_UNAVAILABLE, + }; + (status, Json(ApiResponse { accepted: false, message: error.to_string() })).into_response() + } + } +} + /// One voice command, typed for now: waits for the resident session's spoken replies. async fn post_resident_say(State(state): State, Json(request): Json) -> axum::response::Response { use axum::response::IntoResponse; diff --git a/daemon/src/resident.rs b/daemon/src/resident.rs index a4048627..989f894f 100644 --- a/daemon/src/resident.rs +++ b/daemon/src/resident.rs @@ -1,16 +1,19 @@ //! The resident session (ADR-0017): the one long-running Codex thread every voice command goes to. //! -//! The daemon drives the user's own `codex app-server` over stdio with the user's own login: `thread/start` once, -//! `thread/resume` on every later start, `turn/start` per voice command. The session answers through the `speak` +//! The daemon drives `codex app-server` over stdio with the user's own login, in a Codex home of its own +//! (`resident-codex/` in the daemon's state directory) that the user's Codex and the ChatGPT App never list, so they +//! never take the thread's writer lock: `thread/start` once, `thread/resume` on every later start, `turn/start` per +//! voice command. The session answers through the `speak` //! tool we define (`dynamicTools`), and reads the Activities through `list_activities`; it gets no tool that runs -//! commands or writes files, runs read-only, and every approval or question it raises is declined. +//! commands or writes files, runs read-only, and every approval or question it raises is declined. It reads the +//! user's Codex memories, but only through the daemon, which never writes them. //! //! Experimental and off unless `VIBEBUDDY_RESIDENT_SESSION=1`. The app-server starts on the first voice command //! and again on the next one after it dies. Voice commands are taken one at a time; the ones that arrive meanwhile //! wait their turn in arrival order. //! -//! The resident session is never an Activity: the user's Codex hooks fire for its thread too, and -//! [`Resident::owns_hook`] is how the Codex adapter recognises and drops them. +//! The resident session is never an Activity: should any Codex hooks fire for its thread, [`Resident::owns_hook`] +//! is how the Codex adapter recognises and drops them. use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -36,6 +39,13 @@ pub const CODEX_VARIABLE: &str = "VIBEBUDDY_CODEX"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); /// How long one voice command may take from `turn/start` to `turn/completed`; then the turn is interrupted. const TURN_TIMEOUT: Duration = Duration::from_secs(120); +/// The resident session's own Codex home, in the daemon's state directory. +const CODEX_HOME_DIR: &str = "resident-codex"; +/// How much of the user's `memory_summary.md` goes into the instructions: Codex's own cap, 2,500 tokens, which it +/// counts as 4 bytes each. +const MEMORY_SUMMARY_MAX_BYTES: usize = 10_000; +/// How much one `search_memories` call returns at most. +const MEMORY_SEARCH_MAX_BYTES: usize = 8_000; pub fn enabled_by_env() -> bool { matches!(std::env::var(ENABLE_VARIABLE).as_deref(), Ok("1" | "true")) @@ -78,6 +88,13 @@ pub struct Answer { pub replies: Vec, } +/// One past voice command and the spoken replies to it, read back from the thread. +#[derive(Debug, PartialEq, Serialize)] +pub struct Exchange { + pub said: String, + pub replies: Vec, +} + /// How the app-server is reached: the real `codex` child, or an in-memory fake in tests. pub enum Launcher { Codex, @@ -113,6 +130,10 @@ pub struct Resident { /// The same directory with symlinks resolved, as Codex may report it in hooks. canonical_dir: PathBuf, state_file: PathBuf, + /// The Codex home the app-server runs in; the daemon owns it. + codex_home: PathBuf, + /// The user's own Codex home, whose login and memories the resident session shares. + user_codex_home: PathBuf, launcher: Launcher, activities: Arc>, /// Held for a whole turn: one voice command at a time, the rest wait here in order. @@ -122,8 +143,8 @@ pub struct Resident { } impl Resident { - /// `state_dir` is the daemon's state directory: the thread id is kept in `resident.json` there, and the - /// session works in `resident/`. + /// `state_dir` is the daemon's state directory: the thread id is kept in `resident.json` there, the session + /// works in `resident/`, and its Codex home is `resident-codex/`. pub fn new(enabled: bool, state_dir: &Path, launcher: Launcher, activities: Arc>) -> Self { let dir = state_dir.join("resident"); let state_file = state_dir.join("resident.json"); @@ -134,6 +155,8 @@ impl Resident { dir, canonical_dir, state_file, + codex_home: state_dir.join(CODEX_HOME_DIR), + user_codex_home: user_codex_home(), launcher, activities, session: Mutex::new(None), @@ -149,8 +172,8 @@ impl Resident { self.thread_id.lock().expect("thread id lock").clone() } - /// Whether a Codex hook event comes from the resident session: by its thread, or by its working directory, - /// which also covers the user opening the thread in the Codex App and typing there. + /// Whether a Codex hook event comes from the resident session: by its thread, or by its working directory. The + /// user's own hooks don't load in the resident Codex home, but system and managed ones may. pub fn owns_hook(&self, session_id: &str, thread_id: Option<&str>, cwd: Option<&str>) -> bool { if let Some(resident) = self.thread_id.lock().expect("thread id lock").as_deref() && (session_id == resident || thread_id == Some(resident)) @@ -176,6 +199,36 @@ impl Resident { return Err(ResidentError::Disabled); } let mut slot = self.session.lock().await; + let session = self.running(&mut slot).await?; + let result = self.run_turn(session, text, on_reply).await; + if session.client.is_closed() { + *slot = None; + } + result + } + + /// The last `limit` voice commands and their spoken replies, oldest first, read from the thread through the + /// app-server (the daemon keeps no record of its own). Empty while there is no thread yet. + pub async fn history(&self, limit: usize) -> Result, ResidentError> { + if !self.enabled { + return Err(ResidentError::Disabled); + } + if self.thread_id().is_none() { + return Ok(Vec::new()); + } + let mut slot = self.session.lock().await; + let session = self.running(&mut slot).await?; + let params = json!({"threadId": session.thread_id, "limit": limit, "sortDirection": "desc", "itemsView": "full"}); + let listed = request(&session.client, "thread/turns/list", params).await; + if session.client.is_closed() { + *slot = None; + } + let turns = listed?.get("data").and_then(Value::as_array).cloned().unwrap_or_default(); + Ok(turns.iter().rev().filter_map(exchange_of).collect()) + } + + /// The running session, started (again) if there is none or its app-server has exited. + async fn running<'a>(&self, slot: &'a mut Option) -> Result<&'a mut Session, ResidentError> { if slot.as_ref().is_some_and(|session| session.client.is_closed()) { warn!("the resident app-server exited; starting it again"); *slot = None; @@ -183,17 +236,14 @@ impl Resident { if slot.is_none() { *slot = Some(self.start().await?); } - let session = slot.as_mut().expect("session was just started"); - let result = self.run_turn(session, text, on_reply).await; - if session.client.is_closed() { - *slot = None; - } - result + Ok(slot.as_mut().expect("session was just started")) } async fn start(&self) -> Result { + prepare_codex_home(&self.codex_home, &self.user_codex_home) + .map_err(|error| ResidentError::Rpc(format!("cannot prepare {}: {error}", self.codex_home.display())))?; let (pipes, version) = match &self.launcher { - Launcher::Codex => spawn_codex().await?, + Launcher::Codex => spawn_codex(&self.codex_home).await?, #[cfg(test)] Launcher::Fake(connect) => (connect(), None), }; @@ -213,14 +263,21 @@ impl Resident { if account.get("account").is_none_or(Value::is_null) && account.get("requiresOpenaiAuth").and_then(Value::as_bool) != Some(false) { + if !self.user_codex_home.join("auth.json").exists() { + warn!( + codex_home = %self.user_codex_home.display(), + "no auth.json in the user's Codex home: a login kept in the keychain can't be shared with the resident session" + ); + } return Err(ResidentError::NotLoggedIn); } std::fs::create_dir_all(&self.dir) .map_err(|error| ResidentError::Rpc(format!("cannot create {}: {error}", self.dir.display())))?; let thread_id = match self.thread_id() { - // There is one resident thread for good. Only a thread that no longer exists is replaced; one that - // can't be resumed for another reason (most often the Codex App has it open, and a thread has one - // writer at a time) fails this command and is tried again with the next. + // There is one resident thread for good. Only a thread that no longer exists is replaced (a thread + // saved before the resident session had its own Codex home is one); one that can't be resumed for + // another reason (another Codex process is its writer) fails this command and is tried again with the + // next. Some(saved) => match self.resume(&client, &saved).await { Err(ResidentError::Rpc(message)) if !client.is_closed() && thread_is_gone(&message) => { let thread = self.start_thread(&client).await?; @@ -228,7 +285,7 @@ impl Resident { thread } Err(ResidentError::Rpc(message)) if message.contains("active writer") => { - warn!(thread = %saved, "the resident thread is open in another Codex process, most likely the Codex App; close it there"); + warn!(thread = %saved, "another Codex process is the resident thread's writer"); return Err(ResidentError::Rpc(message)); } other => other?, @@ -265,7 +322,7 @@ impl Resident { "cwd": self.dir, "sandbox": "read-only", "approvalPolicy": "on-request", - "developerInstructions": instructions(None), + "developerInstructions": instructions(None, memory_summary(&self.user_codex_home).as_deref()), }) } @@ -363,6 +420,10 @@ impl Resident { let activities = self.activities.lock().await.summaries(); (true, json!({"activities": activities}).to_string()) } + "search_memories" => { + let query = params.pointer("/arguments/query").and_then(Value::as_str).unwrap_or_default(); + (true, search_memories(&self.user_codex_home.join("memories"), query)) + } other => (false, format!("unknown tool {other}")), }; json!({"success": success, "contentItems": [{"type": "inputText", "text": text}]}) @@ -402,13 +463,32 @@ fn tools() -> Value { project, state (working or needs_input), where it runs, and seconds in that state.", "inputSchema": {"type": "object", "properties": {}, "additionalProperties": false}, }, + { + "type": "function", + "name": "search_memories", + "description": "Search the user's Codex memories (notes Codex keeps from the user's past sessions) for lines \ + containing any of the words in `query`, case-insensitive. Returns the matching lines, each with the \ + line before and after, by file.", + "inputSchema": { + "type": "object", + "properties": {"query": {"type": "string", "description": "A few keywords."}}, + "required": ["query"], + "additionalProperties": false, + }, + }, ]) } /// The developer instructions. `persona` is the seam for the Character the user picked (CONTEXT.md, Persona); -/// until Characters carry one, the buddy speaks plainly. -fn instructions(persona: Option<&str>) -> String { +/// until Characters carry one, the buddy speaks plainly. `memories` is the user's Codex memory summary, if any. +fn instructions(persona: Option<&str>, memories: Option<&str>) -> String { let persona = persona.unwrap_or("You are friendly, calm and brief."); + let memories = memories.map_or_else(String::new, |summary| { + format!( + "\n\nWhat Codex remembers about the user from their past sessions, in summary. For more, call \ + `search_memories`. Use it when it helps; never read it out.\n\n{summary}\n" + ) + }); format!( "You are the buddy of VibeBuddy: a small desk box that watches the user's coding Agents (Codex, Claude Code, \ OpenCode, GitHub Copilot CLI, Pi, GitHub Actions) and speaks up for them. {persona}\n\ @@ -418,7 +498,7 @@ fn instructions(persona: Option<&str>) -> String { To know what the Agents are doing, call `list_activities`; never guess. Titles there are short upper-case \ task-card names; say them naturally.\n\ You do no work yourself: run no commands, read or edit no files, browse nothing. If the user asks for work \ - to be done, say briefly that you can't start it yet." + to be done, say briefly that you can't start it yet.{memories}" ) } @@ -437,10 +517,11 @@ async fn request(client: &Client, method: &str, params: Value) -> Result Result<(Pipes, Option), ResidentError> { +/// Starts `codex app-server` in the resident session's Codex home and checks its version against the one this was +/// tested with. +async fn spawn_codex(codex_home: &Path) -> Result<(Pipes, Option), ResidentError> { let program = find_codex().ok_or(ResidentError::NoCodex)?; - let version = tokio::process::Command::new(&program) + let version = codex_command(&program, codex_home) .arg("--version") .output() .await @@ -456,8 +537,8 @@ async fn spawn_codex() -> Result<(Pipes, Option), ResidentError> { ), } let base = tool_overrides(); - let servers = enabled_mcp_servers(&program, &base).await; - let mut child = tokio::process::Command::new(&program) + let servers = enabled_mcp_servers(&program, codex_home, &base).await; + let mut child = codex_command(&program, codex_home) .args(app_server_args(&base, &servers)) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) @@ -471,10 +552,19 @@ async fn spawn_codex() -> Result<(Pipes, Option), ResidentError> { Ok((Pipes { reader: Box::new(reader), writer: Box::new(writer), child: Some(child) }, version)) } -/// Features that give a Codex thread tools of its own: the resident session gets none of them, only ours. Plugins -/// and apps (connectors) bring tools and MCP servers; the rest are built-in tools. Set as `-c features.=false` -/// rather than `--disable`, which refuses a name another Codex version doesn't know. The user's hooks and -/// AGENTS.md stay: the hooks are filtered, and AGENTS.md only shapes how it talks. +/// `codex` run in the resident session's own Codex home. The user's `config.toml`, `AGENTS.md`, hooks, plugins and +/// MCP servers live in theirs, so none of them load; Codex's system and managed config still do. +fn codex_command(program: &Path, codex_home: &Path) -> tokio::process::Command { + let mut command = tokio::process::Command::new(program); + command.env("CODEX_HOME", codex_home); + command +} + +/// Features that give a Codex thread tools of its own: the resident session gets none of them, only ours. Most are +/// on by default even in an empty Codex home. Plugins and apps (connectors) bring tools and MCP servers; the rest +/// are built-in tools. `memories` (off by default) would also start Codex's memory pipeline, which rewrites the +/// memories folder of the Codex home it runs in; the daemon gives the session the user's memories itself. Set as +/// `-c features.=false` rather than `--disable`, which refuses a name another Codex version doesn't know. const FEATURES_OFF: [&str; 15] = [ "plugins", "apps", @@ -493,16 +583,24 @@ const FEATURES_OFF: [&str; 15] = [ "skill_search", ]; -/// The config overrides that strip a Codex thread of its own tools, before MCP servers. +/// The config overrides for the resident session's Codex, before MCP servers. Besides the tools: +/// - `cli_auth_credentials_store="file"`: the login is the user's `auth.json`, linked in (see [`link_auth`]). The +/// default already, but a keyring store would never find it (keyring entries are keyed by the Codex home's +/// path) and, on saving, would delete the link. +/// - `memories.generate_memories=false`: the resident thread is never summarised into memories, should the feature +/// come on. fn tool_overrides() -> Vec { let mut args: Vec = FEATURES_OFF.iter().flat_map(|feature| ["-c".to_owned(), format!("features.{feature}=false")]).collect(); - args.extend(["-c".to_owned(), "web_search=\"disabled\"".to_owned()]); + for setting in ["web_search=\"disabled\"", "cli_auth_credentials_store=\"file\"", "memories.generate_memories=false"] { + args.extend(["-c".to_owned(), setting.to_owned()]); + } args } -/// `codex app-server` with the tool overrides and every MCP server the user configured switched off. An override -/// can't empty `mcp_servers` (it merges), so each server is turned off by name. +/// `codex app-server` with the overrides and every MCP server still configured switched off. The resident Codex home +/// configures none, but system and managed config may. An override can't empty `mcp_servers` (it merges), so each +/// server is turned off by name. fn app_server_args(base: &[String], mcp_servers: &[String]) -> Vec { let mut args = base.to_vec(); for name in mcp_servers { @@ -515,8 +613,8 @@ fn app_server_args(base: &[String], mcp_servers: &[String]) -> Vec { /// The MCP servers that would be on, as Codex itself resolves them (`codex mcp list --json`, under the same /// overrides, so servers that plugins bring are already gone). A name that can't be written as a bare TOML key /// can't be switched off by an override; it is logged. -async fn enabled_mcp_servers(program: &Path, base: &[String]) -> Vec { - let output = tokio::process::Command::new(program) +async fn enabled_mcp_servers(program: &Path, codex_home: &Path, base: &[String]) -> Vec { + let output = codex_command(program, codex_home) .args(base) .args(["mcp", "list", "--json"]) .stdin(std::process::Stdio::null()) @@ -525,7 +623,7 @@ async fn enabled_mcp_servers(program: &Path, base: &[String]) -> Vec { .await; let listed = output.ok().and_then(|output| serde_json::from_slice::(&output.stdout).ok()); let Some(servers) = listed.as_ref().and_then(Value::as_array) else { - warn!("could not list the user's MCP servers; the resident session may see their tools"); + warn!("could not list the MCP servers; the resident session may see their tools"); return Vec::new(); }; servers @@ -558,6 +656,106 @@ fn find_codex() -> Option { .find(|path| path.is_file()) } +/// The user's own Codex home: `CODEX_HOME`, else `~/.codex`. +fn user_codex_home() -> PathBuf { + if let Some(home) = std::env::var_os("CODEX_HOME").filter(|home| !home.is_empty()) { + return PathBuf::from(home); + } + std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default().join(".codex") +} + +/// Makes the resident session's Codex home (mode 0700) and links the user's login into it. +fn prepare_codex_home(codex_home: &Path, user_codex_home: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::create_dir_all(codex_home)?; + std::fs::set_permissions(codex_home, std::fs::Permissions::from_mode(0o700))?; + link_auth(codex_home, user_codex_home) +} + +/// Makes `auth.json` in the resident Codex home a symlink to the user's, so both share one login and either one's +/// token refresh is the other's. Codex 0.162.0 reads it with a plain open, refreshes by reloading it first (taking a +/// token the other side already refreshed), and saves by truncating and rewriting it in place, all through the link. +/// Anything else found there (a copy, a link elsewhere) is replaced, on every start. Without a login in the user's +/// home the link dangles and the session reports "not logged in". +fn link_auth(codex_home: &Path, user_codex_home: &Path) -> std::io::Result<()> { + let link = codex_home.join("auth.json"); + let target = user_codex_home.join("auth.json"); + if std::fs::read_link(&link).is_ok_and(|current| current == target) { + return Ok(()); + } + match std::fs::remove_file(&link) { + Ok(()) => info!("replaced the resident Codex home's auth.json with a link to the user's"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + std::os::unix::fs::symlink(&target, &link) +} + +/// The user's Codex memory summary (`memories/memory_summary.md`), cut at Codex's own cap; None if there is none. +/// Read again at every start, so it follows the user's Codex as it consolidates. +fn memory_summary(user_codex_home: &Path) -> Option { + let text = std::fs::read_to_string(user_codex_home.join("memories").join("memory_summary.md")).ok()?; + let text = text.trim(); + let end = text.floor_char_boundary(MEMORY_SUMMARY_MAX_BYTES); + (!text.is_empty()).then(|| text[..end].to_owned()) +} + +/// Lines of the user's Codex memories (`MEMORY.md`, `raw_memories.md` and `rollout_summaries/`, as the user's Codex +/// would search them) containing any word of `query`, case-insensitive, each with the line before and after. Only +/// reads: the daemon never writes the user's memories. +fn search_memories(root: &Path, query: &str) -> String { + let words: Vec = query.split_whitespace().map(str::to_lowercase).collect(); + if words.is_empty() { + return "query is empty".to_owned(); + } + let mut files = vec![root.join("MEMORY.md"), root.join("raw_memories.md")]; + if let Ok(entries) = std::fs::read_dir(root.join("rollout_summaries")) { + let mut summaries: Vec = entries.filter_map(Result::ok).map(|entry| entry.path()).collect(); + summaries.sort(); + files.extend(summaries); + } + let mut out = String::new(); + 'files: for file in files { + let Ok(text) = std::fs::read_to_string(&file) else { continue }; + let lines: Vec<&str> = text.lines().collect(); + let name = file.strip_prefix(root).unwrap_or(&file).display().to_string(); + let mut shown_until = 0; + for (index, line) in lines.iter().enumerate() { + let lower = line.to_lowercase(); + if index < shown_until || !words.iter().any(|word| lower.contains(word.as_str())) { + continue; + } + let (from, to) = (index.saturating_sub(1).max(shown_until), (index + 2).min(lines.len())); + let block = format!("{name}:{}\n{}\n\n", from + 1, lines[from..to].join("\n")); + if out.len() + block.len() > MEMORY_SEARCH_MAX_BYTES { + out.push_str("(more matches left out)"); + break 'files; + } + out.push_str(&block); + shown_until = to; + } + } + if out.is_empty() { "no matches".to_owned() } else { out } +} + +/// A turn from `thread/turns/list` as an exchange: the user's text and what was said through `speak`. +fn exchange_of(turn: &Value) -> Option { + let items = turn.get("items")?.as_array()?; + let said = items + .iter() + .filter(|item| item["type"] == "userMessage") + .flat_map(|item| item["content"].as_array().cloned().unwrap_or_default()) + .filter_map(|input| input["text"].as_str().map(str::to_owned)) + .collect::>() + .join(" "); + let replies = items + .iter() + .filter(|item| item["type"] == "dynamicToolCall" && item["tool"] == "speak") + .filter_map(|item| item.pointer("/arguments/text").and_then(Value::as_str).map(str::to_owned)) + .collect(); + (!said.is_empty()).then_some(Exchange { said, replies }) +} + fn load(path: &Path) -> Saved { std::fs::read_to_string(path) .ok() @@ -646,6 +844,18 @@ pub(crate) mod tests { continue; } "thread/resume" => json!({"thread": {"id": message["params"]["threadId"]}}), + "thread/turns/list" => json!({"data": [ + {"id": "turn-2", "items": [ + {"type": "userMessage", "id": "a", "content": [{"type": "text", "text": "and now?"}]}, + {"type": "dynamicToolCall", "id": "b", "tool": "list_activities", "arguments": {}}, + {"type": "dynamicToolCall", "id": "c", "tool": "speak", "arguments": {"text": "Still one."}}, + ]}, + {"id": "turn-1", "items": [ + {"type": "userMessage", "id": "d", "content": [{"type": "text", "text": "what's up?"}]}, + {"type": "dynamicToolCall", "id": "e", "tool": "speak", "arguments": {"text": "One task."}}, + {"type": "dynamicToolCall", "id": "f", "tool": "speak", "arguments": {"text": "It's fine."}}, + ]}, + ], "nextCursor": null}), "turn/start" => { turns += 1; let turn_id = format!("turn-{turns}"); @@ -693,9 +903,13 @@ pub(crate) mod tests { dir } + /// A resident session on the fake app-server, whose user's Codex home is `user-codex/` in `dir`. pub(crate) fn resident(dir: &Path, script: &Shared, activities: Arc>) -> Resident { let script = script.clone(); - Resident::new(true, dir, Launcher::Fake(Box::new(move || fake_server(script.clone()))), activities) + let mut resident = + Resident::new(true, dir, Launcher::Fake(Box::new(move || fake_server(script.clone()))), activities); + resident.user_codex_home = dir.join("user-codex"); + resident } pub(crate) fn logged_in() -> Shared { @@ -730,7 +944,7 @@ pub(crate) mod tests { assert!(start["developerInstructions"].as_str().unwrap().contains("speak")); let tools: Vec<&str> = start["dynamicTools"].as_array().unwrap().iter().map(|tool| tool["name"].as_str().unwrap()).collect(); - assert_eq!(tools, ["speak", "list_activities"]); + assert_eq!(tools, ["speak", "list_activities", "search_memories"]); assert_eq!(received[4]["params"], json!({"threadId": "new-thread", "input": [{"type": "text", "text": "hello"}]})); assert!(dir.join("resident").is_dir()); assert_eq!(load(&dir.join("resident.json")).thread_id.as_deref(), Some("new-thread")); @@ -906,6 +1120,139 @@ pub(crate) mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn the_codex_home_is_private_and_shares_the_users_login_through_a_link() { + use std::os::unix::fs::PermissionsExt; + let dir = temp_dir("home"); + std::fs::create_dir_all(dir.join("user-codex")).unwrap(); + std::fs::write(dir.join("user-codex/auth.json"), "{}").unwrap(); + resident(&dir, &logged_in(), Arc::default()).say("hi").await.unwrap(); + let home = dir.join(CODEX_HOME_DIR); + assert_eq!(std::fs::metadata(&home).unwrap().permissions().mode() & 0o777, 0o700); + assert_eq!(std::fs::read_link(home.join("auth.json")).unwrap(), dir.join("user-codex/auth.json")); + // Nothing else is put there: no config.toml. + assert_eq!(std::fs::read_dir(&home).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_copied_or_misdirected_login_is_replaced_by_the_link_on_every_start() { + let dir = temp_dir("relink"); + let (home, user) = (dir.join("home"), dir.join("user")); + std::fs::create_dir_all(&user).unwrap(); + std::fs::write(user.join("auth.json"), "{\"tokens\":\"user\"}").unwrap(); + let link = home.join("auth.json"); + + prepare_codex_home(&home, &user).unwrap(); + assert_eq!(std::fs::read_link(&link).unwrap(), user.join("auth.json")); + prepare_codex_home(&home, &user).unwrap(); + assert_eq!(std::fs::read_link(&link).unwrap(), user.join("auth.json")); + + // A diverged copy, as a write by rename would leave behind. + std::fs::remove_file(&link).unwrap(); + std::fs::write(&link, "{\"tokens\":\"stale\"}").unwrap(); + prepare_codex_home(&home, &user).unwrap(); + assert_eq!(std::fs::read_link(&link).unwrap(), user.join("auth.json")); + assert_eq!(std::fs::read_to_string(&link).unwrap(), "{\"tokens\":\"user\"}"); + + std::fs::remove_file(&link).unwrap(); + std::os::unix::fs::symlink(dir.join("elsewhere.json"), &link).unwrap(); + prepare_codex_home(&home, &user).unwrap(); + assert_eq!(std::fs::read_link(&link).unwrap(), user.join("auth.json")); + + // A write through the link, as Codex refreshes tokens (truncate and rewrite in place), lands in the user's. + let mut file = std::fs::OpenOptions::new().write(true).truncate(true).open(&link).unwrap(); + std::io::Write::write_all(&mut file, b"{}").unwrap(); + assert_eq!(std::fs::read_to_string(user.join("auth.json")).unwrap(), "{}"); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn the_users_memories_are_read_into_the_instructions_and_searched_but_never_written() { + let dir = temp_dir("memories"); + let memories = dir.join("user-codex/memories"); + std::fs::create_dir_all(memories.join("rollout_summaries")).unwrap(); + std::fs::write(memories.join("memory_summary.md"), "The user's cat is called Biscuit.\n").unwrap(); + std::fs::write(memories.join("MEMORY.md"), "# Pets\nCat: Biscuit\nlikes boxes\n\n# Work\nRust daemon\n").unwrap(); + std::fs::write(memories.join("rollout_summaries/a.md"), "nothing here\nbiscuit again\n").unwrap(); + let before: Vec<_> = walk(&memories); + let script = logged_in(); + script.lock().unwrap().turn_requests = vec![ + tool_call("search_memories", json!({"query": "BISCUIT"})), + tool_call("search_memories", json!({"query": "zebra"})), + ]; + resident(&dir, &script, Arc::default()).say("what's my cat called?").await.unwrap(); + + let start = script.lock().unwrap().received[3]["params"].clone(); + let instructions = start["developerInstructions"].as_str().unwrap(); + assert!(instructions.contains("\nThe user's cat is called Biscuit.\n")); + let answers = script.lock().unwrap().answers.clone(); + let found = answers[0]["result"]["contentItems"][0]["text"].as_str().unwrap(); + assert!(found.contains("MEMORY.md:1\n# Pets\nCat: Biscuit\nlikes boxes"), "{found}"); + assert!(found.contains("rollout_summaries/a.md:1\nnothing here\nbiscuit again"), "{found}"); + assert!(!found.contains("Rust daemon")); + assert_eq!(answers[1]["result"]["contentItems"][0]["text"], "no matches"); + assert_eq!(walk(&memories), before, "the memories folder was changed"); + let _ = std::fs::remove_dir_all(dir); + } + + /// Every path under `root` with its contents. + fn walk(root: &Path) -> Vec<(PathBuf, Vec)> { + let mut found = Vec::new(); + for entry in std::fs::read_dir(root).unwrap().map(Result::unwrap) { + let path = entry.path(); + if path.is_dir() { + found.push((path.clone(), Vec::new())); + found.extend(walk(&path)); + } else { + found.push((path.clone(), std::fs::read(&path).unwrap())); + } + } + found.sort(); + found + } + + #[test] + fn without_memories_the_instructions_say_nothing_of_them_and_a_long_summary_is_cut() { + assert!(!instructions(None, None).contains("memory")); + let dir = temp_dir("summary"); + std::fs::create_dir_all(dir.join("memories")).unwrap(); + assert_eq!(memory_summary(&dir), None); + std::fs::write(dir.join("memories/memory_summary.md"), "记".repeat(5_000)).unwrap(); + let summary = memory_summary(&dir).unwrap(); + assert!(summary.len() <= MEMORY_SUMMARY_MAX_BYTES && summary.len() > MEMORY_SUMMARY_MAX_BYTES - 3); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn the_history_is_read_from_the_thread_oldest_first() { + let dir = temp_dir("history"); + let script = logged_in(); + let resident = resident(&dir, &script, Arc::default()); + assert_eq!(resident.history(5).await.unwrap(), [], "no thread yet, so nothing is started"); + assert_eq!(script.lock().unwrap().starts, 0); + resident.say("hi").await.unwrap(); + let history = resident.history(2).await.unwrap(); + assert_eq!( + history, + [ + Exchange { said: "what's up?".to_owned(), replies: vec!["One task.".to_owned(), "It's fine.".to_owned()] }, + Exchange { said: "and now?".to_owned(), replies: vec!["Still one.".to_owned()] }, + ] + ); + let list = script.lock().unwrap().received.last().unwrap()["params"].clone(); + assert_eq!(list, json!({"threadId": "new-thread", "limit": 2, "sortDirection": "desc", "itemsView": "full"})); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn codex_runs_in_the_resident_codex_home() { + let command = codex_command(Path::new("/bin/codex"), Path::new("/state/resident-codex")); + let home = command.as_std().get_envs().find(|(name, _)| *name == "CODEX_HOME").and_then(|(_, value)| value); + assert_eq!(home, Some(std::ffi::OsStr::new("/state/resident-codex"))); + } + #[test] fn the_app_server_starts_without_plugins_apps_built_in_tools_or_the_users_mcp_servers() { let args = app_server_args(&tool_overrides(), &["node_repl".to_owned(), "computer-use".to_owned()]); @@ -918,7 +1265,10 @@ pub(crate) mod tests { "features.shell_tool=false", "features.unified_exec=false", "features.multi_agent=false", + "features.memories=false", "web_search=\"disabled\"", + "cli_auth_credentials_store=\"file\"", + "memories.generate_memories=false", "mcp_servers.node_repl.enabled=false", "mcp_servers.computer-use.enabled=false", ] { diff --git a/daemon/src/voice.rs b/daemon/src/voice.rs index 4828a65d..4ba32b7d 100644 --- a/daemon/src/voice.rs +++ b/daemon/src/voice.rs @@ -1085,12 +1085,12 @@ pub mod tests { /// `VOICE` lines, recognised by the real recognizer and answered by the real resident session. Run by hand: /// /// ```sh - /// VIBEBUDDY_E2E_WAV=mic.wav VIBEBUDDY_STT_ENV_FILE=~/.vibebuddy-dev-ai.env VIBEBUDDY_CODEX=codex-without-hooks \ + /// VIBEBUDDY_E2E_WAV=mic.wav VIBEBUDDY_STT_ENV_FILE=~/.vibebuddy-dev-ai.env \ /// cargo test -p vibebuddyd e2e_ -- --ignored --nocapture /// ``` /// - /// `VIBEBUDDY_CODEX` should start Codex with `-c features.hooks=false`, or the turn's hooks reach the daemon on - /// 7331 and the box shows the test as an Activity. The thread lives in a temporary state directory. + /// The thread and its Codex home live in a temporary state directory; the user's hooks don't load there, so + /// nothing reaches the daemon on 7331. #[tokio::test(flavor = "multi_thread")] #[ignore = "uses the network, a speech recognizer's credentials and the user's Codex"] async fn e2e_a_real_recording_through_the_real_recognizer_and_resident_session() { diff --git a/docs/adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md b/docs/adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md index b83def48..72a383fa 100644 --- a/docs/adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md +++ b/docs/adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md @@ -25,7 +25,7 @@ The box has a microphone, and with Wi-Fi and the Relay (ADR-0012) it can sit awa - ADR-0009's App still calls no model. The recognition and synthesis service lives in `vibebuddy-cloud` beside the Relay but is not the Relay; it calls the TTS vendor of each Character's voice (ElevenLabs, Volcano Engine Doubao) with our keys, and stores neither audio nor text. - Recognition is Workers AI Whisper (`whisper-large-v3-turbo`), which detects the language itself. A box linked over USB or the local network streams its audio over the link to the daemon, which calls recognition and sends the spoken reply's audio back the same way, so a box on the cable needs no Wi-Fi; over the Relay the box calls the voice service itself (ADR-0018). A user may set their own OpenAI-compatible transcription endpoint and key instead; the daemon calls it, the key never reaches us, and with it set audio goes over the link even over the Relay (ADR-0015 keeps it private). Box and computer messages: docs/protocol.md, "Voice commands". - Voice is offered only to signed-in users, so accounts come first; whether it is paid is decided with accounts. Off by default. -- Delivery is per Agent and belongs to its Adapter, which becomes two-way. For Codex the daemon is an app-server client: `thread/start` once, `thread/resume` after a restart, `turn/start` per voice command; the spoken reply arrives as a call to a client-defined `speak` tool (`dynamicTools`), so no MCP server is needed. The thread runs read-only with approvals declined. The user can open it in the Codex App with `codex://threads/`. Verified on 2026-10-13 with codex-cli 0.162.0: existing ChatGPT login reused, about 10 s per turn, memory kept across a process restart, file writes refused. +- Delivery is per Agent and belongs to its Adapter, which becomes two-way. For Codex the daemon is an app-server client: `thread/start` once, `thread/resume` after a restart, `turn/start` per voice command; the spoken reply arrives as a call to a client-defined `speak` tool (`dynamicTools`), so no MCP server is needed. The thread runs read-only with approvals declined. It lives in a Codex home of the daemon's own, sharing the user's login through a link to their `auth.json` and reading their Codex memories only through the daemon, because the ChatGPT App's app-server holds the writer lock of every thread in `~/.codex` it lists; the Codex App therefore can't open it, and `vibebuddy resident` prints its last exchanges instead. Verified on 2026-10-13 with codex-cli 0.162.0: existing ChatGPT login reused, about 10 s per turn, memory kept across a process restart, file writes refused. - app-server and `dynamicTools` are marked experimental, so the daemon pins the Codex versions it supports. OpenAI permits ChatGPT login for local and open-source apps; before commercial launch we apply for Sign in with ChatGPT. - K0 long becomes push-to-talk; giving up a Pomodoro phase stays in the device menu, which already has it (amends ADR-0007, device menu). - The resident session is one session for good, compacted rather than restarted, so lasting rules belong in a notes file in its working directory. It makes no task cards, announcements or stats. diff --git a/docs/resident-session.md b/docs/resident-session.md index 8f9208bd..a00a55a8 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -8,24 +8,30 @@ It is experimental and off by default. Start `vibebuddyd` with `VIBEBUDDY_RESIDE ```sh vibebuddy say "What are my agents doing?" # prints the spoken reply(ies); the time taken goes to stderr -vibebuddy resident # prints codex://threads/, to open the thread in the Codex App +vibebuddy resident [n] # prints the thread id and the last n (default 5) commands with their replies ``` +The thread lives in a Codex home of the daemon's own (below), so the Codex App can't open it; `vibebuddy resident` is how to look back at it. + `vibebuddy` is a second binary of the daemon package (`daemon/src/bin/vibebuddy.rs`). Like every client, it only talks to the daemon's HTTP API, at `VIBEBUDDY_BIND` (default `127.0.0.1:7331`): | Endpoint | Does | | --- | --- | | `POST /v1/resident/say` `{"text"}` | Runs one turn; answers `{"thread_id","replies":[...],"elapsed_ms"}`. 404 when off, 503 when Codex is missing or not logged in, 504 after two minutes. | -| `GET /v1/resident` | `{"enabled","thread_id","url"}` | +| `GET /v1/resident` | `{"enabled","thread_id"}` | +| `GET /v1/resident/history?limit=n` | `{"thread_id","exchanges":[{"said","replies":[...]}]}`, oldest first, read from the thread with `thread/turns/list` (default 5, at most 50). Starts the app-server if it isn't running; the daemon keeps no record of its own. | ## How it works `daemon/src/app_server.rs` is a newline-delimited JSON-RPC client for `codex app-server` on stdio; `daemon/src/resident.rs` is the session on top of it. +- **Its own Codex home.** The app-server runs with `CODEX_HOME` set to `resident-codex/` in the daemon's state directory (mode 0700), never the user's. The ChatGPT App's own app-server takes the writer lock (`thread-writer-locks/.lock`) of every thread in `~/.codex` it lists whenever the lock is free, and keeps it, even for threads nobody opened; a resident thread there could not be resumed while the App runs. Nothing lists this home but the daemon. The home holds no `config.toml`: everything is a `-c` override (below). +- **The user's login, shared.** The user's Codex home is `$CODEX_HOME` if the daemon has it, else `~/.codex`. On every start `resident-codex/auth.json` is made a symlink to its `auth.json` (whatever else is there, a copy or a link elsewhere, is replaced), so there is one login and one set of tokens. Checked in the codex-cli 0.162.0 source (`codex-rs/login/src/auth/storage.rs`, `manager.rs`): the file store reads `auth.json` with a plain open, a refresh first reloads it and keeps a token the other side already refreshed, and a save truncates and rewrites it in place, so all of it goes through the link and the link survives; a rename on the user's side doesn't matter, since the link is by path. `-c cli_auth_credentials_store="file"` pins that store: the keychain store keys its entry by the Codex home's path, so it would never find the user's login, and its save deletes `auth.json`. A login kept in the keychain therefore can't be shared: the session reports "not logged in" and the log says why. Two Codex processes refreshing at the same moment can still race, as the user's CLI and the ChatGPT App already can on the same file. Credentials are never logged. - **Starting.** On the first voice command, and on the next one after the app-server dies, the daemon runs the `codex` it finds (`VIBEBUDDY_CODEX`, else PATH, else `~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, since launchd's PATH is bare). It sends `initialize` (`experimentalApi: true`) and `initialized`, then `account/read`: with no account it stops with "not logged in". It never starts a login. -- **One thread for good.** `thread/start` once, with `cwd` set to `resident/` in the daemon's state directory, `sandbox: "read-only"`, `approvalPolicy: "on-request"`, the developer instructions and the dynamic tools. The thread id is kept in `resident.json` beside it, and every later start sends `thread/resume` with the same settings (the tools survive a resume; the instructions are sent again). Only a thread that no longer exists (`no rollout found`) is replaced by a new one, saved, with both ids in the log. A thread has one writer at a time, and the Codex App becomes its writer as soon as the user opens it there (it holds `~/.codex/thread-writer-locks/.lock`): the resume then fails with `already has an active writer`, the voice command fails as `unavailable`, and the next one tries again. The thread is never replaced for that, so there stays one resident session. -- **Only our tools.** The app-server is started with the user's own Codex config, minus everything that gives a thread tools of its own: `-c features.=false` for plugins, apps (connectors), the shell and exec tools, sub-agents, goals, memories, image generation and viewing, the browser and computer use, sleep, tool suggestions and skill search, plus `web_search="disabled"`. An override can't empty `mcp_servers` (it merges), so the daemon first asks `codex mcp list --json`, under the same overrides, which servers would be on, and adds `-c mcp_servers..enabled=false` for each. Overrides are `-c` rather than `--disable`, which refuses a feature name another Codex version doesn't know. The user's global `AGENTS.md` and hooks stay: the hooks are filtered (below). Checked on 0.162.0: `mcpServerStatus/list` goes from seven servers (one with 365 app tools) to two, both off with no tools, and the thread's tool list is `speak`, `list_activities` and what Codex always adds and has no switch for: `apply_patch` (refused by the read-only sandbox, its approval declined), a clock, and the MCP resource readers (no server is on to read from). -- **Tools.** `speak({text})` is the spoken reply: the daemon hands it on the moment it is called (`Resident::say_streaming`), to the box as `voice.reply`, or collected for `POST /v1/resident/say`. `list_activities({})` returns the Activities the aggregator already holds, as JSON: agent, task-card title, project, `working`/`needs_input`, host, seconds in that state and since the last event. There are no tools that run commands or write files. +- **One thread for good.** `thread/start` once, with `cwd` set to `resident/` in the daemon's state directory, `sandbox: "read-only"`, `approvalPolicy: "on-request"`, the developer instructions and the dynamic tools. The thread id is kept in `resident.json` beside it, and every later start sends `thread/resume` with the same settings (the tools survive a resume; the instructions are sent again). Only a thread that no longer exists (`no rollout found`) is replaced by a new one, saved, with both ids in the log. That is also what happens once to a thread saved before the session had its own home: it is in `~/.codex`, so the first start in `resident-codex/` begins a new thread; the old one's rollout stays where it is. Should another Codex process ever be the thread's writer, the resume fails with `already has an active writer`, the voice command fails as `unavailable`, and the next one tries again; the thread is never replaced for that, so there stays one resident session. +- **Only our tools.** In its own home none of the user's Codex config loads: not their `config.toml` (model, MCP servers, profiles), `AGENTS.md`, hooks, plugins or skills. Codex's own defaults still give a thread tools, so every start passes `-c features.=false` for plugins, apps (connectors), the shell and exec tools, sub-agents, goals, memories, image generation and viewing, the browser and computer use, sleep, tool suggestions and skill search, plus `web_search="disabled"`; all but `memories` are on by default in an empty home (`codex features list`). System and managed config (`/etc/codex`, MDM) still load and may add MCP servers, and an override can't empty `mcp_servers` (it merges), so the daemon first asks `codex mcp list --json`, under the same overrides, which servers would be on, and adds `-c mcp_servers..enabled=false` for each (on this Mac: none). Overrides are `-c` rather than `--disable`, which refuses a feature name another Codex version doesn't know. With no `model` set, Codex picks its default model (`gpt-6.1-sol` on 0.162.0 with a ChatGPT login). The thread's tool list is `speak`, `list_activities`, `search_memories` and what Codex always adds and has no switch for: `apply_patch` (refused by the read-only sandbox, its approval declined), a clock, and the MCP resource readers (no server is on to read from). +- **Memories, read-only.** The session reads the user's Codex memories (`/memories/`) through the daemon, and Codex in its home never touches them. Codex's own memory feature (`features.memories`) can't do that safely: it gates the read path (injecting `memory_summary.md`) and, at every thread start whatever `generate_memories` says, the memory pipeline, whose consolidation phase rewrites `raw_memories.md` and prunes `rollout_summaries/` from the state database of the Codex home it runs in; in the resident home that database is empty, so on a linked folder it would empty the user's memories, and on a copy it would still run a consolidation agent each time. Its dedicated memory tools also include one that writes a note into the folder. So the feature stays off (and `memories.generate_memories=false`, should it come on), and the daemon does what the read path does: at every start or resume it puts `memory_summary.md`, cut at Codex's own cap of 10,000 bytes, into the developer instructions, and the `search_memories({query})` tool returns the lines of `MEMORY.md`, `raw_memories.md` and `rollout_summaries/` containing any of the query's words, each with the line before and after (at most 8,000 bytes). Both only read. Checked on 2026-10-13: a question answered from the summary, and one whose answer is only in `MEMORY.md`, answered after a `search_memories` call; the memories folder unchanged. +- **Tools.** `speak({text})` is the spoken reply: the daemon hands it on the moment it is called (`Resident::say_streaming`), to the box as `voice.reply`, or collected for `POST /v1/resident/say`. `list_activities({})` returns the Activities the aggregator already holds, as JSON: agent, task-card title, project, `working`/`needs_input`, host, seconds in that state and since the last event. `search_memories({query})` searches the user's Codex memories (above). There are no tools that run commands or write files. Dynamic tools are fixed when a thread starts, so a thread started before `search_memories` existed doesn't have it. - **Declining.** Every approval or question the session raises is declined: command and file-change approvals (`decline`, or `denied` on the legacy methods), permission requests (nothing granted), MCP elicitations (`decline`) and user-input requests (no answers). `currentTime/read` is answered; anything else gets a JSON-RPC "method not found". - **Turns.** `turn/start` with the text, then the daemon answers tool calls until `turn/completed` for that turn. One turn at a time: a command that arrives during another waits for it, in order, rather than being rejected, since two quick sentences are both meant to be answered. A turn that takes over two minutes is interrupted (`turn/interrupt`). - **Persona.** `instructions()` in `resident.rs` takes the Character's persona; until Characters carry one, it is `None` and the buddy speaks plainly. @@ -39,7 +45,7 @@ The box side of push-to-talk is in [`protocol.md`](protocol.md), "Voice commands - **A turn.** The audio goes to the recognizer as a 24 kHz 16-bit mono WAV file; the text goes to the box as `voice.heard` and to the resident session as a voice command; each `speak` goes to the box as `voice.reply` as soon as it is called, and its audio follows as `voice.audio` (see "Speech synthesis" below); `voice.done` follows `turn/completed` and the last reply's audio `end`. Text is cut at a character, with an ellipsis, to fit the 1024-byte line. Errors: `not_heard` (no audio arrived, or the recognizer heard nothing), `unavailable` (voice is off, recognition failed, or the resident session failed, with the reason as `message`), `busy` (a turn started while another is still being answered: sent right after its `VOICE START`, so the box stops recording, and the rest of that turn is dropped; unlike `vibebuddy say`, a voice turn doesn't wait). A typed command and a voice turn still queue behind each other in the resident session. - **Timing.** A 5 s recording, checked end to end on 2026-10-13 (recognition by Workers AI, codex-cli 0.162.0, app-server started cold): `voice.heard` 2.2 s after `VOICE END`, the spoken reply at 10.1 s, `voice.done` at 14.0 s. -The end-to-end check is an ignored test, `e2e_a_real_recording_through_the_real_recognizer_and_resident_session` in `voice.rs`: it turns the left channel of a WAV file into `VOICE` lines with firmware-core's own voice command code (as do the other voice tests, so they hold the daemon to what the box writes) and feeds them through the real recognizer and the real resident session, in a temporary state directory. Its doc comment says how to run it; give it a `VIBEBUDDY_CODEX` that adds `-c features.hooks=false`, or the turn's hooks reach the daemon on 7331 and show up on the box. +The end-to-end check is an ignored test, `e2e_a_real_recording_through_the_real_recognizer_and_resident_session` in `voice.rs`: it turns the left channel of a WAV file into `VOICE` lines with firmware-core's own voice command code (as do the other voice tests, so they hold the daemon to what the box writes) and feeds them through the real recognizer and the real resident session, in a temporary state directory. Its doc comment says how to run it. ### Speech recognition @@ -73,16 +79,18 @@ Each spoken reply is also synthesized and streamed to the box (`daemon/src/synth ## Never an Activity -The user's Codex hooks fire for the resident thread too: its `UserPromptSubmit`, `PostToolUse` and `Stop` reach `/v1/codex-hooks` like any session's. The Codex adapter drops a hook whose session or thread is the resident thread, or whose `cwd` is inside the resident directory (which also covers the thread opened in the Codex App), before anything is recorded: no card, announcement, stats, CI watch or K2 target. This works even with the resident session switched off. +The user's Codex hooks live in their Codex home, so they don't load for the resident thread. Hooks from system or managed config still could, so the Codex adapter still drops a hook whose session or thread is the resident thread, or whose `cwd` is inside the resident directory, before anything is recorded: no card, announcement, stats, CI watch or K2 target. This works even with the resident session switched off. ## Versions -app-server and `dynamicTools` are experimental. This was built and checked against codex-cli 0.162.0 (`TESTED_CODEX_VERSION`); another version logs a warning and is still tried. The version last used is written to `resident.json`. +app-server and `dynamicTools` are experimental, and so is relying on how Codex stores `auth.json` and memories. This was built and checked against codex-cli 0.162.0 (`TESTED_CODEX_VERSION`); another version logs a warning and is still tried. The version last used is written to `resident.json`. ## Privacy `list_activities` sends the task-card titles and project names the daemon holds, with each Activity's agent, state, host app and ages, to the model provider the user's Codex uses, as part of the resident thread. Until now these stayed on the computer and the box. Prompts and replies are not sent: the daemon never holds them. +The user's Codex memory summary, and whatever `search_memories` finds, go to the same provider as part of the resident thread, as they already do in the user's own Codex sessions. + A voice command's audio leaves the computer for the speech recognizer: in development that is Cloudflare Workers AI, under the developer's own account; with the user's own recognizer, only that endpoint (and its key goes nowhere else). The recognised text then goes to the user's Codex model provider as the voice command. The daemon keeps neither the audio nor the text once the turn is over, and logs neither. Each spoken reply's text, which may name what the Agents are doing, goes to the voice service to be synthesized (in development, staging), with the voice name; the service passes it on to the speech engine behind it (Edge voices for now). The audio it returns goes only to the box. The daemon logs the voice, timings and byte counts, never the text. From b4ee5b84998f648a6af37d3e7789ec58b462f839 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 19:27:42 +0800 Subject: [PATCH 30/51] Answer voice commands sooner The resident session runs on gpt-6-luna at low reasoning effort, starts with the daemon instead of on the first command, and speech synthesis reuses one HTTPS agent. Co-Authored-By: Claude Opus 5.5 --- daemon/src/main.rs | 3 +++ daemon/src/resident.rs | 26 +++++++++++++++++++++++++- daemon/src/synthesis.rs | 6 +++++- docs/resident-session.md | 4 ++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 4c9ad78a..9131ed8a 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -265,6 +265,9 @@ async fn main() { Arc::new(resident::Resident::new(enabled, dir, resident::Launcher::Codex, state.activities.clone())) }); state.voice = Arc::new(voice::Voice::new(speech::from_env(), state.resident.clone()).with_synthesizer(synthesis::from_env())); + if let Some(resident) = state.resident.clone() { + tokio::spawn(async move { resident.warm().await }); + } state.device.lock().await.pin = pin; state.updates = Arc::new(Mutex::new(Updates::new( updates::Source::configured(), diff --git a/daemon/src/resident.rs b/daemon/src/resident.rs index 989f894f..256d5480 100644 --- a/daemon/src/resident.rs +++ b/daemon/src/resident.rs @@ -207,6 +207,19 @@ impl Resident { result } + /// Starts the app-server and resumes the thread ahead of the first voice command, which otherwise waits for + /// both. Does nothing when switched off or already running; a failure is logged and the next command tries again. + pub async fn warm(&self) { + if !self.enabled { + return; + } + let mut slot = self.session.lock().await; + match self.running(&mut slot).await { + Ok(session) => info!(thread = %session.thread_id, "the resident session is ready"), + Err(error) => warn!(%error, "the resident session could not be started ahead of time"), + } + } + /// The last `limit` voice commands and their spoken replies, oldest first, read from the thread through the /// app-server (the daemon keeps no record of its own). Empty while there is no thread yet. pub async fn history(&self, limit: usize) -> Result, ResidentError> { @@ -589,10 +602,19 @@ const FEATURES_OFF: [&str; 15] = [ /// path) and, on saving, would delete the link. /// - `memories.generate_memories=false`: the resident thread is never summarised into memories, should the feature /// come on. +/// - `model="gpt-6-luna"`, `model_reasoning_effort="low"`: a voice command waits on the answer, and the resident +/// session only talks and hands work on. Measured on 2026-10-13, a turn's first `speak` came 2.7 s in on +/// gpt-6-luna at low, against 4.3 s on gpt-6.1-sol at high. fn tool_overrides() -> Vec { let mut args: Vec = FEATURES_OFF.iter().flat_map(|feature| ["-c".to_owned(), format!("features.{feature}=false")]).collect(); - for setting in ["web_search=\"disabled\"", "cli_auth_credentials_store=\"file\"", "memories.generate_memories=false"] { + for setting in [ + "web_search=\"disabled\"", + "cli_auth_credentials_store=\"file\"", + "memories.generate_memories=false", + "model=\"gpt-6-luna\"", + "model_reasoning_effort=\"low\"", + ] { args.extend(["-c".to_owned(), setting.to_owned()]); } args @@ -1269,6 +1291,8 @@ pub(crate) mod tests { "web_search=\"disabled\"", "cli_auth_credentials_store=\"file\"", "memories.generate_memories=false", + "model=\"gpt-6-luna\"", + "model_reasoning_effort=\"low\"", "mcp_servers.node_repl.enabled=false", "mcp_servers.computer-use.enabled=false", ] { diff --git a/daemon/src/synthesis.rs b/daemon/src/synthesis.rs index f493df0a..cd90d849 100644 --- a/daemon/src/synthesis.rs +++ b/daemon/src/synthesis.rs @@ -46,7 +46,11 @@ impl VoiceService { let host = url.split('/').nth(2).unwrap_or_default().to_owned(); let body = json!({"text": text, "voice": voice}).to_string(); // The seam for the account session: its Authorization header goes here once the service asks for one. - let mut response = crate::speech::agent(TIMEOUT) + // One agent for every reply, so its connection to the service is reused: a fresh TLS handshake cost about + // half a second a reply. + static AGENT: std::sync::OnceLock = std::sync::OnceLock::new(); + let mut response = AGENT + .get_or_init(|| crate::speech::agent(TIMEOUT)) .post(&url) .header("Content-Type", "application/json") .send(body.as_bytes()) diff --git a/docs/resident-session.md b/docs/resident-session.md index a00a55a8..945460af 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -94,3 +94,7 @@ The user's Codex memory summary, and whatever `search_memories` finds, go to the A voice command's audio leaves the computer for the speech recognizer: in development that is Cloudflare Workers AI, under the developer's own account; with the user's own recognizer, only that endpoint (and its key goes nowhere else). The recognised text then goes to the user's Codex model provider as the voice command. The daemon keeps neither the audio nor the text once the turn is over, and logs neither. Each spoken reply's text, which may name what the Agents are doing, goes to the voice service to be synthesized (in development, staging), with the voice name; the service passes it on to the speech engine behind it (Edge voices for now). The audio it returns goes only to the box. The daemon logs the voice, timings and byte counts, never the text. + +## Latency + +A voice command waits on recognition, the resident session and synthesis in turn, so the resident session runs on `gpt-6-luna` at `low` reasoning effort (set with `-c`; the user's own model settings don't load in its Codex home): on 2026-10-13 a turn's first `speak` came 2.7 s in, against 4.3 s on `gpt-6.1-sol` at `high`. The daemon starts the app-server and resumes the thread when it starts, not on the first command, and the synthesizer keeps one HTTPS agent so its connection to the voice service is reused. From a776283d2efd511a9ca29a08db9f167d87e385a3 Mon Sep 17 00:00:00 2001 From: dragon Date: Tue, 13 Oct 2026 19:27:50 +0800 Subject: [PATCH 31/51] docs: the resident session's model is set, not Codex's default Co-Authored-By: Claude Opus 5.5 --- docs/resident-session.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/resident-session.md b/docs/resident-session.md index 945460af..8837aafa 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -29,7 +29,7 @@ The thread lives in a Codex home of the daemon's own (below), so the Codex App c - **The user's login, shared.** The user's Codex home is `$CODEX_HOME` if the daemon has it, else `~/.codex`. On every start `resident-codex/auth.json` is made a symlink to its `auth.json` (whatever else is there, a copy or a link elsewhere, is replaced), so there is one login and one set of tokens. Checked in the codex-cli 0.162.0 source (`codex-rs/login/src/auth/storage.rs`, `manager.rs`): the file store reads `auth.json` with a plain open, a refresh first reloads it and keeps a token the other side already refreshed, and a save truncates and rewrites it in place, so all of it goes through the link and the link survives; a rename on the user's side doesn't matter, since the link is by path. `-c cli_auth_credentials_store="file"` pins that store: the keychain store keys its entry by the Codex home's path, so it would never find the user's login, and its save deletes `auth.json`. A login kept in the keychain therefore can't be shared: the session reports "not logged in" and the log says why. Two Codex processes refreshing at the same moment can still race, as the user's CLI and the ChatGPT App already can on the same file. Credentials are never logged. - **Starting.** On the first voice command, and on the next one after the app-server dies, the daemon runs the `codex` it finds (`VIBEBUDDY_CODEX`, else PATH, else `~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, since launchd's PATH is bare). It sends `initialize` (`experimentalApi: true`) and `initialized`, then `account/read`: with no account it stops with "not logged in". It never starts a login. - **One thread for good.** `thread/start` once, with `cwd` set to `resident/` in the daemon's state directory, `sandbox: "read-only"`, `approvalPolicy: "on-request"`, the developer instructions and the dynamic tools. The thread id is kept in `resident.json` beside it, and every later start sends `thread/resume` with the same settings (the tools survive a resume; the instructions are sent again). Only a thread that no longer exists (`no rollout found`) is replaced by a new one, saved, with both ids in the log. That is also what happens once to a thread saved before the session had its own home: it is in `~/.codex`, so the first start in `resident-codex/` begins a new thread; the old one's rollout stays where it is. Should another Codex process ever be the thread's writer, the resume fails with `already has an active writer`, the voice command fails as `unavailable`, and the next one tries again; the thread is never replaced for that, so there stays one resident session. -- **Only our tools.** In its own home none of the user's Codex config loads: not their `config.toml` (model, MCP servers, profiles), `AGENTS.md`, hooks, plugins or skills. Codex's own defaults still give a thread tools, so every start passes `-c features.=false` for plugins, apps (connectors), the shell and exec tools, sub-agents, goals, memories, image generation and viewing, the browser and computer use, sleep, tool suggestions and skill search, plus `web_search="disabled"`; all but `memories` are on by default in an empty home (`codex features list`). System and managed config (`/etc/codex`, MDM) still load and may add MCP servers, and an override can't empty `mcp_servers` (it merges), so the daemon first asks `codex mcp list --json`, under the same overrides, which servers would be on, and adds `-c mcp_servers..enabled=false` for each (on this Mac: none). Overrides are `-c` rather than `--disable`, which refuses a feature name another Codex version doesn't know. With no `model` set, Codex picks its default model (`gpt-6.1-sol` on 0.162.0 with a ChatGPT login). The thread's tool list is `speak`, `list_activities`, `search_memories` and what Codex always adds and has no switch for: `apply_patch` (refused by the read-only sandbox, its approval declined), a clock, and the MCP resource readers (no server is on to read from). +- **Only our tools.** In its own home none of the user's Codex config loads: not their `config.toml` (model, MCP servers, profiles), `AGENTS.md`, hooks, plugins or skills. Codex's own defaults still give a thread tools, so every start passes `-c features.=false` for plugins, apps (connectors), the shell and exec tools, sub-agents, goals, memories, image generation and viewing, the browser and computer use, sleep, tool suggestions and skill search, plus `web_search="disabled"`; all but `memories` are on by default in an empty home (`codex features list`). System and managed config (`/etc/codex`, MDM) still load and may add MCP servers, and an override can't empty `mcp_servers` (it merges), so the daemon first asks `codex mcp list --json`, under the same overrides, which servers would be on, and adds `-c mcp_servers..enabled=false` for each (on this Mac: none). Overrides are `-c` rather than `--disable`, which refuses a feature name another Codex version doesn't know. The model and reasoning effort are set too (see Latency below). The thread's tool list is `speak`, `list_activities`, `search_memories` and what Codex always adds and has no switch for: `apply_patch` (refused by the read-only sandbox, its approval declined), a clock, and the MCP resource readers (no server is on to read from). - **Memories, read-only.** The session reads the user's Codex memories (`/memories/`) through the daemon, and Codex in its home never touches them. Codex's own memory feature (`features.memories`) can't do that safely: it gates the read path (injecting `memory_summary.md`) and, at every thread start whatever `generate_memories` says, the memory pipeline, whose consolidation phase rewrites `raw_memories.md` and prunes `rollout_summaries/` from the state database of the Codex home it runs in; in the resident home that database is empty, so on a linked folder it would empty the user's memories, and on a copy it would still run a consolidation agent each time. Its dedicated memory tools also include one that writes a note into the folder. So the feature stays off (and `memories.generate_memories=false`, should it come on), and the daemon does what the read path does: at every start or resume it puts `memory_summary.md`, cut at Codex's own cap of 10,000 bytes, into the developer instructions, and the `search_memories({query})` tool returns the lines of `MEMORY.md`, `raw_memories.md` and `rollout_summaries/` containing any of the query's words, each with the line before and after (at most 8,000 bytes). Both only read. Checked on 2026-10-13: a question answered from the summary, and one whose answer is only in `MEMORY.md`, answered after a `search_memories` call; the memories folder unchanged. - **Tools.** `speak({text})` is the spoken reply: the daemon hands it on the moment it is called (`Resident::say_streaming`), to the box as `voice.reply`, or collected for `POST /v1/resident/say`. `list_activities({})` returns the Activities the aggregator already holds, as JSON: agent, task-card title, project, `working`/`needs_input`, host, seconds in that state and since the last event. `search_memories({query})` searches the user's Codex memories (above). There are no tools that run commands or write files. Dynamic tools are fixed when a thread starts, so a thread started before `search_memories` existed doesn't have it. - **Declining.** Every approval or question the session raises is declined: command and file-change approvals (`decline`, or `denied` on the legacy methods), permission requests (nothing granted), MCP elicitations (`decline`) and user-input requests (no answers). `currentTime/read` is answered; anything else gets a JSON-RPC "method not found". From 7a640b63cc219f0d208c5dad582c8189afe4ac7f Mon Sep 17 00:00:00 2001 From: dragon Date: Wed, 14 Oct 2026 14:34:48 +0800 Subject: [PATCH 32/51] Show the conversation with the buddy in both Apps A Conversation tab, on the Mac and on Linux, while the daemon runs the resident session: the last 20 voice commands and the buddy's spoken replies, newest at the bottom, read again when the tab is shown and on Refresh, with empty and error states. Both Apps ask GET /v1/resident each time the daemon comes up and hide the tab while it is off. Co-Authored-By: Claude Opus 5.5 --- CONTEXT.md | 1 + README.md | 2 +- README.zh-CN.md | 2 +- .../zh-Hans.lproj/Localizable.strings | 10 ++ app/Sources/SelfTest/main.swift | 11 ++ app/Sources/VibeBuddy/AppModel.swift | 10 ++ app/Sources/VibeBuddy/ConversationView.swift | 113 ++++++++++++++++ app/Sources/VibeBuddy/DaemonClient.swift | 17 +++ app/Sources/VibeBuddy/SettingsView.swift | 5 + app/Sources/VibeBuddyCore/Resident.swift | 34 +++++ desktop/src/client.rs | 53 ++++++++ desktop/src/main.rs | 121 +++++++++++++++++- docs/app.md | 7 +- docs/images/app-conversation-empty-zh.png | Bin 0 -> 87336 bytes docs/images/app-conversation-error-zh.png | Bin 0 -> 85201 bytes docs/images/app-conversation-zh.png | Bin 0 -> 148676 bytes docs/resident-session.md | 10 +- 17 files changed, 389 insertions(+), 7 deletions(-) create mode 100644 app/Sources/VibeBuddy/ConversationView.swift create mode 100644 app/Sources/VibeBuddyCore/Resident.swift create mode 100644 docs/images/app-conversation-empty-zh.png create mode 100644 docs/images/app-conversation-error-zh.png create mode 100644 docs/images/app-conversation-zh.png diff --git a/CONTEXT.md b/CONTEXT.md index 882490cd..ba158e95 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -168,6 +168,7 @@ Fixed wording for the App's English UI and English logs. Use these words when wr | 语音指令 | voice command | | | 常驻会话 | resident session | Not shown to users so far; in the UI the buddy is the one you talk to | | 口头回复 | spoken reply | | +| 对话 | conversation | The settings tab (Conversation) listing voice commands and the buddy's spoken replies; shown only while the resident session is on | ### Activity lifecycle diff --git a/README.md b/README.md index 3a0562d9..17caa5c4 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,7 @@ cargo run -p vibebuddyd By default it only listens on `127.0.0.1:7331` and finds the box by its Espressif USB Serial/JTAG ID, `VID:PID 303A:1001`. `VIBEBUDDY_BIND` changes the listen address, `VIBEBUDDY_SERIAL_PORT` names a serial port explicitly, and `VIBEBUDDY_USB_SERIAL` picks one box among several identical ones; without it, the daemon connects to the box that has reported VibeBuddy firmware to it before, and when none of several devices is that box, the app asks which one is. Either variable shows up in the app's Device tab, so a pin left set can't pass for a missing box. A box running other firmware, such as Muse, is written nothing. HTTP `202 Accepted` means the event entered the bounded send queue; whether the box actually received it is what the daemon logs from the box's reply. -Voice commands are experimental and off by default: `VIBEBUDDY_RESIDENT_SESSION=1` turns on the resident session, and with a speech recognizer configured the box gets push-to-talk on a long K0. See [`docs/resident-session.md`](docs/resident-session.md). +Voice commands are experimental and off by default: `VIBEBUDDY_RESIDENT_SESSION=1` turns on the resident session, and with a speech recognizer configured the box gets push-to-talk on a long K0. While it is on, Settings has a Conversation tab, on the Mac and on Linux, with your last voice commands and the buddy's spoken replies. See [`docs/resident-session.md`](docs/resident-session.md). The app normally supervises the daemon. On a development machine without the app you can install it as a LaunchAgent with [`packaging/com.vibebuddy.vibebuddyd.plist`](packaging/com.vibebuddy.vibebuddyd.plist), but don't run both: they'd fight over the serial port. For the app, the daemon also serves `GET /v1/status`, SSE `/v1/status/stream`, `/v1/config`, `/v1/device/{identify,volume,choice,screenshot,voice-pack,firmware}` and `/v1/daemon/restart`. diff --git a/README.zh-CN.md b/README.zh-CN.md index d13e9750..ec2ef393 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -256,7 +256,7 @@ cargo run -p vibebuddyd 默认只监听 `127.0.0.1:7331`,并按 Espressif USB Serial/JTAG 的 `VID:PID 303A:1001` 自动发现设备。可用 `VIBEBUDDY_BIND` 修改监听地址、`VIBEBUDDY_SERIAL_PORT` 显式指定串口,或用 `VIBEBUDDY_USB_SERIAL` 在多块相同设备中选择目标;不指定时,daemon 连之前报出过 VibeBuddy 固件的那块;插着好几块、又都不是那块时,App 会问哪一块是盒子。这两个变量设了都会显示在 App 的设备页上,免得忘了撤的固定被当成找不到盒子。运行其他固件(比如 Muse)的盒子,daemon 不会往里写任何东西。HTTP `202 Accepted` 表示事件进入有界发送队列;设备实际接收结果以 daemon 记录的设备响应为准。 -语音指令还在实验阶段,默认关闭:`VIBEBUDDY_RESIDENT_SESSION=1` 打开常驻会话,再配好语音识别,盒子长按 K0 就成了按住说话。详见 [`docs/resident-session.md`](docs/resident-session.md)。 +语音指令还在实验阶段,默认关闭:`VIBEBUDDY_RESIDENT_SESSION=1` 打开常驻会话,再配好语音识别,盒子长按 K0 就成了按住说话。打开期间,Mac 和 Linux 的设置里都会多出“对话”页,列出你最近的语音指令和氛围小助手的口头回复。详见 [`docs/resident-session.md`](docs/resident-session.md)。 daemon 平时由 App 看管。没有 App 的开发机可以用 [`packaging/com.vibebuddy.vibebuddyd.plist`](packaging/com.vibebuddy.vibebuddyd.plist) 装成 LaunchAgent,但两者不能同时跑,会抢串口。daemon 还为 App 提供 `GET /v1/status`、SSE `/v1/status/stream`、`/v1/config`、`/v1/device/{identify,volume,choice,screenshot,voice-pack,firmware}` 与 `/v1/daemon/restart`。 diff --git a/app/Localization/zh-Hans.lproj/Localizable.strings b/app/Localization/zh-Hans.lproj/Localizable.strings index 1795a0eb..f67f75cc 100644 --- a/app/Localization/zh-Hans.lproj/Localizable.strings +++ b/app/Localization/zh-Hans.lproj/Localizable.strings @@ -317,3 +317,13 @@ "The network name is too long: 32 bytes at most." = "网络名太长了,最多 32 字节。"; "Status" = "状态"; "Change network" = "更换网络"; + +/* Conversation (the resident session, experimental) */ +"Conversation" = "对话"; +"Your last voice commands and the buddy's spoken replies, newest at the bottom." = "你最近的语音指令和氛围小助手的口头回复,最新的在最下面。"; +"Couldn't read the conversation" = "读不到对话"; +"The daemon isn't running. Restart it on the Advanced tab." = "daemon 没在运行,到“高级”页重启它。"; +"The buddy's session isn't available: %@" = "氛围小助手的会话现在用不了:%@"; +"Nothing said yet" = "还没说过话"; +"Hold K0 on the box and talk to the buddy. What you say and what it says back show up here." = "按住盒子上的 K0,对氛围小助手说话。你说的和它回的都会显示在这里。"; +"The daemon answered %lld" = "daemon 返回了 %lld"; diff --git a/app/Sources/SelfTest/main.swift b/app/Sources/SelfTest/main.swift index 665f18ba..b588847f 100644 --- a/app/Sources/SelfTest/main.swift +++ b/app/Sources/SelfTest/main.swift @@ -310,6 +310,17 @@ check(BundleName.step(for: newBundle, exists: { _ in false }) == .none, "bundle: let devBuild = URL(fileURLWithPath: "/Users/me/vibe-buddy/app/build").appendingPathComponent(BundleName.old) check(BundleName.step(for: devBuild, exists: { _ in false }) == .none, "bundle: a build outside Applications is left alone") +// 6. The resident session's info and history, as the daemon writes them (the Conversation tab). +do { + let info = try StatusCoding.decoder().decode(ResidentInfo.self, from: Data(#"{"enabled":true,"thread_id":null}"#.utf8)) + check(info.enabled && info.threadId == nil, "resident info decodes") + let history = try StatusCoding.decoder().decode(ResidentHistory.self, from: Data(#"{"exchanges":[{"replies":["现在没有 Agent 在工作。"],"said":"现在有几个agent在工作呀"},{"replies":["a","b"],"said":"two"}],"thread_id":"t1"}"#.utf8)) + check(history.threadId == "t1" && history.exchanges.count == 2, "resident history decodes") + check(history.exchanges.last == ResidentExchange(said: "two", replies: ["a", "b"]), "exchanges keep their order and every reply") +} catch { + check(false, "resident JSON fails to decode: \(error)") +} + if failures > 0 { print("\(failures) failure(s)") exit(1) diff --git a/app/Sources/VibeBuddy/AppModel.swift b/app/Sources/VibeBuddy/AppModel.swift index 5123cf28..9288604d 100644 --- a/app/Sources/VibeBuddy/AppModel.swift +++ b/app/Sources/VibeBuddy/AppModel.swift @@ -18,6 +18,9 @@ final class AppModel: ObservableObject { @Published private(set) var previewingVoice: String? /// Serial port open but no build ID ever reported: the box isn't running our firmware (a factory box), so offer to flash it. @Published private(set) var foreignFirmware = false + /// The daemon runs the experimental resident session, so the Conversation tab shows. Asked each time the daemon + /// comes up, since only its environment at start decides; kept while it is down, so the tab can say so. + @Published private(set) var residentEnabled = false let client = DaemonClient() let supervisor = DaemonSupervisor() @@ -96,6 +99,7 @@ final class AppModel: ObservableObject { } private func apply(_ status: Status) { + if !daemonAlive { refreshResident() } self.status = status daemonAlive = true refreshMenu() @@ -109,6 +113,12 @@ final class AppModel: ObservableObject { } } + private func refreshResident() { + Task { + if let info = try? await client.resident() { residentEnabled = info.enabled } + } + } + private func refreshForeignFirmware() { let foreign = status.map { Firmware.foreign($0.device) } ?? false if foreign != foreignFirmware { foreignFirmware = foreign } diff --git a/app/Sources/VibeBuddy/ConversationView.swift b/app/Sources/VibeBuddy/ConversationView.swift new file mode 100644 index 00000000..877562ed --- /dev/null +++ b/app/Sources/VibeBuddy/ConversationView.swift @@ -0,0 +1,113 @@ +import SwiftUI +import VibeBuddyCore + +/// The Conversation tab: the user's last voice commands and the buddy's spoken replies, newest at the bottom, read from +/// the resident session's thread. Shown only while the daemon runs the resident session (experimental). +struct ConversationView: View { + /// Reads the exchanges, oldest first; the daemon in the App, canned ones when rendering a screenshot. + let load: () async throws -> [ResidentExchange] + + static let limit = 20 + + @State private var exchanges: [ResidentExchange]? + @State private var failure: Failure? + @State private var loading = false + + enum Failure: Equatable { + case daemonDown + case unavailable(String) + } + + var body: some View { + VStack(spacing: 12) { + HStack { + Text("Your last voice commands and the buddy's spoken replies, newest at the bottom.") + .font(.callout).foregroundStyle(.secondary) + Spacer() + if loading { ProgressView().controlSize(.small) } + Button("Refresh") { Task { await reload() } }.disabled(loading) + } + content.frame(maxWidth: .infinity, maxHeight: .infinity) + } + .padding(16) + // Each time the tab is shown: the conversation goes on on the box while the window is closed. + .task { await reload() } + } + + @ViewBuilder private var content: some View { + if let failure { + ContentUnavailableView { + Label("Couldn't read the conversation", systemImage: "exclamationmark.bubble") + } description: { + switch failure { + case .daemonDown: + Text("The daemon isn't running. Restart it on the Advanced tab.") + case .unavailable(let why): + Text("The buddy's session isn't available: \(why)") + } + } + } else if let exchanges, exchanges.isEmpty { + ContentUnavailableView { + Label("Nothing said yet", systemImage: "bubble.left.and.bubble.right") + } description: { + Text("Hold K0 on the box and talk to the buddy. What you say and what it says back show up here.") + } + } else if let exchanges { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 16) { + ForEach(Array(exchanges.enumerated()), id: \.offset) { index, exchange in + ExchangeRow(exchange: exchange).id(index) + } + } + .padding(12) + } + .background(.background, in: RoundedRectangle(cornerRadius: 8)) + .onAppear { proxy.scrollTo(exchanges.count - 1, anchor: .bottom) } + .onChange(of: exchanges) { proxy.scrollTo(exchanges.count - 1, anchor: .bottom) } + } + } else { + ProgressView() + } + } + + private func reload() async { + loading = true + defer { loading = false } + do { + exchanges = try await load() + failure = nil + } catch is CancellationError { + // The tab was left while reading; the next visit reads again. + } catch let error as URLError where error.code == .cancelled { + } catch let error as URLError where error.code == .cannotConnectToHost { + failure = .daemonDown + } catch { + failure = .unavailable(error.localizedDescription) + } + } +} + +/// The user's words on the right, then the buddy's spoken replies on the left, as in a chat. +struct ExchangeRow: View { + let exchange: ResidentExchange + + var body: some View { + VStack(spacing: 6) { + bubble(exchange.said, mine: true) + ForEach(Array(exchange.replies.enumerated()), id: \.offset) { _, reply in + bubble(reply, mine: false) + } + } + } + + private func bubble(_ words: String, mine: Bool) -> some View { + Text(verbatim: words) + .textSelection(.enabled) + .padding(.horizontal, 12).padding(.vertical, 7) + .foregroundStyle(mine ? Color.white : Color.primary) + .background(mine ? Color.accentColor : Color.secondary.opacity(0.15), in: RoundedRectangle(cornerRadius: 12)) + .frame(maxWidth: 440, alignment: mine ? .trailing : .leading) + .frame(maxWidth: .infinity, alignment: mine ? .trailing : .leading) + } +} diff --git a/app/Sources/VibeBuddy/DaemonClient.swift b/app/Sources/VibeBuddy/DaemonClient.swift index 374d474b..8b39165d 100644 --- a/app/Sources/VibeBuddy/DaemonClient.swift +++ b/app/Sources/VibeBuddy/DaemonClient.swift @@ -122,6 +122,23 @@ struct DaemonClient { guard code == 202 else { throw DaemonError(message: DaemonClient.message(in: data) ?? String(localized: "Update check was rejected (\(code))")) } } + func resident() async throws -> ResidentInfo { + let (data, _) = try await URLSession.shared.data(for: request("/v1/resident")) + return try StatusCoding.decoder().decode(ResidentInfo.self, from: data) + } + + /// The last `limit` voice commands and their spoken replies, oldest first. Slow the first time: the daemon may have to + /// start Codex's app-server to read the thread. + func residentHistory(limit: Int) async throws -> [ResidentExchange] { + var request = self.request("/v1/resident/history") + request.url = URL(string: "\(base.absoluteString)/v1/resident/history?limit=\(limit)") + request.timeoutInterval = 60 + let (data, response) = try await URLSession.shared.data(for: request) + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard code == 200 else { throw DaemonError(message: DaemonClient.message(in: data) ?? String(localized: "The daemon answered \(code)")) } + return try StatusCoding.decoder().decode(ResidentHistory.self, from: data).exchanges + } + private static func message(in data: Data) -> String? { (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["message"] as? String } diff --git a/app/Sources/VibeBuddy/SettingsView.swift b/app/Sources/VibeBuddy/SettingsView.swift index d7a22df3..edb7e563 100644 --- a/app/Sources/VibeBuddy/SettingsView.swift +++ b/app/Sources/VibeBuddy/SettingsView.swift @@ -10,6 +10,11 @@ struct SettingsView: View { VoicesView(model: model).tabItem { Label("Character", systemImage: "person.crop.square") } HooksView(model: model).tabItem { Label("Agents", systemImage: "link") } DeviceView(model: model).tabItem { Label("Device", systemImage: "cpu") } + // Voice commands are experimental (VIBEBUDDY_RESIDENT_SESSION=1): no tab while the daemon has them off. + if model.residentEnabled { + ConversationView { [client = model.client] in try await client.residentHistory(limit: ConversationView.limit) } + .tabItem { Label("Conversation", systemImage: "bubble.left.and.bubble.right") } + } AdvancedView(model: model).tabItem { Label("Advanced", systemImage: "wrench.and.screwdriver") } } .padding(20) diff --git a/app/Sources/VibeBuddyCore/Resident.swift b/app/Sources/VibeBuddyCore/Resident.swift new file mode 100644 index 00000000..ac71d5a3 --- /dev/null +++ b/app/Sources/VibeBuddyCore/Resident.swift @@ -0,0 +1,34 @@ +import Foundation + +/// `GET /v1/resident`: whether the daemon runs the experimental resident session (`VIBEBUDDY_RESIDENT_SESSION=1`). +public struct ResidentInfo: Decodable, Equatable { + public var enabled: Bool + public var threadId: String? + + enum CodingKeys: String, CodingKey { + case enabled + case threadId = "thread_id" + } +} + +/// One voice command and the buddy's spoken replies to it, as `GET /v1/resident/history` reads them from the thread. +public struct ResidentExchange: Decodable, Equatable { + public var said: String + public var replies: [String] + + public init(said: String, replies: [String]) { + self.said = said + self.replies = replies + } +} + +/// `GET /v1/resident/history`: oldest first, so the newest exchange is the last one. +public struct ResidentHistory: Decodable, Equatable { + public var threadId: String? + public var exchanges: [ResidentExchange] + + enum CodingKeys: String, CodingKey { + case threadId = "thread_id" + case exchanges + } +} diff --git a/desktop/src/client.rs b/desktop/src/client.rs index 92a09faf..7193cbc2 100644 --- a/desktop/src/client.rs +++ b/desktop/src/client.rs @@ -149,6 +149,59 @@ pub async fn restart_daemon() -> Result<(), String> { post("/v1/daemon/restart", None).await } +/// Whether the daemon runs the experimental resident session (`VIBEBUDDY_RESIDENT_SESSION=1`). +pub async fn resident_enabled() -> Result { + #[derive(Deserialize)] + struct Info { + enabled: bool, + } + let response = reqwest::get(format!("{BASE}/v1/resident")).await.map_err(|error| error.to_string())?; + let info: Info = response.json().await.map_err(|error| error.to_string())?; + Ok(info.enabled) +} + +/// One voice command and the buddy's spoken replies to it. +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub struct Exchange { + pub said: String, + pub replies: Vec, +} + +#[derive(Clone, Debug)] +pub enum HistoryError { + DaemonDown, + /// The resident session couldn't be read (Codex missing or not logged in, say), with the daemon's reason. + Unavailable(String), +} + +/// The last `limit` voice commands and their spoken replies, oldest first. Slow the first time: the daemon may have +/// to start Codex's app-server to read the thread. +pub async fn resident_history(limit: usize) -> Result, HistoryError> { + #[derive(Deserialize)] + struct History { + exchanges: Vec, + } + let response = reqwest::Client::new() + .get(format!("{BASE}/v1/resident/history?limit={limit}")) + .timeout(Duration::from_secs(60)) + .send() + .await + .map_err(|error| if error.is_connect() { HistoryError::DaemonDown } else { HistoryError::Unavailable(error.to_string()) })?; + if !response.status().is_success() { + let status = response.status(); + return Err(HistoryError::Unavailable( + response + .json::() + .await + .ok() + .and_then(|body| body["message"].as_str().map(str::to_owned)) + .unwrap_or_else(|| format!("the daemon answered {status}")), + )); + } + let history: History = response.json().await.map_err(|error| HistoryError::Unavailable(error.to_string()))?; + Ok(history.exchanges) +} + async fn post(path: &str, body: Option) -> Result<(), String> { let mut request = reqwest::Client::new().post(format!("{BASE}{path}")); if let Some(body) = body { diff --git a/desktop/src/main.rs b/desktop/src/main.rs index b698325c..548ca716 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -29,6 +29,9 @@ use iced::{Element, Font, Length, Subscription, Task, Theme, window}; use i18n::{UiLanguage, tr}; use status::{Config, MenuState, Status}; +/// How many exchanges the Conversation tab reads, as on the Mac. +const CONVERSATION_LIMIT: usize = 20; + /// Omarchy's monospace font, used when Omarchy's theme is: the window should look like the rest of the desktop. const OMARCHY_FONT: &str = "JetBrainsMono Nerd Font"; @@ -66,6 +69,8 @@ enum Tab { Sound, Agents, Device, + /// Only while the daemon runs the resident session (experimental). + Conversation, Advanced, } @@ -139,6 +144,10 @@ enum Message { /// Play a Character's preview lines here, or stop the one playing. TogglePreview(&'static str), PreviewDone(String, Result<(), String>), + /// Whether the daemon runs the resident session, asked each time it comes up. + Resident(Result), + RefreshConversation, + Conversation(Result, client::HistoryError>), /// Ask `vibebuddy-hook` for each agent's state again. RefreshAgents, Agents(Result, String>), @@ -211,6 +220,12 @@ struct App { agents: Vec, /// A change to an agent's config, shown before it's written. hook_plan: Option, + /// The daemon runs the experimental resident session, so the Conversation tab shows. Kept while the daemon is + /// down, so the tab can say so. + resident: bool, + /// The last voice commands and spoken replies read, or why they couldn't be; None before the first read. + conversation: Option, client::HistoryError>>, + conversation_loading: bool, /// The outcome of the last action, shown at the bottom of the window. notice: Option>, /// The language picked in Settings, and, while it differs from the UI's, the offer to restart. @@ -262,6 +277,9 @@ impl App { previewing: None, agents: Vec::new(), hook_plan: None, + resident: false, + conversation: None, + conversation_loading: false, notice: None, language: UiLanguage::saved(), restart_offer: None, @@ -336,8 +354,18 @@ impl App { .then(|| self.pending_voice.take()) .flatten(); notify_new_firmware(&status); + // Only the daemon's environment at start decides, so ask once each time it comes up. + let resident = if self.status.is_none() { + Task::perform(client::resident_enabled(), Message::Resident) + } else { + Task::none() + }; self.status = Some(*status); - return Task::batch([self.refresh_tray(), voice.map_or_else(Task::none, |id| Task::done(Message::UseVoice(id)))]); + return Task::batch([ + self.refresh_tray(), + voice.map_or_else(Task::none, |id| Task::done(Message::UseVoice(id))), + resident, + ]); } Message::Daemon(client::Update::Down) => { self.status = None; @@ -419,6 +447,27 @@ impl App { if tab == Tab::Agents { return Task::done(Message::RefreshAgents); } + // The conversation goes on on the box while the window is elsewhere. + if tab == Tab::Conversation { + return Task::done(Message::RefreshConversation); + } + } + Message::Resident(Ok(enabled)) => { + self.resident = enabled; + if !enabled && self.tab == Tab::Conversation { + self.tab = Tab::General; + } + } + Message::Resident(Err(_)) => {} + Message::RefreshConversation => { + if !self.conversation_loading { + self.conversation_loading = true; + return Task::perform(client::resident_history(CONVERSATION_LIMIT), Message::Conversation); + } + } + Message::Conversation(result) => { + self.conversation_loading = false; + self.conversation = Some(result); } Message::NotifyLink(enabled) => { let mut config = self.status.as_ref().map(|status| status.config.clone()).unwrap_or_default(); @@ -717,9 +766,12 @@ impl App { (Tab::Sound, tr("Character", &[])), (Tab::Agents, tr("Agents", &[])), (Tab::Device, tr("Device", &[])), + (Tab::Conversation, tr("Conversation", &[])), (Tab::Advanced, tr("Advanced", &[])), ]; - let tab_bar = row(tabs.into_iter().map(|(tab, label)| { + // Voice commands are experimental (VIBEBUDDY_RESIDENT_SESSION=1): no tab while the daemon has them off. + let tabs = tabs.into_iter().filter(|(tab, _)| *tab != Tab::Conversation || self.resident); + let tab_bar = row(tabs.map(|(tab, label)| { let style = if tab == self.tab { button::primary } else { button::text }; button(text(label)).style(style).on_press(Message::Tab(tab)).into() })) @@ -729,6 +781,7 @@ impl App { Tab::Sound => self.sound(), Tab::Agents => self.agents(), Tab::Device => self.device(), + Tab::Conversation => self.conversation(), Tab::Advanced => self.advanced(), }; let notice = self.notice.as_ref().map(|notice| match notice { @@ -1542,6 +1595,55 @@ impl App { }) } + /// The user's last voice commands and the buddy's spoken replies, newest at the bottom, as on the Mac. + fn conversation(&self) -> Element<'_, Message> { + let header = row![ + text(tr("Your last voice commands and the buddy's spoken replies, newest at the bottom.", &[])) + .size(13) + .style(text::secondary) + .width(Length::Fill), + button(text(tr("Refresh", &[]))).on_press_maybe((!self.conversation_loading).then_some(Message::RefreshConversation)), + ] + .spacing(12) + .align_y(iced::Alignment::Center); + let placeholder = |title: String, detail: String| -> Element<'_, Message> { + container(column![text(title).size(16), text(detail).size(13).style(text::secondary)].spacing(6).align_x(iced::Alignment::Center)) + .center(Length::Fill) + .into() + }; + let body = match &self.conversation { + None => container(text(tr("Refreshing…", &[])).style(text::secondary)).center(Length::Fill).into(), + Some(Err(client::HistoryError::DaemonDown)) => placeholder( + tr("Couldn't read the conversation", &[]), + tr("The daemon isn't running. Restart it on the Advanced tab.", &[]), + ), + Some(Err(client::HistoryError::Unavailable(why))) => placeholder( + tr("Couldn't read the conversation", &[]), + tr("The buddy's session isn't available: %@", &[why]), + ), + Some(Ok(exchanges)) if exchanges.is_empty() => placeholder( + tr("Nothing said yet", &[]), + tr("Hold K0 on the box and talk to the buddy. What you say and what it says back show up here.", &[]), + ), + Some(Ok(exchanges)) => scrollable( + column(exchanges.iter().map(|exchange| { + column![bubble(&exchange.said, true)] + .extend(exchange.replies.iter().map(|reply| bubble(reply, false))) + .spacing(6) + .into() + })) + .spacing(10) + // Room for the scroll bar, which otherwise covers the ends of the lines. + .padding(iced::Padding::ZERO.right(16)), + ) + // Opens on the newest, at the bottom. + .anchor_bottom() + .height(Length::Fill) + .into(), + }; + column![header, body].spacing(12).into() + } + fn advanced(&self) -> Element<'_, Message> { let daemon = match &self.status { Some(status) => tr("Running · %@", &[&status.daemon.short_build()]), @@ -1652,6 +1754,21 @@ const BUILTIN: &str = "builtin"; const CUSTOM_PROMPT: &str = "Pixel art game sprite of [describe your character], chibi proportions, standing, front view, full body, centered, arms down, flat colors, thick dark outline, limited 16-color palette, plain solid white background. Then the same character in exactly the same pose with the eyes closed; with a big happy smile; with a sad face."; /// A look frame as an image to draw, each pixel `scale` pixels wide. +/// The user's words on the right in the accent color, the buddy's replies on the left, as in a chat. +fn bubble(words: &str, mine: bool) -> Element<'_, Message> { + let bubble = container(text(words).size(14)).padding([7, 12]).max_width(440).style(move |theme: &Theme| { + let palette = theme.extended_palette(); + let pair = if mine { palette.primary.base } else { palette.background.weak }; + container::Style { + background: Some(pair.color.into()), + text_color: Some(pair.text), + border: iced::border::rounded(12), + ..container::Style::default() + } + }); + if mine { row![space::horizontal(), bubble] } else { row![bubble, space::horizontal()] }.into() +} + fn handle(frame: &character::Image, scale: usize) -> image::Handle { let (w, h) = (frame.width * scale, frame.height * scale); let mut pixels = vec![0u8; w * h * 4]; diff --git a/docs/app.md b/docs/app.md index a11324db..a1566e1d 100644 --- a/docs/app.md +++ b/docs/app.md @@ -30,6 +30,7 @@ It keeps using local HTTP on `127.0.0.1:7331`. New additions: - `GET /v1/status`: link, current mode, firmware and daemon build identifiers, today's stats, and the time of the most recent hook event. - `GET /v1/status/stream`: SSE, pushed whenever the status changes. +- `GET /v1/resident` and `GET /v1/resident/history`: whether the resident session is on, and its last exchanges, for the Conversation tab. - Write operations: write a voice pack, update firmware, take a screenshot, restart the daemon itself. Config is owned by the daemon and stored in `~/Library/Application Support/VibeBuddy/config.json` (current voice, notification toggle and so on). The app reads and writes it through the API and never touches the file directly; the existing environment variables remain as a development-time override. The app itself uses UserDefaults only for interface state such as window position. @@ -54,7 +55,7 @@ macOS notifications appear only when the link breaks: the device has been discon ## Settings window -Five tabs, styled like System Settings: tabs across the top, grouped forms, system font and colors. Branding appears only in onboarding, the About page and the menu bar icon. +Five tabs (six while the resident session is on), styled like System Settings: tabs across the top, grouped forms, system font and colors. Branding appears only in onboarding, the About page and the menu bar icon. **General**: Launch at login, the toggle for link-failure notifications, and Updates: a "Check for updates" switch (on by default in builds CI makes, off when built from source), "Check now", the outcome of the last check and a download link when a newer App is out. Next to the versions, "Report a Problem…" does what the menu item does. Below the manifest's `min_supported_app`, a banner at the top says the App is no longer supported; it can't be dismissed and nothing stops working. @@ -64,6 +65,10 @@ Five tabs, styled like System Settings: tabs across the top, grouped forms, syst **Device**: Connection status and serial port name. With several devices plugged in and none of them the box seen before, it lists them (port and USB serial number) with "This is the box"; the daemon remembers the pick as it would a box that reported our firmware (`POST /v1/device/choice`). When `VIBEBUDDY_SERIAL_PORT` or `VIBEBUDDY_USB_SERIAL` narrows the search, it says so and how to unset it, in place of the usual "not showing up" help: a pin left set once made a swapped box look unfound. Then "Box firmware" and "Latest firmware", with "Update to x" when a newer firmware is downloaded; the box's current screen, captured only when you click "Refresh" (over the bridge one capture ties up the serial port for a few seconds, so there's no automatic polling), with "Save image". +**Conversation** (experimental, only while the daemon runs the resident session, `VIBEBUDDY_RESIDENT_SESSION=1`): the last 20 voice commands and the buddy's spoken replies, newest at the bottom, the user's words on the right and the replies on the left as in a chat, read again whenever the tab is shown and on Refresh. It is a tab rather than a window of its own opened from the menu: both Apps already have one settings window with tabs and no other windows, the Linux app's tray menu is the same, and a tab can come and go with the daemon's answer without a menu item that is sometimes missing. An empty conversation says to hold K0 and talk to the buddy; a daemon that isn't running points to Advanced; a resident session that can't be read (Codex missing or not logged in) shows the daemon's reason. The Linux app has the same tab. Details in [`resident-session.md`](resident-session.md#the-conversation-tab). + +![The Conversation tab, with nothing said yet](images/app-conversation-empty-zh.png) ![The Conversation tab when the resident session can't be read](images/app-conversation-error-zh.png) + **Advanced**: Open logs, restart the daemon, export diagnostics (logs, config and both sides' build identifiers bundled into one package). ## Onboarding diff --git a/docs/images/app-conversation-empty-zh.png b/docs/images/app-conversation-empty-zh.png new file mode 100644 index 0000000000000000000000000000000000000000..3d145fffc0ab86550c4b833f16dc8f892d6e82d5 GIT binary patch literal 87336 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 Mq))>>kS zFKs3s-r`{6Fi5D@?n+?+o;?bol6}|4rNgMt$5NoBz6smO*}xM%dx$9@EY_mz6G_Ds z9(3&xH>uN-V`2I2eBTA0NgI-ST9P)oAd55Zijpg+fT5~tGJ@Y8h6*miqJ=_NF%0RWR zMoBqvuFiQ@-%ev(AwoxW%ceGO^Z_|N#c$Vt7H?pdOyWyP)JqilJmNSq7cYX@}Nc_&&_16jv+qXS%O8yP|1VjBJ&7ONDchXq&-AuZ| z91@t4$~!xb4>=j7%j)XMsj86o%WIfUYDFis zS3k~f@wI_|C>q!_UqQ$=D>mfV;v8Nq2&9KCB5JCR`=!9rsgIQOZ)*A^P)bn{E0kBf z4Gh`fYC|J51CmWF#|4CRXjMceEYk^o=sCK{=J) zQXBgE71IdRYXCn_LnfqyyDj;VsnA>J91He#UYZ!6KXm1UPa%J?P5>s%KSDXcim(sJV-Q(a7Tu zC;f#vsZ2p(SH|Q5PF*EureM^xS8Rw`+~<;$tK#O0^*BZm_yNNRtB4*&!<~gjR~0V`QbI*(K5GQu!P>k$mn}{LLzD)kxW^H@Gt_H|?#~B$TnHay#qT&IwNN#vOnwVwnEtB}Orvo5H z9qBJwzQ)7Ts?m@jhI?qTAb$HM<26_}ykxLsELY3Gque)zX=j zrK|i^=XBQnk}&1ClJL=>f-e1>+wydnYx9bcEi!pY2}65WhC=fhlKyfq7+FJlE_&iv zny60HtYWmswO);zIL7_zohHm{Zo z`vR)VRU`y30GTk|ZLBvNpGDY@{LNXAKUHaunyKoMTIp9Y7?N>d>caeY1j zko!cg!R=1=fgnNh(s!U>0#GY?>wVG!5!QPi9O^eqc!qd6#Xk$oXi1?;K}Aw3h+^#| ztXW-5!KPq1i`0`1KVQ)f?NuCHk%~4ucQ1YMY5UCPc2j4Z_M)y1K8YN=iol`iNaUTx z0~$ewl+ST3Pmt!lS)CUv@~tflkhqb#ptUR9r>xNwZ`0IiqeY9M%F=;UrKmzEj+(uE z{3^VIS4Qu5@lw0MG4fWU=_jX`n-}j6 zu~wqkRhz{Z!VwU2>Q3^Ux=Z@oC`eyb&)dbi+DOK)W5T@21vefT`TM_Fy|h`s+lSwy zSBP}XHUkT>Z^LqQLJ7<t?@iipTD-~%6lpT%iRp{l*)q+I6v!Gzr zR&9m%W`VWMwI^}O^~Kh5@%X`<^|wS<0#g=OJd;ho%idGLQa&hJ6}xgIgNB**JjfDk zZxh8s4AA!;lk8lvhR$d#jO#zlT2oM!EHOlz#tbWZd$J?l8=Eo=vsX^mV&(MYOBJ*A z*l0Ww8>FO{kY@OS&!Vt4kgVxb_qYi)9mx+cG`{H2nnhJS$+$ff7eraaZ*w=|vj@Ty z3B{%i&&tUGf)4fLUhs5qX3%BPd)|uaDfCj#B8a7-KJoLP*5}0k$l-r3IV-b6j?1Qp z4#D>jJq#4v+QyoEK$)_-BO8|*w$2Q>bL6MwW3IA2d3S5QjOEr)&dF=YtYz)X@y ztgqz3s$zzl%6}YaRsh#vwVClfrsLRQ|5miZm<8`heu(k4-ZY^*ixkd-_2CMro06Pe zS7)0JjnBni+LOXJ81SIK-RQpRCbwwBQ*I-HT5;*=hD*$utGAlqmlL1Wd1^1R!=_eX z?fH%BMSY=Y$eVv`&@Q`MfGQ+-cc87s@L^W$Y(v^1R#Q!)O3^ZYl*o6~rKu&(5c1nJ zfMS|p0qv71#TmDq_C|A=C3yx{%k8xt!TZai|DLe{^1`TB24Wa*HF(I&$}J_?Wfuu4 zB(vaIo7*pt1*&(YCAMUuyPmWIuY666Ob0jNq4SykZbIr79pQRpfg+P#9& zT4IVy=@BxU1b9WEVSJmCMtz@4#~9qSnPBkGNQ}un0Ep;lJ*)HYZml}r z%tCS6PM}2&b<11v7v~HVu@Gh1fi-%bEo^{!R7q!k%rBC7DB4a&k6K}eNy&ZX~;^P6{9 z9#B@*@3Lu9lf|yJGWhhnB2r zOP2?vs>A8Dl&&Bbdyy*9Z$RwzBi1VRzR2Y=z*n*N0DY7<>*Hi+>5ZU>Ws|zu#36O3 zp%LgAD!VeuPHMq`!kQe@Qhh=QZAbf+1Ngf9SH&TExh3yGml8{!38bGdPo%FrO{_y3yP} zMxjMQ0HZ8Xu0CdN66%caJ0)B%Fxli4s!E1qe?ZV+j=;00%0#cRlIJf#@A;dd z-YhIl!J*LGyf0krT7=RHtlf~e3PyX>-rFueac-*R^?a&>x-1V3EteB0Q5Nlc zVVFUnq01-nS{1!#O{`{sdlQ0jPuDKb~?m{~x-7PmLw={|D0HJ56TTJrX=#hs@u zD;rCR`)DQ-(L$g9y#)Wt)+9O2aCMXvk+oLAJq(7=IF`P*i?Ku5A+<`x>%If^P3Z(0 z!8pLhSd&uSz;v+TUoyswQthqL-XmI@V{vR&q`J*5gGXodPgav57+}Aq%T&}rD)tIu zUT{WjmADyU@K{3_`*P2&WVwD-3x9!{V_oKO?w93-yC#o4Eyy!zo;vQR?doq$D~$5O zlCZMT!2}(LJs8~9IP58NevvxgiGU_n*$>h6>#+Ty!>p&Ms;`tY3$;+Mln4)#?O~`B zA9*>?ZJL18m=2N8s{MXN9=3V|0<#dqk#KODz9$UX(o@Mq)tI~i2T?hKr{{S}SVd!*z8@7j2RjBeh_NEiI4 zZ2V=YP10+{{o5jgVw->gAJYWjj{@2$q7nm#l=2fQ)G9`uW7rxAXS4(7qj)8kAIY-9 zk*LM>iK>{%?BkEeni}IP=4~>dD+&j{G&Q!PNQ0-TBY!LQUQAM-^8+Swp46pAsbu+g zNRAy;GcCV87}FT%D%hi;vJ&PJVeIo|Qeu}jXev@5tIEEcFN`rsU8|wp>up{J+}k;38DRK4g$Y5L%q?$ikoKKG*R0(ZL~{EYWhgvOImX+X+fcz_DM)Lra^g8BUDbq#zijlPZ`J z0rNJhv6a}V%|6i8vU-d+a5>~syH8#o%Inckm-f{k{@yZR6f3iB-TZ#FPZk{_3f;=D zHE%1pEsry$UQ=aUVWDSk;|L#d^7EiQ`sV`DQjxpv|S!;YCZod2L%YPXsIK}yw#BHsegU7-BR zcUaY@Yn;EtT_z#2bgQ^8@OtL;9CPN~?LE5Bn|7*%=9_bhd-;!RCKtrdcZH@?kqNTJ z+mSwn=lE`oI7VbVW7yqN%==(sU~%~82OY`lvcMd5aVJNM7>v?In4ifZS@3>FrRoEo|F~-%f5EHy-6uMgy7IWrY{Zrqj4T;S?@Tx0=XYqhT)X6(H0Ce_ghWq!+G|D+JFT<4w zDdjcDD>Rp`@A?B7v3TI3<2CyMCkYg;z|{;n2rnhXsi^x3a}`0{c!krG3$R2JY+<~K z_vJKr;j$G6AuZutZPCy>^O%Q|WeAK!w1drcnGuND;7(O|5+@$C&cq*3pvqJb$tpV8MJEE0EclLH;n2YW4ctWF`1U4SSQuzKa13QH+?>@~`9r_$BcQ`C5BEIVy7X5#Zb-Jpo%A6q*UJBe zn_VR|v7)=EuxmScdJipk@Bw3Vex_3~!eo6>+{_?m5qT%O3D&V}d>Q8`s#sBrU-N3G zfdpE7M2AMZ=W&s2D(-XIw_M948`N^=g`K$jIjIjghSZRp!zTf5V!sw zEo8RjPqOe=DoUeiU(B^!|4plTa_hwtxw9NY{wFc9rJR-$*x=iQId9fScdHo`0XE*;mHg?Pht(ewuLdFkbB^W= zno<75MtadjgcWA>@GkQ95?jNqXNC)jJ_XBAaR`F`Lj?j&RLq?1P37DCOsBr2ee>lL za>f&#I<|WAN2mqzI1DqBF4KNj{B4iMo8A=q@9%s$vKsj9@Y!!1NPadW71>(SbA5H* zW5Ik6C5pOp>ccLun)6q0=TsW6T@St6d?UgJfha?DmWl{m%uR@H=-ZRPH}N^UOqt3L z9q$B)eb=5R&N$tJ`SXZHo`m$#C_S_mo5zI7J2MW9?C zS>6;(pM&@h_zH z5}z}uTm{7$T3v5bfP`+%VuCRi+YNwH*3OaA0#AQ$&rC3VV7)=B#95d`NT*~ITpUis05&BeIj#q*nSd?uL)ry_s#wYqqNsbf-H zor@1uDk=tJ-FGDdl2QGIb`6+^7xqVF)i3m2Wxj%kA81|;phx?nRk+x2qmIqH?9AAq zA?bbp{;ZQ4xhsOx8;{&8hQyw#z1aImLqsU0X*6L?UU#JWzi#QckztZWF-IoK<%D)| zLSzgCm}f6lj3z-28QjYZMysB7(N3jbiH;iC;nd3Kx_hnht-hQnEPn`J+f5@uQP?SZ z!>tVK0yO*CmsUA8LB2C1g($7*;iNtDW6u@&d2UOL>t~O3o;{c*@P0b|nk3yv{IB zm+4e>Q+F9~oj}$iif8Rv#8eaJPs!DD%nf_EK*5h^un~~@O`W(+1x13U< zrgb4e`M7yuF#TbW&~wT6hW%)!62l&{c-r0{bEw}RO@ASu9NTMm7wNwPc2H z&=<+KcwTAW|6b0?vbbOjZCwT$-o5FsYab7*>Zmi^vhFA$(7ES$RH+x_nOps{g3Jud82o(tqdHx@-Ct+E$%QR zpsHD-zl*v{Xt>LPwV@i#ZX~#BIo+vn#XGcE#XQ@*EBB{1Yaz98P9FhX!$g&2RGv#G z@m*P*sq|HXk_RTrfTz`%Zz=6SL!5>JAs82=uo4Lke6R7&xZ`QIE843xjngUse^LFo`^-4R|4p zOpAt}x+ks9`&W9K`-~c)4qSfcS(D>;5)-5@H9TM$^oe3z&?Uhf!=6Sqixv)U|zgTw=xl5%&d&+>O$;ODmB}XjrcLSjtIy zj}lQv#CGe>Xtn3e;vw~4X+5sm0d*v&YxCO+R@$gFIjL|(A>vjQTv+%AMF#_PV9dLd zo8L!3pxXWxQ0zT5A<0#l6tU`5=%~k0%xO+Kn09E+f=q}bctGs=#)t6VPMAH+Osa7H}g7^(y8RTU!5 zrZIDIdL%C^1CFsjAK4@shGr4P9~S97QN-CKp?x?Kcc18UWSo(DOWfcgBoHz){CXwc zS9x0~dDIhNg7E?bj+Qf^8fUCiof_KXB&<{#3*dp}7Wel+!u%^c`}~C&s6_OD1|G_{ zJ>;d_(T5$PSI7|sVyz6VwjKp0yy+1 z;Z^4t6aY`QM4701#2n|}OhGj{kJz?*kmj;xfVJhSGcDN<4X1vXCi*deB%}7Uxd*rK6e0gwLW`7xsuHfucV=tdB-X`E=TCJEE>I?EGW(~~ z<09abL_eOPKXHUGQoU9D5a~_WW+K#)&uSRp#izYU&(Lu3v{#05a9H7cpGiKS93skn z<-Cljw>zJ3iv-88L_(}NTa7`lb) zre79w^37#ZiKrD0d{xfr6?XIK6;4RIyw)^oitV48nkt7t-^GN;P_X8kLg zw?De-vN62KZ&h4&v~=PULh2Y%;{lZIP_Oni^mS_WM^~^yf^vB3VQpS#q7OT_I3{MOv5z2^V24F30vkAV?uzD++JNdNntTXSVM#NrvF z|LZNeIisVt;-fNmKRE1uFdN29E=Z=f1CmwUq8bh17aR^dV{guaZukYCeTGlGPav~#CSsFM=Nli_r&^Dl+q`WN zfYkfkMZXf;sR#az6I0%cN7;a9e`f)ZdB0zv0C24&vz{0sSzF)hWwlodQRRQUFM+aC zWKs6_D2^^*nB0PQNaMtk(z+WrXv~z{-zDyA4Sj_H!oh`j;A3dMn%aFObYZ6yKlw-p z_+7@JSnHtdPFA4~o0KZDohy=+RM(-o576Tp04>n0#3Hop61sRIH)6Ypd#gkfFfi4T z8?+%wf-WhkmW^XAmzx+bb+%u6^o-^Gdr`H3uU|{xhutIkItOlhsIY)*np7{YI|b7d z_pJj(F#?jlccgTUL$}}YMEY*6fQxo%4s*j=c!r%#`OQfWLWmr?2bY7JL0{VutMB*n{Ch$kf>LT z-#?x4+29_a^>3V9luH#$((>PG5ZD$&(S0lci=B^=!5IVfgnVPOgP{MpF>k_qho`y6zZHHx2{-wDN*|$pUav_hs`ZtC6wG;=5 zl}TU>?v&8B##0a5e$?iut@|J=$%E{Vl?39=!z95F1)Z+^Pk?t5%0zdFaJoR=etsmf znH+D#8}72Z2s%kq6TBJ+Excc35JiH9%$0od7)3Xr5B$l+g(lv}L5zui+rMBXXAO|? zG4=Fi{lB#;{-LfnIpF!^=jD6ePXQrkutv>%jdxGLorbo~sc^<$NgzvQWunX<_(ic;RHG^Qx`3gK(`fu7?5&-iCCp>aKoidF3!JruyU` zW4U@_Vx7PHN`b^Ye}T7j@hEsFo4TT5#WoHWn@Gsz`&oVmEK#Mg#fI#9((xKvUg?vd zu*H-a5SmJD*;{E5_Et$H{9|cR*o4xRUfF8e1nW}jBwbSib=bA%gN2K%JjJV$osN(X zv#SlUZ}zZ}S2S^6PrwG@_q3m6*hFayzxg;tR7$#Sh|h6uyf|UYuw2Oqs!8>nu?g-r zWe=GeFHNDLa{HU|`-(=Wh*t08mTyjUA_Yy)RoTOL4A8Z*OL(gZ-$s6TT(1Wl`GQM zN~d*32pc~<*EG3}sC}-YWZg#FvG~}ACc0qDH^Edex}Y7NBEfk z2nX8e0v(8(1CdD(-T)MR(lNr$e}f5@z0_1wZA8lf8C80bd|8jHPu(|@t_tLENkJ8& zF92JWezKy+w=hT+M|6^lh}=(fs+P1XHf&1=MYaAwWeDM&@weXK2mAp_htj=zr3m^F zE-a}cIRNmdM7njZ5~l%&F^{}!&mbVFEHhHf?)m)0E3~0iR;vq11x}`b z7Ii$RFK^D6sRms(?K40`SJwjgh8w$JBYScH1zA}O8ru`a>4$~^#%_~lz#?SA6otbe z7KCOQqUC0zH6s?@pfyqfPZ&9_^!$6Jh(2Ttv@6f6Gq=H(tko`>^H0bHmvo_9Er5eK z3(O-<1^NJoNuLy<^GgN%5J8u8S^*2Fj7mc1u|Xp?J3?uFS&^y^6W8rq01uF{|8poe z(}E&&Me3svS4ma#V9gR0^z9UxWp@-&21HothdB$YBhqGu2Ua3S z-qJm)1K15ejMdLW0C6ToHZSsfNv+hq_Lz3T@JNbqeEqGgA`QvRgnZdrE_j%%eKLx zWw4Lxf5d8rxbr>D%gfo^tE-(oWo`GyG&GAr3^6#FM%e_j80y|@hj`7br98YS0p;Uk z6qMHoG626cjTR=}Z+tQ`DQwc|Zcg~Vd<4mGuw@=6OnfqaPvcvH6y&|dfRg`|q zZx{tuhx}UL&P3?DV=l?UUGDo9Uy{8vy;A6Mcmuw0$>Rp{Ep`?T6}DpzL(%rgZFoyE zsnSHUUnV!AhE3Zr<{>sXwLHdrq3hLKJrhoeGekye#O;xaCI@m>koMZMl$no;MmNiUG5(|s{oszDLsX&e{xq6b@q4Xl+?G3X3djqGh=rKs8_iD0TG7yZzl-Xz;<+Q zw#n{MCSt*V*1yZE?e8>=uiXc|uC;ohj90R@?U1B=Jz|rV>DI>ELZ}zc5#k?xB12hEbg=?pP7=%NLgw!YPqL+enQ zs0Ca!`1lmEX==?|6#+2tZ%vP+8(LR!OPn+j;3^{7W9Sue+%`8>f*wc(LD{syc+tbb zn`1#bRfB6jQ;Ve<5ix|gk$h*oNwOts@>eKSNl8o3;99Of!Vk6sWc{h;Xh8+5gwy>; z8>fS|G@&@dR@|D>itTu18lcZa=B$-z=ccf4l5!i0LZ%BQy8f2Js(DI5KKQ>7k9M#B zLubxez3W!YYm0qjk=nUT!smd)y3)lV<5g*uiG{L;U#+JnNb@E&b2)MzpO(opDIOL0 zU=4RbSGB%E`v9~V6FPaW3=|M{LYf4#v`8nfs3Vd$H0CV|Z}=JjIMYDo?mB!cxl*1$ zqx-A}c3}T4nKx%8xyU$m_xt~ zc4oSIE>yzOlC}BLV2=^l=@(UU?!@Iv;?0em6u(fyo_JItsNAE5m~0FIe~+R5$7`quYKmy(U3Sc+>`eoi7cDJ|dn&DlR)e`=Q?B4hH6_chhanV5l~?X!HPI z;D~j3SW6!B|7!0#qngUv^?;1ZAn1TIs31C@BI3{n6a)-Jv5d4(r3aKIDn%d!4iH2c zbd;iigd#RmhAwrarn1KWlOh_U`NCJiu?m6hXYYOZB{?@wTSJv7&>P_O3qoclkkA0w&reqHcN0Z`oX@z;~}k4kg!sGZHLf1Y2V`O_+tiPgZnQ=A_>0lhn=|w)I{b`brVI=@Q=qy0Xal|eg58;yRcG~)KcHF ztzWDOls`(Ypkv=W7g@!cLKMZ}$!~)|8$Z%=ieTGi)mq|9RsbmQ47v+qrG#@rCx9_g zU(9rY(Sl&3Cp|b(du`kuLT&ERPZ?fO!3Or0y1&mB`Wr$a9{yI2$Rj8uU9o3YL2?Lv z#uGGDy3M|F(TJyM;?kgk*YmdU>%I!WM&7?KtvB~-!ur0M&u@YTsK+SHSEIg6yIC7m zVcO9HIGQ+Z80Wn5rW3BcLJv$84Qi&cT=GK90;9vq#6GHA8>IL)al^nI^^D3WFscKf;7@n;y^(PVVJLL4h8JTm;8A@#KYdtln@c7zR|yz6 z38by^-A>V;)e` zG6I+<^QuRcOtr^?Y3s{_fg*BEvT2jKQmW}VcOAm3t=fm?%rE)V`Wn&xTa%_B6 z>-JFt0|ZgF4HOk=TSbffgIGT;-ARq{iPZKk)G|I=i;<1Fx*E&yRd>UuDq(}G9(r@m zh3!&tE`?bYLmQX^UqZC)6xa_r6+eMSb;qeA?AyhdU^iZQhyC=0_UYvGP{Y8hj|cT6 ztb$%S%&)KF++(5^lShpmR**mbsoG%l+8o$IHdCEA{tVQ~b&YCs9D#gBAHTB7Nzncf zS*j3`X@{^!R_;=`kVzzSD7?0OrgdXFfT5nD&!nI&qbC_Senu>~YaNOLv7{TKWri1c zcwaZMy>Zjfo#ivQmF-Tt26($hwcAq+Fx_#ds2Cmo=vnucEuNs{_xf)|Xg-7@Ap)os z_gl^vJK;&2XI4cA zWjiXp)Ni3p3r-qHC~el8Zwoo(NF&EggHjFzHznVztbKSc3oJacJU#K}#hRU>A>6C! zP;9HEn$^=ZrGvc(q|fwxeo{|29Yj_}v&s2S%#PiO;tmZ6J}*H)x4{)kc4sEObm2l0 zYy=Wa5w}Jzvz-vp9;K!J>bJ<138eze_@~az+PjpdH+pJ$Be;`LC_OOGR%3mDIJ{a+ zKml|3t+j4vFz7LmURl!)WTAm4e)ot_PFzbBxSi7$6S@Zs^drI0At9o8!nmmE<5On% zTr+e*>6BB+{L^+gnFYpf^_?9H6L47RlFE>Q2~lSYuP10prOz#s@`Y6WtL*UIl`Y+> zCC$Sj>T$Dbak^7^j!5D-6cCPi)wM-{Kq9krplczNrmWM~s*GNUKU8q3@)1}p3g3YC zc!8(jrcvpTbinL9017P-816hKcowv2iYfERG_1MgZIT@)-6B7-@0;u)3r!pkD#3R@ zMHpnL9 z=VG6wKBIRX?{EI>H|pZ++o!`okjR4U!HO8=Qss-}DjpM=E-#1qlRMz8fe) zSOx{qiuj_fkFPC5Mk#xh&SI{MH1NINxgmfe>^t9AA^KL%MFe@KI<5Gs8rhVHF+|Q83Siws}31?s<3F;MlTPQbpM*|=+b5i**~ z3T#7yzOIOA5O7%cKaxHP+8StYI*K~>@?6K1(MQHo{~T1&DSZFUg#Nfs8{%}T6r0Da zB#ALx-I$~8Owe)BxY`bw^lR0G=@WswL;_F9&lKSe+8>h^GJc7%m~{2yLuwx9j(U-5 zkrsGjT3|?+g~ENHt~hDf)x^y-s3mW2ktWu~e>AgEOV!)m&zR77XPE}^?*!XZJ00ot z$;)mjpf6bLb-}DX$ei00G`Iy_a=?DPhyWd73C5u5$bv74vbDQq27I)^Z+EF>w7NoPPf9U41vJ>)_7WA)3mJz9kC5d?^C@;0vz+P0kc_!rr1tOr+3D@ z!d_qccC9^}xwd7C#%9P8eo?mEmQhcLNK@F#I#H1JI2d8N%-0%Je zSwq={hvO&od-Rp+O!PMNgvLt=QzKvQNmMB_EWca0 z0v*wO3SMF*JV*rwAL6wUU;@sfrF;Z-G&3|!%V`s+Vi=#5g!p@9s8Zp&!aEMD^==q4 z85ZGse;jtGG<8HcnQ9O0-4+WvahN2WmUhPDwkqsZj^)2ehYGW9+}*>(r?Lolb^Zej zVk`A)R18O|2pU>lDEtQ&wdu&?tjpCpJ3DTSmV_fMHzG4o_^PT4Sa^J584aU7 z5GIvcC~f6ReYot=G{~#s91J#*?(L_u#@y!X`uNR!Bb=9>$X|Ut>QPG@(6SH(=3v46 zT&Vj7!n8Oi@uVt?#v!SfELEfFqgd9t^xf)bBaXE~E^30V>#^2aaio@P;j5ps_o->f z+oWpy_l4FkS=!H+-TTl0k{82hNJMg$QRQ`TvFYPtU9u0%@&*8!!Dpsw##B*mxd`Z) z-i*B15K(c8^jyEU(TaKb{7w{Z7UR)06D=4Og!{G&yrVjfkhxO~9uOtq0oekMfM;pH z;t@Bx!21w&tj{0RzjW5B8)iFm?ez*i7SyHwS~KOs>-qf}Sn(^`nORTE`01FRm0Kq3 zOMxQ3#wRla(Y9&hZ4UlVtPn}cTZcA;pnM&weis2gNkEWcp6#e24FyU`{-TQAnfUvkL6~p zn_Cix-oD?010~RZim4H$TRhN<2nGsN2Z`g) z&(vA@5BD`@3o7L3XM~SF@TmH_WWPCCmoBn8>kXEk?gAFQ+NUvk8id|df^L%_8?{p* zGc#SrMO$%dGbo+dY#HsSrqQP(*Tayc7Wm=oa1Ft4v3un5v6&G@73C?iSg<2Oqt(%!mXFs$_1 z)F$6h()8vqoKB9x6X^tW1@q1|UIVRU%83-Folikm^!YxH_K-LCjiFv^o&fcK!vR!2 z&m~B*C~3Gg0J`yTVQvh@qwAh~B8%uy?y)8*TSH08{t5<3FvsS{py<&W}AFQC?hkNGu(l}05=|WZr<32>7+GA zxi}3EJSw1D#g7c`SNk!vvm;7%u_zj}hwhXGliqn5ZC@P$KOTo~9?UBfeyJFH_*W@i zuV}5Eq@8cW#*33Gv*>Qyjddj06~1mf7gW@S@o>=Wc{xOQInh1~b?WoA8RJE3De@29 zPhU};J|vilK9smqk#(dNkh_n9kLU!^xR-!cR2-rsqr4wx^KID%p6g`m8hU_9hj(yk z8;=*GtnZ3kA?*B5#Xy7Nmcqv1b%*}d0DiU59(?YlvJb@<{&UpCgCn(3JafdWL;UIx zzaGVVhj@P!?~h_+wJNjw3yc(_M=`V(hY#Y|N*q6mgKIJK@UKJ-F}orr5X1z6m_QH{ z2x0<3OdyB}1TldiCJ_Ds62$;X43NYCNeqz007(px!~jVQki-D#zW^Y8j+>$pFYGzG z32xhwyu31cVSK(Yk?!+7Ugph#58D!N6Ku*-{7{#TCE2r}#Ot4r4C=s%Z>0tw-1_Cm zwc3a>8_S5?XHshsrdxm5VslFi@4Qx;hHVmyw{M2?XGRR za;0TS&T@^{%N4k-gv67{b4hD@zRXJgUmfh(Fu08A0C31k64O58^_sUFvu8`Sc8+I5 z2fJogLJ)G;a>fB(E%GG9tkFLaVz;a3((iLl%Z?_6Dyd!4dN=ut%a5i5Ojc!Z|G0b1 z_c!ncrkAw}MUsNv)IKI}D*`7YP&%}->S6wO5gZ&Ae?I1W+lRtx3I|vx$~r$kzL++8 z@^_3$L1owYFTG9FONq}ke-GHd#t+GCJbo}aaz~!F$zpf{x?sJ%@YH9f$H_bNsR)IT z-K2LsQtTJ|lfU<ao1?U}$rg_$#ZM<;gV2A`tWhtVy`adOosRB|Q_vf`{6-tWHB^by4k@|Uu zeqqXuK&Zgqzh(+vej7v2rt93H)v_Z0Y~Cp)H7Ms6zG1B^?ZCFJXRd#>WBS{_eti@U zpQ=59{>!=W|E}TPC*W|av#9-R-Ld%R))+DIVE#8wm4;NO$QBog&>1-3%cN zFbv;Dz3=yZpXa&%ec!L|;W#+hGkfj5;#%v9^ExjgHPw{}@M!TcFfa%nD$8qOU|iS4 zz`#zyxdD80T)E1Qfq_?NB`2r(P)?3X^R>e(D_e664CTng+inwhS1=H{mb+MEJQvgFgQ zVQjK3it1;u(pZQ=H?`PgLl$D3H?cqNFJ*vx#K{C6liUs2!T6F;Z)nAhkvkOu$?AKg z;G!IfGZT5rA^`2c@iu?;YM*3RW|A)QEfe_jkTN<-eRDx5fg;V&{!N3yDO;= zr^1`W8vwu1*(P6mW4g+Hpc;28HTDG;N=r}v$Yn6Fb>#d8r}ewki)8VL$u~7(&_X5! zwfd1${PupUfo+h2y-y(I(nYnJ#pvBq@#Ld-Umv&Q-^ZdrZ_g@sOw%oAtCy{CTBmdp ztf~qiuA!O8J=X)S(FO3_r;`OChDh%A(D>6lO?p|R^Pao^gW%g4j_j%quz^iuaAq(* z#*(||L1A-DO8rOToug{LVp_4qy|+!(=F`cBcrwnIl|O^6Ap7f5Y( zjkS$={sq$!hYoY#2OH?-y+E5Ech+wZ1=1Y_C!0_=-!vn}hBMul53OeM?Z&PQqGP(T zeEn4JgC;gP5xZFxyqh=FtQ}}y5<}7m>>6P zxa$jT56b?4RG^>hL*d$R1xJPqB8QMZI5(1M78?>=_I0kD&JQ;(8$$(GL5?QdR`V7) zK1G;{OkQ?wVPfF}9hy&gV*%C*H6QIiE*CP7DhW}lDU1w@SQ5Dqi_;Fs@jWP$r~1hA z?dCVNb^P_2b)zHxGoG;M;jg+cW(90j>F!WeQELZq!A4DD&F+~t3S6X|=-y2V6NWXK z2AA(o3T@xF6>y`fCGn=53EhFk{%{2GW>5^0QH3onzIMD`PhTHgFIaC-?>Bq1nbe4J zFM#L^$BzzV4f4JZJz5A&i5ByV&VInpTu%nY@qubV?>rFxkmwlvO@o^C6X`%iRy(ID ztrI~-#-vQMvX8t^ac=I*!sUGO&um)yr8=KobT=xs%DyYTqwRUu&cgogWo}IFMQ&B@ zd2Zdy`%~|?<aFdyHE%uOUQt=OvtrO&#)I%~ zR>NTPv4*z>Rzqd8H{Mz(DWvV2-=#X=4^35iCG{=P^{y+6OVY;n$i_FC;js~d(Zu!L zZ<1vaxk@94!;eOI#w1Eb3pK`0zEf=^Z$gIsWhb%)H)xJtpItvI4~&(K59~K7lP&m~ z@*(v_HC!L22wGg zpR?xHtk!#e&Tes?8 zjK~+>1w4v)Son}Sbxqn~rWq~2)*jT}bU3iem+EgGcpMacy?|1S@--EM8kDW$&KSGz zYM4`TJ!{YQa%K+f5@tySELG|+P&=0|)s51%Htt7tL3<$G;hZ8uylB(I-9 zU_LZt-}6{0EuS#8VOv7a;qxl(!!I%bNo5v>76oL1M_Nb|0kE5U&d~Z6HnxpHMEY~+x1MlZ<$WitZuEA4ep)a zLo}UXtzcQn4td4>^lnx1=R0e44qghQzuiiCfL}&AMUhD1dvBG(_~I)%{D865PIYRs zp~rk=cVKd0alpOK!$C|lNB`Zk?{%(&nPsMIueqgfU{Ii4SH$o+d5nlb73^^szwjaHMtWFF zOm$}t>__=FA_|h=*Xqr-tUU9$b2!ZGPL~@)0Rx|HP+F1*fhd0Z3hzGebB-X5>Kdc0^`}|iK{Xpf`!47W2aAKj=#0b+dhukh&t-NmW?+X-YkP(`ZQncvmKRqZ zVI~jO3C;`|9yj|Oyg5%Kdq(pty{y5|X47#OH+unU*ZoTQmi`0Ah1bK2}*J9THg6ZVzBy22>KMs$v7<|t==P-Bk zS(NeZm3s%;Eq*QZ8mthh?WLbBDBknBp^MX@?xEhSE$K3PM{1B5HNVGUM>Ew0A3I-H z7@ps=3*R-K!)IM+#OX&fkbV7S&C$;~(GG{2`Cb3DqtA9Bf(OgKtLGKNP$GRsLZ6pD zwwL7R8>f?tL-j)|#&=t*{RmFa)(uwc97sREml(#&j>jy>#X!80##r{iC=|tH8cg4= z;kcEC^5>WRA-(@nTIz-JT4mYIfI@SO5TlzWO4l*+Bc!fx%!rYL(ufy?W;NU#x7bzgk?^ z%)a*5HFnBX(~EZY9N-VGqq3ee1_mYIMpLq5KGb4EU|`5#Jd~H!@xa{9#BH|MzPPNj z8KkD+{%}iFNaY>pWA5BrTsiSMsE5}sBEEyOl@pZTDhJ;$2*D$`e@7eFia!CKcIv-y zF11xVDCI2;6B|05bsqHfH!j<&Ix+J0+pC$F->Pc$mUNzT9+K3cOv7el!o*dU!6JGQ75qfV-wEow449D07Ar?GIkhdmH)ah|1;t1I*0$Z zY=7Skya7y)z(h6aADd(cSTp=J*nbznHyIhs8qDaPe-;3_=D@XozDtY4nJp3^p!c6- z`+JaBpFID)+rJh|<{IYp(Hs1C9{pnzK=?#|&-UMB^k3osSNQ)u`Tw9@{(JKOO<(+v zBK-ec?e{w`#fYlN{tFOrCJy*? zIU$qM$&U(qHO=Ppy`J&73OIF-vjmj)d^7)g_Jg_t?sEl6v$?4MtQW;g=h_5#j8L45+k;=GELu)}g5` zsP?rymQBZda;?eaG2-sa`={uGsjR`&NEVg zxn0LJZt2zPHx#SKO=gk(I@bJFIJw^#pTy;rFUISX5H9fB{q9y;GhSeN-|yua7F zNKuAdF0GsQGNuz@v9>NeY7kG^N;z2 zLBF%CL%9S*ur6!f;CuO*I!t1Stohphy>^M=jL4pFD zOm}CQk2!Nh+dufdr!fM1j+oN1>LssVZECXQFEtZJ=jWhC>vpb@;7Y2_um#Pk{p+kT zg#|{w@HD&BQHj87W{JjO+E#SrBNkn?8U&YWND0{5%NbF12Fe+ifO6k{X;zd39N$&9 zcFq<%?KxC~(BG@+`dWbJGE7Y(gJZ9E2ORmFUPRDT1iBFi)xecDY0sFF_}cK}@N@G) z@9m*^xsB$Ni<5;mkNLx_XFiK$5KBYQS>@g1IroJ{;Fwz*=Fi<~pF%1fFCv%X_>QM6 zAdAZh4R$xo_PM0{t-h#Cyx^DEv@aGKN6>*d=w7%t*ZSH1Xhy_^I>(ZY9k?@eLTp=Z ztBo%Gtp9yfBd|Rp$Ny~U4#rDlm(4Xf8rNay+h=TNYL+XLKPI3M(pGCE9KL;fh7I*` zMb|-EI<`aFpbF2lW61h@SK85;?tW~==h+S~uCoLUv2Z(y4#I@Oiwo*De~ggxT;3z*e^jt~okRuu{9#EAynRSjL5Sh%zk>VvSZvCcdv(OAne}^(-`Q^Z~ z!f1_R)i{fHZ^oEn@Q$|2pLM9a54oWNsk>&TE<5@cY7MgQ$2p&CfhKM*>HFu>*`S$8 zk8@V1d?A8O$Y&ZMH^Me6*aC@|g>mOjy>N1<)^LTFL=|XH=w|$%A}3((xJv!$I8P6FHT|bU~)_1&Q|JHQ&3pSS*#ex{BfggE`k&(lQIf}#ACdhQQrfmPV^3F} zYQ5yn*#I{6bMU^rHSBgng2`jW##Uqp-#x_X>TOlZm@VIawN)OGn<-y!M)NY zhcEg%-+HxUKZL}nKvR1VQPLHgDaLtSWjEYDCMuz~&3$OIf$6};AnQzI%-*}T& zo;K-kgAh&@VVNFofD^`?BSm~ZEtsL-V*nM!yYvAC&6epi1b-)fBSquq2U@)Sfav08 zCHfOw=My2329O`Mt3GL`3rYAyPl?+X!g}W-UtBOB!hl{$J1Tl7uk+0UT42smZ6Y!+ z^ybH0DKpsrE=pkvPrd=Zb4%7S1(uj7%TS$kcJTjZJgExh*)heiF=Uxi?Ok!D|-gEgm z_G}{Y=Ba@Ayfm;1+0VYl1=jkEZdEh>Fx0@rB`fbHTq8Y0{1D%*yUcM2T#o<1pAY?=sb0SB* z{KNN)3dBUH*#gexbFuMLi$L9wt?I`%!`fz(E(63)CpgAzK8i>Pv>>gNhC{xcHA%j5 zLqNT?a}fX15i~1;YkU_qiTJcbii?vieX9+GszlhwG^+=9Oh8F&8ruA5r8sEJzsbT+ z!rLT#<`AOcFtT8YV|ipIydfYVs_5>>U>2jG6_d(Dn)xN&VC$CcnDTO?iD=l!Hz8HI zZQ2yxHq=2LNUtu~z`h}`e)-)ayd0?tH-F-v%L#%rXAyL2eFKuBXG_WkUK@E)X0c-V zlP|*Q@vukah$O2geGUhrPFnVSJ34PW81nn4nkR8BGEPJh3-sYUk(%@Wif^QO)?G^A zW1YQipmdo^tyOz8v$=~)i}mW$n8N-AwZ;i?pV5MNz?i;V)*)AEqRw&XpvTA|3-8*M z;lFY&905n;n$kAmm2v`HTUdnrb;8HSqe;m)mrkHCkhp``Q*w*0c+1ijm@%ok5Pndn zJFF<4+3y~0>>QO;$jtLzxsAj~`MEEGQJhXosi*$_0-I+ryXUp&Ry67o@#b&zMD@71hmCC*!Cgv@$Wy z5))k4MYqdSt1;!GkGun)b${y#I8R$?JpTz@zy5#>6eFkrjT5M(fiTNFWHRx6JLduwrdgUa5XupW zS}^42SxVtk!o$HTa#Vf=v?QYe%o zOPMq|x)t$qrX6Q-BL=<}8aHNdT^hmEqyRdZ&#(-CrkQDzdjz2lA>*O1BG$M#;d?O`D4xYv{4$)h=XsGsX25;^RuiXyhb3ackXV(5~pWXWQBN( z4O{Cvsq6;amYG`z$LkVLDITVqq@pK!+@i}_n#>2`wvvCjn>J(_qfxWQ9yFj*>g>%Q zh99Qkpyp2NNPzgne6*q%LceHLdDHY|LQ+AsPqzTLAn}W^mqMSx!<8THNJ(Cel-wmq zU+j4{^#Ui#0s5#*9tcSJriOc7MOY{I zrnuM(F`){C1M7n)Pq)WiG7Ehk*~}N4b_*-7kZ&<3nYIydv}v`{5oe8`x<kH9Cu~Hcpc}oDfi#O;kdXzq89N1(tj2!ETJB6M=evN$#Wy^Z zQGBf)`@oj3+y@a#A%85ER1I>A0I{-ZS<5kZvVq%_c^{nKefIIJ_g>4#aq}kmPo2`m z$s|P|DI>7YEIZaE5?3~D+b)i8>l|@!H{7U#4Mu08%cJPaNc-a5C1|KZEjSiAVs5Q>H;RK3SMb|GiVern2!BD6q~823Q=dr!+!ahD>o3bd+KsDPgZIMEKK6Hn-_BmH zPe+?rG)qhv(b&5~CLl|0RC#Fna@dh&-=45*6@Mt1i|n=^@%`y3QUNl^W>|od{5LA- z0&%Rary?jw!(L~V#JyY&=D^#fh~B14&?zkL5Y9)YJ{3w&j(#a7a!4~z%iH&nExWZOvEKR?$%~3~sOM+P zUp9a#@B2O{CF!?AF%%p4UK02d2q-9VsKZ5D&5jUcaH z!UoKI5^cp}STc60(U!<9ScL>k+8{Ot;t^hAm?(2$Z-Z?ezK~wmyF+RkwQ5L7-=ZL|2$U zd-I@uwEV$NG1P+FQ}Cmb^JAe`1C{J%+n#Vf!UdrWtLO_uYCjv~7Brc#AP`51_)-lt z>@^FSl}?z?+K;v%4`=lAr~u3W)g=%%;J0`0`R;iupk_Q}mpN;Dxi5LN7<>?47l)M? zdDpwD4di?>-RC_mc+rz)afw)VXH`XG&x~6pEg-(H28NV~jZOuN2}Ebhb!|#(4)j)# z0POOlr24zc$L|F)P=h5pgx@4=O7RmnlFxMBdx=U%WY6BJrsyx{Z>+HBdv0({TT-@ zv@IgWS|%m*@e|gD2$f_MzUWG#J|T*%9R}5HA=Lf`B5Dk?tSBqEqt(f0RX?;RNKsD1 zPs52favSiQp;k4~W^*IZIHEQ#ItezPj|kDwGMYgzkNcwvA)|*-4LnG7?V$J;`Ar57 z6Q?hjH^~9iL1F&`k#C`lZU^!fU>|N8JVq^2wQ))T=9lsc;p&UfYVWg;mX3lZ?5FxO zsJ+3MW(&%4>ZHjFXgr>_p_16C)}C(HVfgO&b4DP8iKJMq^SJvEtbIw+Y6My#I7OEu zW+Qwwj?bvYgfc8{#*p<$k+BLLuRv|m{`P~&v388`!e3*U&GoRxfwkBew?qsze)&kR zZU+)sc<1N)sETC9p}Fo56TD--NBmS?XFU4p;;ev=oz3jmN!)C5ozPyx((LaB9N%~) zkDoWbQZz}Y_ICjFldMF%Qw@ta9vqidosZzXEG*lCp#BQ zcfN53$8}_l=|?Jd&r}j<`muj_-+Z1l4*q^n8M92Ge@IO{(BkTWR+mYwcLQ}^2)cY+ zFjD)q7M&U)yQSo)n-ZC1 zH%$9(jpX+0uOEbq>A*B?R3XyuI7FiN##Jml+qJjP;SgMZHBgXAW0$E*Y-M9ukP&7E z15fi^RFWh1NDJ&S&IAyI2yBmo7_fI};5f6=co5RslX-6{(RmNEDaH*l4e;Zvkyq(< zB3rZT_^v~eCwH%cPRek)*UI|<9Qo>L?q*3S9SuGvQv%1ix&Ir2mV#GiLXHg7ttLoD zsP_DdkqHBpq@;m**2!F3Y=sf1Q$py{>XolE@gq zJs%0U-p}03CDpX(PkDSCD334fd;3LEjn{qI6Ww19O12G_LoB=YGA>WvQ$c9F*3o!> zIs59k!96?%+9>eTs>$!ALfirJZ92R=b!k!*`3agQnV?ibZjN?uEDF7CIrt^#mCa*J z;G;_&1=V=;7i}dmmud)U&!O&oEq78P^SH70THd4Ac|((As+D?>W7;B&kIYCINoS~5 zqU!8msQ0i~y%j`Cd{DlZ)F$ZfM-w~;BN0NPQ}@M&BM$Q5H-=xs#*vr;CA6kP45jT+ z+=7g88*#6!jNk01@|EeC5`Bz#*kHuOR0jlyz$+Z6)5m-D zOR?7>W5!ni%ZG3V{WnFYsQu`@wo7zJQ9+itDH)NpD!=)x!59Le%G&&>)B!RlTcPXAAdlS< za@I_WIjQXo7gzo{_+fYSm(ENH!v0Qm=b>_5+(7|&g)3oIb>x~2l+lq?!?R-1oF@>1 zxVej|as?QC@xZa2EkAflu;JRHF+@7i-MOV$R1bH4mv`90tbTUZ8@U!lv}olj6*W{? zGGhN4o^m=Ug6~*5J>XEanVafrKxait_ahRd(Y_H1k0_WlhF)sNQgj?qleW0JBh^Z) zg_!Q!v?9`Bd$#QPFKk|qVtic+c*^}?t7D2(=-#*PfcWzRL=4oQLaJbD$qDKR=B2~@ znFq(2e-_TM)Mz)7hIGBfry(eQ%RTyZEGz<|IBj|rg`Y?CXxSQvzAdr_pqR6gvc5v) z;k6CXw$lNc$(ZVg)IVi6FwemJTQ`D`wfKxH@ zUHKNtRs>V&bk10a$Out8n7hMF7`v^T-j6bvkE5!||S~6HVZ|A3y zXt1}aS>2(BZRs!V+;Hyb*9a}6M$3bO6vqZ*LmojRY0ou5(0g5Aj%kO*+)L6{MI;Bb z4&VPSU92I@916L0>E%&(yr7H7>V+EOZ=(^L;A2tqoq6v)-d5BKU-FvLN~Fptkl8Vy z^GEG>xXlE5=kJHB%NJ$&qfa}4b${x!bbCR$>g+yS@6YFZUeA_l*B@ll7ERY9mNPLC z&l^FgwvPLI8|Ab3W`5{?)ff!$wL#oVPb4Eun?^nr=IdyU{0tw$yYv7N;LkdUh8G;% zQxyJ%sHF5`6Kj%a4`#qIxW5v>Mq>G*RuQLhqOt7oJr0 zqTVV7vl)}RRN9yZhC{hNekN=K)4e@%hsTAOZ99~{F5w)sCt!?W-b8jmjVDyh5Oatw zn&ID8&YD>HjNt)y8U)Ll@iWf_F(xIZaOnB*i>g0~g*(!LF-zo@pX3qBBCIhlVQi#m z!AH(6cLLc4H3eEKG4bBYPu9S7B8rbQl(SjDx8#oGMstFmQ;>&OT)x;ftC^gNGLV>M z3hN4ikfWm(C$QBeYpclKkB(UuKvbbX=R(p%5+4<8;&cTop%z0IGLSU4RtjS(K_JQtm_ zE-vjI+9Z-jAxM1V?_cs5GaDS+YeJAMe*NXle$mBYYTlXDf9upP&hn(OBGdH4##9haz=_33Gz;+^5*5rE zERH*(w(R?<_rb-AN8c1m+b_&Kd|d-m(E7;o5Qs%bYtpT?Mk6|@#GOr0gJ`$|89E=a zR)>NKWKa&)Fh98MRF+^ziSHxi^VyN*DUDKuy~RRQ>JWj_x`#Vb8md7A`BeKfgSupm zz*qn*N)0m&MzCeQHue#3es->0;`qosudGvOWq|{FX_R`>lVbGYoyZ0UiMsU9B8|kg zii2JrD(~$IB{3=Av!mB24(Zb#8Fa};0tEHa2_XI?oi|<_&u~a~VP;~iY@Z3w+-adI zUR@D3Gw-dAfl~F0$m^jOY`sRPH3;0Y z1tN@}SRC`W=o0m*Cn=DGh}hF_UzZbKw_2JU5;S`b_*MC-!CaS7>z8>5hmbZNpEX1% zG=YSq*%bMc>%)Dvy*&!_@l){=Osx62(hc=sjSj3KLXI*zYQjr1Siw8qY4KDYLT3KG z)XX1?0+{CkPY1Sv^6f4*DR| z-A=3#C$^eG;?Gb4Wcknm5?nV~)w6+1>1QnH$$BR^{z)hNK z=EhmJYmgXI5}#&8&kDCbV@jBL6~XH)=bC)~DO#;sr#(CR!Db2Q8a@Xr9f_YV(h2?> zTT^Am0tcTiX5frQ+8?SOT{}YlKG40SNZSk=7HpPpYcNsEQ&|C&8uB5W`l6JX1x$k8 zK)l4sX(Qw?z4aj?f-=J2 zq{6+ZW-6IfP1!6oj|Fe6S!UWgi5L{fsyxO&e!GO3%Ay2%h8)~2+p2@ zP;>`JF7srwxWs+r4oPeTo>jtqbHxn9Km z8y}k=SoZR+#DVSNm)=)h6GZQ$zsV8tB6G9QY9tzv!pnz;QG-nVrW?6)RGpxDP72tjC(d<%!q1cCs_+UO~EFjU07AXT#% z=vQ=$a^eo!zV;&M&G-q-yW)u{d~d7-ajq=apSgYf4ixBWz!8XawnzqCXP8u}c>`r% zI1KN6reAhv4^lM>tK6@M&Z6Q`JMKMHcOon!fQZt4V0dKx?X>`)Qfi_TA%YQ%MIvh^Taj)`qJjeP2hU zshvd>pE|JBuM72{na<%W*G&YlH8DMLjetE9owgIiFX7G2i2%uHAZoLB|`8wI}!P=pPqTe@7;S==U=27O#bA+e=BJ=;6Wl z=Y9Wh5x;O^9Y(7N{9!`!dthnU?F4m;wU4~SA=jxaeBZ*7p)$?_*RjkG9io~aEX3rf z*qxii=G6Ln+pNt}v*tSLjj(S~c{F zU&d~vS}U3>+8l&Yqd8GO;TpIQiRNcbd{47k{7lMt)W58VQMCuWoan+?3mag}io(Tl zqY85_%b-FlgTj=<+qh9J@ZvB&O0*`(O&)YYvgIp~u_gI*$%8}{Z46>zJlW9U*QEzQ zaim@;StC~%jvV#M7a~|{a($=e{t#j))Sn#{19u=WX7)*RY@c*Ho=fq3)^wfwkT@>s zbFSKE4?rPkLT+gIPmhSr0uD)s*jk35HGSi$*Tojs)3hB0KCj0WfI*;=*lTv)2hyQc zZeQJ-m*_jxC3bWq;@ag1#d1LaSBQ4)#HlmEcXm8m3K8x&OUj!yE(I@?2Y( zblOnFJ`a@S=@4NXi0)&*S>{kUw2|(}=dJ-udc8%(cH5e0w#36R)QUo;!2;`bct0V1&W60>s4dnND zvrC6G8o38?#Ic~V9#ahmdd|d7+3Cg#r*^n{;;;)?$DR@2{|OQgUV`r~zjg@QBiMUs z_^6#r9%>YQG5@^}Z%J1qbC2ni)r8gKU9tndQgxqQa>|bkK76HiZ%}il6R#0Hdd=~( zHh|RSj$9-r?YMPIx@y$n$GvEHf!K4=O>f5nl5bVFgDFsQ7jd-Bc7teBSJCdo!T;P~f6Mr?5p^UzUx&}SXAHYLOLXa%b0I)*;`9UTA>tJ$>W=eUib zJyrg*cJR>mDqh8NYn<$@@CEAm;`^ur-8E16a_UVeA8tX$X2;Xm zP_E+dhiTp47eW^p=Lb30{OAeOWogzzouyR`>}tj+TYm)M+ebV723T&$2b9UIUrV$& zmlpxJbv~|Okt+{hj?EEZOtm4aAQ#)xX303QMT;VZy)(o^w%>Q~hVi%R5W>#IXg3z7 zOZAW&62|D@m6__FY}CxzQo<}!Lsyeaw$YzFLX$;gC(TgmTYh-fkrb)PP#rLRNtPmR z(0_fj;bg|4ZAA*er;Wv3)rSP%UL^U`6!Q1jl@ZZp(SH~4Rs%G-T`9xOnnM$Kz z;L$G3QQt*OpyiPJ9`o-EF&4lq6N7Vm$6k3ojZFN$XD(8Gk0`>c6cTsiCSG&Nho*s4rVJ5|UkJ7tE79UGQAhcO82&k?K{jLGzbq$(bD+ znzjWjA}Ig>BpJT$;muh{^;{bD5JCE+1Win8zXPWFO@=AbO&!;GGwh6RpwaZVm4}Z)n-YdA}#yOtx=7I>&vF{nrGvGXp4=i~K?*rLUy* zoHRoE7GQ4^)V<{H4ps$nM$b0QzKQxhAT|yJG6q1R_O5t_usf%os%bFk!+t@cDCyqC zp{ey?!*ogPE0RvZzIuDp@U|G;3HPr)$YKJpHszaG7~-xdQ&Y-J=unT#7?J4+|Ebl5 z?<0aNWjg(xADt(DJB~6~@{*(%5fP$h#ig}p`+3PmXG@s7LuxlMsbRFM@KXq=*8+t7 zX%EzV-apVpyeAJ6!#-j8)lovA%qKdHFteZ3zmO@DFW9SD-n)ROaL_$;xBf>Eip*Ir zGUA0%0~0UgmEmtqQY6b1Ss2%`G1~?YyX|+}%Tu;I^4bmq`-;JrfwweybOGnrfb0>O zIN09@2x-Qj_Jqur1omW_@%Po)eh0DfGA#VhjvU&fzK7H_?KT#t0AX{KM}XR-+0mC@ z*=b>{x;eKQ(Q$xmIHX8}a?H3*B2c6yIi@~#c(q%Z0^ed2r+VyXark^iD6|35pIyu8 zYI>XZFmRAX@@t$(>j4nZLG0(KcHFez`Hnw%dZQ@nt0VU4QFF{=y9n%e@#3P38&`C` zw|PnW)^W_c3(v1)mhur7j7BbX0Ax2q`Gpa(v|y~wkXA55#xFT2{Q$PQgfEcWywE%f zc6X<{{#XE%L_7c8@ztlc-?Dguv&^!I<)nqr3vd}9(R;0bNH1wvO)Gn|zjn9!+XIbE z5xX=J!smwj8~|-)9{|RG>;kFjmAzzOvzDDq+ms2KqGELuyGO#I6dFxGCc6~APY7B? z1c=8B9kU)BX#-h3q=@fqa{ARyW(p((@&pnXn{Krvg>&y-i(T!=3l3tUD*twlFv$Gt zKHj0|gB1Up>Te6*6kK%eP+mz{l?|&Z=U_2=l|V_Eokf@+Qq!D9ew0l<51w%rU6>_J zr`47pkt?6Zl9JCnG)C^4M$5(e@HQ6a=O?LMtczAgX#O`+8W*dY2E;l{U&08$^eKtnf^%tuA95 zBL!t-SexlA2y2(8Cdh zq-&wOKj3kHmA$V^dpd(Z!{+m)Rrjffi>qdqlvQ+rC%Oxk5zhlw`h~Wv%bQf<6jrm? z{E{xPcvrdw&|B8HY#)fe3Pt+Y;HOQ#l_7mBac}+a{!}>uq6OLJYY+ba{ZvW{wieq; zJ6JqUa`{U2`r8qOC-pkR+MyvqC6 z(fE6ef2Mx*)E#C(Mh`uP!T(rSWdgd&IKie`{!H+HJ^be~As*04Y|WMo|5(<@PX~+? z2H!UN_lJKEJ@BEj45llokr2^8))rC|0waYxy7B$vWd9F=`nNRxrN3L@z(^fIO;mqL z>tF6Gn~(zar{Y4vku6{>M5;7&AcirW@Q< z_*>rp@}86sV9`0?3WxFkB>q4+P)bTqXz@o~|A$5S@AmjVi}HW@_5a--|L<&%K+ijH z0q=iMK+gPu91x$^gi_~&1yVq%fvZtBd*dCTfLoZG>JN?nU-x|`_G@^xm5&?4!uGWL zRoTeo@GFvdH?AJ0obpu1+vB7mlyH{LgPO8$2ci{@bRSCiids}%nS_4 zA_xZ~p0m7L4U-WE=)lQRd(DSQEZidxRYSvBtkp~EoUV_~do)!4N0sm;9*2!t2)7_2 z9|>@eweMDTvvg=@9lSS1pRPfcI%Gp$)`O8hf;+Aefi=M>1nl|)?yL|Uh0zKN{p+I@ zRnEWpl7Dpm-|H2KNg{)I6e|Q$}`ucl~eoclf27|@;jnoq`{pHw)1zogBFXw1Q zg^mpUZPAHeuF}6vd0h}QL_3<9Bmor#zVJ|2^!^ce^NlpXNw^AP#Ab;6ud?4_P$3KO z43X_Sx+~+?pvmyNFzEmGO`1Z6!-QbRbOqSZS&juL^hsNrE)ed$xq>`*Dq1$w9JsHF zjDFK~4d?o=pc@c?r@g6b*|F;YA_Uz!R(2NOc07g=J+SxMLbXu$$; z6)^(&e){(^GW~?9Oa2@+1yK79FYO~5uU@VKa-Op0gF;t(fVy>nR2(Q|+XnJf9YE^p zd4|tH&kCYQ!`Iq8GG}%WpcFktoiAM8L;o!ErvS>Rw691<69#;^>mTaZ7SE2nY58{ZJC`VinSYU>p4+xu1GM-ylhm)K2BnT1fsy% zIdO8hm?FPUDmtLM1Nh85mS5Ma3Gf1d0;Q7dp_oQ!95g&A$WP5{V`dp1&X~pf0R@!9 zasrS=<0ATek8j+-p`~JxXJTm9quJ`7*3Sv0$y%(+t~8~b?zfN~TikUU!T5EXqf&O!77|6aX`F{nnOLW#jMx4Dfo z8F2ZF+@g-n8CI~?gonU8i~_;jRA;YAmu7KJgguiFqMId1V$P3F+Ae)702*)7P^tO~ zQXU*GOFsh4^*IWt>$*FxYZkLeGvuMD(FAYkt@<$M%IG$~rf&=o>pHOT?`negJcrWb z_gE!m-Cq19##L&pW`m!HhrYEFQNtFT!;qVFjxrU<#VD#P9@5NM8<1S2YC9V%d&Qe@ z9L`X=l4Ou{_Ao6{@dzLWmrVkZOcX#R6Jd{|mO7srx+gO4v9t&X?;_%;LjB8q`Vrka zfr8Hyt!@$pS~N?ZS2b;XgOVq+i>SjPK0wjMVFhk(R}I}e-IPsWZvdgxxX5QywIMrJ2%gvZ zsRZM1*#f7e=DZPDq~6{+eVfv;nt(Sl`@xK;Y+ayMtM`JVBFwCL^HcHn&rP%}n^M5> z+OyW9d=P)O!so9?3D)@xIm}pdX|Z>e#l98m9>rVB`SiK#m|hi^%f{?!D4!3UGcX2v z0^rho2Dw>3ODrh(d^a9hluiubB%VM}%N%(Dc!vPx%5hYxaY|i3415shkNW6SUT$~h zK4n`qpt8@*D>%;VxdtqfO{zoM!E3&mF#6L{<63|snY8F$HgmrDtWDqVT_vy(NkofA z6MN>TS9wPe5T^HSeRbUkcOqgU){=5+p|R)|f};A2jw?-r2}CTLie=y9+qqA!r3$4v z#pPFWET90`Y3BZdePz!b$wb~$ksFfe0_lq)a!spdSo?oT7}pki7+% zk&B~y$ia8ZV1`$Ja7nKxN|>+p{j*s{VB2`pDvZ8Z^Tmb(#I!P9+lr1}3zaT1>#8qI zliGeq=L%WQYA1ItV)GP>MGD48N~oHDTsN0{tijVz{+DTbPNqy!*z9o9^ud3pW@49m zpvN1$7etMz;g4Ekj4F`av7(P-oC`krD$WY6wzlNmkc?=&5QLoe-(SC;xl~i;YiD<4 z<9fSA6-JuBP1dP@0ywHo7Gq8ib2KV|=qovgi{uS8){KX}hdi!)Z3XqPhFT681s)#O zgYQMnCBQLeb=wDaUt9DSnzy>COgRK!-`y_bnkN2=S3!0a?{XWYObHwCP*MgD0gf-VhT2N(o(8!ub^VmsODgniQHvw#?XLG;6&TEUJ%t?4QvTJc(Sg zUevC;XcVGm)gtZGn14{+ZeI@%(=t?jJmk8yYK3#bk?EqPXZbER0!xX3cy4v%ijHZX zCHo~n^pu0o$7*bm3_HY%duJ!wybi#DrWJ_UzQe+uSF1veV$S2F%--#n%QUYi57R26 zY4R_Cs!J}vlcojX-OB00x1VkNVrZYm(%4F``0rw7oKruJa0+`*uco+6&vzUAex$Xy zkMNVBiL}shJYSa?oqK`h^Ir2^$Q1@@tE^%=;BowjQE67=q8y;#R_xPpN`u^@eZH=# zwotE3ZyB95>R~of(2e=-*VA3VJzmsFt%F-h^4ebCC}AFnR;k*>XG zq=$IV<6hoQu1i>O|M{uk!T!-Hm2r3U#MwaBlGuJbS(oq7(;=IJOt-WrA|u&fn^dG@ z!n9d-{=hH+*f$)HeJo-~;L{dG@4B}&-HOebS7SZSPq!C071*rH zCF|~#Si0YLSI`E1r2-T$UX4HSrKZ6ik)j(9SqQC_ z+sHL&4G;y*-JVB;rHOq^AbuVvv0FbZSV{ii3m2$RUb`V%>jW?}ug6Irf|tYc-vxXN z<*R5XHO~t(cW>lQXHh=?H4-5@C)QbTtRB^?4HA_CG~0y1qE|r!nB-+gVawv_4jx+)J=Z4%U4lMVlS_E|j>zOj=YFRzJiRG;jOD9&JMG1_~V5 zkqD$-p;hnIDxG;rVdzSStp01PQoA}4VV^B9NZLiP$P;#+JMdBnBU7q36Hk3<dU2sj0qGyy)PVX+<&=p@82Ojtmel2 zcM&q+PyBbWGkvJ9*vm~&(oOCzr_!Ef?RgCbptZZ8(j7D3;Hh`3-S8n0lN3U)F3gg1+ZZA?9 z@p6qU5p%{N`}~AFYb3B#Ce}X69;>*rpT&ai{Vi>3f*}xdBv?^ zPxwzHB_b|Vfm1MP(_IrEV|`?MihwbDzA>{1geK3DuWl&ECvs;k2LlVAOxbeR zz&faI%dO5qr!EAWRn!De^+m8HG^EMY!zVzmE}_IqGXA|3(gXM*Ve$%9@3=d~0uy*M zyzfbWR_6t#`l1>BAbI*oAiU5}U4mhjGh~`IELyx4efov*QD~)uma5Z57I$j)G}G%? zZ23o33e?#=Z_y=L+zOAi3F?J*UB*9@Vs?w-SYnTcIH~0e< zxj7-7{s(ZiTpS-erV%bu?j{{Amhul$_xs|qmvSh&7OsxJ$&9q6#d~WT` zbH6?x0Hh5Wg-^1HCGZAPT_%?B>{~*X-1?M^H5xbg$_fo~t_{6io)kd+K%_%-Jt3f? zP!ZM?%IGxd;3^|gf-LnW{ni=c(6!ltNCMAt77H1s(qkME=IvM$!3 zHo#pk)$XMV7 zdZG>O%k@uqX4L9s(qX33T~%S>rf|rKTr1r%SYGS$xD)hT!m9(@UQjHMN;>FUxwu8r z*3`EWMDFNOdiY=(^gD`r2@Ci4rz32^6fU~;0xORRjv2mYyC70b;P0AR%mfDd*}X5c zu;XynWx!N8KxbMg!be#>#`6k%qpKD1DTYZqP`5jzQ{sV&tgj!MigY&ZsbjiVvXZn@ z92ZB%61(qr%u)kkO1Mw&!&E#rACL+85`yIag=2I=zzi)z+m-vgCxEpU-$;p4(Fkqr zR{6W%;r241o0k^&#)3i(d^fkmM2DBMs4Y&#}QX-$S;|c$~5bYOZB|n z-5Pt#X!i{6q}6YvBl(3iD+5BchZ|N2JxfBvA#T+(P_>l0+n;H#?*m{_8PM zq22NXf`Lr{59je`l_)4h@9#)vc5%L<(W{Jj(-D49IfVLXp1xiUnH`)H%&c!xXI?VKWmLobG3b57cyNOx((G$ zfmmiLeYOq1maL*;i(WwMMXLd5g}mKipbhhiB}ErV2LSC==S}J-_^5}ulaD2ucQbo} z)!Cn}koQK-Q-Q6}P7MwAfuwd!a@(X_sP^RMGYoK&Bd|k2qU^Dy$L(pQNa=z!jvDl3 zmca%%$QhFsUVj1=L1r1k$`0WoUHeEnD-;sCj!xl&8Uu5W@!1q!5_~@dW<)EHCdns) zsbfTNWi9RYxDgwb$&NcO?nyS&4yln=a-~1nV`1R!-fuh^gm3>z7(zrDIQ!73jeSnP zQc>8CUmdjglB{)ddVc{EZpnz#SAvD4xIgt?;J)N1-Owa8@n>iX$SiP?eZF(G2y1o; z6R`6`xUfA>5<#)2?moC5wlFwCNZ~DC>1@e;E|zlzVkPTv9^?E3kwc%CA3i36XBdW` z49k@m-<%39be@jHpe(7suy~@Cw=JzkyfzYw7NPa4Fei^mS(Gl9Aa4+RTp&R!E350X zbCE3UF*9xmI!iqH)3=6L(%Fs2VdWD_YXoySW-_L|Tw3lU`PAKvX=&l1?xD5_aZF$1 zTM~l1$aGp01VVvV6py~LBoZ@Q%X91>)#Z04I#22;-JMw(0R z!TjT@=#g5w46=_U?;eOhSG*SF7j-SLt`IsMmXb@o)V)2TU?&%o zO`&NtA#HH`;}4rR0iVA?Q0u&AAV)qGWpgeKyrf8O?CR-fW@tIgq_qnk3i3Akd2`1t zR^DTO#oCi@P(~XJeV&hdySsk#NlW4w`zde`IwABx!euwk!@c^pOZZ&_tj{as-GkX( zIz%Y{**S<6t)O1rO4Ul5b<%q<#4YJ5P5@cKCDx&ps1(b(0$Sf#1BqQI?h1p7dWes^gAu2XPj);kZ#FYZZrV|Rw&e>>kP;d!1EAMS+(U01lFK{w^K*-?u`MO zzqE9$9H(^RwMRZ6O^1~PIK@g~$uBiM-1WeS`=ggRA8jcF=VLugAQ3z?O|=Tbns0Fz zD@+CYL{Hxas<6h6MZ3JIQZ4E-coZFvnM@!sV@$#;DgwWIytgu zoL}g#WKxt_3JzM1#W!Gsrb9Dfb2g&n$gCwOX!|}#IwOMY+uT!4OItl_V#~r`7R?>-s^C91VjIS8bri89-m79ifv$dm@r1iRN#~LF)LNR_szGW) zXdJk*!PQI)H$oifZvTn{-fPdal5Bq;4=%)_Uv=~d_xM=)=x~2HY~qITQO1ep9W8{w zD6_Bmyt?e?H{Vea>bnYYmbWi^)0b85%dyynMPZn~}2Q)UV?$~$!@1()#D{uJu zNeT3&BMrk>qutbpt}rNeGQ7%5p07|><%FVUKhCh$*^{EWRC8zDr8){n_v8}_5hIrj zUk3Ug_l_Fdy^Rd&Co?0VeeiVb=v7o0^m#Wfi$(tOCv8vpQwvLwm_PnObh~{m3I!HH=oYoIVSo68_ z0;EvsGcVRi^}atQ7VDCBzb3R#bqn3J#8Cz+U!YRt$BrRRP5 zGyQ#knh8G1aTVVVb#m64>RNijn98}b_Ab1%nK4{96AOTMe*I+M$I!}B28oQ!@S zQc1bcUa8$5QH6*C6#ZiSANllySG%TVosBtq!djI(*;AWu_Usbln-GBrd+=dAOJ?Em zC-K@YLW3W(6RxW#lZ`=WZ5+;+(HKP&pEwE9s{r=5hYB&N(xw}qdW+K{>;uh*g1Yj4 z9=fw;JfnU~>q0$R6{cD`_iR&~m$luhVW&gN4^2**nkA-Qyx*n1c}4maAE`*Q987iWm>;J!fE5;4;6Vgh8&_6i*dkwFlF;Kx!bw(;%N&JSK^LJy~HBDNghCN!G~jvxop$D+{0vjPO7da zCA<;YH#?;PTA6Jn>fo&BoH#0baMyb=GDPJTnSw)}2S zI6opa-BdU=Gbq$(cOMHTk$kqPq{Zl+VuMI6e5du$ZB85Rxc=4_2#j`=dRmf~3*RLg z`f7Dz?f*JjG#I$!B%F%C$fKE-E_mQid(nn%Nd~3v%SC zOL3p@j>+HR=Oe-{n)@|&3m{Dd(%^6irl*=ZHyP*EN4+C?z7%bVE$9}V>x;`+mIs`e zvF+z*+Wkrz-(v}p_8+~o${*TFIhT5aKWTGnHTx`^{1bV^1L>?9JT+#jV_@xA0Kd%( z%&{;@_6>Sfa~!f!mmVH+mwL{0bCsRZa+yxgv|ON1QeyV=dLm&N*F2w>J2isgyvIX` z1l#aL>8N8?QPAUZ>N>KlbC3p=SdGbJx$+i>Z)VkX3|ZKD$fXk1P?#CB(GJHm>>jxVO7X)Qk^KCkWMYDD2D&6&tBX<8mI!vJzI{ zR0`MdAl0ZRw{8iNRz3dpjyp2SI;bFoM&smxc(@#Orh~BY>a{PzpO0T*Hd6@2UiGhq z=A|TfUysO3JZFjDFs79jd^vfAVEk}yb84C+<7B$)kXlJlR_=xGBxh%P5~$6vyFZ|L z&=%P{*LXy?#Icz!_h9qn_y}?(_(UA;EhG9~Co;z-PZqmW5T2P9<6E@ybm04v*V2Ry zXIaL&r1O>e7uh;GvnGQ8i?NIkxi-UTvck`->V2@rM46?wJF9&T%Bbd~09|fJGUB=R zmR-B`Nu=BFlvkRqiAogBkuwe$>8MWf_3Lvz{W+911be*t)5kw@5h4+uhxj(P@D4T_ zpNPVEB0f4##S$GueCo$;w;_I+IE+MUkNZR^dUQYYIvn{(jk9e^1TcD!jt=Q^$VyvR z(VE+#$8u= z8QYuV42u`NMM6iBc9K*GouAS;zy3aT1&K_WSPmk!BF4yQt&;im0c`Yi=?s8K1l`3c zVlgo?OhU=W&gU@UIl@u<*OyZ*qD8)~Rh0315K-z;Y=2}V@GSweeC#WXEU+;JZb=xU ze}PboEyT#;B^%J9a*mLOrAT33d1q4!!YWUpo9{$B0s@qT`hk)_Q0LEEsrp6R8)HD5 z!E)O#fQWv}P+28{4KE*3%M=j$n$o3S?x-s1RCLw&BN?ML=ULeXnyE-avFt)@7VPX3 zkkzD&+B)RO9VI5&i?9vu;>b4Ete4Oo{Ov(n5)D4XQH=8_pG{)zv^Ju&Oymw9Zs<)p zB`p_QdZDlWXi>L!9EJdz=&QEWRmRb9$DcvdPLT>uW^&@;B%rbuLOFKx#-#Sfc=TOP4tM)aHxHe+|H!~g z-Nb^)c@Va`_|Fi5KSh>slde%Tq?r%o0=P+!?Jsq6*~PuTxbkiHdc9JT_c^e=kS}W{we>PZ7xv1#Bym*tUjnbLHAjP!yc4f>Gr8=+Qr2%*`^D;gd zLErt3uSg6uqf;l*3im8r!>LhiF`I9MXW;RrNwthJzX361tqjytLvnym1rvT`Eoei=#9-Q+uks7sUcFd_bA#UFFBT^bA@l&s)KgS44#x^&FchoE9ZaTV2=XJS4RZ=k0kJ|q4_rXv zKLQB2*v~3-jS}DOG+JYoO1*lZ;=N1#W<%XcLHy@fT@M7O2Rp&OROOXhA*DI{q4uUT z&%%&HR?1Da!DsGFXxOJJN|UFrPq~!!=0kw`Wn3$F^KsTZBc!ZhjqHo~q!}8W_Oq?JWQ?Pi&;EU*bwkmZ@`5f{smX!zDiKiTgop{W!uq2}&oP&H ze>Ed+nk?1S8=AF~3@;HVJ0v427M(^f%@AwGP?}EciNM!zM8do22rB@aC6*KXOi(6; zGXmt)fyob!XGow&4g&fX64QZBaUUgKwy%BOmsM8i8H=JZBfzPbmQ?pwp?xF#dE%yB z~+S*PRW)oy7k~IP%d&q$P9rdf7zQ zQ=4$V{-*K?ZG(6V(jom~4>54ak(9gg*U60(yYyH^)4dlaa78`JCIoPqZJ_Z;I&*OL zBN5%ghIjMpUp{iIrRe@fyLp>3sBO)g0b8r+DmUOWdm`JvaHIY+%6>u&XDr22kXL2F zsyFd4SJ9C&)LcX!%mon%HNP1YJ}}%C_|P&}X5=YCn0P>inC;t&)M|P}GUR6DCsiOd zp$o2BVe!$7tMFG2%=4?crP~^Oi;$I0Y|WI;^6hhMztPS7)1UkwzNR4E2C>W7fdlFoH7jpe-1gG7AZ(xlhA5+uA>`AQwNEA3-;`^7k>vrq-hxNwk)!!oOlz;ih;9 zd*mHNJ4x$y_)*en-4u)>2^r1gD`SQk8E71YYu>gneh2?|oQiyFL3ZrQ*Y z%93orw0kd|i?c^B-C{1`D^i_93YTe9?dNu(tx7$~fg3JULTthf!K-XUpY5-CB@a2y z=H(29js5Cu@3jnP_}US&Yk+OMW!tE*#9|*^TvfP}u=9xARPX`2wb<#w>dZWX_cjtX1a9b~A>UTLYi6B+&cH^jJlHz^>3NBrw{<5~lJ->cpe(mwH4kmVw0U5?XWI>ilKd)mzjis609pE}LkEqlGp>j(LEBcU;~?`3 z%PNgEbzZA6t@lOVNfjCRk2)N7!?w6CxpM53jW^sw@`oZ^WIGuNiaB8WXzK%5~AbL!Z0XqTw670}9f3Lz6*vJ{2TsDfUy$-a*m%Y+ zo1QMGz$W^XY;P@4=R6SSr_nio(7V*j#BBSorPfbK4Mx8=u!N7MJw|>X+MKVvPDwUU zO)k555VWeBu*ZK@K`tdWl|`#rB2Qu8iDTp|({>|4#NjS@-6M8e@u71?Fz*k9{e6hR z1=-wv>D!#arRGmneWW{)6NE4QaWYY#_uYx@oQ>bpgAQnv&y(x|(ld_j3lt{ukF7`+ z3&{|M1zF62%PuF~9%1$KuYN*)FZ_Rf%9JvBvp~!-g%(&W7bHf6J$5<&2J{s~iS}AS z_l;=$E0&rc`9mTs?pC5JR(#)RZQ^J$0FJ8+!U)_^*u4zpH9lwVt6*}f_<`s8uQqk^ z#~6Q3oZ9ATiY4u#j6X-o1;!UiI`UsZ-;#7z*ot+@xIC+dC_R*AbL<%G`Z?Gqw zv0c%MgcS0a9+L*4P7SP==Wh(z?V2OO@m(O!#!NpS5FANX5R25k{3_Hf_!h-jpFIS! z+6?ps=n&Z?PTQw{??5$T|EMZGmJhm=NTu`Tlq$0C&){VIBpmYD5aW_hvU5`=)PcsE z(wJZi)!u8uCN}EbgI^>PVqX*hj2d4y(IRl>^G{d%E8<$1O!^GK6sKXS2v>5>Dj5yf zDRWE|!4M*oyfj1uPu)j(m$~{Sk#-Tzs0n$#toNBocBaQ&ok3F@n?#pyU!l{in6yNN z^lHU8tBuQ>WMuEe@63@*c5w*C4?6Oxr0P7v^h^QhxDTqIYu0D0uip#49l#rfL8erR zSAS4<5M%-uW}iXo!a8Jd!6QZdtn=1OID3zp5FR{%XbKr?QN9IQa6Zj4X@=Jc5*gg$3=RHsE~WW zizVY84NQX6$$KMtFP;@1$(^l-tqN+UspJgFXYIx8Qxb`hodEKL2&{a!4F}P&^l4=R z0Y{x0Olt_yJKZRED5{b-C2pZq5V)P10L@ul1YaC(`9X^p0%whU*N3knZBa5WroQM}Mj=jAastivcLTZ>M{0Kc0`|8x*+AsU{;O?g0 zfaI$P>pm$$|3n`!2_u`{Wh!|a$gwZNOS#_vY}EDbtn9WY(WH5`L;?mh>6f|=LoVD| zs#LSVGj_+t{Pe3rju@>1%hfO7npHZ{jObJ<5p5|B9A{tpYb9$0w~A~=_{7dq)d#*N zBYd_V|IKbenxy0T<@sJ1T2`Nc>hY74KH#458yo`nn${rMWFJx(tx;r-Ls{Y7JJB+^ z`HkjPp>-JN&T&vTD%l4$(HEmf>dl&j$K9#oO7t!`CG|C%MllCCq>q)mKv#fXr zXM{($?ha`OU@)YHhU|{f*~ug6NCu>MYt#%a|6Fxs9CSJg|FBTzk=MkuX11(AQHFL>DXlWn z6~*^K#e+LA-YbQS@K(_UF*JNA&FoK6X1RB*ziurTwne}-Z4X7)U-cvJGj|G?vR$cS zBF*zp3~7(OJf?vo`#-=5x#A5$c5ZiSHf}gVEKjQzko$yJ;szgPJr#5Cb~q6gX`#kU zCpYJs^+S}nyqz|OMqL`o<9figRRL{woT&HChsGPqDVsQJd8!C-%ZCh_LB5|6JBk?v zjl{?E-x9nVc~`(K(p4KHRRD7)oh`cetSeqVllT{Y zR5wYmYx_qbiK}0leB0LWE4G1|=5JA)88D@7B-2I=Dumx^!NIqU26Su3n+%FEOKVKz zJ`+S`%7y7PMB7v>N!&R^>K`EDeV&$+V}y<3a#P3C~HBQK_JBi(jZbOv>Q9W@5)6MB84yGB=O6d@#RNDmK|r=iy&;Y?S!G1QGlH2o*nQf zvY~Ibr9(64WCI~MAW&CF)_VY$^7_}~sSPryBtYi5#1ENWOevK91`9gzW&bp?DXu91 zfk|nE5Mm3kw49&pHGGi;p<_+YOb$`~nD<;0j6g?YZqnzcY)>V_#3;u?LRka>iDBqd z+zXY$AwDrLC!b`AZP28vcb~rh%0$#_SK7p|@@Q%vA*1(wzl%jo?3^edvdDsIN9{b$_nxa6UKzK(KpapIV>02SzE91$L3#W`igSIwj-hVq zm`FBI8$}CX$!zVm5qKDitxS?aHuMBia8mrv!F%oMo9fuY`>A%vs zW-n(@?M3>#jL$o&DFOgmwO@;KHR6LYmql%-#5EQc@BSiZ{CM8lz)iKyCmbwycq7UDEpPUQ0d5q2a2(_#P=JUW;X{t4_PJ&B?i5yVoQ9ye%Nc4e%DFGS6)GdD z6gB>KDqG6asdxU}ldJWZIs)djm>6(C4HJQ0>6c+MLomAe<(?m=XI4^`jyZzEBZDK* z8RCoI6PYIj)e;dg#eZ>;)awZtXO&Kpc_XRz1Sn!V_>E?o`m^?nr}&aN5$&&2Ss}tx zPqG*C@cbVjl9F z3H%8TIMC-@rk?glFlCw<*G6sITV6%IP7h=JUP3ic4-{l96)Qzx*XK=*3H-6`en~dd z{BRMnJI*96;y+G8RtV1T*St>li%y z7)jOZUdju1pQ9CYhz4KJ)9u0GYXfq8Di5*N#ap1%zTY_1z6w0vf6*^Dh*b8Ifg}4v zRCK@H*%#Nf7`cQ9A}0&V(}BegUh;S=44bkYPd>2>H9>a3$BCA&7kJ{1gKmaFax?lC zo!)A$chuaP48sDF^&(6=XS4amS^mLJvhyVpxw#8Is9ntqw_kusj|MSKoBM=mLvtQpa5m&*K(SX+GWHnbeW7glVj`4_D3 z8H!nS+q0s&f7K=VlTP~`xAjFVmT@eo0KpBS-Fg2=x1IOGr8?$CJj!HZz+V^NzZUhs zGVa~};vi2qIofj^jqs>RDx~(iHm(B*mUW3RBF?#B@4)sKKQ&ICv|V&7h_j|CSx0Reu^RcAa&qXw>ZGK8ui_ zpK?~Aa&YMOfFW4hHu}}?Uoj_@>Q>N}7}j&!AgT?}=Yzb_mAb~>&J_5~hNl?(b<6xe ztmylWKm8)^md)(}7`tXSg79*EyGGF8OuA+1n;DU5TM*z0qc7PP6b{E5A7C_r$7Jcb z*SMSbZBB@aP^>bRki8Srrs=nXxFS(l#ePCjJVQDEM6UH$U=*))L>a+GhIi4OAqrJ7 zi-GdJI44t_j(CrSiDb+_7GNs}yg~qD^Ce1V%8=jl*xLT7-SGEu;iN@I^8@*tDz8S6 znpvS0utvF8?C0EWWHTYNbT% z;8g`+t21taH?Fnt)iwbb%KzrZ{nwYpIZ)bHD-&%Db&yq_R9$dF@_{W z<{!nfH2+V5wSV~GsT_iqC+1_z_8ZCVUwlGkPDBLo$)F+dU+NzJ-GlpnZABsWfbaFZ z{=fF>Z*NL^jo4XusrBjqcH{rvkl{bVj2HFQ|6|WS=WIjxEQ|t4T4lJa$SFkAdlG8-Eu( z_}5HfB(@?DQ2cvq@C@I{V!gQIp{n>Bo<9pq%+4+&cv=|=>_*86ta`CYf0Y^f`$P<5 z`hN>BI=frx3Y*&7K;&>#bc~!|={I>eo9UAQ5DT@9M`a*^?M2tvc9%$?RK3zaE(ZTc z$`5*)?$x%(v$5675cRGeDVq!Am--2bml~Ge8{HWrU%v)toxgwnUo*WW|3FSOP^(c> zr_`{J|Gk`k6I+#?yItdKsY>`5LRDG%sEUn-==WLq!^Wlfkxu-2@*?w@pOsrW>5kI_ z&Of9ue%mzB;wC8L-!9wv&CMT;lgN8yNYr|FjV;#K-wS8_{%eGg147d~wTYv+i6!&= z%hFTgAnU6CvFrWqY5m>?x#XV7A@)9&4-;G|k$uk`%l1zJncq6?zlqVe; z^{eMmFPi25+Q{884`$<^`Q5CCBM|l1h^ZXynf%|c#Z&9pj;cU@FXhu2J$zG71K|JX z1x-wX#LL=qXccLIR81qy0r~uEUilww&FeLk@ycX69dVo9!LU*qf&cYBL8R*9ja~vZ zvdWWeYqY_i|1_X}D6*>JY(;_&kH$am61V+PW9^^D-HSRiVFHkdC>s9%`m)9?(oc>& z|GY9|@YDw0M;iV=_j7QN?SESuMueqVCfSAlPfPRjUiHLsGvT)>|JV9z4Mf;Go20hx zh`x~Pb>l8N{A60{Z%MNvK z)D^?RmN1F(tBzv9FZGf~F<&ZL4eaDro#invymeQsZ8BAKony&W;on`CFBM)w80*1W za-OEYH;XeLrIcNI7*7=ly;wPE;?Wd36GP8(Os1hUM1Dvs9)OBTHbqE93Mg%SyBAru z<@F$NYOhA#3TBXBHC4mBr{_gTv{Lj^@cztVF0?fH5>|`tc z^N5p=vkW0`dYk)Be)*&=JAC?m9QfHRS8l+;!m)0e4x_o5^^@Y-_Y*9~?jLNrLJ6#( zqq$Zi*+;Sah=P?T6&7#X|GtV}e?%!|X&lCuc?OqIOvGO}`{V3fz^?NdDY>i0l7|Wg zMMK^w8xkXQfmUo|MJt`}&9ZixaasZg$IJtV(Z93+R%pU21VQ7(mXUv!EBp`Z<6lpI zn24bMKMk(i6i~xsrGnWAC!qJ~k?uai@ksJQ z2GfI?!ECeFbes%0#2ZusnymlP&OiQ)Q+VLNn%e{1sCU;!K<4IgUSnr785mcN&F6N| z!BoYu#uvZ*ZeEp{kRa2|%12I;j#Fc?t!~(v;T6JnZfi2Ng@|)H0sMz-(SIKEt!Tw4%$m89Wg zb?1M-t9r3MyFGBd_xLg~k@_k7scjnXpK!fR68h~4ZhtHS%GZg8f@=YFp7;OdLt*k{ z3aC1|cViae=G(umIuJ^!aoi{lBj(YA`~7xw+Y`MgssbYS z``}n&zUkhHdiH;RB2uS{nA_Bq1L=vARBL4cnWAHDB=Yur1?)Ga5zZczX4egJ#@3E| zhl&YJmoNfmQQqdpI5hsdVf-xhYt-y8LGz9$)jMj=*tOA0Q5ZkN@it}D6=US$xaf0S zdxLHK=-^z=X_=DdWp^R75g*p7(BjG^vkindR|#RL&gsVl*-R2fRbHw<#kvZhoBU62 z%5OcG7tD9SK4)dnE6T~1lCaZQM&A~JT(MhEH+L#1)kHO6&#LQ?ji$cV$D5rX=CpcR z6K>x;<}jwXmkS?nbKGfiwUC@?)@wF@JEV8eQXPyHr%JB)Yw;QuxpRL)@KtZM(WHHu z0?Uneheb4JS0_u@Y%kB3L*B`Wz094niy7Uv*3iG*Xu~J~`KJ)9yiJQq1gU}Qf)cc? zTg1e2tu6abvBUtm!Sn37WjFM>-l9C-{9^91imub#xwUr!xjJ5+mzB16A}i0By_b*J zxK8{ya!ykm8|IGRrsO&$lI2(JVQO4%l|hC&fFkc%4q$hNiapoO4Xb=c*4R_CpM&(B z$5y;*nLBrd44j=68E?N|OZFy@{UL`=km?c@BlJb^B5#(p!uU%IIZA++Prm z2JkeW|9w-_QTx@-mhT;UYs|inJC_sOFJl{7dknN|QFZQn>>D%c*kYVEcPo~?YFqoB z?arM>XqiIjX~Wb{{T1e`TaRV$(B6qo?AY2;Qx_SUe#*;MFs1L*Ws|X^jbfNd$|whx zqLmd{wfQRlAUk03_1DWA9@l{ggMrc_98;o_rGD#1FX5T6gC%{Be6rlCV(dFkAAWX} zRuy!60UtZh4#}tND4Hmyftrmb9kzytP}x?IkHO4Grzr08ZTD2~ACx_VSDsK*Kq87?jzU27Lij=9B z&g99HSl|K@e)9-lpP~#hb>^7P?FUUUQU>hpx&+Ce@0L{?2qnSMB0-YVmWWQ;T;6O6 z6Krbc)DH?wm1ClF9ldJkZkh4*J%xdNTwJW1f(aHgGTeXsusIX}?5un_x%v{I2J5@g z_jJ2~p&y-PAf)s9vcb>lzd1W-~mSIUI4=CarfToBFeSrp6_CS2_Lcr~=noTd%>;qHaJ!$%K4S*jU7*hf3wI_=cR*?5m%;xqIPwgYr;46B{g+iB4x&?uy5?BUB;OH4wqbhAll_^Fu^i0IuaW{WQOW0 z7`$lcRw8;)!=_$LdgZLwwl6VPQ`7#v*1(|5FJk(9OVY{nQpCVs-P~Z*;V!DP1=_Og z-9KM@%aqHw;O%mRU#7m2n{Mdk^U9E{MU+2xKpVJZcSdDAwB+UBi8Z)=3HJr~9eL1e z+fK+jm#p`AUnXi^s@hYS1@Fd;#$miQHH=$hlTF7a%}TTPo?rGJTcuouE5YuXCim%T z7FIl%kLvH(^n2#kzcL%?xotd~lT~NHydr0CzLQbU`rUF8Qu_Ork>3hZU3O&8xgWLN zp2vc1>)jPiZX6YdE)=aPm$2$4#(5W7NKhWfI8K>~QYH>v$y&R=q7E-PWN$h@nGNa6$NlP()cLT*TAMv(dq9sjqoFfDxyTxYF`Fz$v zW1&9Dw|eEBtJqPJ;fIf!h>beR+*Zd4vBVtSC!aQYZ5G}s(+v7lV*gljT{(px9car? z;@siW5xzJQ8fs$AT}qq6-6XC;Hw7ubw0y`^16JlIR4BOP{`XN8 zmqT`fQs^{XO)~Epp}8$QB@7zAI5PPOz(bt~+5d?K&bYNXFpBXt0C}1^MHyd0F8I$H zZdA|@db6zskSAn{G$214G$A>0Fh z*KDcF-igD34_u8(1sKgoIIqIWgO%M=n)l>N>Ot{d-w2JmcLLY9AxBg=WDk53xn|%3k9-zS?q)Za3QDU5@>N^hf}6K)OZY%L z4P$b7ou^*HfL0RXrwt5v(N@aTfylBQt;QM3(8s90BFOp8S}^^k7Gt2=U(=4Y-iv-~2|C7etdTdKPw6b~ex| zP2(~6@^IvoE&rCL-8*xua?po!1@uf2V6$g`38Onm8g;RLq@DmcvSqeuHnmQZq)Rsy zwu!^F>h$q&*{Pj7KpfBz&a`0d*Vtd92tPq}(Cy6fEO`R^y=|BDcdpxaPN&5NYh(@v zLP{>uSG!w*3NzDLuqbBPIld(g$u|*P2Pze zDt>zh?oglQTbf75&TmtEHUsieWy}^Hq63>4=bdXx^wU2L-M)9Ftbs2cqK79{{)j5Q zQJxAJY*+}TXD)#V<;D$KskmOL?2f?Fbz9DfFp$8y#q~JCV4VGs^<)fZH@ueb*e^QR zSpXRw9lRTuD~bMf*!@rN#C=-Iwr;WFJbmgwC-6YRwI=qMMDSoEU%egvcJ9L{PE7u}V!mh{Ypb2BM#%!v8@7S4}el?H5ARQ|CcL%3-QFEv4nJY~QWl`CNp2Jil4JI-J!bSg} zMZON&X4dSD3G274lw09@7+I&DK7zs14I7$PTHOtIuHlE1yH9~{R(24#$P>;+ zAP{lA^(1h|OK-0TU&6ycBsLA*wrj%dQ_3=cSUdUyk2cmpc?er}J*`X!T2Tn?)#n3z z#F2f{g-v+m00(Xb^513BZ}8~qPheZ&q;Zv|`QnFvwu#ecz)YEAI!9O!^nOaIzb}IB zNO5*S>8=RvE^ zMC)Sz0kN;$aJq|-@6D8Sf?^fn(ULqzZ)lU%MBZ4w%Bk9$ZG6W@vCnTbDrgDWq1#$4 z!hV2okDxu$IZ|%Y^IgRL=~(FnCSuiS&L6dR4DXyIHj_#}2p3yNAhl9DI=v_p9BhNB zK51@YN9_>Wc4Zqmsc^M*Zt$HbEgm8D3o}_6lbuGrwNvAKG9aSXVgxGk+J0&`Hgq7? zwbf&ZY2wF7@s%lVdvMDA`+Wd6Eynuq!MRoxTw+Alk@Ab1+(u1RLC8s)LsmXt{#nx* zL&=h{+2&B-{ub)0E~yzB8y?Zf7RY6fQ!T^}tMTfdi9b z$)_Jb@kOZhE`85LiQt7hVl+`Rwu35qs%6gG1@wk~5BzW{B0EuFsUN{%Y-R$4w3J+k z5)lnm*e8b{=G;@hFo)tS4Y8pVbE34Vp08ZxpO!ubme_kO8@X!J%!)W)G2?uE4+Xn5 zbf|=u{=oW?Sd~XoeQS`flF>52rg=FVry=>FV+-|X?=qqk(1-LgB&p=ehas1L564r{s@8x&@|V=T!dDT?Y0sUZaydb{%5s9A?C?us4~;)yQ!BU@guJctd4^mjBej zx#yD{sCG9XX8XYPaF%@$J#@|icX_@7paIE@#fMyq#SmH5ROk?<(GRY!Bn0 z$5y&+a`tEQ(s?HI6OR$BR)43&fM^vT>!&70nGApjdQ5>>JP#=YbV+x^NDPhCS+n2m&i8-L`FyVPjcZsl>*=`fU#zoq zF%x{hD9RI*@P(Cw$`@CKtH#H)=mugC^~fmAmoH#>&8HR*O7Q))X781~jD+T!EIx@G zY=&wm!S3VVpvPW2x#AqNxr|M^x>H28%*j-1BX2&;5 zkv(Gks8l%{Sz~55BxQ&Ndo%3q#noBh&(n=A(cVL=XYTvIXYKD{k{+#yleXeD2FD<(8l8ixr?q=rAPEaFs`qzZXMM6fg4?Z+U>g+Z)T}R*1;>Yb$d_S zQ}$7EC-%XUGrqtDly3i{x9a6#T!&-IZ8`Y00k6a0h&~n{c!$;qnrNr*a?*7DaH`AG zoQFBG^hDpFY{SzClnm?Cr%4{Rp?h2MFyC+4qsL+ff9OI1*DZl#X+^$x#!a8*qAFJW zqGwY{!L>w1Vrx_)c*%Jz{1B|H_ApI6WPP)L`tJD3_R9mahD~dg%LWhd=Ou8oQdLJ#wcXwoI|KLVRAGcg8Zt z4497y<|>dQ7wX$IL0w(kS3C)-bnL{6V&WV}o!WIW1LQ|+7IyvNS_Nr#K=de2_72bove zZ1ZLZK9HPDx2i>FXB^vgr1yWbO(s4lH_f*V56HP=*Fo#k=l)#yFpRwewk$&4vr}u1 zClJKY}?&X|7W@y{#!{=uY4MJlca^ zok^9LgL&4iqxWC-z^D88!MLq^M7|wdnpj@E4$}!&qqn0pqVTAv7bblM2y3pQ>XE)V zCZ*juLQT!vK(MpgVwvRMu}&aO?XpRiH$>|`u1^+pXO=ma)_#g=D0R7GLFl^~pTnTF zdrI>8xB0ttQ70)s={51!+KuHTo>;&6e4+u%cf8QJY+cUaRR()-y*!xj`#Gah*#+e=IpRh z6>_xPP#<>eiY)Jp5=?qorYdQ^>0LX<^-_DrJ%mD$Q?GvK+Ez~Sf#JyQ*}YNjJgsqx z0BGGcE`@TqsH@3k}UNtmD7Eyp(k zZAy=22PVfFH>WWC_22=e=?>##_waArq~~1HdBk0FBF3M4B4P994y2ZHqwvkHSh=u; z)75gH_u=x(a_c7#uvfUR>n_h0qo~~$r_^y#el)(fNKs!!@(wd?H9dO>Me;Xd5Ax!f zTJ4+0QbfpC^2aRS_6{_tS>HKXRGBxF;Ca#1mzf_1VIdH1#Iy3dU4X}4Bc}WBe$`XB zvD-{pfe(LZdp}j{liXOb@&wUqDm4)t+ z#1Ea6nmn#DP-r<&XOuD!0h@&POX|&1K!z8Ij>G)Ov7U;R9PJkQC6UP3*T`AZ8P?UU zmg`UkBR7ru6I726bwXI#rO%yr(}tJiN}TWe{veIs_MU67;j8klmP*JQquII3xJNMV zNh<-wQBhxrf{4Gj?J+bKI}x-$5>04RZDz~My9Q)hKVHL1fdE=Y=TfK( zKph%MoPGXX(?k$v%?t6F5r(lPafRyouZ{ErP z8^7P8$2Zu)N&Vz#J$@d4ida6;V0yjJI;^))Q_g_xmh*~;~|fzp24O9ibiI-`|y)#=!nk*2wW%1s+Rm@(o& zK+czZz2oS;*V)xlMn{u)!tgxa9SS641*4Et-)sR9VTP7L1ioG<_;mUHYy`p9%Lely zNute#(=Lfzak8S@j(qeRTbl4UkRp%Nxg~y5N60eWg27wYmlnI%9uK&9rMJ*=IRk!m z{08Mh5&Lyl1t1SDqFVYNpbx3MF%cGSUudx1_V$mOfoGdsg6S{ADP+k{Dezf$123j< z@1is!)a)d#6|TyBe`>fZZqn(Gb4S#{lD)TnWYV6Kt7o{D=}e)DXX}Z!Te%F#G)MeU z{XRb68OF@ep(>hyJ7$s$2!a7$E2N*d-+E-?o6Dv0@Qs=Ny;rGGDh;-{kNv8bNp~Nc zHm!Bd)I9a#;@WjSj}@;yiLIqWl1v{xl;D)6yt`!Fe=sSR{>fM5T+;l~4Y_)N>skVi z9QmaFc!#i+$Ge2+kof4@@tKmk!rhJ@ffLmdJL+rafz?FvW(ko73gsW4P6Y*e!qNdEz=K8OVcBbR~vWy z+Mk73EX+Pw%xZ(PNnxUIBabj9Lv7SptC8O4G?8#4pNCm$3Fg_LysY8ZJU=Sqt2$mgn(Std#SAGD<8!)#Iw!*G`!zHS=wNBwd)Ur zHm={~MbFLUEPKYOM-Mf_xt43VmwrokGf~VqJ?d?ov#E?t*MqgVP%AJ?A4=PtYl8QW zOe4nZ)5hx(s*gK(QL=KJy&i})4EBc{cMeOW9ps?YwAT&wfhm6d`*@9KTN1YF;A%T&0ILxZf;)n6aH&oAxvq z@bmq$r|E<0uwE9zzgCCPn)D?QD|buO2x`eodVt)wDSmF^M8*`o>!QW zbA3+_(72RVg0r)769cwPTf98Anef1hz`_#eOD z@)Gm^F^{Vx@N{#dEAMmh>F_o!v(dY$^w= zWy&A_m-i^SDQ>2Pw0vOQ0vUUOZgDbTqI2!Byi*s9KvK7Pzi0H{!L}9=-y6Pc~n$H=ciChxoPes^okFMEg76-!R7k5C||V`6%;yu4x6W z0eb%nxG0l0;J#enncRumroUIt;RmA}m-c7uY3?r?s?vbeGN%iszU}*ADUm#vy++OY zFYg`h1%>RO6$eG3U_5nbQ;$bt>|11{#zh}g6<>$M2qt51%*wcPy z#CF!3icxXW$zkHf-w2Yit&vk~m>14%tX8oaSgM2~UKP7~IHNkq%TnmV-afV-%lTfU zo4?wV0GDx^%9A1-t+q7yFBmED*$tDG57SlRoQ?m5)Lb<_$|cjy`8%XW8;mgYu~i(u zn_-RDaU?9x=e|n=;3T6#48f(k-X%uf0&IbXTwLNx;paYW9qhIU=3JHdywBb*6a}Y* za!UkNHU&b~+^4DYTxv?5PmL1MJI7jQKPkrCvJ~>V`|sXORL2Cdip*CWB@JpGenz(u zrzPec#;VN9`I!G+#{bxKgwaq)*{=W0_g{5y2?SsW_5c4*praW7ui9cFb3{6!&kqxg zrmkO0wjKsdC{qB~kLJ~*zKaV|i*C_>z}h&_HPP4FhHM?1LAY^eHC{|>+dtJrIzF?_1?jz+oqf{1OatC zdr|S~S;O~KV4D5W**NmRZy2+!yD1=S3<0I(Ra`>Nf0QQX=?z>Aam4vjT0LMN7oRZ` z<4Q6x?flE5!x(Mg(ocIHK%11%rCp>G2ieE1)^z{-RXu>0)qnZM?iXDHN4Y7>>?lRB zYj2Fr{;wIE7zV7h>n3?}@2xzH>YU6em-*Mu9{gEAU~dVL_n*^48%3Zor&G}7sGh`+ zcu@iCzaDDY0~JdkAPws#Bnzv1&DWP@OPf642K!%i1b)Ip&i{+1hr$}<#b3=6#F&Y@ zmH9Nbc$dEjVgA@uM1xpW7@zBAWKk|$n_f15 zd&`+LgK2hu!eO%NuMayD04(_FT3=-B5ZSYV5lX$(zq$`7)=Nx;$aGrsy(qq4fWnOc z0M||UY0XI+Cz6q;>7RviQS*1VoXhLFoy)Ut$?R#~WweI<*rUnnZs4qkN(0L27AvK9ADYg~XLM9#37luh9Li zEI?E4&nCOu{8jWrY2bQ6{*uY?WUHu*4n-`6b_D!$P_d-=?@q*|HK+e^=rE=L#$cF~ ztK|9Ns+}amY}DT$9wXVKomTq8Iu`&x0+EbkMH^Wp>54CKd}9($4tv*fN_q--M#i8? z8Yr~__WyCkl*h(_^pB`Cq_}?s>~%N+UPgsR@<9|=Q*PfuK%_V$5AYm>GHjlhvp7*;xOxyLg=)BS?)_Z3NsMY5?4~V&U zR;co&1M>0t3ZhHIn}ANfx$t5z=`aQ^&2NCGOsZ;1tqmp!KRea0-#Icxj$Z4Z0xy>k1H2m407|e;-4tC9Bu2s$5Wv%KNNfoDCceD&>psnarAk!4Bl@VKVgr+%4scjCK%bK@{trSNdP z`Kn4(fkV{U3dXmGj6eA_BCKj7G){kg+J!`@0fwjRCKCo-4FoQX4h#g-KL=^ zyV%pud^;Ivf0VZfP}vB&Q&+bD7bK4|2|cFOSzP&9urN4TxCWav8_ldi>;cX8Th z@2jZ_=miOu&PE?iQ?A}Pll17~kBYCPG(aKJ;7rpZ>PVY9q|>hjzzG=RPoj8}ck+5B zGh>uaHUQ5TWNz0$GvIGjA>FGa5y+MS$S1PTt_jq96yqdwz}VB&6KHn*JPh#WUK^oNLV8_2w=XKfD2LEzL&jW~ZvVHi z9P^&|5~UF#<%N*5e)pzI<=d@#Wtdw){{y>P$KVjS1wV`0k@>T#Q1cf8J9R@sP%Jch z(F1LYnu~Ta*#sPY;3^gu@-Nzt&_xyAu1A*U>YhfDRZ^P4A!@2vpaw{nj@e!bZ z?OrW^`6?$x{(iN`)JVFHXJ*qPfjyg?5f#o_>rW5)J0gvjbDC|cMuO+`E>AMNi~-94 z`(l+=v>Ebem(Ah3>5HNQxy^4u3Jq*)wIpn8G3|` z7+virR3lOS{{G>XDH49QCxxAj2SDp0guQ03^Tm%oZlcAX$PFB%(8?!ippTNfaMgRe zMaVLVYvC%nDUq~{o&6;lKe=7+F+3YX2zhLDT#*n5 zY1?;A-HEy(4Y<}{tUWI%wR@|;Nss6(h-^G6u~z}n#n@S-d%WI!r{|vT@=TVfz2FC8 zU2ONGe#y3Ia0-#%R4ISvP3QYR@Y&9G^6)U-0YT;wfETw&Y3;klm%-kFCws*dEo9oc ze|pLL4W}QTf?C5z$F}`Q&-@ky4H_XK)&VOqq%{add z6-1c4xFdd$G@y(&s`uh5A&(>d50BWr{DKjf9 zNYzjanVB+xDX8Mn2za41b0myp;}Cx5iLc>HM?#g%2)!oSo*QI?Gp{xjBsg|c+@{wL zj85vuRjgroVfb^?hfb0g?J=%^m)MJ%TZStLzF{`GL}G+xkyQsNjicajmECeT_WA|j zA695{x*(qK_?vUYenExjnV{@f4KWMk@Sw|F!F*Lp-pKZ^4x}Ii=H&7_LcZs`-3h{? z;fBEANZ=l44!-?h4i5dP@uE}n{Z0$Qo0J}ZH#ni`Fqd!mN95$K1&xqIqBxG~+FhUB z6e9Dz`m7?39xlK|wt{*Q?Wy}rLPVl^c9YwVmB{I#vP|}l6-qVp`SdDap>=W{a zcW;}~9VFTbIyZVK-gqY}%6y>twXtj~{)Dk~L`4kYWK}D@mxC`kvC+9lg?t54gyo@) zuaw}rOLU>-_)@{8UC_SSEuEThxhP*^C!u?oY@4k}dYxFuZBB&4W}pfo2EN8^oY)W9 z0vYduz~Y5YbZW{}?7Z!A(h>_vZxuMk1*Df*Qf!`c`AtvH#ZyS$=Jsiqu5UzJu&7D^ z)?MAJ{HALsZci%L@XF(@lh0EVTPU*3+o@PohDw@WA6QjFce`c4#Do;toRX5c??Nq9 z?NSv$od~lPlec@uKHrNBwetwLcP8=Q#W~P&dwf&suFXTwyb)jnI+dk>7lKRW$95MOAzn@8ynnJIdYO3+Y%SM>`;)LRv#BU!!#U^_VA&K{v}kjYB;tqzVCn0SJ$>Q-i&Ky%zod-0oh#EfC+qY|q>H+DzZwJNGW+M~|1^(CP&DRg8ksI5R z2BVWd^yh!-EX7bm%29)~ac7Yo6lHw^j2pSIa*8KWSIj3G<*KNooO0TJnc1N8h#BLh|a~U9%iq*@9!TI~ewlv@` z6JG5|NjEd310piV$hDf3g{DZ1LTSXxq{;?UB#uDvcb@cwnQ4O_jVlF&&Rp9TPT|re z7NH?0ePk3)XLhtOha03R1?79M)3KE(nn|AH!UfQCCVAQM(>3>p#(Q%^zwy9 zqcwcFE`{aSp~DlnqD+ZYzO&&iOsL-lj(V$SM4Az$Z_?!7b|4o4V=1-C5d7VzvhPI2 z^+b4}HP3Hdb_zCW=unu6adf&sx=3@Tjw0-)c=fQbP@@E*fuEp*Qn+yqW`|pEs2wbj_8W#o#tslQ0&mi;}$)xM4-@9*% z>TC^b3c{^9>zS_xt}h1gQFjG`V>3->>;~cWmt%cxSnTq=;tT#*if*pRcZ3FT3J49V zl|{`RILJRYW;M8h_3`^8aLkXXRi#`M+Oe0_D#gN?e#@2n-S*sEqI#45|&184=3{N69g2riA zZNJy<2wBK)$@k8f!Nm%TVy~Bf{?HjL+6i`H8eNz7kevJ`Zh?nE3Mn`NWQT_F1m*_{ z@^LRCSL&pnhO$jBds`}-d`ed}k^EVgFQm#nz@MH5?sXg>QDqSg4_h5 zOD+FIO-7~t#@Z9PvS^-oM`iK77Pgcp^TNxMt#vil2()_9}Q}MFSyGX&*ZOtbAGEn?P@v6^(#0mwrG&{ zk!j27w1X{pc(nZ6IP!HeG=?V3dp%i}qJXc~i2!^ztc&wHyT|QFIMU}g)t#Uj{V;QW zu7#VjX4uL|)9s@ks`5MlA`4i&bq7fAwG;}7w>NfF4fpGHp2?rWy~WzW(g9i?3=1TW zumeic9yf}AlfkA5#*r;^!{j(qs^4So(y!Fb2H&#ZDEyL$Lx5xQ@!+oc3y8SZNz8Tr z`NPhm z6E5mQzV{ZiQTL(#I#GrsGf2>ycKLhP^Sbq=sn`-KJIG8=yuR!a<$m(}+1%)i-0BXoV1}BFwkJbX z-bh7xS1(ZxicAdD-2p05 zk~SrRGSOh7`0_h(m6P&SQI}z?bXl@BSd|Nx8Wzyw5NNn@C0e02-Crx3Vw+s!j$MYQ>@GHrUHO^=u)r-2aTFhevEoW${djnpUk z&us@Wb~(`BnCzAb`n-dmx4JQSt`ksrP}6?yMgZ3884JRGar8-T^O8f9g4(S_F-=pNbG8mge+6{)ITn5Ghk5o zt;Cw9Xorya31>PSHE;B57i8wKLMOQR6|yl!dRJCwo6OqRrRd?|^mDdaJVe^kvMo%3 zrEM;@Z3m1vI#lhbkHPZhL3*Yj9I&OjR7*@KR~GDvYp4Z#9PW>}GVfJ>I1xhnHFVv1;A(u*sD=kMEtd@dI1TnjGu~7ln3xNd*g{gnBcTw9-AXt|abcwXa zsRsAA-V2D{IR_DfFkBS2wR5ERqH5uwmTlRUN5ohc(tr`jj28^~UH)eKRV_F1mgi$x z9_%8b+=UJ?e@V4;;3AOmP_7>o!OJ!VYJ@&m2ea-Wn8jJw!`-Q*XD6+Phh9 zGuo{2+Ihw|;~oEVhYYjt>h$~t>Ta$0<&mYc1{vV+BWE>7MBV_ur_~_H4J|sf$YhzA zeS)1{krP9a1cQm%dsYD_W7oO(cRJk+*jn8Nt*nQpzh6Ue9`D1vU>4edj?dGDzHbMZ z#P4w)QQiHzF1#$BZ&Fj5e-n9t4}TPQ?P%0_z{ahV#9~Wf`XCh7uD2+3j7^uVY2Pc} zV?|__u<}K$MzKOR*{V+2z{O-VZ|~|Chr}+2`Cpk!Wk5bIi;m9vlJ5HRh^l@~1lng4+1`=(EF?_BYLi2E-9+mC4VhPkNL7WK z8Mg`O5E<(K;*@QGjR zg{sRn>Btr9)$EXi;oY7SOn}6o)BEMnJ+hwGqNv+Uj;Dp8`3!S5vs9)cL2no0w8o<( zMB_hVpHvt{!;P=dH*!da;BfFbA(Oa5E!I9O_NY{O9%E`mzM~CCZz`+DUBjjF;*9y) z&aWp#I|rAGx1x35KoTY=;*XG}+mkzx`Y;8$_eRi+)y^|he$y4K@)cySfXXSnzP21} z%5Z1wVVPlbC2iQw5IU(*N7cnFZ^9kw4i#B+ovbi7=hPC_Kz47`cF7cMuGY>LM}!$d z8yQ<#qHCjLSq%hHHidW}hWoB?t5kD>E(r=})F98%)DtRhrFZb(diaWdB32qP$}V|CJDY(}OPn7i{)1-uq-W#4 zR4`{C&LGTfH0P3xW6co3Ma)?5sM_;(`MupqX z3ZEem!?9iXJ>#2h!sP16VBTYyn}qff*rNrad=hH*Wi}Jy5(1Df$kP=vVcC{9A-`RY zQbMGiQT$_zo*s(CW@TxM5K^q@RpdgPQq5OG{4X>ZWT-%)v_)6>oBTL?R!enXeY2z? zDumLy6w)l|g%g>2H4#Tjlx^j8bJsw`I4*A9u$4qM`M_wO5s^LxF)Gf;aBlKx9%nrd z7cF*M9%$Hg4?j=J&$mmi;GmM(is!ShUfr(lSzkZVfsU^CSft1c*|TO=JX{jvjYo0@ zPa|du^~*LzP3;c8fUE=Z*`@8EDWsU#)E&+rMb%Tvq{Nu&=ookG8y=-FubAIIk-&V@ ze4^D1oKo-eUkw6My(+tS_(Pv!+${j}-Ep?=Gthm_>{};EzqA0HPp4kQlHpg3M-h8s zu<*MOU#%<>`a!$;Q~?=QrzZqB+dHgO?}_P%eXT}~uT9!X`o%t&`8F8IPM{q)S54?CB|j}#K(60D0V6NY}1p+ z9PZ!k;)8_EmmV%j1^gx$&Xv?2+rIIJ9wLS858;{u$9L`%y+6Hf%jyxxPz|L}T*cuv z6<}haLbVFXOPD%>tW%9Zb*^3VpR@PnSBW%Y0xW-;wDA(jw(Sw6_ANov#$6D!_guNff6nN&y%#A_X^dFqH*Sh7XT5YiQS%=6+MC zW8KKqa~F1NUkYK$a(rwf1@RQ=TlQU=>J+A}nRQ(9v~t>&um9TSj##G6Wxbc~1Xf&p zM{Ag=BI~epXJ^WUhfG!#`xznziIW82kzc!*L778w-2{lfeCEk&%6ZTOCS^NxZG*5J2&L0ah)FT!y~@o_!_u7mgt*L zAs$vwm`+{SOBi^8)0dJ?do9GBUyfS`t8>I@o6$k$KlUml3~vcHUgeWS+)yBYc1($~ z%|5>ZqNj=VPx7}M8~X5MLqC~(iuUQUPcOZgDEAh>lBc5s46SlObhD>Sgs89gah%IS zpBk}_cKNam@azy^FZdET=1~Xad8Puf3J>!qF<#}`Z4(JtkfPwVf#m&QOlh`(EViDlW8MN}uHC%rL2c2=(vn+IRk3T=`IZIrWrB@Ow2WwQU(qkGQssOJit(g-r5LztOdhe@@f zpdl|hL#HSYHCvsd)OOJ7Wh$j081#!s1lM0 zj&?IFaP$Rz0S6zp0k$CLH@mNjiYJ0u5?yeUTltZDA^8DCoY0AZopzF5IW3| zbu!|U;k-F28SwP32_dL~NPun30C!t`F+-JMq*~(pz@Qoh2zZD zzHb@eH;8!~^Nq{$x?#i@)nsVK^PLbPlCFZQ&=XD%N>f|cv{SY08|5#h;_?YvQ)RMG_H8Ei&+HT_GJ(m&zCTLn&JJG7DKqHDyS7DV!ARN%zc zhIpw@6r=CWacN!Dr`^k>X#CXADLNXCGl#c)e@(GOFLZ=31GkEuqC<&xi9L%BqVGZM zdwo7{bjMONCDW88PC|Z0ZlM)vUtQYU{vMxO7^klwq5}%p5scDtk>AXIWCVGq2uYKp z5sL>OKdN{9PNajZ4!h;JPK3xR@^+ohyLO*6PjKRYRqZ!_KBs$|ZP7}*Mh&5nHBHMa zE+ZKg{&?EK*ERhiY>`;)Q9VzkuLQ7TMyuS9+F46mGN|ya33VsP(r4Ako%T6um^+@f zH}cfcIFkisFDXscu6=7`2{nEiBn_b%^}bPTAW3`II(W0*8v6rFbl>9)JiA4e$(_07 zrf4aI6Wxr2i`e>HxX*fzSv^E|6y4bpOUzLM=zP%Tv)(iCPljXuw*y@p%lI_F?u2#x zz0B9OOWSJnbU&e4)RwlpmC*0BZDh^$9M^H@p}mI^`!Q3Kapf(2XuXLGeg8qawOIG6 zTdP-{NgFrfd~b03Zo2uyYeyWJPTM5ScbRfp zY^s6ycsJwJ&xb)c&^0p|^F--c6*f(t{9 zJMkQ$xJy>*tT?OFA*=)kTc#O|KY|rrH_?tjBNqb#C?qewDFETn5gilXh5&6R;@cP` zgyV<@;+xW-J8C6XB3xJM&oZ50`Ky9}(TSxOaj? zh)4-|qkMQAmSb+=QgC}0lmecW;@1#fD5~~vj2?+#Iu6Fulz&X;m zktaVReHL#j(K$u0m_WxF6@!InkZuPN3rXt=vjQL%Xrrbbc*3nhc=y0~)~Z3Zhr;2l z6W4z3OO}=C=U1{e@;HvImZKk1*OOm8nXR;1_>daudf>yKv{rw1m@felH!H;C*vuaS zpUJih}=v5v>7wZkdNo{>yl`MFM+j92iu zA%ph;Lds(R764+8*~?l@ZS%Uv8|U6=M_|XhM=tt|NzANY;`+jgJQ)90_F~E$GvdLp zr0mQF0z+_YIYF<_Kmu)FNXfN{P9%6PV9_U*J$k>1j&85K5{3QRdHw+4^xgT*-Pyi6 zY^Qo@HJ%)~_gQk~LtxFxoEK}o#y+NMN9@p0Hi?{EV8ZNVWVnq>m;%p-2Q+h;E+W^f z+I^p8_5R*-#+in3{oDbw1cXF{ZtyjXLU!K&?FSEDv^^ z=-N`Io--})!uZwsuF;$y$W_ZQTZvVgdR)_P#><3XfFYG(htX;1VLY-jO>Emr?ZJs! zjfQB85FIAh2^S`XxfC={_he}IC~23_o()@T=iOfao8&Mx&*2!73{@P`V4MlLjrXcZ z-GndXV=DN>4E2o)+)HEzoG}U1p_yB^}bc!MG;-fQ7e+X9FZnTf4;lw zu0HUz+auU=$GwykkWmQ(*}z^$(!g878i7n@x`3&d#L3-r>z~TYN%Sa3kFkyxl8 zn>emo*Jv7Dl#a6vzj%3Id3ryUU&AHwK_Z66O0L`abB7HT~415iC_ z=|{yDaajy;Aq6}u$meId&u1crZjzwL!DMROvcbBITiUkn0eX~V8Q%9zE1whRJGGyo zDZ8(rSvq2}=fJgPLHfi^HnHGT3$7AET=s1e;Zryr)#KOB+)C=;OEIVH3TCTU;m%?o zXEaRnE)0crR2f!DePZ#(nNx=udpl*D_B3i7tucu~vto2O2Ec_K)iV-W-Pw%v2WN7% zPyi8OU?*cv@0iO`E1$f-ZCXE%r2e4x2B@S?pgH1&U z9+^NTneNEd?^%|4Ms@gf3-Ch+i5`E|Xu(BA=KRPP&HEwGR?@)zRH6qp(^E3Z||69+E{kNSGrMKFjc`B`7Leq~O6Z4dwZ05X!2P9f9Sdvrw5N&n@C1Mo7 za%UqLV5i?21`NBsrRG&<%I}__2DT zOrIzCR&+=h6(+H?QdMgY809bfBj>#q*@_+=_XkvuC_XQC`Aw;N10g*1cb|jJjLCsx zZ~J#e-X&bfD2f8CvS|ASg(@)d02O?+OeRE7z|_3?@H#1(Q@0& zQso?u=+d@-VHlmQz*7^n1`a5mrtZ9lxQ6zYEhj|;z{a5L4KdB3V22zpqg z-{DvXXENR!W^OuyL0wrLoil_2I#2TTk!;{ z^WWjw`yp_%BOZ;bexKmR8V^2s*ocFpan)6ox^|9|+ug@o&S{2^4bEp$Dm!rVa0hLU z0fS?|S%!cgF%B~P1`f2clV2*aOq%WSKv5#(D#WtJF^rV`(Ou`et!>WSS3&crH z8%!>A4vZ^yVXD_AslnK5+A5?THTrs*9)X~IYmm%u({ib+KUVEM9cJpRw;%@ zOBjY0pa7S0xzu37X@=tk0yuFX1H4bUPALeq$^ht?E`vB|$ibih%Aw^?ZFiJ-soZ^M z(4fLx^#O4rZ8q*b4mqK;zL>ZpCqi#3Ov}^42iK^?_LK~}{q~o(tPJZ`*1}>i3+62F zs`$y{McevDdf@yov~D6kQg1FyzA9R-U+2cVL%2gq^=)r%rP1GAWjMqihPwC|9=95p z%)LXfBZF`x#&&LtNwE;yn561#R7ku*Rd|{^B+Oa8 zXvjc=0a*tYpd|MwH?b%-T}xRhmo7ZY4|nY~u$?0v__WK0gU|GFsxboJ$MOylE%46>Iue12WkAJw-`u$JlrKRB3O7!Dwakp6%lvV!V?MXX+cW4@Sg=^qfE*kMgsI)XGRZ6vKXNR$8w4tpmxX?8b)0`GndmcNWu} z?xhQ%g0keq(nt}Iv^8T2>}3f)OfJ>S@Pf87^nfSBN+mI}FttmBlv}G* zs$DZObZO(cVZYwF9F|PYl2sFAmY-nwyH{MNzg>ss@wWy{!zq60)ll_sl^I^7gllf{ zFBEmpKB)$2IbILy2kNzhxk#{1f=N}`#GavHpq+M8S4(o?`P`5*E9|}pGW>ZAEhJ1S zY7_?ww$5jzVq4Bf&|kCmPz_Ka4aN65%CvE!K>So+dVGtM853$QN4`Np@fw`gygo&b zfp{<=deB=@;WswL^);A85vt%~lx zXrGG=z3FSk|6wRMLFi*J|Dtc^iL5rXWBR>U0_LI^ND}~3N0oqmjHYl7^oCqDN=B{w?tkpbDVft zA&v{q^e6lupbMPIx40wE;?44O{|Al1Lx;Y{Gtp1w1tuUUW-_-GxqfF+{P`VW5`e#e zQN_ea{)=@t7z&UtsCkwjrI0teF%iO*s8qs!UBu8st1}HCNC>k;dT}L*|1tfcXfEY| zcTzmZ%WlDm7F1K76bkgj} z02Se7@gHa39sWxk+)@!>KCssj+t*kQWw`+8So?N`BZpsO9Zma5oX3WStwCP?74cU= z_t||x-06pq`cY_sbIYvEuUZ16iC#6#cCl63zde5mlxaI`j>e|JX7Z8T=#zyV@Gtxv zQ1LZ#jCRerv_EUWJjjiqz_OLFKw~oyv4GZ`NAo0HW?jeoq;8Am{Js92F*kVCHml3l zH=%3rkAIMidKG|x+X3%*IRAn4SIevix~0nF05d{V3@_&I4hjKZh!-vsF|s2)goW~U zp8Z1E!~*Ohly*zR;_DRK_)^4v_h&%Qxlv=?(JAg+goH zI0L~$LLE2oJ9nk&=hlAZ>%QbCPou*AE=LSV$q(XLqn0{WExx_K-}VIN_1EuH0;gR3 z&K%`^MoR7&{6}(;T8QVwnrEwR0C{YECsRwR^{>P}Lx2X+S22a8ehFyez7+pz0!9AW z&+MleU4M;cz!t)nU@!0)UgKY7{j(jB$J)mh(C96ygj~97nyd{3HsjS_~z+JbGoPFWd7So)}cH<#u%J zhDK;kL7yq6aH?$L0P@bON6)7`M`GK_-PQ7<~V^uHV(i@oFzG~W-KBSq8!8ozj5rhN}bPX%r5_DbW9=9Uh z^m9I+muOB|Psbi}ibvp~v{Pt+86Ojqkp8bJA8_;84frtOC1Wm=s^TW1=N>br8d?Wm z1`|}~$4+H)>ivl=qI!xsH^@zSbHS@dRA-&Xiq2CKdrZIT4cVD!b+u_nIGUjI*>VW_ z>;xcGolQLAGx>KFFa!QC!rnSAsCaVelE*#ws}hEc?_=rZbT8QYin3)xZCW+ z0g2$WTUhg-sTgp9myiD`8Bovtr55w*M!!P84Ce!-7fST%pDRny~H2?a-Q-U8L;RHhx>A0LGcke*WUJwwc`Kxw^zH}0b+s7Vq{ZxTNK zG^7tuTPbzE`9JQsN1*FtftpsBX~@Qni)b;xEzWALrg_+bIN+E~Zb{)Ev=4!etXt2+ z>1$f^+QA$3QPnLJ$uk7du+D}^@3i3)Acc50MHl`-NfFq1*J*)?Rk!7@@{G5i*vWAo zl)EPhd`vWk)%O11(Fm7>GkLCpg*fJBx{k|MgaFh1MTtK-Uo&FUpb8}G>XVM~-*c`1 zWb9uC_)<^ddTNb*)jid-0BHvk*$k+o;n|u!;2{lDQp3;wZ7k9z6cA4rWWVsP#eh9v zCw*1~H6zZb0WaVj^lQw1bUAi2r#@~toV~_HOn>)%E0@0 zoV0)VKi_AW>)x*Y^n}yZT~<7dXT#Lm|LsL;7SN4$Rgr*}#c_PV0Rp{wxZ+*!_2(M{ ze~oM&u+c~mXJRxd^6Q!;S8iaQJ7;Ivq&1yN^6@1&nmV3_`b$YPil`fiaEmA+VH`R0 zk1o~?&#aWf+gBYq%@mDKq?bE6mQUY}v1l9e&V?8f4BttnQ)M*Gycop{&%(EI&z0A`iPo_XaAw-k`Kt_D#g+j)~%=A-jD# zssgIWpHD07hR7`Rj=VSZSkQtxI|ZA{L0bYmLTZ|lutV=wc9x~&JdwIJI`XE*+O$t+ zXC4T?Iq_75G#6#0O|=4IODU#9SSaG~RE#{DozE5RSe1cEHK8d*B4kZMc@KAQT?uuh zErfcY`Q zIBi5URo+9ZrM9ChnP8dIsCq85o%t(CP>5HHLz{Ci026+9-+TCB-t+*q!3sOM@J0P7 zQwe5rr{ zs_?AWCWUXTYZA214g5lsRJ^o3Qcr5yn9$dM?z_+pH96;)`!rPm4~#5E?~bzhRnkVg z57V*qK2a%$D8UsQs<#FaXE6$Qulq8@yeYp6qcJn1o-s}$n28y+5tl0*4uUqWs=gQ(4T+iZNj04G#=#e>E~1dhLDhdHUV9 z?ZmSnn*{@F`4Myar7LlYOC?fdfz|ih z2VKEFevop7bKqw#B!Y1C&P(<#0}ac0)4Cxea}047dxb{s+>Viv|~fJb)ra}Co@Yz?lZ@7I|9>7$*YY$ zVe)#=rd;;uE-&|Xdzu6xXfX{3v4ZPZvw zjNEn8!&Xf>#N3lTMof>_QNJhOA$T{RClZ@;A)uPAkj%kjSm{mC;ciq) zc6lNUPrtY$$W?(M5jjGaLmaT^+Otod!yDYHSqh)GUGeNj&6N16NzXTx6%(T_Zx)q(eM<6Y21jd zK-SHs2@bRU1<}+Sk1OHi!tr&20%l1<6zV%}kaQM(lz=ku&z7Hwkc=YTsOZ#z=k7Nz z-beQsJ~vMm>s>w@2GxAgsoglL+ZKA{{UqywEsxl%vMS2HW$rg2%$G9UtV*YUco6iw z70yrmiekFbZWO{w@@7$_jEjM;<(p&VdI9|{l04`0Lhg-z=k{87?K<2R_itN~@T+SAQ(xG#9e_9e;D z*hR8=nd(e4wY=Vt)%&SOM2AjA;@h{O6LUzGSEkh1@H{u!X70SPMD@9VQ1?t@35zNf+I;Bfi;n1*>~Kf@me@siTJ-3l3JuL@Q@X1XU*VY!Bi2)*m17IR-LvGL}_ zDNQw{-{Vl2QA^aE zzxotBpkF|>w7o2RmlUO%#Ql6;<8buzI2Q@e&sO1rXJhPbXKJ5qPS_Rh44XoIxXaq` z73e{l!Fam!zo6!EmlAW21s*YJ#ExcFV(&K9IJnK;mwScmEYsDFe;zC>E$#a-tlAr% z5*_7ZP{Knx{`CV7x)eG&=5ne+uyZkFnm91Eql>IA`JTZd*{H82eWo#&(D1y;%Nco4 zl9$7UX~|*4qtbX!MpZ;>oK!SQZlki;R-yk?>b4DS2R)C*f?TTPUT9$LX5(|M83m_{ zg|W-15x;NB34~c+C*nHZG^`x?t4_Ds9nC77Pv+UjwFmn*E;dtI9;R9URkWB&Mtr1&lHzjr%Zys`egN_=f%BJMLwj!oOYEhCFa6(_D2`oJvjQ#+LNL(BJ!URhT_n1TRQ~6dzyr)1C z6+beYMW6nFwk_vLXfkT1iFzk})2tSvnvZuPO`6v6z89ohfRt=|Qax`IVr{|@rh7+q zgFVNt>heP{8n0q=QZ+f>FQfW= z)>T8bc6z><>^&(x_{brHZGB3N*xC%Vnyyws>I|2J=MsN9N!LMwi&@ z2<_xM+h8$iva?YPE432n2X}JFcO6^3nI#Ap{_BlB)+59d3WT2e)hxT5Myu4jSHA>b zda3WfQ80OP-xjq)S7HoqwzVHw&^tcsfn~+lcCEZ^sLP{d=J{H%8GU#ZnJ|^(2+%_+ zdFi&moV9J4RAihzV+xfwi&XnlP*aTuZaGwx0waRRzkBq*TeQ*`GFlQIZZpp9ahE*K zP|6GaDGU+>BRO+PWwDww-gIyO75^=NmsT^STvfP@H36kZ+`YWLf6gXXMoU=`H{YWl z)zlgE9FkG?<)-1j+T4m0p)A7ds%o>~xvWrBGRIYGB4gdbpgP7kHpo3vs>P%RBUgzr z7No^2g=6b_i@`I^`sf**{qIE*8RU!hk{O<5(7s+Sx$YP_cd)n~_-YB7K^9OdQcBd*Yxp*(Go>wwhx3~eP&Lkr zdw*X{n0o9O!6EbNWw7m?_{S1l(ZapuUxrOnZ*9CZB+HqNH@IRP<{?4?HibqrJ+-+V ziD4P)!$7M#?2Hx_Q;5r_zLwS;xMdkpe!LEBB>G@gznP)_yVF)sxy17#$5w!m?&AZs zMJDHVH~!|Xt?u-EP&!oBbMIK}$AnmX0~W4i{Ap}!yGuwkOk>XuHJy{Ndcc9KH))W1 zFt-~=4Bq2~*tr`rXI7To(R9|D_drU43Due&n6)gl=Ap1&7BrZ7v_9l zbYA%2PmS({euG+r*c@e=lM*TTfczA)k0jREO1(Sd9<+)!aqGBBGx}=U9Y=*GH)L12;e0xO5C#guWT>e4RjZ~u?aYu8n`xk~B{xN3cp7GbQtx476 z%@^hxC5}cKK8V?mGSU_w3l}Mui;~hm6lQOcSoBwTS-6pwpe178pPi(`N+FX~c})H< zEM8E5>$2DL?w@VzElQk(L?31le^D`K7usCzWvorIn*Ygy7E`+&YOAH`bAAfVI4wO+ zk!Z~A@XzEl-r2&_plKxDB1Y5X`pND(4sBhMG_B7E&f0dpjpSd}-ns2irvMsh?Ln<6 zL<4=cU!{Woos0f%%+nI$Ljs~g0*)8=V22U4R|^r1*o#HAta6Lh1HJlN1C>9pKmFO% z>ePQ z^d8sKb_K0>qKhq0*X^mbXNninN55>0!Qv5c2=lTC#9M`ejQoIXRkG5H;#VT~&lz9+ z{ZEWO7NydI9+W6M`UjwvibH+zwJ2zYvB~abP+q8G;?KJpR9!NT_TmmFj^R{dclYgM z%C^kp1~V~Yl&OgV1aUkmHI2H`6wyJg%9Bm0LmAq#;WiL#|V!;>(yGWj%qgz6#1#af)bp zZkrFzgL?^6RntpqG8R?raXNOa=HVimeORYSiLi_@^V)SNvEZxFWg&Nqh58^3wx=-xei+!KB#%nE97s&ay4@7-8GJ_x3=N&df>$YOf0SB z2Zi-s6x^W4IjI6dl_<-nqO_bk_KZVfjzgPT8^+ou&ax{88}=_ksEb-09Ox0}!y(~) zzCYbVaycrG<-@a-bpy+cJpGm8kUA0a#W(#tN~H$9jqj}epn1+cb8$VPPGA|>Wvr=i z-LowA%6!)CSE=jS^x5c^hC`g$D^RHg z^TL8*qpv*0$;0jp1&^*5e(SYtUuOe9*jLf*7;7;UgXn5P+1cSfDxrR@c9&6+d+a%{ znjBX?QLFs0symTYlQ%r;H#^<|nnH-3FZ(BULm@D5h8jGb^z$jy9|rXsIwQY$Vq9!d zFQSUEWPb4T<%q<4gap&WeZ`&hyk+9Ri)Zix?pGnRR1p?|O^1Sqz8DYf=$0?=q6X_B z&GLOPx{Sm>cmLO@pXK!n)1+08Fh~XWLoF0BtW@hZyULGHLK!a8n;+<4-+lLDp%;?$ z*u$Nk=kA+^xQf;G2eM0#bTDirx9Z8io4(C8_uk8Ya(HBR))pSCduJu9iEE#DX>3oAZU~i$W-)u3X+Md&t(~~Y*HVr#q47$zL zc5x`7McSl*hKWu)IE2~g880k*>(C4oUc?8_p;WNLY%f{}>lF)YX8ImY992N6qErSd zc}iUBMX!FO$yp;s-#06El(X|%f62FDUlI-JnjAXS(aq#+)`^blML)}nf(9a)+Llrq zMkR&vMu)xG*9k}rEhaV@JxliN_uK8x!h3V%x}_d@I$5Dc72#aqnlOKT6j^#$7|eM? zXP9R{wV~YUwsAKS`oQ1hgOR}aaviWg-8fcTIL*oU9$9YxhvBgCo)dSESd?^VI!2B@ z^c3>@6#-R@<9%B?#qL>6F7DiQ3*$1vmjBB9`0VWuW#=zcLtT8g^q|WFKUyPKTGGo< zB}PV1RtF8PB$l0?4x#o|w|WG7JTXHBHlwUUYwFl1M8^FoYAOj*P7N%Zcg<+s^;gTc z2kp%=iW7oc8IyRBJ!_JQU?>Xq@^QXGq6Ca|N@Py;sZTqZLitO-^(ylEG)bp6PXhh9 z)+OD~hO)%a>CJevrStqFndit&gd0=w{RolI8?+3GDJ3;59aTjQ@MH3apX^MYe7d9g z)7E?p?rTMWZ(l+`Eihgoe+)7!VZ;2|c=iw+jdM#M2vl0d(3m89}6&7`)%lImC_yzaTd3VW)=5(LbUH}kj#5?w_Vo@W61%ZCS&8ClKTW%5;`2u zOK+b#3zH(|-gZ%CqLBPd2*nanJtH2hsV{ATSaV6OC0;RApH^{ zqpE4$Wu?z0;2*fVgPT2(-rwCwmbbW|^U&(wi*i6Yk#%xA>pxhNu0=CjW zso4KSJ|?i+*g<6S_PzK1!y*1JOrS_;D=~t*v%=Bq{`(uu+bO#NnMsky7jFvKk`ocu zC3R9}l_9L?`Zu#vkuwc1y&i1WtzHfu7h*2<^E_)0;#<4}w@md9&viVXEW+(Kr(Tc9 z-DjVA{vX)aKTrWcy{`-QjCLC!n|s}+DW?J^ATQLN0&$VJBd^H&$b6OU{(%lgs&%&u zu0{GKNIgQ&JqrE$bn>TX?E7**iiNMYeZPw6E;E{L9y}G1m2SP+0QQ{g(NmdMU~6?H z#fY!x_>-oMn=q*2PfTCK{OHh3zeq9;rl;k~VkAywGO8qFca@mANauL8c>^!FXe3@wmwTNZ0FbfFjEO-Gj-2+ln+M+RKEh>c6rF?|ou1Z^_?C~M z=hi+mtPf2VlD!=or&*6JK5JJPy0whfnzD+Ls?sD+rp zs_l3gFy}C75)2xxt$b&-%IB{H>HsR%*@dE1Ciq~^+U~N$^oiIMJS^V-w#?r+uHVp* zOb_B$q)nxT{U0$=z&z!$ZNMSa*RFC^(1xIMB@U8M#GSch-cx1!@HuzD3m%5%9?ec$ zcZp?P0^6~p@E1B$CQzwd4DYCG;;qPF=HOC+##`~|O$ShTY42kfu{ zg}+b5`FX$aE%Zl7W^tK%%oTFafxAEPH5F;_$Lvq2(Ji7x-$YKijY64eS&%GGe)6z8 z(D1oFSF=o;`v|pKrfzyj?ZF}e)1FOHn$!pu`)P6DNBxZ!xATB?uA1DP@u)mvjl zVIPjTS+ z&;G$NJ+EmvTA!M|Iw?9H=46aHX^{=>h?Q2Vi6n~4Jd0*iXMJA&O9##3L-`4hnSqbi z(VSq@t7xPe(Lqf+;-^(ajJJ#qe@7!xST*8BdlJ5 zgsjlP`jS&igP%EHorlKXa7S$rk&zMTJLY_o6N#;^^sr}8b zy;!o4nHY(Akdm2>Ud8P+^LbVk9YyF}o^79dVfINES!6!9O&)Dshb^hAQCENaZo5y% znvA}9{$aeg;#6PUx|NukvF`X>#;{02Y7)i$?XB=VmYM2eYpJAcG+F7>)l`Al8JLLZ z^S6XnJCm1uVar?~RHR+X(KXZE)B4-lv_5IrNN8vN&Y*wYuBf9ZQGJsHzwXz& zxsHy{4Pm+$YpF!b>Ctz-fohETsQsPIt(rz^yT>2QW8od)yo8m@UW)>zLJk*imTh;C zlq(!Eq+<&|;^y0EOd>p&o(h+Oga`*s-J|eVd0RYF{;*zZVnFw1iR(-C`;e!&O<@E* zeuiO#?qT5om9{h^Xm;1<@gGxn;G$h>yg9AcIQu^i`D-|C1Ka3E%~I z8~g%Af5Qa?{-5J{fzLHzOeRH+sgA98?*vM+G&e+f!-wl};Du_(!av`=@!nUD@T=eP zh5v$Lgee7hGaQH&RRiFjefJAp@jqaHzREArdaR9Qe$ztJ@06wSeWm{&*Q*93>v+3> zyyWvp!Bc17$Pviv8aDv#s}OQY=9l1#{rK(&Ny3IS<_1fZgM<_-XtHlRRMav&*(wn# z+zTq5Nh61`_c;C-Q7rr_jZAw-L6;!;Ye)s~uS%=Jqv$3{Wjs3q~ApqEN z&o9UyAdA1B0XWXJFTPV}IG6czIsd1TYOMgc933O-&ph(qXZYeUOC|Nw?LZ{>-=EQg zgUn{li~eb|zyCvEDS$xZb}#`%hyVQ%C`e?b5M$15R=2|9B_Iwp}n;@EYn)?E>&oevh1{{zIPG1RJ-$ktEQ7em_DQ|LxVS zmoYT{0@!Eio=YiQC1vbuGX>y(PXc!ZfEBpfcXCRcCTR2ALg}2qI!g6FEBq$p5-G#c z$q~SQ-ceZIt55#C{`uFCf3Nj?I1s^VR*M4@+TJdWS!EO$@ncu>j>NPCHTntX~RJWSoEKJxAz&EAK4jOO7urJ$M`Z6ZI_Vrn-Cn9l8K0$*we`)l?2d ztXHgi^{<4`T*LO>JS(cJ)4=9mmh|!kU|><4y}g$0@iG!>;$5SY|AC07T=I8K@6E8O z`5WdBWS|S&`4DzrRFDR@)5qV2-zR~8y0Pf^_1|dyT^wA~`R4)Bob_Sq?>|W&5HBQu znd0h?{rB=1vjV7XTZ!knl01klnXml+eU|jW)h*%Vrndk7_`jCDF9VmZ5z|;7zD=8G zkJzEV>W9FqY5`4cy#y&+Q+AjCiF8-Tf%}ZO8(hhvY5c$LJ@eQ7ADsIyHT=^D!z7hb zCur?|`6ymxM@N9vuovQOA^fjTw0i^Z;@*L(&V>3um+v}`$>@@zqc3D7I|=7B8%0Wb z^#5~(bOyzYBfo#@zdvba6X5atPX560k}e5D@q+)X%P%SXbNkIE$?CS>R)sS`AcW2G z%|us#HW~hNq3_e?rXJt14IDD^zcB)ZixIyry(|p}T=u|vqrF&PCH;*?QR%!?jI!Tu zzv6uDGCcLWYK1)ott-A7;H$|ark|;19LxPf6_ond{<%`Tjkx!-_HWqkxp#ckuZ9VbEWiTdBITxOk(#p>5=XoPFy#8$)y%$g1_)<%WfBTyGbj(& zOfGKI+kJh!=;zVR*YPHzyKTr3_}wjh8w`%kf5=$>$Oq7EXa~3)m{hbvF3)vV3{)K;7V~o9 z`3sLg;Jgyxd`})a7vPwEW^`{#4AS+W8foM)rFFDvUpb^b#X0Ok5g6^an}x-x1l^^^ z5ulr=SR{tYl1fx;fBiTnk7InFi*)ocFK4^lD{+1}?>rMo)c*Nt9A}6BQI|d!2@t9%fHIOw?5Z;WN-lh@*99W9$t4oh_A4ybAJohQnd?`>9@c$f znM47{_&x@VPZb{m@+9;K{ZbkepVHXtj-84i&(AA&HzrY@PI~~sNG;1kBOvin0&cDa ze$%|I$%+1AV2966&qzg~wi>S7az305)rrLbk_ic+{KN-(a=ue*#v`w_6W(;+v0kE5 z`?Q?l-x!-1svSSwZJF*ie8;FnU)*=R!2YZ8fBhDhqNUex3DRn4D1K_+p>`!Fb&ka3 z@LMKFni?1@X9(zJC5+%s;)jp)d82pWkGUV#ua|}AvfvLo5xfW%TgPeCbORzOZ?~5( z83&C5np`?#?#D%7c~`*@StK>Mi8rT0h$5T55@Uq%(qRB7%&^%!zIiP0^CjV?yxj?% zrsJW<`0Sbiwh|3wKl}mn5bSJpVZd$zZfAVwVcf-Hl8G?~sbhWrtNsd#EXX$68)Hv< zJIIMBn5&sg9+28$s?g#O8i2g|sm*%A#y8DOpmIGL0+yoDxBl39IXogj5IB>naxUUl z4}4H$JwWuHm*M%n#RI5Tm%@PP0C<(9c)Tw_CPHvvzMlE^WBC%BV< z*?b~@E`S0HK%|kQ7dSGVt0sZD5(n{s^o{sF@IFeS8_+M4Blg=k8|A zZS)i3H+JPO19$@uw1niYB22K*Gk~bjSX5;kAd}@1{gL^=*c*^ETg{x`0KL5S0G}A& z%Ja2<{n22KRsK3oC8pC9EHOc)Uh5P{NKI@;!vetxB{>6GLR;>=Aqof~WwBB+)Uu-BAuZsf6Qt%{tQPjkHraaxJcdWzCI*2qbx;)prs=g_J<+rw49hrNAo}+UEd4RcP2HKCygiegdFSzR{Q2 z9ZdWCZ*{i6ag%%O_uZ=gXeo8aVQNE(t4MIJE{1Pl-?l+`jao#3h@7=)Cmr)b&U!qp zO*8*34c)swvFEZiE%XuZZ*pIfHCfQDC|WRrqtikLwTtO{1Yz7|PX>3=EbI1f2v4eW zTzdzhuQK0#Og%VW+gmZ8Bu(A>nsCqswl#^GmBNV)ZRgdUM96N|BSG!%)4m7IYl8If zBjQwO9MW1|&O+$|h@vTR*J~I3PKq3TxgG+ULP?)G>KMp;k~3mv67#C6VsbCQ#@L$d zQ|(CF;XULOptn%!Qo_PS{o3G7zq~;nWVUsZk_9~28hEz z7vwd!-XXI}wZVksK`THUrAOAbCI?AA$E%F(kFUn*SV`Ba@U@*#wX(v<@J4e|9mdrc z=58ZcJ34i@2(dK%v7@EwWkAZSW^tLh8v)Vb+Wtm@y=>r6yLelhwS(0zIrGh@b!P-1 z{2~~kX;Al>Kk+c)$=iUiN7qp5qGK(>b0fnrQRPUZ)=$D>&r<^28O-jdk?Z`fAK+sBx(;R^ga;$*qCiB|6&9be^`Qi@t zD>3mdGCJ28Wf^Yk1F0mGN;`0SrEw&c%+Gk(I{$3`9W|FrE`gE_|sMe_a(&6z) zld-3C3l=BvMKxQN_Rbe{kJ%2Vf>iifUghos_$S#&#yK{DVOMdXu~%`A4CzRE_OJ=K1$85{WX?79r5gyoh_cv>ff)+OaG}HIZqd4u>6Ui?IZynA=x=1`{ z=)OnqSb~C(!WNudSf!NSNMZqfk`G@P@69srO#Y>>@4Z9)6|jUj6>ZsRiIFOj_? zgi{MMY5~2Bs5F4 zH7Eb7%h{c-e66I(PyRYRCldm8^o;EF9iCT6T1b}x`Z;;s7DcgDy!`s)fSQzq3zX<* z|Am2LgWDEa)^KW~NKMn(h9j5E8X)JTjg<*iHfDskO_J?yq2<#~aMcm_eqx8n^kuL= z`SdECo0*a*jL@Pb_RXL>Z#DeCVtL;_5Gg{x zA*GgXCsSSvc0S6LI?JQTa%-c~=}uCeVku@dgaeAw38e~j#Kjhv;k+(fu$aVB#||yi zPPp!WK~#U$N10qeWUCLVJ(H=!&POnnof3B(VvQWfE$n;3BtA&Ub_H44*YzJqjJ~D0 z#X|7uF)Lrt_7=GH!&UkOf(MPv?_#Z}Yqu>Jx-7`)sWGG6uNDO%d;E6~fPx)%(0I_0 z&??%L_U^%1+KCvfWO1kR1QO4}Hm~sx64b#saEQGwyCr}B@f(F(vS~$DNOmM;JVyhR zXKahN7oT$=y8KBZiuLuZMBS{spTiBE;@;9k%YzqjH_;B%ZtL|?yIx0cDtT4z~W>_fp!s`f@s zi#nFe093x9CdDe2w9!Ju6ajp>>as0ri`D`=A5yX>Hq-2q*s$%qPD%4>p$H;&YbG$% zsH-(hUv0#tf$XT0RHO#JW2lC(Y^0R+w05~6)WP7Yve&-oozk>yK{|CRVgH6_u0BqF z)XS8$2Gl~!EXeaYxI^qrpDT!^LX#+`k2TZG=VFCtvrProF~Lv;zBC*7#Q0@+quA+I zYc6{J$Ztpb<^AYUCFYe#`Rx0lUM!g|Ozo*YfOi$dkF1NML|)nr3bfOge2nn6;xTA+ zJaAzw`uF68*9-W(iqVTTT^twRbLWb@Zt*WB=~PD$zCWDpm0>^4w2I+CzW366x@GS7GHVJo>|pZukv|k!{gM^5QU<-DQmc5!l&=t>P7SgG2;JQm>-1y3 z03LrM+JxW+A_OIlL4=PmYI@}3C*+~`W*T{-z+}!bPn&4n^2l9$7 z2Ft)=ph9rT*RiwL7Aj0E@tz8PPut!*fPr@!WJa5K==`0-oQ9G zvW``zdixA@CcFa2`5W(=p*2WqW}-lAph0sL!FUz# z{SCek<{Pwgbm#!rUQsLg?&kR$rk6in)wubw3f^q^@NM1u;uz*e%NXK7?v5WlE96f1 zF#r)!0kJM|JJWGr==KU6%`&;z=4IVFf!?nw9!%oshP*Hj){wsRmi3Bb&X$0E3K`uV zpx!kagCOH5ujaL}xy<5AeZ2L`$SU29b8JgOY7WZEdE$fO5QfFXE9>6O!R$yc{>q9j zhR_aE#_PR=9$Rz`eAm-U$AI=s4K0}^qW4Sy)i)|q`?;KLGJ-C6-a4f~GDh7pu(|z( z8&X@&WW2{c$;h4g=uVa)h;{)ZA;ad$O9e0O`s7-4?aN{}IdkcFO~V-1qv~>>8z*A| zl+H=pUCcrv(OhWO3mt*2L`uXV5F0^~{?~$6Mm}2lF$=t39 z?#~OT^vUO>asm3XgWsLWutBoYjP|mpfJ#VV^Uh7#ORcuWjI!66-7UtFpQS}Q;T2vp zwuM4eO55wN^N0l37$4l0?Ur5dNi;X$9}@RoTXglj#;;i%CTw6!th=$x1@9Omt1Nq& z9L?w8$=lqDA6%_(o;Dje{vGQ3f$&9VCqAcrml-KZlm%9Bxs`Sth!vc$Yb}>r%FxNZ zb2^Pt56p@1sIt(NpUgb#MBod0(iSQf1;F_yh}(P&Y*dNhAzbRbwW_HI?uP%J2|dRoeI|YATuYs!i)U{ zvD$yO4S)_pUj)#16A&|p++=2ffztC8M61llxXLsaA9Yp7j2Io=Cb*qLC~y~0qJ#=8 zGEM7UDxh-i^o-c|llU0M-wCsI<4-gx5|P#0h)RtajFzte^yr#iCmKCLHP)qu*8$OGB)OZ{y!*X3(!J3s6$&@_MJkXMUU9^*(DFAq$TI z;%<2d+em_ZahdLq%fyYcmD+yELArY4#kD%|O}0l~-^5;-lNac7sVVbBce$h|0u7Au zF3t=(MdD)OFWkvmsI5&qw(;e*pW}4XyX+CXNS*7mNn`|LWi>`?dLK}%g4@!>F|qRX zTinGrVuErHtqtri1W!N|q?IYl-U613kgv5jc)QM0313jhvM31539*R6>qV!*l2?^u zq8AaP->US^x@LVpwdY4B{(EeOYu9S)?M@kwUB2m<_XuE_>872@i_dL*eid8+ zR35gcWF4+yZ6(RQs5k5DzU|S@g)v_>Qb(+Pdd(i&8&4f$Nm+BGN|Ll0*xxOLvBP!A z1NKMFqr)@tMiO+4YpcAdW^Z74VDTA zcdo4kxSI981s)Z(1$mX>y^~;u=F~VcDkx&HrZ`Qp(C$UGLPu?J%o$MW7Z-||NU9NE zGOir%_KvqXBF+USZH8KA*VJBhkOMgAT^Qyc8DC3k6Z15!g@YC8DDy0UN*Nv(0ebS zVWtvUkks~Y&U+tLnn?l*Ayxsv(nd@2X}VFA3IQTzm1d-$xaLXlYS7^u8(ZeHOxhJz zVjk3L(#5X8(b#$0DF>1IAu69(ogGI^DbiZFS_gQwnp-esS z?Hz@6Sbcbak{80-d!t1roBReE?tgxC#cGwQ*SOxPleB^?hThi^GTL?)8fql!E4qwz zBE{^Fa_=~s{M|9eEe-DY{ic9p9@61d#QYiAo&}&I5mt;2p#5>SbWClkYThF8M;-ly?fvIoF!{@0Eeor9?ELkQd@MJO|B_B_tw@&)WCsOtkYwM1Kd%P1uMI21>BT6g9TYmSwHAbVj&zhG zooh{uY>U)23{5B}R}b@e;?LJ6r54$?R^2aSxQKXHp3$~GYd8^RD+#odwGr3W+8o#A zJomP{x#C=-Ldm%6v}}nBaD9=D+0JE#`db>Xknq&ii#4T{e0oH8L5r7>du_UD54AT3Xcuc*YOI+aw=$i7ohwpCr|Z7S(@QV65$m zNcEaYu`AaJlhFfWb0-ypHu zCz~(@h@scSN-~v9IRKIwROIzOr$Arns@YI15&Q?_t7XZIOWiY!2hb(ToiIKBr-{Co zwgqg|V197#Mb=%r8(PlbdY~a)pC<)r0@Rj~_SI9=7*CY9*~bv-a6sBzQ1`_;|g|eyOzxy86r;Rco zD|wpJF>-q#ajmG=I5kQP?J@0}(8_&NTkUgRgC)XO!H{JTAFHcJaD!*nzH02Aw!Tev z=dO)*fR(;C>s#@G!W4zx3YX%L2d0m<1T9VTyc7!Oae~)W{b6edQy$YZc*La=r#!u<#+SCNy7T7St7@NZCZA=CUd`Mk;yulFUExJs~a0)Ba%xI`dP&hf8kh zaCKX#DHHti!XF1D)j_zuKNr5ozIJwzMN5srhIKB(=HtD_CQ;JH)Ck6Q{SA*DVDg(T zwhv6f(gQSZ;=M0%tXO=kd}OL@Uw1=j^kqOmr|#V=Lhn3y{0kr&MD1$AMF##^^<($C z{(XA#Hqc?R@3vGyz1is)16uc*Sg;t-1Lm60d?tDqG9)0E2~2tfJ4w`slt(s8p*EbR2Ed*KRY9BQ|c_Ru!^d+JTRf zFt&KBw^8bIPML8iXL0+8kj9W#>rLnKc4BEj=xYH~XPUcU6_WlgXdPHRBhwhBzW5zK zpxveGNvyucXYfqm!%4)1d6Z~Q?A>od(*tFt_iwd()WDhJ`Qhb!grNOB- zS-zgjl7xeJUrzrdk# zxo~__=TQDV7fA;3lhbC0wq$;MKWwri-IYN zS%T1PB*QDdT*RDP0SgJ-bBxjf00wl+Yga*qpAC4B!G-W&vc+#C*Vx|KxUVRRq;xVUwxB9({? zy2Q&(qQ~9Y-WmZus|CNiPmjF@tkRn>y_DQzO8&sSUO*O6F&krQB>r$^f(IBS2HttP zKI!tEr1PU)aS7G@PV$!`WlN2RxyB#1kGB09;U_nZJ0n&QWaP`=NgUgw{ZHrJBsxR$ zX*?IUSBGFbdl5vGW2_XQUD6|UP{+>y$KGFuMYX;EAFv`SSac`?(#?Rhq;z+ufOJVo z3?WFTNJ@irN;fDe4Gx_$Fw!wY3^2sA@touF_GxExQ+qq5L09K8W_Rza z=V3R=y^4n*arE%3y_EQu2L$c3@at8dx-n0;Z;&60NUVr8e{{gPhqH^oH~mIYvUJ|3 z?n$`R&cp_mqHKoomWF^a82QNPCTnfvg^hgA4{sZEtLvj13-s@?ZN@FFCz;(E*r9@!6Xz3_hOoR5t`()B=9R7Ql^VPtXZB^;60netn&hImqrDWz-^EYOLx*<3i zyY%BEQSSJS8a=A)L8orj5p7^+H;g36s=FW9)G!!fbmezu13Qw%yyb7+(&Tl^9?2wEJ{Yhj7~7M z5#a|DT`tKIv3tXv%;|`S6*rbJMzOzOMt|B$>psyed9BMj`F&mLv7#-nVQ2i^V0e%e zS=PNEn}{WIO7$u)9E{Bh%hw7*QXgs-vQOre^~ANHdkLoS2d)bF!)kN^`i7=v0{F{d z^h6M_B@u7UTsP+w&$7tWDixsh9rdmYQc<+|s^2V`AE`*_)qg4TkP&o&*TFj|Zw+zY zh55wn!fIAiynigr1L&j9P zsTTzX6AL@l_@O81hSAZGd0{xnKeX!tJHKFEM&TI~CTN?4UXT3zHrA@!J)nj|Nayh_ zrGc6WU7I2{bW`a4_mj%uP86(5gR(|@?anO059sewd$diYe8@NIWEa2IeK%q6RvKp> z>PKJ&b}Sd>OEl)4KzhZbP}@0+y-(t{<*Qa+S1N-WSOUr(Q=j7yqnx5rdj>PWZaI@- z!pC@?EGA2ca*(p{SPKD2y&e1=XR!!J7Jb(SdQ?`~$5?8pDT73|T?zsxoL1j)Mn_)l z>1#vn^a3ZSCHEW%FEVKx$bT28jHq3Sn+)CPGap3j>7k9Yg=6@vB;@O0@sO9tih%G$ zcxfhJz*%0}AJEd4Iggyuu2ytM+R!oTR37j_+Nf*<(G6a z`*e+1k-*QdOhdwyuJ-op0Ois&ShKEB?=KC}-zt^v^v~6%MmKFnB1HI^oO$~HRWU|H zyzz~d>K-osy~1pUS9^i(!*Ej;P%C6v14v~3sr>Q7lmo<004lW>Y?t(Jod9hlpc}Fa zv)EEXytMUkvVt=pv2qiFq5x0Q>cQ`-kYs_eww# z+nQnDxNcHAZB9a0QwxF_MOo#GICdoz`0`;`+NG4{X_V0Z4TrLZA7Mm>?i4_NLJkj5KmNb# z*CKYUTrF?w3gq3Q#$fY@j`1Gw_Vo3zC9ME*FZUeE9E9iLxK?n+2d%#~-=_iBmR=o` z^xehL_+9z{^j76)U?EPBae0qe_$(x{Z!2N1h(gV&WC?hj;J2WxJ$*8N*PsN%=`WA?B2oAh zv+n23Y-AX5f>P6%OWU_|9>?G1S!Q~_ebu^bD#2AARW zt8f5~Nech!j1#R8_IrwhQKY+-Q|C%`+L>;rO|ve;XU;2M;u-guv(ONdQ@8F;li2Yv zKRk)T&LW+{1W4ifk;GsEB=`JC0RSg_RD>CHCjVqO068TeOC}1gd;>v*`G1|Z^b0Rs zcfQa}q6Jb%A5Q}(ht9NU#(=2d^y&$KnJbOChaD39ygZKxE7Z{4Og3tmXZYcg>whyo zq@l&!j(~SlOl_^E`Q`BbYE)g?H&W|L7sQSS_i?a@? z-~17Bd~A&aE;n>9nV?24^HQ@70)o-C)$|%*k6OSy*(xu|zH#CG&d(ebGxM%LJR~RO zo+_dMdi&x_qJ+SQyiJ>*g&28=RxSrQZL9R@q>Fv`%rv^5O$gqJmu9ua=A0(r;q+4u zKP+fzh4qV0i?EC0 zNF%@(F4ygr{1&zS489UDQx7IX;(c-9lb6+Ap_o5C_6vNBSHQ3Oy|qcM^x-`gZ8|o8 zxJVYf;{^>I{tQC6mmzj8j7j0IK(m% ztI@&9x>8q9#Y?#sjtxV>eQcDrY89&|*8QH1dX63aKIH&ylqC9QJv&27z*XSt!WskG zTw>bLqME_emuA3hB>ocZj{+`7U%Q*Ibux<~kp9<(^cpGflLq0YPwKk|y~{?BYOJ@lwEQ}kk?znilOy1LSooVds{Mjqny3y+$oG&N-C(oDAZ~y%I0wDwe(1h&&Dg2d}vavHTHu>2LfYQvhHALIA$N($rJg6?+KlZaM z!#^8b+Aw(x)22J;g;cqP^7__=oKNgbTR$k8(wc~Nhce+zdJ8}mobNxIhu^3g!iFF5 zNZK~2ccKy_wQbX*h^AGq8|l}r^;fB8s7jTx`^7|Lb*)C$JC=v^v_6J6)I3TY4Ywdn zqJpOEM)5jKhVToK!hv+V1kaACahITVO7mnN+rA8XB)lSPa$2>=Qre|fy3TqElf zyzd)JS?0?I9qFek^M3KY!QNXJ$%2!@tP;l!E?UeP9Ebv*D2z&n7%m2NJyI9E(b_*AgL4LVG-%>?b+hp8lo5b8Ne*I z@R?-s6-(g^Z(tTwQ9E?rU(j8V*|-$aYcXd&!WL|6^H4z`%c6L9@+NFzsuPmH3D&1{ zP53>pwk4>1+V($J7T3_9T7}mU`0wNoMF9XcSk(>^*DtGkM0vUD?A(JHbG5vAMy}LG zKQX%GnX(=B0q*(k=H87gr^pHIoIRgX0=q<`C;pQFt!&HP`m}6brL~9ymDU>V zmN#)8Q~dueaHZw-t98Fj;;&s7Eo&5JjYEkJ!TKrTGJ4XU14^UU>Fsd`_oW%w;Jcey>FCT`qkiZNTPn( ziN&+mN)+j(sm(Y%+RSJw7(>EfqfW~5@MDBT2h(uzGr1Q9?3`oA;*T@D^R99W9nS`F zyo`F@0>2Qf@_rh6CVXOgF_yONzivDnxDF0*;|x0X0vI$9x^kx-#{hhagH8vxF!xz3 zAlI?veTk{VRb@+{-tQ{VtjS#}75?kvXodWGGIqU4K=M$1Lgf$121;861j>_2lw@dz z+b$+$!$E623)_7tDtFh8yfj*mSlZ{DJyz6l?MJ<0JJou(6E1+hncnE#$sem7i9@oF z(vos9Ey1TbU3hRc(tza3eUL(b#)C{@7?DVtK`7P$fn3Xc%fFe)B{|W)5Go>)tx>_NcgwG~*TwYo2=eq~(CPBMC z4C5y58#qMOZvlBb6`JwG1VrHV zADdT=WYX(oK3Q>H&au{Zh8Wl_ZN!MNl5^QpqW{AJU`}^B$_O2wX_K~F%uk{fbcXIN zX>9bwOFYp99riwD;Jg6{4w1q*j?XtzvP5wQ*wrXlTexb-hgdk~;ObL;GBsWz97c@RCzMZ{3{rY=h!&d;7vGEO<*;{8W$&DT6?Kf7 zUp=|8Lm({gzaCx3Z0#@QmJg^D=6b>>ju%+{4r>0-NAQbgytQsDhrIktLCi!9K@2(D zlhnxd(-10RH3J+<_Nc_0>H#!_dhOV*$sYEUhF;c@ly)bi4i$&Kd{Zg!r-~M5cug`~ z6w2gI8f`8fR*b{PH>Hs$u6+!u%^k3CBB?eXtIp?%_XLb~A;u|rUWu$j%uZ7x6c4cK zOlu20cN5B`c%L0tR^K^+-RNp}N}f3{O)Mny+nh`u!+K6M+=*AqR5oo2`+~M{=dSpf zd5`F|vLE_e6>E6+9yC=R$-__1`z6nw)w*m+u^38 z8HF!)jpjo7wpP6gX3A$%i5-7z!OFTYFhY~O0JZNa1q9fd^m?dmq|IBnJ=Ded#!5fk2XR#5P zrP;H}|DN9W!d}9M`9-Z%pD@W`2i{}wE~W(dLxLgq4tWMY2uj405KV{*N$s2&^oUc_ zPis|j&$bAXG9a&*)Pdo@Me6WwWMg3o3{mQ??&u0~U)B|&oxSR&LlO}dc39#;nT=U0 zE2Qz!7+0~UjpXR8CG@@}>=xG;9`Kcq&L#Ad40;XPte}6x9{(rC_-6qFXk~W_%$WI4 zm35XLkA%z|n#F5wp)!mjJG6xq?+6_2`FE7bq7CfJmcKQIND{C|LFRwQT$zktU`hK##AiPSvT&`7Zn-0rZos7-xr_UWuJ*Jckn0` zCxcYM8iyPU@xIdjmV`6K7GZ)LN5NwmIUT@x0xS334JwBD+h2Grk#{feFcGZc{1Emu z=d%0QQoK{1UPwBe$`G7GFZ|@IR(yE_;sQ$FQ+6!v4UfZjx4n24S`02z&2cyqnpxh@ zq0X5M%a4neKCMDN*a3B~GRV9rqf2)p6@{y;H@+^3-&I%Scn|-uS#9$O50J@`TgWv- z9Sm^b+Xp5CardKF5gi0%GcGfYuH?He{In()z@k-!mR;=c%cRsJZs^v&XKr_{IVxAh zcTqJ9>M%T}idvj@lbm}e(DaEQHgSo>oaxBJ$YONEhmTq0(6S=<%@_4!F%YP;&CDmM zvze*bpF%CHA8kHarbNu8tNy`oFW385^+&^ZV^#nhzq1^TQg;mz%Mn(CfJx{s5yUN= zN+wB;%=PiI>+$%>+FtD#W(CpP{@aRCN!ytrxoUasKN%h4q? zYZtxJ(yd2`-+1u_>I!KqUdJW}O_e4uwnP(=B{7kGKwnfJMiebe*%4H%zaO_L+}wR# z6y_-J)(v*f%@E%@N-?1zQ?0^K5)|tc+Zuw>LxzOkqbv1wgArv6>4Wk_QZIAoi6JGs z(pKz46gwvFTvf`NN6#)0T{ylreuHi4WvoGx!tsJutBQ7B7LQD)i90-!J%kc^5Ne>D z@nv%Qs4HX$SGtn>@VC|e3;IAM>FxGSoR8^;;_Fb`L+|PyxI?=GU*)be7lv}Mx=IFRqv^q zj20OmI9VxW2H78`y5V*9aWih7m{V$I$g|Ae-uS1 z-o8O_134jgFTu;(aci9#+s;v+i~0tf#dp81y`RYVv68p|?Xc1S+l+*~);%1%ej}|w z1#z*Nlb&cIxNx}ojYXVB_=_&>>f0(&*f_*ykZ26;kc0@9ZCrqsQ&^`N{c=|^ z>Pd+GD|t9$cs}1@xx)~6+*2#s#e6rEI?rs@eEJKdG1Mq|Q=iarpQ}bsTnHT$aWlH| zn#NkS?l^9N4_LqyGRzkr&Hc>UbZWQ4oCtmq=?(fXuoQYljMqWe}H&+WhQMkqU zAftO(@xWQ55x;7b(YL1Y9Wvt*Gt{8|9iA zdVX}23uQG&-|=_3q9(gVV8oPEOwm=}j`e=9<`FNaC3ijf=F*RyU%3QxM_N$r1xbjT zKI3730iUqzapFbKYols68}50Hcw(oM+0Ids&K`fdFxDnFpjWbTajFx(KJFf9-tX^u zky;nBj8WLow(nRAtNcJ|I0VW!g`L`(eTg>>HjEK4hqxZFJ3g)Q{xOHqIr%!`;_G1v zT_$A|)cdQ;4%ho*C2cf;*Zo%eNuWfzq_^X$=NQK94$yAmmyM`d2rn^HzcH6}ztrdM zw~wDEvj?&%g+$o$(2geWz7{mCC~JPD)w16qs7Xe(9X0IqVKV2!Zd|)M{K(XO%Ko)J z`qINjv%?*WdB$0%vP>VubYUxmJ^||U*}mgc=24koj<(m16!VsaDq5b zgjEA@Xl>b(Y$%H^VOk$tJ)G%Qt1x2g^Y95ITxQPdWr^}|7*c+6_oivsKHV@YVW?;- zwB{&#+zN#;;NFc}UNdT<3ws^cq8_^awfa!g#5-aCE2=+HT)Fw+(e&bl9!5cGJ{IV_ zM+D~`pvSnNTdc%Rgv8&3AnAtZvLewi zD;_wGEP{bj7y1jY`#!#=Uarz_dwtZuTsZpH?=`}P$Bt)rd3&OC z5yF&tgu?iA{-2KYIp7J~ZAvYK`Bo5nJdr~i7B%}j1_Zc7m4)C-wQWd_$7tc;`@L#} zf0vGUTl1+K-?{XWn^7TSdSwU>q@n+A8{2$JU}Sf@_LEg6A|g?aA>)u2kpVq* zN;t#VXwCz=?My2SM**}2ZY(DFW|JI!hPHk9=h2r!hjIvg`<^7+%W&lVW;B%(i*;hA zfjS!)!|_3vtXemN+NAS^+Tp`CG;l7^K;Tz-0d)jxa5k{|1gFUS zCWZ0UU7akbxW}jdN$UAiRO%h2-9z|(OdCA@z zwODk%6_)>a(cN(&xOO!4LJ15Hqdqd@CfwrN$-}>Wkl&@g=k++IhyXReJ+3Z~x9G6F zvFZs9w%2!8fb#Ke-W$hv$F%@2#t8x9Fv3xfixryirIDlZ#k`HnTd2z zVx)zWUeQgc%RBY@edl_&lrYh``5jN;X5;TnejCbRC!=Yw-wojITV zO-i9!T}OPf&dR|cjP&hebr7KrFo#7zT;GfEI25c8&`H77*wB^9S>Hk;#W37?mBH#EoPbz3lnzc`tC zRY$Z%2+siJX$-Y-j;QghY8^S*j8h@q+{EV+ItOeQWziU*E~OL`q_tK!+)px43*#Oj zXm@ZCDnowZlRJLm{n4!h(juuoe@zeBbGn6x74!T>>r)+f_gD3>1T+D`t?IOqfv`zG zHF~055GTRKlwG()owobt{-*ly&L{mEAgc+_^r&i8b9mXVX-|KAxOB%1q%L~>^Mn3u z1{4h5GSmK;p%danF0`-enCb@YN{HlM;?g6m=wD6QcC+^4287qZDNo7%LXE)csDrKg zK~G>)zV=tEHZ|_|aK5=`-mY({b9NJ#8tmrcD2*c1Z@EKUUSn8X=qi9_W_$;b-y2A0o#4uG;-(ltRb{V_{qfvA^ z9RFxbX|UM&Q9m!ZDzLljwliJD&%wVxurud;fg0kkCPBk~a}E8;wd)un*ML7ooUuo$ z_dI8Mgp*oO2Ih^GLuvgsa*is)3v6oqU2R?hdo%ry8GPj+{REny!9@mjk<)hekJ?0U z(%XJMQgqqGb2p+L=4`L&N$J$Wlt@nYh7`j^r7yG#cZ*`1d1=xOszdeS=P@0uo5T&r zWgL0TaNjuiQZ29^Xpu#t7MA*^`J<)_H9E-WAX|!bC9+cqnfcrWw!&%pW__E;T+q{8 zVIcrFQSSM*%#ADv%t8Vx$nm<<@MKr#17XuMSd6=#ZvFnif49qDO;pWx9aHX*+at!)&psRP zkjw>5n;VRpb@!}}_BdIgGhd<ah9*(#Skf_fl8b22F5|yWgeTIRm4# zU&TFPf=}K3+UrEkw&QEj(}Eye*w(C?PL*cF(qws)vwqOaaEMFK)7&ke-M};M5u7sb zFQaIwnW_fUuvKnpwo+jXjhcpLZp<~!LGzZHoXelFnSU(&-n;%?wk{ekyyjbre0xDg zdztbrW8NDzyEVcetTx(aJ}n~56NQLYnSSh2k#~pdoZK9(=8cT;y0=#nv)jnuDsUTkAuFyA%OB02u1Il+|6)oZ`lm)IXVIt zBMo~Gbrf0NL(LG*d}ayB`5fJF)y=7*^`=_u4lAC2$HJA({md!ZEJe=s@D!JQO~j9G zntPQ}3wZPsU8*k>B531bx&(%5hp>td@>JJt4=K!XQ_dW7M~?`Gd;C8x3U`P4XB7?% zmW8))!oCzHqCaj4V>i+o7t}X4gdh@|M?4gsWvw&O(FWx?Tf=j{XeFmzXuIds+jULq zFX2|D81=K=buQXLJeb=QuFziJB+iS$sO>s5C*sfInMHe!xMQ%pVP!g7bgz^iWUO&0 z?d0hdfF}uZ^UuS>H$x{SMttmPKAltDS7wY1K) zo7Zrcn|tG=F@lu8c5Jh$%#M`LDd%uwyuZ=aIl4;qCbORy68GDC{QtV{Yv`)7A`2e48AUlgxz+JqT5=Ro z<8kz%pBr~>A8*+&<$&;tTN2eVLMecWl^TP-b;m=A<~5?i8lBVL=B)dh+#80o^=%UC zTmNzIBAL`^y3^imFu6Walb>c>dx`d_j?2e%o#xG(Z@_yn%BU)bnL6mFcT=RygzGMIC)lH56s+%%Ti^bp zJTK91KXb^V@iB1Xe)?B@K#FOZI^iCfn*qGy6czGF^uLWmA1b0$r@yB#`{B@d`uqF( z7{IJ=L|QN`y(V}|;g95mucMD0AaS??e)W%^HBx@+7ihsjQkkoh_}=;b^`Gr+EhDmA z*=-RC(Kf{v5wVN#$xiq`wg0q?Yi?0y^s99DF)j5cjw84p0I%s@XR^NYf9fMuVts^# zG$mCUdp{a;^&$}sOv}d;?j?>7ZI}ToM2;Jc5&qHNdeIL|&rj__Lv^3>{xm}Y?)5H# zaoz}bU7Ac+)?3$WhCcI={>Ken^Ba1Sxt@K=|5?6R$n>u|3Zp%8`jY4 zW{n4&nfKGGzl^~^lX+7veRgqc>;8cGM%p)7RhFwe6huXqOS<(Uo0y5mr_weahp_+g zJ8Vk=G{O1u&mEfVvvyWh;pyy62Ygi^U+ee@h~m|~HFRvK19KFM!)u_wa3D+1Pk6<) zFipTwkZ#d@mox$;SL)@(sBcswvZZ$bq1SNip)Imh)1b zB46qWA}ouyKqZvly&(+NwbZQovY%4g_GY?qXPnC5sV102GV;-QYg$lbZ7p{So3a0W zVzwvu$9r{d3n)8hFa4_hU+?n!L6=E>Lc)UIHa0W5JZO0FZEJpaV1eA~sLQgdyeBF0 zvBePty^0-guv_VA%38prjM@Ng(QBG-7}Dt4b_>TEs*<>3e|7ofxNG6kTvk2bnEz~ye*)@EC!j;cbL61^&l_YgpJa0X zzeik|I`jV*Tg)?o{Bgg@e+R_I6}_=^vvCY+C8eb-o|Cml!Jx%k+a14JO(c=kV1~ky z-^+D-X8d4dqEzo-p#_Xwa|hqz{&f@3v?Jo^*vcsd9NucQim8^dvN&ojJ6c7Ub$~tDN)9FCWY-_4wH115Xy2G-^uvrc1upQQ=tx* z3&~Bcjk~{myzbS!{Pv)QFyb5KJ>p*@*zy+Iso}?6o`3%X6yuxXwd-8lf}AxzVGr}B zjQ_m`+$}_F%~2i5{hMp%@eN{#)a6;EW(iyobO1YHpx{@63Vbu-YNj z;_UvQ=O6C_w0r>t@Zd-7rPXVpcyu$s zU}0nlI{RheC*$Y`-cpNTw%^@TP7GkfM_@DSw5!7opff65zpg3^qIvBWlF7;b)^Q40 zfj4X*O02EU-W4z^-ucIIJ^@Oz5DOj3vi|kD%r3y$_Ms}RQo8=(QwHo`-7n|EwME}=bNplXfBmbP)o-VC_Q0hs zwd>=zdEv1#A7I+nC6fV$ZNZ`ppISa{{c;q){P`91c@{t(%@n#89`*tmlHN_Q1U*}T zkXT(>J5w}~Uu<(SI9KcGH|FM-B^Epb&=pN)?dsA70c_;qGuQmI;9cRgpme;yr-7@c zU7-M43(2o+BXP zSORThfMeM93Z!TE>C3I!18Ye2}QVDehv;J5M^UjFj~NFufTD+Bki+%}FfmzRW!TK3eS zO_LozNjtqBGTGsLR)a?Mnx|a{#u`f6FO3gRFVtSTO8?e1uK9%k{ZtkdzA`T9~@dwu$67x+rHl2eJA#yqQbOC(gJWv(WSs;ZooLY zlVt`}?7qi)OWfuINz&5uzL)0;WcExYpG13W-eZ8Fp>&c^t8&8@d9vu-kY#-$8Y+k3 z&-LG;C}(&9&{SOl2FM8 zW;iqR{YBrf62{(toB=RUb$`}^KSje*ZH8UFm=>K@J_+07ttZ^2FgGDgK;8n#oq4dz z|NYGk%B6h(;p@QJg&I>+s!6eL>|J@TThhCH`jsl0Gw{wDFtxaldAu;iUb}~uT?*^Nnd;lzvR zC_x$)0g}R3z3QZWL$`U?BcMs7WUgQUvp`C`y$oFac0N#UX-iVxS1;l;)lP%&J;b#m z+Lwa+z+gq2QV?UMXX`nY0@~qxT1kL{Fl%#XyKb=-he|Razx1@WjHb(Y>}10Zk4`aS zh8}ChL}3nQmT#ltZL~cV_Q)4Nx3)+R3~SOU#Avn^?8GNskgw@ZCgncuuMwRLb+_93 zMyzR-CRb?6wG(X}y$*|l74-y@`w+9=6C=j&|2S5`p;LCbSRj%AY*|QQB0qK5>yjb( zk;iXsWv!4Ij;g$rD^*gJIiZq@;#8;rHdyp%y3_^Bq5c!!sJGUo%u4M5qP2arpC`Z! z6wjXpz-CmV6asC{FJ9_i*Cz8l*-CR;s1Q7wqLOt2l7OWkpE?e7t5*TMqgu6G`Wrpl z3JLeA_Otv{r?C5E0=&=4$lq!idDk!mK_`uT=hLOa@3K^M;>Hk7%5w~kwHlE?>qjL? zBv3A8S7@!g*R80XrfrixAaM;h)H<1UXB)6e4MdTj1^4-RG#{qD&@F>X18WFy%F5$V zh6^d1-GvrAco$K0GwEuw4XcsM0)0TUrLL z@m2);%K8cUu#Mt!d*Edb)!4C0cn(u1Ptt*OIFBXZ0>P|Lyl zNOEDJ#?~u1bU{c5mhfD<&*?h7Vo6(FtQk1~b5==u;BjUMBm{2M;;e?J7%MJia zQmAfhIh~)4;20u~LF-PA|Iu3Vfnz1l-)78n5_!6Pn=);*hn>40ift%qRt zpsww_loZGyhm>TOIf|VZz=4B`WAi={G}5^Yf+0z{D3#Reg5>T30B~G#Bl;88z5yMh z8s(=Q@5q}M#^Dn~bu>PG1L_7quwd5L~G3Tcw--b4zqy`vI~;P7+1X&qP41<^~3@lO)Nn-Wb! zmaXDp$^sgsXBOw*SzadB79PhV0#r(Rm6OyBG1*%MO4(BJmE(KJkxZ&`k7oenH{}wC zhYw~TF4+nfO_mwpQyi4CoxF4a>ac8eV-pdE7p2E*Qh+e6;>`olJ1y(%Ut|pDt7RPf zU}TB!?>k{tk+oAHy~@*n4d)s7-m+e; zv@Ql|m&J8>pXUQ!%L=Ac5(u@b8hT~}#vLD@oNucmPi@3OU_b&qu5vI7hL+$j`KEct-HhbPkYU2tZZ9;VRrKz_g5@J7;U|J~Tu#~E5ESZ)gbk&FIhY%|lLh=iyL1(St- z3{Q^u%!;tP$Fa_H7x*E>SA7R`LoUEv4py@lbiNU+k|O7i6q`hr*xBo-yyV{KlT%xeFMBz`jU2r zKkT;5Rp=W|d(szNU@2^k?|QC($^3SFThN#v5t$~tiqf84Xg|aK466T*M#5vCD*U*m zpF|Vd&|KTGIggY};?*J#vf8NGz8s#|^h&+`D_jGj>n>njhb2fgJU6B;*5|mlA5k-D z$e~b}FqtL4@HtRE-QbFMMXf1&PeFGr>uUM~;W~)!5@%Qd6l^vRqP`WCLh}8m9@2fL zDhn!zB+tDsx2eh%JS?x^+EIjaA-J>`_ei)0R{Zi9=Hx&vE-va&zr5(po&xb`Acg@U z(DeY8p}e8T=_pf-Y#wS-o7b~nbwzohHDRVWN^?9^$de}0Nqnc*qTO{kSeoJ zPTf%KbcxBY4?HYMmv$@GXiqb=u>eV;1Stq=W9!X>J=wBqgC{EmGF#k>rfLzY29{ZY*>Ew}@uWPs zc=)o+8|Lw9j0E%%;R|?l?N`chaaa*q+6=leq*=nD3>_i0FPfPqzUwi1v|` z(9Uq`Q8Wr_TNsz}ETApdH=t@vMid%ee@Fd zZnF&fShXW2Uh=KYOuh&IiR^cEaiJWSLoHwVF&3ME^pe&5tQ%DJtxiyKHS)c01? z?t$RqiemaoCV3&kK2FAKkFrqQ0+R<CU$e8zs0R0+BFvq*PAAL;aj}JfkSoW)F?E-9mxwuwcUv)Q+=Q5m(UBJE* zlZ429$Y~+0HFeJbu1<5Sw^PzYzd>`$Ri*qw8z91eyiK}vBgAPofhhx>Tu>gK?(FHF zX(KDz(2P$Ylu1lcIp-*mp3vCSvJHF+je5{*ST&D`JrMR;(?wu`S1^4SJXUMMi|+_$ zvr7QUkoAr-*?FC1Ce0S-4TlTmMr{T9?C-4ZeS4eUf?VJ~WyVo(-Se-F>Zj5)jqB#e z{Ma=$&Z-|OE;tRG{$Mc+go3kGg|?V|h(6@966y;xNkbW4Cz77_^0>?Q$$zFb?zA z3c-_+$LNjRQXG~NGZ+ETd~`yG5DV+A`cf=e&8tB{_D!JWyvVfkp(U!FD()hK0Wwk2{7wrbKkg7B5j zJ=#A#S*j_q1GUn3+pT>lYQ`0Y@iE3u=5~jxDww7aDS0nQ5E*bH?u}buMjHwo_Gn;X zHa;aUYbWnN+$pD+*Sf0A=Jj>}mUsJ-eHUD|Dek@2ldmjr7#LWgM5C_N?=`D#NVBZL z)pM}-2xP`QNlX=+=*&HAf{6@+l?uIEOB?JX<@eTUBnS9IPcAFY4Uo}9M>O3iY}#$i zsoz#2fIq`%-`utyE!ap=Hk3ESDA@f_?9A6TiBt0 zzoufDH!LKdQO=VI~hg^dqXS%iRcoWoo12)*RbdbU1frTo3 z4`(DI8P;`WK00N#U{2I3Y!Ch#Cp_t1KEmt?tGsfG>BK*D@mbD<8I7mzo(MTST6A`l zH#I24`xUjl5!t-jJ4tIT1IxjlGhQE8Do#wwm56_7C^BB_GbY0}!l@0)lzk)bIl+)u z0R1X+7K^P}1k)WY9b;%-@*r4MWQw!#dk|WnTWSdUFhn=3tuW65lPdNR=-b}}oK>p% zqc$RCE*4Jw`z=o>upldOm!DdTg7d2S@zg}$Zy2%2dEO$@v!4R#-lI7%9MY0pqxoS9 zGnXs3kqICFnxX}_btPrI7a*YG%{*&+!Z@uJeKF#yXOs0jJ+@&dS|7! zt9P8UT>?kK<|+K{OPJ@#cNE1UxWP0Z#Ibmps63KLwZH%1U+(}c<>1qf)V#t~ zb-CqdeTK0c;TgPopti@wVq}u$!`ZJVFRTvhqJj5;M?8$j?-t15C@iGuN*FLj$qB^2 z$-Wt;J_LO6S=>mgv$6O94YyW#RQjoOy{UBnxGm*Y*?W7zMJ zV^Fj5o|hfe>OWYBRavE%7`bzE>{G<2vKikWA1ov(GxNMr66z~0B{$oek8yq!uD*VR z`k205e$?RQ+ee~gDwd^HihdwFn|rgY*<;u9lFtjowIZFQHOr`{)=z*p9iER%1Hjyx zT;nw~Wy}jVS3MWWbE9BO^1;@gYZC-cca$`DeUvEBiqQWx!aGqk_sqOcYU;V>$Jeut zT@VwhTU-hmNB)Tc8kZev=>=h8#|IE4DU;x6v-K{mig&?rniYvw)_KrgE8T367^>#U zz_|Tr{LoVm5n1|iIfx<6f%Q!r7Id0usnWbsZnJQOnoDR1mJPU@b*GNiuXy6qV6`nuFm$VVNpOzRodl7 zzz}C+$5e{B0%tPmRo{kv^oKPS*9IT6HYc*!=^k-)ATz2z<&gba?YxFIP6hb8Lv78P z9M1DMdxi$N+{(~EqEpUD;RxT2{`Wo6@?-_cqKptUmayv^3Mg;VD^{|!_Ps8(+(y@9m{m1m*(CWaB~ z`nn_WKL^Iy@P7Nz_+dH0cP4gnD$VRHfi*F>I*gFtJboszHNz!JwTB5-m`L!b zpDX=X2j2u{%1XDHf7_a8(W~lwRrpedtf6#H zzeRPx4h2R5!}c7o-nSR7YB`R4^(9^Pot3Nc2k~p?U)7qTBk(Vt+cd|L;&ZhhI^&W{CQ5I?%PEcx369;D96jl0avXRTMis+1U zi)~&%J2<~WjwWwqJ;`#PR2BYEVp< zh0C6PY%w9Qr%w%#pY)IJCm}4fnw{RsXH#IQ6>O62j@LiD)4;^-SbH;o`cdDkYPszs zxU6!s#W$Gr^(XuJMu~jE^_6|hJ=ofS+5aEJ?Y3=Q`n9q~E&l}9oM z{bsG?t^%`y@0c=XNTbO zUd{qBwjs2^UEe((hQj#>9V<*O&zpiu{vY0@Kzk)WJGA>nBQE~qWA`pd7DR-#7 zU#1`iH0tRRl73|=5U!8vPo#dKKK$~ushk?O6rQiHo-iNImdN1!=3H;+|7B8G|6t^G9gC!1II}j#97T7CCo(;K{`O?*##wObX z;TgtcpCWTzfv08U^b*~4A1w;%BnjJP)SG1$qFy|JR;W>7tEamZpIlc__q`a@GPE$_ z2)up_5VGFc)11^v6xar0Xj|>_z3(jOjPx9wct`bU!W*QK%?6H~@e>`3=V#IwdpdxB zO_0GlXGw3S#~PVvp+JyPivB{?hT>+Axm?Y1~65EZ)U zrs%nM^trqSpyS&F(gv1>@8nHNE;;h9Oo60;6TCv>tmrDdE}aMZWW1WvgTm){ZtJQL zM|k%$&Z~T-&5hzMJ#TlQDb!OVCufcsAVndk<0KFGxFr54g5UFE=^t-MT9rAR&VDh>oxSVe2i`DIxN_y`%D;d9q!if2QDu7Myj4M0J?d5K)R9-n zVElUqEA4o_DWlX13dp{9l0x=N3OMt4%j?WF-!&lqZ4dMsG_9#`bAhb{c99@IMYR4wT*H>`)Tk)G$C7hY zS}m2HX1!A#FqCjBk6PhXs~Oy{uA%=@3Q;5XVu4I)5dwRw`HJ`Pc zTu1h`Gm)o^JWU~I{erZ0Xob7Mn1>#oTi}_>TeEf@&8b!l^igBoFh^(HbzeVp9+CUq zd~upL3lDy90Wb;&H*}iC>p9APHIiuYU|G(WC|>m$7a_b^U{t3!6}auMcTypC8#uiA z0Lh+ihDiIv_>MxIi{_ClaVlJ0)HNVLevkL@oRV|thG+C8My%tdo_C)0lP#vhBpEKY zqjKSe4VMz*4N1YDhBK&u{(<1b*8@*+!8Sh>v|;sP%k@`&g14jQT}O0kpvJf7V*2K- zqzXW^_c|2Wj7+tFV%5mhk2Y z6(FyAy#>cy#8>HIVbiZYXn!MfI0@VcxaWvcDw^QRJd^TDE!Du zLKHTfY^i($pzYZa(^ArKg!`nG2>phycv^Hc{gnD@IJQlW1+WtWO^Z1&PkKD;?gm?pQqwuyaR=+ltor!_E4gE2Hm$LRS=M4$!U|yDRI5q0#pGc{fo${JjH^2Q z65;^>xKW#KKc&vp7%Cg1$!HfO1h!HO|8$@+-vE+ve54cAvY0Zf@=hKu3R}$*QFxS} zpmEz9#vA*e{n2cJJpEh3+oU`eD&EYk&!0(&8DnFpx`W1EPllCLd zGdr>@Hpw`(V&ZED01)TWupbx_KXEAD;490+Q@c^;U5h(E4TaJF8mEf9-#xl5Qk(Kb z2~+?zq-0c_hyU||JW7PS4~VWMWGk&v!ANpUXfb}wfQkNoshW55 zpP&_2U_6#PnfZqsW5vme{4>tCpXnC-ik!g-1`(wH<2M;TjUIro zLWt0QIh?!O@rtAo0#`vTyNw%q0vNb9ogp7Kso3ASW|w-5tZEILMsz zFB}V)7`{p#wYS)iJPq@o@_7Krr>l`@L|;tlE}2Ft60`#lt7U2F>gv7={VCh~DrBfh zB+zW>$kowu^MT2ffM-ACh}8W-Eab|gcoZw=Q?XqJhY=<|d#eu4ky9A0H<+Yq@=Dc? z>5l$R@sBDFPNGW;sM-c|fD_i~htzjM9Y`VewvH8)zce*t0&oR&TFFX9WI8u^KK*o@zaks|jJ6p04c^m4&A*__zs75Z%uAt>KL72l zfBfk`hE!zI3k80xV;X-&4XLCiTY< zfJtTW0^g{5Dh8GNpHJKZY}!nm7W;?c|NX5&Qs5i9+3A+_|LX}A#m}Yvf0J6w=w>JWw^op*eqP-Jarjyu zZM%VXB6-e?D<=5GlD);oHgmB+CJ&Or(@ps-Lnqq!%J~qmqp%4M4_=Rjd@%rA?u4p8 zO;T4QHgn*}9xPIyc=&SX7p6P$EUAw#b%LBb;Wp`us_(2I`F=aTCp$@6kp#{(fDS7K z4CMc9t@ru;WtGq=F4HC+B0N^n%B!2DF#8n4Xo0;a2mK&J3Re$O6HnRpHeI-~&EZ5o zZnti*2=sb|U)L9<+-0UPn82`X89@F`3VX}S|6Ia<+!fDt6mX2e!P|+Pfkf{UH8q}) zr+~&QDu}mi=x(D#8W7c$Ss$(S9qGb0{q7k@jy|yS=;0~u`Br8!I*?!ao>p210G$=- zRy)fgkY!G6+<)v8NbASLQgDVD;8Q7q+cq)y^{J3s=U^8)$Ps!PX$ajEXZUcW5Q4ps zr<(mNu%fuQ4?vc^K)X@nj>*JF_+HLoMdcT;^IyLx@x9o-H?FyDbN38SSvP0Ek^wy_ z)sGV0QUUr6!ii(82UvRel-mgTpO!AJcBf|VUO78T;S@M;LZDe&UacY&DWE&6GU7m*STG!{y?`epiyqX0CLR9Q z55+UMQ1S62$TgpnEFuxogN*1EnD43fBeW}3trSJ1uHmYep#AL<^Mp_o%|veQRcuc` zq6U6*g3fcqncBR6mKj3|cxs8TioboM3MUF1T2;R|xT-*v$lyGlSwCCCd4jz;! zl@6z&Nq=o17VfA7`sAM}>>eyzwz|>X2IRm@laY`|ip~4Lc#Q#oh+c|j1^xA%q@d4% z0|}N=R>%{ecp<@{b{;3*|0<$^$MqsCaPVtZY zI;j4~gNsA&5CirTFUG6rWPyo11r*>7e#_&;m*)VC?%?P)@9#%Cjpm z0fv8yPE1=kP?1DWlc5>?!0fjJ^*?XdM8)-f=fW@a9~JnF_u{Pr!VHk(_GPB)UveZ( z`)pAy(>*oos>Hoa_Ts4CF!69xvw0t}o%U$l-kej{h`pTNd|;Es%{p;5VXIbQZaSIW zrfSO=WF&HPg-h4`-Nfh1K(&JD*?PV;UisOUHC;}rcy>_XZ5~}~(egyHH$xeTz>QEm zeYz214Q`!H>U(6e%@a$WIIjP?F36?P6UtkCo&qAP-R~EPp4GjqUoUYw-$Q`b3waVc zWWr;$1+mauJp@@~ivL<8@r*L8hVz^Wj?}>b63^tuBZl&?YP~p3otx`KLYfj1eJ$lK z)A8pA!$g55)pCB%J#fmyQ)|liBuaXp_Z>C1ZU$-f_G{1Da5Ic}Jsh?foB&Ce+PQYg zfqWegNlta(Hnn2%0*#J6$L{ zj@Ap=F>}4>0o6})*?Ny62DYih-VJu|VZcOelk_}sbcSrwmwH_rCT8LdSLDoG5hb9o zx-_?Bagj@b{!FTFKQ;8(${M z3B<+w`U4yPTKM}a^r$;NS~;)ace(YI%ftA2u|-xqY7HCk15JZVY^p}H_Np#nmyqXI zFp#-@!bJRZR6V1jEL14kz8vRadTmQtq z%lgQPI<3UULtBR>##s2pj^8|#NN!@eWaVP}Vf{(vCezX$)1LZK%gUZ7d)of@UEjEFsgp7FNEgsE`c=9RfpBm02W7lG!o@~~qb4y2Rx6fz+%?4H| zB9&7Hb*!PT?nL7Sj zhPftY?irtuaQA8Y)U3_K3o8e%**pi(6{2avH$|Lfe?AW#hAW*&;o!@fb@Wmqd7TBt zbz!5oiI^gH;7}{g@=U1gaJ{&f?v`PBWwL~??56(4{^m#bO{~HJgkWTyu?cfQst_Aq zeAcPw7j2iA-Pnppsr(uIqlN~hnTpOfRF@B@)y`}Wi;2fuLT_K)FS`^IdX(wxUqeV$ zdl|P{b=t7!j9^H4Q+GL9+y2#bX6ftt^}fY2M=^%dm5=0sNxMgFn9ZYm-uqJX1KA7- zBFB$AO-@8*cjND{+1pgEm87OJtPc)1eB6(vqMWjnEjQ7`MJ&!5IEivM!Y4|qlk~MP zls7!y#-2=mEt>|sqEq=C(((Oq%~1Cjr*`;wGxwu?M}mG*!Wc{GrgT4C?OTU+g#o_^IE zNas@gNbuEn z$+nF?N+yXHV~5&#iWg@IU#Fh)or@6Z%t76SrVPOu21}>>@v_q~-do!_T1}zVt<6>o zR%i<%_^U2kMAjxVyp>D0K`S<^C4I5;4>9;{$Yxn?@@5`G45qh)4x2_QybW^=F(_g3 z0%g-XMA9L(l@&}8Q@Jj?q8eoDoSI`%8r<a6P@Ax~O{knNl=(VwQ@V3@)+kPS6)YUAgHuP^Tw0Fm}hUNNnBfk44&XilTmV|~$TDI>gbSx^S zZPk|rZsc80ZSeEx**H0S^t?%wg*>nUM3h3m@I|PQV8K^dG}@!|s%=lkO^>E2$VHOo zQkelXOjphBH3{~|*{RWzSfoaDpf(z#uuQk4YdPVvPG9VfG?nF%E^E7FBpS$0T_O6( zrW#Gj49ProJJSU(M;rNei+Y<}FcK+htu`wgzwjnr%#GDlc#ElsLRP5eXg>&}y#)#WwRloIit4a*sw_ZI7M{#-||@s+QQd(eH_1-^*@6PC!OLOsSCVAX9W>Fp+t zpk!f{##pEHHPR?I8w)EfVw}g+EC-+G7Av{)o%NToZNE2u;(o-Xi?6BC)R13Y`xWdh zLZ2tKLOXTAIoOi?==uvXZpuSBI;j}sH03f!7q z**63KxljD(IE4JupBe8szeCN&o0nca5c|I67y6x8CR{Eo23Xyr_l%bK+H?vY(F_LK zX%OPG*irAKhm`9+J@pT-^;_t zn{QpG0#ZY+_lHy;BHoL&&9U8mAWm7x1@0EDF>mm0d7Zd>$8ZU6eQB;>7Yn8yq|jnt zBLjZhz9ponU5p53uSbY&DoZwb5FAy`RPMF)g%8JK7UzHWZD5J;6*QIS4%C0A!e+1DeqH3eX_#pCrdIMeuX+g6htF52 zM(X2r4Ul{3&avS6DEg0fzU9eHV$^4eBd)p1I@BnlvFDrz^xE~$)&kN)6TD$}c?NEMOD__V~>`f?!T;~w_Bb{(_4 z+Bt^6W3lvNUPCYS@tN-@`WO53X&##q{M?yzp)xRPocxBT`ypQ2j*+ZjD#nVAhKpR0 zluswH-u3Oq1M_4CoI*W^ z_Kn3u9o-)LYrXr_8Lkh!`)~hoR1?p*jycj=xb9camG(aL3Oun4()nTlIS$12zGHvZ zJX>q|ne36nNHWAZy;_yLne!mVQ{eqTF2|96VPg-05;O$k2Bmw_x>s9)(i>8ER-9gDb%!=JQ-^xBDmx0J5*Dhwf9HAg@;fK}I7} z|0?wCM0wOZ{$=e&bC^lI@V&wWg`EP0PCXK6eANKNPPKhP_jdynrif$WcYH~U+T@WV;XYM$(EQ@YZVO{;y4*__f0S)HQPz$-Jf`1UP7Q51GI0q5aE3u*2&G?Ypb&se^{ zt`ioVsQ4&mp|kllxdy@-y-&eUK>M3Fud8Fp8wtzC9-f=;Ia}Mt8;3^MHWB#39M$}m z!t?K~@#;@|637WhxieF*jYB&rL)v3}{Rkos3gS{p6v~@Bu{29Vv!10P;s6X7`JXhYZNFQE= z9lP74D{N)LZEH@@RiW%6Gr8XXgm=o=Ty(~uB+a~NMXY^?K_sTxhd}SbDKO04=PJSs z%&BUe*LNh-!;PWFqkF*Vl*CFooaLR8SD!YUwO=?!bBOwZ1^1Y8k);5tw!BB9`sD9z+y9H zclFQ5=hDsruCN1Con(USpr$x6?^%rHbUtQS-jL+~U%X~t&jaiVj*;2v|b2xp5z*o6$;Ff#(E|FA5h zry}5mac>!hNR_AYZTW1it<}i&>U1{VEmt>w#@Ewn(6V}+th2UleE_%3Wu}*nlHT90 zkZhXgq8;KJCeD5kknFmfvLDacW8ULXKF zXsye%(~@CDkr{V*&9iow*!mI3z6}@a+}KKvNVH;b#jENtJTW_TVg33@v3=+c9>r)A z0yh$xpT0c+d%?OI}xt-2hs*8t&Ne62+yxjDZ9)_3sE%@Pd#>@sQPD*sq4=XaP z)BE?9`OEU0x5ga6bRORQG zYV0&FXp8wI!xyzz$0k&Ly85&qVFu1GPz{Cvck>0(4B<4%1f3^ zrJ56<@lHx>xZiEXmK^k%hoyv;!|DjHs(6Bl2m=wNvccMZsPnrp`&!wC(Bl;IwlHFm z^#Cp~%~M!biAeP$dvW`HL6kA&J~3|*h7UM<(({Og@wX=Br;Yd@0ZuT*p#S*_r;-exfEs>^RMf$&m`0^$fPi#LThqOu?Yn=kzjIsh9lJgFIOM z)p>JDLjIW|Hn;4E0=tezHyECCvBGVK$BwurLD_pVx$JWrtFJYqFjYCj_9z{t_`OMlKF+N&B{X52d)e%%%baI zhHV^JFBdr4>-*~Zopuhd;*Djy?{aGtCb2}AI%>EnX2;96#dJMD z(SRH$8(0V)7_3mci8ovukyY}mo54~x_#At!pe8a@3n(`Ne#2hK-<~#YGj|+4FWR)) zfXhpv$EmWkm-NUv?5b}nPu&!;qb zrPpSXvq>sY|IGUU94d0+2KuQ+=2zqGav3rH`*HzzP&I3frx6B9kavC;n9{fXVmlV zcr&@9GU>`rj?0)wNfX0peuK_im*C3YG!}!GVaDOzUhHN)Vs}(1!7Con)|kUmzIxy4 zZv=-0#l4yih_r93um&5a@Dp2qH&{xQi-()Wyb|%xc`h4MUobmpsfAsvAHl!hY7~gnrjITil;HFA?6q-P zDfTGbxA9)s_|~eq5@`?*2u|I2$O^5G(21sSzP)=fGyWFs!|}I!sB$`zve@dD@7ZEQ zVwJ^TWeT(6I^N5pYd zTVZCW|81@nSLBt~{!wCkb495-r}B7QwH|67p@-T`Luy^bzOYMHsRYO(rDpD4=bC|U z#Q}m1;$d6Zz#z7kaAk=A?>0}PIteBAt(7>nXO4Q)D#7;E$|4aB#U_kzBXUNL)M&$F zE9u`i4dbam#~DU2|I{jRHM}lfdQrCUnD{hfIV(K!kf2W-(w#ejmmZIMa-&t4O4StO z5sZ$b-X+-G+@eb z*GiQJEHJQz-s+wDCQ1IA`tzHHsv)*qhjnTeDB#Bow(loWFc8~i85@ynD6PJFBFf%$ zFpdG`ozyMps=gWXb`h(V-Y|H^d!ZGxi)AX9?~PNtZ>3D!x0z2*SW|*%%I0(Iz^^Bs zPz5|%uTMQH`_#lU?81jW;(Ulv`4?8)P24n9C>6`hd-Dm6-HmL8!*mrobIA&6J z3*4h&pmp1$RfD|>%Vy=WXSnqo2KV$OiXm7%$ttkS9dgpJ967R^dD4$cAMap!E)zH? z$mG0#v$&Non)+}>nE0M9Zv`0C9dQ5MeY!|Vwytb9HhkV-Pw?6NYHy36Pr9!bd3UZT zXZw zY2est^Pk+^%!XGz41d;Xa_r=w#B+Cfd_I@}{@{>rWGi|t=@46=Nw1^ZNr-b|hIT>$ zm!5K(JHpdrM`-Ugwnz%5NNf30;c`<&WbCMob#`o^-=u}eQ}4g+o*1RsLQSWzIKMH| za_9lrcKH)ZZGU+miz} zHj69WZs3}o^2RwQ3}a1EZLe{w_HDA&roo8<_Tzk;MBx|o&ia>n=g)mB@oNne^(2Z2 zdxx^rT2nhWUT@PoC;&&+>W}kS7FNgilFb9umd?GLS+8|_RuBuBh!CYaRVv7ZxMRS~ z9=tEthSmZM$bDlDUV0&!=C&^&+C@G3!nRB|S*URp?WY7$vyu)o&zsXG_BS%yEh9Tn%L$KkMzM+i{Hb5O-=EPT=Ex2_qv-ux+@_X-9G_r!X3 zT6)GT=>Ixbio@KM;vcTaXgWm?Rt%f%S++=-!c(=e85P~0v8Kt$^zur5i_dgi>RYot zH!Y`DS{=E2v2ev<&~7lVz&E!h&3_yGy3j<`+J#}8u}>DaQ|-_jY%Vf4&!cqDK%s^ZSY3`S7?2?i8P!ec*h4z+mUoHrV&bx-;&@ zjD{r9=EE$IZf)JUPk*lnM3C()ST&|niZn8P!Pb0`J0chtZyP0YI zG@6ZB-}UU4yQ3imKcmtAL{el}{H6N2qk}=_$&yJX<8Q3X`V{v@j-G9N(0-q!S-kcI z&K!Fz;QA~-C*h(4i|_RDW&7$d-0Dkmpm|@K^b%8lPJ?`*8T{tRyqHxkgwMQO1EV8M z{cD(pWmM+%PfKfWy{!d?bc~4T()2IAwyZMl+?KD&U%PUBRgG9=JG23^yho`7l1M%DHcDl0|6_4B0`iGJl2ZP0 zbT0>I_IRe^Y=py+@D&xm&;BemgVf1)Wz+*(G4qxlJ9~)!ACYM`fT}jn_9O-gKh1cL z_3$aA9sxiy0HQOTzjx3(gUsKJrUxMwcmIRMQMryvO2+%t_D%^j8X%wZtUn5$ue^A}R_s=*vQNlw=$;zC81ihl$m5R{c z&p&yw+yv?@o#5%q{13vM`9TJ!stN7?WSz~&w8&Yb_nA>w%;H2|)w^jj%2Z5f;(UN> z{Mrt!73N<7JWI@jO*>bpE696V2sxq*fYSBW$p23ujXF9ZkfZO41L-FfXz<(sBq;6x zK!PlRpS|HlVjeqI8La+-sREez&6V zdFRJqrRKsC4(!j4cLud{Ih9SQ#_$y?P86DTYRebd!3DIXMB{Mk)9BQEHRW7QgeIhDGcf+Qrm zO~X46DbC+k6&U}+{63pwPf+XhMka1A>~f5CRP04^F`5M~U@~`snup3iuz1pSrbS1t zZ%KIzyoGL)0sMk|MlI-M4_}Cr7DSihxMwU&w*L0|fBswry8qti(_!?aHLFOEg6!&EFk2JXr4ydT9q}8a1X2 z=GOeBvrFkV`>Gi(VtuV66DHDoddQR96hGl73d8`-`8scbUZ*Tq{|ib)%akU~itH(- zL$#`1?D|39_%qGtLVw@ixDvderLWxk4^>=74%Wvv>hf-4;lCj2KO$KFdSW;pfQ^w3 z{_kMHzmpc12W-=JZ98}GuOI%Dy0|D{Tqmn9|H60u9M&BROW*;vnG2rZpy9x31LpbT z9bM7@2#ty&;=lLkf1h9jP->Mq_30hIQ78Vnm7D;O=H=|xU;L}TD+MqYKokJx*%<+K z|I5ex9LIesz<9l!1^(NE{yWkN9^iqvZ68wH|5Av5pID?F6q$V-!7J)-=yTEzP*2*Z zK@`fWs;XA?r;q@8`AXa$A%zTKRQ)<<%YnSD-BF-I0a~bAZ8zkJ*q+H=*dqCh|Kgb} zAfDKnXU}u~FQ(b`C-=SXkl3vV*0+sNig-wtripki%XRqtG0zMFVEV2!=T>PtV#6Jg zR8)&nUn}Z%McxggWTR zi4OljLB2CUP11n>Ni%ye7WW59dd3Nsv+d;rK6C8D>~)}7yri&g|8|bXzav=nZMYoY z@cnD|bEZTQRuPKyo-iL95mns#N%3o8hYEi>YD1v7vjfdlcH)m4`ymPBeDCL=?*b%1 zor?v8^Oo#foBx^)4+jbrStVFkrsp@Q+y{mZ>?~W!0Eq#UmwTGh>p+ShLb;!@b4_8z zKBNTlI~6t)Er3G&g!5H{f{i%mI{nXDADwu=ChS!qls~m^BodJNg|?RSgLW}Ba-JV1 z=08UN-6X4u9YyDN3JU*Iz*LLH_M-*_e9n(8=wVUZ{}Q4DZXH7V5DYhkx}p7e$3Hp@ z6iT6woW5i~V}Zd@V3v;(J=*K_+O`W}$RPSr~8?U;!AmAP# z*;Jp#UQtOsFZ)RFulw=OM?Wt4obyY~0ZC0Fr6{uSsXwEg8fc!--7ODs#v6>B+QlfT zdQQx$@2y@usgNdI%XD^BJ|J42?sJ;2lkiLxXTkc?j#$K_f<8CsYPS{lgnRUR-Zm4f z7>a(+x$@V6H`)J8|6jSs0He=Aw~i5G6C>4#Bv2L}(lqpTnR1=7-2x1uR5i@nu*l~H zrT4*5)u^WK1VB)#c1BRg?FE!?R{@pTPfGzpw_%_2J?SZ+w0`TGNu=4~q9kquyql$J zOvhSkd@@$cAW=XECXEI3?W|7K2bhTH_chPwfLeLTDuFU^+H{?9(b6>u=iMwS?`IY? zoFwo83X1VsUB;ew2Hhr$J6b2yXa*=GdF#gbCQJzmCGVaCCDK`*dI)e%>9dl`EYjyj zS=j6~pZR8-)yM>$E!2mBIzEey(&naOfWC}=R(?ryDL*&u6YsblHz~JtIH|@n&N`|A zfrVSh%H^>B+_L8j?Z8|Oozp7l6nfl(Dt0q|XTi9W$LGI2%Ki>s@Z|pOFzK&ns|h9% zWdNrxm(>W8Sr=dhh)e}I7Li=CS!B#}&uDCTZ4onW)j&01=?9v6=QZQg_e^-l{uq=W zU*iaNc^s^&J|oF_uniM&3Jb{-@?Qn*rfUkYXaM@|3V^1;F3IWcB&XwImy9~4kgH9MbJsiSsy2LiB!i$Xqcv=y zdd4*w$s;Nw83iY~69H^&z7_he6FIFX=NL1Qq_j(HJJ&&+ z(T6*;vyL;;qP`_=;b4Fp)Vrj0=+d@oqGK5#80|Vc+$l2|1^!g-9_wpku0t`|kH6lUlwu-L&fPnor)OP27v~-GA&jJZS!J>!7!aEwlKX zj}D$;;Q{5w9~yxPL8waMy+gQ$;OwpDtIPKoD*$J(CePknYcNr2RYJfp z%jiTwtQX+fyn33JQ4RAb>;vjZPoktdHT4|Y-#q>j@Ekut;xzz`iPL2_M#MLt)8nhs z&&#pK2*mPW6$@&zO}%WrU4Ao9OV1&22A~wvc+@An-)jP}hd9Zah4eUdQkcV+lg;9u zBl(+oz-hA1&469mR84?b@`cm2mkJ`y;3=K}%Yq`!J9Lvqy0KF?@D1~FfIwpcphDDA z5%<109Nz*I!ir-)fI!J&w!gXzAnVjEv)qqW`^p*822blX11evYombtO&r<2H7wLg3 z3^$#dN1+FP^GLRVwrYU<_#1oOYE}_yG7(z5fyY{CK`w@@!NGvWAV6S9jx~tQR-y70 zm&nQBPv6z89abotp8z^&HNrRHP;`-G(UUhbz)wUg*u1OBBMNm+*n(8JE~ z<9}((QjcCqgj}qjM(3tN(hE795{G54Axofb-%AmOz+6)4&qNaqTY3$zPRDT!Ug5tE z;{?~bR?PsW>k1Gn5f%lZR{(}m`=ailf55IssA}`Ymi<-*R4GgX2oUrOxdPTFm6m5j zC1){w*C(-rCB&mWq2#tcEzEgq8UFpi@}T4)ipTvNPPK6jQME)P{n4}!T2={$1|RGj z_MVQwZu5P+FZjX1vWKnuZ593H3LG>3b84SD2H38L59YqmwV*x)AW^;PZW5VX4ZspV z_dJVntFP5_MOA$zp^o~PfDTJl{mrXaWNp_`RP|I)o=fV{X0%|)pi82^{`3a>{{0%s zJ1Fge_lXtmqSN0Gp<_l@G(O7P>{+T;4n%xVn%z!IOk3(r@qx|S_xG-C7nFDu)R~_e z+Bdd;7vFB|D?$;T<1A5b5ZU8eNy1$q-Ud2*5>M|7?q0Mt-nNEQE(?mTq&N+;-t6u{ zpt;cYK1~h8e6*d2<+4HrI+M5r5ksaSv3|e0UwjPf1fTUR!XWlEnZp08^ zlfv;O>u7T~h#42^Lu()@(O(j|2!^BF6lU!4L52O?x2Li=6rMb1ef?|o{Ll$$QLc|L z*=n?f$xZ>>&?)H8D97+x?ofBnGvXElG_xKapO^ee-_IvmmoVW7u4aG@Bwojf7|wYD zt4P9g(y#y)hXE8ZE4E|@RFJpu37ixJCv84&Ek7x{w-7|VK&(oO)s-;k z&p6_iPNGTIpq1)Ewq^ki6DBRl)Ucfq&^laFYDBW@scB}Z$O6g0nMt!aUC z4pnnOWSU1|ykpw5OT2H6<r7-58*Rp@DaoP|NwBwUsinbV zIy@AN8Pu{1A<_$pq47K&XAfb6(RtE7INQ2y<&xxPf`ZT$H(-Bv?gH3&;;AOb%_Vz3 ztrE_2UEDPK*nh`i&!#Q8c3W^EOyM%k<&9SLVz#4^9HyZ$8;3 z(KCs8E&6;#itJ=hY3e$aa8S<{ac7Wt?^W-u1YWXsL}$_H1Y8d2F8-s@_=_m?yDAK_ zo_a3f56N=kK;GDhM3Hzb`|WN#$hcb6O(6E#nP(}FPAWsa?lX9zgLi7Li!n#=j# zyFti?WetxAHGwQqJT4tSFo%rM+}Zy;5IXFliFje?<^>kAec8*|+1s8kdCf@93o~G8 zfoR4 z*6;#`Em?-tm;iIpg|hHDYNct5w;PdXa>R3}#0uZfshxvX_|^6{ZrB-r57>#4kxtW- z#v{GXF}~{;zTf`&-nhWHCUKUvnwOfSdJ{B>^$MrEXCwxcbfgI9Z{-=C0&?oVE!H#S zVo7;?-dC76!hDZzPfK`#HNepJAV3tN^obZ7YPgrw>WN*dM0oOLSHakyin0cec$cw_ zJLG!2HgQPs%Hv!1oNqD4>++XhF9v__n3u7&d1pEOhM+M(nG%MlvWL?bd2uq}o7Qmm zY%j~I00XMD=W%|%B9@M`;H5wtDey+olA$(|@3H%>9qsk-T|7=aChV~1UqYnPS6?G( zLFAkO7Hq=ZtkU_%T`_u+#h>Qx$!LdYE7<6znZ}UdDnc04_!^dI;%L*RFpb~X>(OFz z&lSH(=y!Sbou@D84wW$(ES8!O!OU$tHej{V2q$UxDN#g!gx>f@qx*s#lD+~<#GG~) z!SALanx<`#EcK_BoB-NqI0YK7K=&qO4XNKgv2&+(YCJN*tUP&v7IsGc)RCfbRf5Q1frFb?81W7Fd^W{PUw6QH94p|1 z~G&ZWWvJ_jsLLYK!~tDW5r>+ZzT2_C&B zl+lE?As8n<@a{;XGS&c2$_zZCpnC#%XfL{qpBM@6E@Zypjl+fegyuJ5PE``O@ z*J(iNWv^{^IA8ab{=0!&ehwmW;q4#nJO%FFFyfPV%SJ_XxP$tTtT;Y($Ut!>|Fvv&nIqjRU2`!tD!pZ~JC1KB?3t z$~^Zlgw=kX7opWzjW0K=dZTdZ?>iSH&<}OJqM|LJ$6t$t1APQIb_gm)vWMAWX0Ud> z>b%cFrMz;nopj)qP^$%j{)z})ki9Jj?tIHCNZtXKz{04(@I8>~tMaSz_R z6FE55acBF^QpPUw`B!9ZtI4N4MX&G3q^Awq$%h=hB3?nevb<;`?a<)Tm;b z%?AEz8gn;e8upQ*q;rF zhVjsI;JH#3Yg61CB=qp_LAD055!_uSH7KGH)zD)iB3w9_-+FAxV^`)h^C{fT-1AU+ z(N)X3-y#j22o&ID+;BA2y-dGp%Kogc_WtVo%*brmBZ<-XQsEJiP2e+~cPyy*??XD# zr%QLF!e;a*c|!uOC89_I8p6K5DzZBlJvW7a*q)Cu45t{lA34L`1uwbJM~WWK#M2wd zd=zoZ?_jb59rjS@Za{U0?Pi~oh z*H`m65{L=K{N|g$flZON3UY`l20b}Oz59)Z=4PYuv0=~HUy`l3XtC)Cz$NH&q*;Vq{TmjbM(;epxflDU6@1cAr zjRD|%gaMPmJ)93v;)aprD?&jm9Y*wJeVt0Q%>$15eNZtJop6I#2Zy+^bBNq;VybpM zs<>Lye(aoZz}A{)+hGM!|6jF)9?lF*VIro#8|tEumK90rvk*F|Z9}6ru<0Mtvqqp9 zT!&h z0IS{1So~IUi!kfu0L?&=a$$HHdq%z2e>m!+J9J%uGwuQYId3n0N1Y|h3A$MZZ+i`# zb#y{IWO=xcYMr{A{o;divODOoc4jhEt4?E;j^;7np03>RhUEE{|Dvfg)A>6(Kw2@| z9Qn%0UNe}`#@P#iH;gWN?%m|?AF3I5Ig1h@+5>LDgPFEv(V0ihD#225Cdwv7?^9(% z&ysgPzMjA3)E^-0AEJ z1*0Bj@_WW6g#2T!nz;E#)ise9jEX&us6K*uc$ft>HT0rF7th>(q_D|>dLj0KLNdS3 zX|OI0_9O-x3j|>Ah^I7p+o@a~AVL`EuxOb;+<)jC-H{`7CHQNZa_M^L)##-4Izcm7L*87+Mhoem?arzqq}|no zEOU9B^t2Si-Wbc5&6*Cl!4b-Z*?c**msD$Yy4x-n4L#N-OP%dDx|}%{v+dJBic1K+ z@(n{xhzbVdUG|B#Gq)i!xIDIM3$}-nyy+{aAw6h;>+hl*itNXFfdVC8fdJOeXNj z%y6b3H1w^Apnc^AZsD>ScW9~mR8lKecbW9s8=t=-*JW5IypadKNN+@O1KMS zA2P2r96k%S%ALR8GnU$7Q~#6G<7$YyQPI}vz>C41IaN-xyV>%XtYfG>NrpiRI&l$V z%7@V9o4{Iyjt<#R16=z2E1g$EI#`t+mX{REUhBj#Xb6elXLiff7%c~;mKXjk|<3ApF zCoPGfDB53D;=U}grGV$Uit=Tv)?~xJD3hwT3nqW#Y=hb4i(M18`H_1ksUB8Kg?OTAu(~xZsPu` z(rWQjaUG2xsJtLX!$b2yPHPEok)7`g8K)`2)vJAiv@JrGcI^$AL8IzR)${^WlMbDWp7(fNDg299v)G&Rg>BAt&Zg98LIpmzFmD#{) zN4xEVsDw>He6CS2_%J@kbtjd7Ec9<-$4I~vvox*AtwlgpqoS;O@^E3}u=#O=BFF2i z75YPyxoys=Ndo3gn;r5zo?sWcZ5kM#JeS1L(ezs=8f#ctrrY2nO|NJjYC*y-z^fhS9roX^UJ$TuWSf4#N>@#r*bgktGbsmSh6bKv3j z?&mt=9Lv8Qu3eb5&LVaIkImbNe+b65DmFnYyQpF6$_F#z?s4`P=1KTcOvuaWyTN&3 zK*>u8TV*!+De)d6=J;$|5_eKOIbzi%KnR%z{&R4Zc7s>_}W6UE^%YKg$zOJa^8C2wlo~JIOV9x{4|t07BDY@e#3I zJqYiNx}3L~bF0S!wG1q(G=`ggdB|6OOiW{x(L*bK5pFNtd%I9|3d4FpgU4G9vfB-8 zMC=KI?UMtOvaW>8J^8SsJguHl)C7Z@IskG@hqZr`*mpQwOWDw=E~97ffb>AT#lOEx zX7FL^rPrS{`!^e~bz-O*$uMIcs|m2Va)G7r3HyI;;}6?oC+eV>PxVOlg0X!=*YqU0Q?7|LJ2*;yaVy-f|)!MigD9B$9& zST2Lo_jkr=L-H^Q+MBKH{pSHIJ53icOZfAqtxtZIiWh=K4Z~cX?TW_5JL4U`xnnh% zO!M6KU;<`!RK+3V6_!nA?Ry{}gf zDA)qPY?i8!Qf)YJn{dwzDAt;WVI4^0YywN6C->&bSI?=lx{iZ|s^pNe{8l5d3 zLFhQ^*SK#7y`FhDv0mjyo?-ob+7Y5{QR_cPOP35Lacg?HmmI>*m)U2ToSnPXxdoAJ z8w|mp!gr4Iv7H5ZOIcG}`O>7U{AP=|gx4XSGJGl)b)Xws&iIZ}bV5an#Aam*y)2noO1o{`B|*0lYlnkQcHmdOmlynr=5Ve@`1 zQCaWex5lZD2rVS!RoX=)`ob(`InRpV8NgN6wc;X@^sWGyPB5K{J?dry%ps0UJw(Gf z6vVlzC3`wFf~H$F@IQYegOJ3r59Keyx4NBCy2u;(_Gg8JuakW3C+e;D$uqo31K&ML zUMP-gXwB}xVxA$3siFtD+YCu}AfpPTzhj)93oPClM<#J=iA}+AETqR#Q_o%98WJ*IIOO_@ z$~6T!+*_CxKihMqd0l|HsTouFcH~++nei{nOk3T6(pqbLET*Mrm8_@ZT3H zISzciudAgZvm-?OjU%Qn#F-BnBaf1}{DjafWpNueU~CyzD>@-EV6Rx#g)lkY5hug_ zaQJG5MgQ$fNH7gZd>v9oKT>2 z>~1r$SHPs?9LPJoq(*B=Hi1%?D4T9(o-+6D&3*+lt!lHM>-M%6Bwb+(A>Y%SUjGl_|MM^L3IEMDrAO`Sfaf*AkheQ?%MT_mqH z)#OAny`scOe1NRz^FGN^c>^ubX?5WE=p}{^K~6vVme8F~t2W7K&JgzVEqhR(KwmI> zhS&@FzGjLRU=Vwb14d8w4q2ff0}7pD%FY#wAjzz()@p9=$2Yw6I{rsLR~D$!4o{!9 zQQbaDV?su+I&{vQj@iQ!5H#tDT6L@MAcREJ!lt~}-UY89J12Ea7#qNE!RK-n0 zAm;7x8^|NrD;3<%!mw}S%!9ens~d-X0ADay^%UHn7G4~jD7A0?`Q+5ks zF8Umf`))K6j3F$X^ocpDauVZehHQ}r(Js`q;dr2_v;B@B_If8*ghB2yNI2Tj+5NtN zV!7yn6cyu2EAv4!=~xSaPp3dYh#*tBm$JdDpGh&9FJvzDJ7csH;4-bk=}(s4>X;m9 zmC%--_yRJ+5|0R{Z7GJ8Id^i+f{2eYTxjcFxh34i5YF>9jP0W65d><6#y`qdD-fN1 zI2Cj5_)Jx(_Ro@-`-cd&TjxVZreQ*^f@<1DNp_v0w&vd0T}T<^2@X0H+{<{H!Gu8* z1w2B5^b@A$9@6SyJ$Ttw|EJD}&-5hOd#vpaZwwQ(?o71`;C36WIK@;{8GNgF875YA z3q}HIJg_mSvPD8({mSyS_C`SDS}Cts&lAEY^4HCKKrv z{K1ZNn55UhO1|s;pbe+LU6q^@(OD0)xl##?z6Nh}C{u^MM$5{nhcs@ITUC+co#9nw zzZ2dN=(kHMhmDJn&z0WfdVMQZKyErzxEF_SL>xTA6kDz>w zl8p>>%LK^lNHg}XTW44I6LL*2yRhZieu2S{>ZWeEY14ygpSLkZT862KKQ>`$*9u-g)8|w8Givdq8KEM!SvUy=ke+TAgi0x0b8*CjH}x}^STDE zgmKfNCumk|_1FU-gT<-h8>JEIi-G_33{;g+e|9CY0!R_`ipeBLgnu!;0{> z&jHmk;_u&IW`J649sU0uUvi3Qkm0Gd#vdGY>E%_^05JB-!>+m3*SD1mO3weC*nnwN zPPzHm(CTgVF~WtvCU`pr)bPo5%Ip`Ow}pa+3l}>`Zj=1A&F$R(CTVRPwH(Zotn|Z6 z2wwlse;y3#q68kPWP7lTCn@^Ix1b?ekYTuJy6W9hzd(>o<|o_#+hzqva37UCBGQRX|4)rsgW1G49Nj*?-w(55ZPB)YF zEw>MVG^uy^-f|(Y!vwO14lrutpeHo9J{7}$tgxQ{p8&jqFx^K9M~^JKe(x`K_Cmd; zAMO4KvM z{K^<@1pc~}I;`MU$(H0A{QvvJPp0T6-zu)% z_1*&EeB7XX>(qj7_B2SfWWF4BEp+dQp3lIaiRTtQoef@nK#+GJ#82t4m^IepLao&Lkt?AuJt)5_;B%|c#) z>D8e4#w&k~&Q;JWuO{s=uW(u3zZawhU{SFClr-rAlvg!v&(CFrzD%uW z`0vH>aX2@LcMu+Ro z`~U7<&Oy#mdHDMIt4QG`5IV?3K3%u<*B5I18FIL9A7~WXM5tdas3psDp0e5>@0Iyi zQWTERoij>L2sSS|q)m*ZCOMxhvJ`&rnfd1{nqXm$Wb>EJPi228o{uVrHLX2sc5{w* zaw-0$0bN2^rc#JL@G@F;ZRhcU%hu;4{{2*+i4Oaq5ptm3hI)H@6wmQzyl4qJn82Gk zKH8uE0WYKc!F%AF&u!MprJ!kL-!lDgW=>(9Ve{zh?Y*ad609fxj4utI$3?f?rW6^z zj(ZG$AB7LcRJ~yH++Xi+^aujf>tc(Bzh$Pcd;KMxuS`YV9`#(ClYR5&D1XXp;7$)V0~PHk8{;nDAKrkr+0esL4A$GRF_)#Q(XH z(Tox$QEyw0_ARUdCpYQe@q*2S{<%O0W{01~duz|~_cBla%;sQpttsq+l%HslKmbtk)&d{~Q~u0r=QOx6lGdcb48&JJk6&YR88p{~0E&g$*{PD%`yO_omEAf}7Ie zdm9A3TLsL?{Jks%hTkt#*)#|Ko%shawpl2+5Z(J7J0G2%`_HTovwUPb9J%nx+{V%R zAH||>3C_h#<4_VSa&s!i=8}4n_+ggh`-g_!4X7ELz79v>PyD7u?$Z+DtZH}1>a(T9 z6&4saUl26^e!z2Xg2HYys&(+hWj^@mX6kZ>WgNu1b{K1Kf1YmM-)Y`?h6k_FCFJbc zUa5mS*OXeV{!?Re%d!?kgK$>|fAYQ|a=&>ctNQUO4*c^u@zI?z`3P##wEX^xyf=;g zCE0mz#yL@QW4IJ(7nrcl{q-nGPWk`?ObibaI6%+xDufXu+=;)j`P_(Dv)mhJd_}9Xao@!|beV-Rmsl+bolPL*UF(~lT*K78;nT{?dq47X%U+iJ zBipxgTKbO-g+HroN~6<@Y8AT)KB*~@0+Y=Lp?BmVBsIZGCSsWuQB5888VM$+sPZ5y zY>b5mdHS#WZLW8Rj(ZfI&Id7^kJLa2@%FQ?BEMTjxFWa9BQO01A&=6>KN!-p!f4XDOKrE%fX%5x>bIyby~piG2{;P`CNfQ)}SoXS2vj5TQ5gk;HcnwE&(q zZt=JN7F4q4AllsVZgO|2Xn>y{5y9PMW29HQfA5 zd2^haJ%J3CLCoN;$cN9JW5w;7fE58dPvUoF>&I_RC~j%POOA4urrzl+T6-)x5tmm)7BT zj)wN=cB{>kSGQBQCt}rDPpzcj8vW3%?|O*$MjvkCEWF+C1XlmFvh#q# z-X%u+%o0ABf^V)l4?L_UZ z3~ed3A)Dgu!C*p%He6;fIJGk%Xc@u}*tiYW5-EI!28N*XT;)s#3_h}l|J3g|&~&`c zdn?&VPtIlcQ1#XfPV}UZDNRSoZwUe*qW64H5D0~lz?$JNF*xIb!FGx(dB+}}TJi)R zfx7`<+L6otN{}5jM&x%1m_o*19G$7fhWl_>!Yn2?>gvU7vo_$qS)jZ*n<<{7MwIt> zobubjOKP28c-VQi9U3qfxO?2=YT8pDOk3luP>lInQq6J%?OmWvv6mGps178n=RSbo ze_LXlw9JXC3HVk|xgNG&>0D?TGnH&@((I{lgse>O4jftrJt;5$h2sQ_A470ZS-E=SjBqNZVmY`s^u9~L;CbU0 zP%Dt?b6|N@swKDxPeiWu>91OtK0a|%njRW(<$&4x18WjJNIJgqp#Je`Qh3Amd}aB9 zH_B*48^URx@#!eL<8%_jwg{}JTSW@zCSp|qbJNIHR+P2BZ~YknVcrfNQ?DE}3aabq z|Fnma+L7~Bs7-dF8spu(JJx(9H(9O5+}$`#uMh9e{qQMqN62ps1oo@Ww6E>Ibhs2m zhksh8WSpld%s!a`K@qw^k^K4*fGr%0BE?1{14`zuJFVS;X+{ zhYs%V%@mEEIDIj8p=*G){c)MDE!L$vPT7JbMaNzX_CM_Xq`#_@wwjqjc!&n1RZSniM zY0|6}0fNDrBo?ca7#hEx$FKHoHr*oK^z*}qnFZ6_ULXv_#t}L+C3N#j0FXnHa`3=k z7e7ieP;)LMStRzMt!-yXmo2+BtA2U)Dgp)lOz1VyJ4+>T%)d(Fh7{->_z>Y&HZy3= z`@0_&RQ1f*LW)dk*NzIcduvc}57K}-$PTDAVDlqg;skd|T zS@q}Nd@B|FLYeP^YLs3CSZriTOx)ogGR&feGAyr3x9e-QJ;Lt|yfzF)z*D1rg3zvDV{i zS7v<3NP7M(!Xm{imm2Zdf^vO+_90GMyUdJ}RW3P=#e9FzGSJZB!B}`FSQBeORj>Ib@cZ z8X&GfvfnebTY%NJMLN;hV(cTXc=_bLo_2M@tdR*TPxB3qM9QAqk-L1ZER-nxPb>4G zIQaOyo_kH=nV@GJsg9x}MO7h1Vf*nk*%)iZzO>Lgw`5&D z7GU~V$Juvk1J>WHG&ZUdf=7D$ zs<{s)*WQ~6osIL(Shm6K^AA7Wwk$1%7r4u$4s|zF9N# zn)EICQZ{puW9LOvXOYN5UxSl60%q<`cXiS$oH9~=^7aFCxh;vQ^?2(U)waW7;GH(F zy#P~ahsqyNl)iRhzZZ~ov^y@4ERJS&h2$5+Gw*@b*=ODe&HNmKA{`Dr^#LZbWEn6k zclHQLX)B_lG?3bL4pz0g84jezQgwRV3>rIb%vn6*2|vN>0ow&(s5?-sOtQBq@1@mM z(UbU~JU`D6q#}n1t%2~w`?2LPHq?}}9y$42`QB~7e0FTk9%2U(CCNTm#`a=(@p7@} z2Lb}ZnwK$NGfk}}^kFQDx4=rrsy1%R*RU2W@PVaVp>Jzz42ef zg-^I~(5(4phR%QQUC*q)Y~!4CnhbdQ$K|r@9;a=+Bm`W!w5`Q@)D=%OP!O+@P%MFU zZYIM*d83nGqRYKUsT2{UwW8b4&gl!gM=IDo&u^&^n>v4`T-RS(L%T%UN zwoRUy=%!4FS1A}xfRJXRUvH4Vk9LY?(cRD^iTvY0gCTf0%UZQRTQDMn-AL2`+j`;X zoznyHiw$zq`?q!G*aM6d4OU*3m_(NLW~aojy^QKrgf(aM z+STflK!BRdc6qP16E9|m&*KHB)rd&=yI;DACX9a5&k>I~jy;;sO9q8Rwa3N-ct3-% z6kMI=ZEld)mBsa6w_>`G=KxP6J-n>Kbn}*?@Df1G#y)8#oi&@(&(t@NrA6PG_>I-! zt>xlRd%7}Vl=(1>#ORo1^-Tr6>6DBmU)P>DY_8_?NN`TN>-*ZZk=+1Ajwt`O4SN*< zdTu?kX@&*Fh$fpAZ!D0@$2#Y_mOm^)W5)k*u!XR9KnPLg>d(8x7zUs1NNma3I+Mp= z-B{DD%Ho4OT%NWY{eJdJ)v0D>grzJ^4mrYq@`;d_Gp>t#sr6lhbS6`HVsv}QwSW}) zx)8^IkG&@6AjMJkRj@{`_Lkg-XJ!3%9)#1rsNLfz1FAwYuTI!0C#(XZ3GF*ylc4jzyTnX$}j!W=LUOnW>jyt`dsM~%skUwCw< zHiwv;#y!9q`s9>VXeybSe3Y6woCG<7EI_!_9AZYLV#t~4?t!kpd@2PZ^0iPaSiQpx zf9Gpu*6Arb(|2T+&dg5{$xX0f_BhHzRj%<2-u*O=rt4}4Wj(VDar9E5(YU>#U% zJf^s91W;cp@IK_>+nl=OUc>yb*;GxU*ujSh4siu+<9Go`*SLJ!tJD?OU;1pFoG=n* zzf?Z<5T8dv)7KL^gj!HJ)E%N1voPXsTzXX9QaJpnwM5Fd3ELXG*)tVkw10%%F17t! zL0#t~UsA^*7Mc!QkwanBTWEO7%9QSLJ%yrvzE>Q#di(hXzehaqn+^4{J~gS#WKk?) z^%D)XY7}my*?wXJ`Q zYiR(bAvNh&+SW0#khReHuu&RV)IaL?GCGxfIEPsR&B>4gfwjc+zbaaP&P9WrFT0UP z(UBw`>r|HIDJSXf=!9u{W#WoiYq-y zsuB4(P#Ed6Q#UkH*N z_1V)!U>f1WLX9$pO(WSm!8*pB7P=$BuhMuPghJyauVEzSqWVQ}%&-&pf|>+K+O%6s z+&0iV4uu$@N3IyuR%1xGk*(VeDTJ&W!$bxq7_fN2tUQgfwp*UnTn_%Ak?x&jm@5Z@ zG|xtt+4FCYSdH8wv5};r1N z1VK}8tY~|%T*A)@eFsqTI3C?hg#LsTE4GPFeNi`aFF85=0|9Mi(Om$|-gVr7LC&$V zE`pmLhWvT*Ebx$VFqpAwE1tsQbayb_YYr04X8YNgS#eYu_s*2CD~ta8Hf*z~mGX8r zLTG4TYv)%6v0Zc^L9$3KJFGuk)-u_|L1jr_5p#bz=8Bd#d~#?Za9n#e?Zo^3wb5n} zH&do%UVN>DZh4??h)r(?bRY9qbWVKItV!VPj^d%&H`OERlqV%WZt~ftE!$Oljfl{; zyri%*ey+?Xj)c&(RIodw2p{huf`BZ(1^xg16t@78hE8bU{+A@tq^B3L0e07>#+ohO4 z*Wj%L2xb#CpURh~*Aq1UF4*N&mIj z80M{;uVCE=b z{1(V8|Fbj+3{>5>cScMYR~gk-$goQhvv(sTSt{U=SK9K+B|av_#R7^oiB?ElAo8BKHl6e?0{;Iq<-*fyAWpX}5S!YPXfl{h0%Mt?U(9>PsiIOaF9blGX8hE^D#RR{NSuvG$9e z0;pFI0~u@dp@fPw`Qd?cY7$tA!jHfasJh#zfAHkVJ$CuiWfL62AZ5MGPp-~4acS6^ zFTHNc>2ecu0x~9CdZ&y0A)mI+a1%N;JoHRvwDd2z9X8g!r#LNTgYNFU9WmO}13BMX z>BGjg2->i#Dq_?3MGzMu72~rGTx_M2qAN6a(xMZ0@tfi`vR0f{XHYzx=O);B#8k%+ z-&uOP&3{drSq~XdOYM`iLdL*8H58H65mu;1`2AO1Tt*n4h5;)C+C&aNGe;~o{x;Ml zLQ0=o@@T*OfWpQ1if?$VDygcv zztYjlhT!>^<$1r3{q+7Ur7uCPxK#68I&upWkeD{~)!idasY6fK{EaaVEJ`zrcQ{U{ z>AwR3>}9dN#L5p)eOMfE!qd)+5@#SfCD@MXfV*92mY}9eZ2)Q{QcT5%_B92c3Y(=* zi8OEdq}BC6LDC^&XPb!!EBraGa6cI0p)S+LqIidPsfi;n*euNMZI}Qe1RbS=e34`? zFz_{h^w4rUFK&u4aAaU(D|2%=x~A*dpn~YJlc-Qqx>0aVS;zo0i~u%fS?E%FpTPqM z(i%EemH1k}TLEN-N_9-T=cX+tVt@w}vyyxzIfR=-?i&?WI1pMpDn(0&c89$h}@ z@bb1Egdoi-OyJOFx-t^uEVarzEJ3Y7gs8*Qp6DAcuJQ~<+c4h_nyvYqF`IG04-_Bi zESMJP6+hvm9dl%(^q2Q**Dr5`7r7N1=B(BTm~?@i(D%w32)EWBgf@AcKVigK1*KJ} zrK+c9&^%LQzh1~EJZN!Gh2CrHkaNE3SI$@p#2cRjRz>61#4JUxuDb*DVht{sWQZ7~N9H$o9Ms6^qXazN9cB)bqh8Q2 zK@_xz#$mYKCKph&x5USoe)xq<&59KU4BOV)6@xhS^RW$)2RW2PGB6%Mc+z1@89buf zY&M(}2v>otfZmS1gUsgO=`<)IbwQ~54E`HbmsyitkvF%=YBCAH?0%1i5Z=djJk&Fg zY*KXNe#Bk$6gY(##=mL1=Y}KJfvrp)18;!gEccuT*v0ioyrFE8b7q$PNP{P(ZTo!@ zF*tcOlBr`V5w@zLcL}%X>vxV`&8Z%cR1G%Gco5dG0Uf&j3TjQgCVo+t#<+zox*awvC#t*~*=q|At2DV&`cfl$ zHv$9@>YeX#W=IcbHiOe(b(>Wn`#{c_*7v88HT=+N4L3!{@faSP!skhUG_r#a2#qLz zZ74}~KOP{>^UXC+eBuTPej%Qb-Jnd^ZOpGZd@+V&CwKbepd%YK&5LM0^Rawl`s@YC zlTXmy`{*dHeS!viw_RLKJPqHpX zlw(<{1W_V(a_UQrKSvwiJ)J?U6ftPR@4Pm8Du;NMO+dP*F}KcY#KF44K5~wiXW}I$ zj!!rQDabjd?CcafT(%3T_#T}`UrPDvaalW#a8u^K;YVA1zO=iwD3cnIdZsdU*;8N$ z>9%}OSi$h>oc#OKvSl0(!8E!pTIadb1XQ+kcSnjKO7JhV4Ziv#xaRutizqHIwlp-W zOTKOtF|&}}P!F_;-A4>F*Q-U0FOTm)IV;&}wTd^AnT3i58zEX%9edA+1IsE5=2iCF zy5_l(vb9QJtEb)*2t*AcQRS$LLPLvYp<0tibV?}ISZgIc*w76czi0e$3RLBCi((Ns z%x5LgdO$+&xnXkg`SmlSAqR!3Bp=8oTm^BDQY^>xS*jFc?M=p6WEZz+$RceXu zm`-z|t!X9$!;R&4N7Z5@C8+s+M=E{O|6JG>^Mmwv|BpO8)(gbNYw`#nngmbTD=)t3 zp&V-WPKx(Hl0$-Hu7K`wV5QV*RE(%y9md;nqA2MeoGuIdbmi-G&Uh$et!gA+5)vx@ ztiPQ>{si(CGxsE7`R!fdm3EsXhjfg1uzBMxb$sDLX4m(c#}3pwSm9V{1T~6(3@~P6 z?=3_!%_;bFsrID!x4!=QR_&SK*w;r^-zUnybGMlQ@G~x8x zF1isEZ1^@}BEi_?mlf_~zRWhTq!`z*4kF^!k3Rd(8hd6&IWe5gzzGC`2MQ;Zb_Lo7 zD*zeJ-QyYVqe{E{*dCPIdIhoHPYWMo__G~4N>t2+**cmW!G<#F^C?uW&KsZjeo0R! z<>{2g%+wAuyA{(q-yr!sO;m+T(A^_36) zaIXLE$w||#5*fnkj#M3NK5F@?mta`ef=a?<*w)tXzwt3(NlJ{QV#f)H`8;6_)$MN3 z*?5p*Z;U715`ujMHjl!tf(ehsxo;f8A>Zw|cQOs(ZDOy&w94w^6}Xc!z7rI<6@(wG zE{64JhCoHz7sOLbogl42?89)573-e%$Pws$SZdC#Pj39X$0v$wMPcj}NGId4?pwm< z_D8X&@KwJbo+cU}pU;*b<=TRtQ^r-VNIfrgYY)cwnrIT4REH_gZoFHeZtVk6ooic| zK)xM)olhO>!`aOP6Q6IMt^A#{;>jR9nz` z3FK!JTUsg0wxf?<1RQmz3XNy7yZMFjDtnh$3eLPkFvU;* z^2S9t`R=G!`C3aV?-BMN&;133bCFzPsq0Dx0$|L=Ik=^xlHJc*_)!%Yk3P}$vYJ?- zeNT)%2BTv>83AS_^Mq5WVaWACo7s!sWy_7jJPdu1p8y4qw{l+8O#?hB`V8(i(QB*t zOWz3g(1$hKi~B!Zxen92W-!wL6OT*tU}>5>DjMVHKNEcvCa~;;zOZ5(g}#ficx@)QHBsI5F{^z4$xwXs^A!gJE`k_~t2l2ai}HC7nVT_D*w0@T;y44iVa46G z^o|X2dsdMh`nh|3z3m#a5O_i0j2;Wfa)x>5ko7aFFS;SFW6+Vo1r@MZgRLt`g;WGb ziq@S7uF;ke*~IwH+%<4WUKJhw%pO=25Kh`kgrKv9hB)0=eFcWBvh3A;O#qC-3ZiJv zO$m37#Vake;K2gn^enV^gb(qZjSpAyqs78?}57wmN4Q1gXq$`wO8%z z+Q{G+f!apEi@eT4`G$*fk!$CgG$1i8cR43HF|Wpz^}A*#M5qZLV#vdBCY|BR^`^8l zJder8ALB?KA=D1uazsFQpkIP_{9(be;-P>~3$S9b2{kwPWV5eqTb%8i^F@-A_U8HV zVw2~?ioZCbGhHLUynilt<;$IGsLc0;m8lDFQkE+*=@!~Q>e=NDBASLIyfU^wseVuo zmirUvtr>>MT=C()B(t9T9qWa=2OQEvPJ_htZ<+lW)C>&KuTgQvu%ZmiE6gPrS*ah+ zH+_YZI^`CM6JuMcN#HXUhOcx+Ogl|$UhqIZR(`|Z8XX@#T|Q&9l|VSon)-bQ!@u3i z9-$D-i#URqkFCiTiRVk+Jd2@DVK$S}5@}p+{^oPfiOjY4xhuNk_@YD&3qZA3>4UMy zM9-)J?KLDpzyxt^dW(;}U?x!PCHdhc@jDN&Df=)ZJqPzU>*G)ZH)ltO88rhYyc?P& zIX>8J%8o?|@)H6|(Zgc+MgIvW^JMp&!~(+IR zJ`rs)a^G4s@-sm63))YeB?^uOm$MbH!te4VZ@-_nt2U!ajEc`rdXK8p%Q>J8x)!>F z_~ObX+j!i*Rir2>HN?zb1*H15f>7}R5lnfI{CfoCZm#Y#_tdjx%U)j~YvT(FOPqYu z8YnUvCb}U-pcJ-Ep|q~USb1Yl=5WaGzXGFw%RG9wDQ%@iYDJladp%QTS}l5$KXwvy zOU4DXh|UqlY{_XtGdrGJ!z=qUO}uosul)A!A0TWlwnUvA5~V$O7HV5?3hmkyn!l1l z?p|g)*+#z{0y>Z4TPA9w(=8T_lb~8O2x^EM1qAjUWXtX*ePB# zMl%{@tKY=``T7km?sq(ub$4)Vj0-nUq%5JLH} zS)~~cO}Hjc!=Db{p0pj&)Y86gs(nC^q+z1To0SJw9mK%aO?uQ@{b7Iofus31_|3kv z7+txG0@gh+v9ZUF*DWvB-W-Svr1s`Tkm~CPi?;b+G(5sfKdVKkh8O3qqLJraBQpO`b8l=F z&Vm^mF>-K*P+9GBoY8h9K!?+d!|e|~w~!R5(Z;9SbzbXSl*hGx?ucqa7`8JmkVmlG zu}AZ(iuNJS#xeYD_4gMACf!EnDGjjLt`uS~!nlXNU?z3=%e?*@yAU^SRX44g8`C?o zdo~)z&k8e4YWLr*H;w%iJ`2l`8cY%HZYq0lsxxukUXZ(kU9dyH0I~Ij_L#l;?u`9=`Y2!E!f=*LOb&lx|X7jAU%!uOFwCj86qU!;m8D@;io4r|e&QpRAQYx*&pGQ>>d^)lPrzCtnY9EFW|gbBL6fMxShH#61fQ z+az(GFzQmjyE}@q;a;@Sz>%*9P}P|U!O-K{M;i6MjXOo?Jh;;`)fvi=yU1@(VG!ec zJ@peWVxjEX7^v-30^f$oT_L<#~(pTfwnN{LDFf!QE7@puWA_$f7ebNC#x?-NOf~_}Sl<7@=EcAwrYE0nz zD40WCkDsA5F3+^SYLn~Z!947%Ksp$0Y1-p|s|tO}XGsGG`t=DcaO1% zjfzr~Z>pa)@wDIG-|Xrbatuw)hRObW2EoKjx;Nf0-uH>31>z!mIJ~vlo|Hx@;WtXC z^*B>mUoImApCQ%9E$tnWX4#iZqDNx{*3fIw6gd+j1~G_NOYst@1|D~ zm-DT_D#b?Za*4CEI6Wfc_gQaR!%T)pq*lr)az8BS;WXZHH&L@3=>mJ_*keSg zeh%mFH*ZGzmnqrRe6vpoh$oZJ-H{iTJpSyPqwPGqG)O7-mnMcQpQy*$h3x^4W^?wp%_%c)gsntxg| z91DM3qN;oT$;B8GKG^N~B0hSq)k`E9QL|1AA^(|~4lPQcj0q7P=(b5rSBYv+e#RHt zrI@oTDAeP@oL0LH|hap4Z7Z@)b|R7oG8?*QD=&WE{g zs;<+wHWq%LaqL5%?3Imq#}Qh)5$#!9GZBd zs>GoXog;l)55{y9Jkb}2aG07SbjMFz@iTkRCx&yy?2v}Q(xM1M(m`=C>JkbNZhFn0 zs8b;O&3-Gr;>s8Ag*(jxv0pnqs79VwD4kA49GKWHRAx}$^t-q!x`gqTr+enZ_K_1t zun{8zXC{&YG@AQVxHFN&DW82=hZ8vF55|(Q@|0kTWhS;WT7##y@jm*#-##4scwhd` zS}mN}<*1{T3xjW9MJKY4CG5Qga+MoxM}`g@t948f^533tJ3shazz8?*n0SCdz9fUD zU8K!c7vbL(njT>xYg=F*$|bW7;bWYUFQzE;wM_5I>{)PL=4pxCkWUZhlIjhm?+f}v z2RK4SA-A#b>ry4=aEXA=Ec;6mxSk8DS0|3;c!vX8h?>V_~zYb2GqwR9~ zlq9p+C7uRw0gV^D`2ofO8wiaIQVaBSxxB3L|byMS7szbMXb0a_PbOp zjJ>W^!}ZKvXjT|o)p%#fsl+r<%M}1s^HwHCq5IXn<`=O-t$i$T7G6B9hq#V5v5bk3 zETDl9qEIRO+0-;t$30;Ks^i-gSChofL#I8nNow=MPTWgsMdJasgipY9rjh?MC{^HR zV0irAazKg%<&*+&5w2%kw?B+8nLyvJmzhAYWwx>@GOW7gc0Qwh7nXm;%BBXSo>OOU z0GcUF`Q;;s2@{#h*-+#Oh{%e+nkAO#-ovd3Wc!Y+Jg}0*B#@{82EDVz7SNdb-UAgK zUg*0iWUZW;@kwk|@28EjS&Y#j$VAk*Q$e|i7sQ;MZMK3dz&f@* zZ$M>aaZ=qW zR;Fb~RORJmEa@7N@*Pd7s9t-d?kiw3kYNTbMDi}NWYR`lW2{IH^O{bP9Zi_*!=m?ZE;*Gy#)z z;Q)>Q6z&k>&>4lJB_|MV_1l+GbpQ#3CrSlh8`sosXPIz8eqctOmiRf7ZK~U;Oqfq8 zsLRm$34AcImQ*PcU756pFsztsocZDXRCACc{c!zpHx@Cvy5QL0B$K@j6HI#=MHEbu z(A{pEOUW(&;t;qH-ttJsJbF1Xk(`fswpZ`l^{O%K`%A9%dz21*>3;;u!LFpC?*MU8 zn}NI+{DC+O3$|!^6aD^RJ8y^~r?JEOWuIdKPyT)RzW@#b>zphqEM9TxX_Z@-79?O^bU10G9WTxibUYh4s0wkLOV$}T~u&Y&fJY)hS#fDEPEU)2ZfY#r4 zk_Evoa1_7u@-H8;h9&(mA!{!217o)aFJ4+KYc8?o606=JYc8?o5^FB;um4lu|81rx%Qcr+bBX`AF7Z4d7@Xa$vXqmSCTZ32O4<;w@aJAHj<%`KgkN&)$oqo$ zK}0z@FYOmnB%9N~F)lD5>z5}*!Qa7IGM?{57bX53f#oX(%QlPkfw&(Tj*&|*aXOyu z!QJSzO4T2Sn2p=g3XfQSD>Vf_21?ViUXO-r}o#R|g&m(nSO-;?0 z+I@{B7_%H@0Gun=Nmhpg2LK_@>(@h87k2*K?6g{@Fui;rY-{St*Y}+jOjAIWY1Zd0 zA9AmuNujq%ooU5P`_?fF+iAOBTMibi3JT0Y+X_M*?~>G@RRGfcz?-8G&6fOwiHgP2 z%uBnw(B}N+f}$yKdVW)AXgWPi(qdCyVM$?ChGxl~ZX*)vySsLMPR+O+VADD2sL%UX z^T5wl&fJFYGA)wx+zq^@X527$w6uRi+#JGNnP=7Ciod&)U&3jhHoY(q13vU|jQ1Mx z4n++T?ZOh=VNo5cOokh$K2%q(k0mqtIkVj609ucFy-{5v&1L07?s5cznHIX6J!|;S zgM5v7vX?+v<5YG=#>d5pgerL^Vb_JtjlYVq5H7G;ZA#wM8Jm`!DOGu@=yj496xR5B z{c&d5#e1NBq@ZDZj_I47tj}7}RM(j8-Xx$MGPH`V9vb|1H52lYSSo!Rg^ue0+-bPc z;uZtB&EMn{A#YTEZj})MnWhsT7U$}e=jYmO4qeMFgM5VU)(cCc<>D^=Y8_%VA&m+s zG@+1%Lk#J?!TB^PvN*9RhGe zZCz92hl8(KgJqvu^Np2F;{Uc%$hkr$7J$W%N!cckJUf_Cr_JZnU@}&-d1V(_cX~_9`ywM)x=PUGBt5&)) z9N!-yLSLW5m(KaR81Pd{kDDpBY2<+KI;-ydp2Qsd9VmCn#wrOC7&r=0jWXfqrHOY^BaY~ZSrN&J=7UT!`Di=qS*w`UZ7+f4r zTDc&PZv4;aZX48_V>VtYmF+WGxZwOxqF=vEjm`#-%r1Pf@fzRzhhung$9|ShKg7K2 z;r=|j$0p(i{r3;4*|R47kdMK=O<&F(CcH&`>N(FK{JRT@#(;f}`b0%V+?W;?Guh@4 z_+qt2OyLaz!e6%L6k1kT{YzocEDD9Th01l3S4OcsE;5Bg617)}{aF(yJ9}|&c)&k~ z|D&V0gm}8HC7%?lWqvfJYvA#or8l>(vfKAo3Jico4r_7$)1=> Date: Wed, 14 Oct 2026 14:38:10 +0800 Subject: [PATCH 33/51] Resident session: no made-up facts, live web search, recent Activities Asked about tomorrow's weather, the resident session invented a forecast; asked what just finished, list_activities had nothing, as the tracker drops an Activity when it ends. - The instructions forbid stating anything not seen in a tool result, a search result or the memories; for things about now it searches, and otherwise says plainly it can't find out. - Codex's built-in web search is on (web_search="live"). Measured on a daemon of its own: gpt-6-luna at low answered weather, news and price questions from what it found, first speak in 5.6 to 7.6 s, against 11 to 26 s on gpt-6.1-sol at low, so luna stays. MODEL and REASONING_EFFORT are constants. - list_activities returns {activities, recently_ended, today}: the last 20 ended Activities (agent, title, project, done/failed/stopped/expired, seconds ago), in memory only, and today's stats. Co-Authored-By: Claude Opus 5.5 --- daemon/src/activity.rs | 214 +++++++++++++++++++++++++++++++++++++-- daemon/src/resident.rs | 75 ++++++++++---- docs/resident-session.md | 24 ++++- 3 files changed, 281 insertions(+), 32 deletions(-) diff --git a/daemon/src/activity.rs b/daemon/src/activity.rs index 0be7857b..35233971 100644 --- a/daemon/src/activity.rs +++ b/daemon/src/activity.rs @@ -3,7 +3,7 @@ //! Adapters translate one agent's events into calls here; task-card ordering, global state //! priority, dedup, one-shot announcements and expiry are implemented here exactly once. -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -26,6 +26,8 @@ const CHILD_TTL: Duration = Duration::from_secs(10 * 60); /// How long to remember project roots an agent worked in. If nobody has worked there for this long, /// its CI is no longer worth watching. const WORKSPACE_TTL: Duration = Duration::from_secs(60 * 60); +/// How many ended Activities the resident session can look back on. Kept only in memory. +const RECENTLY_ENDED: usize = 20; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ActivityStatus { @@ -205,6 +207,51 @@ pub struct ActivitySummary { pub delegated: bool, } +/// How an Activity ended, for the resident session's look back. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Ending { + /// Announced as done. + Done, + /// Announced as failed. + Failed, + /// Ended with no outcome: interrupted or stopped, its session closed, or a CI run cancelled or pushed out. + Stopped, + /// Nothing heard from the agent for too long: probably killed. + Expired, +} + +/// An Activity that has ended, as the resident session sees it: what its task card showed, and how and when it ended. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct EndedSummary { + pub agent: String, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub project: Option, + pub ended: Ending, + /// Seconds since it ended. + pub ended_s: u64, +} + +/// What the resident session's `list_activities` tool returns. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ActivityReport { + /// Live Activities, newest first. + pub activities: Vec, + /// The last Activities that ended since the daemon started, newest first. + pub recently_ended: Vec, + pub today: TodaySummary, +} + +#[derive(Clone, Debug)] +struct Ended { + agent: String, + title: String, + project: Option, + ended: Ending, + at: Instant, +} + /// Today's stats rotated on the idle screen. #[derive(Debug, Default)] struct DailyStats { @@ -262,6 +309,8 @@ pub struct ActivityTracker { projects: HashMap, /// The greeting or welcome back the last activity earned, not yet sent. pending_say: Option, + /// The last Activities that ended, newest first, at most `RECENTLY_ENDED`; never written to disk. + recently_ended: VecDeque, } impl ActivityTracker { @@ -412,6 +461,7 @@ impl ActivityTracker { self.sync_busy(); return self.visible_activity(); } + self.remember_ended(&id.key, &finished, Ending::Done, Instant::now()); self.remember_announced(finished); self.sync_busy(); self.record(|stats| stats.done += 1); @@ -488,6 +538,7 @@ impl ActivityTracker { let Some(failed) = self.activities.remove(&id.key) else { return self.visible_activity(); }; + self.remember_ended(&id.key, &failed, Ending::Failed, Instant::now()); self.remember_announced(failed); self.sync_busy(); self.announce_end("task.error", id, title, "failed") @@ -495,13 +546,26 @@ impl ActivityTracker { /// Drop an activity without announcing success. pub fn discard(&mut self, id: &ActivityId, idle_title: &str) -> Option { - self.activities.remove(&id.key); + if let Some(discarded) = self.activities.remove(&id.key) { + self.remember_ended(&id.key, &discarded, Ending::Stopped, Instant::now()); + } self.sync_busy(); self.idle_or_refresh(&id.session_id, idle_title) } /// Drop all of a session's activities without announcing success. pub fn discard_session(&mut self, session_id: &str, idle_title: &str) -> Option { + let mut ended: Vec<(String, Activity)> = self + .activities + .iter() + .filter(|(_, activity)| activity.session_id == session_id) + .map(|(key, activity)| (key.clone(), activity.clone())) + .collect(); + ended.sort_by_key(|(_, activity)| activity.sequence); + let now = Instant::now(); + for (key, activity) in &ended { + self.remember_ended(key, activity, Ending::Stopped, now); + } self.clear_session(session_id); self.idle_or_refresh(session_id, idle_title) } @@ -534,7 +598,8 @@ impl ActivityTracker { fn sweep_expired_at(&mut self, now: Instant) -> Option { let mut expired_session = None; - self.activities.retain(|_, activity| { + let mut expired = Vec::new(); + self.activities.retain(|key, activity| { let ttl = match activity.status { ActivityStatus::InputRequired => INPUT_REQUIRED_TTL, ActivityStatus::Working if activity.child => CHILD_TTL, @@ -543,9 +608,14 @@ impl ActivityTracker { let alive = now.duration_since(activity.updated_at) < ttl; if !alive { expired_session = Some(activity.session_id.clone()); + expired.push((key.clone(), activity.clone())); } alive }); + expired.sort_by_key(|(_, activity)| activity.sequence); + for (key, activity) in &expired { + self.remember_ended(key, activity, Ending::Expired, now); + } self.sync_busy(); let expired_session = expired_session?; self.idle_or_refresh(&expired_session, "TIMED OUT") @@ -762,12 +832,13 @@ impl ActivityTracker { activities .into_iter() .map(|(key, activity)| { - let (agent, surface) = match &activity.source { - Some(ActivitySource::Codex { surface, .. }) => ("codex".to_owned(), Some(surface)), - Some(ActivitySource::ClaudeCode { surface, .. }) => ("claude-code".to_owned(), Some(surface)), - Some(ActivitySource::GitHubActions { .. }) => ("github-actions".to_owned(), None), - Some(ActivitySource::Cli { agent, surface, .. }) => (agent.clone(), Some(surface)), - None => ("unknown".to_owned(), None), + let surface = match &activity.source { + Some( + ActivitySource::Codex { surface, .. } + | ActivitySource::ClaudeCode { surface, .. } + | ActivitySource::Cli { surface, .. }, + ) => Some(surface), + Some(ActivitySource::GitHubActions { .. }) | None => None, }; let host = match (&activity.source, surface) { (Some(ActivitySource::GitHubActions { .. }), _) => "github".to_owned(), @@ -777,7 +848,7 @@ impl ActivityTracker { _ => "none".to_owned(), }; ActivitySummary { - agent, + agent: agent_of(activity.source.as_ref()), title: activity.title.clone(), project: self.projects.get(key).cloned(), state: match activity.status { @@ -793,6 +864,44 @@ impl ActivityTracker { .collect() } + /// What the resident session's `list_activities` tool returns: the live Activities, the ones that ended lately, + /// and today's stats. + pub fn report(&mut self) -> ActivityReport { + self.roll_day(); + let now = Instant::now(); + ActivityReport { + activities: self.summaries(), + recently_ended: self + .recently_ended + .iter() + .map(|ended| EndedSummary { + agent: ended.agent.clone(), + title: ended.title.clone(), + project: ended.project.clone(), + ended: ended.ended, + ended_s: now.saturating_duration_since(ended.at).as_secs(), + }) + .collect(), + today: self.today(), + } + } + + /// Remember how an Activity ended, for the resident session. Work an agent delegated inside its own session is + /// left out, as its end is never announced either. + fn remember_ended(&mut self, key: &str, activity: &Activity, ended: Ending, at: Instant) { + if activity.child { + return; + } + self.recently_ended.push_front(Ended { + agent: agent_of(activity.source.as_ref()), + title: activity.title.clone(), + project: self.projects.get(key).cloned(), + ended, + at, + }); + self.recently_ended.truncate(RECENTLY_ENDED); + } + fn stats_lines(&self) -> Vec { let busy = self.stats.busy_seconds + self @@ -876,6 +985,17 @@ impl ActivityTracker { } } +/// The agent an Activity belongs to, by its source, as the resident session names it. +fn agent_of(source: Option<&ActivitySource>) -> String { + match source { + Some(ActivitySource::Codex { .. }) => "codex".to_owned(), + Some(ActivitySource::ClaudeCode { .. }) => "claude-code".to_owned(), + Some(ActivitySource::GitHubActions { .. }) => "github-actions".to_owned(), + Some(ActivitySource::Cli { agent, .. }) => agent.clone(), + None => "unknown".to_owned(), + } +} + /// Duration in the stats line: minutes only under an hour, `1H23` above. fn format_duration(seconds: u64) -> String { let minutes = seconds / 60; @@ -1057,6 +1177,80 @@ mod tests { assert_eq!(summaries[1].state, "working"); } + #[test] + fn the_report_remembers_how_activities_ended_newest_first() { + let mut tracker = ActivityTracker::default(); + let done = id("a", "a:1"); + tracker.note_project(&done, "VIBE-BUDDY"); + tracker.observe(&done, "CX:REVIEW", ActivityStatus::Working); + tracker.associate_source(&done, ActivitySource::Codex { thread_id: "a".to_owned(), surface: Surface::default() }); + tracker.finish(&done, "CX:REVIEW"); + let failed = id("ci", "ci:1"); + tracker.observe(&failed, "CI:BUILD", ActivityStatus::Working); + tracker.associate_source(&failed, ActivitySource::GitHubActions { repo: "o/r".to_owned(), run_id: 1 }); + tracker.fail(&failed, "CI:BUILD"); + let stopped = id("b", "b:1"); + tracker.observe(&stopped, "CC:DOCS", ActivityStatus::Working); + tracker.discard(&stopped, "STOPPED"); + let closed = id("c", "c:1"); + tracker.require_input(&closed, "CC:ASK"); + tracker.discard_session("c", "ALL QUIET"); + tracker.observe(&id("d", "d:1"), "OC:OLD", ActivityStatus::Working); + tracker.sweep_expired_at(Instant::now() + WORKING_TTL + Duration::from_secs(1)); + // Delegated work isn't the user's task: its end isn't remembered, as it isn't announced. + let parent = id("e", "e:1"); + tracker.observe(&parent, "CC:MAIN", ActivityStatus::Working); + let child = id("e", "e:1:agent"); + tracker.observe_child(&child, "CC:MAIN"); + tracker.discard(&child, "ALL QUIET"); + // An end for an Activity that isn't tracked is not an ending. + tracker.finish(&id("x", "x:1"), "CX:GHOST"); + + let report = tracker.report(); + let ended: Vec<(&str, Ending)> = + report.recently_ended.iter().map(|ended| (ended.title.as_str(), ended.ended)).collect(); + assert_eq!( + ended, + [ + ("OC:OLD", Ending::Expired), + ("CC:ASK", Ending::Stopped), + ("CC:DOCS", Ending::Stopped), + ("CI:BUILD", Ending::Failed), + ("CX:REVIEW", Ending::Done), + ] + ); + assert_eq!(report.recently_ended[4].agent, "codex"); + assert_eq!(report.recently_ended[4].project.as_deref(), Some("VIBE-BUDDY")); + assert_eq!(report.recently_ended[3].agent, "github-actions"); + assert_eq!(report.recently_ended[2].agent, "unknown"); + assert!(report.recently_ended.iter().all(|ended| ended.ended_s < 5)); + assert_eq!(report.activities.len(), 1, "only the parent still runs"); + assert_eq!(report.activities[0].title, "CC:MAIN"); + assert_eq!((report.today.done, report.today.asks), (1, 1)); + + let json = serde_json::to_value(&report).unwrap(); + assert_eq!(json["recently_ended"][0]["ended"], "expired"); + assert_eq!(json["recently_ended"][4]["ended"], "done"); + assert_eq!(json["recently_ended"][4]["project"], "VIBE-BUDDY"); + assert!(json["recently_ended"][4]["ended_s"].is_u64()); + assert!(json["recently_ended"][0].get("project").is_none()); + assert!(json["today"]["busy_seconds"].is_u64()); + } + + #[test] + fn the_ended_activities_are_bounded_and_the_oldest_go_first() { + let mut tracker = ActivityTracker::default(); + for number in 0..RECENTLY_ENDED + 5 { + let task = id("s", &format!("s:{number}")); + tracker.observe(&task, &format!("TASK {number}"), ActivityStatus::Working); + tracker.finish(&task, &format!("TASK {number}")); + } + let ended = tracker.report().recently_ended; + assert_eq!(ended.len(), RECENTLY_ENDED); + assert_eq!(ended[0].title, format!("TASK {}", RECENTLY_ENDED + 4)); + assert_eq!(ended[RECENTLY_ENDED - 1].title, "TASK 5"); + } + #[test] fn source_survives_activity_refreshes() { let mut tracker = ActivityTracker::default(); diff --git a/daemon/src/resident.rs b/daemon/src/resident.rs index 256d5480..546a5a80 100644 --- a/daemon/src/resident.rs +++ b/daemon/src/resident.rs @@ -46,6 +46,13 @@ const CODEX_HOME_DIR: &str = "resident-codex"; const MEMORY_SUMMARY_MAX_BYTES: usize = 10_000; /// How much one `search_memories` call returns at most. const MEMORY_SEARCH_MAX_BYTES: usize = 8_000; +/// The model the resident session runs on, and its reasoning effort: a voice command waits on the answer, and the +/// session only talks, looks things up and hands work on. See [`tool_overrides`] for what was measured. +const MODEL: &str = "gpt-6-luna"; +const REASONING_EFFORT: &str = "low"; +/// Codex's built-in web search (`disabled`, `cached`, `indexed` or `live` in codex 0.162.0): `live`, since the +/// questions it is for (weather, news, prices) are about now, which a cached index may not have yet. +const WEB_SEARCH: &str = "live"; pub fn enabled_by_env() -> bool { matches!(std::env::var(ENABLE_VARIABLE).as_deref(), Ok("1" | "true")) @@ -430,8 +437,8 @@ impl Resident { } } "list_activities" => { - let activities = self.activities.lock().await.summaries(); - (true, json!({"activities": activities}).to_string()) + let report = self.activities.lock().await.report(); + (true, json!(report).to_string()) } "search_memories" => { let query = params.pointer("/arguments/query").and_then(Value::as_str).unwrap_or_default(); @@ -472,8 +479,13 @@ fn tools() -> Value { { "type": "function", "name": "list_activities", - "description": "List what the user's coding Agents are doing right now, newest first: agent, task card title, \ - project, state (working or needs_input), where it runs, and seconds in that state.", + "description": "What the user's coding Agents are doing and have done. `activities`: what runs right now, \ + newest first: agent, task card title, project, state (working or needs_input), where it runs, and \ + seconds in that state. `recently_ended`: the last tasks that ended since VibeBuddy started, newest \ + first: agent, title, project, how it ended (done, failed, stopped: interrupted or closed with no \ + outcome, or expired: nothing heard for too long) and `ended_s`, seconds ago. `today`: tasks done, \ + times an Agent asked for input, and seconds with at least one Agent busy, today. Use it for what is \ + happening now, what just finished, and how today went.", "inputSchema": {"type": "object", "properties": {}, "additionalProperties": false}, }, { @@ -508,10 +520,16 @@ fn instructions(persona: Option<&str>, memories: Option<&str>) -> String { Every user message is one voice command, transcribed from speech. Answer only by calling the `speak` tool, \ with one or two short sentences meant to be heard, not read: no Markdown, lists, code, paths or URLs. \ Reply in the language the user spoke. Do not write any other answer.\n\ - To know what the Agents are doing, call `list_activities`; never guess. Titles there are short upper-case \ - task-card names; say them naturally.\n\ - You do no work yourself: run no commands, read or edit no files, browse nothing. If the user asks for work \ - to be done, say briefly that you can't start it yet.{memories}" + To know what the Agents are doing, have just finished or did today, call `list_activities`; never guess. It \ + has the live tasks, the ones that ended lately with how and how long ago (`recently_ended`), and today's \ + counts (`today`). Titles there are short upper-case task-card names; say them naturally.\n\ + Never state a fact you have not seen in a tool result, a web search result or the memories below: not the \ + weather, news, prices, scores, schedules or anything else about now, and nothing about the user's world. \ + For things about now, search the web, and answer only from what the search found; today's date and the \ + user's time zone are in the environment context, so work out today and tomorrow from them. If you can't \ + find out, say so plainly in one sentence; never make up an answer that sounds likely.\n\ + You do no work yourself: run no commands, read or edit no files. If the user asks for work to be done, say \ + briefly that you can't start it yet.{memories}" ) } @@ -602,20 +620,21 @@ const FEATURES_OFF: [&str; 15] = [ /// path) and, on saving, would delete the link. /// - `memories.generate_memories=false`: the resident thread is never summarised into memories, should the feature /// come on. -/// - `model="gpt-6-luna"`, `model_reasoning_effort="low"`: a voice command waits on the answer, and the resident -/// session only talks and hands work on. Measured on 2026-10-13, a turn's first `speak` came 2.7 s in on -/// gpt-6-luna at low, against 4.3 s on gpt-6.1-sol at high. +/// - `web_search`: Codex's built-in web search, so a question about now is looked up rather than made up. The +/// queries go to OpenAI, the model provider. +/// - [`MODEL`] at [`REASONING_EFFORT`]: a voice command waits on the answer. Measured on 2026-10-13, a turn's first +/// `speak` came 2.7 s in on gpt-6-luna at low, against 4.3 s on gpt-6.1-sol at high. fn tool_overrides() -> Vec { let mut args: Vec = FEATURES_OFF.iter().flat_map(|feature| ["-c".to_owned(), format!("features.{feature}=false")]).collect(); for setting in [ - "web_search=\"disabled\"", - "cli_auth_credentials_store=\"file\"", - "memories.generate_memories=false", - "model=\"gpt-6-luna\"", - "model_reasoning_effort=\"low\"", + format!("web_search=\"{WEB_SEARCH}\""), + "cli_auth_credentials_store=\"file\"".to_owned(), + "memories.generate_memories=false".to_owned(), + format!("model=\"{MODEL}\""), + format!("model_reasoning_effort=\"{REASONING_EFFORT}\""), ] { - args.extend(["-c".to_owned(), setting.to_owned()]); + args.extend(["-c".to_owned(), setting]); } args } @@ -963,10 +982,17 @@ pub(crate) mod tests { assert_eq!(start["cwd"], json!(dir.join("resident"))); assert_eq!(start["sandbox"], "read-only"); assert_eq!(start["approvalPolicy"], "on-request"); - assert!(start["developerInstructions"].as_str().unwrap().contains("speak")); + let instructions = start["developerInstructions"].as_str().unwrap(); + assert!(instructions.contains("speak")); + assert!(instructions.contains("Never state a fact you have not seen in a tool result"), "{instructions}"); + assert!(instructions.contains("not the weather, news, prices")); + assert!(instructions.contains("search the web")); + assert!(instructions.contains("`recently_ended`")); let tools: Vec<&str> = start["dynamicTools"].as_array().unwrap().iter().map(|tool| tool["name"].as_str().unwrap()).collect(); assert_eq!(tools, ["speak", "list_activities", "search_memories"]); + let listing = start["dynamicTools"][1]["description"].as_str().unwrap(); + assert!(listing.contains("`recently_ended`") && listing.contains("`today`"), "{listing}"); assert_eq!(received[4]["params"], json!({"threadId": "new-thread", "input": [{"type": "text", "text": "hello"}]})); assert!(dir.join("resident").is_dir()); assert_eq!(load(&dir.join("resident.json")).thread_id.as_deref(), Some("new-thread")); @@ -1025,6 +1051,10 @@ pub(crate) mod tests { let mut tracker = ActivityTracker::default(); let id = ActivityId { session_id: "s".to_owned(), key: "s:t".to_owned() }; tracker.observe(&id, "CX:REVIEW", ActivityStatus::Working); + let ended = ActivityId { session_id: "e".to_owned(), key: "e:t".to_owned() }; + tracker.note_project(&ended, "VIBE-BUDDY"); + tracker.observe(&ended, "CC:DOCS", ActivityStatus::Working); + tracker.finish(&ended, "CC:DOCS"); let script = logged_in(); script.lock().unwrap().turn_requests = vec![ tool_call("list_activities", json!({})), @@ -1042,6 +1072,13 @@ pub(crate) mod tests { let listed: Value = serde_json::from_str(&text(0)).unwrap(); assert_eq!(listed["activities"][0]["title"], "CX:REVIEW"); assert_eq!(listed["activities"][0]["state"], "working"); + assert_eq!(listed["activities"].as_array().unwrap().len(), 1); + assert_eq!(listed["recently_ended"][0]["title"], "CC:DOCS"); + assert_eq!(listed["recently_ended"][0]["project"], "VIBE-BUDDY"); + assert_eq!(listed["recently_ended"][0]["ended"], "done"); + assert!(listed["recently_ended"][0]["ended_s"].is_u64()); + assert_eq!(listed["today"]["done"], 1); + assert!(listed["today"]["asks"].is_u64() && listed["today"]["busy_seconds"].is_u64()); assert_eq!(answers[1]["result"]["success"], true); assert_eq!(answers[2]["result"]["success"], false); assert_eq!(answers[3]["result"]["success"], false); @@ -1288,7 +1325,7 @@ pub(crate) mod tests { "features.unified_exec=false", "features.multi_agent=false", "features.memories=false", - "web_search=\"disabled\"", + "web_search=\"live\"", "cli_auth_credentials_store=\"file\"", "memories.generate_memories=false", "model=\"gpt-6-luna\"", diff --git a/docs/resident-session.md b/docs/resident-session.md index 8837aafa..83783263 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -29,9 +29,11 @@ The thread lives in a Codex home of the daemon's own (below), so the Codex App c - **The user's login, shared.** The user's Codex home is `$CODEX_HOME` if the daemon has it, else `~/.codex`. On every start `resident-codex/auth.json` is made a symlink to its `auth.json` (whatever else is there, a copy or a link elsewhere, is replaced), so there is one login and one set of tokens. Checked in the codex-cli 0.162.0 source (`codex-rs/login/src/auth/storage.rs`, `manager.rs`): the file store reads `auth.json` with a plain open, a refresh first reloads it and keeps a token the other side already refreshed, and a save truncates and rewrites it in place, so all of it goes through the link and the link survives; a rename on the user's side doesn't matter, since the link is by path. `-c cli_auth_credentials_store="file"` pins that store: the keychain store keys its entry by the Codex home's path, so it would never find the user's login, and its save deletes `auth.json`. A login kept in the keychain therefore can't be shared: the session reports "not logged in" and the log says why. Two Codex processes refreshing at the same moment can still race, as the user's CLI and the ChatGPT App already can on the same file. Credentials are never logged. - **Starting.** On the first voice command, and on the next one after the app-server dies, the daemon runs the `codex` it finds (`VIBEBUDDY_CODEX`, else PATH, else `~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, since launchd's PATH is bare). It sends `initialize` (`experimentalApi: true`) and `initialized`, then `account/read`: with no account it stops with "not logged in". It never starts a login. - **One thread for good.** `thread/start` once, with `cwd` set to `resident/` in the daemon's state directory, `sandbox: "read-only"`, `approvalPolicy: "on-request"`, the developer instructions and the dynamic tools. The thread id is kept in `resident.json` beside it, and every later start sends `thread/resume` with the same settings (the tools survive a resume; the instructions are sent again). Only a thread that no longer exists (`no rollout found`) is replaced by a new one, saved, with both ids in the log. That is also what happens once to a thread saved before the session had its own home: it is in `~/.codex`, so the first start in `resident-codex/` begins a new thread; the old one's rollout stays where it is. Should another Codex process ever be the thread's writer, the resume fails with `already has an active writer`, the voice command fails as `unavailable`, and the next one tries again; the thread is never replaced for that, so there stays one resident session. -- **Only our tools.** In its own home none of the user's Codex config loads: not their `config.toml` (model, MCP servers, profiles), `AGENTS.md`, hooks, plugins or skills. Codex's own defaults still give a thread tools, so every start passes `-c features.=false` for plugins, apps (connectors), the shell and exec tools, sub-agents, goals, memories, image generation and viewing, the browser and computer use, sleep, tool suggestions and skill search, plus `web_search="disabled"`; all but `memories` are on by default in an empty home (`codex features list`). System and managed config (`/etc/codex`, MDM) still load and may add MCP servers, and an override can't empty `mcp_servers` (it merges), so the daemon first asks `codex mcp list --json`, under the same overrides, which servers would be on, and adds `-c mcp_servers..enabled=false` for each (on this Mac: none). Overrides are `-c` rather than `--disable`, which refuses a feature name another Codex version doesn't know. The model and reasoning effort are set too (see Latency below). The thread's tool list is `speak`, `list_activities`, `search_memories` and what Codex always adds and has no switch for: `apply_patch` (refused by the read-only sandbox, its approval declined), a clock, and the MCP resource readers (no server is on to read from). +- **Only our tools.** In its own home none of the user's Codex config loads: not their `config.toml` (model, MCP servers, profiles), `AGENTS.md`, hooks, plugins or skills. Codex's own defaults still give a thread tools, so every start passes `-c features.=false` for plugins, apps (connectors), the shell and exec tools, sub-agents, goals, memories, image generation and viewing, the browser and computer use, sleep, tool suggestions and skill search; all but `memories` are on by default in an empty home (`codex features list`). System and managed config (`/etc/codex`, MDM) still load and may add MCP servers, and an override can't empty `mcp_servers` (it merges), so the daemon first asks `codex mcp list --json`, under the same overrides, which servers would be on, and adds `-c mcp_servers..enabled=false` for each (on this Mac: none). Overrides are `-c` rather than `--disable`, which refuses a feature name another Codex version doesn't know. The model and reasoning effort are set too (see Latency below), and Codex's built-in web search is on, `web_search="live"` (below). The thread's tool list is `speak`, `list_activities`, `search_memories`, the web search and what Codex always adds and has no switch for: `apply_patch` (refused by the read-only sandbox, its approval declined), a clock, and the MCP resource readers (no server is on to read from). - **Memories, read-only.** The session reads the user's Codex memories (`/memories/`) through the daemon, and Codex in its home never touches them. Codex's own memory feature (`features.memories`) can't do that safely: it gates the read path (injecting `memory_summary.md`) and, at every thread start whatever `generate_memories` says, the memory pipeline, whose consolidation phase rewrites `raw_memories.md` and prunes `rollout_summaries/` from the state database of the Codex home it runs in; in the resident home that database is empty, so on a linked folder it would empty the user's memories, and on a copy it would still run a consolidation agent each time. Its dedicated memory tools also include one that writes a note into the folder. So the feature stays off (and `memories.generate_memories=false`, should it come on), and the daemon does what the read path does: at every start or resume it puts `memory_summary.md`, cut at Codex's own cap of 10,000 bytes, into the developer instructions, and the `search_memories({query})` tool returns the lines of `MEMORY.md`, `raw_memories.md` and `rollout_summaries/` containing any of the query's words, each with the line before and after (at most 8,000 bytes). Both only read. Checked on 2026-10-13: a question answered from the summary, and one whose answer is only in `MEMORY.md`, answered after a `search_memories` call; the memories folder unchanged. -- **Tools.** `speak({text})` is the spoken reply: the daemon hands it on the moment it is called (`Resident::say_streaming`), to the box as `voice.reply`, or collected for `POST /v1/resident/say`. `list_activities({})` returns the Activities the aggregator already holds, as JSON: agent, task-card title, project, `working`/`needs_input`, host, seconds in that state and since the last event. `search_memories({query})` searches the user's Codex memories (above). There are no tools that run commands or write files. Dynamic tools are fixed when a thread starts, so a thread started before `search_memories` existed doesn't have it. +- **Tools.** `speak({text})` is the spoken reply: the daemon hands it on the moment it is called (`Resident::say_streaming`), to the box as `voice.reply`, or collected for `POST /v1/resident/say`. `list_activities({})` returns, as JSON, `{"activities","recently_ended","today"}`: the live Activities the aggregator holds (agent, task-card title, project, `working`/`needs_input`, host, seconds in that state and since the last event, newest first); the last 20 Activities that ended since the daemon started (agent, title, project, `ended`: `done`, `failed`, `stopped` or `expired`, and `ended_s`, seconds ago, newest first); and today's stats (`done`, `asks`, `busy_seconds`), the same numbers as the idle screen. `stopped` is an Activity that ended with no outcome: interrupted or stopped by the user, its session closed, or a CI run cancelled or pushed out; `expired` is one nothing was heard from for too long. Work an agent delegated inside its own session (a Claude Code subagent) is left out of `recently_ended`, as its end is never announced. The ended ones are kept only in memory, so a restart forgets them; today's stats were already kept in `stats.json`. The tool's description says what each part is for ("what just finished", "how did today go"), and the instructions say so too, since a thread started before this keeps its old description. `search_memories({query})` searches the user's Codex memories (above). There are no tools that run commands or write files. Dynamic tools are fixed when a thread starts, so a thread started before `search_memories` existed doesn't have it. +- **Web search.** Codex's built-in web search (`web_search`: `disabled`, `cached`, `indexed` or `live` in codex 0.162.0) is on as `live`, as what it is for (weather, news, prices) is about now, which a cached index may not have yet. Searches run on OpenAI's side, as part of the model's turn; the daemon sees none of it. On `gpt-6-luna` the session looks things up through Codex's own `web.run`, which also has weather and finance lookups. +- **No made-up facts.** The instructions forbid stating anything not seen in a tool result, a search result or the memories: no weather, news, prices or other facts about now, and nothing about the user's world. For those it searches, answers only from what it found, and otherwise says plainly in one sentence that it can't find out. Before this, asked about tomorrow's weather in Singapore, it invented a forecast. Codex's environment context gives the thread today's date and the user's time zone; the instructions point at it for "today" and "tomorrow", after luna once searched for the wrong day's news. - **Declining.** Every approval or question the session raises is declined: command and file-change approvals (`decline`, or `denied` on the legacy methods), permission requests (nothing granted), MCP elicitations (`decline`) and user-input requests (no answers). `currentTime/read` is answered; anything else gets a JSON-RPC "method not found". - **Turns.** `turn/start` with the text, then the daemon answers tool calls until `turn/completed` for that turn. One turn at a time: a command that arrives during another waits for it, in order, rather than being rejected, since two quick sentences are both meant to be answered. A turn that takes over two minutes is interrupted (`turn/interrupt`). - **Persona.** `instructions()` in `resident.rs` takes the Character's persona; until Characters carry one, it is `None` and the buddy speaks plainly. @@ -89,6 +91,10 @@ app-server and `dynamicTools` are experimental, and so is relying on how Codex s `list_activities` sends the task-card titles and project names the daemon holds, with each Activity's agent, state, host app and ages, to the model provider the user's Codex uses, as part of the resident thread. Until now these stayed on the computer and the box. Prompts and replies are not sent: the daemon never holds them. +It also sends the last ended Activities (the same titles and projects, how each ended and how long ago) and today's stats. + +Web searches go to OpenAI: the resident session's searches are made by OpenAI's model, from words of the voice command (a city, a topic), as part of the resident thread; OpenAI runs them against its search providers. Nothing the daemon holds is searched for unless the model puts it in a query. + The user's Codex memory summary, and whatever `search_memories` finds, go to the same provider as part of the resident thread, as they already do in the user's own Codex sessions. A voice command's audio leaves the computer for the speech recognizer: in development that is Cloudflare Workers AI, under the developer's own account; with the user's own recognizer, only that endpoint (and its key goes nowhere else). The recognised text then goes to the user's Codex model provider as the voice command. The daemon keeps neither the audio nor the text once the turn is over, and logs neither. @@ -97,4 +103,16 @@ Each spoken reply's text, which may name what the Agents are doing, goes to the ## Latency -A voice command waits on recognition, the resident session and synthesis in turn, so the resident session runs on `gpt-6-luna` at `low` reasoning effort (set with `-c`; the user's own model settings don't load in its Codex home): on 2026-10-13 a turn's first `speak` came 2.7 s in, against 4.3 s on `gpt-6.1-sol` at `high`. The daemon starts the app-server and resumes the thread when it starts, not on the first command, and the synthesizer keeps one HTTPS agent so its connection to the voice service is reused. +A voice command waits on recognition, the resident session and synthesis in turn, so the resident session runs on `gpt-6-luna` at `low` reasoning effort (`MODEL` and `REASONING_EFFORT` in `resident.rs`, set with `-c`; the user's own model settings don't load in its Codex home): on 2026-10-13 a turn's first `speak` came 2.7 s in, against 4.3 s on `gpt-6.1-sol` at `high`. + +With web search on, measured on 2026-10-14 through `POST /v1/resident/say` on a daemon of its own (temporary home, its own port, no box, no Relay), each model on a fresh thread, two rounds; seconds from the request to the first `speak`: + +| Question | gpt-6-luna, low | gpt-6.1-sol, low | +| --- | --- | --- | +| No search: "你好,你能做什么" | 1.9 | 5.2 | +| No search, `list_activities`: "现在有几个agent在工作" / "刚刚结束的agent在干什么" | 5.3 (first turn), 4.0 | 6.3, 31.7 | +| "新加坡明天天气怎么样" | 6.7, 5.6 | 14.7, 11.2 | +| "今天有什么重要的科技新闻" | 7.6, 6.4 | 17.1, 26.0 | +| "现在比特币多少钱" | 6.4 | 10.9 | + +Both searched every time and answered from what they found (the weather from a forecast lookup for the right day, the price from a finance lookup, the news from that day's headlines). sol was a little more careful (dates, sources) but two to four times slower; luna's answers were good enough, and once, before the instructions pointed at the date, it searched for the wrong day's news and said it found nothing reliable rather than guess. luna stays. The daemon starts the app-server and resumes the thread when it starts, not on the first command, and the synthesizer keeps one HTTPS agent so its connection to the voice service is reused. From 8bb8ce32fe1ef7ec7a64f52d538e2b3f2c3b4dc1 Mon Sep 17 00:00:00 2001 From: dragon Date: Wed, 14 Oct 2026 14:48:06 +0800 Subject: [PATCH 34/51] Hold an Agent's news back during a voice command Another session's repeated needs-input closed the voice overlay a second after the transcript appeared. While the overlay is up, needs input, failed and done no longer take the screen or cut a reply short: the overlay marks the first two in yellow, and the worst of what came is announced once when the overlay closes. Co-Authored-By: Claude Opus 5.5 --- docs/device-menu.md | 2 +- docs/protocol.md | 2 +- firmware-rs/core/src/firmware.rs | 61 +++++++++++++++++++++++++++--- firmware-rs/core/tests/firmware.rs | 37 +++++++++++------- 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/docs/device-menu.md b/docs/device-menu.md index ec7d9ab3..1e4a5e60 100644 --- a/docs/device-menu.md +++ b/docs/device-menu.md @@ -88,7 +88,7 @@ Every screen shows how the box is linked as a small icon in its top left: a plug ## Behavior around the menu -- **Agent events don't wait for the menu.** An event that needs the user (input required, failed) closes the menu so the screen can show it; its line is announced as usual. Other events (working, done) update the screen underneath and show once the menu closes. A Pomodoro phase ending closes the menu too, since its alarm needs K0. +- **Agent events don't wait for the menu** (the voice overlay is different: there they wait, see docs/protocol.md, "Voice commands"). An event that needs the user (input required, failed) closes the menu so the screen can show it; its line is announced as usual. Other events (working, done) update the screen underneath and show once the menu closes. A Pomodoro phase ending closes the menu too, since its alarm needs K0. - **Leisure** is left for On duty the moment the menu opens, like any key press today, and doesn't start while the menu is open. - **Volume and mute stay where they are.** The menu changes the same values as the app and K2 long. A volume change from the menu prints the `VOLUME ` line `device.volume` already uses, so the daemon's status, and the app's slider, follow; mute keeps its `MUTE ON` / `OFF` lines and stays device-only, as [`app.md`](app.md) says. - **Nothing moves to the box that belongs to the Mac.** Connecting agents, the voice library, firmware updates, launch at login and notifications all stay in the app: they need the Mac's files, network or system settings. The box holds one voice pack at a time, so there's nothing to choose between on it. The menu is about making the box's own controls visible, not about replacing the app. diff --git a/docs/protocol.md b/docs/protocol.md index 2c6ff186..02b8f6b2 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -224,7 +224,7 @@ The computer also sends each spoken reply's audio, synthesized in the Character' - `voice.audio` with `reply` (0 for the turn's first `voice.reply`, counting up), `seq` (from 0, without gaps) and `data`: base64 of the next bytes of an MP3 stream (MPEG-2 Layer III, 24 kHz mono, 48 kbps, as the voice service returns it), at most 768 base64 characters. Chunks don't follow MP3 frame boundaries; the box buffers and decodes frame by frame. - `voice.audio` with `reply`, `seq` and `"end":true` (no `data`): that reply's audio is over. With `"error":"synthesis"` as well, synthesis failed or was cut short: the box plays what it has, keeps the text, and logs the failure instead of passing it off as a whole reply. -The box plays replies in order, one after another, and starts a reply as soon as it has enough frames, without waiting for its end. If synthesis fails the reply stays text only. While muted the box shows replies without playing them. The box plays audio only for the turn it recorded last (`voice.audio` carries `turn` like the other answers), and stops a turn's replies for good, ignoring the rest of its audio, when an announcement or the Pomodoro chime is about to play (they win), when it is muted, when a new K0 push-to-talk starts, or when the overlay closes. The overlay stays up past its read time while a reply is still being spoken. A missing chunk is skipped (the decoder finds the next frame, so the reply only loses a moment); a chunk arriving after a later one is ignored. +The box plays replies in order, one after another, and starts a reply as soon as it has enough frames, without waiting for its end. If synthesis fails the reply stays text only. While muted the box shows replies without playing them. The box plays audio only for the turn it recorded last (`voice.audio` carries `turn` like the other answers), and stops a turn's replies for good, ignoring the rest of its audio, when the Pomodoro chime is about to play (it wins), when it is muted, when a new K0 push-to-talk starts, or when the overlay closes. The overlay stays up past its read time while a reply is still being spoken. An Agent's news during a voice command (needs input, failed, done) doesn't close the overlay or cut a reply short: the overlay marks needs input and failed in yellow, and the worst of what came is announced once, when the overlay closes (`AUDIO HELD ` when it is held). A missing chunk is skipped (the decoder finds the next frame, so the reply only loses a moment); a chunk arriving after a later one is ignored. ## Stage 1 error output diff --git a/firmware-rs/core/src/firmware.rs b/firmware-rs/core/src/firmware.rs index 842a875e..e4328e46 100644 --- a/firmware-rs/core/src/firmware.rs +++ b/firmware-rs/core/src/firmware.rs @@ -254,6 +254,9 @@ pub struct Firmware { /// The turn whose spoken replies are played; None once they were stopped (an announcement, mute, K2, the /// overlay closing), and the rest of that turn's audio is then ignored. reply_turn: Option, + /// An Agent's news that came while the voice overlay was up: it doesn't take the screen from a conversation + /// the user is having, the overlay marks it, and it is announced once the overlay closes. The worst one wins. + voice_held: Option, } /// Borrows the Firmware's pomodoro and leisure director to build the context for one frame. @@ -369,6 +372,7 @@ impl Firmware { voice: VoiceCommand::new(), voice_mic: false, reply_turn: None, + voice_held: None, } } @@ -1045,13 +1049,40 @@ impl Firmware { /// Something that needs the user takes the screen back from the menu and from a voice command (a /// recording is given up). fn close_overlays(&mut self, board: &mut B) { + self.close_menu(board); + if self.voice.is_open() { + self.close_voice(board); + } + } + + fn close_menu(&mut self, board: &mut B) { if self.menu.is_open() { self.menu.close(); Self::write_literal(board, "MENU CLOSED\n"); self.show_overlay(board); } - if self.voice.is_open() { - self.close_voice(board); + } + + /// An Agent's news during a voice command waits for the overlay to close; the worst of what waits is said. + fn hold_for_voice(&mut self, board: &mut B, occasion: Occasion) { + let rank = |occasion: Occasion| match occasion.fallback().unwrap_or(occasion) { + Occasion::Failed => 2, + Occasion::InputRequired => 1, + _ => 0, + }; + if self.voice_held.is_none_or(|held| rank(occasion) > rank(held)) { + self.voice_held = Some(occasion); + } + Self::write_value_line(board, "AUDIO HELD ", occasion_label(occasion).as_bytes()); + self.show_overlay(board); + } + + /// Once the voice overlay is gone, says what was held back during it. + fn release_held(&mut self, board: &mut B) { + if !self.voice.is_open() + && let Some(occasion) = self.voice_held.take() + { + self.announce(board, occasion); } } @@ -1060,7 +1091,17 @@ impl Firmware { let now = board.now_ms(); let panel = match self.menu.view() { Some(view) => Some(self.menu_panel(view, now)), - None => self.voice.panel(now), + None => self.voice.panel(now).map(|mut panel| { + let waiting: Option<&[u8]> = match self.voice_held.map(|held| held.fallback().unwrap_or(held)) { + Some(Occasion::Failed) => Some(b"AN AGENT FAILED"), + Some(Occasion::InputRequired) => Some(b"AN AGENT NEEDS YOU"), + _ => None, + }; + if let Some(waiting) = waiting { + panel.lines.push((waiting.to_vec(), Vec::new(), Tone::Warn)); + } + panel + }), }; self.display.set_panel(board, &scene!(self, now), panel); } @@ -1129,6 +1170,7 @@ impl Firmware { self.stop_voice_mic(board); self.stop_reply(board); self.show_overlay(board); + self.release_held(board); } /// Stops the turn's spoken replies, and ignores the rest of its audio. @@ -1188,6 +1230,8 @@ impl Firmware { self.stop_reply(board); self.show_overlay(board); } + // However the overlay went (expired, K2, a lost link, voice withdrawn), what was held back is said now. + self.release_held(board); self.stop_voice_mic(board); } @@ -1494,8 +1538,13 @@ impl Firmware { occasion = Some(special); } + let talking = self.voice.is_open(); if matches!(state, State::InputRequired | State::Failed) { - self.close_overlays(board); + if talking { + self.close_menu(board); + } else { + self.close_overlays(board); + } } let shown = self.display.show_tasks(board, &scene!(self, now), state, title.map(str::as_bytes), &tasks); if shown.is_err() { @@ -1504,7 +1553,9 @@ impl Firmware { Self::write_value_line(board, "DISPLAY STATE ", state_label.as_bytes()); } if let Some(occasion) = occasion { - if self.end_speaks(occasion, now) { + if talking { + self.hold_for_voice(board, occasion); + } else if self.end_speaks(occasion, now) { self.announce(board, occasion); } else { Self::write_value_line(board, "AUDIO MERGED ", occasion_label(occasion).as_bytes()); diff --git a/firmware-rs/core/tests/firmware.rs b/firmware-rs/core/tests/firmware.rs index 6a2b64e3..31906e2f 100644 --- a/firmware-rs/core/tests/firmware.rs +++ b/firmware-rs/core/tests/firmware.rs @@ -1480,7 +1480,7 @@ fn the_computers_answers_fill_the_overlay_and_k2_closes_it() { } #[test] -fn a_needs_input_event_takes_the_screen_and_gives_up_the_recording() { +fn a_needs_input_event_waits_for_a_voice_command_and_is_said_after_it() { let (mut firmware, mut board, _) = booted(blank_flash()); send(&mut firmware, &mut board, VOICE_ON); board.advance(100); @@ -1489,14 +1489,23 @@ fn a_needs_input_event_takes_the_screen_and_gives_up_the_recording() { board.advance(1100); firmware.poll(&mut board); assert_eq!(board.take_lines(), ["VOICE START 1"]); - assert!(board.voice_running); + board.played.clear(); let lines = send(&mut firmware, &mut board, r#"{"version":1,"event":"agent.input_required","title":"CC:ASK"}"#); - assert!(lines.contains(&"VOICE CANCEL 1".to_owned()), "{lines:?}"); - assert!(!board.voice_running); + assert!(!lines.iter().any(|line| line.starts_with("VOICE CANCEL")), "the recording goes on: {lines:?}"); + assert!(lines.contains(&"AUDIO HELD INPUT_REQUIRED".to_owned()), "{lines:?}"); + assert!(board.voice_running); + assert!(board.played.is_empty(), "nothing is said over the conversation"); + // The same Agent asking again is still one thing held. + send(&mut firmware, &mut board, r#"{"version":1,"event":"agent.input_required","title":"CC:ASK"}"#); board.advance(500); board.k0 = false; firmware.poll(&mut board); - assert!(board.take_lines().is_empty(), "letting go afterwards says nothing"); + assert!(board.take_lines().iter().any(|line| line.starts_with("VOICE END 1"))); + assert!(board.played.is_empty()); + // K2 closes the overlay, and the held news is said once. + let lines = press(&mut firmware, &mut board, Key::K2, 50); + assert!(lines.contains(&"AUDIO QUEUED INPUT_REQUIRED".to_owned()), "{lines:?}"); + assert_eq!(board.played.len(), 1, "{:?}", board.played); } #[test] @@ -1650,7 +1659,7 @@ fn while_muted_replies_are_shown_and_not_played() { } #[test] -fn an_announcement_stops_a_reply_and_the_rest_of_its_turn() { +fn an_agents_news_during_a_reply_waits_and_the_reply_plays_on() { let (mut firmware, mut board, _) = booted(blank_flash()); answered_turn(&mut firmware, &mut board); let lines = audio_lines(1, 0, &REPLY_MP3[..576 * 6], ""); @@ -1659,21 +1668,21 @@ fn an_announcement_stops_a_reply_and_the_rest_of_its_turn() { } board.reply_audio.clear(); board.played.clear(); - // A greeting with no line says nothing, so it leaves the reply alone. - send(&mut firmware, &mut board, r#"{"version":1,"event":"buddy.say","occasion":"greeting_morning"}"#); - assert!(board.reply_audio.is_empty()); - send( + let out = send( &mut firmware, &mut board, r#"{"version":1,"event":"task.done","title":"CC:VIBE","tasks":[{"title":"CC:VIBE","status":"done","elapsed_s":3}]}"#, ); - assert_eq!(board.reply_audio, [ReplyAudio::Stop], "the announcement wins"); - assert_eq!(board.played, [Sound::Builtin(Prompt::Done)]); + assert!(out.contains(&"AUDIO HELD DONE".to_owned()), "{out:?}"); + assert!(!board.reply_audio.contains(&ReplyAudio::Stop), "the reply isn't cut off"); + assert!(board.played.is_empty(), "nothing is said over the reply"); for line in &lines[3..] { send(&mut firmware, &mut board, line); } - send(&mut firmware, &mut board, &audio_lines(1, 1, &REPLY_MP3[..576], "")[0]); - assert_eq!(board.reply_audio, [ReplyAudio::Stop], "the turn's later audio is ignored"); + assert!(board.reply_audio.iter().any(|audio| matches!(audio, ReplyAudio::Chunk { .. })), "the reply's audio still plays"); + // Once the overlay closes, the news is said. + press(&mut firmware, &mut board, Key::K2, 50); + assert_eq!(board.played, [Sound::Builtin(Prompt::Done)]); } #[test] From 1b3dc9d5c86f99e892051f69d8a595a49fae6ecb Mon Sep 17 00:00:00 2001 From: dragon Date: Wed, 14 Oct 2026 14:54:05 +0800 Subject: [PATCH 35/51] Speak replies in each Character's own ElevenLabs voice The voice service moved from Edge, which now refuses it, to ElevenLabs with voices named :. The daemon takes each Character's voice from its voice.env, so replies sound like its lines; the robot gets Jessica, and the Doubao Characters borrow Mei until Doubao is spoken. Co-Authored-By: Claude Opus 5.5 --- daemon/src/synthesis.rs | 82 ++++++++++++++++++---------------------- daemon/src/voice.rs | 7 ++-- docs/resident-session.md | 6 +-- 3 files changed, 43 insertions(+), 52 deletions(-) diff --git a/daemon/src/synthesis.rs b/daemon/src/synthesis.rs index cd90d849..c5bc43c5 100644 --- a/daemon/src/synthesis.rs +++ b/daemon/src/synthesis.rs @@ -129,41 +129,37 @@ pub fn split(text: &str, max: usize) -> Vec<&str> { pieces } -/// DEVELOPMENT ONLY: an Edge voice for each Character, picked to roughly match its persona (docs/characters.md) -/// while the voice service speaks with Edge voices. The voices spoken replies use in production are still to be -/// decided; this table is the one place to change. -const CHARACTER_VOICES: &[(&str, &str)] = &[ - // A soft Taiwanese accent. - ("wanwanxiaohe", "zh-TW-HsiaoChenNeural"), - // A witty Londoner. - ("ada", "en-GB-SoniaNeural"), - // Calm late-night lofi. - ("luna", "en-US-AvaNeural"), - // Upbeat, from the Bay Area. - ("mei", "en-US-AriaNeural"), - // A greybeard in his fifties: low and dry. - ("hank", "en-US-ChristopherNeural"), - // A laid-back San Diego surfer. - ("kai", "en-US-AndrewNeural"), - // Fast and punchy; there is no Danish-accented English voice, so a European one. - ("dave", "en-GB-RyanNeural"), - // The built-in default Character, whose voice is Jessica. - ("robot", "en-US-JennyNeural"), +/// Each Character's voice file (`characters//voice.env`), so a spoken reply is in the very voice its lines +/// were made with: `ENGINE=elevenlabs` voices are the ones in our ElevenLabs account, which the voice service uses. +const VOICE_FILES: &[(&str, &str)] = &[ + ("ada", include_str!("../../characters/ada/voice.env")), + ("dave", include_str!("../../characters/dave/voice.env")), + ("hank", include_str!("../../characters/hank/voice.env")), + ("kai", include_str!("../../characters/kai/voice.env")), + ("luna", include_str!("../../characters/luna/voice.env")), + ("mei", include_str!("../../characters/mei/voice.env")), + ("wanwanxiaohe", include_str!("../../characters/wanwanxiaohe/voice.env")), ]; -/// No Character pack, or one this table doesn't know: the built-in default Character's. -const DEFAULT_VOICE: &str = "en-US-JennyNeural"; -/// A reply in Chinese from a Character whose voice is English: an English voice can't read it. -const CHINESE_VOICE: &str = "zh-CN-XiaoxiaoNeural"; +/// The built-in default Character speaks with ElevenLabs' Jessica (voices/README.md); so does no Character pack, or +/// one these files don't know. +const DEFAULT_VOICE: &str = "elevenlabs:cgSgspJ2msm6clMCkdW9"; +/// Until the voice service speaks Doubao, a Character whose voice is there (the Chinese ones) borrows Mei's: she +/// is Chinese-American, and ElevenLabs' multilingual model reads Chinese in her voice. +const NOT_YET_SPOKEN: &str = "mei"; -/// The voice to speak `text` in for `character` (the id the box reports in `VOICES`). A Character speaks one -/// language, but the resident session answers in the user's: Chinese text gets a Chinese voice whatever the -/// Character. -pub fn voice_for(character: Option<&str>, text: &str) -> &'static str { - let voice = character - .and_then(|character| CHARACTER_VOICES.iter().find(|(id, _)| *id == character)) - .map_or(DEFAULT_VOICE, |(_, voice)| voice); - let chinese = text.chars().any(|character| ('\u{4e00}'..='\u{9fff}').contains(&character)); - if chinese && !voice.starts_with("zh-") { CHINESE_VOICE } else { voice } +/// The voice service's name for `character`'s voice (the id the box reports in `VOICES`), as `:`. +/// The model is multilingual, so a reply in another language than the Character's keeps the Character's voice. +pub fn voice_for(character: Option<&str>) -> String { + let voice_of = |character: &str| { + let file = VOICE_FILES.iter().find(|(id, _)| *id == character)?.1; + let field = |key: &str| file.lines().find_map(|line| line.strip_prefix(key)?.strip_prefix('=')); + Some((field("ENGINE")?, field("VOICE")?)) + }; + match character.and_then(voice_of) { + Some(("elevenlabs", voice)) => format!("elevenlabs:{voice}"), + Some(_) => voice_of(NOT_YET_SPOKEN).map_or(DEFAULT_VOICE.to_owned(), |(_, voice)| format!("elevenlabs:{voice}")), + None => DEFAULT_VOICE.to_owned(), + } } /// The synthesizer the environment configures, if any. @@ -194,19 +190,13 @@ mod tests { use super::*; #[test] - fn each_character_has_its_voice_and_chinese_text_gets_a_chinese_one() { - assert_eq!(voice_for(Some("wanwanxiaohe"), "你好"), "zh-TW-HsiaoChenNeural"); - assert_eq!(voice_for(Some("wanwanxiaohe"), "OK, done."), "zh-TW-HsiaoChenNeural"); - assert_eq!(voice_for(Some("ada"), "Two agents are working."), "en-GB-SoniaNeural"); - assert_eq!(voice_for(Some("hank"), "Done."), "en-US-ChristopherNeural"); - assert_eq!(voice_for(Some("robot"), "Hi"), DEFAULT_VOICE); - assert_eq!(voice_for(None, "Hi"), DEFAULT_VOICE, "no Character pack"); - assert_eq!(voice_for(Some("hsiaoyu"), "Hi"), DEFAULT_VOICE, "an old voice pack"); - assert_eq!(voice_for(Some("ada"), "两个智能体在工作"), CHINESE_VOICE); - assert_eq!(voice_for(None, "Codex 在跑测试"), CHINESE_VOICE); - for (id, voice) in CHARACTER_VOICES { - assert!(voice.ends_with("Neural"), "{id}"); - } + fn each_character_speaks_in_the_voice_of_its_lines() { + assert_eq!(voice_for(Some("ada")), "elevenlabs:tcSsCJFYrsHpK5rCQZMf"); + assert_eq!(voice_for(Some("hank")), "elevenlabs:U6jH23hcPh0zH17nf3DT"); + assert_eq!(voice_for(Some("wanwanxiaohe")), voice_for(Some("mei")), "Doubao isn't spoken yet"); + assert_eq!(voice_for(Some("robot")), DEFAULT_VOICE); + assert_eq!(voice_for(None), DEFAULT_VOICE, "no Character pack"); + assert_eq!(voice_for(Some("hsiaoyu")), DEFAULT_VOICE, "an old voice pack"); } #[test] diff --git a/daemon/src/voice.rs b/daemon/src/voice.rs index 4ba32b7d..dac6066e 100644 --- a/daemon/src/voice.rs +++ b/daemon/src/voice.rs @@ -429,7 +429,8 @@ fn speak( if stopped.load(Ordering::Acquire) { return; } - let voice = synthesis::voice_for(character, text); + let voice = synthesis::voice_for(character); + let voice = voice.as_str(); let started = Instant::now(); let (mut seq, mut bytes, mut first_byte_ms) = (0, 0, None); let result = synthesizer.synthesize(text, voice, &mut |piece| { @@ -933,7 +934,7 @@ pub mod tests { assert_eq!(rig.names().last().map(String::as_str), Some("voice.done")); // No Character pack: the built-in Character's voice, and a Chinese one for Chinese. let voices: Vec = synthesizer.calls.lock().unwrap().iter().map(|(_, voice)| voice.clone()).collect(); - assert_eq!(voices, ["en-US-JennyNeural", "zh-CN-XiaoxiaoNeural"]); + assert_eq!(voices, ["elevenlabs:cgSgspJ2msm6clMCkdW9", "elevenlabs:cgSgspJ2msm6clMCkdW9"]); } #[test] @@ -1154,7 +1155,7 @@ pub mod tests { std::fs::write(&path, &bytes).unwrap(); println!( "{name} {}: first audio after {first_ms} ms, end after {total_ms} ms, {} bytes in {} lines -> {}", - synthesis::voice_for(character, text), + synthesis::voice_for(character), bytes.len(), events.len(), path.display() diff --git a/docs/resident-session.md b/docs/resident-session.md index 7bc22089..2e809a72 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -70,7 +70,7 @@ Each setting is taken from the environment, else from `speech.json` in the confi ### Speech synthesis -Each spoken reply is also synthesized and streamed to the box (`daemon/src/synthesis.rs`, `Speaker` in `voice.rs`), behind the `Synthesizer` trait so the backend can change. There is one backend, the voice service in `vibebuddy-cloud`: `POST {base}/v0/voice/speak` with `{"text","voice"}` (text 1 to 1000 characters, `voice` an Edge voice name), answering a chunked `audio/mpeg` stream (MPEG-2 Layer III, 24 kHz mono, 48 kbps) that starts as soon as there is audio. v0 takes no credentials; the account session's header goes in `VoiceService::speak` when it does. +Each spoken reply is also synthesized and streamed to the box (`daemon/src/synthesis.rs`, `Speaker` in `voice.rs`), behind the `Synthesizer` trait so the backend can change. There is one backend, the voice service in `vibebuddy-cloud`: `POST {base}/v0/voice/speak` with `{"text","voice"}` (text 1 to 1000 characters, `voice` as `:`, today `elevenlabs:`), answering a chunked `audio/mpeg` stream (MPEG-2 Layer III, 24 kHz mono, 48 kbps) that starts as soon as there is audio. v0 takes no credentials; the account session's header goes in `VoiceService::speak` when it does. | Setting | Effect | | --- | --- | @@ -81,7 +81,7 @@ Each spoken reply is also synthesized and streamed to the box (`daemon/src/synth - **Order.** Replies are synthesized one after another, in the order they were said, on a task of their own: a reply's `voice.reply` goes out the moment it is said and never waits for an earlier reply's audio, so the text of reply 1 may come between reply 0's audio pieces. Each reply's audio is forwarded as the bytes arrive, cut into pieces of at most 576 bytes (768 base64 characters), `seq` from 0, then `end`. `voice.done` waits for the last reply's `end`, so the turn is over only once everything has been sent; it is still the last message of the turn. - **Failures.** A status other than 200, or a stream cut before its end (the service closes the connection when it fails partway), ends that reply's audio at once with `"error":"synthesis"`, keeping what was sent; the box keeps the text. The next reply is still tried. When the resident session fails the turn, the reply being synthesized stops at its next piece, the rest are dropped, and `voice.error` follows. - **Long replies.** Over 1000 characters (counted in UTF-16, as the service counts), a reply is split at the end of a sentence, else at a comma or space; the pieces are requested one after another and stream as one reply, since MP3 frames stand alone. -- **Voices.** For development, `CHARACTER_VOICES` in `synthesis.rs` gives each Character an Edge voice roughly matching its persona (Xiaohe `zh-TW-HsiaoChenNeural`, Ada `en-GB-SoniaNeural`, Luna `en-US-AvaNeural`, Mei `en-US-AriaNeural`, Hank `en-US-ChristopherNeural`, Kai `en-US-AndrewNeural`, Dave `en-GB-RyanNeural`, the robot and no pack `en-US-JennyNeural`). The Character is the one the box reports (`VOICES`), else the one last written to it. A Character speaks one language but the resident session answers in the user's, so a reply with Chinese in it gets `zh-CN-XiaoxiaoNeural` when the Character's voice isn't Chinese. Production voices are still to be decided; that table is the one place to change. +- **Voices.** A reply is spoken in its Character's own voice, the one its lines were made with: `synthesis.rs` reads each Character's `characters//voice.env` at build time and sends `elevenlabs:` (our ElevenLabs account; the service uses `eleven_flash_v2_5`, multilingual, so a Chinese reply keeps an English Character's voice). The robot and no pack get Jessica. Chinese Characters' voices are Doubao, which the service doesn't speak yet, so they borrow Mei's. The Character is the one the box reports (`VOICES`), else the one last written to it. - **Links.** Only where voice is offered, native USB and the local network: nothing goes over the Relay or the UART bridge. - **Timing.** Checked on 2026-10-13 against staging, from this computer through the daemon's own client (a new TLS connection each time): a Chinese sentence, first audio after 1.3 to 1.4 s and its `end` after 1.5 to 1.6 s for 4.5 s of speech; an English one, 0.5 to 1.8 s and 0.6 to 2.0 s for 3.8 s. `e2e_spoken_replies_through_the_real_voice_service` in `voice.rs` (ignored) repeats it and saves the reassembled MP3s. @@ -105,7 +105,7 @@ The user's Codex memory summary, and whatever `search_memories` finds, go to the A voice command's audio leaves the computer for the speech recognizer: in development that is Cloudflare Workers AI, under the developer's own account; with the user's own recognizer, only that endpoint (and its key goes nowhere else). The recognised text then goes to the user's Codex model provider as the voice command. The daemon keeps neither the audio nor the text once the turn is over, and logs neither. -Each spoken reply's text, which may name what the Agents are doing, goes to the voice service to be synthesized (in development, staging), with the voice name; the service passes it on to the speech engine behind it (Edge voices for now). The audio it returns goes only to the box. The daemon logs the voice, timings and byte counts, never the text. +Each spoken reply's text, which may name what the Agents are doing, goes to the voice service to be synthesized (in development, staging), with the voice name; the service passes it on to the speech engine behind it (ElevenLabs). The audio it returns goes only to the box. The daemon logs the voice, timings and byte counts, never the text. ## Latency From 59f489e2d2b0246c0098c9980d5b487197bcb591 Mon Sep 17 00:00:00 2001 From: dragon Date: Wed, 14 Oct 2026 14:54:40 +0800 Subject: [PATCH 36/51] Fix a test still expecting an Edge voice Co-Authored-By: Claude Opus 5.5 --- daemon/src/voice.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/src/voice.rs b/daemon/src/voice.rs index dac6066e..1aa20c26 100644 --- a/daemon/src/voice.rs +++ b/daemon/src/voice.rs @@ -908,7 +908,7 @@ pub mod tests { } assert!(audio.iter().all(|(.., error)| error.is_none())); let calls = synthesizer.calls.lock().unwrap().clone(); - let voice = "en-GB-SoniaNeural".to_owned(); + let voice = "elevenlabs:tcSsCJFYrsHpK5rCQZMf".to_owned(); assert_eq!(calls, [("Two agents are working.".to_owned(), voice.clone()), ("One needs you.".to_owned(), voice)]); } From 52d0a63fdfb30b02c2a6e3a2e080eab941233dbb Mon Sep 17 00:00:00 2001 From: dragon Date: Wed, 14 Oct 2026 14:57:20 +0800 Subject: [PATCH 37/51] Give a Character without an ElevenLabs voice the stock one Xiaohe's voice is Doubao; she spoke with Mei's voice, which made her sound like another Character. ElevenLabs' stock Jessica is used instead. Co-Authored-By: Claude Opus 5.5 --- daemon/src/synthesis.rs | 13 +++++-------- docs/resident-session.md | 2 +- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/daemon/src/synthesis.rs b/daemon/src/synthesis.rs index c5bc43c5..36dfc765 100644 --- a/daemon/src/synthesis.rs +++ b/daemon/src/synthesis.rs @@ -140,12 +140,10 @@ const VOICE_FILES: &[(&str, &str)] = &[ ("mei", include_str!("../../characters/mei/voice.env")), ("wanwanxiaohe", include_str!("../../characters/wanwanxiaohe/voice.env")), ]; -/// The built-in default Character speaks with ElevenLabs' Jessica (voices/README.md); so does no Character pack, or -/// one these files don't know. +/// ElevenLabs' own Jessica (voices/README.md), a stock voice that belongs to no Character but the built-in default. +/// Every Character whose voice ElevenLabs doesn't have (the Doubao ones, until the service speaks Doubao) uses it, +/// rather than borrowing another Character's voice; so does no Character pack, or one these files don't know. const DEFAULT_VOICE: &str = "elevenlabs:cgSgspJ2msm6clMCkdW9"; -/// Until the voice service speaks Doubao, a Character whose voice is there (the Chinese ones) borrows Mei's: she -/// is Chinese-American, and ElevenLabs' multilingual model reads Chinese in her voice. -const NOT_YET_SPOKEN: &str = "mei"; /// The voice service's name for `character`'s voice (the id the box reports in `VOICES`), as `:`. /// The model is multilingual, so a reply in another language than the Character's keeps the Character's voice. @@ -157,8 +155,7 @@ pub fn voice_for(character: Option<&str>) -> String { }; match character.and_then(voice_of) { Some(("elevenlabs", voice)) => format!("elevenlabs:{voice}"), - Some(_) => voice_of(NOT_YET_SPOKEN).map_or(DEFAULT_VOICE.to_owned(), |(_, voice)| format!("elevenlabs:{voice}")), - None => DEFAULT_VOICE.to_owned(), + _ => DEFAULT_VOICE.to_owned(), } } @@ -193,7 +190,7 @@ mod tests { fn each_character_speaks_in_the_voice_of_its_lines() { assert_eq!(voice_for(Some("ada")), "elevenlabs:tcSsCJFYrsHpK5rCQZMf"); assert_eq!(voice_for(Some("hank")), "elevenlabs:U6jH23hcPh0zH17nf3DT"); - assert_eq!(voice_for(Some("wanwanxiaohe")), voice_for(Some("mei")), "Doubao isn't spoken yet"); + assert_eq!(voice_for(Some("wanwanxiaohe")), DEFAULT_VOICE, "a Doubao voice: the stock one"); assert_eq!(voice_for(Some("robot")), DEFAULT_VOICE); assert_eq!(voice_for(None), DEFAULT_VOICE, "no Character pack"); assert_eq!(voice_for(Some("hsiaoyu")), DEFAULT_VOICE, "an old voice pack"); diff --git a/docs/resident-session.md b/docs/resident-session.md index 2e809a72..b1681e07 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -81,7 +81,7 @@ Each spoken reply is also synthesized and streamed to the box (`daemon/src/synth - **Order.** Replies are synthesized one after another, in the order they were said, on a task of their own: a reply's `voice.reply` goes out the moment it is said and never waits for an earlier reply's audio, so the text of reply 1 may come between reply 0's audio pieces. Each reply's audio is forwarded as the bytes arrive, cut into pieces of at most 576 bytes (768 base64 characters), `seq` from 0, then `end`. `voice.done` waits for the last reply's `end`, so the turn is over only once everything has been sent; it is still the last message of the turn. - **Failures.** A status other than 200, or a stream cut before its end (the service closes the connection when it fails partway), ends that reply's audio at once with `"error":"synthesis"`, keeping what was sent; the box keeps the text. The next reply is still tried. When the resident session fails the turn, the reply being synthesized stops at its next piece, the rest are dropped, and `voice.error` follows. - **Long replies.** Over 1000 characters (counted in UTF-16, as the service counts), a reply is split at the end of a sentence, else at a comma or space; the pieces are requested one after another and stream as one reply, since MP3 frames stand alone. -- **Voices.** A reply is spoken in its Character's own voice, the one its lines were made with: `synthesis.rs` reads each Character's `characters//voice.env` at build time and sends `elevenlabs:` (our ElevenLabs account; the service uses `eleven_flash_v2_5`, multilingual, so a Chinese reply keeps an English Character's voice). The robot and no pack get Jessica. Chinese Characters' voices are Doubao, which the service doesn't speak yet, so they borrow Mei's. The Character is the one the box reports (`VOICES`), else the one last written to it. +- **Voices.** A reply is spoken in its Character's own voice, the one its lines were made with: `synthesis.rs` reads each Character's `characters//voice.env` at build time and sends `elevenlabs:` (our ElevenLabs account; the service uses `eleven_flash_v2_5`, multilingual, so a Chinese reply keeps an English Character's voice). A Character whose voice ElevenLabs doesn't have (the Chinese ones are Doubao, which the service doesn't speak yet) gets ElevenLabs' stock Jessica rather than another Character's voice; so do the robot and no pack. The Character is the one the box reports (`VOICES`), else the one last written to it. - **Links.** Only where voice is offered, native USB and the local network: nothing goes over the Relay or the UART bridge. - **Timing.** Checked on 2026-10-13 against staging, from this computer through the daemon's own client (a new TLS connection each time): a Chinese sentence, first audio after 1.3 to 1.4 s and its `end` after 1.5 to 1.6 s for 4.5 s of speech; an English one, 0.5 to 1.8 s and 0.6 to 2.0 s for 3.8 s. `e2e_spoken_replies_through_the_real_voice_service` in `voice.rs` (ignored) repeats it and saves the reassembled MP3s. From d9c0d45bcd0ec38c3d3ab4a72fa0041199bc7ea8 Mon Sep 17 00:00:00 2001 From: dragon Date: Wed, 14 Oct 2026 14:58:35 +0800 Subject: [PATCH 38/51] Pick a stock voice by gender for a Character ElevenLabs can't speak voice.env gains GENDER. A Character whose voice isn't in ElevenLabs gets the stock Sarah or George, voices of no Character; the robot keeps Jessica. Co-Authored-By: Claude Opus 5.5 --- characters/ada/voice.env | 1 + characters/dave/voice.env | 1 + characters/hank/voice.env | 1 + characters/kai/voice.env | 1 + characters/luna/voice.env | 1 + characters/mei/voice.env | 1 + characters/wanwanxiaohe/voice.env | 1 + daemon/src/synthesis.rs | 25 ++++++++++++++----------- docs/resident-session.md | 2 +- tools/make-character.sh | 3 ++- 10 files changed, 24 insertions(+), 13 deletions(-) diff --git a/characters/ada/voice.env b/characters/ada/voice.env index d4022d3d..6bbf42b2 100644 --- a/characters/ada/voice.env +++ b/characters/ada/voice.env @@ -1,3 +1,4 @@ ENGINE=elevenlabs VOICE=tcSsCJFYrsHpK5rCQZMf LANGUAGE=en +GENDER=female diff --git a/characters/dave/voice.env b/characters/dave/voice.env index 877447f3..023e3428 100644 --- a/characters/dave/voice.env +++ b/characters/dave/voice.env @@ -1,3 +1,4 @@ ENGINE=elevenlabs VOICE=Mb37aOTLC27T6HIFEA5r LANGUAGE=en +GENDER=male diff --git a/characters/hank/voice.env b/characters/hank/voice.env index 72094aa7..4c75f1f2 100644 --- a/characters/hank/voice.env +++ b/characters/hank/voice.env @@ -1,3 +1,4 @@ ENGINE=elevenlabs VOICE=U6jH23hcPh0zH17nf3DT LANGUAGE=en +GENDER=male diff --git a/characters/kai/voice.env b/characters/kai/voice.env index b3b2d1aa..6163bb75 100644 --- a/characters/kai/voice.env +++ b/characters/kai/voice.env @@ -1,3 +1,4 @@ ENGINE=elevenlabs VOICE=E02BDDILcu36QAixQqyA LANGUAGE=en +GENDER=male diff --git a/characters/luna/voice.env b/characters/luna/voice.env index 08a4aca5..8d50da20 100644 --- a/characters/luna/voice.env +++ b/characters/luna/voice.env @@ -1,3 +1,4 @@ ENGINE=elevenlabs VOICE=ggx46gxUmdtruayPIL8q LANGUAGE=en +GENDER=female diff --git a/characters/mei/voice.env b/characters/mei/voice.env index b770811c..f93c935c 100644 --- a/characters/mei/voice.env +++ b/characters/mei/voice.env @@ -1,3 +1,4 @@ ENGINE=elevenlabs VOICE=DkZuBPOXCw6ntlXANTdj LANGUAGE=en +GENDER=female diff --git a/characters/wanwanxiaohe/voice.env b/characters/wanwanxiaohe/voice.env index 53520bf2..dfb60fdc 100644 --- a/characters/wanwanxiaohe/voice.env +++ b/characters/wanwanxiaohe/voice.env @@ -1,3 +1,4 @@ ENGINE=volc VOICE=zh_female_wanwanxiaohe_moon_bigtts LANGUAGE=zh +GENDER=female diff --git a/daemon/src/synthesis.rs b/daemon/src/synthesis.rs index 36dfc765..34073b24 100644 --- a/daemon/src/synthesis.rs +++ b/daemon/src/synthesis.rs @@ -140,22 +140,25 @@ const VOICE_FILES: &[(&str, &str)] = &[ ("mei", include_str!("../../characters/mei/voice.env")), ("wanwanxiaohe", include_str!("../../characters/wanwanxiaohe/voice.env")), ]; -/// ElevenLabs' own Jessica (voices/README.md), a stock voice that belongs to no Character but the built-in default. -/// Every Character whose voice ElevenLabs doesn't have (the Doubao ones, until the service speaks Doubao) uses it, -/// rather than borrowing another Character's voice; so does no Character pack, or one these files don't know. +/// The built-in default Character speaks with ElevenLabs' Jessica (voices/README.md); so does no Character pack, or +/// one these files don't know. const DEFAULT_VOICE: &str = "elevenlabs:cgSgspJ2msm6clMCkdW9"; +/// ElevenLabs' stock Sarah and George, voices of no Character: one whose voice ElevenLabs doesn't have (the Doubao +/// ones, until the service speaks Doubao) gets the one of its `GENDER`, rather than another Character's voice. +const STOCK_FEMALE: &str = "elevenlabs:EXAVITQu4vr4xnSDxMaL"; +const STOCK_MALE: &str = "elevenlabs:JBFqnCBsd6RMkjVDRZzb"; /// The voice service's name for `character`'s voice (the id the box reports in `VOICES`), as `:`. /// The model is multilingual, so a reply in another language than the Character's keeps the Character's voice. pub fn voice_for(character: Option<&str>) -> String { - let voice_of = |character: &str| { - let file = VOICE_FILES.iter().find(|(id, _)| *id == character)?.1; - let field = |key: &str| file.lines().find_map(|line| line.strip_prefix(key)?.strip_prefix('=')); - Some((field("ENGINE")?, field("VOICE")?)) + let Some(file) = character.and_then(|character| VOICE_FILES.iter().find(|(id, _)| *id == character)) else { + return DEFAULT_VOICE.to_owned(); }; - match character.and_then(voice_of) { - Some(("elevenlabs", voice)) => format!("elevenlabs:{voice}"), - _ => DEFAULT_VOICE.to_owned(), + let field = |key: &str| file.1.lines().find_map(|line| line.strip_prefix(key)?.strip_prefix('=')); + match (field("ENGINE"), field("VOICE")) { + (Some("elevenlabs"), Some(voice)) => format!("elevenlabs:{voice}"), + _ if field("GENDER") == Some("male") => STOCK_MALE.to_owned(), + _ => STOCK_FEMALE.to_owned(), } } @@ -190,7 +193,7 @@ mod tests { fn each_character_speaks_in_the_voice_of_its_lines() { assert_eq!(voice_for(Some("ada")), "elevenlabs:tcSsCJFYrsHpK5rCQZMf"); assert_eq!(voice_for(Some("hank")), "elevenlabs:U6jH23hcPh0zH17nf3DT"); - assert_eq!(voice_for(Some("wanwanxiaohe")), DEFAULT_VOICE, "a Doubao voice: the stock one"); + assert_eq!(voice_for(Some("wanwanxiaohe")), STOCK_FEMALE, "a Doubao voice: the stock one of her gender"); assert_eq!(voice_for(Some("robot")), DEFAULT_VOICE); assert_eq!(voice_for(None), DEFAULT_VOICE, "no Character pack"); assert_eq!(voice_for(Some("hsiaoyu")), DEFAULT_VOICE, "an old voice pack"); diff --git a/docs/resident-session.md b/docs/resident-session.md index b1681e07..5e0bfeef 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -81,7 +81,7 @@ Each spoken reply is also synthesized and streamed to the box (`daemon/src/synth - **Order.** Replies are synthesized one after another, in the order they were said, on a task of their own: a reply's `voice.reply` goes out the moment it is said and never waits for an earlier reply's audio, so the text of reply 1 may come between reply 0's audio pieces. Each reply's audio is forwarded as the bytes arrive, cut into pieces of at most 576 bytes (768 base64 characters), `seq` from 0, then `end`. `voice.done` waits for the last reply's `end`, so the turn is over only once everything has been sent; it is still the last message of the turn. - **Failures.** A status other than 200, or a stream cut before its end (the service closes the connection when it fails partway), ends that reply's audio at once with `"error":"synthesis"`, keeping what was sent; the box keeps the text. The next reply is still tried. When the resident session fails the turn, the reply being synthesized stops at its next piece, the rest are dropped, and `voice.error` follows. - **Long replies.** Over 1000 characters (counted in UTF-16, as the service counts), a reply is split at the end of a sentence, else at a comma or space; the pieces are requested one after another and stream as one reply, since MP3 frames stand alone. -- **Voices.** A reply is spoken in its Character's own voice, the one its lines were made with: `synthesis.rs` reads each Character's `characters//voice.env` at build time and sends `elevenlabs:` (our ElevenLabs account; the service uses `eleven_flash_v2_5`, multilingual, so a Chinese reply keeps an English Character's voice). A Character whose voice ElevenLabs doesn't have (the Chinese ones are Doubao, which the service doesn't speak yet) gets ElevenLabs' stock Jessica rather than another Character's voice; so do the robot and no pack. The Character is the one the box reports (`VOICES`), else the one last written to it. +- **Voices.** A reply is spoken in its Character's own voice, the one its lines were made with: `synthesis.rs` reads each Character's `characters//voice.env` at build time and sends `elevenlabs:` (our ElevenLabs account; the service uses `eleven_flash_v2_5`, multilingual, so a Chinese reply keeps an English Character's voice). A Character whose voice ElevenLabs doesn't have (the Chinese ones are Doubao, which the service doesn't speak yet) gets an ElevenLabs stock voice of its `GENDER` (from `voice.env`: Sarah or George) rather than another Character's voice. The robot and no pack get Jessica. The Character is the one the box reports (`VOICES`), else the one last written to it. - **Links.** Only where voice is offered, native USB and the local network: nothing goes over the Relay or the UART bridge. - **Timing.** Checked on 2026-10-13 against staging, from this computer through the daemon's own client (a new TLS connection each time): a Chinese sentence, first audio after 1.3 to 1.4 s and its `end` after 1.5 to 1.6 s for 4.5 s of speech; an English one, 0.5 to 1.8 s and 0.6 to 2.0 s for 3.8 s. `e2e_spoken_replies_through_the_real_voice_service` in `voice.rs` (ignored) repeats it and saves the reassembled MP3s. diff --git a/tools/make-character.sh b/tools/make-character.sh index be584011..ca60ace4 100755 --- a/tools/make-character.sh +++ b/tools/make-character.sh @@ -3,7 +3,8 @@ # # Reads characters//voice.env and characters//lines.tsv: # - voice.env sets ENGINE (volc or elevenlabs) and VOICE (the engine's voice id), and for a Doubao -# 2.0 voice VOLC_RESOURCE_ID=seed-tts-2.0. The API keys stay in your environment, never in the repo: +# 2.0 voice VOLC_RESOURCE_ID=seed-tts-2.0, and GENDER (female or male), which picks a stock voice for spoken +# replies when the voice service can't speak the Character's own. The API keys stay in your environment, never in the repo: # VOLC_API_KEY or ELEVENLABS_API_KEY (a paid plan; the free one has no commercial license). # - lines.tsv has one line per row: occasiontext. Blank rows and rows starting with # are skipped. # A line with {address} is said with each form of address of the Character's LANGUAGE (voice.env) From 088e2c7b3f624ae2b3ac7bb6f4378a78beffb313 Mon Sep 17 00:00:00 2001 From: dragon Date: Wed, 14 Oct 2026 15:25:37 +0800 Subject: [PATCH 39/51] Update the Conversation tab by itself and say when voice needs Codex GET /v1/resident now carries an in-memory `revision`, bumped when a resident turn ends (voice or `say`) or the thread changes, plus `ready` and `problem` (codex_missing / codex_logged_out). Readiness is checked without starting the app-server or a turn: codex is looked up as a start does and `codex login status` runs in the resident Codex home; the answer stands for 30 s, and every app-server start counts as a check too. The box is offered voice only while the session is on and ready and a recognizer is configured; when readiness flips the daemon tells a booted box on USB or the network right away, so K0 long falls back to giving up the Pomodoro phase. Both Apps poll GET /v1/resident every 2 s while the Conversation tab is visible, re-read the history only when `revision` moved (every 5 s for an older daemon), and stop when the tab or window is left. When voice isn't ready, the tab says what's missing: install the Codex CLI (link to OpenAI's page) or run `codex login`. Co-Authored-By: Claude Opus 5.5 --- .../zh-Hans.lproj/Localizable.strings | 4 + app/Sources/SelfTest/main.swift | 10 +- app/Sources/VibeBuddy/ConversationView.swift | 61 +++++- app/Sources/VibeBuddy/Resources.swift | 3 + app/Sources/VibeBuddy/SettingsView.swift | 5 +- app/Sources/VibeBuddyCore/Resident.swift | 23 ++ daemon/src/bin/vibebuddy.rs | 5 + daemon/src/main.rs | 114 +++++++++- daemon/src/resident.rs | 200 +++++++++++++++++- daemon/src/voice.rs | 23 +- desktop/src/client.rs | 63 +++++- desktop/src/main.rs | 91 +++++++- docs/app.md | 4 +- docs/protocol.md | 2 +- docs/resident-session.md | 12 +- 15 files changed, 588 insertions(+), 32 deletions(-) diff --git a/app/Localization/zh-Hans.lproj/Localizable.strings b/app/Localization/zh-Hans.lproj/Localizable.strings index f67f75cc..f1e9c9b2 100644 --- a/app/Localization/zh-Hans.lproj/Localizable.strings +++ b/app/Localization/zh-Hans.lproj/Localizable.strings @@ -327,3 +327,7 @@ "Nothing said yet" = "还没说过话"; "Hold K0 on the box and talk to the buddy. What you say and what it says back show up here." = "按住盒子上的 K0,对氛围小助手说话。你说的和它回的都会显示在这里。"; "The daemon answered %lld" = "daemon 返回了 %lld"; +"Voice needs Codex" = "语音需要 Codex"; +"Voice commands are answered by Codex on this computer, and it isn't installed. Install the Codex CLI, then sign in with `codex login`. This tab notices within half a minute." = "语音指令由这台电脑上的 Codex 来回答,但它还没安装。先装好 Codex CLI,再用 `codex login` 登录。半分钟内这一页会自己更新。"; +"Voice commands are answered by Codex on this computer, and it isn't signed in. Run `codex login` in a terminal and sign in. This tab notices within half a minute." = "语音指令由这台电脑上的 Codex 来回答,但它还没登录。在终端里运行 `codex login` 登录即可。半分钟内这一页会自己更新。"; +"How to install the Codex CLI" = "如何安装 Codex CLI"; diff --git a/app/Sources/SelfTest/main.swift b/app/Sources/SelfTest/main.swift index b588847f..33c7e0f8 100644 --- a/app/Sources/SelfTest/main.swift +++ b/app/Sources/SelfTest/main.swift @@ -313,7 +313,15 @@ check(BundleName.step(for: devBuild, exists: { _ in false }) == .none, "bundle: // 6. The resident session's info and history, as the daemon writes them (the Conversation tab). do { let info = try StatusCoding.decoder().decode(ResidentInfo.self, from: Data(#"{"enabled":true,"thread_id":null}"#.utf8)) - check(info.enabled && info.threadId == nil, "resident info decodes") + check(info.enabled && info.threadId == nil && info.revision == nil, "resident info decodes, without a revision from an older daemon") + let revised = try StatusCoding.decoder().decode(ResidentInfo.self, from: Data(#"{"enabled":true,"revision":7,"thread_id":"t1"}"#.utf8)) + check(revised.revision == 7 && revised.threadId == "t1" && revised.problem == nil, "resident info carries its revision") + let missing = try StatusCoding.decoder().decode(ResidentInfo.self, from: Data(#"{"enabled":true,"problem":"codex_missing","ready":false,"revision":0,"thread_id":null}"#.utf8)) + check(missing.problem == .codexMissing, "resident info says Codex is missing") + let loggedOut = try StatusCoding.decoder().decode(ResidentInfo.self, from: Data(#"{"enabled":true,"problem":"codex_logged_out","ready":false,"revision":0,"thread_id":null}"#.utf8)) + check(loggedOut.problem == .codexLoggedOut, "resident info says Codex is logged out") + let later = try StatusCoding.decoder().decode(ResidentInfo.self, from: Data(#"{"enabled":true,"problem":"something_new","ready":false,"revision":0,"thread_id":null}"#.utf8)) + check(later.problem == .other("something_new"), "an unknown problem still decodes") let history = try StatusCoding.decoder().decode(ResidentHistory.self, from: Data(#"{"exchanges":[{"replies":["现在没有 Agent 在工作。"],"said":"现在有几个agent在工作呀"},{"replies":["a","b"],"said":"two"}],"thread_id":"t1"}"#.utf8)) check(history.threadId == "t1" && history.exchanges.count == 2, "resident history decodes") check(history.exchanges.last == ResidentExchange(said: "two", replies: ["a", "b"]), "exchanges keep their order and every reply") diff --git a/app/Sources/VibeBuddy/ConversationView.swift b/app/Sources/VibeBuddy/ConversationView.swift index 877562ed..1cfa87c7 100644 --- a/app/Sources/VibeBuddy/ConversationView.swift +++ b/app/Sources/VibeBuddy/ConversationView.swift @@ -6,11 +6,15 @@ import VibeBuddyCore struct ConversationView: View { /// Reads the exchanges, oldest first; the daemon in the App, canned ones when rendering a screenshot. let load: () async throws -> [ResidentExchange] + /// `GET /v1/resident`: the history's change marker, and whether voice commands can be taken. + let info: () async throws -> ResidentInfo static let limit = 20 @State private var exchanges: [ResidentExchange]? @State private var failure: Failure? + /// What voice commands are missing (the user's Codex), as the daemon last said. + @State private var problem: ResidentProblem? @State private var loading = false enum Failure: Equatable { @@ -30,12 +34,30 @@ struct ConversationView: View { content.frame(maxWidth: .infinity, maxHeight: .infinity) } .padding(16) - // Each time the tab is shown: the conversation goes on on the box while the window is closed. - .task { await reload() } + // Read each time the tab is shown (the conversation goes on on the box while the window is closed), then + // followed while it stays shown. SwiftUI cancels the task when the tab is left or the window closed. + .task { await follow() } } @ViewBuilder private var content: some View { - if let failure { + if let problem { + ContentUnavailableView { + Label("Voice needs Codex", systemImage: "mic.slash") + } description: { + switch problem { + case .codexMissing: + Text("Voice commands are answered by Codex on this computer, and it isn't installed. Install the Codex CLI, then sign in with `codex login`. This tab notices within half a minute.") + case .codexLoggedOut: + Text("Voice commands are answered by Codex on this computer, and it isn't signed in. Run `codex login` in a terminal and sign in. This tab notices within half a minute.") + case .other(let why): + Text("The buddy's session isn't available: \(why)") + } + } actions: { + if problem == .codexMissing { + Link("How to install the Codex CLI", destination: Resources.codexInstallPage) + } + } + } else if let failure { ContentUnavailableView { Label("Couldn't read the conversation", systemImage: "exclamationmark.bubble") } description: { @@ -71,6 +93,39 @@ struct ConversationView: View { } } + /// Asks `GET /v1/resident` every 2 seconds and reads the history again only when its change marker moved, or + /// when voice has just become ready. A daemon without the marker gets its history read every 5 seconds instead. + private func follow() async { + var seen: UInt64? + var marked = true + if let first = try? await info() { + seen = first.revision + problem = first.problem + } + await reload() + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(marked ? 2 : 5)) + if Task.isCancelled { return } + do { + let now = try await info() + marked = now.revision != nil + let becameReady = problem != nil && now.problem == nil + problem = now.problem + if now.revision == nil || now.revision != seen || becameReady { + seen = now.revision + await reload() + } + } catch let error as URLError where error.code == .cannotConnectToHost { + // Read again once it answers: a restarted daemon counts from 0 again. + seen = nil + problem = nil + failure = .daemonDown + } catch { + // A slow or cancelled ask; the next one tries again. + } + } + } + private func reload() async { loading = true defer { loading = false } diff --git a/app/Sources/VibeBuddy/Resources.swift b/app/Sources/VibeBuddy/Resources.swift index 1947fff7..388e6819 100644 --- a/app/Sources/VibeBuddy/Resources.swift +++ b/app/Sources/VibeBuddy/Resources.swift @@ -19,6 +19,9 @@ enum Resources { /// Where every release lives, for downloading firmware by hand when the daemon can't. static let releasesPage = URL(string: "https://github.com/second-state/vibebuddy/releases")! + /// OpenAI's page on installing the Codex CLI, which voice commands need. + static let codexInstallPage = URL(string: "https://learn.chatgpt.com/docs/codex/cli")! + static var macOSDirectory: URL { Bundle.main.executableURL!.deletingLastPathComponent() } diff --git a/app/Sources/VibeBuddy/SettingsView.swift b/app/Sources/VibeBuddy/SettingsView.swift index edb7e563..fe418123 100644 --- a/app/Sources/VibeBuddy/SettingsView.swift +++ b/app/Sources/VibeBuddy/SettingsView.swift @@ -12,7 +12,10 @@ struct SettingsView: View { DeviceView(model: model).tabItem { Label("Device", systemImage: "cpu") } // Voice commands are experimental (VIBEBUDDY_RESIDENT_SESSION=1): no tab while the daemon has them off. if model.residentEnabled { - ConversationView { [client = model.client] in try await client.residentHistory(limit: ConversationView.limit) } + ConversationView( + load: { [client = model.client] in try await client.residentHistory(limit: ConversationView.limit) }, + info: { [client = model.client] in try await client.resident() } + ) .tabItem { Label("Conversation", systemImage: "bubble.left.and.bubble.right") } } AdvancedView(model: model).tabItem { Label("Advanced", systemImage: "wrench.and.screwdriver") } diff --git a/app/Sources/VibeBuddyCore/Resident.swift b/app/Sources/VibeBuddyCore/Resident.swift index ac71d5a3..a120ec46 100644 --- a/app/Sources/VibeBuddyCore/Resident.swift +++ b/app/Sources/VibeBuddyCore/Resident.swift @@ -4,10 +4,33 @@ import Foundation public struct ResidentInfo: Decodable, Equatable { public var enabled: Bool public var threadId: String? + /// Goes up when a voice command's turn ends or the thread changes, so the Conversation tab reads the history again + /// only then. Kept in memory: it starts again at 0 when the daemon restarts. Nil from a daemon older than it. + public var revision: UInt64? + /// What voice commands are missing while the session is on but not ready (the user's Codex, installed and logged + /// in). Nil when ready, and from a daemon too old to say; a reason this App doesn't know is `.other`. + public var problem: ResidentProblem? enum CodingKeys: String, CodingKey { case enabled case threadId = "thread_id" + case revision + case problem + } +} + +/// Why the resident session can't take voice commands now: voice needs the user's Codex. +public enum ResidentProblem: Equatable, Decodable { + case codexMissing + case codexLoggedOut + case other(String) + + public init(from decoder: Decoder) throws { + switch try decoder.singleValueContainer().decode(String.self) { + case "codex_missing": self = .codexMissing + case "codex_logged_out": self = .codexLoggedOut + case let other: self = .other(other) + } } } diff --git a/daemon/src/bin/vibebuddy.rs b/daemon/src/bin/vibebuddy.rs index 617d99c3..0202709b 100644 --- a/daemon/src/bin/vibebuddy.rs +++ b/daemon/src/bin/vibebuddy.rs @@ -63,6 +63,11 @@ fn resident(limit: usize) -> Result<(), String> { if info["enabled"] != true { return Err("the resident session is off; start vibebuddyd with VIBEBUDDY_RESIDENT_SESSION=1".to_owned()); } + match info["problem"].as_str() { + Some("codex_missing") => eprintln!("voice is off: Codex isn't installed (https://learn.chatgpt.com/docs/codex/cli)"), + Some("codex_logged_out") => eprintln!("voice is off: Codex isn't logged in; run `codex login`"), + _ => {} + } let Some(thread) = info["thread_id"].as_str() else { println!("no resident thread yet; it starts with the first `vibebuddy say`"); return Ok(()); diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 9131ed8a..40eae2b9 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -285,6 +285,9 @@ async fn main() { tokio::spawn(poll_ci(state.clone())); tokio::spawn(handle_device_events(state.clone(), device_events)); tokio::spawn(check_for_updates(state.clone())); + if let Some(resident) = state.resident.clone().filter(|resident| resident.enabled()) { + follow_resident_readiness(&state, resident); + } // On macOS the app reports a lost link; elsewhere there is no app, so the daemon does it. if !cfg!(target_os = "macos") { tokio::spawn(link_alert::watch(state.clone())); @@ -433,6 +436,38 @@ async fn handle_voice_line(state: &AppState, line: &str) -> Option) { + let mut flips = resident.watch_ready(); + let follower = state.clone(); + tokio::spawn(async move { + while flips.changed().await.is_ok() { + offer_voice_again(&follower).await; + } + }); + tokio::spawn(async move { + loop { + resident.check_readiness().await; + tokio::time::sleep(resident::READINESS_TTL).await; + } + }); +} + async fn open_k2_source(state: &AppState) { { let sources = state.activities.lock().await.focus_sources(); @@ -1340,12 +1375,27 @@ struct SayRequest { struct ResidentInfo { enabled: bool, thread_id: Option, + /// Goes up when a voice command's turn ends or the thread changes; the Conversation tab polls it. + revision: u64, + /// On, and the user's Codex was last found installed and logged in: voice commands can be taken. + ready: bool, + /// What is missing when it is on but not ready: `codex_missing` or `codex_logged_out`. + problem: Option, } +/// Asking checks the resident session's readiness again when the last check is older than +/// [`resident::READINESS_TTL`]; it never starts the app-server. async fn get_resident(State(state): State) -> Json { + let Some(resident) = state.resident.as_ref() else { + return Json(ResidentInfo { enabled: false, thread_id: None, revision: 0, ready: false, problem: None }); + }; + let readiness = resident.check_readiness().await; Json(ResidentInfo { - enabled: state.resident.as_ref().is_some_and(|resident| resident.enabled()), - thread_id: state.resident.as_ref().and_then(|resident| resident.thread_id()), + enabled: resident.enabled(), + thread_id: resident.thread_id(), + revision: resident.revision(), + ready: resident.ready(), + problem: if resident.enabled() { readiness.problem } else { None }, }) } @@ -1654,6 +1704,7 @@ mod tests { let transport = Arc::new(RecordingTransport::default()); let mut state = test_state(transport.clone()); let resident = Arc::new(resident::Resident::new(enabled, &dir, resident::Launcher::Codex, Arc::default())); + resident.note(None); state.voice = Arc::new(voice::Voice::new(Some(Arc::new(SilentRecognizer)), Some(resident))); publish_device_message(&state, DeviceMessage::Connected { port: "/dev/cu.x".to_owned(), bridge: false, usb_serial: None }).await; @@ -1677,6 +1728,65 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn the_box_is_told_each_time_the_resident_session_becomes_ready_or_stops_being() { + let dir = resident::tests::temp_dir("main-readiness-offer"); + let transport = Arc::new(RecordingTransport::default()); + let mut state = test_state(transport.clone()); + let resident = Arc::new(resident::tests::resident(&dir, &resident::tests::logged_in(), Arc::default())); + state.resident = Some(resident.clone()); + state.voice = Arc::new(voice::Voice::new(Some(Arc::new(SilentRecognizer)), Some(resident.clone()))); + let offers = || { + transport.events().iter().filter(|event| event.event == "voice.available").map(|event| event.extra["on"].clone()).collect::>() + }; + let settle = || tokio::time::sleep(Duration::from_millis(50)); + let build = || DeviceMessage::Line("DISPLAY READY BUILD v9 2026-10-01".to_owned()); + + publish_device_message(&state, DeviceMessage::Connected { port: "/dev/cu.x".to_owned(), bridge: false, usb_serial: None }).await; + publish_device_message(&state, build()).await; + assert!(offers().is_empty(), "not offered before the session is found ready"); + + // The first check finds Codex installed and logged in. + follow_resident_readiness(&state, resident.clone()); + settle().await; + assert_eq!(offers(), [serde_json::json!(true)]); + resident.note(Some(resident::Problem::CodexLoggedOut)); + settle().await; + assert_eq!(offers(), [serde_json::json!(true), serde_json::json!(false)], "withdrawn when the user logs out"); + resident.note(Some(resident::Problem::CodexMissing)); + settle().await; + assert_eq!(offers().len(), 2, "still not ready: nothing new to tell"); + + // Never over the UART bridge, where voice isn't offered at all. + publish_device_message(&state, DeviceMessage::Connected { port: "/dev/cu.wchusbserial1".to_owned(), bridge: true, usb_serial: None }).await; + publish_device_message(&state, build()).await; + resident.note(None); + settle().await; + assert_eq!(offers().len(), 2); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn the_resident_info_carries_a_revision_without_starting_the_app_server() { + let dir = resident::tests::temp_dir("main-revision"); + let script = resident::tests::logged_in(); + let mut state = test_state(Arc::new(RecordingTransport::default())); + state.resident = Some(Arc::new(resident::tests::resident(&dir, &script, Arc::default()))); + + let Json(info) = get_resident(State(state.clone())).await; + assert_eq!( + serde_json::to_value(&info).unwrap(), + serde_json::json!({"enabled": true, "thread_id": null, "revision": 0, "ready": true, "problem": null}) + ); + assert_eq!(script.lock().unwrap().starts, 0, "asking never starts the app-server"); + + state.resident.as_ref().unwrap().say("hi").await.unwrap(); + let Json(info) = get_resident(State(state.clone())).await; + assert_eq!(info.thread_id.as_deref(), Some("new-thread")); + assert_eq!(info.revision, 2, "a new thread and one turn"); + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn status_reflects_device_lines_hooks_and_config() { let transport = Arc::new(RecordingTransport::default()); diff --git a/daemon/src/resident.rs b/daemon/src/resident.rs index 546a5a80..da7b97c0 100644 --- a/daemon/src/resident.rs +++ b/daemon/src/resident.rs @@ -22,7 +22,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tokio::io::{AsyncRead, AsyncWrite}; -use tokio::sync::{Mutex, mpsc}; +use tokio::sync::{Mutex, mpsc, watch}; use tracing::{info, warn}; use crate::activity::ActivityTracker; @@ -50,6 +50,10 @@ const MEMORY_SEARCH_MAX_BYTES: usize = 8_000; /// session only talks, looks things up and hands work on. See [`tool_overrides`] for what was measured. const MODEL: &str = "gpt-6-luna"; const REASONING_EFFORT: &str = "low"; +/// How long a readiness check (is `codex` there, is the user logged in) stands before it is made again. +pub const READINESS_TTL: Duration = Duration::from_secs(30); +/// The login store the resident Codex uses: the user's `auth.json`, linked in (see [`tool_overrides`]). +const AUTH_STORE: &str = "cli_auth_credentials_store=\"file\""; /// Codex's built-in web search (`disabled`, `cached`, `indexed` or `live` in codex 0.162.0): `live`, since the /// questions it is for (weather, news, prices) are about now, which a cached index may not have yet. const WEB_SEARCH: &str = "live"; @@ -88,6 +92,27 @@ impl From for ResidentError { } } +/// Why the resident session can't take a voice command now: voice needs the user's Codex, installed and logged in. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Problem { + CodexMissing, + CodexLoggedOut, +} + +/// The last readiness check: none yet, or when it was made and what it found. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Readiness { + pub checked: Option, + pub problem: Option, +} + +impl Readiness { + pub fn ready(&self) -> bool { + self.checked.is_some() && self.problem.is_none() + } +} + /// What the resident session said in answer to one voice command. #[derive(Debug, Serialize)] pub struct Answer { @@ -147,6 +172,13 @@ pub struct Resident { session: Mutex>, /// Read synchronously by the hook filter. thread_id: std::sync::Mutex>, + /// Bumped when a turn ends or the thread changes, so a client can tell the history moved without reading it. + revision: std::sync::atomic::AtomicU64, + /// The last readiness check; watchers hear when `ready()` flips. + readiness: watch::Sender, + /// What the readiness check finds with the fake app-server. + #[cfg(test)] + fake_problem: std::sync::Mutex>, } impl Resident { @@ -168,6 +200,10 @@ impl Resident { activities, session: Mutex::new(None), thread_id: std::sync::Mutex::new(saved.thread_id), + revision: std::sync::atomic::AtomicU64::new(0), + readiness: watch::channel(Readiness::default()).0, + #[cfg(test)] + fake_problem: std::sync::Mutex::new(None), } } @@ -179,6 +215,84 @@ impl Resident { self.thread_id.lock().expect("thread id lock").clone() } + /// A change marker for the history, kept in memory only: it starts at 0 with the daemon and goes up each time a + /// voice command's turn ends (answered or not) and when the thread changes. + pub fn revision(&self) -> u64 { + self.revision.load(std::sync::atomic::Ordering::Relaxed) + } + + fn bump_revision(&self) { + self.revision.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + + /// The last readiness check, as it stands; see [`Resident::check_readiness`]. + pub fn readiness(&self) -> Readiness { + *self.readiness.borrow() + } + + /// Whether the session can take a voice command: on, and the last check found Codex installed and logged in. + pub fn ready(&self) -> bool { + self.enabled && self.readiness().ready() + } + + /// Changes each time [`Resident::ready`] flips. + pub fn watch_ready(&self) -> watch::Receiver { + self.readiness.subscribe() + } + + /// Checks again whether `codex` is there and the user logged in, unless the last check is younger than + /// [`READINESS_TTL`]. Starts no app-server and no turn: it looks `codex` up as a start would and asks + /// `codex login status` in the resident Codex home, which reads the login the app-server's `account/read` would. + pub async fn check_readiness(&self) -> Readiness { + let last = self.readiness(); + if !self.enabled || last.checked.is_some_and(|checked| checked.elapsed() < READINESS_TTL) { + return last; + } + let problem = match &self.launcher { + Launcher::Codex => self.login_problem().await, + #[cfg(test)] + Launcher::Fake(_) => *self.fake_problem.lock().unwrap(), + }; + self.note(problem); + self.readiness() + } + + async fn login_problem(&self) -> Option { + let Some(program) = find_codex() else { return Some(Problem::CodexMissing) }; + if let Err(error) = prepare_codex_home(&self.codex_home, &self.user_codex_home) { + warn!(%error, "cannot prepare the resident Codex home to check the login"); + return Some(Problem::CodexLoggedOut); + } + let status = codex_command(&program, &self.codex_home) + .args(["-c", AUTH_STORE, "login", "status"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await; + match status { + Ok(status) if status.success() => None, + Ok(_) => Some(Problem::CodexLoggedOut), + Err(error) => { + warn!(program = %program.display(), %error, "cannot run codex to check the login"); + Some(Problem::CodexMissing) + } + } + } + + /// Records what a check or a start found; watchers are told only when readiness itself flips. + pub(crate) fn note(&self, problem: Option) { + let now = Readiness { checked: Some(tokio::time::Instant::now()), problem }; + self.readiness.send_if_modified(|readiness| { + let flipped = readiness.ready() != now.ready(); + if readiness.problem != now.problem { + info!(?problem, "resident session readiness changed"); + } + *readiness = now; + flipped + }); + } + /// Whether a Codex hook event comes from the resident session: by its thread, or by its working directory. The /// user's own hooks don't load in the resident Codex home, but system and managed ones may. pub fn owns_hook(&self, session_id: &str, thread_id: Option<&str>, cwd: Option<&str>) -> bool { @@ -211,6 +325,8 @@ impl Resident { if session.client.is_closed() { *slot = None; } + // Even a failed or timed-out turn may have left the command in the thread. + self.bump_revision(); result } @@ -254,7 +370,15 @@ impl Resident { *slot = None; } if slot.is_none() { - *slot = Some(self.start().await?); + let started = self.start().await; + // A start is a readiness check too, and the most recent one. + match &started { + Ok(_) => self.note(None), + Err(ResidentError::NoCodex) => self.note(Some(Problem::CodexMissing)), + Err(ResidentError::NotLoggedIn) => self.note(Some(Problem::CodexLoggedOut)), + Err(_) => {} + } + *slot = Some(started?); } Ok(slot.as_mut().expect("session was just started")) } @@ -312,7 +436,10 @@ impl Resident { }, None => self.start_thread(&client).await?, }; - *self.thread_id.lock().expect("thread id lock") = Some(thread_id.clone()); + let previous = self.thread_id.lock().expect("thread id lock").replace(thread_id.clone()); + if previous.as_deref() != Some(thread_id.as_str()) { + self.bump_revision(); + } let saved = Saved { thread_id: Some(thread_id.clone()), codex_version: version }; if let Err(error) = save(&self.state_file, &saved) { warn!(%error, "failed to save the resident thread id"); @@ -629,7 +756,7 @@ fn tool_overrides() -> Vec { FEATURES_OFF.iter().flat_map(|feature| ["-c".to_owned(), format!("features.{feature}=false")]).collect(); for setting in [ format!("web_search=\"{WEB_SEARCH}\""), - "cli_auth_credentials_store=\"file\"".to_owned(), + AUTH_STORE.to_owned(), "memories.generate_memories=false".to_owned(), format!("model=\"{MODEL}\""), format!("model_reasoning_effort=\"{REASONING_EFFORT}\""), @@ -1305,6 +1432,71 @@ pub(crate) mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test(start_paused = true)] + async fn readiness_is_checked_without_a_turn_cached_and_follows_what_a_start_finds() { + let dir = temp_dir("readiness"); + let script = Arc::new(std::sync::Mutex::new(Script::default())); + let resident = resident(&dir, &script, Arc::default()); + let mut watcher = resident.watch_ready(); + assert!(!resident.ready(), "not ready before the first check"); + + assert_eq!(resident.check_readiness().await.problem, None); + assert!(resident.ready()); + assert!(watcher.has_changed().unwrap(), "watchers hear it come on"); + watcher.mark_unchanged(); + assert_eq!(script.lock().unwrap().starts, 0, "the check starts no app-server"); + + // A check younger than the TTL stands. + *resident.fake_problem.lock().unwrap() = Some(Problem::CodexLoggedOut); + assert!(resident.check_readiness().await.ready()); + tokio::time::advance(READINESS_TTL).await; + assert_eq!(resident.check_readiness().await.problem, Some(Problem::CodexLoggedOut)); + assert!(!resident.ready()); + assert!(watcher.has_changed().unwrap(), "and go off"); + watcher.mark_unchanged(); + + // A start that finds the user logged in is the newer word, and so is one that finds them logged out. + script.lock().unwrap().logged_in = true; + resident.say("hi").await.unwrap(); + assert!(resident.ready()); + let script = Arc::new(std::sync::Mutex::new(Script::default())); + let logged_out = super::tests::resident(&dir, &script, Arc::default()); + assert!(logged_out.check_readiness().await.ready()); + let watcher = logged_out.watch_ready(); + assert_eq!(logged_out.say("hi").await.unwrap_err(), ResidentError::NotLoggedIn); + assert_eq!(logged_out.readiness().problem, Some(Problem::CodexLoggedOut)); + assert!(watcher.has_changed().unwrap()); + + let off = Resident::new(false, &dir, Launcher::Codex, Arc::default()); + assert!(!off.check_readiness().await.ready() && !off.ready(), "switched off, nothing is checked"); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn the_revision_moves_when_a_turn_ends_or_the_thread_changes_but_not_on_reads() { + let dir = temp_dir("revision"); + let saved = Saved { thread_id: Some("old-thread".to_owned()), codex_version: None }; + save(&dir.join("resident.json"), &saved).unwrap(); + let script = logged_in(); + let first = resident(&dir, &script, Arc::default()); + assert_eq!(first.revision(), 0); + first.history(5).await.unwrap(); + assert_eq!(first.revision(), 0, "resuming the same thread and reading it changes nothing"); + first.say("hi").await.unwrap(); + assert_eq!(first.revision(), 1); + first.history(5).await.unwrap(); + assert_eq!(first.revision(), 1); + + // A lost thread replaced by a new one counts, and so does a turn that fails. + let script = logged_in(); + script.lock().unwrap().lose_thread = true; + script.lock().unwrap().die_on_turn = Some(1); + let second = resident(&dir, &script, Arc::default()); + assert!(second.say("one").await.is_err()); + assert_eq!(second.revision(), 2, "the new thread and the failed turn"); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn codex_runs_in_the_resident_codex_home() { let command = codex_command(Path::new("/bin/codex"), Path::new("/state/resident-codex")); diff --git a/daemon/src/voice.rs b/daemon/src/voice.rs index 1aa20c26..decddff0 100644 --- a/daemon/src/voice.rs +++ b/daemon/src/voice.rs @@ -262,9 +262,10 @@ impl Voice { Self { synthesizer, ..self } } - /// Voice is offered when the resident session is on and speech recognition is configured. + /// Voice is offered when speech recognition is configured and the resident session is on and ready: the user's + /// Codex is installed and logged in. pub fn available(&self) -> bool { - self.recognizer.is_some() && self.resident.as_ref().is_some_and(|resident| resident.enabled()) + self.recognizer.is_some() && self.resident.as_ref().is_some_and(|resident| resident.ready()) } /// Takes one `VOICE` line from the box. A turn that ends starts a task, returned so tests can wait for it; @@ -1164,15 +1165,25 @@ pub mod tests { } #[tokio::test] - async fn voice_is_offered_only_with_a_recognizer_and_the_resident_session_on() { + async fn voice_is_offered_only_with_a_recognizer_and_the_resident_session_on_and_ready() { let dir = temp_dir("voice-offer"); - let on = || Some(Arc::new(resident(&dir, &logged_in(), Arc::default()))); - let off = Some(Arc::new(Resident::new(false, &dir, crate::resident::Launcher::Codex, Arc::default()))); + let with = |problem: Option| { + let resident = resident(&dir, &logged_in(), Arc::default()); + resident.note(problem); + Some(Arc::new(resident)) + }; + let on = || with(None); + let off = Resident::new(false, &dir, crate::resident::Launcher::Codex, Arc::default()); + off.note(None); let recognizer = || Some(Arc::new(FakeRecognizer { answer: Ok(""), heard: Default::default() }) as Arc); assert!(Voice::new(recognizer(), on()).available()); assert!(!Voice::new(None, on()).available()); - assert!(!Voice::new(recognizer(), off).available()); + assert!(!Voice::new(recognizer(), Some(Arc::new(off))).available()); assert!(!Voice::new(recognizer(), None).available()); + let unchecked = Some(Arc::new(resident(&dir, &logged_in(), Arc::default()))); + assert!(!Voice::new(recognizer(), unchecked).available(), "not before the first check"); + assert!(!Voice::new(recognizer(), with(Some(crate::resident::Problem::CodexMissing))).available()); + assert!(!Voice::new(recognizer(), with(Some(crate::resident::Problem::CodexLoggedOut))).available()); // Lines that arrive anyway are told voice is off. let voice = Voice::new(None, on()); diff --git a/desktop/src/client.rs b/desktop/src/client.rs index 7193cbc2..6ebb533e 100644 --- a/desktop/src/client.rs +++ b/desktop/src/client.rs @@ -151,13 +151,47 @@ pub async fn restart_daemon() -> Result<(), String> { /// Whether the daemon runs the experimental resident session (`VIBEBUDDY_RESIDENT_SESSION=1`). pub async fn resident_enabled() -> Result { - #[derive(Deserialize)] - struct Info { - enabled: bool, - } - let response = reqwest::get(format!("{BASE}/v1/resident")).await.map_err(|error| error.to_string())?; - let info: Info = response.json().await.map_err(|error| error.to_string())?; - Ok(info.enabled) + resident_info().await.map(|info| info.enabled).map_err(|error| match error { + HistoryError::DaemonDown => "the daemon isn't running".to_owned(), + HistoryError::Unavailable(why) => why, + }) +} + +/// `GET /v1/resident`. +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub struct ResidentInfo { + pub enabled: bool, + /// Goes up when a voice command's turn ends or the thread changes, so the Conversation tab reads the history + /// again only then. Kept in memory: it starts again at 0 when the daemon restarts. None from an older daemon. + #[serde(default)] + pub revision: Option, + /// What voice commands are missing while the session is on but not ready; None when ready, and from a daemon + /// too old to say. + #[serde(default)] + pub problem: Option, +} + +/// Why the resident session can't take voice commands now: voice needs the user's Codex. +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Problem { + CodexMissing, + CodexLoggedOut, + /// A reason this app doesn't know yet. + #[serde(other)] + Other, +} + +/// Asked every 2 seconds while the Conversation tab shows, so it answers quickly or not at all. +pub async fn resident_info() -> Result { + let response = reqwest::Client::new() + .get(format!("{BASE}/v1/resident")) + .timeout(Duration::from_secs(5)) + .send() + .await + .map_err(|error| if error.is_connect() { HistoryError::DaemonDown } else { HistoryError::Unavailable(error.to_string()) })?; + let body = response.bytes().await.map_err(|error| HistoryError::Unavailable(error.to_string()))?; + serde_json::from_slice(&body).map_err(|error| HistoryError::Unavailable(error.to_string())) } /// One voice command and the buddy's spoken replies to it. @@ -243,4 +277,19 @@ mod tests { assert_eq!(statuses[0].today.done, 3); assert!(buffer.is_empty()); } + + #[test] + fn the_resident_info_reads_its_revision_and_problem_and_an_older_daemons_without_them() { + let parse = |json: &str| serde_json::from_str::(json).unwrap(); + let older = parse(r#"{"enabled":true,"thread_id":null}"#); + assert_eq!(older, ResidentInfo { enabled: true, revision: None, problem: None }); + let ready = parse(r#"{"enabled":true,"thread_id":"t1","revision":7,"ready":true,"problem":null}"#); + assert_eq!(ready, ResidentInfo { enabled: true, revision: Some(7), problem: None }); + let missing = parse(r#"{"enabled":true,"thread_id":null,"revision":0,"ready":false,"problem":"codex_missing"}"#); + assert_eq!(missing.problem, Some(Problem::CodexMissing)); + let logged_out = parse(r#"{"enabled":true,"thread_id":null,"revision":0,"ready":false,"problem":"codex_logged_out"}"#); + assert_eq!(logged_out.problem, Some(Problem::CodexLoggedOut)); + let later = parse(r#"{"enabled":true,"thread_id":null,"revision":0,"ready":false,"problem":"something_new"}"#); + assert_eq!(later.problem, Some(Problem::Other)); + } } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 548ca716..43fbbe08 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -148,6 +148,10 @@ enum Message { Resident(Result), RefreshConversation, Conversation(Result, client::HistoryError>), + /// Ask `GET /v1/resident` again while the Conversation tab shows. + PollConversation, + ConversationPolled(Result), + OpenCodexInstallPage, /// Ask `vibebuddy-hook` for each agent's state again. RefreshAgents, Agents(Result, String>), @@ -226,6 +230,13 @@ struct App { /// The last voice commands and spoken replies read, or why they couldn't be; None before the first read. conversation: Option, client::HistoryError>>, conversation_loading: bool, + /// The history's change marker when it was last read; None before that, and after the daemon went away. + conversation_seen: Option, + /// The daemon has the change marker; an older one gets its history read every 5 seconds instead. + conversation_marked: bool, + conversation_polling: bool, + /// What voice commands are missing (the user's Codex), as the daemon last said. + resident_problem: Option, /// The outcome of the last action, shown at the bottom of the window. notice: Option>, /// The language picked in Settings, and, while it differs from the UI's, the offer to restart. @@ -280,6 +291,10 @@ impl App { resident: false, conversation: None, conversation_loading: false, + conversation_seen: None, + conversation_marked: true, + conversation_polling: false, + resident_problem: None, notice: None, language: UiLanguage::saved(), restart_offer: None, @@ -335,12 +350,20 @@ impl App { ); #[cfg(not(target_os = "linux"))] let (tray, instance) = (Subscription::none(), Subscription::none()); + // Only while the Conversation tab is in an open window: the history is read again when it changed. + let conversation = if self.settings.is_some() && self.resident && self.tab == Tab::Conversation { + let every = if self.conversation_marked { 2 } else { 5 }; + iced::time::every(Duration::from_secs(every)).map(|_| Message::PollConversation) + } else { + Subscription::none() + }; Subscription::batch([ Subscription::run(client::status_updates).map(Message::Daemon), iced::time::every(Duration::from_secs(2)).map(|_| Message::ThemeTick), window::close_events().map(Message::WindowClosed), tray, instance, + conversation, ]) } @@ -428,8 +451,15 @@ impl App { ..Default::default() }); self.settings = Some(id); + // Reopened on the Conversation tab: read it again, as when the tab opens. + let conversation = if self.tab == Tab::Conversation { + self.conversation_seen = None; + Task::done(Message::PollConversation) + } else { + Task::none() + }; // The Agents tab reads each agent's config from disk; have it ready, whichever tab shows first. - return Task::batch([open.discard(), Task::done(Message::RefreshAgents)]); + return Task::batch([open.discard(), Task::done(Message::RefreshAgents), conversation]); } Message::WindowClosed(id) => { if self.settings == Some(id) { @@ -447,9 +477,10 @@ impl App { if tab == Tab::Agents { return Task::done(Message::RefreshAgents); } - // The conversation goes on on the box while the window is elsewhere. + // The conversation goes on on the box while the window is elsewhere: read it each time the tab opens. if tab == Tab::Conversation { - return Task::done(Message::RefreshConversation); + self.conversation_seen = None; + return Task::done(Message::PollConversation); } } Message::Resident(Ok(enabled)) => { @@ -469,6 +500,40 @@ impl App { self.conversation_loading = false; self.conversation = Some(result); } + Message::PollConversation => { + if !self.conversation_polling { + self.conversation_polling = true; + return Task::perform(client::resident_info(), Message::ConversationPolled); + } + } + Message::ConversationPolled(result) => { + self.conversation_polling = false; + match result { + Ok(info) => { + self.conversation_marked = info.revision.is_some(); + let became_ready = self.resident_problem.is_some() && info.problem.is_none(); + self.resident_problem = info.problem; + let changed = info.revision.is_none() || info.revision != self.conversation_seen || became_ready; + // A read already under way is left alone; the next poll sees the change again. + if (changed || self.conversation.is_none()) && !self.conversation_loading { + self.conversation_seen = info.revision; + return Task::done(Message::RefreshConversation); + } + } + Err(client::HistoryError::DaemonDown) => { + // Read again once it answers: a restarted daemon counts from 0 again. + self.conversation_seen = None; + self.resident_problem = None; + self.conversation = Some(Err(client::HistoryError::DaemonDown)); + } + Err(client::HistoryError::Unavailable(_)) => {} + } + } + Message::OpenCodexInstallPage => { + if let Err(error) = launch("xdg-open", &[std::ffi::OsStr::new(CODEX_INSTALL_PAGE)]) { + self.notice = Some(Err(error)); + } + } Message::NotifyLink(enabled) => { let mut config = self.status.as_ref().map(|status| status.config.clone()).unwrap_or_default(); config.notify_link = enabled; @@ -1611,6 +1676,24 @@ impl App { .center(Length::Fill) .into() }; + // Voice needs the user's Codex, installed and logged in: say what's missing first. + if let Some(problem) = &self.resident_problem { + let detail = match problem { + client::Problem::CodexMissing => tr("Voice commands are answered by Codex on this computer, and it isn't installed. Install the Codex CLI, then sign in with `codex login`. This tab notices within half a minute.", &[]), + client::Problem::CodexLoggedOut => tr("Voice commands are answered by Codex on this computer, and it isn't signed in. Run `codex login` in a terminal and sign in. This tab notices within half a minute.", &[]), + client::Problem::Other => tr("The buddy's session isn't available: %@", &[&"?"]), + }; + let install = (*problem == client::Problem::CodexMissing) + .then(|| button(text(tr("How to install the Codex CLI", &[]))).on_press(Message::OpenCodexInstallPage)); + let body = container( + column![text(tr("Voice needs Codex", &[])).size(16), text(detail).size(13).style(text::secondary)] + .push(install) + .spacing(6) + .align_x(iced::Alignment::Center), + ) + .center(Length::Fill); + return column![header, body].spacing(12).into(); + } let body = match &self.conversation { None => container(text(tr("Refreshing…", &[])).style(text::secondary)).center(Length::Fill).into(), Some(Err(client::HistoryError::DaemonDown)) => placeholder( @@ -1742,6 +1825,8 @@ async fn export_diagnostics(summary: String, config: Option) -> Result Date: Wed, 14 Oct 2026 15:46:40 +0800 Subject: [PATCH 40/51] Let the resident session run on Claude Code with the user's own API key A second brain for the resident session (ADR-0017): the user's own `claude` CLI, headless, authenticated only with the user's Anthropic API key. Which brain is a setting: codex, claude, or auto (Codex when it is ready, else Claude Code when a key is set); each keeps its own session for good. - One long-lived `claude -p` with stream-json in and out, in a config dir of its own (resident-claude/, 0700), started once with --session-id and resumed with --resume; replaced only when its transcript is gone. - The child loses every CLAUDE*/ANTHROPIC* variable of the daemon's and gets ANTHROPIC_API_KEY; the init line must report that key as the credential, or the process is stopped. - Only our tools: --tools WebSearch, our speak and list_activities from an MCP server on a loopback port of its own behind a random token (--strict-mcp-config), dontAsk with only those allowed, the built-ins denied by name. --bare is not used: it takes WebSearch away. - speak reaches voice.rs while the turn runs; the history is read from the session's transcript. - The key and the brain setting live in resident-settings.json (0600) in the config dir, behind their own endpoints (PUT /v1/resident/brain, PUT/DELETE /v1/resident/anthropic-key); the key is never sent back or logged. - GET /v1/resident reports brain, brain_setting, anthropic_key_set and the new problems claude_missing and anthropic_key_missing; Claude Code hooks from the resident session are dropped like Codex's. Co-Authored-By: Claude Opus 5.5 --- daemon/src/bin/vibebuddy.rs | 9 +- daemon/src/main.rs | 163 ++++- daemon/src/resident.rs | 443 +++++++++++-- daemon/src/resident_claude.rs | 1112 +++++++++++++++++++++++++++++++++ 4 files changed, 1667 insertions(+), 60 deletions(-) create mode 100644 daemon/src/resident_claude.rs diff --git a/daemon/src/bin/vibebuddy.rs b/daemon/src/bin/vibebuddy.rs index 0202709b..923365ca 100644 --- a/daemon/src/bin/vibebuddy.rs +++ b/daemon/src/bin/vibebuddy.rs @@ -3,8 +3,8 @@ //! //! - `vibebuddy say `: hands a voice command, typed, to the resident session and prints its spoken replies. //! - `vibebuddy resident [n]`: prints the resident thread's id and its last `n` (default 5) voice commands with the -//! spoken replies, read from the thread. The thread lives in a Codex home of the daemon's own, so the Codex App -//! can't open it. +//! spoken replies, read from the thread (or, on Claude Code, the session). Both live in a home of the daemon's +//! own, so neither Agent's App can open them. //! //! The resident session is experimental: the daemon has it only when started with `VIBEBUDDY_RESIDENT_SESSION=1`. @@ -66,8 +66,13 @@ fn resident(limit: usize) -> Result<(), String> { match info["problem"].as_str() { Some("codex_missing") => eprintln!("voice is off: Codex isn't installed (https://learn.chatgpt.com/docs/codex/cli)"), Some("codex_logged_out") => eprintln!("voice is off: Codex isn't logged in; run `codex login`"), + Some("claude_missing") => eprintln!("voice is off: Claude Code isn't installed (https://code.claude.com/docs/en/setup)"), + Some("anthropic_key_missing") => eprintln!("voice is off: no Anthropic API key is set for Claude Code (Settings → Conversation)"), _ => {} } + if let Some(brain) = info["brain"].as_str() { + println!("brain {brain}"); + } let Some(thread) = info["thread_id"].as_str() else { println!("no resident thread yet; it starts with the first `vibebuddy say`"); return Ok(()); diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 40eae2b9..5b83b74a 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -10,6 +10,7 @@ mod lan; mod noise_link; mod relay; mod resident; +mod resident_claude; mod link_alert; mod occasions; mod rom_flasher; @@ -262,7 +263,11 @@ async fn main() { if enabled { warn!("the experimental resident session is on"); } - Arc::new(resident::Resident::new(enabled, dir, resident::Launcher::Codex, state.activities.clone())) + let resident = resident::Resident::new(enabled, dir, resident::Launcher::Codex, state.activities.clone()); + Arc::new(match config::config_dir() { + Some(config) => resident.with_settings_file(config.join(resident::SETTINGS_FILE)), + None => resident, + }) }); state.voice = Arc::new(voice::Voice::new(speech::from_env(), state.resident.clone()).with_synthesizer(synthesis::from_env())); if let Some(resident) = state.resident.clone() { @@ -592,6 +597,8 @@ fn app(state: AppState) -> Router { .route("/v1/resident", get(get_resident)) .route("/v1/resident/say", post(post_resident_say)) .route("/v1/resident/history", get(get_resident_history)) + .route("/v1/resident/brain", axum::routing::put(put_resident_brain)) + .route("/v1/resident/anthropic-key", axum::routing::put(put_anthropic_key).delete(delete_anthropic_key)) .with_state(state) } @@ -1311,6 +1318,16 @@ async fn post_claude_hook( State(state): State, Json(hook): Json, ) -> (StatusCode, Json) { + // The resident session on Claude Code is never an Activity either (ADR-0017). + if let Some(resident) = &state.resident + && resident.owns_claude_hook(&hook.session_id, hook.cwd.as_deref()) + { + tracing::debug!(event = %hook.hook_event_name, "ignoring a Claude Code hook from the resident session"); + return ( + StatusCode::ACCEPTED, + Json(ApiResponse { accepted: true, message: "resident session, ignored".to_owned() }), + ); + } state.hooks_seen.lock().await.claude = Some(chrono::Local::now()); state.notify_status(); let event = { @@ -1379,26 +1396,100 @@ struct ResidentInfo { revision: u64, /// On, and the user's Codex was last found installed and logged in: voice commands can be taken. ready: bool, - /// What is missing when it is on but not ready: `codex_missing` or `codex_logged_out`. + /// What is missing when it is on but not ready: `codex_missing`, `codex_logged_out`, `claude_missing` or + /// `anthropic_key_missing`. problem: Option, + /// The brain voice commands go to while ready: `codex` or `claude`. + brain: Option, + /// The user's setting: `auto`, `codex` or `claude`. + brain_setting: resident::BrainChoice, + /// Whether an Anthropic API key is stored; the key itself is never sent back. + anthropic_key_set: bool, } /// Asking checks the resident session's readiness again when the last check is older than -/// [`resident::READINESS_TTL`]; it never starts the app-server. +/// [`resident::READINESS_TTL`]; it never starts the app-server or Claude Code. async fn get_resident(State(state): State) -> Json { let Some(resident) = state.resident.as_ref() else { - return Json(ResidentInfo { enabled: false, thread_id: None, revision: 0, ready: false, problem: None }); + return Json(ResidentInfo { + enabled: false, + thread_id: None, + revision: 0, + ready: false, + problem: None, + brain: None, + brain_setting: resident::BrainChoice::default(), + anthropic_key_set: false, + }); }; let readiness = resident.check_readiness().await; + let on = resident.enabled(); Json(ResidentInfo { - enabled: resident.enabled(), + enabled: on, thread_id: resident.thread_id(), revision: resident.revision(), ready: resident.ready(), - problem: if resident.enabled() { readiness.problem } else { None }, + problem: if on { readiness.problem } else { None }, + brain: if resident.ready() { readiness.brain } else { None }, + brain_setting: resident.brain_setting(), + anthropic_key_set: resident.anthropic_key_set(), }) } +#[derive(serde::Deserialize)] +struct BrainRequest { + brain: resident::BrainChoice, +} + +/// Sets which brain the resident session runs on. Its own endpoint, kept out of `PUT /v1/config`. +async fn put_resident_brain(State(state): State, Json(request): Json) -> axum::response::Response { + use axum::response::IntoResponse; + let Some(resident) = state.resident.clone() else { + return no_state_directory(); + }; + if let Err(error) = resident.set_brain(request.brain).await { + warn!(%error, "failed to save the resident session's brain setting"); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse { accepted: false, message: error.to_string() })).into_response(); + } + get_resident(State(state)).await.into_response() +} + +#[derive(serde::Deserialize)] +struct AnthropicKeyRequest { + api_key: String, +} + +/// Stores the user's Anthropic API key for Claude Code (mode 0600, in its own file); it is never sent back or +/// logged. Its own endpoint, so an App that doesn't know it can't wipe it. +async fn put_anthropic_key(State(state): State, Json(request): Json) -> axum::response::Response { + use axum::response::IntoResponse; + if request.api_key.trim().is_empty() { + return (StatusCode::BAD_REQUEST, Json(ApiResponse { accepted: false, message: "api_key is empty".to_owned() })).into_response(); + } + set_anthropic_key(state, Some(request.api_key)).await +} + +async fn delete_anthropic_key(State(state): State) -> axum::response::Response { + set_anthropic_key(state, None).await +} + +async fn set_anthropic_key(state: AppState, key: Option) -> axum::response::Response { + use axum::response::IntoResponse; + let Some(resident) = state.resident.clone() else { + return no_state_directory(); + }; + if let Err(error) = resident.set_anthropic_key(key).await { + warn!(%error, "failed to save the Anthropic API key"); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse { accepted: false, message: error.to_string() })).into_response(); + } + get_resident(State(state)).await.into_response() +} + +fn no_state_directory() -> axum::response::Response { + use axum::response::IntoResponse; + (StatusCode::SERVICE_UNAVAILABLE, Json(ApiResponse { accepted: false, message: "no state directory".to_owned() })).into_response() +} + #[derive(serde::Deserialize)] struct HistoryQuery { limit: Option, @@ -1675,6 +1766,55 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn the_resident_claude_sessions_hooks_make_no_card_and_the_key_is_never_sent_back() { + let dir = resident::tests::temp_dir("main-claude"); + let transport = Arc::new(RecordingTransport::default()); + let mut state = test_state(transport.clone()); + let claude = resident_claude::tests::script(); + state.resident = Some(Arc::new( + resident::tests::resident(&dir, &resident::tests::logged_in(), state.activities.clone()) + .with_claude(resident_claude::tests::brain(&dir, &claude, state.activities.clone())), + )); + + let request = AnthropicKeyRequest { api_key: " ".to_owned() }; + assert_eq!(put_anthropic_key(State(state.clone()), Json(request)).await.status(), StatusCode::BAD_REQUEST); + let request = AnthropicKeyRequest { api_key: "sk-ant-secret".to_owned() }; + let response = put_anthropic_key(State(state.clone()), Json(request)).await; + assert_eq!(response.status(), StatusCode::OK); + let body = http_body_util::BodyExt::collect(response.into_body()).await.unwrap().to_bytes(); + let info: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(info["anthropic_key_set"], true); + assert!(!String::from_utf8_lossy(&body).contains("sk-ant-secret")); + let request = BrainRequest { brain: resident::BrainChoice::Claude }; + let response = put_resident_brain(State(state.clone()), Json(request)).await; + let body = http_body_util::BodyExt::collect(response.into_body()).await.unwrap().to_bytes(); + let info: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!((info["brain_setting"].as_str(), info["brain"].as_str(), info["ready"].as_bool()), (Some("claude"), Some("claude"), Some(true))); + + let resident = state.resident.clone().unwrap(); + resident.say("hi").await.unwrap(); + let session = resident.thread_id().unwrap(); + for event in ["UserPromptSubmit", "Stop"] { + let hook: ClaudeHook = serde_json::from_value(serde_json::json!({ + "session_id": session, + "prompt_id": "p1", + "hook_event_name": event, + "cwd": dir.join("resident").display().to_string(), + })) + .unwrap(); + let _ = post_claude_hook(State(state.clone()), Json(hook)).await; + } + assert!(transport.events().is_empty()); + assert!(state.activities.lock().await.summaries().is_empty()); + + let response = delete_anthropic_key(State(state.clone())).await; + let body = http_body_util::BodyExt::collect(response.into_body()).await.unwrap().to_bytes(); + let info: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!((info["anthropic_key_set"].as_bool(), info["problem"].as_str()), (Some(false), Some("anthropic_key_missing"))); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn the_app_version_leads_the_build_identity() { assert_eq!( @@ -1776,7 +1916,16 @@ mod tests { let Json(info) = get_resident(State(state.clone())).await; assert_eq!( serde_json::to_value(&info).unwrap(), - serde_json::json!({"enabled": true, "thread_id": null, "revision": 0, "ready": true, "problem": null}) + serde_json::json!({ + "enabled": true, + "thread_id": null, + "revision": 0, + "ready": true, + "problem": null, + "brain": "codex", + "brain_setting": "auto", + "anthropic_key_set": false, + }) ); assert_eq!(script.lock().unwrap().starts, 0, "asking never starts the app-server"); diff --git a/daemon/src/resident.rs b/daemon/src/resident.rs index da7b97c0..983e75a2 100644 --- a/daemon/src/resident.rs +++ b/daemon/src/resident.rs @@ -1,4 +1,7 @@ -//! The resident session (ADR-0017): the one long-running Codex thread every voice command goes to. +//! The resident session (ADR-0017): the one long-running Agent session every voice command goes to, on one of two +//! brains: the user's Codex (this module) or the user's Claude Code with their own Anthropic API key +//! ([`crate::resident_claude`]). Which one is a setting (`codex`, `claude` or `auto`: Codex when it is ready, else +//! Claude Code when a key is set); each brain keeps its own session for good. //! //! The daemon drives `codex app-server` over stdio with the user's own login, in a Codex home of its own //! (`resident-codex/` in the daemon's state directory) that the user's Codex and the ChatGPT App never list, so they @@ -27,6 +30,7 @@ use tracing::{info, warn}; use crate::activity::ActivityTracker; use crate::app_server::{Client, Incoming, RpcError}; +use crate::resident_claude::ClaudeBrain; /// The Codex version the app-server protocol was verified with. app-server and `dynamicTools` are experimental, /// so other versions get a warning in the log, but are still tried. @@ -57,6 +61,10 @@ const AUTH_STORE: &str = "cli_auth_credentials_store=\"file\""; /// Codex's built-in web search (`disabled`, `cached`, `indexed` or `live` in codex 0.162.0): `live`, since the /// questions it is for (weather, news, prices) are about now, which a cached index may not have yet. const WEB_SEARCH: &str = "live"; +/// The user's brain setting and Anthropic API key, in the config directory (mode 0600). A file of its own, written +/// only through its own endpoints, because `PUT /v1/config` replaces the whole config and an older App would wipe a +/// key it doesn't know. +pub const SETTINGS_FILE: &str = "resident-settings.json"; pub fn enabled_by_env() -> bool { matches!(std::env::var(ENABLE_VARIABLE).as_deref(), Ok("1" | "true")) @@ -67,6 +75,8 @@ pub enum ResidentError { Disabled, NoCodex, NotLoggedIn, + NoClaude, + NoAnthropicKey, Rpc(String), Timeout, /// The turn ended without completing: interrupted or failed. @@ -79,6 +89,10 @@ impl std::fmt::Display for ResidentError { Self::Disabled => write!(formatter, "the resident session is experimental and off; start vibebuddyd with {ENABLE_VARIABLE}=1"), Self::NoCodex => write!(formatter, "codex was not found on PATH (set {CODEX_VARIABLE} to its path)"), Self::NotLoggedIn => write!(formatter, "Codex is not logged in; run `codex login` first"), + Self::NoClaude => { + write!(formatter, "claude was not found on PATH (set {} to its path)", crate::resident_claude::CLAUDE_VARIABLE) + } + Self::NoAnthropicKey => write!(formatter, "no Anthropic API key is set for Claude Code"), Self::Rpc(message) => write!(formatter, "{message}"), Self::Timeout => write!(formatter, "the resident session took longer than {} s and was interrupted", TURN_TIMEOUT.as_secs()), Self::TurnEnded(message) => write!(formatter, "the turn ended without completing: {message}"), @@ -92,19 +106,78 @@ impl From for ResidentError { } } -/// Why the resident session can't take a voice command now: voice needs the user's Codex, installed and logged in. +/// Why the resident session can't take a voice command now: voice needs the user's Codex, installed and logged in, +/// or the user's Claude Code, installed, with their Anthropic API key. #[derive(Clone, Copy, Debug, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum Problem { CodexMissing, CodexLoggedOut, + ClaudeMissing, + AnthropicKeyMissing, } -/// The last readiness check: none yet, or when it was made and what it found. +/// The Agent the resident session runs on. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Brain { + Codex, + Claude, +} + +/// Which brain the user wants: `auto` is Codex when it is ready, else Claude Code when an Anthropic API key is set. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrainChoice { + #[default] + Auto, + Codex, + Claude, +} + +/// The last readiness check: none yet, or when it was made, what it found, and the brain that answers. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Readiness { pub checked: Option, pub problem: Option, + /// The brain voice commands go to; None while not ready under `auto`. + pub brain: Option, +} + +/// What the user set: the brain, and their Anthropic API key (never sent back to a client). +#[derive(Clone, Default, Deserialize, Serialize)] +struct Settings { + #[serde(default)] + brain: BrainChoice, + #[serde(default, skip_serializing_if = "Option::is_none")] + anthropic_api_key: Option, +} + +/// Readiness under a brain setting, from the last Codex check and whether Claude Code is there with a key. +fn decide(choice: BrainChoice, codex: Readiness, claude_installed: bool, key_set: bool) -> Readiness { + let claude_problem = if !claude_installed { + Some(Problem::ClaudeMissing) + } else if !key_set { + Some(Problem::AnthropicKeyMissing) + } else { + None + }; + let codex_side = Readiness { brain: Some(Brain::Codex), ..codex }; + let claude_side = Readiness { checked: Some(tokio::time::Instant::now()), problem: claude_problem, brain: Some(Brain::Claude) }; + match choice { + BrainChoice::Codex => codex_side, + BrainChoice::Claude => claude_side, + BrainChoice::Auto if codex.ready() => codex_side, + BrainChoice::Auto if claude_problem.is_none() => claude_side, + // Codex hasn't been looked at yet: no answer either way. + BrainChoice::Auto if codex.checked.is_none() => Readiness::default(), + // A key says the user means Claude Code, so its problem is the one to fix; else Codex's. + BrainChoice::Auto => Readiness { + checked: codex.checked, + problem: if key_set { claude_problem } else { codex.problem }, + brain: None, + }, + } } impl Readiness { @@ -174,8 +247,15 @@ pub struct Resident { thread_id: std::sync::Mutex>, /// Bumped when a turn ends or the thread changes, so a client can tell the history moved without reading it. revision: std::sync::atomic::AtomicU64, - /// The last readiness check; watchers hear when `ready()` flips. + /// The last Codex check (its `brain` unused). + codex_readiness: std::sync::Mutex, + /// Readiness under the brain setting; watchers hear when `ready()` flips. readiness: watch::Sender, + /// The session on the user's Claude Code. + claude: ClaudeBrain, + settings: std::sync::Mutex, + /// Where the settings are kept; None keeps them in memory only (tests). + settings_file: Option, /// What the readiness check finds with the fake app-server. #[cfg(test)] fake_problem: std::sync::Mutex>, @@ -197,21 +277,99 @@ impl Resident { codex_home: state_dir.join(CODEX_HOME_DIR), user_codex_home: user_codex_home(), launcher, + claude: ClaudeBrain::new(state_dir, crate::resident_claude::Launcher::Claude, activities.clone()), activities, session: Mutex::new(None), thread_id: std::sync::Mutex::new(saved.thread_id), revision: std::sync::atomic::AtomicU64::new(0), + codex_readiness: std::sync::Mutex::new(Readiness::default()), readiness: watch::channel(Readiness::default()).0, + settings: std::sync::Mutex::new(Settings::default()), + settings_file: None, #[cfg(test)] fake_problem: std::sync::Mutex::new(None), } } + /// Keeps the brain setting and the Anthropic API key in `path` (see [`SETTINGS_FILE`]), reading them now. + pub fn with_settings_file(mut self, path: PathBuf) -> Self { + let settings = std::fs::read_to_string(&path).ok().and_then(|text| serde_json::from_str(&text).ok()); + self.settings = std::sync::Mutex::new(settings.unwrap_or_default()); + self.settings_file = Some(path); + self + } + + #[cfg(test)] + pub(crate) fn with_claude(mut self, claude: ClaudeBrain) -> Self { + self.claude = claude; + self + } + + pub fn brain_setting(&self) -> BrainChoice { + self.settings.lock().expect("settings lock").brain + } + + pub fn anthropic_key_set(&self) -> bool { + self.anthropic_key().is_some() + } + + fn anthropic_key(&self) -> Option { + self.settings.lock().expect("settings lock").anthropic_api_key.clone() + } + + pub async fn set_brain(&self, choice: BrainChoice) -> std::io::Result<()> { + self.change_settings(|settings| settings.brain = choice)?; + info!(?choice, "the resident session's brain setting changed"); + self.check_readiness_now().await; + Ok(()) + } + + /// Sets or clears the user's Anthropic API key. A running Claude Code session is stopped, so the next command + /// starts one with the new key. + pub async fn set_anthropic_key(&self, key: Option) -> std::io::Result<()> { + let key = key.map(|key| key.trim().to_owned()).filter(|key| !key.is_empty()); + let set = key.is_some(); + self.change_settings(|settings| settings.anthropic_api_key = key)?; + info!(set, "the Anthropic API key for the resident session changed"); + self.claude.stop().await; + self.check_readiness_now().await; + Ok(()) + } + + fn change_settings(&self, change: impl FnOnce(&mut Settings)) -> std::io::Result<()> { + let mut settings = self.settings.lock().expect("settings lock"); + let mut changed = settings.clone(); + change(&mut changed); + if let Some(path) = &self.settings_file { + let text = serde_json::to_string_pretty(&changed).map_err(std::io::Error::other)?; + crate::resident_claude::write_private(path, &text)?; + } + *settings = changed; + Ok(()) + } + + /// The brain a voice command goes to now: the ready one, else the one the setting points at (Codex under + /// `auto`), whose start then says what is missing. + pub fn brain(&self) -> Brain { + self.readiness().brain.unwrap_or(match self.brain_setting() { + BrainChoice::Claude => Brain::Claude, + BrainChoice::Codex | BrainChoice::Auto => Brain::Codex, + }) + } + pub fn enabled(&self) -> bool { self.enabled } + /// The session voice commands go to now: the Codex thread or the Claude Code session. pub fn thread_id(&self) -> Option { + match self.brain() { + Brain::Codex => self.codex_thread_id(), + Brain::Claude => self.claude.session_id(), + } + } + + fn codex_thread_id(&self) -> Option { self.thread_id.lock().expect("thread id lock").clone() } @@ -230,7 +388,8 @@ impl Resident { *self.readiness.borrow() } - /// Whether the session can take a voice command: on, and the last check found Codex installed and logged in. + /// Whether the session can take a voice command: on, and the last check found the brain the setting picks + /// ready (Codex installed and logged in, or Claude Code installed with a key). pub fn ready(&self) -> bool { self.enabled && self.readiness().ready() } @@ -241,22 +400,35 @@ impl Resident { } /// Checks again whether `codex` is there and the user logged in, unless the last check is younger than - /// [`READINESS_TTL`]. Starts no app-server and no turn: it looks `codex` up as a start would and asks - /// `codex login status` in the resident Codex home, which reads the login the app-server's `account/read` would. + /// [`READINESS_TTL`] or the setting is Claude Code; and whether `claude` is there and a key set, which costs + /// nothing. Starts no app-server, process or turn: it looks `codex` up as a start would and asks `codex login + /// status` in the resident Codex home, which reads the login the app-server's `account/read` would. pub async fn check_readiness(&self) -> Readiness { - let last = self.readiness(); - if !self.enabled || last.checked.is_some_and(|checked| checked.elapsed() < READINESS_TTL) { - return last; + if !self.enabled { + return self.readiness(); + } + let last = *self.codex_readiness.lock().expect("readiness lock"); + if self.brain_setting() != BrainChoice::Claude + && !last.checked.is_some_and(|checked| checked.elapsed() < READINESS_TTL) + { + let problem = match &self.launcher { + Launcher::Codex => self.login_problem().await, + #[cfg(test)] + Launcher::Fake(_) => *self.fake_problem.lock().unwrap(), + }; + self.note(problem); + } else { + self.publish(); } - let problem = match &self.launcher { - Launcher::Codex => self.login_problem().await, - #[cfg(test)] - Launcher::Fake(_) => *self.fake_problem.lock().unwrap(), - }; - self.note(problem); self.readiness() } + /// The same check, but Codex is asked again whatever the age of its last answer: a setting changed. + async fn check_readiness_now(&self) { + self.codex_readiness.lock().expect("readiness lock").checked = None; + self.check_readiness().await; + } + async fn login_problem(&self) -> Option { let Some(program) = find_codex() else { return Some(Problem::CodexMissing) }; if let Err(error) = prepare_codex_home(&self.codex_home, &self.user_codex_home) { @@ -280,17 +452,31 @@ impl Resident { } } - /// Records what a check or a start found; watchers are told only when readiness itself flips. + /// Records what a Codex check or start found; see [`Resident::publish`]. pub(crate) fn note(&self, problem: Option) { - let now = Readiness { checked: Some(tokio::time::Instant::now()), problem }; + *self.codex_readiness.lock().expect("readiness lock") = + Readiness { checked: Some(tokio::time::Instant::now()), problem, brain: None }; + self.publish(); + } + + /// Works out readiness under the brain setting; watchers are told only when it flips. A change of brain moves + /// the revision, since the history shown is the other brain's now. + fn publish(&self) { + let codex = *self.codex_readiness.lock().expect("readiness lock"); + let now = decide(self.brain_setting(), codex, self.claude.installed(), self.anthropic_key_set()); + let mut switched = false; self.readiness.send_if_modified(|readiness| { let flipped = readiness.ready() != now.ready(); - if readiness.problem != now.problem { - info!(?problem, "resident session readiness changed"); + if (readiness.problem, readiness.brain) != (now.problem, now.brain) { + info!(problem = ?now.problem, brain = ?now.brain, "resident session readiness changed"); } + switched = readiness.brain.is_some() && now.brain.is_some() && readiness.brain != now.brain; *readiness = now; flipped }); + if switched { + self.bump_revision(); + } } /// Whether a Codex hook event comes from the resident session: by its thread, or by its working directory. The @@ -301,6 +487,16 @@ impl Resident { { return true; } + self.owns_cwd(cwd) + } + + /// Whether a Claude Code hook event comes from the resident session: by its session, or by its working + /// directory. Its config dir loads none of the user's hooks, but managed ones may. + pub fn owns_claude_hook(&self, session_id: &str, cwd: Option<&str>) -> bool { + self.claude.owns_session(session_id) || self.owns_cwd(cwd) + } + + fn owns_cwd(&self, cwd: Option<&str>) -> bool { cwd.map(Path::new) .is_some_and(|cwd| cwd.starts_with(&self.dir) || cwd.starts_with(&self.canonical_dir)) } @@ -319,14 +515,31 @@ impl Resident { if !self.enabled { return Err(ResidentError::Disabled); } + let result = match self.brain() { + Brain::Codex => self.say_codex(text, on_reply).await, + Brain::Claude => self.say_claude(text, on_reply).await, + }; + // Even a failed or timed-out turn may have left the command in the thread. + self.bump_revision(); + result + } + + async fn say_codex(&self, text: &str, on_reply: &mut (dyn FnMut(&str) + Send)) -> Result { let mut slot = self.session.lock().await; let session = self.running(&mut slot).await?; let result = self.run_turn(session, text, on_reply).await; if session.client.is_closed() { *slot = None; } - // Even a failed or timed-out turn may have left the command in the thread. - self.bump_revision(); + result + } + + async fn say_claude(&self, text: &str, on_reply: &mut (dyn FnMut(&str) + Send)) -> Result { + let key = self.anthropic_key().ok_or(ResidentError::NoAnthropicKey)?; + let result = self.claude.say_streaming(&key, &instructions(None, None, Brain::Claude), text, on_reply).await; + if result == Err(ResidentError::NoClaude) { + self.publish(); + } result } @@ -336,6 +549,13 @@ impl Resident { if !self.enabled { return; } + self.check_readiness().await; + if self.brain() == Brain::Claude { + if let Some(key) = self.anthropic_key() { + self.claude.warm(&key, &instructions(None, None, Brain::Claude)).await; + } + return; + } let mut slot = self.session.lock().await; match self.running(&mut slot).await { Ok(session) => info!(thread = %session.thread_id, "the resident session is ready"), @@ -349,7 +569,10 @@ impl Resident { if !self.enabled { return Err(ResidentError::Disabled); } - if self.thread_id().is_none() { + if self.brain() == Brain::Claude { + return Ok(self.claude.history(limit)); + } + if self.codex_thread_id().is_none() { return Ok(Vec::new()); } let mut slot = self.session.lock().await; @@ -417,7 +640,7 @@ impl Resident { } std::fs::create_dir_all(&self.dir) .map_err(|error| ResidentError::Rpc(format!("cannot create {}: {error}", self.dir.display())))?; - let thread_id = match self.thread_id() { + let thread_id = match self.codex_thread_id() { // There is one resident thread for good. Only a thread that no longer exists is replaced (a thread // saved before the resident session had its own Codex home is one); one that can't be resumed for // another reason (another Codex process is its writer) fails this command and is tried again with the @@ -469,7 +692,7 @@ impl Resident { "cwd": self.dir, "sandbox": "read-only", "approvalPolicy": "on-request", - "developerInstructions": instructions(None, memory_summary(&self.user_codex_home).as_deref()), + "developerInstructions": instructions(None, memory_summary(&self.user_codex_home).as_deref(), Brain::Codex), }) } @@ -552,31 +775,44 @@ impl Resident { async fn call_tool(&self, params: &Value, on_reply: &mut (dyn FnMut(&str) + Send)) -> Value { let tool = params.get("tool").and_then(Value::as_str).unwrap_or_default(); - let (success, text) = match tool { - "speak" => { - let text = params.pointer("/arguments/text").and_then(Value::as_str).unwrap_or_default().trim(); - if text.is_empty() { - (false, "text is empty".to_owned()) - } else { - info!(chars = text.chars().count(), "the resident session spoke"); - on_reply(text); - (true, "spoken".to_owned()) - } - } - "list_activities" => { - let report = self.activities.lock().await.report(); - (true, json!(report).to_string()) - } - "search_memories" => { - let query = params.pointer("/arguments/query").and_then(Value::as_str).unwrap_or_default(); - (true, search_memories(&self.user_codex_home.join("memories"), query)) - } - other => (false, format!("unknown tool {other}")), - }; + let memories = self.user_codex_home.join("memories"); + let (success, text) = run_tool(tool, ¶ms["arguments"], &self.activities, Some(&memories), on_reply).await; json!({"success": success, "contentItems": [{"type": "inputText", "text": text}]}) } } +/// Runs one of our tools, for either brain: whether it succeeded, and what the session reads back. `memories` is +/// the user's Codex memories folder; without it there is no `search_memories`. +pub(crate) async fn run_tool( + tool: &str, + arguments: &Value, + activities: &Mutex, + memories: Option<&Path>, + on_reply: &mut (dyn FnMut(&str) + Send), +) -> (bool, String) { + match (tool, memories) { + ("speak", _) => { + let text = arguments.get("text").and_then(Value::as_str).unwrap_or_default().trim(); + if text.is_empty() { + (false, "text is empty".to_owned()) + } else { + info!(chars = text.chars().count(), "the resident session spoke"); + on_reply(text); + (true, "spoken".to_owned()) + } + } + ("list_activities", _) => { + let report = activities.lock().await.report(); + (true, json!(report).to_string()) + } + ("search_memories", Some(memories)) => { + let query = arguments.get("query").and_then(Value::as_str).unwrap_or_default(); + (true, search_memories(memories, query)) + } + (other, _) => (false, format!("unknown tool {other}")), + } +} + /// The answer that declines a server-to-client request asking for permission or input; None if it isn't one. fn decline(method: &str) -> Option { Some(match method { @@ -590,7 +826,7 @@ fn decline(method: &str) -> Option { } /// The tools the resident session gets. Nothing here may run a command or write a file. -fn tools() -> Value { +pub(crate) fn tools() -> Value { json!([ { "type": "function", @@ -633,7 +869,9 @@ fn tools() -> Value { /// The developer instructions. `persona` is the seam for the Character the user picked (CONTEXT.md, Persona); /// until Characters carry one, the buddy speaks plainly. `memories` is the user's Codex memory summary, if any. -fn instructions(persona: Option<&str>, memories: Option<&str>) -> String { +/// `brain` says where today's date is: in Codex's environment context, or in the context block the daemon puts +/// before each voice command for Claude Code. +pub(crate) fn instructions(persona: Option<&str>, memories: Option<&str>, brain: Brain) -> String { let persona = persona.unwrap_or("You are friendly, calm and brief."); let memories = memories.map_or_else(String::new, |summary| { format!( @@ -641,6 +879,10 @@ fn instructions(persona: Option<&str>, memories: Option<&str>) -> String { `search_memories`. Use it when it helps; never read it out.\n\n{summary}\n" ) }); + let date = match brain { + Brain::Codex => "today's date and the user's time zone are in the environment context", + Brain::Claude => "the user's local date, time and time zone come with each voice command, in a context block", + }; format!( "You are the buddy of VibeBuddy: a small desk box that watches the user's coding Agents (Codex, Claude Code, \ OpenCode, GitHub Copilot CLI, Pi, GitHub Actions) and speaks up for them. {persona}\n\ @@ -652,8 +894,8 @@ fn instructions(persona: Option<&str>, memories: Option<&str>) -> String { counts (`today`). Titles there are short upper-case task-card names; say them naturally.\n\ Never state a fact you have not seen in a tool result, a web search result or the memories below: not the \ weather, news, prices, scores, schedules or anything else about now, and nothing about the user's world. \ - For things about now, search the web, and answer only from what the search found; today's date and the \ - user's time zone are in the environment context, so work out today and tomorrow from them. If you can't \ + For things about now, search the web, and answer only from what the search found; {date}, so work out \ + today and tomorrow from them. If you can't \ find out, say so plainly in one sentence; never make up an answer that sounds likely.\n\ You do no work yourself: run no commands, read or edit no files. If the user asks for work to be done, say \ briefly that you can't start it yet.{memories}" @@ -1401,7 +1643,7 @@ pub(crate) mod tests { #[test] fn without_memories_the_instructions_say_nothing_of_them_and_a_long_summary_is_cut() { - assert!(!instructions(None, None).contains("memory")); + assert!(!instructions(None, None, Brain::Codex).contains("memory")); let dir = temp_dir("summary"); std::fs::create_dir_all(dir.join("memories")).unwrap(); assert_eq!(memory_summary(&dir), None); @@ -1532,6 +1774,105 @@ pub(crate) mod tests { assert!(!args.iter().any(|arg| arg == "--disable")); } + #[test] + fn the_brain_setting_decides_readiness_and_auto_prefers_a_ready_codex() { + let now = Some(tokio::time::Instant::now()); + let codex_ready = Readiness { checked: now, problem: None, brain: None }; + let logged_out = Readiness { checked: now, problem: Some(Problem::CodexLoggedOut), brain: None }; + let missing = Readiness { checked: now, problem: Some(Problem::CodexMissing), brain: None }; + let unchecked = Readiness::default(); + let brain = |readiness: Readiness| readiness.ready().then_some(readiness.brain).flatten(); + let problem = |readiness: Readiness| readiness.problem; + + // auto: Codex when ready, else Claude Code when it is there with a key. + assert_eq!(brain(decide(BrainChoice::Auto, codex_ready, true, true)), Some(Brain::Codex)); + assert_eq!(brain(decide(BrainChoice::Auto, logged_out, true, true)), Some(Brain::Claude)); + assert_eq!(brain(decide(BrainChoice::Auto, unchecked, true, true)), Some(Brain::Claude)); + assert!(!decide(BrainChoice::Auto, unchecked, true, false).ready(), "nothing known yet"); + assert_eq!(decide(BrainChoice::Auto, unchecked, true, false).problem, None); + // Not ready: without a key it is Codex's problem; with one, Claude Code's. + assert_eq!(problem(decide(BrainChoice::Auto, missing, true, false)), Some(Problem::CodexMissing)); + assert_eq!(problem(decide(BrainChoice::Auto, logged_out, true, false)), Some(Problem::CodexLoggedOut)); + assert_eq!(problem(decide(BrainChoice::Auto, logged_out, false, true)), Some(Problem::ClaudeMissing)); + assert_eq!(decide(BrainChoice::Auto, logged_out, false, true).brain, None); + + // codex and claude stick to their brain. + assert_eq!(brain(decide(BrainChoice::Codex, codex_ready, true, true)), Some(Brain::Codex)); + assert_eq!(problem(decide(BrainChoice::Codex, logged_out, true, true)), Some(Problem::CodexLoggedOut)); + assert_eq!(brain(decide(BrainChoice::Claude, codex_ready, true, true)), Some(Brain::Claude)); + assert_eq!(problem(decide(BrainChoice::Claude, codex_ready, false, true)), Some(Problem::ClaudeMissing)); + assert_eq!(problem(decide(BrainChoice::Claude, codex_ready, true, false)), Some(Problem::AnthropicKeyMissing)); + } + + #[tokio::test] + async fn the_key_and_brain_are_kept_in_a_private_file_and_switching_brains_moves_the_revision() { + use std::os::unix::fs::PermissionsExt; + let dir = temp_dir("settings"); + let file = dir.join("config").join(SETTINGS_FILE); + let script = Arc::new(std::sync::Mutex::new(Script::default())); + let claude = crate::resident_claude::tests::script(); + let resident = resident(&dir, &script, Arc::default()) + .with_claude(crate::resident_claude::tests::brain(&dir, &claude, Arc::default())) + .with_settings_file(file.clone()); + *resident.fake_problem.lock().unwrap() = Some(Problem::CodexLoggedOut); + assert_eq!(resident.check_readiness().await.problem, Some(Problem::CodexLoggedOut)); + assert_eq!(resident.brain(), Brain::Codex); + + resident.set_anthropic_key(Some(" sk-ant-secret \n".to_owned())).await.unwrap(); + assert!(resident.ready() && resident.anthropic_key_set()); + assert_eq!(resident.brain(), Brain::Claude, "auto falls back to Claude Code with a key"); + assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600); + let stored: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); + assert_eq!(stored, json!({"brain": "auto", "anthropic_api_key": "sk-ant-secret"})); + + // The voice command goes to Claude Code, with the stored key, and its session is the one reported. + let answer = resident.say("hi").await.unwrap(); + assert_eq!(Some(answer.thread_id.clone()), resident.thread_id()); + let launches = claude.lock().unwrap().launches.clone(); + assert!(launches[0].1.contains(&("ANTHROPIC_API_KEY".to_owned(), "sk-ant-secret".to_owned()))); + assert_eq!(script.lock().unwrap().starts, 0, "Codex wasn't started"); + + // Codex comes back: auto goes back to it, and the history to show is another one. + let before = resident.revision(); + *resident.fake_problem.lock().unwrap() = None; + resident.set_brain(BrainChoice::Auto).await.unwrap(); + assert_eq!(resident.brain(), Brain::Codex); + assert_eq!(resident.revision(), before + 1); + + resident.set_brain(BrainChoice::Claude).await.unwrap(); + assert_eq!(resident.brain(), Brain::Claude); + resident.set_anthropic_key(None).await.unwrap(); + assert_eq!(resident.readiness().problem, Some(Problem::AnthropicKeyMissing)); + assert!(!resident.ready()); + assert_eq!(resident.say("hi").await.unwrap_err(), ResidentError::NoAnthropicKey); + let stored: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); + assert_eq!(stored, json!({"brain": "claude"}), "the key is gone from the file"); + + // Read back at the next start. + let again = Resident::new(true, &dir, Launcher::Codex, Arc::default()).with_settings_file(file); + assert_eq!(again.brain_setting(), BrainChoice::Claude); + assert!(!again.anthropic_key_set()); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn the_claude_history_and_hooks_are_the_claude_sessions() { + let dir = temp_dir("claude-hooks"); + let claude = crate::resident_claude::tests::script(); + let resident = resident(&dir, &logged_in(), Arc::default()) + .with_claude(crate::resident_claude::tests::brain(&dir, &claude, Arc::default())); + resident.set_anthropic_key(Some("key".to_owned())).await.unwrap(); + resident.set_brain(BrainChoice::Claude).await.unwrap(); + assert_eq!(resident.history(5).await.unwrap(), [], "no session yet"); + resident.say("hi").await.unwrap(); + let session = resident.thread_id().unwrap(); + assert!(resident.owns_claude_hook(&session, None)); + assert!(resident.owns_claude_hook("other", Some(&dir.join("resident").display().to_string()))); + assert!(!resident.owns_claude_hook("other", Some("/work/vibe-buddy"))); + assert!(!resident.owns_hook(&session, None, None), "a Claude session id isn't a Codex thread"); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn hooks_from_the_resident_thread_or_its_directory_are_recognised() { let dir = temp_dir("hooks"); diff --git a/daemon/src/resident_claude.rs b/daemon/src/resident_claude.rs new file mode 100644 index 00000000..7841fe4b --- /dev/null +++ b/daemon/src/resident_claude.rs @@ -0,0 +1,1112 @@ +//! The resident session on Claude Code (ADR-0017): the user's own `claude` CLI, run headless with the user's own +//! Anthropic API key, never a claude.ai login (Anthropic doesn't let third-party products use one). +//! +//! One long-lived `claude -p --input-format stream-json --output-format stream-json` process holds the resident +//! session; each voice command is one user message on its stdin, and the turn is over at the `result` line. The +//! session is created once with `--session-id` and taken up again with `--resume` after every restart, so like the +//! Codex thread it is one session for good, replaced only when its transcript is gone. +//! +//! - **Its own config dir.** `CLAUDE_CONFIG_DIR` is `resident-claude/` in the daemon's state directory (0700), so +//! none of the user's Claude config loads: settings, CLAUDE.md, hooks, plugins, MCP servers, and their login (the +//! credentials file and the macOS Keychain entry are keyed by the config dir). +//! - **Only the user's API key.** The child's environment loses every `CLAUDE*` and `ANTHROPIC*` variable the daemon +//! has (an OAuth token, an auth token, a provider switch) and gets `ANTHROPIC_API_KEY`, which `-p` always uses when +//! present. The `system/init` line says where the key came from; anything but `ANTHROPIC_API_KEY` stops the +//! process. The key is never logged. +//! - **Only our tools.** `--tools WebSearch` leaves Claude Code's only built-in tool the web search; `speak` and +//! `list_activities` come from an MCP server the daemon runs on a loopback port of its own, behind a random +//! token (`--strict-mcp-config`, so no other server loads). `--permission-mode dontAsk` with only those allowed, +//! and the usual built-ins denied by name besides. `--bare` would skip even more, but it also takes WebSearch +//! away (checked with claude 2.1.296), so the config dir and the flags do it instead. +//! +//! Experimental, behind the same `VIBEBUDDY_RESIDENT_SESSION=1` as the Codex session; see docs/resident-session.md. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader, Lines}; +use tokio::sync::{Mutex, OnceCell, mpsc}; +use tracing::{info, warn}; + +use crate::activity::ActivityTracker; +use crate::resident::{Exchange, Pipes, ResidentError, run_tool, tools}; + +/// The model the resident session runs on: a voice command waits on the answer. +pub const MODEL: &str = "claude-haiku-4-5"; +/// The Claude Code version the flags and the stream were checked with; another one gets a warning and is tried. +pub const TESTED_CLAUDE_VERSION: &str = "2.1.296"; +/// Names the `claude` binary explicitly; otherwise it is looked for on PATH and where installers put it. +pub const CLAUDE_VARIABLE: &str = "VIBEBUDDY_CLAUDE"; +/// Development only: where the child sends its API requests (a fake Messages API in testing). The daemon's own +/// `ANTHROPIC_BASE_URL`, like every other `ANTHROPIC*` variable, never reaches the child. +pub const BASE_URL_VARIABLE: &str = "VIBEBUDDY_CLAUDE_BASE_URL"; +/// The resident session's own Claude config dir, in the daemon's state directory. +const CONFIG_DIR: &str = "resident-claude"; +/// The session id and the Claude Code version it was last started with, in the daemon's state directory. +const STATE_FILE: &str = "resident-claude.json"; +/// The name the daemon's MCP server has in Claude Code: its tools are `mcp__vibebuddy__`. +const MCP_SERVER: &str = "vibebuddy"; +/// The one built-in tool the session gets. +const WEB_SEARCH: &str = "WebSearch"; +/// Built-in tools denied by name on top of `--tools`, so a Claude Code that changes what `--tools` covers still +/// gives the session none of them. +const DENIED_TOOLS: [&str; 12] = + ["Bash", "Edit", "Write", "Read", "NotebookEdit", "WebFetch", "Glob", "Grep", "Agent", "Task", "Skill", "BashOutput"]; +/// How long one voice command may take, from writing it to the `result` line; then the process is stopped. +const TURN_TIMEOUT: Duration = Duration::from_secs(120); +/// What the `system/init` line must report as the credential in use. +const API_KEY_SOURCE: &str = "ANTHROPIC_API_KEY"; +/// Starts a voice command's context block, which carries the local time; the history leaves it out. +const CONTEXT_PREFIX: &str = ""; + +/// How `claude` is reached: the real binary, or an in-memory fake in tests. +pub enum Launcher { + Claude, + #[cfg(test)] + Fake(Box Pipes + Send + Sync>), +} + +/// What would be run: the arguments, and the variables set on top of the daemon's cleaned environment. +pub struct Launch { + pub args: Vec, + pub env: Vec<(String, String)>, +} + +struct Process { + writer: Box, + lines: Lines>>, + /// The key it was started with: a new key needs a new process. + key: String, + _child: Option, +} + +#[derive(Default, Deserialize, Serialize)] +struct Saved { + session_id: Option, + /// The Claude Code version the session was last started with, for diagnosing a change in the stream. + claude_version: Option, +} + +/// What the daemon's MCP server shares with the turn in progress. +struct McpState { + token: String, + activities: Arc>, + /// Where `speak` goes while a turn runs; None between turns. + speak: std::sync::Mutex>>, +} + +pub struct ClaudeBrain { + /// The working directory, shared with the Codex session: `resident/` in the state directory. + dir: PathBuf, + config_dir: PathBuf, + state_file: PathBuf, + launcher: Launcher, + mcp: Arc, + /// The loopback address of the MCP server, once started. + mcp_url: OnceCell, + /// Held for a whole turn: one voice command at a time. + process: Mutex>, + /// Read synchronously by the hook filter. + session_id: std::sync::Mutex>, +} + +impl ClaudeBrain { + pub fn new(state_dir: &Path, launcher: Launcher, activities: Arc>) -> Self { + let state_file = state_dir.join(STATE_FILE); + let saved: Saved = std::fs::read_to_string(&state_file) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_default(); + Self { + dir: state_dir.join("resident"), + config_dir: state_dir.join(CONFIG_DIR), + state_file, + launcher, + mcp: Arc::new(McpState { token: random_hex(32), activities, speak: std::sync::Mutex::new(None) }), + mcp_url: OnceCell::new(), + process: Mutex::new(None), + session_id: std::sync::Mutex::new(saved.session_id), + } + } + + pub fn session_id(&self) -> Option { + self.session_id.lock().expect("session id lock").clone() + } + + /// Whether `claude` can be found; the readiness check, which starts nothing. + pub fn installed(&self) -> bool { + match &self.launcher { + Launcher::Claude => find_claude().is_some(), + #[cfg(test)] + Launcher::Fake(_) => true, + } + } + + /// Stops the process, if any, so the next command starts one with the current key. + pub async fn stop(&self) { + *self.process.lock().await = None; + } + + /// Starts `claude` ahead of the first voice command. A failure is logged; the next command tries again. + pub async fn warm(&self, key: &str, instructions: &str) { + let mut slot = self.process.lock().await; + if let Err(error) = self.running(&mut slot, key, instructions).await { + warn!(%error, "the resident Claude session could not be started ahead of time"); + } + } + + /// Hands one voice command to the session; each spoken reply goes to `on_reply` when `speak` is called. + /// Returns the session id once the turn is over. + pub async fn say_streaming( + &self, + key: &str, + instructions: &str, + text: &str, + on_reply: &mut (dyn FnMut(&str) + Send), + ) -> Result { + let mut slot = self.process.lock().await; + let process = self.running(&mut slot, key, instructions).await?; + let (sender, spoken) = mpsc::unbounded_channel(); + *self.mcp.speak.lock().expect("speak lock") = Some(sender); + let result = run_turn(process, spoken, text, on_reply).await; + *self.mcp.speak.lock().expect("speak lock") = None; + match &result { + // The process is still there and the session intact: an API error, a refused key. + Err(ResidentError::TurnEnded(_)) | Ok(()) => {} + Err(_) => *slot = None, + } + result.map(|()| self.session_id().unwrap_or_default()) + } + + /// The last `limit` voice commands and their spoken replies, oldest first, read from the session's transcript + /// (the daemon keeps no record of its own). Empty while there is no session yet. + pub fn history(&self, limit: usize) -> Vec { + let Some(path) = self.session_id().and_then(|id| transcript(&self.config_dir, &id)) else { + return Vec::new(); + }; + let exchanges = exchanges_of(&std::fs::read_to_string(path).unwrap_or_default()); + let skip = exchanges.len().saturating_sub(limit); + exchanges.into_iter().skip(skip).collect() + } + + /// Whether a Claude Code hook event comes from the resident session: by its session id. (The caller checks the + /// working directory.) + pub fn owns_session(&self, session_id: &str) -> bool { + self.session_id.lock().expect("session id lock").as_deref() == Some(session_id) + } + + async fn running<'a>( + &self, + slot: &'a mut Option, + key: &str, + instructions: &str, + ) -> Result<&'a mut Process, ResidentError> { + if slot.as_ref().is_some_and(|process| process.key != key) { + info!("the Anthropic API key changed; starting the resident Claude session again"); + *slot = None; + } + if slot.is_none() { + *slot = Some(self.start(key, instructions).await?); + } + Ok(slot.as_mut().expect("process was just started")) + } + + async fn start(&self, key: &str, instructions: &str) -> Result { + let failed = |what: &str, error: std::io::Error| ResidentError::Rpc(format!("cannot {what}: {error}")); + prepare_config_dir(&self.config_dir).map_err(|error| failed("prepare the Claude config dir", error))?; + std::fs::create_dir_all(&self.dir).map_err(|error| failed("create the resident directory", error))?; + let url = self.mcp_url.get_or_try_init(|| serve_mcp(self.mcp.clone())).await.map_err(|error| failed("start the MCP server", error))?; + let mcp_config = self.config_dir.join("mcp.json"); + write_private(&mcp_config, &mcp_config_json(url, &self.mcp.token).to_string()) + .map_err(|error| failed("write the MCP config", error))?; + // One session for good: resumed while its transcript is there, else a new one. + let saved = self.session_id(); + let session = match &saved { + Some(id) if transcript(&self.config_dir, id).is_some() => Session::Resume(id.clone()), + other => { + let id = new_session_id(); + if let Some(old) = other { + warn!(old_session = %old, new_session = %id, "the resident Claude session's transcript is gone; starting a new one"); + } + Session::New(id) + } + }; + let launch = Launch { + args: claude_args(&mcp_config, &session, instructions), + env: child_env(&self.config_dir, key, std::env::var(BASE_URL_VARIABLE).ok()), + }; + let (pipes, version) = match &self.launcher { + Launcher::Claude => spawn_claude(&launch, &self.dir).await?, + #[cfg(test)] + Launcher::Fake(connect) => (connect(&launch), None), + }; + let id = session.id().to_owned(); + *self.session_id.lock().expect("session id lock") = Some(id.clone()); + let saved = Saved { session_id: Some(id.clone()), claude_version: version }; + if let Err(error) = write_private(&self.state_file, &serde_json::to_string_pretty(&saved).unwrap_or_default()) { + warn!(%error, "failed to save the resident Claude session id"); + } + info!(session = %id, resumed = matches!(session, Session::Resume(_)), "resident Claude session started"); + Ok(Process { + writer: pipes.writer, + lines: BufReader::new(pipes.reader).lines(), + key: key.to_owned(), + _child: pipes.child, + }) + } +} + +enum Session { + New(String), + Resume(String), +} + +impl Session { + fn id(&self) -> &str { + match self { + Self::New(id) | Self::Resume(id) => id, + } + } +} + +/// One turn: the voice command in, lines out until `result`, and whatever `speak` said meanwhile. +async fn run_turn( + process: &mut Process, + mut spoken: mpsc::UnboundedReceiver, + text: &str, + on_reply: &mut (dyn FnMut(&str) + Send), +) -> Result<(), ResidentError> { + let line = format!("{}\n", user_message(text, &chrono::Local::now())); + let written = async { + process.writer.write_all(line.as_bytes()).await?; + process.writer.flush().await + }; + written.await.map_err(|error| ResidentError::Rpc(format!("claude is gone: {error}")))?; + let deadline = tokio::time::Instant::now() + TURN_TIMEOUT; + loop { + tokio::select! { + Some(reply) = spoken.recv() => on_reply(&reply), + line = tokio::time::timeout_at(deadline, process.lines.next_line()) => { + let line = match line { + Err(_) => return Err(ResidentError::Timeout), + Ok(Ok(Some(line))) => line, + Ok(_) => return Err(ResidentError::Rpc("claude exited".to_owned())), + }; + let Ok(message) = serde_json::from_str::(&line) else { continue }; + match (message["type"].as_str(), message["subtype"].as_str()) { + (Some("system"), Some("init")) => { + let source = message["apiKeySource"].as_str().unwrap_or("none"); + if source != API_KEY_SOURCE { + warn!(source, "the resident Claude session isn't using the user's API key; stopped it"); + return Err(ResidentError::Rpc(format!( + "Claude Code would authenticate with {source}, not the Anthropic API key; refused" + ))); + } + } + (Some("result"), _) => { + // `speak` is answered before Claude Code goes on, so anything it said is already queued. + while let Ok(reply) = spoken.try_recv() { + on_reply(&reply); + } + if message["is_error"].as_bool() == Some(true) { + let reason = message["result"] + .as_str() + .or(message["terminal_reason"].as_str()) + .or(message["subtype"].as_str()) + .unwrap_or("unknown"); + return Err(ResidentError::TurnEnded(reason.to_owned())); + } + return Ok(()); + } + _ => {} + } + } + } + } +} + +/// A voice command as a stream-json user message: the local time first, as context, then what was said. +fn user_message(text: &str, now: &chrono::DateTime) -> Value { + let context = format!("{CONTEXT_PREFIX}The user's local time: {}", now.format("%A %Y-%m-%d %H:%M (UTC%:z)")); + json!({"type": "user", "message": {"role": "user", "content": [ + {"type": "text", "text": context}, + {"type": "text", "text": text}, + ]}}) +} + +/// The arguments for `claude`; see the module docs for why each is there. +fn claude_args(mcp_config: &Path, session: &Session, instructions: &str) -> Vec { + let allowed: Vec = std::iter::once(WEB_SEARCH.to_owned()) + .chain(mcp_tools().iter().map(|tool| format!("mcp__{MCP_SERVER}__{}", tool["name"].as_str().unwrap_or_default()))) + .collect(); + let mut args: Vec = [ + "-p", + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--verbose", + "--model", + MODEL, + "--tools", + WEB_SEARCH, + "--permission-mode", + "dontAsk", + "--permission-prompts", + "none", + "--strict-mcp-config", + "--disable-slash-commands", + // The instructions are read again on every request, so a change reaches a resumed session. + "--system-prompt-snapshot", + "off", + ] + .map(str::to_owned) + .to_vec(); + args.extend(["--allowedTools".to_owned(), allowed.join(",")]); + args.extend(["--disallowedTools".to_owned(), DENIED_TOOLS.join(",")]); + args.extend(["--mcp-config".to_owned(), mcp_config.display().to_string()]); + args.extend(["--settings".to_owned(), json!({"disableAllHooks": true}).to_string()]); + // Replaces Claude Code's coding-assistant prompt: the buddy isn't one, and a short prompt is a faster turn. + args.extend(["--system-prompt".to_owned(), instructions.to_owned()]); + match session { + Session::New(id) => args.extend(["--session-id".to_owned(), id.clone()]), + Session::Resume(id) => args.extend(["--resume".to_owned(), id.clone()]), + } + args +} + +/// What the child gets on top of the daemon's environment, which loses every `CLAUDE*` and `ANTHROPIC*` variable +/// first (see [`spawn_claude`]). +fn child_env(config_dir: &Path, key: &str, base_url: Option) -> Vec<(String, String)> { + let mut env = vec![ + ("CLAUDE_CONFIG_DIR".to_owned(), config_dir.display().to_string()), + ("ANTHROPIC_API_KEY".to_owned(), key.to_owned()), + // No memory files written into the config dir for a session that has its own way to remember. + ("CLAUDE_CODE_DISABLE_AUTO_MEMORY".to_owned(), "1".to_owned()), + // The daemon's child doesn't update the user's Claude Code behind their back. + ("DISABLE_AUTOUPDATER".to_owned(), "1".to_owned()), + ]; + if let Some(url) = base_url.filter(|url| !url.is_empty()) { + env.push(("ANTHROPIC_BASE_URL".to_owned(), url)); + } + env +} + +/// Whether a variable of the daemon's environment is kept from the child: every one that can choose Claude Code's +/// credential or provider (an OAuth token, an auth token, Bedrock/Vertex/Foundry switches, profiles) starts so. +fn withheld(name: &str) -> bool { + name.starts_with("CLAUDE") || name.starts_with("ANTHROPIC") +} + +async fn spawn_claude(launch: &Launch, cwd: &Path) -> Result<(Pipes, Option), ResidentError> { + let program = find_claude().ok_or(ResidentError::NoClaude)?; + let command = |args: &[String]| { + let mut command = tokio::process::Command::new(&program); + for (name, _) in std::env::vars_os() { + if withheld(&name.to_string_lossy()) { + command.env_remove(&name); + } + } + command.envs(launch.env.iter().map(|(name, value)| (name, value))).args(args).current_dir(cwd); + command + }; + let version = command(&["--version".to_owned()]) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .await + .ok() + .and_then(|output| String::from_utf8(output.stdout).ok()) + .and_then(|text| text.split_whitespace().next().map(str::to_owned)); + if version.as_deref() != Some(TESTED_CLAUDE_VERSION) { + warn!( + version = version.as_deref().unwrap_or("unknown"), + tested = TESTED_CLAUDE_VERSION, + "the resident session was tested with another Claude Code version; its flags or stream may differ" + ); + } + let mut child = command(&launch.args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|error| ResidentError::Rpc(format!("cannot start {}: {error}", program.display())))?; + info!(program = %program.display(), "started claude for the resident session"); + // A refused flag or a crash is only on stderr; it carries no credentials. + if let Some(stderr) = child.stderr.take() { + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let end = line.floor_char_boundary(300); + info!(line = &line[..end], "claude stderr"); + } + }); + } + let reader = child.stdout.take().expect("stdout is piped"); + let writer = child.stdin.take().expect("stdin is piped"); + Ok((Pipes { reader: Box::new(reader), writer: Box::new(writer), child: Some(child) }, version)) +} + +/// `VIBEBUDDY_CLAUDE`, else `claude` on PATH, else where installers put it: the daemon may run with launchd's bare +/// PATH. +pub fn find_claude() -> Option { + if let Some(path) = std::env::var_os(CLAUDE_VARIABLE) { + return Some(PathBuf::from(path)); + } + let home = std::env::var_os("HOME").map(PathBuf::from); + std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).map(|dir| dir.join("claude")).collect::>()) + .unwrap_or_default() + .into_iter() + .chain(home.iter().flat_map(|home| [home.join(".local/bin/claude"), home.join(".claude/local/claude")])) + .chain(["/opt/homebrew/bin/claude", "/usr/local/bin/claude"].map(PathBuf::from)) + .find(|path| path.is_file()) +} + +fn prepare_config_dir(config_dir: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::create_dir_all(config_dir)?; + std::fs::set_permissions(config_dir, std::fs::Permissions::from_mode(0o700)) +} + +/// Writes a file only the user can read (0600), replacing it whole. +pub(crate) fn write_private(path: &Path, text: &str) -> std::io::Result<()> { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let temporary = path.with_extension("tmp"); + let mut file = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&temporary)?; + // A file left from before with wider permissions keeps them through `open`. + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + file.write_all(text.as_bytes())?; + file.sync_all()?; + std::fs::rename(temporary, path) +} + +fn mcp_config_json(url: &str, token: &str) -> Value { + json!({"mcpServers": {MCP_SERVER: {"type": "http", "url": url, "headers": {"Authorization": format!("Bearer {token}")}}}}) +} + +/// The session's transcript in the config dir (`projects//.jsonl`), if it exists. +fn transcript(config_dir: &Path, session_id: &str) -> Option { + std::fs::read_dir(config_dir.join("projects")) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path().join(format!("{session_id}.jsonl"))) + .find(|path| path.is_file()) +} + +/// The voice commands in a transcript and what was said through `speak` after each, oldest first. Lines are put +/// in time order first: Claude Code doesn't always write them in order. +fn exchanges_of(transcript: &str) -> Vec { + let speak = format!("mcp__{MCP_SERVER}__speak"); + let mut entries: Vec = transcript + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|entry| matches!(entry["type"].as_str(), Some("user" | "assistant")) && entry["isSidechain"] != true) + .collect(); + entries.sort_by(|a, b| a["timestamp"].as_str().unwrap_or_default().cmp(b["timestamp"].as_str().unwrap_or_default())); + let mut exchanges: Vec = Vec::new(); + for entry in entries { + let content = &entry["message"]["content"]; + if entry["type"] == "user" { + if entry["isMeta"] == true || entry["isCompactSummary"] == true { + continue; + } + let said = match content { + Value::String(text) => text.clone(), + Value::Array(blocks) => blocks + .iter() + .filter(|block| block["type"] == "text") + .filter_map(|block| block["text"].as_str()) + .filter(|text| !text.starts_with(CONTEXT_PREFIX)) + .collect::>() + .join(" "), + _ => String::new(), + }; + if !said.is_empty() { + exchanges.push(Exchange { said, replies: Vec::new() }); + } + } else if let (Some(exchange), Some(blocks)) = (exchanges.last_mut(), content.as_array()) { + exchange.replies.extend( + blocks + .iter() + .filter(|block| block["type"] == "tool_use" && block["name"] == speak.as_str()) + .filter_map(|block| block["input"]["text"].as_str().map(str::to_owned)), + ); + } + } + exchanges +} + +/// Our tools as MCP tools: the Codex session's, less `search_memories` (Codex memories are Codex's). +fn mcp_tools() -> Vec { + tools() + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|tool| tool["name"] != "search_memories") + .map(|tool| json!({"name": tool["name"], "description": tool["description"], "inputSchema": tool["inputSchema"]})) + .collect() +} + +/// Starts the MCP server on a loopback port of its own and returns its URL. +async fn serve_mcp(state: Arc) -> std::io::Result { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let url = format!("http://{}/mcp", listener.local_addr()?); + let router = axum::Router::new().route("/mcp", axum::routing::post(post_mcp).get(no_stream).delete(no_stream)).with_state(state); + tokio::spawn(async move { + if let Err(error) = axum::serve(listener, router).await { + warn!(%error, "the resident session's MCP server stopped"); + } + }); + info!(%url, "the resident session's MCP server is listening"); + Ok(url) +} + +/// No server-sent stream: every answer comes back on its POST. +async fn no_stream() -> StatusCode { + StatusCode::METHOD_NOT_ALLOWED +} + +/// MCP's Streamable HTTP transport, the JSON-response form: one JSON-RPC message per POST. +async fn post_mcp(State(state): State>, headers: HeaderMap, body: String) -> axum::response::Response { + let authorized = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .is_some_and(|token| same(token.as_bytes(), state.token.as_bytes())); + if !authorized { + return StatusCode::UNAUTHORIZED.into_response(); + } + let Ok(message) = serde_json::from_str::(&body) else { + return (StatusCode::BAD_REQUEST, axum::Json(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": "parse error"}}))) + .into_response(); + }; + let Some(id) = message.get("id").cloned() else { + // A notification (`notifications/initialized` and the like) needs no answer. + return StatusCode::ACCEPTED.into_response(); + }; + let answer = match mcp_result(&state, message["method"].as_str().unwrap_or_default(), &message["params"]).await { + Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}), + Err((code, text)) => json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": text}}), + }; + axum::Json(answer).into_response() +} + +async fn mcp_result(state: &McpState, method: &str, params: &Value) -> Result { + match method { + "initialize" => { + let version = params["protocolVersion"].as_str().unwrap_or("2025-06-18"); + Ok(json!({ + "protocolVersion": version, + "capabilities": {"tools": {}}, + "serverInfo": {"name": MCP_SERVER, "title": "VibeBuddy", "version": env!("CARGO_PKG_VERSION")}, + })) + } + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({"tools": mcp_tools()})), + "tools/call" => { + let tool = params["name"].as_str().unwrap_or_default(); + if tool == "search_memories" { + return Ok(json!({"content": [{"type": "text", "text": "unknown tool search_memories"}], "isError": true})); + } + let speak = state.speak.lock().expect("speak lock").clone(); + let (success, text) = run_tool(tool, ¶ms["arguments"], &state.activities, None, &mut |reply| match &speak { + Some(turn) => { + let _ = turn.send(reply.to_owned()); + } + None => warn!("the resident Claude session spoke outside a turn; dropped"), + }) + .await; + Ok(json!({"content": [{"type": "text", "text": text}], "isError": !success})) + } + other => Err((-32601, format!("method not found: {other}"))), + } +} + +/// Compares two secrets in time that doesn't depend on where they differ. +fn same(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).fold(0u8, |difference, (x, y)| difference | (x ^ y)) == 0 +} + +fn random_hex(bytes: usize) -> String { + let mut buffer = vec![0u8; bytes]; + getrandom::fill(&mut buffer).expect("the system's random source"); + buffer.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// A random (version 4) UUID, as `--session-id` wants. +fn new_session_id() -> String { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).expect("the system's random source"); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + let hex: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + format!("{}-{}-{}-{}-{}", &hex[..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..]) +} + +#[cfg(test)] +pub(crate) mod tests { + use std::collections::VecDeque; + + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + use super::*; + use crate::activity::{ActivityId, ActivityStatus}; + use crate::resident::tests::temp_dir; + + /// One thing the fake `claude` does while answering a voice command. + #[derive(Clone)] + pub(crate) enum Step { + /// The `system/init` line, naming where the credential came from. + Init(&'static str), + /// Calls one of the daemon's MCP tools over HTTP, as Claude Code would, with the token from the MCP config. + Call(&'static str, Value), + /// Waits for the test before going on. + Hold, + /// The `result` line: whether it is an error, and its text. + Result(bool, &'static str), + /// Ends its output, as if the process died. + Exit, + } + + /// How the fake `claude` behaves, and what it saw. + #[derive(Default)] + pub(crate) struct Script { + /// What each voice command gets, in order; a command with none left gets an init and a result. + pub(crate) turns: VecDeque>, + /// The arguments and environment of each start. + pub(crate) launches: Vec, + /// The lines the daemon wrote. + pub(crate) received: Vec, + /// The MCP server's answers to the calls made, with their HTTP status. + pub(crate) answers: Vec<(u16, Value)>, + pub(crate) hold: Option>, + } + + pub(crate) type Shared = Arc>; + /// The arguments and environment of one start. + pub(crate) type Launched = (Vec, Vec<(String, String)>); + + pub(crate) fn script() -> Shared { + Arc::default() + } + + /// A Claude brain on the fake `claude`, in `state_dir`. + pub(crate) fn brain(state_dir: &Path, script: &Shared, activities: Arc>) -> ClaudeBrain { + let script = script.clone(); + ClaudeBrain::new(state_dir, Launcher::Fake(Box::new(move |launch| fake_claude(script.clone(), launch))), activities) + } + + fn argument(args: &[String], flag: &str) -> Option { + args.iter().position(|arg| arg == flag).and_then(|index| args.get(index + 1)).cloned() + } + + /// Posts one JSON-RPC message to the MCP server named in the MCP config file, as Claude Code does. + pub(crate) async fn post_to_mcp(config: &Path, token: Option<&str>, message: Value) -> (u16, Value) { + let config: Value = serde_json::from_str(&std::fs::read_to_string(config).unwrap()).unwrap(); + let server = &config["mcpServers"][MCP_SERVER]; + let url = server["url"].as_str().unwrap().to_owned(); + let authorization = match token { + Some(token) => format!("Bearer {token}"), + None => server["headers"]["Authorization"].as_str().unwrap().to_owned(), + }; + tokio::task::spawn_blocking(move || { + let mut response = crate::speech::agent(Duration::from_secs(5)) + .post(&url) + .header("Authorization", &authorization) + .header("Content-Type", "application/json") + .send(message.to_string().as_bytes()) + .unwrap(); + let status = response.status().as_u16(); + let body = response.body_mut().read_to_string().unwrap_or_default(); + (status, serde_json::from_str(&body).unwrap_or(Value::Null)) + }) + .await + .unwrap() + } + + fn fake_claude(script: Shared, launch: &Launch) -> Pipes { + script.lock().unwrap().launches.push((launch.args.clone(), launch.env.clone())); + let mcp_config = PathBuf::from(argument(&launch.args, "--mcp-config").unwrap()); + let (daemon_side, fake_side) = tokio::io::duplex(1 << 16); + let (daemon_read, daemon_write) = tokio::io::split(daemon_side); + tokio::spawn(async move { + let (fake_read, mut out) = tokio::io::split(fake_side); + let mut lines = BufReader::new(fake_read).lines(); + while let Ok(Some(line)) = lines.next_line().await { + script.lock().unwrap().received.push(serde_json::from_str(&line).unwrap()); + let steps = script.lock().unwrap().turns.pop_front(); + let steps = steps.unwrap_or_else(|| vec![Step::Init(API_KEY_SOURCE), Step::Result(false, "")]); + for (number, step) in steps.into_iter().enumerate() { + let line = match step { + Step::Init(source) => json!({"type": "system", "subtype": "init", "apiKeySource": source, "tools": [WEB_SEARCH]}), + Step::Call(tool, arguments) => { + let name = format!("mcp__{MCP_SERVER}__{tool}"); + let call = json!({"type": "assistant", "message": {"content": [{"type": "tool_use", "id": number, "name": name, "input": arguments}]}}); + out.write_all(format!("{call}\n").as_bytes()).await.unwrap(); + let message = json!({"jsonrpc": "2.0", "id": number, "method": "tools/call", "params": {"name": tool, "arguments": arguments}}); + let answer = post_to_mcp(&mcp_config, None, message).await; + script.lock().unwrap().answers.push(answer); + continue; + } + Step::Hold => { + let hold = script.lock().unwrap().hold.clone().unwrap(); + hold.notified().await; + continue; + } + Step::Result(is_error, text) => { + json!({"type": "result", "subtype": "success", "is_error": is_error, "result": text}) + } + Step::Exit => return, + }; + out.write_all(format!("{line}\n").as_bytes()).await.unwrap(); + } + } + }); + Pipes { reader: Box::new(daemon_read), writer: Box::new(daemon_write), child: None } + } + + fn launches(script: &Shared) -> Vec { + script.lock().unwrap().launches.clone() + } + + async fn say(brain: &ClaudeBrain, key: &str, text: &str) -> Result, ResidentError> { + let mut replies = Vec::new(); + brain.say_streaming(key, "You are the buddy of VibeBuddy.", text, &mut |reply| replies.push(reply.to_owned())).await?; + Ok(replies) + } + + #[tokio::test] + async fn the_first_command_starts_one_session_with_only_our_tools_and_the_users_key() { + use std::os::unix::fs::PermissionsExt; + let dir = temp_dir("claude-start"); + let script = script(); + let brain = brain(&dir, &script, Arc::default()); + say(&brain, "sk-ant-test", "hello").await.unwrap(); + + let (args, env) = launches(&script).remove(0); + let flags = |name: &str| args.iter().any(|arg| arg == name); + for flag in ["-p", "--verbose", "--strict-mcp-config", "--disable-slash-commands"] { + assert!(flags(flag), "missing {flag}: {args:?}"); + } + assert!(!flags("--bare"), "bare mode takes WebSearch away"); + assert_eq!(argument(&args, "--input-format").as_deref(), Some("stream-json")); + assert_eq!(argument(&args, "--output-format").as_deref(), Some("stream-json")); + assert_eq!(argument(&args, "--model").as_deref(), Some("claude-haiku-4-5")); + assert_eq!(argument(&args, "--tools").as_deref(), Some("WebSearch")); + assert_eq!(argument(&args, "--permission-mode").as_deref(), Some("dontAsk")); + assert_eq!(argument(&args, "--permission-prompts").as_deref(), Some("none")); + assert_eq!(argument(&args, "--system-prompt-snapshot").as_deref(), Some("off")); + assert_eq!( + argument(&args, "--allowedTools").as_deref(), + Some("WebSearch,mcp__vibebuddy__speak,mcp__vibebuddy__list_activities") + ); + let denied = argument(&args, "--disallowedTools").unwrap(); + for tool in ["Bash", "Edit", "Write", "Read", "WebFetch"] { + assert!(denied.split(',').any(|denied| denied == tool), "{tool} isn't denied: {denied}"); + } + assert_eq!(argument(&args, "--settings").as_deref(), Some(r#"{"disableAllHooks":true}"#)); + assert_eq!(argument(&args, "--system-prompt").as_deref(), Some("You are the buddy of VibeBuddy.")); + let session = argument(&args, "--session-id").unwrap(); + assert_eq!(session.len(), 36); + assert_eq!(&session[14..15], "4", "a version 4 UUID"); + assert!(!flags("--resume")); + + let variable = |name: &str| env.iter().find(|(key, _)| key == name).map(|(_, value)| value.clone()); + let config_dir = dir.join("resident-claude"); + assert_eq!(variable("CLAUDE_CONFIG_DIR"), Some(config_dir.display().to_string())); + assert_eq!(variable("ANTHROPIC_API_KEY").as_deref(), Some("sk-ant-test")); + assert_eq!(variable("ANTHROPIC_BASE_URL"), None); + assert_eq!(std::fs::metadata(&config_dir).unwrap().permissions().mode() & 0o777, 0o700); + let mcp_config = PathBuf::from(argument(&args, "--mcp-config").unwrap()); + assert_eq!(mcp_config, config_dir.join("mcp.json")); + assert_eq!(std::fs::metadata(&mcp_config).unwrap().permissions().mode() & 0o777, 0o600); + let config: Value = serde_json::from_str(&std::fs::read_to_string(&mcp_config).unwrap()).unwrap(); + assert_eq!(config["mcpServers"]["vibebuddy"]["type"], "http"); + assert!(config["mcpServers"]["vibebuddy"]["url"].as_str().unwrap().starts_with("http://127.0.0.1:")); + assert!(!std::fs::read_to_string(&mcp_config).unwrap().contains("sk-ant-test"), "the key stays out of files"); + + // The voice command goes in as a user message, after a context block with the local time. + let line = script.lock().unwrap().received[0].clone(); + assert_eq!(line["type"], "user"); + let content = line["message"]["content"].as_array().unwrap(); + assert!(content[0]["text"].as_str().unwrap().starts_with("The user's local time: ")); + assert_eq!(content[1], json!({"type": "text", "text": "hello"})); + let saved: Value = serde_json::from_str(&std::fs::read_to_string(dir.join(STATE_FILE)).unwrap()).unwrap(); + assert_eq!(saved["session_id"], session.as_str()); + assert_eq!(brain.session_id().as_deref(), Some(session.as_str())); + + // One process for good: the next command goes to the same one. + say(&brain, "sk-ant-test", "again").await.unwrap(); + assert_eq!(launches(&script).len(), 1); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn a_spoken_reply_is_handed_over_when_speak_is_called_not_when_the_turn_ends() { + let dir = temp_dir("claude-stream"); + let script = script(); + let hold = Arc::new(tokio::sync::Notify::new()); + script.lock().unwrap().hold = Some(hold.clone()); + script.lock().unwrap().turns.push_back(vec![ + Step::Init(API_KEY_SOURCE), + Step::Call("speak", json!({"text": " On it. "})), + Step::Hold, + Step::Call("speak", json!({"text": "Done."})), + Step::Result(false, ""), + ]); + let brain = Arc::new(brain(&dir, &script, Arc::default())); + let (sender, mut receiver) = mpsc::unbounded_channel(); + let turn = tokio::spawn({ + let brain = brain.clone(); + async move { + brain.say_streaming("key", "instructions", "hi", &mut |reply| sender.send(reply.to_owned()).unwrap()).await + } + }); + assert_eq!(receiver.recv().await.as_deref(), Some("On it.")); + assert!(!turn.is_finished(), "the turn is still running"); + hold.notify_one(); + assert_eq!(turn.await.unwrap().unwrap(), brain.session_id().unwrap()); + assert_eq!(receiver.recv().await.as_deref(), Some("Done."), "said just before the result, still heard"); + let answers = script.lock().unwrap().answers.clone(); + assert_eq!(answers[0].0, 200); + assert_eq!(answers[0].1["result"], json!({"content": [{"type": "text", "text": "spoken"}], "isError": false})); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn list_activities_reads_the_tracker_and_other_tools_are_refused() { + let dir = temp_dir("claude-tools"); + let mut tracker = ActivityTracker::default(); + let id = ActivityId { session_id: "s".to_owned(), key: "s:t".to_owned() }; + tracker.observe(&id, "CX:REVIEW", ActivityStatus::Working); + let script = script(); + script.lock().unwrap().turns.push_back(vec![ + Step::Init(API_KEY_SOURCE), + Step::Call("list_activities", json!({})), + Step::Call("search_memories", json!({"query": "cat"})), + Step::Call("run_shell", json!({"command": "ls"})), + Step::Call("speak", json!({"text": ""})), + Step::Result(false, ""), + ]); + let brain = brain(&dir, &script, Arc::new(Mutex::new(tracker))); + assert_eq!(say(&brain, "key", "what's up?").await.unwrap(), Vec::::new()); + let answers = script.lock().unwrap().answers.clone(); + let text = |index: usize| answers[index].1["result"]["content"][0]["text"].as_str().unwrap().to_owned(); + let listed: Value = serde_json::from_str(&text(0)).unwrap(); + assert_eq!(listed["activities"][0]["title"], "CX:REVIEW"); + assert!(listed["recently_ended"].is_array() && listed["today"].is_object()); + assert_eq!(answers[1].1["result"]["isError"], true, "Codex memories aren't Claude's"); + assert_eq!(answers[2].1["result"]["isError"], true); + assert_eq!(text(2), "unknown tool run_shell"); + assert_eq!(answers[3].1["result"]["isError"], true, "an empty reply isn't spoken"); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn the_mcp_server_answers_only_with_its_token_and_lists_only_our_tools() { + let dir = temp_dir("claude-mcp"); + let brain = brain(&dir, &script(), Arc::default()); + say(&brain, "key", "hi").await.unwrap(); + let config = dir.join("resident-claude/mcp.json"); + + let (status, _) = post_to_mcp(&config, Some("wrong"), json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})).await; + assert_eq!(status, 401); + let initialize = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "claude-code", "version": "2"}}}); + let (status, answer) = post_to_mcp(&config, None, initialize).await; + assert_eq!(status, 200); + assert_eq!(answer["result"]["protocolVersion"], "2025-06-18"); + assert_eq!(answer["result"]["capabilities"], json!({"tools": {}})); + let (status, _) = post_to_mcp(&config, None, json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).await; + assert_eq!(status, 202); + let (_, listed) = post_to_mcp(&config, None, json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})).await; + let names: Vec<&str> = listed["result"]["tools"].as_array().unwrap().iter().map(|tool| tool["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["speak", "list_activities"]); + assert!(listed["result"]["tools"][0]["inputSchema"]["properties"]["text"].is_object()); + let (_, unknown) = post_to_mcp(&config, None, json!({"jsonrpc": "2.0", "id": 3, "method": "resources/list"})).await; + assert_eq!(unknown["error"]["code"], -32601); + // Outside a turn a spoken reply has nowhere to go; it is answered but dropped. + let speak = json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "speak", "arguments": {"text": "Hi."}}}); + let (_, spoken) = post_to_mcp(&config, None, speak).await; + assert_eq!(spoken["result"]["isError"], false); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn a_later_start_resumes_the_session_and_one_whose_transcript_is_gone_is_replaced() { + let dir = temp_dir("claude-resume"); + let first = script(); + say(&brain(&dir, &first, Arc::default()), "key", "hi").await.unwrap(); + let session = argument(&launches(&first)[0].0, "--session-id").unwrap(); + + // Claude Code wrote the transcript under the working directory's project folder. + let project = dir.join("resident-claude/projects/-state-resident"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join(format!("{session}.jsonl")), "").unwrap(); + let second = script(); + let restarted = brain(&dir, &second, Arc::default()); + assert_eq!(restarted.session_id().as_deref(), Some(session.as_str()), "read back from the state file"); + say(&restarted, "key", "hi").await.unwrap(); + let args = &launches(&second)[0].0; + assert_eq!(argument(args, "--resume").as_deref(), Some(session.as_str())); + assert!(!args.iter().any(|arg| arg == "--session-id")); + + std::fs::remove_dir_all(dir.join("resident-claude/projects")).unwrap(); + let third = script(); + let replaced = brain(&dir, &third, Arc::default()); + say(&replaced, "key", "hi").await.unwrap(); + let new = argument(&launches(&third)[0].0, "--session-id").unwrap(); + assert_ne!(new, session); + assert_eq!(replaced.session_id().as_deref(), Some(new.as_str())); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn a_credential_other_than_the_api_key_stops_the_process() { + let dir = temp_dir("claude-oauth"); + let script = script(); + script.lock().unwrap().turns.push_back(vec![Step::Init("none"), Step::Result(false, "")]); + let brain = brain(&dir, &script, Arc::default()); + let error = say(&brain, "key", "hi").await.unwrap_err(); + assert!(matches!(&error, ResidentError::Rpc(message) if message.contains("not the Anthropic API key")), "{error}"); + say(&brain, "key", "hi").await.unwrap(); + assert_eq!(launches(&script).len(), 2, "the refused process was dropped"); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn an_error_result_fails_the_turn_and_a_dead_process_is_started_again() { + let dir = temp_dir("claude-errors"); + let script = script(); + script.lock().unwrap().turns.extend([ + vec![Step::Init(API_KEY_SOURCE), Step::Result(true, "Invalid API key · Fix external API key")], + vec![Step::Exit], + ]); + let brain = brain(&dir, &script, Arc::default()); + let error = say(&brain, "key", "one").await.unwrap_err(); + assert_eq!(error, ResidentError::TurnEnded("Invalid API key · Fix external API key".to_owned())); + assert!(matches!(say(&brain, "key", "two").await, Err(ResidentError::Rpc(_)))); + assert_eq!(launches(&script).len(), 1, "an error result leaves the process running"); + say(&brain, "key", "three").await.unwrap(); + assert_eq!(launches(&script).len(), 2, "a process that died is started again"); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn a_new_key_starts_a_new_process_and_stop_drops_it() { + let dir = temp_dir("claude-key"); + let script = script(); + let brain = brain(&dir, &script, Arc::default()); + say(&brain, "old", "hi").await.unwrap(); + say(&brain, "new", "hi").await.unwrap(); + let keys: Vec = launches(&script) + .iter() + .map(|(_, env)| env.iter().find(|(name, _)| name == "ANTHROPIC_API_KEY").unwrap().1.clone()) + .collect(); + assert_eq!(keys, ["old", "new"]); + brain.stop().await; + say(&brain, "new", "hi").await.unwrap(); + assert_eq!(launches(&script).len(), 3); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test(start_paused = true)] + async fn a_turn_that_takes_too_long_stops_the_process() { + let dir = temp_dir("claude-timeout"); + let script = script(); + script.lock().unwrap().hold = Some(Arc::new(tokio::sync::Notify::new())); + script.lock().unwrap().turns.push_back(vec![Step::Init(API_KEY_SOURCE), Step::Hold]); + let brain = brain(&dir, &script, Arc::default()); + assert_eq!(say(&brain, "key", "hi").await.unwrap_err(), ResidentError::Timeout); + say(&brain, "key", "hi").await.unwrap(); + assert_eq!(launches(&script).len(), 2); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn the_history_is_read_from_the_transcript_in_time_order() { + let line = |time: &str, entry: Value| { + let mut entry = entry; + entry["timestamp"] = json!(time); + entry.to_string() + }; + let speak = |text: &str| json!({"type": "tool_use", "name": "mcp__vibebuddy__speak", "input": {"text": text}}); + let transcript = [ + // Written out of order, as Claude Code does. + line("2026-10-14T07:00:02Z", json!({"type": "assistant", "message": {"content": [speak("One task."), speak("It's fine.")]}})), + line("2026-10-14T07:00:01Z", json!({"type": "user", "message": {"content": [ + {"type": "text", "text": "The user's local time: Tuesday"}, + {"type": "text", "text": "what's up?"}, + ]}})), + json!({"type": "queue-operation", "operation": "enqueue", "content": "what's up?"}).to_string(), + line("2026-10-14T07:00:03Z", json!({"type": "user", "message": {"content": [{"type": "tool_result", "tool_use_id": "x", "content": "spoken"}]}})), + line("2026-10-14T07:01:00Z", json!({"type": "user", "message": {"content": "and now?"}})), + line("2026-10-14T07:01:01Z", json!({"type": "assistant", "message": {"content": [ + {"type": "tool_use", "name": "mcp__vibebuddy__list_activities", "input": {}}, + {"type": "text", "text": "thinking aloud"}, + ]}})), + line("2026-10-14T07:01:02Z", json!({"type": "assistant", "message": {"content": [speak("Still one.")]}})), + line("2026-10-14T07:01:03Z", json!({"type": "user", "isMeta": true, "message": {"content": "meta"}})), + line("2026-10-14T07:01:04Z", json!({"type": "user", "isSidechain": true, "message": {"content": "sub"}})), + "not json".to_owned(), + ] + .join("\n"); + assert_eq!( + exchanges_of(&transcript), + [ + Exchange { said: "what's up?".to_owned(), replies: vec!["One task.".to_owned(), "It's fine.".to_owned()] }, + Exchange { said: "and now?".to_owned(), replies: vec!["Still one.".to_owned()] }, + ] + ); + + let dir = temp_dir("claude-history"); + let brain = brain(&dir, &script(), Arc::default()); + assert_eq!(brain.history(5), [], "no session yet"); + *brain.session_id.lock().unwrap() = Some("abc".to_owned()); + let project = dir.join("resident-claude/projects/-state-resident"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("abc.jsonl"), &transcript).unwrap(); + assert_eq!(brain.history(1), [Exchange { said: "and now?".to_owned(), replies: vec!["Still one.".to_owned()] }]); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn the_child_gets_none_of_the_daemons_claude_or_anthropic_variables() { + for name in ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDECODE", "ANTHROPIC_PROFILE", "CLAUDE_CONFIG_DIR"] { + assert!(withheld(name), "{name}"); + } + for name in ["HOME", "PATH", "LANG", "VIBEBUDDY_CLAUDE_BASE_URL"] { + assert!(!withheld(name), "{name}"); + } + let env = child_env(Path::new("/state/resident-claude"), "sk", Some("http://127.0.0.1:9".to_owned())); + assert!(env.contains(&("ANTHROPIC_BASE_URL".to_owned(), "http://127.0.0.1:9".to_owned()))); + assert!(env.contains(&("CLAUDE_CODE_DISABLE_AUTO_MEMORY".to_owned(), "1".to_owned()))); + assert!(!child_env(Path::new("/s"), "sk", Some(String::new())).iter().any(|(name, _)| name == "ANTHROPIC_BASE_URL")); + } + + #[test] + fn private_files_are_written_0600_even_over_a_wider_one() { + use std::os::unix::fs::PermissionsExt; + let dir = temp_dir("claude-private"); + let path = dir.join("secret.json"); + std::fs::write(&path, "old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + write_private(&path, "new").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "new"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + assert!(!path.with_extension("tmp").exists()); + let _ = std::fs::remove_dir_all(dir); + } +} From 5ad219e77c1daadb1b02ac3994d473c9e1fd3971 Mon Sep 17 00:00:00 2001 From: dragon Date: Wed, 14 Oct 2026 15:55:19 +0800 Subject: [PATCH 41/51] Pick who answers voice commands and add the Anthropic API key, in both Apps The Conversation tab, on the Mac and on Linux, gets "Answered by" (Automatic, Codex or Claude Code) and, unless it is Codex, a secure field for the user's own Anthropic API key with Save, Remove and a link to the Claude Console. The key is never shown again: the daemon only says whether it has one. The tab's not-ready states cover Claude Code: not installed (with Anthropic's setup page) or no key yet; under Automatic, a missing or signed-out Codex says Claude Code can answer instead. zh-Hans translations for both Apps. Docs: the Claude Code brain in docs/resident-session.md (auth rule with the terms link, flags and why not --bare, the MCP server, privacy: text goes to Anthropic), docs/app.md, docs/claude-adapter.md, ADR-0017's consequences, CONTEXT.md (Brain; Answered by, Anthropic API key), and both READMEs. Co-Authored-By: Claude Opus 5.5 --- CONTEXT.md | 6 + README.md | 2 +- README.zh-CN.md | 2 +- .../zh-Hans.lproj/Localizable.strings | 13 ++ app/Sources/SelfTest/main.swift | 7 + app/Sources/VibeBuddy/ConversationView.swift | 114 ++++++++++++-- app/Sources/VibeBuddy/DaemonClient.swift | 21 +++ app/Sources/VibeBuddy/Resources.swift | 6 + app/Sources/VibeBuddy/SettingsView.swift | 4 +- app/Sources/VibeBuddyCore/Resident.swift | 38 ++++- desktop/src/client.rs | 78 +++++++++- desktop/src/main.rs | 141 ++++++++++++++++-- ...sident-session-that-speaks-as-the-buddy.md | 3 +- docs/app.md | 4 +- docs/claude-adapter.md | 4 + docs/resident-session.md | 62 ++++++-- 16 files changed, 465 insertions(+), 40 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index ba158e95..3398ed5a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -102,6 +102,10 @@ _Avoid_: voice assistant (an assistant means an Agent), prompt (that is the Agen The one long-running Agent Session every voice command goes to, so the user never has to say which work they mean. It answers from what it can see of the Activities and hands work on to other Sessions, but does no work itself: it runs no commands and changes no files. There is only one, and it is never restarted to make room: when its context grows long, it is compacted. It is an Agent that speaks as the buddy, in the persona of the Character the user picked, and changes persona when the Character changes; it is never an Activity: it makes no task cards, announcements or stats. _Avoid_: assistant, concierge, dispatcher, main session +**Brain (大脑)**: +The Agent the resident session runs on: the user's own Codex, or the user's own Claude Code with their own Anthropic API key (never a Claude subscription). The user picks one, or leaves it automatic: Codex when it is ready, else Claude Code. Each brain has its own resident session; the UI calls the setting "Answered by". +_Avoid_: model (that is what the brain runs on), backend, engine, provider + **Spoken reply (口头回复)**: The short sentence the resident session writes for the user to hear in answer to a voice command, synthesized when it is spoken, in the announcement voice of the Character the user picked. Unlike a line, it is written at the moment it is spoken and may talk about what the Agents are doing. _Avoid_: line (lines are written in advance), announcement, TTS, response @@ -169,6 +173,8 @@ Fixed wording for the App's English UI and English logs. Use these words when wr | 常驻会话 | resident session | Not shown to users so far; in the UI the buddy is the one you talk to | | 口头回复 | spoken reply | | | 对话 | conversation | The settings tab (Conversation) listing voice commands and the buddy's spoken replies; shown only while the resident session is on | +| 由谁回答 / 自动 | Answered by / Automatic | The brain setting on the Conversation tab: Automatic, Codex or Claude Code; "brain" stays a code word | +| Anthropic API 密钥 | Anthropic API key | The user's own key for Claude Code; never "Claude key" or "token", and never a Claude subscription | ### Activity lifecycle diff --git a/README.md b/README.md index 17caa5c4..fb35b289 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,7 @@ cargo run -p vibebuddyd By default it only listens on `127.0.0.1:7331` and finds the box by its Espressif USB Serial/JTAG ID, `VID:PID 303A:1001`. `VIBEBUDDY_BIND` changes the listen address, `VIBEBUDDY_SERIAL_PORT` names a serial port explicitly, and `VIBEBUDDY_USB_SERIAL` picks one box among several identical ones; without it, the daemon connects to the box that has reported VibeBuddy firmware to it before, and when none of several devices is that box, the app asks which one is. Either variable shows up in the app's Device tab, so a pin left set can't pass for a missing box. A box running other firmware, such as Muse, is written nothing. HTTP `202 Accepted` means the event entered the bounded send queue; whether the box actually received it is what the daemon logs from the box's reply. -Voice commands are experimental and off by default: `VIBEBUDDY_RESIDENT_SESSION=1` turns on the resident session, and with a speech recognizer configured the box gets push-to-talk on a long K0. While it is on, Settings has a Conversation tab, on the Mac and on Linux, with your last voice commands and the buddy's spoken replies. See [`docs/resident-session.md`](docs/resident-session.md). +Voice commands are experimental and off by default: `VIBEBUDDY_RESIDENT_SESSION=1` turns on the resident session, and with a speech recognizer configured the box gets push-to-talk on a long K0. Your own Codex answers, or, if you'd rather, your own Claude Code with your own Anthropic API key (a Claude subscription can't be used). While it is on, Settings has a Conversation tab, on the Mac and on Linux, with your last voice commands and the buddy's spoken replies, which Agent answers, and the API key. See [`docs/resident-session.md`](docs/resident-session.md). The app normally supervises the daemon. On a development machine without the app you can install it as a LaunchAgent with [`packaging/com.vibebuddy.vibebuddyd.plist`](packaging/com.vibebuddy.vibebuddyd.plist), but don't run both: they'd fight over the serial port. For the app, the daemon also serves `GET /v1/status`, SSE `/v1/status/stream`, `/v1/config`, `/v1/device/{identify,volume,choice,screenshot,voice-pack,firmware}` and `/v1/daemon/restart`. diff --git a/README.zh-CN.md b/README.zh-CN.md index ec2ef393..77268da6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -256,7 +256,7 @@ cargo run -p vibebuddyd 默认只监听 `127.0.0.1:7331`,并按 Espressif USB Serial/JTAG 的 `VID:PID 303A:1001` 自动发现设备。可用 `VIBEBUDDY_BIND` 修改监听地址、`VIBEBUDDY_SERIAL_PORT` 显式指定串口,或用 `VIBEBUDDY_USB_SERIAL` 在多块相同设备中选择目标;不指定时,daemon 连之前报出过 VibeBuddy 固件的那块;插着好几块、又都不是那块时,App 会问哪一块是盒子。这两个变量设了都会显示在 App 的设备页上,免得忘了撤的固定被当成找不到盒子。运行其他固件(比如 Muse)的盒子,daemon 不会往里写任何东西。HTTP `202 Accepted` 表示事件进入有界发送队列;设备实际接收结果以 daemon 记录的设备响应为准。 -语音指令还在实验阶段,默认关闭:`VIBEBUDDY_RESIDENT_SESSION=1` 打开常驻会话,再配好语音识别,盒子长按 K0 就成了按住说话。打开期间,Mac 和 Linux 的设置里都会多出“对话”页,列出你最近的语音指令和氛围小助手的口头回复。详见 [`docs/resident-session.md`](docs/resident-session.md)。 +语音指令还在实验阶段,默认关闭:`VIBEBUDDY_RESIDENT_SESSION=1` 打开常驻会话,再配好语音识别,盒子长按 K0 就成了按住说话。由你自己的 Codex 来回答;也可以换成你自己的 Claude Code,配你自己的 Anthropic API 密钥(不能用 Claude 订阅)。打开期间,Mac 和 Linux 的设置里都会多出“对话”页,列出你最近的语音指令和氛围小助手的口头回复,也在这里选由谁回答、填 API 密钥。详见 [`docs/resident-session.md`](docs/resident-session.md)。 daemon 平时由 App 看管。没有 App 的开发机可以用 [`packaging/com.vibebuddy.vibebuddyd.plist`](packaging/com.vibebuddy.vibebuddyd.plist) 装成 LaunchAgent,但两者不能同时跑,会抢串口。daemon 还为 App 提供 `GET /v1/status`、SSE `/v1/status/stream`、`/v1/config`、`/v1/device/{identify,volume,choice,screenshot,voice-pack,firmware}` 与 `/v1/daemon/restart`。 diff --git a/app/Localization/zh-Hans.lproj/Localizable.strings b/app/Localization/zh-Hans.lproj/Localizable.strings index f1e9c9b2..3872c0ae 100644 --- a/app/Localization/zh-Hans.lproj/Localizable.strings +++ b/app/Localization/zh-Hans.lproj/Localizable.strings @@ -331,3 +331,16 @@ "Voice commands are answered by Codex on this computer, and it isn't installed. Install the Codex CLI, then sign in with `codex login`. This tab notices within half a minute." = "语音指令由这台电脑上的 Codex 来回答,但它还没安装。先装好 Codex CLI,再用 `codex login` 登录。半分钟内这一页会自己更新。"; "Voice commands are answered by Codex on this computer, and it isn't signed in. Run `codex login` in a terminal and sign in. This tab notices within half a minute." = "语音指令由这台电脑上的 Codex 来回答,但它还没登录。在终端里运行 `codex login` 登录即可。半分钟内这一页会自己更新。"; "How to install the Codex CLI" = "如何安装 Codex CLI"; +"Answered by" = "由谁回答"; +"Automatic" = "自动"; +"Automatic: Codex when it's ready, else Claude Code with your API key." = "自动:Codex 能用时用 Codex,否则用 Claude Code 和你的 API 密钥。"; +"Anthropic API key" = "Anthropic API 密钥"; +"Anthropic API key saved" = "已保存 Anthropic API 密钥"; +"Save" = "保存"; +"Get an API key" = "获取 API 密钥"; +"Claude Code answers only with your own Anthropic API key, billed to your Anthropic account; a Claude subscription can't be used here. What you say and your Agents' task titles go to Anthropic." = "Claude Code 只用你自己的 Anthropic API 密钥回答,费用记在你的 Anthropic 账户上;这里不能用 Claude 订阅。你说的话和各个 Agent 的任务标题会发给 Anthropic。"; +"Voice needs Claude Code" = "语音需要 Claude Code"; +"Voice commands are answered by Claude Code on this computer, and it isn't installed. Install Claude Code; you don't need to sign in to it. This tab notices within half a minute." = "语音指令由这台电脑上的 Claude Code 来回答,但它还没安装。装好 Claude Code 即可,不用登录。半分钟内这一页会自己更新。"; +"Claude Code answers voice commands only with your own Anthropic API key. Add your key above." = "Claude Code 只用你自己的 Anthropic API 密钥回答语音指令。请在上面填入你的密钥。"; +"Or let Claude Code answer, with your own Anthropic API key: add it above." = "也可以让 Claude Code 来回答,用你自己的 Anthropic API 密钥:在上面填入即可。"; +"How to install Claude Code" = "如何安装 Claude Code"; diff --git a/app/Sources/SelfTest/main.swift b/app/Sources/SelfTest/main.swift index 33c7e0f8..6492fdef 100644 --- a/app/Sources/SelfTest/main.swift +++ b/app/Sources/SelfTest/main.swift @@ -322,6 +322,13 @@ do { check(loggedOut.problem == .codexLoggedOut, "resident info says Codex is logged out") let later = try StatusCoding.decoder().decode(ResidentInfo.self, from: Data(#"{"enabled":true,"problem":"something_new","ready":false,"revision":0,"thread_id":null}"#.utf8)) check(later.problem == .other("something_new"), "an unknown problem still decodes") + check(later.brainSetting == nil && later.anthropicKeySet == nil, "an older daemon has no brain setting") + let claude = try StatusCoding.decoder().decode(ResidentInfo.self, from: Data(#"{"enabled":true,"problem":null,"ready":true,"revision":3,"thread_id":"s1","brain":"claude","brain_setting":"auto","anthropic_key_set":true}"#.utf8)) + check(claude.brain == "claude" && claude.brainSetting == .auto && claude.anthropicKeySet == true, "resident info says Claude Code answers") + let noKey = try StatusCoding.decoder().decode(ResidentInfo.self, from: Data(#"{"enabled":true,"problem":"anthropic_key_missing","ready":false,"revision":0,"thread_id":null,"brain":null,"brain_setting":"claude","anthropic_key_set":false}"#.utf8)) + check(noKey.problem == .anthropicKeyMissing && noKey.brainSetting == .claude, "resident info says the Anthropic API key is missing") + let noClaude = try StatusCoding.decoder().decode(ResidentInfo.self, from: Data(#"{"enabled":true,"problem":"claude_missing","brain_setting":"something_new"}"#.utf8)) + check(noClaude.problem == .claudeMissing && noClaude.brainSetting == nil, "resident info says Claude Code is missing; an unknown setting is nil") let history = try StatusCoding.decoder().decode(ResidentHistory.self, from: Data(#"{"exchanges":[{"replies":["现在没有 Agent 在工作。"],"said":"现在有几个agent在工作呀"},{"replies":["a","b"],"said":"two"}],"thread_id":"t1"}"#.utf8)) check(history.threadId == "t1" && history.exchanges.count == 2, "resident history decodes") check(history.exchanges.last == ResidentExchange(said: "two", replies: ["a", "b"]), "exchanges keep their order and every reply") diff --git a/app/Sources/VibeBuddy/ConversationView.swift b/app/Sources/VibeBuddy/ConversationView.swift index 1cfa87c7..550a4786 100644 --- a/app/Sources/VibeBuddy/ConversationView.swift +++ b/app/Sources/VibeBuddy/ConversationView.swift @@ -8,13 +8,21 @@ struct ConversationView: View { let load: () async throws -> [ResidentExchange] /// `GET /v1/resident`: the history's change marker, and whether voice commands can be taken. let info: () async throws -> ResidentInfo + /// Picks the Agent that answers voice commands; the daemon answers with its info. + let setBrain: (ResidentBrainChoice) async throws -> ResidentInfo + /// Stores the user's Anthropic API key, or removes it with nil; the daemon answers with its info. + let setKey: (String?) async throws -> ResidentInfo static let limit = 20 @State private var exchanges: [ResidentExchange]? @State private var failure: Failure? - /// What voice commands are missing (the user's Codex), as the daemon last said. + /// What voice commands are missing (the user's Codex or Claude Code), as the daemon last said. @State private var problem: ResidentProblem? + /// The daemon's last answer, for the Agent setting and whether a key is stored. + @State private var latest: ResidentInfo? + @State private var keyDraft = "" + @State private var settingError: String? @State private var loading = false enum Failure: Equatable { @@ -31,6 +39,10 @@ struct ConversationView: View { if loading { ProgressView().controlSize(.small) } Button("Refresh") { Task { await reload() } }.disabled(loading) } + // A daemon older than the setting doesn't say it; then there is only Codex. + if let choice = latest?.brainSetting { + agentSetting(choice) + } content.frame(maxWidth: .infinity, maxHeight: .infinity) } .padding(16) @@ -39,23 +51,105 @@ struct ConversationView: View { .task { await follow() } } + /// Which Agent answers, and the user's Anthropic API key for Claude Code. The key is typed once and never shown + /// again: the daemon only says whether it has one. + private func agentSetting(_ choice: ResidentBrainChoice) -> some View { + VStack(alignment: .leading, spacing: 6) { + Picker(selection: Binding(get: { choice }, set: { picked in Task { await apply { try await setBrain(picked) } } })) { + Text("Automatic").tag(ResidentBrainChoice.auto) + Text(verbatim: "Codex").tag(ResidentBrainChoice.codex) + Text(verbatim: "Claude Code").tag(ResidentBrainChoice.claude) + } label: { + Text("Answered by") + } + .pickerStyle(.segmented) + .fixedSize() + if choice == .auto { + Text("Automatic: Codex when it's ready, else Claude Code with your API key.") + .font(.caption).foregroundStyle(.secondary) + } + if choice != .codex { + HStack { + if latest?.anthropicKeySet == true { + Label("Anthropic API key saved", systemImage: "key.fill") + Button("Remove") { Task { await apply { try await setKey(nil) } } } + } else { + SecureField(text: $keyDraft, prompt: Text("Anthropic API key")) { Text("Anthropic API key") } + .labelsHidden() + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 320) + .onSubmit { saveKey() } + Button("Save") { saveKey() } + .disabled(keyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + Link("Get an API key", destination: Resources.anthropicConsole) + } + Text("Claude Code answers only with your own Anthropic API key, billed to your Anthropic account; a Claude subscription can't be used here. What you say and your Agents' task titles go to Anthropic.") + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if let settingError { + Text(verbatim: settingError).font(.caption).foregroundStyle(.red) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func saveKey() { + let key = keyDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + Task { + await apply { try await setKey(key) } + if latest?.anthropicKeySet == true { keyDraft = "" } + } + } + + /// Sends a setting and takes the daemon's answer as the new state. + private func apply(_ change: () async throws -> ResidentInfo) async { + do { + let now = try await change() + latest = now + problem = now.problem + settingError = nil + } catch { + settingError = error.localizedDescription + } + } + @ViewBuilder private var content: some View { if let problem { ContentUnavailableView { - Label("Voice needs Codex", systemImage: "mic.slash") - } description: { switch problem { - case .codexMissing: - Text("Voice commands are answered by Codex on this computer, and it isn't installed. Install the Codex CLI, then sign in with `codex login`. This tab notices within half a minute.") - case .codexLoggedOut: - Text("Voice commands are answered by Codex on this computer, and it isn't signed in. Run `codex login` in a terminal and sign in. This tab notices within half a minute.") - case .other(let why): - Text("The buddy's session isn't available: \(why)") + case .claudeMissing, .anthropicKeyMissing: + Label("Voice needs Claude Code", systemImage: "mic.slash") + default: + Label("Voice needs Codex", systemImage: "mic.slash") + } + } description: { + VStack(spacing: 8) { + switch problem { + case .codexMissing: + Text("Voice commands are answered by Codex on this computer, and it isn't installed. Install the Codex CLI, then sign in with `codex login`. This tab notices within half a minute.") + case .codexLoggedOut: + Text("Voice commands are answered by Codex on this computer, and it isn't signed in. Run `codex login` in a terminal and sign in. This tab notices within half a minute.") + case .claudeMissing: + Text("Voice commands are answered by Claude Code on this computer, and it isn't installed. Install Claude Code; you don't need to sign in to it. This tab notices within half a minute.") + case .anthropicKeyMissing: + Text("Claude Code answers voice commands only with your own Anthropic API key. Add your key above.") + case .other(let why): + Text("The buddy's session isn't available: \(why)") + } + if latest?.brainSetting == .auto, problem == .codexMissing || problem == .codexLoggedOut { + Text("Or let Claude Code answer, with your own Anthropic API key: add it above.") + } } } actions: { if problem == .codexMissing { Link("How to install the Codex CLI", destination: Resources.codexInstallPage) } + if problem == .claudeMissing { + Link("How to install Claude Code", destination: Resources.claudeInstallPage) + } } } else if let failure { ContentUnavailableView { @@ -101,6 +195,7 @@ struct ConversationView: View { if let first = try? await info() { seen = first.revision problem = first.problem + latest = first } await reload() while !Task.isCancelled { @@ -111,6 +206,7 @@ struct ConversationView: View { marked = now.revision != nil let becameReady = problem != nil && now.problem == nil problem = now.problem + latest = now if now.revision == nil || now.revision != seen || becameReady { seen = now.revision await reload() diff --git a/app/Sources/VibeBuddy/DaemonClient.swift b/app/Sources/VibeBuddy/DaemonClient.swift index 8b39165d..0002e946 100644 --- a/app/Sources/VibeBuddy/DaemonClient.swift +++ b/app/Sources/VibeBuddy/DaemonClient.swift @@ -139,6 +139,27 @@ struct DaemonClient { return try StatusCoding.decoder().decode(ResidentHistory.self, from: data).exchanges } + /// Which Agent answers voice commands. Its own endpoint, not part of the config. + func setResidentBrain(_ choice: ResidentBrainChoice) async throws -> ResidentInfo { + let body = try JSONSerialization.data(withJSONObject: ["brain": choice.rawValue]) + return try await residentSetting("/v1/resident/brain", method: "PUT", body: body) + } + + /// Stores the user's Anthropic API key in the daemon (nil removes it). The daemon never sends it back. + func setAnthropicKey(_ key: String?) async throws -> ResidentInfo { + guard let key else { return try await residentSetting("/v1/resident/anthropic-key", method: "DELETE", body: nil) } + let body = try JSONSerialization.data(withJSONObject: ["api_key": key]) + return try await residentSetting("/v1/resident/anthropic-key", method: "PUT", body: body) + } + + private func residentSetting(_ path: String, method: String, body: Data?) async throws -> ResidentInfo { + let request = request(path, method: method, body: body, contentType: body == nil ? nil : "application/json") + let (data, response) = try await URLSession.shared.data(for: request) + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard code == 200 else { throw DaemonError(message: DaemonClient.message(in: data) ?? String(localized: "The daemon answered \(code)")) } + return try StatusCoding.decoder().decode(ResidentInfo.self, from: data) + } + private static func message(in data: Data) -> String? { (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["message"] as? String } diff --git a/app/Sources/VibeBuddy/Resources.swift b/app/Sources/VibeBuddy/Resources.swift index 388e6819..9beb0d00 100644 --- a/app/Sources/VibeBuddy/Resources.swift +++ b/app/Sources/VibeBuddy/Resources.swift @@ -22,6 +22,12 @@ enum Resources { /// OpenAI's page on installing the Codex CLI, which voice commands need. static let codexInstallPage = URL(string: "https://learn.chatgpt.com/docs/codex/cli")! + /// Anthropic's page on installing Claude Code, the other Agent that can answer voice commands. + static let claudeInstallPage = URL(string: "https://code.claude.com/docs/en/setup")! + + /// The Claude Console, where the user makes the Anthropic API key Claude Code answers voice commands with. + static let anthropicConsole = URL(string: "https://platform.claude.com/")! + static var macOSDirectory: URL { Bundle.main.executableURL!.deletingLastPathComponent() } diff --git a/app/Sources/VibeBuddy/SettingsView.swift b/app/Sources/VibeBuddy/SettingsView.swift index fe418123..c69983a2 100644 --- a/app/Sources/VibeBuddy/SettingsView.swift +++ b/app/Sources/VibeBuddy/SettingsView.swift @@ -14,7 +14,9 @@ struct SettingsView: View { if model.residentEnabled { ConversationView( load: { [client = model.client] in try await client.residentHistory(limit: ConversationView.limit) }, - info: { [client = model.client] in try await client.resident() } + info: { [client = model.client] in try await client.resident() }, + setBrain: { [client = model.client] choice in try await client.setResidentBrain(choice) }, + setKey: { [client = model.client] key in try await client.setAnthropicKey(key) } ) .tabItem { Label("Conversation", systemImage: "bubble.left.and.bubble.right") } } diff --git a/app/Sources/VibeBuddyCore/Resident.swift b/app/Sources/VibeBuddyCore/Resident.swift index a120ec46..4295ec30 100644 --- a/app/Sources/VibeBuddyCore/Resident.swift +++ b/app/Sources/VibeBuddyCore/Resident.swift @@ -8,27 +8,61 @@ public struct ResidentInfo: Decodable, Equatable { /// only then. Kept in memory: it starts again at 0 when the daemon restarts. Nil from a daemon older than it. public var revision: UInt64? /// What voice commands are missing while the session is on but not ready (the user's Codex, installed and logged - /// in). Nil when ready, and from a daemon too old to say; a reason this App doesn't know is `.other`. + /// in, or the user's Claude Code with their Anthropic API key). Nil when ready, and from a daemon too old to say; + /// a reason this App doesn't know is `.other`. public var problem: ResidentProblem? + /// The Agent answering voice commands while ready: `codex` or `claude`. Nil when not ready, or from an older daemon. + public var brain: String? + /// The user's choice of that Agent; nil from a daemon older than the choice, or one this App doesn't know. + public var brainSetting: ResidentBrainChoice? + /// Whether the daemon holds an Anthropic API key; the key itself never comes back. + public var anthropicKeySet: Bool? enum CodingKeys: String, CodingKey { case enabled case threadId = "thread_id" case revision case problem + case brain + case brainSetting = "brain_setting" + case anthropicKeySet = "anthropic_key_set" } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + enabled = try container.decode(Bool.self, forKey: .enabled) + threadId = try container.decodeIfPresent(String.self, forKey: .threadId) + revision = try container.decodeIfPresent(UInt64.self, forKey: .revision) + problem = try container.decodeIfPresent(ResidentProblem.self, forKey: .problem) + brain = try container.decodeIfPresent(String.self, forKey: .brain) + brainSetting = (try? container.decodeIfPresent(String.self, forKey: .brainSetting)).flatMap { $0 }.flatMap(ResidentBrainChoice.init(rawValue:)) + anthropicKeySet = try container.decodeIfPresent(Bool.self, forKey: .anthropicKeySet) + } +} + +/// Which Agent answers voice commands: Codex when it is ready, else Claude Code when an Anthropic API key is set +/// (`auto`); or always the one picked. +public enum ResidentBrainChoice: String, CaseIterable, Equatable { + case auto + case codex + case claude } -/// Why the resident session can't take voice commands now: voice needs the user's Codex. +/// Why the resident session can't take voice commands now: voice needs the user's Codex, or the user's Claude Code +/// with their own Anthropic API key. public enum ResidentProblem: Equatable, Decodable { case codexMissing case codexLoggedOut + case claudeMissing + case anthropicKeyMissing case other(String) public init(from decoder: Decoder) throws { switch try decoder.singleValueContainer().decode(String.self) { case "codex_missing": self = .codexMissing case "codex_logged_out": self = .codexLoggedOut + case "claude_missing": self = .claudeMissing + case "anthropic_key_missing": self = .anthropicKeyMissing case let other: self = .other(other) } } diff --git a/desktop/src/client.rs b/desktop/src/client.rs index 6ebb533e..bb622aef 100644 --- a/desktop/src/client.rs +++ b/desktop/src/client.rs @@ -169,19 +169,78 @@ pub struct ResidentInfo { /// too old to say. #[serde(default)] pub problem: Option, + /// Which Agent the user wants answering; None from a daemon older than the choice. + #[serde(default)] + pub brain_setting: Option, + /// Whether the daemon holds an Anthropic API key; the key itself never comes back. + #[serde(default)] + pub anthropic_key_set: Option, } -/// Why the resident session can't take voice commands now: voice needs the user's Codex. +/// Why the resident session can't take voice commands now: voice needs the user's Codex, or the user's Claude Code +/// with their own Anthropic API key. #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum Problem { CodexMissing, CodexLoggedOut, + ClaudeMissing, + AnthropicKeyMissing, /// A reason this app doesn't know yet. #[serde(other)] Other, } +/// Which Agent answers voice commands: Codex when it is ready, else Claude Code when an Anthropic API key is set +/// (`Auto`); or always the one picked. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BrainChoice { + Auto, + Codex, + Claude, + /// A setting this app doesn't know yet. + #[serde(other)] + Other, +} + +impl BrainChoice { + fn as_str(self) -> &'static str { + match self { + Self::Auto | Self::Other => "auto", + Self::Codex => "codex", + Self::Claude => "claude", + } + } +} + +/// Which Agent answers voice commands. Its own endpoint, not part of the config. +pub async fn set_resident_brain(choice: BrainChoice) -> Result { + let request = reqwest::Client::new().put(format!("{BASE}/v1/resident/brain")).json(&serde_json::json!({"brain": choice.as_str()})); + resident_setting(request).await +} + +/// Stores the user's Anthropic API key in the daemon (None removes it). The daemon never sends it back. +pub async fn set_anthropic_key(key: Option) -> Result { + let client = reqwest::Client::new(); + let url = format!("{BASE}/v1/resident/anthropic-key"); + let request = match key { + Some(key) => client.put(url).json(&serde_json::json!({"api_key": key})), + None => client.delete(url), + }; + resident_setting(request).await +} + +async fn resident_setting(request: reqwest::RequestBuilder) -> Result { + let response = request.timeout(Duration::from_secs(10)).send().await.map_err(|error| error.to_string())?; + let status = response.status(); + let body: serde_json::Value = response.json().await.map_err(|error| error.to_string())?; + if !status.is_success() { + return Err(body["message"].as_str().map(str::to_owned).unwrap_or_else(|| format!("the daemon answered {status}"))); + } + serde_json::from_value(body).map_err(|error| error.to_string()) +} + /// Asked every 2 seconds while the Conversation tab shows, so it answers quickly or not at all. pub async fn resident_info() -> Result { let response = reqwest::Client::new() @@ -282,9 +341,10 @@ mod tests { fn the_resident_info_reads_its_revision_and_problem_and_an_older_daemons_without_them() { let parse = |json: &str| serde_json::from_str::(json).unwrap(); let older = parse(r#"{"enabled":true,"thread_id":null}"#); - assert_eq!(older, ResidentInfo { enabled: true, revision: None, problem: None }); + let nothing = ResidentInfo { enabled: true, revision: None, problem: None, brain_setting: None, anthropic_key_set: None }; + assert_eq!(older, nothing); let ready = parse(r#"{"enabled":true,"thread_id":"t1","revision":7,"ready":true,"problem":null}"#); - assert_eq!(ready, ResidentInfo { enabled: true, revision: Some(7), problem: None }); + assert_eq!(ready, ResidentInfo { revision: Some(7), ..nothing.clone() }); let missing = parse(r#"{"enabled":true,"thread_id":null,"revision":0,"ready":false,"problem":"codex_missing"}"#); assert_eq!(missing.problem, Some(Problem::CodexMissing)); let logged_out = parse(r#"{"enabled":true,"thread_id":null,"revision":0,"ready":false,"problem":"codex_logged_out"}"#); @@ -292,4 +352,16 @@ mod tests { let later = parse(r#"{"enabled":true,"thread_id":null,"revision":0,"ready":false,"problem":"something_new"}"#); assert_eq!(later.problem, Some(Problem::Other)); } + + #[test] + fn the_resident_info_says_which_agent_answers_and_whether_a_key_is_stored() { + let parse = |json: &str| serde_json::from_str::(json).unwrap(); + let claude = parse(r#"{"enabled":true,"thread_id":"s1","revision":3,"ready":true,"problem":null,"brain":"claude","brain_setting":"auto","anthropic_key_set":true}"#); + assert_eq!((claude.brain_setting, claude.anthropic_key_set), (Some(BrainChoice::Auto), Some(true))); + let no_key = parse(r#"{"enabled":true,"problem":"anthropic_key_missing","brain_setting":"claude","anthropic_key_set":false}"#); + assert_eq!((no_key.problem, no_key.brain_setting), (Some(Problem::AnthropicKeyMissing), Some(BrainChoice::Claude))); + let no_claude = parse(r#"{"enabled":true,"problem":"claude_missing","brain_setting":"something_new"}"#); + assert_eq!((no_claude.problem, no_claude.brain_setting), (Some(Problem::ClaudeMissing), Some(BrainChoice::Other))); + assert_eq!(BrainChoice::Other.as_str(), "auto"); + } } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 43fbbe08..3c2c735d 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -152,6 +152,15 @@ enum Message { PollConversation, ConversationPolled(Result), OpenCodexInstallPage, + OpenClaudeInstallPage, + OpenAnthropicConsole, + /// Which Agent answers voice commands. + PickBrain(client::BrainChoice), + AnthropicKeyDraft(String), + SaveAnthropicKey, + RemoveAnthropicKey, + /// The daemon's answer to a brain or key change. + ResidentSettingSaved(Result), /// Ask `vibebuddy-hook` for each agent's state again. RefreshAgents, Agents(Result, String>), @@ -235,8 +244,14 @@ struct App { /// The daemon has the change marker; an older one gets its history read every 5 seconds instead. conversation_marked: bool, conversation_polling: bool, - /// What voice commands are missing (the user's Codex), as the daemon last said. + /// What voice commands are missing (the user's Codex or Claude Code), as the daemon last said. resident_problem: Option, + /// The daemon's last word on the resident session: which Agent the user picked, whether a key is stored. + resident_info: Option, + /// The Anthropic API key being typed; cleared once the daemon has it, and never filled from the daemon. + anthropic_key_draft: String, + /// Why the last brain or key change failed. + resident_setting_error: Option, /// The outcome of the last action, shown at the bottom of the window. notice: Option>, /// The language picked in Settings, and, while it differs from the UI's, the offer to restart. @@ -295,6 +310,9 @@ impl App { conversation_marked: true, conversation_polling: false, resident_problem: None, + resident_info: None, + anthropic_key_draft: String::new(), + resident_setting_error: None, notice: None, language: UiLanguage::saved(), restart_offer: None, @@ -512,7 +530,8 @@ impl App { Ok(info) => { self.conversation_marked = info.revision.is_some(); let became_ready = self.resident_problem.is_some() && info.problem.is_none(); - self.resident_problem = info.problem; + self.resident_problem = info.problem.clone(); + self.resident_info = Some(info.clone()); let changed = info.revision.is_none() || info.revision != self.conversation_seen || became_ready; // A read already under way is left alone; the next poll sees the change again. if (changed || self.conversation.is_none()) && !self.conversation_loading { @@ -534,6 +553,39 @@ impl App { self.notice = Some(Err(error)); } } + Message::OpenClaudeInstallPage => { + if let Err(error) = launch("xdg-open", &[std::ffi::OsStr::new(CLAUDE_INSTALL_PAGE)]) { + self.notice = Some(Err(error)); + } + } + Message::OpenAnthropicConsole => { + if let Err(error) = launch("xdg-open", &[std::ffi::OsStr::new(ANTHROPIC_CONSOLE)]) { + self.notice = Some(Err(error)); + } + } + Message::PickBrain(choice) => return Task::perform(client::set_resident_brain(choice), Message::ResidentSettingSaved), + Message::AnthropicKeyDraft(draft) => self.anthropic_key_draft = draft, + Message::SaveAnthropicKey => { + let key = self.anthropic_key_draft.trim().to_owned(); + if !key.is_empty() { + return Task::perform(client::set_anthropic_key(Some(key)), Message::ResidentSettingSaved); + } + } + Message::RemoveAnthropicKey => return Task::perform(client::set_anthropic_key(None), Message::ResidentSettingSaved), + Message::ResidentSettingSaved(result) => match result { + Ok(info) => { + if info.anthropic_key_set == Some(true) { + self.anthropic_key_draft.clear(); + } + self.resident_problem = info.problem.clone(); + self.resident_info = Some(info); + self.resident_setting_error = None; + // Another Agent may answer now, with its own history. + self.conversation_seen = None; + return Task::done(Message::PollConversation); + } + Err(error) => self.resident_setting_error = Some(error), + } Message::NotifyLink(enabled) => { let mut config = self.status.as_ref().map(|status| status.config.clone()).unwrap_or_default(); config.notify_link = enabled; @@ -1676,17 +1728,29 @@ impl App { .center(Length::Fill) .into() }; - // Voice needs the user's Codex, installed and logged in: say what's missing first. + // A daemon older than the setting doesn't say it; then there is only Codex. + let choice = self.resident_info.as_ref().and_then(|info| info.brain_setting); + let header = column![header].push(choice.map(|choice| self.agent_setting(choice))).spacing(12); + // Voice needs the user's Codex, installed and logged in, or Claude Code with a key: say what's missing first. if let Some(problem) = &self.resident_problem { - let detail = match problem { - client::Problem::CodexMissing => tr("Voice commands are answered by Codex on this computer, and it isn't installed. Install the Codex CLI, then sign in with `codex login`. This tab notices within half a minute.", &[]), - client::Problem::CodexLoggedOut => tr("Voice commands are answered by Codex on this computer, and it isn't signed in. Run `codex login` in a terminal and sign in. This tab notices within half a minute.", &[]), - client::Problem::Other => tr("The buddy's session isn't available: %@", &[&"?"]), + let (title, detail) = match problem { + client::Problem::CodexMissing => (tr("Voice needs Codex", &[]), tr("Voice commands are answered by Codex on this computer, and it isn't installed. Install the Codex CLI, then sign in with `codex login`. This tab notices within half a minute.", &[])), + client::Problem::CodexLoggedOut => (tr("Voice needs Codex", &[]), tr("Voice commands are answered by Codex on this computer, and it isn't signed in. Run `codex login` in a terminal and sign in. This tab notices within half a minute.", &[])), + client::Problem::ClaudeMissing => (tr("Voice needs Claude Code", &[]), tr("Voice commands are answered by Claude Code on this computer, and it isn't installed. Install Claude Code; you don't need to sign in to it. This tab notices within half a minute.", &[])), + client::Problem::AnthropicKeyMissing => (tr("Voice needs Claude Code", &[]), tr("Claude Code answers voice commands only with your own Anthropic API key. Add your key above.", &[])), + client::Problem::Other => (tr("Voice needs Codex", &[]), tr("The buddy's session isn't available: %@", &[&"?"])), + }; + let codex_problem = matches!(problem, client::Problem::CodexMissing | client::Problem::CodexLoggedOut); + let or_claude = (codex_problem && choice == Some(client::BrainChoice::Auto)) + .then(|| text(tr("Or let Claude Code answer, with your own Anthropic API key: add it above.", &[])).size(13).style(text::secondary)); + let install = match problem { + client::Problem::CodexMissing => Some(button(text(tr("How to install the Codex CLI", &[]))).on_press(Message::OpenCodexInstallPage)), + client::Problem::ClaudeMissing => Some(button(text(tr("How to install Claude Code", &[]))).on_press(Message::OpenClaudeInstallPage)), + _ => None, }; - let install = (*problem == client::Problem::CodexMissing) - .then(|| button(text(tr("How to install the Codex CLI", &[]))).on_press(Message::OpenCodexInstallPage)); let body = container( - column![text(tr("Voice needs Codex", &[])).size(16), text(detail).size(13).style(text::secondary)] + column![text(title).size(16), text(detail).size(13).style(text::secondary)] + .push(or_claude) .push(install) .spacing(6) .align_x(iced::Alignment::Center), @@ -1727,6 +1791,59 @@ impl App { column![header, body].spacing(12).into() } + /// Which Agent answers, and the user's Anthropic API key for Claude Code, as on the Mac. The key is typed once + /// and never shown again: the daemon only says whether it has one. + fn agent_setting(&self, choice: client::BrainChoice) -> Element<'_, Message> { + let option = |label: String, option: client::BrainChoice| { + let style = if option == choice { button::primary } else { button::secondary }; + button(text(label)).style(style).on_press(Message::PickBrain(option)) + }; + let mut setting = column![ + row![ + text(tr("Answered by", &[])), + option(tr("Automatic", &[]), client::BrainChoice::Auto), + option("Codex".to_owned(), client::BrainChoice::Codex), + option("Claude Code".to_owned(), client::BrainChoice::Claude), + ] + .spacing(8) + .align_y(iced::Alignment::Center) + ] + .spacing(6); + if choice == client::BrainChoice::Auto { + setting = setting.push(text(tr("Automatic: Codex when it's ready, else Claude Code with your API key.", &[])).size(12).style(text::secondary)); + } + if choice != client::BrainChoice::Codex { + let saved = self.resident_info.as_ref().and_then(|info| info.anthropic_key_set) == Some(true); + let key = if saved { + row![ + text(tr("Anthropic API key saved", &[])), + button(text(tr("Remove", &[]))).style(button::secondary).on_press(Message::RemoveAnthropicKey), + ] + } else { + let draft = self.anthropic_key_draft.trim(); + row![ + text_input(&tr("Anthropic API key", &[]), &self.anthropic_key_draft) + .secure(true) + .on_input(Message::AnthropicKeyDraft) + .on_submit(Message::SaveAnthropicKey) + .width(320), + button(text(tr("Save", &[]))).on_press_maybe((!draft.is_empty()).then_some(Message::SaveAnthropicKey)), + ] + }; + setting = setting + .push( + key.push(button(text(tr("Get an API key", &[]))).style(button::text).on_press(Message::OpenAnthropicConsole)) + .spacing(8) + .align_y(iced::Alignment::Center), + ) + .push(text(tr("Claude Code answers only with your own Anthropic API key, billed to your Anthropic account; a Claude subscription can't be used here. What you say and your Agents' task titles go to Anthropic.", &[])).size(12).style(text::secondary)); + } + if let Some(error) = &self.resident_setting_error { + setting = setting.push(text(error.clone()).size(12).style(text::danger)); + } + setting.into() + } + fn advanced(&self) -> Element<'_, Message> { let daemon = match &self.status { Some(status) => tr("Running · %@", &[&status.daemon.short_build()]), @@ -1827,6 +1944,10 @@ async fn export_diagnostics(summary: String, config: Option) -> Result" claude`. Use this path when configuring by hand too; don't point it inside the App bundle, or it breaks when the App moves (ADR-0005). + +## The resident session + +The daemon's own resident session (ADR-0017) can run on Claude Code, with the user's own Anthropic API key. Its config dir is the daemon's own, so the user's VibeBuddy hook doesn't load for it, but managed hooks could; they are dropped at the entry point, by its `session_id` or by a `cwd` inside its working directory, so it never becomes an Activity. See [`resident-session.md`](resident-session.md#on-claude-code). diff --git a/docs/resident-session.md b/docs/resident-session.md index 4016dd47..b6d87ead 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -1,8 +1,10 @@ # Resident session -The resident session is the one long-running Codex thread every voice command goes to ([ADR-0017](adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md); see "Voice commands" in [`CONTEXT.md`](../CONTEXT.md)). A voice command reaches it spoken to a box linked over USB or the local network (below), or typed with `vibebuddy say`. +The resident session is the one long-running Agent session every voice command goes to ([ADR-0017](adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md); see "Voice commands" in [`CONTEXT.md`](../CONTEXT.md)). A voice command reaches it spoken to a box linked over USB or the local network (below), or typed with `vibebuddy say`. -It is experimental and off by default. Start `vibebuddyd` with `VIBEBUDDY_RESIDENT_SESSION=1` to turn it on. For now voice needs the user's own Codex: the Codex CLI installed and signed in (`codex login`) on the computer the daemon runs on. Without it the box isn't offered voice, and the Conversation tab says what's missing. While it is on, both Apps' settings windows have a Conversation tab that shows it (below); nothing else of it is in the App UI, on the Mac or on Linux. +It runs on one of two brains: the user's own Codex (a Codex thread, most of this page), or the user's own Claude Code with their own Anthropic API key (a Claude Code session, "On Claude Code" below). Which one is a setting: `codex`, `claude`, or `auto`, the default: Codex when it is ready, else Claude Code when a key is set. Each brain keeps its own session for good; switching brains switches which session voice commands go to and which history the Conversation tab shows. + +It is experimental and off by default. Start `vibebuddyd` with `VIBEBUDDY_RESIDENT_SESSION=1` to turn it on. Voice needs one of the brains on the computer the daemon runs on: the Codex CLI installed and signed in (`codex login`), or Claude Code installed with the user's Anthropic API key entered in the Conversation tab. Without either the box isn't offered voice, and the Conversation tab says what's missing. While it is on, both Apps' settings windows have a Conversation tab that shows it (below); nothing else of it is in the App UI, on the Mac or on Linux. ## Using it @@ -17,7 +19,9 @@ The thread lives in a Codex home of the daemon's own (below), so the Codex App c Both Apps ask `GET /v1/resident` each time the daemon comes up (only its environment at start decides) and show a Conversation tab, between Device and Advanced, only while it says `enabled`. The tab reads the last 20 exchanges with `GET /v1/resident/history?limit=20` each time it is shown and on Refresh, and lists them oldest first, newest at the bottom: each voice command on the right, the buddy's spoken replies under it on the left. With none yet, it says how to talk to the buddy (hold K0); when the daemon can't be reached it points to Advanced → Restart daemon, and when the resident session can't be read (Codex missing or not logged in) it shows the daemon's reason. The last answer from `GET /v1/resident` is kept while the daemon is down, so an open tab says the daemon isn't running rather than vanishing. -While the tab is shown it updates by itself: it asks `GET /v1/resident` every 2 seconds and reads the history again only when `revision` moved (or voice just became ready), so a new exchange shows up on its own and the list stays scrolled to the newest. It stops asking when another tab is picked or the window is closed. A daemon older than `revision` gets its history read every 5 seconds instead. When the answer says voice isn't ready, the tab says what's missing in place of the conversation: Codex not installed, with a link to OpenAI's [Codex CLI page](https://learn.chatgpt.com/docs/codex/cli); or not signed in, with `codex login` to run in a terminal. It notices the fix within half a minute. +While the tab is shown it updates by itself: it asks `GET /v1/resident` every 2 seconds and reads the history again only when `revision` moved (or voice just became ready), so a new exchange shows up on its own and the list stays scrolled to the newest. It stops asking when another tab is picked or the window is closed. A daemon older than `revision` gets its history read every 5 seconds instead. When the answer says voice isn't ready, the tab says what's missing in place of the conversation: Codex not installed, with a link to OpenAI's [Codex CLI page](https://learn.chatgpt.com/docs/codex/cli); or not signed in, with `codex login` to run in a terminal (under Automatic it adds that Claude Code can answer instead, with the user's own key); Claude Code not installed, with a link to Anthropic's [setup page](https://code.claude.com/docs/en/setup); or no Anthropic API key yet. It notices the fix within half a minute. + +Above the conversation, while the daemon says `brain_setting`, the tab has the brain setting, "Answered by": Automatic, Codex or Claude Code (`PUT /v1/resident/brain`). Unless it is Codex, a secure field takes the user's Anthropic API key, with Save, and a link to the [Claude Console](https://platform.claude.com/) to make one; once the daemon has a key the field gives way to "Anthropic API key saved" and Remove. The key is never shown again: the daemon only says whether it has one. A line under it says the key is billed to the user's Anthropic account, that a Claude subscription can't be used, and that what they say and their Agents' task titles go to Anthropic. ![The Conversation tab on the Mac, in Chinese](images/app-conversation-zh.png) @@ -25,19 +29,30 @@ While the tab is shown it updates by itself: it asks `GET /v1/resident` every 2 | Endpoint | Does | | --- | --- | -| `POST /v1/resident/say` `{"text"}` | Runs one turn; answers `{"thread_id","replies":[...],"elapsed_ms"}`. 404 when off, 503 when Codex is missing or not logged in, 504 after two minutes. | -| `GET /v1/resident` | `{"enabled","thread_id","revision","ready","problem"}`. `revision` goes up when a voice command's turn ends (answered or not, from the box or `say`) and when the thread changes; it is kept in memory and starts at 0 with the daemon. `ready` is on and Codex found installed and logged in; `problem` is then `null`, else `codex_missing` or `codex_logged_out` (`null` while off). Never starts the app-server; see Readiness below. | -| `GET /v1/resident/history?limit=n` | `{"thread_id","exchanges":[{"said","replies":[...]}]}`, oldest first, read from the thread with `thread/turns/list` (default 5, at most 50). Starts the app-server if it isn't running; the daemon keeps no record of its own. | +| `POST /v1/resident/say` `{"text"}` | Runs one turn; answers `{"thread_id","replies":[...],"elapsed_ms"}`. 404 when off, 503 when the brain is missing, not logged in or has no key, 504 after two minutes. | +| `GET /v1/resident` | `{"enabled","thread_id","revision","ready","problem","brain","brain_setting","anthropic_key_set"}`. `thread_id` is the session of the brain voice commands go to now (a Codex thread id or a Claude Code session id). `revision` goes up when a voice command's turn ends (answered or not, from the box or `say`), when the thread changes, and when the brain answering changes; it is kept in memory and starts at 0 with the daemon. `ready` is on and the brain the setting picks ready (Readiness below); `problem` is then `null`, else `codex_missing`, `codex_logged_out`, `claude_missing` or `anthropic_key_missing` (`null` while off). `brain` is `codex` or `claude` while ready, else `null`; `brain_setting` is `auto`, `codex` or `claude`; `anthropic_key_set` says whether a key is stored, never the key. Never starts the app-server or Claude Code. | +| `GET /v1/resident/history?limit=n` | `{"thread_id","exchanges":[{"said","replies":[...]}]}`, oldest first (default 5, at most 50): on Codex read from the thread with `thread/turns/list`, starting the app-server if it isn't running; on Claude Code read from the session's transcript. The daemon keeps no record of its own. | +| `PUT /v1/resident/brain` `{"brain"}` | Sets the brain: `auto`, `codex` or `claude`; answers as `GET /v1/resident`. | +| `PUT /v1/resident/anthropic-key` `{"api_key"}`, `DELETE` | Stores or removes the user's Anthropic API key for Claude Code; answers as `GET /v1/resident`, without the key. 400 for an empty key. A running Claude Code process is stopped, so the next command starts one with the new key. | + +The brain setting and the key are kept in `resident-settings.json` in the config directory (`{"brain","anthropic_api_key"}`), written with mode 0600 and only through these two endpoints, never `PUT /v1/config`: that replaces the whole config, and an App that doesn't know the fields would wipe them (the same reason `speech.json` is a file of its own). Both work while the session is off. ## Readiness -Voice needs the user's Codex, so the daemon keeps a readiness check that never starts the app-server or a turn: it looks `codex` up as a start does (`VIBEBUDDY_CODEX`, PATH, the usual install places) and runs `codex -c cli_auth_credentials_store="file" login status` in the resident Codex home (with `auth.json` linked as for a start), which reads the same login `account/read` would and exits 0 only when signed in. The answer stands for 30 seconds: `GET /v1/resident` checks again once it is older, and so does a timer in the daemon every 30 seconds while the session is on. Every start of the app-server is a check too: one that ends in "codex not found" or "not logged in" marks the session not ready, and one that gets through marks it ready. +Readiness is worked out under the brain setting from two checks, neither of which starts anything: + +- **Claude Code**: `claude` found (`VIBEBUDDY_CLAUDE`, PATH, `~/.local/bin`, `~/.claude/local`, `/opt/homebrew/bin`, `/usr/local/bin`) and a key stored. Free, so it is made every time. `claude_missing` comes first, then `anthropic_key_missing`. Whether the key works is only known at the first turn: a rejected key fails that turn with Claude Code's message. +- **Codex**, below; skipped while the setting is `claude`. -The box is offered voice (`voice.available`, below) only while the session is on and ready and a recognizer is configured. When readiness flips, the daemon tells a box that has reported its build `"on":true` or `"on":false` right away (not over the Relay or the UART bridge, where voice is never offered), so a long K0 goes back to giving up the Pomodoro phase while Codex is missing or signed out. +Under `codex` or `claude` it is that brain's readiness. Under `auto` it is Codex when Codex is ready, else Claude Code when that is ready; when neither is, the problem is Claude Code's if a key is stored (the user means Claude Code) and Codex's otherwise, and nothing is said until Codex has been checked once. Changing the setting or the key checks again at once. -## How it works +Voice on Codex needs the user's Codex, so the daemon keeps a readiness check that never starts the app-server or a turn: it looks `codex` up as a start does (`VIBEBUDDY_CODEX`, PATH, the usual install places) and runs `codex -c cli_auth_credentials_store="file" login status` in the resident Codex home (with `auth.json` linked as for a start), which reads the same login `account/read` would and exits 0 only when signed in. The answer stands for 30 seconds: `GET /v1/resident` checks again once it is older, and so does a timer in the daemon every 30 seconds while the session is on. Every start of the app-server is a check too: one that ends in "codex not found" or "not logged in" marks the session not ready, and one that gets through marks it ready. -`daemon/src/app_server.rs` is a newline-delimited JSON-RPC client for `codex app-server` on stdio; `daemon/src/resident.rs` is the session on top of it. +The box is offered voice (`voice.available`, below) only while the session is on and ready and a recognizer is configured. When readiness flips, the daemon tells a box that has reported its build `"on":true` or `"on":false` right away (not over the Relay or the UART bridge, where voice is never offered), so a long K0 goes back to giving up the Pomodoro phase while the brain isn't ready (Codex missing or signed out, Claude Code missing or without a key). + +## How it works on Codex + +`daemon/src/app_server.rs` is a newline-delimited JSON-RPC client for `codex app-server` on stdio; `daemon/src/resident.rs` is the session on top of it, and picks the brain. - **Its own Codex home.** The app-server runs with `CODEX_HOME` set to `resident-codex/` in the daemon's state directory (mode 0700), never the user's. The ChatGPT App's own app-server takes the writer lock (`thread-writer-locks/.lock`) of every thread in `~/.codex` it lists whenever the lock is free, and keeps it, even for threads nobody opened; a resident thread there could not be resumed while the App runs. Nothing lists this home but the daemon. The home holds no `config.toml`: everything is a `-c` override (below). - **The user's login, shared.** The user's Codex home is `$CODEX_HOME` if the daemon has it, else `~/.codex`. On every start `resident-codex/auth.json` is made a symlink to its `auth.json` (whatever else is there, a copy or a link elsewhere, is replaced), so there is one login and one set of tokens. Checked in the codex-cli 0.162.0 source (`codex-rs/login/src/auth/storage.rs`, `manager.rs`): the file store reads `auth.json` with a plain open, a refresh first reloads it and keeps a token the other side already refreshed, and a save truncates and rewrites it in place, so all of it goes through the link and the link survives; a rename on the user's side doesn't matter, since the link is by path. `-c cli_auth_credentials_store="file"` pins that store: the keychain store keys its entry by the Codex home's path, so it would never find the user's login, and its save deletes `auth.json`. A login kept in the keychain therefore can't be shared: the session reports "not logged in" and the log says why. Two Codex processes refreshing at the same moment can still race, as the user's CLI and the ChatGPT App already can on the same file. Credentials are never logged. @@ -52,6 +67,25 @@ The box is offered voice (`voice.available`, below) only while the session is on - **Turns.** `turn/start` with the text, then the daemon answers tool calls until `turn/completed` for that turn. One turn at a time: a command that arrives during another waits for it, in order, rather than being rejected, since two quick sentences are both meant to be answered. A turn that takes over two minutes is interrupted (`turn/interrupt`). - **Persona.** `instructions()` in `resident.rs` takes the Character's persona; until Characters carry one, it is `None` and the buddy speaks plainly. +## On Claude Code + +`daemon/src/resident_claude.rs` drives the user's own `claude` CLI headless: no Node, no Agent SDK. + +- **Only the user's own API key.** Anthropic doesn't let third-party products use a claude.ai login: its [legal and compliance page](https://code.claude.com/docs/en/legal-and-compliance) says developers building products, including with the Agent SDK, use API key authentication, and "Anthropic does not permit third-party developers to offer Claude.ai login into their own applications, or to route requests through Free, Pro, or Max plan credentials on behalf of their users". So the Claude Code brain authenticates only with the Anthropic API key the user enters, billed to their Anthropic account, and never with a subscription or OAuth login, not even as a fallback: the child's environment loses every `CLAUDE*` and `ANTHROPIC*` variable the daemon has (an OAuth token, `ANTHROPIC_AUTH_TOKEN`, the Bedrock, Vertex and Foundry switches, profiles) and gets `ANTHROPIC_API_KEY`, which `-p` always uses when present ([authentication precedence](https://code.claude.com/docs/en/authentication#authentication-precedence)); its own config dir holds no login, and the macOS Keychain entry is keyed by the config dir. On top of that, the `system/init` line reports `apiKeySource`, and anything but `ANTHROPIC_API_KEY` stops the process and fails the command. The key is never logged, never written outside `resident-settings.json` (the MCP config and the command line don't carry it), and only reaches the child's environment. +- **Its own config dir.** `CLAUDE_CONFIG_DIR` is `resident-claude/` in the daemon's state directory (mode 0700), so none of the user's Claude Code config loads: settings, CLAUDE.md, hooks, plugins, MCP servers, sessions. The session doesn't show up in the user's `claude --resume` or the Claude App. The working directory is the same `resident/` as the Codex thread's. `CLAUDE_CODE_DISABLE_AUTO_MEMORY=1` and `DISABLE_AUTOUPDATER=1` keep it from writing memory files or updating the user's Claude Code. +- **One process, one session for good.** One long-lived `claude -p --input-format stream-json --output-format stream-json --verbose`; each voice command is one user message on its stdin, and the turn is over at the `result` line. The first start passes `--session-id` with a new UUID, saved in `resident-claude.json` in the state directory with the Claude Code version; every later start passes `--resume` with it, as long as its transcript (`resident-claude/projects/*/.jsonl`) is there. Only a session whose transcript is gone is replaced, with both ids in the log. A process that exits or doesn't finish a turn in two minutes is stopped, and the next command starts it again with `--resume`; an error `result` (a rejected key, an API error) fails the command and keeps the process. A new key starts a new process. The daemon starts it when it starts, as for Codex. +- **Each command** is two text blocks: a context block with the user's local date, time and UTC offset, then what was said. The history leaves the context block out. +- **Only our tools.** `--tools WebSearch` leaves Web search as the only built-in tool; `--disallowedTools` denies Bash, Edit, Write, Read, NotebookEdit, WebFetch, Glob, Grep, Agent, Task, Skill and BashOutput by name besides, which removes them from the model's context. `speak` and `list_activities` come from the daemon's MCP server (below), with `--strict-mcp-config` so no other server loads. `--permission-mode dontAsk --permission-prompts none` with `--allowedTools WebSearch,mcp__vibebuddy__speak,mcp__vibebuddy__list_activities`: anything else that would need a permission is refused, never asked. `--disable-slash-commands` turns skills and commands off, and `--settings '{"disableAllHooks":true}'` hooks. `search_memories` isn't offered: Codex memories are Codex's. +- **Why not `--bare`.** Bare mode would skip even more and never reads OAuth or the keychain, but in Claude Code 2.1.296 it gives the model only Bash, Read and Edit and drops WebSearch even when `--tools` names it (checked against a fake Messages API: the request's tool list was empty with `--bare --tools WebSearch`, and `WebSearch` without `--bare`). Without web search the buddy couldn't answer questions about now, so the config dir, the cleaned environment, the init check and the flags do it instead. +- **The MCP server.** Claude Code reaches `speak` and `list_activities` over MCP's Streamable HTTP transport (JSON responses, no server-sent stream) at `http://127.0.0.1:/mcp`, a loopback port of the daemon's own picked at start, behind a random 256-bit bearer token made when the daemon starts. The URL and token are in `resident-claude/mcp.json` (0600), passed with `--mcp-config`. Chosen over a `vibebuddyd` stdio subcommand: `speak` must reach the turn in progress in the daemon at once, and a stdio child would have had to call back into the daemon over HTTP anyway; in-process there is no second process, no binary path to find inside the App bundle, and the daemon's own API (no token, on `VIBEBUDDY_BIND`) stays as it is. Without the token the server answers 401. +- **Spoken replies stream.** A `speak` call is handed to `voice.rs` the moment the MCP request arrives, as on Codex; anything said just before the `result` line is still handed on before the turn ends. +- **The instructions** are the Codex session's (no made-up facts, short spoken replies, `list_activities` for the Agents), with the date taken from the context block, passed as `--system-prompt`: they replace Claude Code's coding-assistant prompt, since the buddy isn't one, and a short prompt is a faster turn. `--system-prompt-snapshot off` has them read again at every request, so a change reaches a resumed session. +- **The model** is `claude-haiku-4-5` (`MODEL` in `resident_claude.rs`), for the same reason Codex runs on luna at low effort: a voice command waits on the answer. +- **Web search** is Claude Code's WebSearch, which runs Anthropic's server-side web search under the user's key; an organization can turn web search off in the Claude Console, and then searches fail. +- **History** is read from the session's transcript: each user message that isn't a tool result, with the text of the `mcp__vibebuddy__speak` calls after it, in time order (Claude Code doesn't always write the lines in order). The transcript format isn't a documented API; the stream-json shape it shares is. + +Checked on 2026-10-14 with Claude Code 2.1.296 against a fake Messages API on this Mac (`ANTHROPIC_BASE_URL` through `VIBEBUDDY_CLAUDE_BASE_URL`, a development-only setting), through `POST /v1/resident/say` on a daemon of its own (temporary home, its own port, no box, no Relay, `CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_AUTH_TOKEN` set in its environment): the model's tool list was exactly `WebSearch`, `mcp__vibebuddy__list_activities` and `mcp__vibebuddy__speak`; requests carried the key as `x-api-key` and no `Authorization`; calls to Bash, Read, Write, WebFetch and `search_memories` came back "No such tool available" and changed nothing; WebSearch ran; `speak` came back as the reply; the history read back; and after a daemon restart the same session was resumed with its earlier messages. Not yet checked against the real API (no key was at hand): the turn times, and how Haiku follows the instructions. + ## From the box The box side of push-to-talk is in [`protocol.md`](protocol.md), "Voice commands (experimental)"; `daemon/src/voice.rs` is the computer side and `daemon/src/speech.rs` the recognizers. @@ -97,10 +131,14 @@ Each spoken reply is also synthesized and streamed to the box (`daemon/src/synth The user's Codex hooks live in their Codex home, so they don't load for the resident thread. Hooks from system or managed config still could, so the Codex adapter still drops a hook whose session or thread is the resident thread, or whose `cwd` is inside the resident directory, before anything is recorded: no card, announcement, stats, CI watch or K2 target. This works even with the resident session switched off. +The same goes for Claude Code: its own config dir loads none of the user's hooks (and `disableAllHooks` is set), but managed hooks still could, so the Claude Code adapter drops a hook whose `session_id` is the resident Claude Code session or whose `cwd` is inside the resident directory. + ## Versions app-server and `dynamicTools` are experimental, and so is relying on how Codex stores `auth.json` and memories. This was built and checked against codex-cli 0.162.0 (`TESTED_CODEX_VERSION`); another version logs a warning and is still tried. The version last used is written to `resident.json`. +The Claude Code brain was checked against Claude Code 2.1.296 (`TESTED_CLAUDE_VERSION`), every flag against `claude --help` and the [CLI reference](https://code.claude.com/docs/en/cli-reference), [headless mode](https://code.claude.com/docs/en/headless) and [permissions](https://code.claude.com/docs/en/permissions) pages; another version logs a warning and is still tried. The version last used is written to `resident-claude.json`. + ## Privacy `list_activities` sends the task-card titles and project names the daemon holds, with each Activity's agent, state, host app and ages, to the model provider the user's Codex uses, as part of the resident thread. Until now these stayed on the computer and the box. Prompts and replies are not sent: the daemon never holds them. @@ -109,6 +147,8 @@ It also sends the last ended Activities (the same titles and projects, how each Web searches go to OpenAI: the resident session's searches are made by OpenAI's model, from words of the voice command (a city, a topic), as part of the resident thread; OpenAI runs them against its search providers. Nothing the daemon holds is searched for unless the model puts it in a query. +On Claude Code all of this goes to Anthropic instead, under the user's own API key: the voice commands' text with the local time, the instructions, what `list_activities` returns, and the searches, which Anthropic runs. Anthropic's terms for API use apply. The session's transcript stays in `resident-claude/` on this computer, as Claude Code keeps it. The API key is stored in `resident-settings.json` (mode 0600) and is sent only to Claude Code, which sends it only to Anthropic. + The user's Codex memory summary, and whatever `search_memories` finds, go to the same provider as part of the resident thread, as they already do in the user's own Codex sessions. A voice command's audio leaves the computer for the speech recognizer: in development that is Cloudflare Workers AI, under the developer's own account; with the user's own recognizer, only that endpoint (and its key goes nowhere else). The recognised text then goes to the user's Codex model provider as the voice command. The daemon keeps neither the audio nor the text once the turn is over, and logs neither. @@ -130,3 +170,5 @@ With web search on, measured on 2026-10-14 through `POST /v1/resident/say` on a | "现在比特币多少钱" | 6.4 | 10.9 | Both searched every time and answered from what they found (the weather from a forecast lookup for the right day, the price from a finance lookup, the news from that day's headlines). sol was a little more careful (dates, sources) but two to four times slower; luna's answers were good enough, and once, before the instructions pointed at the date, it searched for the wrong day's news and said it found nothing reliable rather than guess. luna stays. The daemon starts the app-server and resumes the thread when it starts, not on the first command, and the synthesizer keeps one HTTPS agent so its connection to the voice service is reused. + +On Claude Code the session runs on `claude-haiku-4-5`, a small, fast model, for the same reason. Its turn times against the real API are not measured yet; against a fake Messages API a whole turn through the daemon took under 0.1 s once the process was up, so the overhead of the CLI and the MCP round trip is small. From 617c5e168ea805bbdb0a9a947079ab6d4df689e9 Mon Sep 17 00:00:00 2001 From: dragon Date: Wed, 14 Oct 2026 16:03:12 +0800 Subject: [PATCH 42/51] Tell the Claude resident its time zone, and end a turn after speak Claude asked where the user was for the weather: unlike Codex it had no time zone, so each voice command now carries it next to the local time. Both brains are told to write nothing after speak; Claude went on to repeat its answer as text, taking nine more seconds. Co-Authored-By: Claude Opus 5.5 --- daemon/src/resident.rs | 3 ++- daemon/src/resident_claude.rs | 36 +++++++++++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/daemon/src/resident.rs b/daemon/src/resident.rs index 983e75a2..b80e3e6c 100644 --- a/daemon/src/resident.rs +++ b/daemon/src/resident.rs @@ -888,7 +888,8 @@ pub(crate) fn instructions(persona: Option<&str>, memories: Option<&str>, brain: OpenCode, GitHub Copilot CLI, Pi, GitHub Actions) and speaks up for them. {persona}\n\ Every user message is one voice command, transcribed from speech. Answer only by calling the `speak` tool, \ with one or two short sentences meant to be heard, not read: no Markdown, lists, code, paths or URLs. \ - Reply in the language the user spoke. Do not write any other answer.\n\ + Reply in the language the user spoke. Do not write any other answer: once you have called `speak`, end your \ + turn without writing anything else.\n\ To know what the Agents are doing, have just finished or did today, call `list_activities`; never guess. It \ has the live tasks, the ones that ended lately with how and how long ago (`recently_ended`), and today's \ counts (`today`). Titles there are short upper-case task-card names; say them naturally.\n\ diff --git a/daemon/src/resident_claude.rs b/daemon/src/resident_claude.rs index 7841fe4b..e5dbcb45 100644 --- a/daemon/src/resident_claude.rs +++ b/daemon/src/resident_claude.rs @@ -282,7 +282,7 @@ async fn run_turn( text: &str, on_reply: &mut (dyn FnMut(&str) + Send), ) -> Result<(), ResidentError> { - let line = format!("{}\n", user_message(text, &chrono::Local::now())); + let line = format!("{}\n", user_message(text, &chrono::Local::now(), local_time_zone().as_deref())); let written = async { process.writer.write_all(line.as_bytes()).await?; process.writer.flush().await @@ -331,9 +331,25 @@ async fn run_turn( } } -/// A voice command as a stream-json user message: the local time first, as context, then what was said. -fn user_message(text: &str, now: &chrono::DateTime) -> Value { - let context = format!("{CONTEXT_PREFIX}The user's local time: {}", now.format("%A %Y-%m-%d %H:%M (UTC%:z)")); +/// The computer's IANA time zone (`Asia/Singapore`): `TZ` if set, else where `/etc/localtime` points, on macOS +/// and Linux alike. +fn local_time_zone() -> Option { + if let Ok(zone) = std::env::var("TZ") + && !zone.is_empty() + { + return Some(zone.trim_start_matches(':').to_owned()); + } + let target = std::fs::read_link("/etc/localtime").ok()?; + let path = target.to_string_lossy(); + path.split_once("zoneinfo/").map(|(_, zone)| zone.to_owned()) +} + +/// A voice command as a stream-json user message: the local time and time zone first, as context, then what was +/// said. The time zone stands in for where the user is (Codex gets it from its own environment context), so a +/// question about the weather or the news has a place. +fn user_message(text: &str, now: &chrono::DateTime, time_zone: Option<&str>) -> Value { + let zone = time_zone.map(|zone| format!(", time zone {zone}")).unwrap_or_default(); + let context = format!("{CONTEXT_PREFIX}The user's local time: {}{zone}", now.format("%A %Y-%m-%d %H:%M (UTC%:z)")); json!({"type": "user", "message": {"role": "user", "content": [ {"type": "text", "text": context}, {"type": "text", "text": text}, @@ -663,6 +679,18 @@ pub(crate) mod tests { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use super::*; + + #[test] + fn a_voice_command_carries_the_local_time_and_time_zone_as_context() { + let now = chrono::Local::now(); + let message = user_message("今天天气怎么样", &now, Some("Asia/Singapore")); + let content = message["message"]["content"].as_array().unwrap(); + let context = content[0]["text"].as_str().unwrap(); + assert!(context.starts_with(CONTEXT_PREFIX) && context.contains("time zone Asia/Singapore"), "{context}"); + assert_eq!(content[1]["text"], "今天天气怎么样"); + let without = user_message("hi", &now, None); + assert!(!without["message"]["content"][0]["text"].as_str().unwrap().contains("time zone")); + } use crate::activity::{ActivityId, ActivityStatus}; use crate::resident::tests::temp_dir; From 66dfbb53675a8fc8a169f040e2c8a4a8fd82fd5c Mon Sep 17 00:00:00 2001 From: dragon Date: Wed, 14 Oct 2026 16:06:33 +0800 Subject: [PATCH 43/51] Reset the Conversation tab's scroll when the answering Agent changes Switching from Codex (20 exchanges) to Claude Code (3) left the scroll view past the end of the shorter history, so the tab looked empty. A new thread now gets a new scroll view, and a plain stack is scrolled to its end once laid out. Co-Authored-By: Claude Opus 5.5 --- app/Sources/VibeBuddy/ConversationView.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/app/Sources/VibeBuddy/ConversationView.swift b/app/Sources/VibeBuddy/ConversationView.swift index 550a4786..31a6385f 100644 --- a/app/Sources/VibeBuddy/ConversationView.swift +++ b/app/Sources/VibeBuddy/ConversationView.swift @@ -171,22 +171,32 @@ struct ConversationView: View { } else if let exchanges { ScrollViewReader { proxy in ScrollView { - LazyVStack(spacing: 16) { + // A plain stack: at most 20 exchanges, and a lazy one can't be scrolled to rows it hasn't laid + // out, which left the view scrolled past a shorter history (switching from Codex to Claude). + VStack(spacing: 16) { ForEach(Array(exchanges.enumerated()), id: \.offset) { index, exchange in ExchangeRow(exchange: exchange).id(index) } } .padding(12) } + // A new thread (the other Agent answering) gets a new scroll view, so no offset carries over. + .id(latest?.threadId ?? "") .background(.background, in: RoundedRectangle(cornerRadius: 8)) - .onAppear { proxy.scrollTo(exchanges.count - 1, anchor: .bottom) } - .onChange(of: exchanges) { proxy.scrollTo(exchanges.count - 1, anchor: .bottom) } + .onAppear { scrollToEnd(proxy) } + .onChange(of: exchanges) { scrollToEnd(proxy) } } } else { ProgressView() } } + /// Scrolls to the newest exchange once the rows are laid out. + private func scrollToEnd(_ proxy: ScrollViewProxy) { + guard let exchanges, !exchanges.isEmpty else { return } + DispatchQueue.main.async { proxy.scrollTo(exchanges.count - 1, anchor: .bottom) } + } + /// Asks `GET /v1/resident` every 2 seconds and reads the history again only when its change marker moved, or /// when voice has just become ready. A daemon without the marker gets its history read every 5 seconds instead. private func follow() async { From 14bde4686dd6fe23341a3568cf5d4b3e8cd4ec5d Mon Sep 17 00:00:00 2001 From: dragon Date: Wed, 14 Oct 2026 16:26:05 +0800 Subject: [PATCH 44/51] Speak spoken replies as the Character the buddy wears The resident session's instructions now carry the persona of the Character the buddy wears (characters//persona.md, embedded at build time; the robot gets a persona of its own) and its one language: the buddy understands any language but always replies in its Character's, whatever earlier replies or the user's Codex memories say. The user's form of address goes in too. The box reports only the pack's id, so the Apps now say, when they write a pack, which preset lends a custom Character its voice, persona and lines and which form of address its lines carry (POST /v1/device/voice-pack?lender=& address=). The daemon keeps that in character.json; a custom Character also speaks in its lender's voice. When the Character changes, the next voice command resumes the same session with the new instructions: Codex restarts the app-server and resumes the thread (turn/start takes no instructions), Claude Code restarts with --resume. That command carries a context line naming the new Character, which the history leaves out. Co-Authored-By: Claude Opus 5.5 --- app/Sources/VibeBuddy/AppModel.swift | 12 +- app/Sources/VibeBuddy/DaemonClient.swift | 13 +- characters/robot/README.md | 2 + characters/robot/persona.md | 7 + daemon/src/main.rs | 79 ++++-- daemon/src/persona.rs | 201 ++++++++++++++ daemon/src/resident.rs | 331 +++++++++++++++++------ daemon/src/resident_claude.rs | 114 ++++++-- daemon/src/synthesis.rs | 9 +- daemon/src/voice.rs | 24 +- desktop/src/client.rs | 12 +- desktop/src/main.rs | 6 +- docs/resident-session.md | 19 +- 13 files changed, 689 insertions(+), 140 deletions(-) create mode 100644 characters/robot/persona.md create mode 100644 daemon/src/persona.rs diff --git a/app/Sources/VibeBuddy/AppModel.swift b/app/Sources/VibeBuddy/AppModel.swift index 9288604d..fa20cb4b 100644 --- a/app/Sources/VibeBuddy/AppModel.swift +++ b/app/Sources/VibeBuddy/AppModel.swift @@ -230,7 +230,8 @@ final class AppModel: ObservableObject { func writeVoice(_ id: String) { guard let pack = Resources.voicePack(id) else { lastError = String(localized: "This app has no voice pack for \(id)"); return } - run { try await self.client.writeVoicePack(self.addressed(pack, id: id).data) } + let address = VoiceCatalogEntry.language(ofVoice: id).flatMap(formOfAddress) + run { try await self.client.writeVoicePack(self.addressed(pack, id: id).data, address: address) } } /// The form of address picked for a language; nil for none. @@ -280,9 +281,10 @@ final class AppModel: ObservableObject { return addressed } - /// Writes a pack the app put together itself, such as a custom Character. - func writePack(_ data: Data) { - run { try await self.client.writeVoicePack(data) } + /// Writes a pack the app put together itself, such as a custom Character with the Character lending it + /// voice, persona and lines, and the form of address they carry. + func writePack(_ data: Data, lender: String? = nil, address: String? = nil) { + run { try await self.client.writeVoicePack(data, lender: lender, address: address) } } private static let customLookURL = Resources.applicationSupport.appendingPathComponent("custom-look.bin") @@ -303,7 +305,7 @@ final class AppModel: ObservableObject { try? FileManager.default.createDirectory(at: Resources.applicationSupport, withIntermediateDirectories: true) try? look.write(to: Self.customLookURL) UserDefaults.standard.set(lender, forKey: Self.customLenderKey) - writePack(pack) + writePack(pack, lender: lender, address: VoiceCatalogEntry.language(ofVoice: lender).flatMap(formOfAddress)) return true } diff --git a/app/Sources/VibeBuddy/DaemonClient.swift b/app/Sources/VibeBuddy/DaemonClient.swift index 0002e946..44e0b311 100644 --- a/app/Sources/VibeBuddy/DaemonClient.swift +++ b/app/Sources/VibeBuddy/DaemonClient.swift @@ -95,8 +95,17 @@ struct DaemonClient { guard code == 202 else { throw DaemonError(message: DaemonClient.message(in: data) ?? String(localized: "Forgetting Wi-Fi was rejected (\(code))")) } } - func writeVoicePack(_ pack: Data) async throws { - let (code, data) = try await post("/v1/device/voice-pack", body: pack, contentType: "application/octet-stream") + /// `lender` (the Character lending a custom one its voice, persona and lines) and `address` (the form of + /// address its lines carry) aren't in the pack; the daemon keeps them for the buddy's spoken replies. + func writeVoicePack(_ pack: Data, lender: String? = nil, address: String? = nil) async throws { + var request = self.request("/v1/device/voice-pack", method: "POST", body: pack, contentType: "application/octet-stream") + let query = [("lender", lender), ("address", address)].compactMap { name, value in value.map { URLQueryItem(name: name, value: $0) } } + if !query.isEmpty, let url = request.url, var components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + components.queryItems = query + request.url = components.url + } + let (data, response) = try await URLSession.shared.data(for: request) + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 guard code == 202 else { throw DaemonError(message: DaemonClient.message(in: data) ?? String(localized: "Voice pack write was rejected (\(code))")) } } diff --git a/characters/robot/README.md b/characters/robot/README.md index 63897a43..d79967b1 100644 --- a/characters/robot/README.md +++ b/characters/robot/README.md @@ -2,4 +2,6 @@ The default Character. Its look is drawn by the firmware itself, so there is no `look.png` and no `pack.bin` here: the App writes the robot as a pack without a look, with its own voice: the built-in voice's five fixed lines (`voices/jessica/`, packed as `robot`). +`persona.md` is how it talks when it answers a voice command (docs/resident-session.md, "Persona and language"); it has no `lines.tsv`, as its lines are the five fixed ones. + `face.png` is the robot on its idle frame, rendered by the firmware's host preview and halved, for the Character cards. diff --git a/characters/robot/persona.md b/characters/robot/persona.md new file mode 100644 index 00000000..285512f5 --- /dev/null +++ b/characters/robot/persona.md @@ -0,0 +1,7 @@ +# VibeBuddy + +Speaks English, in a clear, warm American voice. + +The original pixel robot, the default Character: a small, cheerful desk companion that watches the Agents and speaks up for you. Friendly, calm and brief, with a light touch of fun ("All done!", "Uh-oh, something went wrong."). It gets straight to the point, celebrates small wins without fuss, and stays steady when something breaks. Never sarcastic, never robotic beeps or a put-on machine voice. + +Plain, everyday spoken English. No slang that will date. diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 5b83b74a..43977d27 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -13,6 +13,7 @@ mod resident; mod resident_claude; mod link_alert; mod occasions; +mod persona; mod rom_flasher; mod screenshot; mod serial_transport; @@ -106,6 +107,9 @@ struct AppState { voice: Arc, /// The box on the cable said `LINK NOISE` since its last `BOX KEY` (ADR-0015). noise_seen: Arc, + /// What the App said about the Character pack it last wrote (who lends a custom one, the form of address), + /// kept in [`persona::RECORD_FILE`] in the state directory. + written: Arc>, } impl AppState { @@ -142,6 +146,7 @@ impl AppState { resident: None, voice: Arc::new(voice::Voice::new(None, None)), noise_seen: Arc::new(std::sync::atomic::AtomicBool::new(false)), + written: Arc::default(), } } @@ -258,7 +263,10 @@ async fn main() { state.identity = identity; state.relay = relay_config; state.state_dir = config::state_dir(); - state.resident = state.state_dir.as_deref().map(|dir| { + if let Some(dir) = &state.state_dir { + state.written = Arc::new(Mutex::new(persona::Written::load(&dir.join(persona::RECORD_FILE)))); + } + state.resident =state.state_dir.as_deref().map(|dir| { let enabled = resident::enabled_by_env(); if enabled { warn!("the experimental resident session is on"); @@ -271,7 +279,8 @@ async fn main() { }); state.voice = Arc::new(voice::Voice::new(speech::from_env(), state.resident.clone()).with_synthesizer(synthesis::from_env())); if let Some(resident) = state.resident.clone() { - tokio::spawn(async move { resident.warm().await }); + let wearing = wearing(&state).await; + tokio::spawn(async move { resident.warm(&wearing).await }); } state.device.lock().await.pin = pin; state.updates = Arc::new(Mutex::new(Updates::new( @@ -413,9 +422,9 @@ async fn publish_device_message(state: &AppState, message: DeviceMessage) { /// over the UART bridge, which is too slow for it, and over the Relay it will talk to the voice service itself, so /// voice isn't offered on either, and voice lines over the Relay are ignored. async fn handle_voice_line(state: &AppState, line: &str) -> Option> { - let (over_relay, on_bridge, character) = { + let (over_relay, on_bridge) = { let device = state.device.lock().await; - (device.relay, device.bridge, device.voice.clone()) + (device.relay, device.bridge) }; if line.contains(serial_transport::BUILD_MARKER) && state.voice.available() { if over_relay { @@ -433,12 +442,18 @@ async fn handle_voice_line(state: &AppState, line: &str) -> Option Some(character), + state.voice.handle_line(line, &state.transport, &wearing(state).await) +} + +/// What the buddy wears for spoken replies: the Character the box reported wearing, before it has the one last +/// written to it, with what the App said when it wrote it (a custom Character's lender, the form of address). +async fn wearing(state: &AppState) -> persona::Wearing { + let reported = state.device.lock().await.voice.clone(); + let id = match reported { + Some(id) => Some(id), None => state.config.lock().await.voice.clone(), }; - state.voice.handle_line(line, &state.transport, character.as_deref()) + persona::Wearing::of(id, &*state.written.lock().await) } /// Tells a box that has reported its build whether voice is on now, after the resident session's readiness flipped @@ -947,9 +962,18 @@ async fn post_choice( (StatusCode::ACCEPTED, Json(ApiResponse { accepted: true, message: "remembered".to_owned() })) } +/// What the App may say about a Character pack it writes, which the pack itself doesn't carry: the preset lending a +/// custom Character its voice, persona and lines, and the form of address its lines were made with. +#[derive(Default, serde::Deserialize)] +struct PackQuery { + lender: Option, + address: Option, +} + /// Write a voice pack: the request body is the pack itself. The write runs in the background with progress in the status stream. async fn post_voice_pack( State(state): State, + axum::extract::Query(query): axum::extract::Query, body: Bytes, ) -> (StatusCode, Json) { let Some(voice) = voice_writer::voice_id_of(&body) else { @@ -974,6 +998,13 @@ async fn post_voice_pack( Ok(written) => { info!(voice = %written, "voice pack written to device"); task_state.save_config(|config| config.voice = Some(written.clone())).await; + let record = persona::Written { id: Some(written.clone()), lender: query.lender, address: query.address }; + if let Some(dir) = &task_state.state_dir + && let Err(error) = record.save(&dir.join(persona::RECORD_FILE)) + { + warn!(%error, "failed to save what the App said about the Character"); + } + *task_state.written.lock().await = record; task_state .set_operation(Some(Operation { kind: OperationKind::VoicePack, @@ -1529,7 +1560,7 @@ async fn post_resident_say(State(state): State, Json(request): Json { info!(elapsed_ms = started.elapsed().as_millis() as u64, replies = answer.replies.len(), "voice command answered"); Json(serde_json::json!({ @@ -1793,7 +1824,7 @@ mod tests { assert_eq!((info["brain_setting"].as_str(), info["brain"].as_str(), info["ready"].as_bool()), (Some("claude"), Some("claude"), Some(true))); let resident = state.resident.clone().unwrap(); - resident.say("hi").await.unwrap(); + resident.say("hi", &persona::Wearing::default()).await.unwrap(); let session = resident.thread_id().unwrap(); for event in ["UserPromptSubmit", "Stop"] { let hook: ClaudeHook = serde_json::from_value(serde_json::json!({ @@ -1929,7 +1960,7 @@ mod tests { ); assert_eq!(script.lock().unwrap().starts, 0, "asking never starts the app-server"); - state.resident.as_ref().unwrap().say("hi").await.unwrap(); + state.resident.as_ref().unwrap().say("hi", &persona::Wearing::default()).await.unwrap(); let Json(info) = get_resident(State(state.clone())).await; assert_eq!(info.thread_id.as_deref(), Some("new-thread")); assert_eq!(info.revision, 2, "a new thread and one turn"); @@ -2054,6 +2085,10 @@ mod tests { assert!(transport.events().is_empty()); } + fn no_query() -> axum::extract::Query { + axum::extract::Query(PackQuery::default()) + } + fn sample_pack(voice: &str, payload_len: usize) -> Vec { let mut pack = vec![0_u8; 256 + payload_len]; pack[0..4].copy_from_slice(b"VBVP"); @@ -2068,9 +2103,10 @@ mod tests { async fn writing_a_voice_pack_waits_for_each_acknowledgement() { let transport = Arc::new(RecordingTransport::default()); let state = test_state(transport.clone()); - let pack = sample_pack("hsiaoyu", 1000); // 1256 bytes → two chunks + let pack = sample_pack("custom", 1000); // 1256 bytes → two chunks - let (status, _) = post_voice_pack(State(state.clone()), Bytes::from(pack.clone())).await; + let query = PackQuery { lender: Some("ada".to_owned()), address: Some("boss".to_owned()) }; + let (status, _) = post_voice_pack(State(state.clone()), axum::extract::Query(query), Bytes::from(pack.clone())).await; assert_eq!(status, StatusCode::ACCEPTED); let begin = nth_event(&transport, 0).await; @@ -2095,7 +2131,7 @@ mod tests { let end = nth_event(&transport, 3).await; assert_eq!(end.event, "voice.end"); - publish_device_message(&state, device_event(r#"{"version":1,"event":"voice.written","voice":"hsiaoyu"}"#)).await; + publish_device_message(&state, device_event(r#"{"version":1,"event":"voice.written","voice":"custom"}"#)).await; for _ in 0..200 { if state.operation.lock().await.as_ref().map(|op| op.state) == Some(OperationState::Done) { break; @@ -2104,7 +2140,12 @@ mod tests { } let operation = state.operation.lock().await.clone().expect("an operation should be recorded"); assert_eq!(operation.state, OperationState::Done); - assert_eq!(state.config.lock().await.voice.as_deref(), Some("hsiaoyu")); + assert_eq!(state.config.lock().await.voice.as_deref(), Some("custom")); + // What the App said about it is kept: the custom Character speaks with Ada's persona and calls the user boss. + assert_eq!( + wearing(&state).await, + persona::Wearing { character: Some("ada".to_owned()), address: Some("boss".to_owned()) } + ); } #[tokio::test] @@ -2112,7 +2153,7 @@ mod tests { let transport = Arc::new(RecordingTransport::default()); let state = test_state(transport.clone()); let pack = sample_pack("hsiaoyu", 100); - let _ = post_voice_pack(State(state.clone()), Bytes::from(pack)).await; + let _ = post_voice_pack(State(state.clone()), no_query(), Bytes::from(pack)).await; nth_event(&transport, 0).await; publish_device_message( &state, @@ -2135,10 +2176,10 @@ mod tests { async fn a_second_write_is_refused_while_one_is_running_and_garbage_is_rejected() { let transport = Arc::new(RecordingTransport::default()); let state = test_state(transport.clone()); - let (status, _) = post_voice_pack(State(state.clone()), Bytes::from_static(b"not a pack")).await; + let (status, _) = post_voice_pack(State(state.clone()), no_query(), Bytes::from_static(b"not a pack")).await; assert_eq!(status, StatusCode::BAD_REQUEST); - let _ = post_voice_pack(State(state.clone()), Bytes::from(sample_pack("a", 10))).await; - let (status, _) = post_voice_pack(State(state.clone()), Bytes::from(sample_pack("b", 10))).await; + let _ = post_voice_pack(State(state.clone()), no_query(), Bytes::from(sample_pack("a", 10))).await; + let (status, _) = post_voice_pack(State(state.clone()), no_query(), Bytes::from(sample_pack("b", 10))).await; assert_eq!(status, StatusCode::CONFLICT); } diff --git a/daemon/src/persona.rs b/daemon/src/persona.rs new file mode 100644 index 00000000..e3729ace --- /dev/null +++ b/daemon/src/persona.rs @@ -0,0 +1,201 @@ +//! Who the buddy is when it answers a voice command (ADR-0017): the persona and language of the Character it +//! wears, and the user's form of address, as the resident session's instructions carry them. +//! +//! The box reports only the id of the pack it wears (`VOICES`). Which preset lends a custom Character its voice, +//! persona and lines, and which form of address the pack's lines were written with, only the App knows; it says so +//! when it writes the pack (`POST /v1/device/voice-pack?lender=…&address=…`), and the daemon keeps it in +//! [`RECORD_FILE`]. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +/// What the App said about the pack it last wrote, in the daemon's state directory. +pub const RECORD_FILE: &str = "character.json"; +/// The id a custom Character's pack carries, whatever lends it voice and lines. +const CUSTOM: &str = "custom"; +/// The default Character, and the persona of any pack these files don't know. +const ROBOT: &str = "robot"; +/// The robot's lines are the built-in voice's, in English; it has no `voice.env`. +const ROBOT_LANGUAGE: &str = "en"; + +/// Each Character's persona (`characters//persona.md`), the same one its lines were written in. +const PERSONAS: &[(&str, &str)] = &[ + ("ada", include_str!("../../characters/ada/persona.md")), + ("dave", include_str!("../../characters/dave/persona.md")), + ("hank", include_str!("../../characters/hank/persona.md")), + ("kai", include_str!("../../characters/kai/persona.md")), + ("luna", include_str!("../../characters/luna/persona.md")), + ("mei", include_str!("../../characters/mei/persona.md")), + ("robot", include_str!("../../characters/robot/persona.md")), + ("wanwanxiaohe", include_str!("../../characters/wanwanxiaohe/persona.md")), +]; + +/// The forms of address the App offers (docs/characters.md): id, words, language. +const FORMS_OF_ADDRESS: &[(&str, &str, &str)] = &[ + ("laoban", "老板", "zh"), + ("dalao", "大佬", "zh"), + ("ge", "哥", "zh"), + ("jie", "姐", "zh"), + ("qin", "亲", "zh"), + ("boss", "boss", "en"), + ("captain", "captain", "en"), + ("buddy", "buddy", "en"), +]; + +/// What the App said when it last wrote a Character pack: its id, the preset lending a custom Character its +/// voice, persona and lines, and the form of address its lines carry. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +#[serde(default)] +pub struct Written { + pub id: Option, + pub lender: Option, + pub address: Option, +} + +impl Written { + pub fn load(path: &Path) -> Self { + std::fs::read_to_string(path).ok().and_then(|text| serde_json::from_str(&text).ok()).unwrap_or_default() + } + + pub fn save(&self, path: &Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, serde_json::to_string_pretty(self).map_err(std::io::Error::other)?) + } +} + +/// The Character the buddy wears, as far as its voice and persona go. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Wearing { + /// Whose voice and persona: the lender for a custom Character; None with no Character pack. + pub character: Option, + /// The form of address the user picked (`boss`, `laoban`), if any. + pub address: Option, +} + +impl Wearing { + /// The Character pack `id` (the box's report, else the one last written), with what the App said when it + /// wrote it, if that was this pack. + pub fn of(id: Option, written: &Written) -> Self { + match id { + Some(id) if written.id.as_deref() == Some(id.as_str()) => Self { + character: if id == CUSTOM { written.lender.clone() } else { Some(id) }, + address: written.address.clone(), + }, + id => Self { character: id, address: None }, + } + } +} + +/// How the buddy speaks its spoken replies. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Persona { + /// The Character whose persona this is. + pub id: &'static str, + /// Its name, from the persona's heading. + pub name: &'static str, + /// The persona, without its heading. + pub text: &'static str, + /// The one language it speaks, in English (`English`, `Chinese`). + pub language: &'static str, + /// The words of the user's form of address, when one in this language is picked. + pub address: Option<&'static str>, +} + +impl Persona { + /// The persona of what the buddy wears: the robot's for no pack, a custom Character with no known lender, + /// or a pack these files don't know. + pub fn of(wearing: &Wearing) -> Self { + let (id, file) = wearing + .character + .as_deref() + .and_then(|character| PERSONAS.iter().find(|(id, _)| *id == character)) + .or_else(|| PERSONAS.iter().find(|(id, _)| *id == ROBOT)) + .copied() + .expect("the robot has a persona"); + let (heading, text) = file.split_once('\n').unwrap_or((file, "")); + let code = crate::synthesis::VOICE_FILES + .iter() + .find(|(voice, _)| *voice == id) + .and_then(|(_, voice)| crate::synthesis::voice_field(voice, "LANGUAGE")) + .unwrap_or(ROBOT_LANGUAGE); + let address = wearing.address.as_deref().and_then(|form| { + FORMS_OF_ADDRESS.iter().find(|(id, _, language)| *id == form && *language == code).map(|(_, words, _)| *words) + }); + Self { + id, + name: heading.trim_start_matches('#').trim(), + text: text.trim(), + language: match code { + "zh" => "Chinese", + _ => "English", + }, + address, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wearing(character: Option<&str>, address: Option<&str>) -> Wearing { + Wearing { character: character.map(str::to_owned), address: address.map(str::to_owned) } + } + + #[test] + fn each_character_has_its_own_persona_name_and_language() { + let ada = Persona::of(&wearing(Some("ada"), None)); + assert_eq!((ada.id, ada.name, ada.language), ("ada", "Ada", "English")); + assert!(ada.text.starts_with("Speaks English") && ada.text.contains("Londoner"), "{}", ada.text); + let xiaohe = Persona::of(&wearing(Some("wanwanxiaohe"), None)); + assert_eq!((xiaohe.id, xiaohe.name, xiaohe.language), ("wanwanxiaohe", "Xiaohe (小何)", "Chinese")); + for id in ["dave", "hank", "kai", "luna", "mei"] { + let persona = Persona::of(&wearing(Some(id), None)); + assert_eq!((persona.id, persona.language), (id, "English")); + assert!(!persona.text.is_empty() && !persona.text.starts_with('#')); + } + } + + #[test] + fn no_pack_or_an_unknown_one_is_the_robot() { + for character in [None, Some("robot"), Some("custom"), Some("hsiaoyu")] { + let persona = Persona::of(&wearing(character, None)); + assert_eq!((persona.id, persona.name, persona.language), ("robot", "VibeBuddy", "English"), "{character:?}"); + } + } + + #[test] + fn a_form_of_address_counts_only_in_the_characters_language() { + assert_eq!(Persona::of(&wearing(Some("ada"), Some("boss"))).address, Some("boss")); + assert_eq!(Persona::of(&wearing(Some("wanwanxiaohe"), Some("laoban"))).address, Some("老板")); + assert_eq!(Persona::of(&wearing(Some("ada"), Some("laoban"))).address, None); + assert_eq!(Persona::of(&wearing(Some("ada"), Some("darling"))).address, None); + } + + #[test] + fn a_custom_character_wears_its_lenders_voice_and_persona_and_the_form_written_with_it() { + let written = Written { id: Some("custom".to_owned()), lender: Some("ada".to_owned()), address: Some("boss".to_owned()) }; + assert_eq!(Wearing::of(Some("custom".to_owned()), &written), wearing(Some("ada"), Some("boss"))); + let persona = Persona::of(&Wearing::of(Some("custom".to_owned()), &written)); + assert_eq!((persona.id, persona.address), ("ada", Some("boss"))); + // The box reports another pack than the App last wrote (written elsewhere): only its id is known. + assert_eq!(Wearing::of(Some("mei".to_owned()), &written), wearing(Some("mei"), None)); + // A custom Character written by an App that didn't say who lends it: the robot's persona. + assert_eq!(Wearing::of(Some("custom".to_owned()), &Written::default()), wearing(Some("custom"), None)); + assert_eq!(Wearing::of(None, &written), wearing(None, None)); + } + + #[test] + fn the_record_round_trips_and_a_missing_one_is_empty() { + let dir = std::env::temp_dir().join(format!("vibebuddy-persona-{}", std::process::id())); + let path = dir.join(RECORD_FILE); + assert_eq!(Written::load(&path), Written::default()); + let written = Written { id: Some("custom".to_owned()), lender: Some("kai".to_owned()), address: None }; + written.save(&path).unwrap(); + assert_eq!(Written::load(&path), written); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/daemon/src/resident.rs b/daemon/src/resident.rs index b80e3e6c..43b44982 100644 --- a/daemon/src/resident.rs +++ b/daemon/src/resident.rs @@ -30,7 +30,8 @@ use tracing::{info, warn}; use crate::activity::ActivityTracker; use crate::app_server::{Client, Incoming, RpcError}; -use crate::resident_claude::ClaudeBrain; +use crate::persona::{Persona, Wearing}; +use crate::resident_claude::{CONTEXT_PREFIX, ClaudeBrain}; /// The Codex version the app-server protocol was verified with. app-server and `dynamicTools` are experimental, /// so other versions get a warning in the log, but are still tried. @@ -218,6 +219,8 @@ struct Session { client: Client, incoming: mpsc::UnboundedReceiver, thread_id: String, + /// Who its instructions make the buddy: they are set only when the thread starts or is resumed. + persona: Persona, _child: Option, } @@ -226,6 +229,9 @@ struct Saved { thread_id: Option, /// The Codex version the thread was last resumed with, for diagnosing a protocol change. codex_version: Option, + /// The Character whose persona the thread last answered in. + #[serde(default)] + persona: Option, } pub struct Resident { @@ -245,6 +251,10 @@ pub struct Resident { session: Mutex>, /// Read synchronously by the hook filter. thread_id: std::sync::Mutex>, + /// The Character whose persona the thread last answered in (kept in `resident.json`). + spoke_as: std::sync::Mutex>, + /// The persona of the last voice command, for starting the session when no command asks (history). + persona: std::sync::Mutex>, /// Bumped when a turn ends or the thread changes, so a client can tell the history moved without reading it. revision: std::sync::atomic::AtomicU64, /// The last Codex check (its `brain` unused). @@ -281,6 +291,8 @@ impl Resident { activities, session: Mutex::new(None), thread_id: std::sync::Mutex::new(saved.thread_id), + spoke_as: std::sync::Mutex::new(saved.persona), + persona: std::sync::Mutex::new(None), revision: std::sync::atomic::AtomicU64::new(0), codex_readiness: std::sync::Mutex::new(Readiness::default()), readiness: watch::channel(Readiness::default()).0, @@ -502,62 +514,113 @@ impl Resident { } /// Hands one voice command to the resident session and waits for its spoken replies. - pub async fn say(&self, text: &str) -> Result { + pub async fn say(&self, text: &str, wearing: &Wearing) -> Result { let mut replies = Vec::new(); - let thread_id = self.say_streaming(text, &mut |reply| replies.push(reply.to_owned())).await?; + let thread_id = self.say_streaming(text, wearing, &mut |reply| replies.push(reply.to_owned())).await?; Ok(Answer { thread_id, replies }) } - /// Hands one voice command to the resident session; each spoken reply goes to `on_reply` the moment the - /// session calls `speak`, not when the turn completes, which in testing was 4 to 6 seconds later. Returns the - /// thread id once the turn has completed. - pub async fn say_streaming(&self, text: &str, on_reply: &mut (dyn FnMut(&str) + Send)) -> Result { + /// Hands one voice command to the resident session, answered as the Character the buddy is `wearing`; each + /// spoken reply goes to `on_reply` the moment the session calls `speak`, not when the turn completes, which in + /// testing was 4 to 6 seconds later. Returns the thread id once the turn has completed. + pub async fn say_streaming( + &self, + text: &str, + wearing: &Wearing, + on_reply: &mut (dyn FnMut(&str) + Send), + ) -> Result { if !self.enabled { return Err(ResidentError::Disabled); } + let persona = Persona::of(wearing); + *self.persona.lock().expect("persona lock") = Some(persona.clone()); let result = match self.brain() { - Brain::Codex => self.say_codex(text, on_reply).await, - Brain::Claude => self.say_claude(text, on_reply).await, + Brain::Codex => self.say_codex(text, &persona, on_reply).await, + Brain::Claude => self.say_claude(text, &persona, on_reply).await, }; // Even a failed or timed-out turn may have left the command in the thread. self.bump_revision(); result } - async fn say_codex(&self, text: &str, on_reply: &mut (dyn FnMut(&str) + Send)) -> Result { + async fn say_codex( + &self, + text: &str, + persona: &Persona, + on_reply: &mut (dyn FnMut(&str) + Send), + ) -> Result { let mut slot = self.session.lock().await; - let session = self.running(&mut slot).await?; - let result = self.run_turn(session, text, on_reply).await; + // A running thread keeps the instructions it was resumed with, so a new Character resumes it again. + if slot.as_ref().is_some_and(|session| session.persona != *persona) { + info!(character = persona.id, "the Character changed; resuming the resident thread with its persona"); + *slot = None; + } + let session = self.running(&mut slot, persona).await?; + let spoke_as = self.spoke_as.lock().expect("spoke as lock").clone(); + let changed = spoke_as.as_deref() != Some(persona.id); + let note = (changed && spoke_as.is_some()).then(|| change_note(persona)); + let result = self.run_turn(session, note.as_deref(), text, on_reply).await; if session.client.is_closed() { *slot = None; } + if result.is_ok() && changed { + self.remember_persona(persona.id); + } result } - async fn say_claude(&self, text: &str, on_reply: &mut (dyn FnMut(&str) + Send)) -> Result { + /// Keeps the Character the thread now answers as, so a change is noticed after a restart too. + fn remember_persona(&self, id: &str) { + *self.spoke_as.lock().expect("spoke as lock") = Some(id.to_owned()); + let mut saved = load(&self.state_file); + saved.persona = Some(id.to_owned()); + if let Err(error) = save(&self.state_file, &saved) { + warn!(%error, "failed to save the resident session's Character"); + } + } + + async fn say_claude( + &self, + text: &str, + persona: &Persona, + on_reply: &mut (dyn FnMut(&str) + Send), + ) -> Result { let key = self.anthropic_key().ok_or(ResidentError::NoAnthropicKey)?; - let result = self.claude.say_streaming(&key, &instructions(None, None, Brain::Claude), text, on_reply).await; + let instructions = instructions(persona, None, Brain::Claude); + let result = self.claude.say_streaming(&key, &instructions, persona, text, on_reply).await; if result == Err(ResidentError::NoClaude) { self.publish(); } result } + /// The persona of the last voice command, else the one the thread last answered in. + fn last_persona(&self) -> Persona { + if let Some(persona) = self.persona.lock().expect("persona lock").clone() { + return persona; + } + let character = self.spoke_as.lock().expect("spoke as lock").clone(); + Persona::of(&Wearing { character, address: None }) + } + /// Starts the app-server and resumes the thread ahead of the first voice command, which otherwise waits for - /// both. Does nothing when switched off or already running; a failure is logged and the next command tries again. - pub async fn warm(&self) { + /// both, as the Character the buddy is `wearing`. Does nothing when switched off or already running; a failure + /// is logged and the next command tries again. + pub async fn warm(&self, wearing: &Wearing) { if !self.enabled { return; } self.check_readiness().await; + let persona = Persona::of(wearing); + *self.persona.lock().expect("persona lock") = Some(persona.clone()); if self.brain() == Brain::Claude { if let Some(key) = self.anthropic_key() { - self.claude.warm(&key, &instructions(None, None, Brain::Claude)).await; + self.claude.warm(&key, &instructions(&persona, None, Brain::Claude)).await; } return; } let mut slot = self.session.lock().await; - match self.running(&mut slot).await { + match self.running(&mut slot, &persona).await { Ok(session) => info!(thread = %session.thread_id, "the resident session is ready"), Err(error) => warn!(%error, "the resident session could not be started ahead of time"), } @@ -576,7 +639,7 @@ impl Resident { return Ok(Vec::new()); } let mut slot = self.session.lock().await; - let session = self.running(&mut slot).await?; + let session = self.running(&mut slot, &self.last_persona()).await?; let params = json!({"threadId": session.thread_id, "limit": limit, "sortDirection": "desc", "itemsView": "full"}); let listed = request(&session.client, "thread/turns/list", params).await; if session.client.is_closed() { @@ -587,13 +650,13 @@ impl Resident { } /// The running session, started (again) if there is none or its app-server has exited. - async fn running<'a>(&self, slot: &'a mut Option) -> Result<&'a mut Session, ResidentError> { + async fn running<'a>(&self, slot: &'a mut Option, persona: &Persona) -> Result<&'a mut Session, ResidentError> { if slot.as_ref().is_some_and(|session| session.client.is_closed()) { warn!("the resident app-server exited; starting it again"); *slot = None; } if slot.is_none() { - let started = self.start().await; + let started = self.start(persona).await; // A start is a readiness check too, and the most recent one. match &started { Ok(_) => self.note(None), @@ -606,7 +669,7 @@ impl Resident { Ok(slot.as_mut().expect("session was just started")) } - async fn start(&self) -> Result { + async fn start(&self, persona: &Persona) -> Result { prepare_codex_home(&self.codex_home, &self.user_codex_home) .map_err(|error| ResidentError::Rpc(format!("cannot prepare {}: {error}", self.codex_home.display())))?; let (pipes, version) = match &self.launcher { @@ -645,9 +708,9 @@ impl Resident { // saved before the resident session had its own Codex home is one); one that can't be resumed for // another reason (another Codex process is its writer) fails this command and is tried again with the // next. - Some(saved) => match self.resume(&client, &saved).await { + Some(saved) => match self.resume(&client, &saved, persona).await { Err(ResidentError::Rpc(message)) if !client.is_closed() && thread_is_gone(&message) => { - let thread = self.start_thread(&client).await?; + let thread = self.start_thread(&client, persona).await?; warn!(old_thread = %saved, new_thread = %thread, %message, "the resident thread no longer exists; started a new one"); thread } @@ -657,48 +720,50 @@ impl Resident { } other => other?, }, - None => self.start_thread(&client).await?, + None => self.start_thread(&client, persona).await?, }; let previous = self.thread_id.lock().expect("thread id lock").replace(thread_id.clone()); if previous.as_deref() != Some(thread_id.as_str()) { self.bump_revision(); } - let saved = Saved { thread_id: Some(thread_id.clone()), codex_version: version }; + let spoke_as = self.spoke_as.lock().expect("spoke as lock").clone(); + let saved = Saved { thread_id: Some(thread_id.clone()), codex_version: version, persona: spoke_as }; if let Err(error) = save(&self.state_file, &saved) { warn!(%error, "failed to save the resident thread id"); } info!(thread = %thread_id, "resident session ready"); - Ok(Session { client, incoming, thread_id, _child: pipes.child }) + Ok(Session { client, incoming, thread_id, persona: persona.clone(), _child: pipes.child }) } - async fn start_thread(&self, client: &Client) -> Result { - let mut params = self.thread_params(); + async fn start_thread(&self, client: &Client, persona: &Persona) -> Result { + let mut params = self.thread_params(persona); params["dynamicTools"] = tools(); let started = request(client, "thread/start", params).await?; thread_id_of(&started) } - async fn resume(&self, client: &Client, thread_id: &str) -> Result { + async fn resume(&self, client: &Client, thread_id: &str, persona: &Persona) -> Result { // Dynamic tools live with the thread and survive a resume; the instructions are sent again so a change - // (a new Character, later) takes effect. - let mut params = self.thread_params(); + // (a new Character) takes effect. + let mut params = self.thread_params(persona); params["threadId"] = json!(thread_id); let resumed = request(client, "thread/resume", params).await?; thread_id_of(&resumed) } - fn thread_params(&self) -> Value { + fn thread_params(&self, persona: &Persona) -> Value { json!({ "cwd": self.dir, "sandbox": "read-only", "approvalPolicy": "on-request", - "developerInstructions": instructions(None, memory_summary(&self.user_codex_home).as_deref(), Brain::Codex), + "developerInstructions": instructions(persona, memory_summary(&self.user_codex_home).as_deref(), Brain::Codex), }) } async fn run_turn( &self, session: &mut Session, + note: Option<&str>, text: &str, on_reply: &mut (dyn FnMut(&str) + Send), ) -> Result { @@ -706,12 +771,10 @@ impl Resident { while let Ok(message) = session.incoming.try_recv() { self.handle(&session.client, message, &mut |_| {}).await; } - let started = request( - &session.client, - "turn/start", - json!({"threadId": session.thread_id, "input": [{"type": "text", "text": text}]}), - ) - .await?; + let input: Vec = + note.into_iter().chain([text]).map(|text| json!({"type": "text", "text": text})).collect(); + let started = + request(&session.client, "turn/start", json!({"threadId": session.thread_id, "input": input})).await?; let turn_id = started.pointer("/turn/id").and_then(Value::as_str).unwrap_or_default().to_owned(); let deadline = tokio::time::Instant::now() + TURN_TIMEOUT; loop { @@ -867,12 +930,18 @@ pub(crate) fn tools() -> Value { ]) } -/// The developer instructions. `persona` is the seam for the Character the user picked (CONTEXT.md, Persona); -/// until Characters carry one, the buddy speaks plainly. `memories` is the user's Codex memory summary, if any. -/// `brain` says where today's date is: in Codex's environment context, or in the context block the daemon puts -/// before each voice command for Claude Code. -pub(crate) fn instructions(persona: Option<&str>, memories: Option<&str>, brain: Brain) -> String { - let persona = persona.unwrap_or("You are friendly, calm and brief."); +/// The developer instructions: the buddy as the Character it wears (CONTEXT.md, Persona), in that Character's one +/// language and with the user's form of address. `memories` is the user's Codex memory summary, if any. `brain` +/// says where today's date is: in Codex's environment context, or in the context block the daemon puts before each +/// voice command for Claude Code. +pub(crate) fn instructions(persona: &Persona, memories: Option<&str>, brain: Brain) -> String { + let Persona { name, text, language, address, .. } = persona; + let address = address.map_or_else(String::new, |words| { + format!( + " The user likes to be called \"{words}\": call them that now and then, naturally, as your lines do, not \ + in every reply." + ) + }); let memories = memories.map_or_else(String::new, |summary| { format!( "\n\nWhat Codex remembers about the user from their past sessions, in summary. For more, call \ @@ -885,11 +954,15 @@ pub(crate) fn instructions(persona: Option<&str>, memories: Option<&str>, brain: }; format!( "You are the buddy of VibeBuddy: a small desk box that watches the user's coding Agents (Codex, Claude Code, \ - OpenCode, GitHub Copilot CLI, Pi, GitHub Actions) and speaks up for them. {persona}\n\ + OpenCode, GitHub Copilot CLI, Pi, GitHub Actions) and speaks up for them. Right now you are {name}, the \ + Character the user picked. This is {name}'s persona, written for the short lines {name} says when an Agent \ + needs the user or is done; speak every reply as {name}, in this persona:\n\n{text}\n\n\ + You speak {language} only. The user may talk to you in any language and you understand it, but you always \ + reply in {language}: a question asked in another language still gets its answer in {language}, never in the \ + language it was asked in, whatever earlier replies or the memories below say about language.{address}\n\ Every user message is one voice command, transcribed from speech. Answer only by calling the `speak` tool, \ with one or two short sentences meant to be heard, not read: no Markdown, lists, code, paths or URLs. \ - Reply in the language the user spoke. Do not write any other answer: once you have called `speak`, end your \ - turn without writing anything else.\n\ + Do not write any other answer: once you have called `speak`, end your turn without writing anything else.\n\ To know what the Agents are doing, have just finished or did today, call `list_activities`; never guess. It \ has the live tasks, the ones that ended lately with how and how long ago (`recently_ended`), and today's \ counts (`today`). Titles there are short upper-case task-card names; say them naturally.\n\ @@ -903,6 +976,16 @@ pub(crate) fn instructions(persona: Option<&str>, memories: Option<&str>, brain: ) } +/// A context line put before the next voice command once the Character has changed, so the session takes up the +/// new voice rather than carrying on in the old one's. The history leaves it out, as it does every context block. +pub(crate) fn change_note(persona: &Persona) -> String { + let Persona { name, language, .. } = persona; + format!( + "{CONTEXT_PREFIX}The user just switched you to another Character: from now on you are {name}. Speak only as \ + {name}, in {language}, whatever your earlier replies in this conversation sounded like." + ) +} + fn thread_id_of(response: &Value) -> Result { response .pointer("/thread/id") @@ -1157,6 +1240,7 @@ fn exchange_of(turn: &Value) -> Option { .filter(|item| item["type"] == "userMessage") .flat_map(|item| item["content"].as_array().cloned().unwrap_or_default()) .filter_map(|input| input["text"].as_str().map(str::to_owned)) + .filter(|text| !text.starts_with(CONTEXT_PREFIX)) .collect::>() .join(" "); let replies = items @@ -1323,6 +1407,15 @@ pub(crate) mod tests { resident } + /// The persona with no Character pack: the robot's. + pub(crate) fn robot() -> Persona { + Persona::of(&Wearing::default()) + } + + pub(crate) fn wearing(character: &str) -> Wearing { + Wearing { character: Some(character.to_owned()), address: None } + } + pub(crate) fn logged_in() -> Shared { Arc::new(std::sync::Mutex::new(Script { logged_in: true, ..Script::default() })) } @@ -1342,7 +1435,7 @@ pub(crate) mod tests { let script = logged_in(); let resident = resident(&dir, &script, Arc::default()); - let answer = resident.say("hello").await.unwrap(); + let answer = resident.say("hello", &Wearing::default()).await.unwrap(); assert_eq!(answer.thread_id, "new-thread"); assert_eq!(methods(&script), ["initialize", "initialized", "account/read", "thread/start", "turn/start"]); let received = script.lock().unwrap().received.clone(); @@ -1372,10 +1465,10 @@ pub(crate) mod tests { #[tokio::test] async fn a_later_start_resumes_the_saved_thread_and_a_lost_one_is_replaced() { let dir = temp_dir("resume"); - let saved = Saved { thread_id: Some("old-thread".to_owned()), codex_version: None }; + let saved = Saved { thread_id: Some("old-thread".to_owned()), codex_version: None, persona: None }; save(&dir.join("resident.json"), &saved).unwrap(); let script = logged_in(); - let answer = resident(&dir, &script, Arc::default()).say("hi").await.unwrap(); + let answer = resident(&dir, &script, Arc::default()).say("hi", &Wearing::default()).await.unwrap(); assert_eq!(answer.thread_id, "old-thread"); assert_eq!(methods(&script), ["initialize", "initialized", "account/read", "thread/resume", "turn/start"]); let resume = script.lock().unwrap().received[3]["params"].clone(); @@ -1385,7 +1478,7 @@ pub(crate) mod tests { let script = logged_in(); script.lock().unwrap().lose_thread = true; - let answer = resident(&dir, &script, Arc::default()).say("hi").await.unwrap(); + let answer = resident(&dir, &script, Arc::default()).say("hi", &Wearing::default()).await.unwrap(); assert_eq!(answer.thread_id, "new-thread"); assert_eq!(methods(&script)[3..5], ["thread/resume", "thread/start"]); assert_eq!(load(&dir.join("resident.json")).thread_id.as_deref(), Some("new-thread")); @@ -1395,11 +1488,11 @@ pub(crate) mod tests { #[tokio::test] async fn a_thread_open_elsewhere_fails_the_command_and_is_never_replaced() { let dir = temp_dir("busy"); - let saved = Saved { thread_id: Some("old-thread".to_owned()), codex_version: None }; + let saved = Saved { thread_id: Some("old-thread".to_owned()), codex_version: None, persona: None }; save(&dir.join("resident.json"), &saved).unwrap(); let script = logged_in(); script.lock().unwrap().busy_thread = true; - assert!(resident(&dir, &script, Arc::default()).say("hi").await.is_err()); + assert!(resident(&dir, &script, Arc::default()).say("hi", &Wearing::default()).await.is_err()); assert!(!methods(&script).contains(&"thread/start".to_owned()), "{:?}", methods(&script)); assert_eq!(load(&dir.join("resident.json")).thread_id.as_deref(), Some("old-thread")); let _ = std::fs::remove_dir_all(dir); @@ -1409,7 +1502,7 @@ pub(crate) mod tests { async fn without_a_login_nothing_starts_and_no_login_flow_is_begun() { let dir = temp_dir("login"); let script = Arc::new(std::sync::Mutex::new(Script::default())); - let error = resident(&dir, &script, Arc::default()).say("hi").await.unwrap_err(); + let error = resident(&dir, &script, Arc::default()).say("hi", &Wearing::default()).await.unwrap_err(); assert_eq!(error, ResidentError::NotLoggedIn); assert_eq!(methods(&script), ["initialize", "initialized", "account/read"]); let _ = std::fs::remove_dir_all(dir); @@ -1432,7 +1525,7 @@ pub(crate) mod tests { tool_call("speak", json!({"text": ""})), tool_call("run_shell", json!({"command": "ls"})), ]; - let answer = resident(&dir, &script, Arc::new(Mutex::new(tracker))).say("what's up?").await.unwrap(); + let answer = resident(&dir, &script, Arc::new(Mutex::new(tracker))).say("what's up?", &Wearing::default()).await.unwrap(); assert_eq!(answer.replies, ["One task is running."]); let answers = script.lock().unwrap().answers.clone(); @@ -1467,7 +1560,7 @@ pub(crate) mod tests { let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); let turn = tokio::spawn({ let resident = resident.clone(); - async move { resident.say_streaming("hi", &mut |reply| sender.send(reply.to_owned()).unwrap()).await } + async move { resident.say_streaming("hi", &Wearing::default(), &mut |reply| sender.send(reply.to_owned()).unwrap()).await } }); assert_eq!(receiver.recv().await.as_deref(), Some("On it.")); assert!(!turn.is_finished(), "the turn is still running"); @@ -1491,7 +1584,7 @@ pub(crate) mod tests { ] .map(|method| (method.to_owned(), json!({}))) .to_vec(); - resident(&dir, &script, Arc::default()).say("delete everything").await.unwrap(); + resident(&dir, &script, Arc::default()).say("delete everything", &Wearing::default()).await.unwrap(); let answers = script.lock().unwrap().answers.clone(); assert_eq!(answers[0]["result"], json!({"decision": "decline"})); assert_eq!(answers[1]["result"], json!({"decision": "decline"})); @@ -1509,8 +1602,8 @@ pub(crate) mod tests { let script = logged_in(); script.lock().unwrap().die_on_turn = Some(1); let resident = resident(&dir, &script, Arc::default()); - assert!(matches!(resident.say("one").await, Err(ResidentError::Rpc(_)))); - let answer = resident.say("two").await.unwrap(); + assert!(matches!(resident.say("one", &Wearing::default()).await, Err(ResidentError::Rpc(_)))); + let answer = resident.say("two", &Wearing::default()).await.unwrap(); assert_eq!(answer.thread_id, "new-thread"); assert_eq!(script.lock().unwrap().starts, 2); // The second start resumes the thread the first one made. @@ -1523,7 +1616,8 @@ pub(crate) mod tests { let dir = temp_dir("queue"); let script = logged_in(); let resident = Arc::new(resident(&dir, &script, Arc::default())); - let (first, second) = tokio::join!(resident.say("one"), resident.say("two")); + let robot = Wearing::default(); + let (first, second) = tokio::join!(resident.say("one", &robot), resident.say("two", &robot)); assert!(first.is_ok() && second.is_ok()); let turns: Vec = script .lock() @@ -1544,7 +1638,7 @@ pub(crate) mod tests { let script = logged_in(); let launch = script.clone(); let resident = Resident::new(false, &dir, Launcher::Fake(Box::new(move || fake_server(launch.clone()))), Arc::default()); - assert_eq!(resident.say("hi").await.unwrap_err(), ResidentError::Disabled); + assert_eq!(resident.say("hi", &Wearing::default()).await.unwrap_err(), ResidentError::Disabled); assert_eq!(script.lock().unwrap().starts, 0); let _ = std::fs::remove_dir_all(dir); } @@ -1555,7 +1649,7 @@ pub(crate) mod tests { let dir = temp_dir("home"); std::fs::create_dir_all(dir.join("user-codex")).unwrap(); std::fs::write(dir.join("user-codex/auth.json"), "{}").unwrap(); - resident(&dir, &logged_in(), Arc::default()).say("hi").await.unwrap(); + resident(&dir, &logged_in(), Arc::default()).say("hi", &Wearing::default()).await.unwrap(); let home = dir.join(CODEX_HOME_DIR); assert_eq!(std::fs::metadata(&home).unwrap().permissions().mode() & 0o777, 0o700); assert_eq!(std::fs::read_link(home.join("auth.json")).unwrap(), dir.join("user-codex/auth.json")); @@ -1611,7 +1705,7 @@ pub(crate) mod tests { tool_call("search_memories", json!({"query": "BISCUIT"})), tool_call("search_memories", json!({"query": "zebra"})), ]; - resident(&dir, &script, Arc::default()).say("what's my cat called?").await.unwrap(); + resident(&dir, &script, Arc::default()).say("what's my cat called?", &Wearing::default()).await.unwrap(); let start = script.lock().unwrap().received[3]["params"].clone(); let instructions = start["developerInstructions"].as_str().unwrap(); @@ -1644,7 +1738,7 @@ pub(crate) mod tests { #[test] fn without_memories_the_instructions_say_nothing_of_them_and_a_long_summary_is_cut() { - assert!(!instructions(None, None, Brain::Codex).contains("memory")); + assert!(!instructions(&robot(), None, Brain::Codex).contains("memory")); let dir = temp_dir("summary"); std::fs::create_dir_all(dir.join("memories")).unwrap(); assert_eq!(memory_summary(&dir), None); @@ -1654,6 +1748,91 @@ pub(crate) mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn the_instructions_make_the_buddy_the_character_in_its_one_language() { + let ada = instructions(&Persona::of(&wearing("ada")), None, Brain::Codex); + assert!(ada.contains("Right now you are Ada") && ada.contains("Londoner"), "{ada}"); + assert!(ada.contains("always reply in English")); + assert!(!ada.contains("in the language the user spoke")); + let xiaohe = instructions(&Persona::of(&wearing("wanwanxiaohe")), None, Brain::Claude); + assert!(xiaohe.contains("Right now you are Xiaohe (小何)") && xiaohe.contains("Taiwan"), "{xiaohe}"); + assert!(xiaohe.contains("always reply in Chinese")); + let robot = instructions(&robot(), None, Brain::Codex); + assert!(robot.contains("Right now you are VibeBuddy") && robot.contains("always reply in English")); + // Every rule is still there, whoever the buddy is. + for text in [&ada, &xiaohe, &robot] { + assert!(text.contains("Answer only by calling the `speak` tool")); + assert!(text.contains("call `list_activities`; never guess")); + assert!(text.contains("Never state a fact you have not seen")); + assert!(text.contains("You do no work yourself")); + } + assert!(!ada.contains("likes to be called"), "no form of address picked"); + let boss = Wearing { character: Some("ada".to_owned()), address: Some("boss".to_owned()) }; + assert!(instructions(&Persona::of(&boss), None, Brain::Codex).contains("likes to be called \"boss\"")); + let laoban = Wearing { character: Some("wanwanxiaohe".to_owned()), address: Some("laoban".to_owned()) }; + assert!(instructions(&Persona::of(&laoban), None, Brain::Codex).contains("likes to be called \"老板\"")); + } + + #[tokio::test] + async fn a_new_character_resumes_the_same_thread_with_its_persona_and_a_note() { + let dir = temp_dir("character"); + let script = logged_in(); + let resident = resident(&dir, &script, Arc::default()); + let received = |method: &str| -> Vec { + let script = script.lock().unwrap(); + script.received.iter().filter(|message| message["method"] == method).map(|message| message["params"].clone()).collect() + }; + let texts = |input: &Value| -> Vec { + input["input"].as_array().unwrap().iter().map(|item| item["text"].as_str().unwrap().to_owned()).collect() + }; + + resident.say("hi", &wearing("ada")).await.unwrap(); + resident.say("again", &wearing("ada")).await.unwrap(); + assert_eq!(script.lock().unwrap().starts, 1, "the same Character keeps the running thread"); + let start = &received("thread/start")[0]; + assert!(start["developerInstructions"].as_str().unwrap().contains("Right now you are Ada")); + + resident.say("你好", &wearing("wanwanxiaohe")).await.unwrap(); + assert_eq!(script.lock().unwrap().starts, 2, "a new Character starts the app-server again"); + let resume = &received("thread/resume")[0]; + assert_eq!(resume["threadId"], "new-thread", "the same thread, never a fork"); + let instructions = resume["developerInstructions"].as_str().unwrap(); + assert!(instructions.contains("Right now you are Xiaohe (小何)") && instructions.contains("always reply in Chinese")); + assert_eq!(received("thread/start").len(), 1, "never a new thread"); + let turns = received("turn/start"); + assert_eq!(texts(&turns[0]), ["hi"], "the first command has nothing to switch from"); + assert_eq!(texts(&turns[1]), ["again"]); + let switched = texts(&turns[2]); + assert!(switched[0].starts_with(CONTEXT_PREFIX) && switched[0].contains("from now on you are Xiaohe (小何)"), "{switched:?}"); + assert_eq!(switched[1], "你好"); + + resident.say("还在吗", &wearing("wanwanxiaohe")).await.unwrap(); + assert_eq!(script.lock().unwrap().starts, 2); + assert_eq!(texts(&received("turn/start")[3]), ["还在吗"], "the note goes once"); + + // Whom the thread spoke as is kept, so a change while the daemon was down is noticed too. + let restarted = self::resident(&dir, &script, Arc::default()); + restarted.say("hello", &wearing("ada")).await.unwrap(); + let after = texts(&received("turn/start")[4]); + assert!(after[0].contains("from now on you are Ada"), "{after:?}"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn the_history_leaves_out_context_notes() { + let turn = json!({"items": [ + {"type": "userMessage", "content": [ + {"type": "text", "text": "The user just switched you to another Character: from now on you are Ada."}, + {"type": "text", "text": "what's up?"}, + ]}, + {"type": "dynamicToolCall", "tool": "speak", "arguments": {"text": "All quiet."}}, + ]}); + assert_eq!( + exchange_of(&turn), + Some(Exchange { said: "what's up?".to_owned(), replies: vec!["All quiet.".to_owned()] }) + ); + } + #[tokio::test] async fn the_history_is_read_from_the_thread_oldest_first() { let dir = temp_dir("history"); @@ -1661,7 +1840,7 @@ pub(crate) mod tests { let resident = resident(&dir, &script, Arc::default()); assert_eq!(resident.history(5).await.unwrap(), [], "no thread yet, so nothing is started"); assert_eq!(script.lock().unwrap().starts, 0); - resident.say("hi").await.unwrap(); + resident.say("hi", &Wearing::default()).await.unwrap(); let history = resident.history(2).await.unwrap(); assert_eq!( history, @@ -1700,13 +1879,13 @@ pub(crate) mod tests { // A start that finds the user logged in is the newer word, and so is one that finds them logged out. script.lock().unwrap().logged_in = true; - resident.say("hi").await.unwrap(); + resident.say("hi", &Wearing::default()).await.unwrap(); assert!(resident.ready()); let script = Arc::new(std::sync::Mutex::new(Script::default())); let logged_out = super::tests::resident(&dir, &script, Arc::default()); assert!(logged_out.check_readiness().await.ready()); let watcher = logged_out.watch_ready(); - assert_eq!(logged_out.say("hi").await.unwrap_err(), ResidentError::NotLoggedIn); + assert_eq!(logged_out.say("hi", &Wearing::default()).await.unwrap_err(), ResidentError::NotLoggedIn); assert_eq!(logged_out.readiness().problem, Some(Problem::CodexLoggedOut)); assert!(watcher.has_changed().unwrap()); @@ -1718,14 +1897,14 @@ pub(crate) mod tests { #[tokio::test] async fn the_revision_moves_when_a_turn_ends_or_the_thread_changes_but_not_on_reads() { let dir = temp_dir("revision"); - let saved = Saved { thread_id: Some("old-thread".to_owned()), codex_version: None }; + let saved = Saved { thread_id: Some("old-thread".to_owned()), codex_version: None, persona: None }; save(&dir.join("resident.json"), &saved).unwrap(); let script = logged_in(); let first = resident(&dir, &script, Arc::default()); assert_eq!(first.revision(), 0); first.history(5).await.unwrap(); assert_eq!(first.revision(), 0, "resuming the same thread and reading it changes nothing"); - first.say("hi").await.unwrap(); + first.say("hi", &Wearing::default()).await.unwrap(); assert_eq!(first.revision(), 1); first.history(5).await.unwrap(); assert_eq!(first.revision(), 1); @@ -1735,7 +1914,7 @@ pub(crate) mod tests { script.lock().unwrap().lose_thread = true; script.lock().unwrap().die_on_turn = Some(1); let second = resident(&dir, &script, Arc::default()); - assert!(second.say("one").await.is_err()); + assert!(second.say("one", &Wearing::default()).await.is_err()); assert_eq!(second.revision(), 2, "the new thread and the failed turn"); let _ = std::fs::remove_dir_all(dir); } @@ -1827,7 +2006,7 @@ pub(crate) mod tests { assert_eq!(stored, json!({"brain": "auto", "anthropic_api_key": "sk-ant-secret"})); // The voice command goes to Claude Code, with the stored key, and its session is the one reported. - let answer = resident.say("hi").await.unwrap(); + let answer = resident.say("hi", &Wearing::default()).await.unwrap(); assert_eq!(Some(answer.thread_id.clone()), resident.thread_id()); let launches = claude.lock().unwrap().launches.clone(); assert!(launches[0].1.contains(&("ANTHROPIC_API_KEY".to_owned(), "sk-ant-secret".to_owned()))); @@ -1845,7 +2024,7 @@ pub(crate) mod tests { resident.set_anthropic_key(None).await.unwrap(); assert_eq!(resident.readiness().problem, Some(Problem::AnthropicKeyMissing)); assert!(!resident.ready()); - assert_eq!(resident.say("hi").await.unwrap_err(), ResidentError::NoAnthropicKey); + assert_eq!(resident.say("hi", &Wearing::default()).await.unwrap_err(), ResidentError::NoAnthropicKey); let stored: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap(); assert_eq!(stored, json!({"brain": "claude"}), "the key is gone from the file"); @@ -1865,7 +2044,7 @@ pub(crate) mod tests { resident.set_anthropic_key(Some("key".to_owned())).await.unwrap(); resident.set_brain(BrainChoice::Claude).await.unwrap(); assert_eq!(resident.history(5).await.unwrap(), [], "no session yet"); - resident.say("hi").await.unwrap(); + resident.say("hi", &Wearing::default()).await.unwrap(); let session = resident.thread_id().unwrap(); assert!(resident.owns_claude_hook(&session, None)); assert!(resident.owns_claude_hook("other", Some(&dir.join("resident").display().to_string()))); @@ -1877,7 +2056,7 @@ pub(crate) mod tests { #[test] fn hooks_from_the_resident_thread_or_its_directory_are_recognised() { let dir = temp_dir("hooks"); - let saved = Saved { thread_id: Some("resident-thread".to_owned()), codex_version: None }; + let saved = Saved { thread_id: Some("resident-thread".to_owned()), codex_version: None, persona: None }; save(&dir.join("resident.json"), &saved).unwrap(); let resident = Resident::new(false, &dir, Launcher::Codex, Arc::default()); let cwd = dir.join("resident").display().to_string(); diff --git a/daemon/src/resident_claude.rs b/daemon/src/resident_claude.rs index e5dbcb45..02d45b77 100644 --- a/daemon/src/resident_claude.rs +++ b/daemon/src/resident_claude.rs @@ -35,7 +35,8 @@ use tokio::sync::{Mutex, OnceCell, mpsc}; use tracing::{info, warn}; use crate::activity::ActivityTracker; -use crate::resident::{Exchange, Pipes, ResidentError, run_tool, tools}; +use crate::persona::Persona; +use crate::resident::{Exchange, Pipes, ResidentError, change_note, run_tool, tools}; /// The model the resident session runs on: a voice command waits on the answer. pub const MODEL: &str = "claude-haiku-4-5"; @@ -63,7 +64,7 @@ const TURN_TIMEOUT: Duration = Duration::from_secs(120); /// What the `system/init` line must report as the credential in use. const API_KEY_SOURCE: &str = "ANTHROPIC_API_KEY"; /// Starts a voice command's context block, which carries the local time; the history leaves it out. -const CONTEXT_PREFIX: &str = ""; +pub(crate) const CONTEXT_PREFIX: &str = ""; /// How `claude` is reached: the real binary, or an in-memory fake in tests. pub enum Launcher { @@ -83,6 +84,9 @@ struct Process { lines: Lines>>, /// The key it was started with: a new key needs a new process. key: String, + /// The instructions it was started with: `--system-prompt` is per process, so new ones (a new Character) need + /// a new process on the same session. + instructions: String, _child: Option, } @@ -91,6 +95,9 @@ struct Saved { session_id: Option, /// The Claude Code version the session was last started with, for diagnosing a change in the stream. claude_version: Option, + /// The Character whose persona the session last answered in. + #[serde(default)] + persona: Option, } /// What the daemon's MCP server shares with the turn in progress. @@ -114,6 +121,8 @@ pub struct ClaudeBrain { process: Mutex>, /// Read synchronously by the hook filter. session_id: std::sync::Mutex>, + /// The Character whose persona the session last answered in (kept in the state file). + spoke_as: std::sync::Mutex>, } impl ClaudeBrain { @@ -132,6 +141,7 @@ impl ClaudeBrain { mcp_url: OnceCell::new(), process: Mutex::new(None), session_id: std::sync::Mutex::new(saved.session_id), + spoke_as: std::sync::Mutex::new(saved.persona), } } @@ -161,29 +171,49 @@ impl ClaudeBrain { } } - /// Hands one voice command to the session; each spoken reply goes to `on_reply` when `speak` is called. - /// Returns the session id once the turn is over. + /// Hands one voice command to the session, answered in `instructions`, which make the buddy `persona`; each + /// spoken reply goes to `on_reply` when `speak` is called. Returns the session id once the turn is over. pub async fn say_streaming( &self, key: &str, instructions: &str, + persona: &Persona, text: &str, on_reply: &mut (dyn FnMut(&str) + Send), ) -> Result { let mut slot = self.process.lock().await; let process = self.running(&mut slot, key, instructions).await?; + let spoke_as = self.spoke_as.lock().expect("spoke as lock").clone(); + let changed = spoke_as.as_deref() != Some(persona.id); + let note = (changed && spoke_as.is_some()).then(|| change_note(persona)); let (sender, spoken) = mpsc::unbounded_channel(); *self.mcp.speak.lock().expect("speak lock") = Some(sender); - let result = run_turn(process, spoken, text, on_reply).await; + let result = run_turn(process, spoken, note.as_deref(), text, on_reply).await; *self.mcp.speak.lock().expect("speak lock") = None; match &result { // The process is still there and the session intact: an API error, a refused key. Err(ResidentError::TurnEnded(_)) | Ok(()) => {} Err(_) => *slot = None, } + if result.is_ok() && changed { + self.remember_persona(persona.id); + } result.map(|()| self.session_id().unwrap_or_default()) } + /// Keeps the Character the session now answers as, so a change is noticed after a restart too. + fn remember_persona(&self, id: &str) { + *self.spoke_as.lock().expect("spoke as lock") = Some(id.to_owned()); + let mut saved: Saved = std::fs::read_to_string(&self.state_file) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_default(); + saved.persona = Some(id.to_owned()); + if let Err(error) = write_private(&self.state_file, &serde_json::to_string_pretty(&saved).unwrap_or_default()) { + warn!(%error, "failed to save the resident Claude session's Character"); + } + } + /// The last `limit` voice commands and their spoken replies, oldest first, read from the session's transcript /// (the daemon keeps no record of its own). Empty while there is no session yet. pub fn history(&self, limit: usize) -> Vec { @@ -211,6 +241,10 @@ impl ClaudeBrain { info!("the Anthropic API key changed; starting the resident Claude session again"); *slot = None; } + if slot.as_ref().is_some_and(|process| process.instructions != instructions) { + info!("the instructions changed (a new Character); resuming the resident Claude session with them"); + *slot = None; + } if slot.is_none() { *slot = Some(self.start(key, instructions).await?); } @@ -248,7 +282,8 @@ impl ClaudeBrain { }; let id = session.id().to_owned(); *self.session_id.lock().expect("session id lock") = Some(id.clone()); - let saved = Saved { session_id: Some(id.clone()), claude_version: version }; + let spoke_as = self.spoke_as.lock().expect("spoke as lock").clone(); + let saved = Saved { session_id: Some(id.clone()), claude_version: version, persona: spoke_as }; if let Err(error) = write_private(&self.state_file, &serde_json::to_string_pretty(&saved).unwrap_or_default()) { warn!(%error, "failed to save the resident Claude session id"); } @@ -257,6 +292,7 @@ impl ClaudeBrain { writer: pipes.writer, lines: BufReader::new(pipes.reader).lines(), key: key.to_owned(), + instructions: instructions.to_owned(), _child: pipes.child, }) } @@ -279,10 +315,11 @@ impl Session { async fn run_turn( process: &mut Process, mut spoken: mpsc::UnboundedReceiver, + note: Option<&str>, text: &str, on_reply: &mut (dyn FnMut(&str) + Send), ) -> Result<(), ResidentError> { - let line = format!("{}\n", user_message(text, &chrono::Local::now(), local_time_zone().as_deref())); + let line = format!("{}\n", user_message(text, note, &chrono::Local::now(), local_time_zone().as_deref())); let written = async { process.writer.write_all(line.as_bytes()).await?; process.writer.flush().await @@ -346,14 +383,13 @@ fn local_time_zone() -> Option { /// A voice command as a stream-json user message: the local time and time zone first, as context, then what was /// said. The time zone stands in for where the user is (Codex gets it from its own environment context), so a -/// question about the weather or the news has a place. -fn user_message(text: &str, now: &chrono::DateTime, time_zone: Option<&str>) -> Value { +/// question about the weather or the news has a place. A `note` (the Character changed) goes with the context. +fn user_message(text: &str, note: Option<&str>, now: &chrono::DateTime, time_zone: Option<&str>) -> Value { let zone = time_zone.map(|zone| format!(", time zone {zone}")).unwrap_or_default(); let context = format!("{CONTEXT_PREFIX}The user's local time: {}{zone}", now.format("%A %Y-%m-%d %H:%M (UTC%:z)")); - json!({"type": "user", "message": {"role": "user", "content": [ - {"type": "text", "text": context}, - {"type": "text", "text": text}, - ]}}) + let content: Vec = + [context.as_str()].into_iter().chain(note).chain([text]).map(|text| json!({"type": "text", "text": text})).collect(); + json!({"type": "user", "message": {"role": "user", "content": content}}) } /// The arguments for `claude`; see the module docs for why each is there. @@ -679,16 +715,17 @@ pub(crate) mod tests { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use super::*; + use crate::resident::tests::robot; #[test] fn a_voice_command_carries_the_local_time_and_time_zone_as_context() { let now = chrono::Local::now(); - let message = user_message("今天天气怎么样", &now, Some("Asia/Singapore")); + let message = user_message("今天天气怎么样", None, &now, Some("Asia/Singapore")); let content = message["message"]["content"].as_array().unwrap(); let context = content[0]["text"].as_str().unwrap(); assert!(context.starts_with(CONTEXT_PREFIX) && context.contains("time zone Asia/Singapore"), "{context}"); assert_eq!(content[1]["text"], "今天天气怎么样"); - let without = user_message("hi", &now, None); + let without = user_message("hi", None, &now, None); assert!(!without["message"]["content"][0]["text"].as_str().unwrap().contains("time zone")); } use crate::activity::{ActivityId, ActivityStatus}; @@ -812,7 +849,7 @@ pub(crate) mod tests { async fn say(brain: &ClaudeBrain, key: &str, text: &str) -> Result, ResidentError> { let mut replies = Vec::new(); - brain.say_streaming(key, "You are the buddy of VibeBuddy.", text, &mut |reply| replies.push(reply.to_owned())).await?; + brain.say_streaming(key, "You are the buddy of VibeBuddy.", &robot(), text, &mut |reply| replies.push(reply.to_owned())).await?; Ok(replies) } @@ -900,7 +937,7 @@ pub(crate) mod tests { let turn = tokio::spawn({ let brain = brain.clone(); async move { - brain.say_streaming("key", "instructions", "hi", &mut |reply| sender.send(reply.to_owned()).unwrap()).await + brain.say_streaming("key", "instructions", &robot(), "hi", &mut |reply| sender.send(reply.to_owned()).unwrap()).await } }); assert_eq!(receiver.recv().await.as_deref(), Some("On it.")); @@ -1001,6 +1038,49 @@ pub(crate) mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn a_new_character_resumes_the_same_session_with_its_instructions_and_a_note() { + let dir = temp_dir("claude-character"); + let script = script(); + let brain = brain(&dir, &script, Arc::default()); + async fn say_as(brain: &ClaudeBrain, character: &str, text: &str) { + let persona = Persona::of(&crate::resident::tests::wearing(character)); + let instructions = crate::resident::instructions(&persona, None, crate::resident::Brain::Claude); + brain.say_streaming("key", &instructions, &persona, text, &mut |_| {}).await.unwrap(); + } + let texts = |script: &Shared, index: usize| -> Vec { + let message = script.lock().unwrap().received[index].clone(); + let content = message["message"]["content"].as_array().unwrap().clone(); + content.iter().skip(1).map(|block| block["text"].as_str().unwrap().to_owned()).collect() + }; + + say_as(&brain, "ada", "hi").await; + let session = argument(&launches(&script)[0].0, "--session-id").unwrap(); + let project = dir.join("resident-claude/projects/-state-resident"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join(format!("{session}.jsonl")), "").unwrap(); + say_as(&brain, "ada", "again").await; + assert_eq!(launches(&script).len(), 1, "the same Character keeps the process"); + assert_eq!(texts(&script, 0), ["hi"], "the first command has nothing to switch from"); + + say_as(&brain, "wanwanxiaohe", "你好").await; + let launched = launches(&script); + assert_eq!(launched.len(), 2, "--system-prompt is per process"); + assert_eq!(argument(&launched[1].0, "--resume").as_deref(), Some(session.as_str()), "the same session, never a fork"); + let prompt = argument(&launched[1].0, "--system-prompt").unwrap(); + assert!(prompt.contains("Right now you are Xiaohe (小何)") && prompt.contains("always reply in Chinese"), "{prompt}"); + let switched = texts(&script, 2); + assert!(switched[0].starts_with(CONTEXT_PREFIX) && switched[0].contains("from now on you are Xiaohe (小何)"), "{switched:?}"); + assert_eq!(switched[1], "你好"); + + say_as(&brain, "wanwanxiaohe", "还在吗").await; + assert_eq!(launches(&script).len(), 2); + assert_eq!(texts(&script, 3), ["还在吗"], "the note goes once"); + // Whom the session spoke as is kept across a restart. + assert!(std::fs::read_to_string(dir.join(STATE_FILE)).unwrap().contains("\"wanwanxiaohe\"")); + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn a_credential_other_than_the_api_key_stops_the_process() { let dir = temp_dir("claude-oauth"); diff --git a/daemon/src/synthesis.rs b/daemon/src/synthesis.rs index 34073b24..76275131 100644 --- a/daemon/src/synthesis.rs +++ b/daemon/src/synthesis.rs @@ -131,7 +131,7 @@ pub fn split(text: &str, max: usize) -> Vec<&str> { /// Each Character's voice file (`characters//voice.env`), so a spoken reply is in the very voice its lines /// were made with: `ENGINE=elevenlabs` voices are the ones in our ElevenLabs account, which the voice service uses. -const VOICE_FILES: &[(&str, &str)] = &[ +pub(crate) const VOICE_FILES: &[(&str, &str)] = &[ ("ada", include_str!("../../characters/ada/voice.env")), ("dave", include_str!("../../characters/dave/voice.env")), ("hank", include_str!("../../characters/hank/voice.env")), @@ -154,7 +154,7 @@ pub fn voice_for(character: Option<&str>) -> String { let Some(file) = character.and_then(|character| VOICE_FILES.iter().find(|(id, _)| *id == character)) else { return DEFAULT_VOICE.to_owned(); }; - let field = |key: &str| file.1.lines().find_map(|line| line.strip_prefix(key)?.strip_prefix('=')); + let field = |key: &str| voice_field(file.1, key); match (field("ENGINE"), field("VOICE")) { (Some("elevenlabs"), Some(voice)) => format!("elevenlabs:{voice}"), _ if field("GENDER") == Some("male") => STOCK_MALE.to_owned(), @@ -162,6 +162,11 @@ pub fn voice_for(character: Option<&str>) -> String { } } +/// One `KEY=value` of a `voice.env`. +pub(crate) fn voice_field<'a>(file: &'a str, key: &str) -> Option<&'a str> { + file.lines().find_map(|line| line.strip_prefix(key)?.strip_prefix('=')) +} + /// The synthesizer the environment configures, if any. pub fn from_env() -> Option> { configure(std::env::var(BASE_URL_VARIABLE).ok(), crate::relay::release()) diff --git a/daemon/src/voice.rs b/daemon/src/voice.rs index decddff0..612e6f62 100644 --- a/daemon/src/voice.rs +++ b/daemon/src/voice.rs @@ -22,6 +22,7 @@ use tokio::task::JoinHandle; use tracing::{debug, info, warn}; use vibebuddy_protocol::{Event, MAX_MESSAGE_BYTES}; +use crate::persona::Wearing; use crate::resident::Resident; use crate::serial_transport::{Transport, TransportError}; use crate::speech::Recognizer; @@ -269,8 +270,8 @@ impl Voice { } /// Takes one `VOICE` line from the box. A turn that ends starts a task, returned so tests can wait for it; - /// its replies are spoken in the voice of `character`, the Character the box wears. - pub fn handle_line(&self, line: &str, transport: &Arc, character: Option<&str>) -> Option> { + /// its replies are written in the persona and spoken in the voice of what the box `wearing`. + pub fn handle_line(&self, line: &str, transport: &Arc, wearing: &Wearing) -> Option> { let parsed = match parse_line(line) { None => return None, Some(Err(error)) => { @@ -325,8 +326,8 @@ impl Voice { if taken.as_ref().is_some_and(|taken| taken.refused) { return None; } - let speaker = self.synthesizer.clone().map(|synthesizer| (synthesizer, character.map(str::to_owned))); - return self.end(turn, samples as usize, taken, transport.clone(), speaker); + let speaker = self.synthesizer.clone().map(|synthesizer| (synthesizer, wearing.character.clone())); + return self.end(turn, samples as usize, taken, transport.clone(), speaker, wearing.clone()); } } None @@ -339,6 +340,7 @@ impl Voice { recording: Option, transport: Arc, speaker: Option<(Arc, Option)>, + wearing: Wearing, ) -> Option> { let (Some(recognizer), Some(resident)) = (self.recognizer.clone(), self.resident.clone().filter(|resident| resident.enabled())) else { @@ -362,7 +364,7 @@ impl Voice { } Some(tokio::spawn(async move { let _running = running; - run_turn(turn, audio, recognizer, resident, transport, speaker).await; + run_turn(turn, audio, recognizer, resident, transport, speaker, wearing).await; })) } } @@ -499,6 +501,7 @@ async fn run_turn( resident: Arc, transport: Arc, speaker: Option<(Arc, Option)>, + wearing: Wearing, ) { let started = Instant::now(); let seconds = audio.len() as f32 / SAMPLE_RATE as f32; @@ -525,7 +528,7 @@ async fn run_turn( let speaker = speaker.map(|(synthesizer, character)| Speaker::start(turn, synthesizer, character, transport.clone())); let mut replies = 0; let answered = resident - .say_streaming(&transcript.text, &mut |reply| { + .say_streaming(&transcript.text, &wearing, &mut |reply| { replies += 1; info!(turn, elapsed_ms = started.elapsed().as_millis() as u64, "voice reply"); send(&*transport, text_event("voice.reply", turn, reply)); @@ -794,7 +797,8 @@ pub mod tests { /// Feeds the lines; returns the task the last one started, if any. fn feed(&self, lines: &[String]) -> Option> { let transport: Arc = self.transport.clone(); - lines.iter().filter_map(|line| self.voice.handle_line(line, &transport, self.character)).last() + let wearing = Wearing { character: self.character.map(str::to_owned), address: None }; + lines.iter().filter_map(|line| self.voice.handle_line(line, &transport, &wearing)).last() } fn names(&self) -> Vec { @@ -1113,10 +1117,10 @@ pub mod tests { println!("{:.2} s of audio in {} lines", left.len() as f32 / SAMPLE_RATE as f32, lines.len()); let (end, data) = lines.split_last().unwrap(); for line in data { - assert!(voice.handle_line(line, &transport, None).is_none()); + assert!(voice.handle_line(line, &transport, &Wearing::default()).is_none()); } *timed.end.lock().unwrap() = Some(Instant::now()); - voice.handle_line(end, &transport, None).expect("the turn runs").await.unwrap(); + voice.handle_line(end, &transport, &Wearing::default()).expect("the turn runs").await.unwrap(); let events = timed.events.lock().unwrap(); let names: Vec<&str> = events.iter().map(|(_, event)| event.event.as_str()).collect(); @@ -1190,7 +1194,7 @@ pub mod tests { let transport = Arc::new(RecordingTransport::default()); let shared: Arc = transport.clone(); for line in voice_lines(1, &sine(12_000)) { - voice.handle_line(&line, &shared, None); + voice.handle_line(&line, &shared, &Wearing::default()); } assert_eq!(transport.events()[0].extra["reason"], "unavailable"); let _ = std::fs::remove_dir_all(dir); diff --git a/desktop/src/client.rs b/desktop/src/client.rs index bb622aef..455301e4 100644 --- a/desktop/src/client.rs +++ b/desktop/src/client.rs @@ -72,9 +72,17 @@ pub async fn set_volume(level: u8, preview: bool) -> Result<(), String> { } /// The daemon accepts the pack and writes it in the background; progress arrives in the status stream. -pub async fn write_voice_pack(pack: Vec) -> Result<(), String> { +/// `lender` (the Character lending a custom one its voice, persona and lines) and `address` (the form of address +/// its lines carry) aren't in the pack; the daemon keeps them for the buddy's spoken replies. +pub async fn write_voice_pack(pack: Vec, lender: Option<&str>, address: Option<&str>) -> Result<(), String> { + // Both are ids from fixed lists (`ada`, `laoban`): nothing to escape. + let query: Vec = [("lender", lender), ("address", address)] + .into_iter() + .filter_map(|(name, value)| Some(format!("{name}={}", value?))) + .collect(); + let query = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) }; let request = reqwest::Client::new() - .post(format!("{BASE}/v1/device/voice-pack")) + .post(format!("{BASE}/v1/device/voice-pack{query}")) .header("content-type", "application/octet-stream") .body(pack); accepted(request).await diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 3c2c735d..cf5a446f 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -688,7 +688,7 @@ impl App { let form = assets::language_of(id).and_then(|language| self.characters.address(language)).map(str::to_owned); let write = async move { let pack = assets::read_character(id, form.as_deref()).await?; - client::write_voice_pack(pack.build().ok_or("the Character pack doesn't fit")?).await + client::write_voice_pack(pack.build().ok_or("the Character pack doesn't fit")?, None, form.as_deref()).await }; return Task::perform(write, Message::Done); } @@ -720,7 +720,7 @@ impl App { Message::UseRobot => { self.characters.robot_lender = None; self.characters.save(); - let write = async move { client::write_voice_pack(assets::read_pack(ROBOT).await?).await }; + let write = async move { client::write_voice_pack(assets::read_pack(ROBOT).await?, None, None).await }; return Task::perform(write, Message::Done); } Message::UseCustom => { @@ -733,7 +733,7 @@ impl App { let form = assets::language_of(lender.id).and_then(|language| self.characters.address(language)).map(str::to_owned); let write = async move { let pack = assets::read_character(lender.id, form.as_deref()).await?.with_look(look, CUSTOM); - client::write_voice_pack(pack.build().ok_or("that character can't lend its voice")?).await + client::write_voice_pack(pack.build().ok_or("that character can't lend its voice")?, Some(lender.id), form.as_deref()).await }; return Task::perform(write, Message::Done); } diff --git a/docs/resident-session.md b/docs/resident-session.md index b6d87ead..a78cdbad 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -65,7 +65,16 @@ The box is offered voice (`voice.available`, below) only while the session is on - **No made-up facts.** The instructions forbid stating anything not seen in a tool result, a search result or the memories: no weather, news, prices or other facts about now, and nothing about the user's world. For those it searches, answers only from what it found, and otherwise says plainly in one sentence that it can't find out. Before this, asked about tomorrow's weather in Singapore, it invented a forecast. Codex's environment context gives the thread today's date and the user's time zone; the instructions point at it for "today" and "tomorrow", after luna once searched for the wrong day's news. - **Declining.** Every approval or question the session raises is declined: command and file-change approvals (`decline`, or `denied` on the legacy methods), permission requests (nothing granted), MCP elicitations (`decline`) and user-input requests (no answers). `currentTime/read` is answered; anything else gets a JSON-RPC "method not found". - **Turns.** `turn/start` with the text, then the daemon answers tool calls until `turn/completed` for that turn. One turn at a time: a command that arrives during another waits for it, in order, rather than being rejected, since two quick sentences are both meant to be answered. A turn that takes over two minutes is interrupted (`turn/interrupt`). -- **Persona.** `instructions()` in `resident.rs` takes the Character's persona; until Characters carry one, it is `None` and the buddy speaks plainly. +- **Persona.** The instructions make the buddy the Character it wears, in that Character's language (below). The app-server takes developer instructions only at `thread/start` and `thread/resume` (`turn/start` has no field for them in codex-cli 0.162.0), so a new Character stops the app-server and resumes the same thread with the new ones. + +## Persona and language + +The resident session speaks as the buddy, as the Character it wears (ADR-0017): its instructions carry that Character's persona, the very `characters//persona.md` its lines were written in (embedded at build time, in `daemon/src/persona.rs`), and its one language, `LANGUAGE` in its `voice.env`. The buddy understands whatever language the user speaks, but always replies in its Character's: asked in Chinese, Ada answers in English and Xiaohe in Chinese. The instructions say so whatever earlier replies or the user's Codex memories say about language: one user's memories said the resident session answers in Chinese, and with them a first draft of the rule still had Ada answering a Chinese question in Chinese. The robot, the default Character, has its own `characters/robot/persona.md` and speaks English, the language of its five built-in lines; so do no pack and a pack the daemon doesn't know. + +- **Which Character.** The one the box reports wearing (`VOICES`), else the one last written to it, as for the voice the reply is spoken in. The box reports only the pack's id, so whatever else the App knows it says when it writes a pack, as query parameters of `POST /v1/device/voice-pack`: `lender`, the preset lending a custom Character its voice, persona and lines, and `address`, the form of address its lines carry. The daemon keeps them, with the pack's id, in `character.json` in its state directory, and uses them while the box wears that pack. A custom Character answers in its lender's persona and language, and its spoken replies are in the lender's voice; one written by an App that didn't say who lends it (or by another computer) answers as the robot. +- **Form of address.** When the user picked one for the Character's language, the instructions say the buddy calls the user that now and then, naturally, as the lines do, not in every reply. +- **The rules stay.** Whoever the buddy is, it answers only through `speak` in one or two short spoken sentences, never states a fact it hasn't seen, looks up the Activities with `list_activities`, searches for anything about now, and does no work. +- **A new Character.** The next voice command after the Character changes (the box reports another one, or the App writes one) is answered in the new persona: Codex stops the app-server and resumes the same thread with the new developer instructions; Claude Code, whose `--system-prompt` is per process, is started again with `--resume` on the same session. Neither is ever forked or replaced. That command also carries a context line saying the user switched to another Character and naming it, so the session takes up the new voice rather than carrying on in the one its earlier replies were in. Like the time context, it is left out of the history. Which Character the session last answered as is kept (`persona` in `resident.json` and `resident-claude.json`), so a change while the daemon was down gets the line too. Checked on 2026-10-14 with codex-cli 0.162.0: the same Chinese question answered in English as Ada and in Chinese as Xiaohe, back and forth on one thread, the switch costing about two seconds on the first command. ## On Claude Code @@ -74,12 +83,12 @@ The box is offered voice (`voice.available`, below) only while the session is on - **Only the user's own API key.** Anthropic doesn't let third-party products use a claude.ai login: its [legal and compliance page](https://code.claude.com/docs/en/legal-and-compliance) says developers building products, including with the Agent SDK, use API key authentication, and "Anthropic does not permit third-party developers to offer Claude.ai login into their own applications, or to route requests through Free, Pro, or Max plan credentials on behalf of their users". So the Claude Code brain authenticates only with the Anthropic API key the user enters, billed to their Anthropic account, and never with a subscription or OAuth login, not even as a fallback: the child's environment loses every `CLAUDE*` and `ANTHROPIC*` variable the daemon has (an OAuth token, `ANTHROPIC_AUTH_TOKEN`, the Bedrock, Vertex and Foundry switches, profiles) and gets `ANTHROPIC_API_KEY`, which `-p` always uses when present ([authentication precedence](https://code.claude.com/docs/en/authentication#authentication-precedence)); its own config dir holds no login, and the macOS Keychain entry is keyed by the config dir. On top of that, the `system/init` line reports `apiKeySource`, and anything but `ANTHROPIC_API_KEY` stops the process and fails the command. The key is never logged, never written outside `resident-settings.json` (the MCP config and the command line don't carry it), and only reaches the child's environment. - **Its own config dir.** `CLAUDE_CONFIG_DIR` is `resident-claude/` in the daemon's state directory (mode 0700), so none of the user's Claude Code config loads: settings, CLAUDE.md, hooks, plugins, MCP servers, sessions. The session doesn't show up in the user's `claude --resume` or the Claude App. The working directory is the same `resident/` as the Codex thread's. `CLAUDE_CODE_DISABLE_AUTO_MEMORY=1` and `DISABLE_AUTOUPDATER=1` keep it from writing memory files or updating the user's Claude Code. - **One process, one session for good.** One long-lived `claude -p --input-format stream-json --output-format stream-json --verbose`; each voice command is one user message on its stdin, and the turn is over at the `result` line. The first start passes `--session-id` with a new UUID, saved in `resident-claude.json` in the state directory with the Claude Code version; every later start passes `--resume` with it, as long as its transcript (`resident-claude/projects/*/.jsonl`) is there. Only a session whose transcript is gone is replaced, with both ids in the log. A process that exits or doesn't finish a turn in two minutes is stopped, and the next command starts it again with `--resume`; an error `result` (a rejected key, an API error) fails the command and keeps the process. A new key starts a new process. The daemon starts it when it starts, as for Codex. -- **Each command** is two text blocks: a context block with the user's local date, time and UTC offset, then what was said. The history leaves the context block out. +- **Each command** is two text blocks: a context block with the user's local date, time and UTC offset, then what was said (with the context line of a new Character between them, see "Persona and language"). The history leaves the context blocks out. - **Only our tools.** `--tools WebSearch` leaves Web search as the only built-in tool; `--disallowedTools` denies Bash, Edit, Write, Read, NotebookEdit, WebFetch, Glob, Grep, Agent, Task, Skill and BashOutput by name besides, which removes them from the model's context. `speak` and `list_activities` come from the daemon's MCP server (below), with `--strict-mcp-config` so no other server loads. `--permission-mode dontAsk --permission-prompts none` with `--allowedTools WebSearch,mcp__vibebuddy__speak,mcp__vibebuddy__list_activities`: anything else that would need a permission is refused, never asked. `--disable-slash-commands` turns skills and commands off, and `--settings '{"disableAllHooks":true}'` hooks. `search_memories` isn't offered: Codex memories are Codex's. - **Why not `--bare`.** Bare mode would skip even more and never reads OAuth or the keychain, but in Claude Code 2.1.296 it gives the model only Bash, Read and Edit and drops WebSearch even when `--tools` names it (checked against a fake Messages API: the request's tool list was empty with `--bare --tools WebSearch`, and `WebSearch` without `--bare`). Without web search the buddy couldn't answer questions about now, so the config dir, the cleaned environment, the init check and the flags do it instead. - **The MCP server.** Claude Code reaches `speak` and `list_activities` over MCP's Streamable HTTP transport (JSON responses, no server-sent stream) at `http://127.0.0.1:/mcp`, a loopback port of the daemon's own picked at start, behind a random 256-bit bearer token made when the daemon starts. The URL and token are in `resident-claude/mcp.json` (0600), passed with `--mcp-config`. Chosen over a `vibebuddyd` stdio subcommand: `speak` must reach the turn in progress in the daemon at once, and a stdio child would have had to call back into the daemon over HTTP anyway; in-process there is no second process, no binary path to find inside the App bundle, and the daemon's own API (no token, on `VIBEBUDDY_BIND`) stays as it is. Without the token the server answers 401. - **Spoken replies stream.** A `speak` call is handed to `voice.rs` the moment the MCP request arrives, as on Codex; anything said just before the `result` line is still handed on before the turn ends. -- **The instructions** are the Codex session's (no made-up facts, short spoken replies, `list_activities` for the Agents), with the date taken from the context block, passed as `--system-prompt`: they replace Claude Code's coding-assistant prompt, since the buddy isn't one, and a short prompt is a faster turn. `--system-prompt-snapshot off` has them read again at every request, so a change reaches a resumed session. +- **The instructions** are the Codex session's (no made-up facts, short spoken replies, `list_activities` for the Agents), with the date taken from the context block, passed as `--system-prompt`: they replace Claude Code's coding-assistant prompt, since the buddy isn't one, and a short prompt is a faster turn. `--system-prompt-snapshot off` has them read again at every request, so a change reaches a resumed session; new instructions (a new Character) start the process again with `--resume`. - **The model** is `claude-haiku-4-5` (`MODEL` in `resident_claude.rs`), for the same reason Codex runs on luna at low effort: a voice command waits on the answer. - **Web search** is Claude Code's WebSearch, which runs Anthropic's server-side web search under the user's key; an organization can turn web search off in the Claude Console, and then searches fail. - **History** is read from the session's transcript: each user message that isn't a tool result, with the text of the `mcp__vibebuddy__speak` calls after it, in time order (Claude Code doesn't always write the lines in order). The transcript format isn't a documented API; the stream-json shape it shares is. @@ -123,7 +132,7 @@ Each spoken reply is also synthesized and streamed to the box (`daemon/src/synth - **Order.** Replies are synthesized one after another, in the order they were said, on a task of their own: a reply's `voice.reply` goes out the moment it is said and never waits for an earlier reply's audio, so the text of reply 1 may come between reply 0's audio pieces. Each reply's audio is forwarded as the bytes arrive, cut into pieces of at most 576 bytes (768 base64 characters), `seq` from 0, then `end`. `voice.done` waits for the last reply's `end`, so the turn is over only once everything has been sent; it is still the last message of the turn. - **Failures.** A status other than 200, or a stream cut before its end (the service closes the connection when it fails partway), ends that reply's audio at once with `"error":"synthesis"`, keeping what was sent; the box keeps the text. The next reply is still tried. When the resident session fails the turn, the reply being synthesized stops at its next piece, the rest are dropped, and `voice.error` follows. - **Long replies.** Over 1000 characters (counted in UTF-16, as the service counts), a reply is split at the end of a sentence, else at a comma or space; the pieces are requested one after another and stream as one reply, since MP3 frames stand alone. -- **Voices.** A reply is spoken in its Character's own voice, the one its lines were made with: `synthesis.rs` reads each Character's `characters//voice.env` at build time and sends `elevenlabs:` (our ElevenLabs account; the service uses `eleven_flash_v2_5`, multilingual, so a Chinese reply keeps an English Character's voice). A Character whose voice ElevenLabs doesn't have (the Chinese ones are Doubao, which the service doesn't speak yet) gets an ElevenLabs stock voice of its `GENDER` (from `voice.env`: Sarah or George) rather than another Character's voice. The robot and no pack get Jessica. The Character is the one the box reports (`VOICES`), else the one last written to it. +- **Voices.** A reply is spoken in its Character's own voice, the one its lines were made with: `synthesis.rs` reads each Character's `characters//voice.env` at build time and sends `elevenlabs:` (our ElevenLabs account; the service uses `eleven_flash_v2_5`, multilingual, so a Chinese reply keeps an English Character's voice). A Character whose voice ElevenLabs doesn't have (the Chinese ones are Doubao, which the service doesn't speak yet) gets an ElevenLabs stock voice of its `GENDER` (from `voice.env`: Sarah or George) rather than another Character's voice. The robot and no pack get Jessica. The Character is the one the box reports (`VOICES`), else the one last written to it; a custom Character speaks in the voice of the Character lending it its lines, when the App said which (see "Persona and language"). - **Links.** Only where voice is offered, native USB and the local network: nothing goes over the Relay or the UART bridge. - **Timing.** Checked on 2026-10-13 against staging, from this computer through the daemon's own client (a new TLS connection each time): a Chinese sentence, first audio after 1.3 to 1.4 s and its `end` after 1.5 to 1.6 s for 4.5 s of speech; an English one, 0.5 to 1.8 s and 0.6 to 2.0 s for 3.8 s. `e2e_spoken_replies_through_the_real_voice_service` in `voice.rs` (ignored) repeats it and saves the reassembled MP3s. @@ -149,6 +158,8 @@ Web searches go to OpenAI: the resident session's searches are made by OpenAI's On Claude Code all of this goes to Anthropic instead, under the user's own API key: the voice commands' text with the local time, the instructions, what `list_activities` returns, and the searches, which Anthropic runs. Anthropic's terms for API use apply. The session's transcript stays in `resident-claude/` on this computer, as Claude Code keeps it. The API key is stored in `resident-settings.json` (mode 0600) and is sent only to Claude Code, which sends it only to Anthropic. +The instructions carry the persona of the Character the buddy wears (`characters//persona.md`, written by us and public in this repository), its language and the user's form of address, so they go to the brain's provider (OpenAI for Codex, Anthropic for Claude Code) with every thread start or resume, as part of the resident session. + The user's Codex memory summary, and whatever `search_memories` finds, go to the same provider as part of the resident thread, as they already do in the user's own Codex sessions. A voice command's audio leaves the computer for the speech recognizer: in development that is Cloudflare Workers AI, under the developer's own account; with the user's own recognizer, only that endpoint (and its key goes nowhere else). The recognised text then goes to the user's Codex model provider as the voice command. The daemon keeps neither the audio nor the text once the turn is over, and logs neither. From 4ad558bcfb1f3e231d8ab75aac1dae4c653013b5 Mon Sep 17 00:00:00 2001 From: dragon Date: Wed, 14 Oct 2026 16:46:29 +0800 Subject: [PATCH 45/51] Remind the Claude resident of its Character's language on every command With the rule only in the system prompt, Haiku answered a Chinese question in Chinese as Mei, carried along by the user's language and the session's earlier Chinese replies. Co-Authored-By: Claude Opus 5.5 --- daemon/src/resident_claude.rs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/daemon/src/resident_claude.rs b/daemon/src/resident_claude.rs index 02d45b77..5bd48906 100644 --- a/daemon/src/resident_claude.rs +++ b/daemon/src/resident_claude.rs @@ -188,7 +188,7 @@ impl ClaudeBrain { let note = (changed && spoke_as.is_some()).then(|| change_note(persona)); let (sender, spoken) = mpsc::unbounded_channel(); *self.mcp.speak.lock().expect("speak lock") = Some(sender); - let result = run_turn(process, spoken, note.as_deref(), text, on_reply).await; + let result = run_turn(process, spoken, persona, note.as_deref(), text, on_reply).await; *self.mcp.speak.lock().expect("speak lock") = None; match &result { // The process is still there and the session intact: an API error, a refused key. @@ -315,11 +315,12 @@ impl Session { async fn run_turn( process: &mut Process, mut spoken: mpsc::UnboundedReceiver, + persona: &Persona, note: Option<&str>, text: &str, on_reply: &mut (dyn FnMut(&str) + Send), ) -> Result<(), ResidentError> { - let line = format!("{}\n", user_message(text, note, &chrono::Local::now(), local_time_zone().as_deref())); + let line = format!("{}\n", user_message(text, persona, note, &chrono::Local::now(), local_time_zone().as_deref())); let written = async { process.writer.write_all(line.as_bytes()).await?; process.writer.flush().await @@ -384,9 +385,23 @@ fn local_time_zone() -> Option { /// A voice command as a stream-json user message: the local time and time zone first, as context, then what was /// said. The time zone stands in for where the user is (Codex gets it from its own environment context), so a /// question about the weather or the news has a place. A `note` (the Character changed) goes with the context. -fn user_message(text: &str, note: Option<&str>, now: &chrono::DateTime, time_zone: Option<&str>) -> Value { +/// Every command also repeats who answers and in which language: with only the system prompt saying so, Haiku +/// answered a Chinese question in Chinese as an English Character, carried by the user's language and the session's +/// earlier replies. +fn user_message( + text: &str, + persona: &Persona, + note: Option<&str>, + now: &chrono::DateTime, + time_zone: Option<&str>, +) -> Value { let zone = time_zone.map(|zone| format!(", time zone {zone}")).unwrap_or_default(); - let context = format!("{CONTEXT_PREFIX}The user's local time: {}{zone}", now.format("%A %Y-%m-%d %H:%M (UTC%:z)")); + let Persona { name, language, .. } = persona; + let context = format!( + "{CONTEXT_PREFIX}The user's local time: {}{zone}. You are {name}: answer in {language} only, whatever language \ + this is said in.", + now.format("%A %Y-%m-%d %H:%M (UTC%:z)") + ); let content: Vec = [context.as_str()].into_iter().chain(note).chain([text]).map(|text| json!({"type": "text", "text": text})).collect(); json!({"type": "user", "message": {"role": "user", "content": content}}) @@ -720,12 +735,13 @@ pub(crate) mod tests { #[test] fn a_voice_command_carries_the_local_time_and_time_zone_as_context() { let now = chrono::Local::now(); - let message = user_message("今天天气怎么样", None, &now, Some("Asia/Singapore")); + let message = user_message("今天天气怎么样", &Persona::of(&crate::resident::tests::wearing("ada")), None, &now, Some("Asia/Singapore")); let content = message["message"]["content"].as_array().unwrap(); let context = content[0]["text"].as_str().unwrap(); assert!(context.starts_with(CONTEXT_PREFIX) && context.contains("time zone Asia/Singapore"), "{context}"); + assert!(context.contains("You are Ada: answer in English only"), "{context}"); assert_eq!(content[1]["text"], "今天天气怎么样"); - let without = user_message("hi", None, &now, None); + let without = user_message("hi", &Persona::of(&crate::resident::tests::wearing("ada")), None, &now, None); assert!(!without["message"]["content"][0]["text"].as_str().unwrap().contains("time zone")); } use crate::activity::{ActivityId, ActivityStatus}; From dfea772b31a8c6d7817aacb06149de6d1d68e1c4 Mon Sep 17 00:00:00 2001 From: dragon Date: Wed, 14 Oct 2026 20:33:53 +0800 Subject: [PATCH 46/51] Report why the box last started, with a mark for a panic A box that restarts by itself left no trace in the daemon's log. At boot it now sends RESET , plus PANIC when a panic caused the software reset. Co-Authored-By: Claude Opus 5.5 --- docs/firmware-bringup.md | 1 + firmware-rs/device/src/main.rs | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/docs/firmware-bringup.md b/docs/firmware-bringup.md index 3fc6fe7b..f4c5bf32 100644 --- a/docs/firmware-bringup.md +++ b/docs/firmware-bringup.md @@ -39,6 +39,7 @@ The review fixes applied before bring-up (see the PR) were in place, so it can't | UART receive | More `invalid_json` with only the UART bridge connected | The interrupt and ring buffer in `device/src/serial.rs` | | USB output blocking | Frozen screen after a night on the UART bridge alone (see LESSONS.md on output channels without a reader) | `write_usb` in `serial.rs` | | Settings storage | Wrong pomodoro tally or volume after a restart | `core/src/storage.rs` | +| The box restarts by itself | The boot report again in the daemon's log, unasked | The `RESET` line just before `MEMORY`: esp-hal's reset reason, such as `Some(CoreMwdt0)` or `Some(RtcWdt…)` for a watchdog and `Some(SysBrownOut)` for a brownout, plus ` PANIC` when a panic caused it (its backtrace went out on the port before the reset). A panic and the restart after a new identity are both `Some(CoreSw)`; only the mark tells them apart. A reset from the flasher or the USB port shows as `Some(CoreUsbJtag)` or `Some(CoreUsbUart)`. `Some(ChipPowerOn)` can also be a brownout of the whole chip. | ## Intentional differences from the C firmware diff --git a/firmware-rs/device/src/main.rs b/firmware-rs/device/src/main.rs index 94f06a7f..dacd3c7c 100644 --- a/firmware-rs/device/src/main.rs +++ b/firmware-rs/device/src/main.rs @@ -67,9 +67,18 @@ esp_bootloader_esp_idf::esp_app_desc!(); /// the box freezes until power is cut. #[unsafe(no_mangle)] fn custom_halt() -> ! { + // SAFETY: one word written just before the reset; nothing else runs. + unsafe { PANICKED = PANIC_MARK }; esp_hal::system::software_reset() } +/// Set by a panic just before its reset, so the next boot can tell it from a restart asked for: both are +/// software resets to the chip. RTC fast memory keeps it across the reset; after power-on it holds noise, +/// hence a mark rather than a flag. +#[esp_hal::ram(unstable(rtc_fast, persistent))] +static mut PANICKED: u32 = 0; +const PANIC_MARK: u32 = 0x5041_4e43; + const XL9555_ADDRESS: u8 = 0x20; const XL9555_INPUT_PORT0: u8 = 0x00; const XL9555_OUTPUT_PORT0: u8 = 0x02; @@ -495,6 +504,7 @@ async fn main(spawner: Spawner) -> ! { // Booting got this far, so an image installed over the air keeps its slot. storage::confirm_running_image(); net::CREDENTIALS.signal(wifi_credentials(&firmware)); + report_reset(&mut board); report_memory(&mut board); if let Some(stack) = stack { if let Some(link) = firmware.link() { @@ -614,6 +624,16 @@ fn wifi_credentials(firmware: &Firmware) -> Option<(alloc::string::String, alloc } /// `MEMORY INTERNAL / PSRAM /` in bytes: what the heaps have left (#108). +/// Why the box last started, so a box that restarted by itself shows in the daemon's log: a watchdog, a brownout or +/// a panic. +fn report_reset(board: &mut DeviceBoard) { + // SAFETY: read and cleared once at boot, before anything can panic again. + let panicked = unsafe { core::mem::replace(&mut *core::ptr::addr_of_mut!(PANICKED), 0) } == PANIC_MARK; + let reason = esp_hal::system::reset_reason(); + let line = alloc::format!("RESET {reason:?}{}\n", if panicked { " PANIC" } else { "" }); + board.write(line.as_bytes()); +} + fn report_memory(board: &mut DeviceBoard) { let stats = esp_alloc::HEAP.stats(); let internal = esp_alloc::HEAP.free_caps(esp_alloc::MemoryCapability::Internal.into()); From 04d9f036b117b6201f300387d8598adbae38928a Mon Sep 17 00:00:00 2001 From: dragon Date: Wed, 14 Oct 2026 21:39:38 +0800 Subject: [PATCH 47/51] Keep shell snapshots and skills out of the resident Codex session Codex snapshotted the user's login shell, API keys included, into the resident Codex home at every start, for a shell tool the session doesn't have, and listed the user's own and bundled skills every turn. Both are now off. Code mode stays on: the model calls every tool through it, and its JavaScript has no file, network or process access. Co-Authored-By: Claude Opus 5.5 --- daemon/src/resident.rs | 21 ++++++++++++++++++--- docs/resident-session.md | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/daemon/src/resident.rs b/daemon/src/resident.rs index 43b44982..221796c4 100644 --- a/daemon/src/resident.rs +++ b/daemon/src/resident.rs @@ -1047,9 +1047,15 @@ fn codex_command(program: &Path, codex_home: &Path) -> tokio::process::Command { /// Features that give a Codex thread tools of its own: the resident session gets none of them, only ours. Most are /// on by default even in an empty Codex home. Plugins and apps (connectors) bring tools and MCP servers; the rest /// are built-in tools. `memories` (off by default) would also start Codex's memory pipeline, which rewrites the -/// memories folder of the Codex home it runs in; the daemon gives the session the user's memories itself. Set as -/// `-c features.=false` rather than `--disable`, which refuses a name another Codex version doesn't know. -const FEATURES_OFF: [&str; 15] = [ +/// memories folder of the Codex home it runs in; the daemon gives the session the user's memories itself. +/// `shell_snapshot` runs the user's login shell at every start and saves its whole environment, API keys included, +/// in the Codex home, for a shell tool the session doesn't have. Set as `-c features.=false` rather than +/// `--disable`, which refuses a name another Codex version doesn't know. +/// +/// `code_mode_host` stays on: the model calls every tool, ours included, through its `exec` JavaScript, and with +/// the host off no tool call gets through. That JavaScript has no file system, network or process (checked on +/// 2026-10-14: no `fetch`, `require` or `process`, and `import` is refused); it reaches only the thread's tools. +const FEATURES_OFF: [&str; 16] = [ "plugins", "apps", "shell_tool", @@ -1065,6 +1071,7 @@ const FEATURES_OFF: [&str; 15] = [ "sleep_tool", "tool_suggest", "skill_search", + "shell_snapshot", ]; /// The config overrides for the resident session's Codex, before MCP servers. Besides the tools: @@ -1073,6 +1080,9 @@ const FEATURES_OFF: [&str; 15] = [ /// path) and, on saving, would delete the link. /// - `memories.generate_memories=false`: the resident thread is never summarised into memories, should the feature /// come on. +/// - `skills.include_instructions=false` and `skills.bundled.enabled=false`: no skills. Codex lists the user's +/// own skills (`~/.agents/skills`) whatever the Codex home, and installs its bundled ones (`imagegen` and the +/// like) into it; the session can't run them, and the list costs every turn context. /// - `web_search`: Codex's built-in web search, so a question about now is looked up rather than made up. The /// queries go to OpenAI, the model provider. /// - [`MODEL`] at [`REASONING_EFFORT`]: a voice command waits on the answer. Measured on 2026-10-13, a turn's first @@ -1084,6 +1094,8 @@ fn tool_overrides() -> Vec { format!("web_search=\"{WEB_SEARCH}\""), AUTH_STORE.to_owned(), "memories.generate_memories=false".to_owned(), + "skills.include_instructions=false".to_owned(), + "skills.bundled.enabled=false".to_owned(), format!("model=\"{MODEL}\""), format!("model_reasoning_effort=\"{REASONING_EFFORT}\""), ] { @@ -1939,6 +1951,9 @@ pub(crate) mod tests { "features.unified_exec=false", "features.multi_agent=false", "features.memories=false", + "features.shell_snapshot=false", + "skills.include_instructions=false", + "skills.bundled.enabled=false", "web_search=\"live\"", "cli_auth_credentials_store=\"file\"", "memories.generate_memories=false", diff --git a/docs/resident-session.md b/docs/resident-session.md index a78cdbad..19474a1a 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -58,7 +58,7 @@ The box is offered voice (`voice.available`, below) only while the session is on - **The user's login, shared.** The user's Codex home is `$CODEX_HOME` if the daemon has it, else `~/.codex`. On every start `resident-codex/auth.json` is made a symlink to its `auth.json` (whatever else is there, a copy or a link elsewhere, is replaced), so there is one login and one set of tokens. Checked in the codex-cli 0.162.0 source (`codex-rs/login/src/auth/storage.rs`, `manager.rs`): the file store reads `auth.json` with a plain open, a refresh first reloads it and keeps a token the other side already refreshed, and a save truncates and rewrites it in place, so all of it goes through the link and the link survives; a rename on the user's side doesn't matter, since the link is by path. `-c cli_auth_credentials_store="file"` pins that store: the keychain store keys its entry by the Codex home's path, so it would never find the user's login, and its save deletes `auth.json`. A login kept in the keychain therefore can't be shared: the session reports "not logged in" and the log says why. Two Codex processes refreshing at the same moment can still race, as the user's CLI and the ChatGPT App already can on the same file. Credentials are never logged. - **Starting.** On the first voice command, and on the next one after the app-server dies, the daemon runs the `codex` it finds (`VIBEBUDDY_CODEX`, else PATH, else `~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, since launchd's PATH is bare). It sends `initialize` (`experimentalApi: true`) and `initialized`, then `account/read`: with no account it stops with "not logged in". It never starts a login. - **One thread for good.** `thread/start` once, with `cwd` set to `resident/` in the daemon's state directory, `sandbox: "read-only"`, `approvalPolicy: "on-request"`, the developer instructions and the dynamic tools. The thread id is kept in `resident.json` beside it, and every later start sends `thread/resume` with the same settings (the tools survive a resume; the instructions are sent again). Only a thread that no longer exists (`no rollout found`) is replaced by a new one, saved, with both ids in the log. That is also what happens once to a thread saved before the session had its own home: it is in `~/.codex`, so the first start in `resident-codex/` begins a new thread; the old one's rollout stays where it is. Should another Codex process ever be the thread's writer, the resume fails with `already has an active writer`, the voice command fails as `unavailable`, and the next one tries again; the thread is never replaced for that, so there stays one resident session. -- **Only our tools.** In its own home none of the user's Codex config loads: not their `config.toml` (model, MCP servers, profiles), `AGENTS.md`, hooks, plugins or skills. Codex's own defaults still give a thread tools, so every start passes `-c features.=false` for plugins, apps (connectors), the shell and exec tools, sub-agents, goals, memories, image generation and viewing, the browser and computer use, sleep, tool suggestions and skill search; all but `memories` are on by default in an empty home (`codex features list`). System and managed config (`/etc/codex`, MDM) still load and may add MCP servers, and an override can't empty `mcp_servers` (it merges), so the daemon first asks `codex mcp list --json`, under the same overrides, which servers would be on, and adds `-c mcp_servers..enabled=false` for each (on this Mac: none). Overrides are `-c` rather than `--disable`, which refuses a feature name another Codex version doesn't know. The model and reasoning effort are set too (see Latency below), and Codex's built-in web search is on, `web_search="live"` (below). The thread's tool list is `speak`, `list_activities`, `search_memories`, the web search and what Codex always adds and has no switch for: `apply_patch` (refused by the read-only sandbox, its approval declined), a clock, and the MCP resource readers (no server is on to read from). +- **Only our tools.** In its own home none of the user's Codex config loads: not their `config.toml` (model, MCP servers, profiles), `AGENTS.md`, hooks, plugins or skills. Codex's own defaults still give a thread tools, so every start passes `-c features.=false` for plugins, apps (connectors), the shell and exec tools, sub-agents, goals, memories, image generation and viewing, the browser and computer use, sleep, tool suggestions, skill search and shell snapshots; all but `memories` are on by default in an empty home (`codex features list`). Shell snapshots matter beyond tools: at every start Codex runs the user's login shell and saves its whole environment, API keys included, into the Codex home. Skills go too: `skills.include_instructions=false` drops the skill list, which otherwise names the user's own skills (`~/.agents/skills`, read whatever the Codex home), and `skills.bundled.enabled=false` stops Codex installing its bundled ones (`imagegen` and the like) into the home. System and managed config (`/etc/codex`, MDM) still load and may add MCP servers, and an override can't empty `mcp_servers` (it merges), so the daemon first asks `codex mcp list --json`, under the same overrides, which servers would be on, and adds `-c mcp_servers..enabled=false` for each (on this Mac: none). Overrides are `-c` rather than `--disable`, which refuses a feature name another Codex version doesn't know. The model and reasoning effort are set too (see Latency below), and Codex's built-in web search is on, `web_search="live"` (below). The thread's tool list is `speak`, `list_activities`, `search_memories`, the web search and what Codex always adds and has no switch for: `apply_patch` (refused by the read-only sandbox, its approval declined), a clock, and the MCP resource readers (no server is on to read from). The model calls all of these through Codex's code mode, an `exec` tool that runs JavaScript: switching the host off (`features.code_mode_host`) leaves no tool call working, so it stays on. That JavaScript reaches only the thread's tools: checked on 2026-10-14, it has no `fetch`, `require` or `process`, and `import` is refused, so no file, network or process access. - **Memories, read-only.** The session reads the user's Codex memories (`/memories/`) through the daemon, and Codex in its home never touches them. Codex's own memory feature (`features.memories`) can't do that safely: it gates the read path (injecting `memory_summary.md`) and, at every thread start whatever `generate_memories` says, the memory pipeline, whose consolidation phase rewrites `raw_memories.md` and prunes `rollout_summaries/` from the state database of the Codex home it runs in; in the resident home that database is empty, so on a linked folder it would empty the user's memories, and on a copy it would still run a consolidation agent each time. Its dedicated memory tools also include one that writes a note into the folder. So the feature stays off (and `memories.generate_memories=false`, should it come on), and the daemon does what the read path does: at every start or resume it puts `memory_summary.md`, cut at Codex's own cap of 10,000 bytes, into the developer instructions, and the `search_memories({query})` tool returns the lines of `MEMORY.md`, `raw_memories.md` and `rollout_summaries/` containing any of the query's words, each with the line before and after (at most 8,000 bytes). Both only read. Checked on 2026-10-13: a question answered from the summary, and one whose answer is only in `MEMORY.md`, answered after a `search_memories` call; the memories folder unchanged. - **Tools.** `speak({text})` is the spoken reply: the daemon hands it on the moment it is called (`Resident::say_streaming`), to the box as `voice.reply`, or collected for `POST /v1/resident/say`. `list_activities({})` returns, as JSON, `{"activities","recently_ended","today"}`: the live Activities the aggregator holds (agent, task-card title, project, `working`/`needs_input`, host, seconds in that state and since the last event, newest first); the last 20 Activities that ended since the daemon started (agent, title, project, `ended`: `done`, `failed`, `stopped` or `expired`, and `ended_s`, seconds ago, newest first); and today's stats (`done`, `asks`, `busy_seconds`), the same numbers as the idle screen. `stopped` is an Activity that ended with no outcome: interrupted or stopped by the user, its session closed, or a CI run cancelled or pushed out; `expired` is one nothing was heard from for too long. Work an agent delegated inside its own session (a Claude Code subagent) is left out of `recently_ended`, as its end is never announced. The ended ones are kept only in memory, so a restart forgets them; today's stats were already kept in `stats.json`. The tool's description says what each part is for ("what just finished", "how did today go"), and the instructions say so too, since a thread started before this keeps its old description. `search_memories({query})` searches the user's Codex memories (above). There are no tools that run commands or write files. Dynamic tools are fixed when a thread starts, so a thread started before `search_memories` existed doesn't have it. - **Web search.** Codex's built-in web search (`web_search`: `disabled`, `cached`, `indexed` or `live` in codex 0.162.0) is on as `live`, as what it is for (weather, news, prices) is about now, which a cached index may not have yet. Searches run on OpenAI's side, as part of the model's turn; the daemon sees none of it. On `gpt-6-luna` the session looks things up through Codex's own `web.run`, which also has weather and finance lookups. From f40b1aa29fc32b2fb2bdb32500f135623f77417d Mon Sep 17 00:00:00 2001 From: dragon Date: Thu, 15 Oct 2026 10:08:35 +0800 Subject: [PATCH 48/51] Give Codex resident turns the local time and time zone, as Claude's get Codex's clock tool answers in UTC, so the Codex resident told the time in UTC. Both brains now put the same context block (local time, time zone, who answers in which language) before every voice command. Co-Authored-By: Claude Opus 5.5 --- daemon/src/resident.rs | 22 ++++++++++++++++------ daemon/src/resident_claude.rs | 29 +++++++++++++++++------------ daemon/src/voice.rs | 2 +- docs/resident-session.md | 4 ++-- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/daemon/src/resident.rs b/daemon/src/resident.rs index 221796c4..2b48bb61 100644 --- a/daemon/src/resident.rs +++ b/daemon/src/resident.rs @@ -31,7 +31,7 @@ use tracing::{info, warn}; use crate::activity::ActivityTracker; use crate::app_server::{Client, Incoming, RpcError}; use crate::persona::{Persona, Wearing}; -use crate::resident_claude::{CONTEXT_PREFIX, ClaudeBrain}; +use crate::resident_claude::{CONTEXT_PREFIX, ClaudeBrain, command_context, local_time_zone}; /// The Codex version the app-server protocol was verified with. app-server and `dynamicTools` are experimental, /// so other versions get a warning in the log, but are still tried. @@ -559,7 +559,8 @@ impl Resident { let spoke_as = self.spoke_as.lock().expect("spoke as lock").clone(); let changed = spoke_as.as_deref() != Some(persona.id); let note = (changed && spoke_as.is_some()).then(|| change_note(persona)); - let result = self.run_turn(session, note.as_deref(), text, on_reply).await; + let context = command_context(persona, &chrono::Local::now(), local_time_zone().as_deref()); + let result = self.run_turn(session, &context, note.as_deref(), text, on_reply).await; if session.client.is_closed() { *slot = None; } @@ -763,6 +764,7 @@ impl Resident { async fn run_turn( &self, session: &mut Session, + context: &str, note: Option<&str>, text: &str, on_reply: &mut (dyn FnMut(&str) + Send), @@ -772,7 +774,7 @@ impl Resident { self.handle(&session.client, message, &mut |_| {}).await; } let input: Vec = - note.into_iter().chain([text]).map(|text| json!({"type": "text", "text": text})).collect(); + [context].into_iter().chain(note).chain([text]).map(|text| json!({"type": "text", "text": text})).collect(); let started = request(&session.client, "turn/start", json!({"threadId": session.thread_id, "input": input})).await?; let turn_id = started.pointer("/turn/id").and_then(Value::as_str).unwrap_or_default().to_owned(); @@ -1468,7 +1470,12 @@ pub(crate) mod tests { assert_eq!(tools, ["speak", "list_activities", "search_memories"]); let listing = start["dynamicTools"][1]["description"].as_str().unwrap(); assert!(listing.contains("`recently_ended`") && listing.contains("`today`"), "{listing}"); - assert_eq!(received[4]["params"], json!({"threadId": "new-thread", "input": [{"type": "text", "text": "hello"}]})); + assert_eq!(received[4]["params"]["threadId"], "new-thread"); + let input = received[4]["params"]["input"].as_array().unwrap(); + assert_eq!(input.len(), 2); + let context = input[0]["text"].as_str().unwrap(); + assert!(context.starts_with("The user's local time: ") && context.contains("You are"), "{context}"); + assert_eq!(input[1], json!({"type": "text", "text": "hello"})); assert!(dir.join("resident").is_dir()); assert_eq!(load(&dir.join("resident.json")).thread_id.as_deref(), Some("new-thread")); let _ = std::fs::remove_dir_all(dir); @@ -1637,7 +1644,7 @@ pub(crate) mod tests { .received .iter() .filter(|message| message["method"] == "turn/start") - .map(|message| message["params"]["input"][0]["text"].clone()) + .map(|message| message["params"]["input"][1]["text"].clone()) .collect(); assert_eq!(turns, [json!("one"), json!("two")]); assert_eq!(script.lock().unwrap().starts, 1); @@ -1794,8 +1801,11 @@ pub(crate) mod tests { let script = script.lock().unwrap(); script.received.iter().filter(|message| message["method"] == method).map(|message| message["params"].clone()).collect() }; + // What follows the context every command starts with. let texts = |input: &Value| -> Vec { - input["input"].as_array().unwrap().iter().map(|item| item["text"].as_str().unwrap().to_owned()).collect() + let items = input["input"].as_array().unwrap(); + assert!(items[0]["text"].as_str().unwrap().contains("The user's local time")); + items[1..].iter().map(|item| item["text"].as_str().unwrap().to_owned()).collect() }; resident.say("hi", &wearing("ada")).await.unwrap(); diff --git a/daemon/src/resident_claude.rs b/daemon/src/resident_claude.rs index 5bd48906..033c53b6 100644 --- a/daemon/src/resident_claude.rs +++ b/daemon/src/resident_claude.rs @@ -371,7 +371,7 @@ async fn run_turn( /// The computer's IANA time zone (`Asia/Singapore`): `TZ` if set, else where `/etc/localtime` points, on macOS /// and Linux alike. -fn local_time_zone() -> Option { +pub(crate) fn local_time_zone() -> Option { if let Ok(zone) = std::env::var("TZ") && !zone.is_empty() { @@ -382,12 +382,8 @@ fn local_time_zone() -> Option { path.split_once("zoneinfo/").map(|(_, zone)| zone.to_owned()) } -/// A voice command as a stream-json user message: the local time and time zone first, as context, then what was -/// said. The time zone stands in for where the user is (Codex gets it from its own environment context), so a -/// question about the weather or the news has a place. A `note` (the Character changed) goes with the context. -/// Every command also repeats who answers and in which language: with only the system prompt saying so, Haiku -/// answered a Chinese question in Chinese as an English Character, carried by the user's language and the session's -/// earlier replies. +/// A voice command as a stream-json user message: the [`command_context`] first, then a `note` (the Character +/// changed) if any, then what was said. fn user_message( text: &str, persona: &Persona, @@ -395,16 +391,25 @@ fn user_message( now: &chrono::DateTime, time_zone: Option<&str>, ) -> Value { + let context = command_context(persona, now, time_zone); + let content: Vec = + [context.as_str()].into_iter().chain(note).chain([text]).map(|text| json!({"type": "text", "text": text})).collect(); + json!({"type": "user", "message": {"role": "user", "content": content}}) +} + +/// The context put before every voice command, for both brains: the local time and time zone, and who answers in +/// which language. The time zone stands in for where the user is, so a question about the weather or the news has a +/// place; Codex's own clock tool answers in UTC. The reminder is there because with only the instructions saying +/// so, Haiku answered a Chinese question in Chinese as an English Character, carried by the user's language and the +/// session's earlier replies. +pub(crate) fn command_context(persona: &Persona, now: &chrono::DateTime, time_zone: Option<&str>) -> String { let zone = time_zone.map(|zone| format!(", time zone {zone}")).unwrap_or_default(); let Persona { name, language, .. } = persona; - let context = format!( + format!( "{CONTEXT_PREFIX}The user's local time: {}{zone}. You are {name}: answer in {language} only, whatever language \ this is said in.", now.format("%A %Y-%m-%d %H:%M (UTC%:z)") - ); - let content: Vec = - [context.as_str()].into_iter().chain(note).chain([text]).map(|text| json!({"type": "text", "text": text})).collect(); - json!({"type": "user", "message": {"role": "user", "content": content}}) + ) } /// The arguments for `claude`; see the module docs for why each is there. diff --git a/daemon/src/voice.rs b/daemon/src/voice.rs index 612e6f62..b1250101 100644 --- a/daemon/src/voice.rs +++ b/daemon/src/voice.rs @@ -846,7 +846,7 @@ pub mod tests { let wav = rig.recognizer.heard.lock().unwrap()[0].clone(); assert_eq!(wav.len(), 44 + 30_000 * 2, "the whole recording, as 16-bit samples"); let turn_text = rig.script.lock().unwrap().received.iter().find(|message| message["method"] == "turn/start").unwrap() - ["params"]["input"][0]["text"] + ["params"]["input"][1]["text"] .clone(); assert_eq!(turn_text, "what are my agents doing"); } diff --git a/docs/resident-session.md b/docs/resident-session.md index 19474a1a..dcf1de5c 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -64,7 +64,7 @@ The box is offered voice (`voice.available`, below) only while the session is on - **Web search.** Codex's built-in web search (`web_search`: `disabled`, `cached`, `indexed` or `live` in codex 0.162.0) is on as `live`, as what it is for (weather, news, prices) is about now, which a cached index may not have yet. Searches run on OpenAI's side, as part of the model's turn; the daemon sees none of it. On `gpt-6-luna` the session looks things up through Codex's own `web.run`, which also has weather and finance lookups. - **No made-up facts.** The instructions forbid stating anything not seen in a tool result, a search result or the memories: no weather, news, prices or other facts about now, and nothing about the user's world. For those it searches, answers only from what it found, and otherwise says plainly in one sentence that it can't find out. Before this, asked about tomorrow's weather in Singapore, it invented a forecast. Codex's environment context gives the thread today's date and the user's time zone; the instructions point at it for "today" and "tomorrow", after luna once searched for the wrong day's news. - **Declining.** Every approval or question the session raises is declined: command and file-change approvals (`decline`, or `denied` on the legacy methods), permission requests (nothing granted), MCP elicitations (`decline`) and user-input requests (no answers). `currentTime/read` is answered; anything else gets a JSON-RPC "method not found". -- **Turns.** `turn/start` with the text, then the daemon answers tool calls until `turn/completed` for that turn. One turn at a time: a command that arrives during another waits for it, in order, rather than being rejected, since two quick sentences are both meant to be answered. A turn that takes over two minutes is interrupted (`turn/interrupt`). +- **Turns.** `turn/start` with the text after a context block, as on Claude Code (below): the user's local date, time, UTC offset and time zone, and who answers in which language. Codex's own clock tool answers in UTC, so without it the time of day came out in UTC. Then the daemon answers tool calls until `turn/completed` for that turn. One turn at a time: a command that arrives during another waits for it, in order, rather than being rejected, since two quick sentences are both meant to be answered. A turn that takes over two minutes is interrupted (`turn/interrupt`). - **Persona.** The instructions make the buddy the Character it wears, in that Character's language (below). The app-server takes developer instructions only at `thread/start` and `thread/resume` (`turn/start` has no field for them in codex-cli 0.162.0), so a new Character stops the app-server and resumes the same thread with the new ones. ## Persona and language @@ -83,7 +83,7 @@ The resident session speaks as the buddy, as the Character it wears (ADR-0017): - **Only the user's own API key.** Anthropic doesn't let third-party products use a claude.ai login: its [legal and compliance page](https://code.claude.com/docs/en/legal-and-compliance) says developers building products, including with the Agent SDK, use API key authentication, and "Anthropic does not permit third-party developers to offer Claude.ai login into their own applications, or to route requests through Free, Pro, or Max plan credentials on behalf of their users". So the Claude Code brain authenticates only with the Anthropic API key the user enters, billed to their Anthropic account, and never with a subscription or OAuth login, not even as a fallback: the child's environment loses every `CLAUDE*` and `ANTHROPIC*` variable the daemon has (an OAuth token, `ANTHROPIC_AUTH_TOKEN`, the Bedrock, Vertex and Foundry switches, profiles) and gets `ANTHROPIC_API_KEY`, which `-p` always uses when present ([authentication precedence](https://code.claude.com/docs/en/authentication#authentication-precedence)); its own config dir holds no login, and the macOS Keychain entry is keyed by the config dir. On top of that, the `system/init` line reports `apiKeySource`, and anything but `ANTHROPIC_API_KEY` stops the process and fails the command. The key is never logged, never written outside `resident-settings.json` (the MCP config and the command line don't carry it), and only reaches the child's environment. - **Its own config dir.** `CLAUDE_CONFIG_DIR` is `resident-claude/` in the daemon's state directory (mode 0700), so none of the user's Claude Code config loads: settings, CLAUDE.md, hooks, plugins, MCP servers, sessions. The session doesn't show up in the user's `claude --resume` or the Claude App. The working directory is the same `resident/` as the Codex thread's. `CLAUDE_CODE_DISABLE_AUTO_MEMORY=1` and `DISABLE_AUTOUPDATER=1` keep it from writing memory files or updating the user's Claude Code. - **One process, one session for good.** One long-lived `claude -p --input-format stream-json --output-format stream-json --verbose`; each voice command is one user message on its stdin, and the turn is over at the `result` line. The first start passes `--session-id` with a new UUID, saved in `resident-claude.json` in the state directory with the Claude Code version; every later start passes `--resume` with it, as long as its transcript (`resident-claude/projects/*/.jsonl`) is there. Only a session whose transcript is gone is replaced, with both ids in the log. A process that exits or doesn't finish a turn in two minutes is stopped, and the next command starts it again with `--resume`; an error `result` (a rejected key, an API error) fails the command and keeps the process. A new key starts a new process. The daemon starts it when it starts, as for Codex. -- **Each command** is two text blocks: a context block with the user's local date, time and UTC offset, then what was said (with the context line of a new Character between them, see "Persona and language"). The history leaves the context blocks out. +- **Each command** is two text blocks: a context block with the user's local date, time, UTC offset and time zone, and a reminder of who answers in which language, then what was said (with the context line of a new Character between them, see "Persona and language"). The history leaves the context blocks out. - **Only our tools.** `--tools WebSearch` leaves Web search as the only built-in tool; `--disallowedTools` denies Bash, Edit, Write, Read, NotebookEdit, WebFetch, Glob, Grep, Agent, Task, Skill and BashOutput by name besides, which removes them from the model's context. `speak` and `list_activities` come from the daemon's MCP server (below), with `--strict-mcp-config` so no other server loads. `--permission-mode dontAsk --permission-prompts none` with `--allowedTools WebSearch,mcp__vibebuddy__speak,mcp__vibebuddy__list_activities`: anything else that would need a permission is refused, never asked. `--disable-slash-commands` turns skills and commands off, and `--settings '{"disableAllHooks":true}'` hooks. `search_memories` isn't offered: Codex memories are Codex's. - **Why not `--bare`.** Bare mode would skip even more and never reads OAuth or the keychain, but in Claude Code 2.1.296 it gives the model only Bash, Read and Edit and drops WebSearch even when `--tools` names it (checked against a fake Messages API: the request's tool list was empty with `--bare --tools WebSearch`, and `WebSearch` without `--bare`). Without web search the buddy couldn't answer questions about now, so the config dir, the cleaned environment, the init check and the flags do it instead. - **The MCP server.** Claude Code reaches `speak` and `list_activities` over MCP's Streamable HTTP transport (JSON responses, no server-sent stream) at `http://127.0.0.1:/mcp`, a loopback port of the daemon's own picked at start, behind a random 256-bit bearer token made when the daemon starts. The URL and token are in `resident-claude/mcp.json` (0600), passed with `--mcp-config`. Chosen over a `vibebuddyd` stdio subcommand: `speak` must reach the turn in progress in the daemon at once, and a stdio child would have had to call back into the daemon over HTTP anyway; in-process there is no second process, no binary path to find inside the App bundle, and the daemon's own API (no token, on `VIBEBUDDY_BIND`) stays as it is. Without the token the server answers 401. From 7a8a004b282886f08b8646501f57bac1fb87ebc9 Mon Sep 17 00:00:00 2001 From: dragon Date: Thu, 15 Oct 2026 10:17:28 +0800 Subject: [PATCH 49/51] Recognize voice commands through the voice service by default The daemon sends the recorded WAV to vibebuddy-api's /v0/voice/transcribe (Groq, falling back to Workers AI) at the base spoken replies use: staging outside a release. The user's own recognizer still wins; calling Cloudflare directly is now only by name. Co-Authored-By: Claude Opus 5.5 --- daemon/src/speech.rs | 124 +++++++++++++++++++++++++++++---------- daemon/src/synthesis.rs | 20 ++++--- docs/resident-session.md | 11 ++-- 3 files changed, 113 insertions(+), 42 deletions(-) diff --git a/daemon/src/speech.rs b/daemon/src/speech.rs index 4eb08410..8830c054 100644 --- a/daemon/src/speech.rs +++ b/daemon/src/speech.rs @@ -1,11 +1,13 @@ //! Speech recognition for voice commands (ADR-0017): a recorded turn, as a WAV file, in; its text out. //! -//! Behind [`Recognizer`] so the backend can change: the plan is the voice service in `vibebuddy-cloud`, called with -//! the account session. Until then there are two: +//! Behind [`Recognizer`] so the backend can change. In the order they are picked: //! //! - The user's own recognizer: any OpenAI-compatible transcription endpoint (`VIBEBUDDY_STT_BASE_URL`, -//! `VIBEBUDDY_STT_API_KEY`, `VIBEBUDDY_STT_MODEL`). It wins over Cloudflare when both are set. -//! - Cloudflare Workers AI's Whisper (`CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_AI_TOKEN`), for development. +//! `VIBEBUDDY_STT_API_KEY`, `VIBEBUDDY_STT_MODEL`). +//! - The voice service in `vibebuddy-cloud`, `POST {base}/v0/voice/transcribe`, at the base spoken replies use +//! ([`crate::synthesis::service_base`]): staging outside a release, none in a release until accounts come. +//! - Cloudflare Workers AI's Whisper (`CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_AI_TOKEN`), for development; picked only +//! by name now that the voice service recognizes. //! //! A setting comes from the environment, else from `speech.json` in the config directory (the user's own //! recognizer, which an App setting will write later: a file of its own, mode 0600, because `PUT /v1/config` @@ -21,7 +23,8 @@ use base64::Engine; use serde_json::{Value, json}; use tracing::{info, warn}; -/// Picks the backend: `cloudflare`, `openai` or `off`. Unset, the user's own recognizer if set, else Cloudflare. +/// Picks the backend: `service`, `cloudflare`, `openai` or `off`. Unset, the user's own recognizer if set, else the +/// voice service. pub const BACKEND_VARIABLE: &str = "VIBEBUDDY_STT"; /// A `KEY=VALUE` file the other variables can come from, so credentials stay out of the daemon's environment. pub const ENV_FILE_VARIABLE: &str = "VIBEBUDDY_STT_ENV_FILE"; @@ -103,6 +106,41 @@ impl Recognizer for Cloudflare { } } +/// The voice service: the WAV as the body, `{"text","language"}` back. It tries Groq, then Workers AI. +pub struct VoiceService { + base_url: String, + language: Option, +} + +impl VoiceService { + fn request(&self, wav: &[u8]) -> HttpRequest { + let query = self.language.as_ref().map(|language| format!("?language={language}")).unwrap_or_default(); + HttpRequest { + url: format!("{}/v0/voice/transcribe{query}", self.base_url.trim_end_matches('/')), + headers: vec![("Content-Type".to_owned(), "audio/wav".to_owned())], + body: wav.to_vec(), + } + } + + fn parse(status: u16, body: &str) -> Result { + if (200..300).contains(&status) + && let Ok(parsed) = serde_json::from_str::(body) + && let Some(text) = parsed.get("text").and_then(Value::as_str) + { + let language = parsed.get("language").and_then(Value::as_str).map(str::to_owned); + return Ok(Transcript { text: text.trim().to_owned(), language }); + } + Err(format!("the voice service answered {status}: {}", body.trim().chars().take(200).collect::())) + } +} + +impl Recognizer for VoiceService { + fn recognize(&self, wav: &[u8]) -> Result { + let (status, body) = send(self.request(wav))?; + Self::parse(status, &body) + } +} + /// An OpenAI-compatible `POST {base}/audio/transcriptions`: OpenAI itself, or a server the user runs. pub struct OpenAiCompatible { base_url: String, @@ -190,18 +228,26 @@ fn send(request: HttpRequest) -> Result<(u16, String), String> { pub fn from_env() -> Option> { let own = crate::config::config_dir().map(|dir| read_settings(&dir.join(SETTINGS_FILE))).unwrap_or_default(); let file = std::env::var_os(ENV_FILE_VARIABLE).map(|path| read_env_file(Path::new(&path))).unwrap_or_default(); - configure(&|name| { - std::env::var(name) - .ok() - .or_else(|| own.get(name).cloned()) - .or_else(|| file.get(name).cloned()) - .filter(|value| !value.trim().is_empty()) - }) + let service = crate::synthesis::service_base( + std::env::var(crate::synthesis::BASE_URL_VARIABLE).ok(), + crate::relay::release(), + ); + configure( + &|name| { + std::env::var(name) + .ok() + .or_else(|| own.get(name).cloned()) + .or_else(|| file.get(name).cloned()) + .filter(|value| !value.trim().is_empty()) + }, + service, + ) } type Named = (&'static str, Arc); -fn configure(lookup: &dyn Fn(&str) -> Option) -> Option> { +/// `service` is the voice service's base URL, if there is one. +fn configure(lookup: &dyn Fn(&str) -> Option, service: Option) -> Option> { let language = lookup(LANGUAGE_VARIABLE).filter(|language| language != "auto"); let shown = language.clone().unwrap_or_else(|| "detected".to_owned()); let own = || -> Option { @@ -218,17 +264,21 @@ fn configure(lookup: &dyn Fn(&str) -> Option) -> Option Option { + Some(("the voice service", Arc::new(VoiceService { base_url: service.clone()?, language: language.clone() }))) + }; let backend = lookup(BACKEND_VARIABLE); let chosen = match backend.as_deref() { Some("off") => return None, + Some("service") => service(), Some("cloudflare") => cloudflare(), Some("openai") => own(), Some(other) => { - warn!(backend = %other, "unknown speech recognizer; expected cloudflare, openai or off"); + warn!(backend = %other, "unknown speech recognizer; expected service, cloudflare, openai or off"); return None; } // The user's own recognizer is their choice; Cloudflare is only there for development. - None => own().or_else(cloudflare), + None => own().or_else(service), }; match (&chosen, backend) { (Some((name, _)), _) => info!(language = %shown, "speech recognition: {name}"), @@ -348,28 +398,42 @@ mod tests { #[test] fn the_backend_is_chosen_by_name_or_by_which_settings_are_there() { - assert!(configure(&lookup(&[])).is_none()); - assert!(configure(&lookup(&[("CLOUDFLARE_ACCOUNT_ID", "a")])).is_none(), "a token is needed too"); - assert!(configure(&lookup(&[("CLOUDFLARE_ACCOUNT_ID", "a"), ("CLOUDFLARE_AI_TOKEN", "t")])).is_some()); - assert!(configure(&lookup(&[("VIBEBUDDY_STT_BASE_URL", "http://localhost:8000/v1")])).is_some()); - assert!(configure(&lookup(&[("VIBEBUDDY_STT", "off"), ("CLOUDFLARE_ACCOUNT_ID", "a"), ("CLOUDFLARE_AI_TOKEN", "t")])).is_none()); - assert!(configure(&lookup(&[("VIBEBUDDY_STT", "openai"), ("CLOUDFLARE_ACCOUNT_ID", "a"), ("CLOUDFLARE_AI_TOKEN", "t")])).is_none()); - assert!(configure(&lookup(&[("VIBEBUDDY_STT", "whisper.cpp")])).is_none()); + let service = || Some("https://api-staging.korekore.ai".to_owned()); + assert!(configure(&lookup(&[]), None).is_none(), "a release has no voice service yet"); + assert!(configure(&lookup(&[]), service()).is_some(), "the voice service by default"); + assert!(configure(&lookup(&[("CLOUDFLARE_ACCOUNT_ID", "a"), ("CLOUDFLARE_AI_TOKEN", "t")]), None).is_none(), "Cloudflare only by name"); + assert!(configure(&lookup(&[("VIBEBUDDY_STT", "cloudflare"), ("CLOUDFLARE_ACCOUNT_ID", "a")]), service()).is_none(), "a token is needed too"); + assert!(configure(&lookup(&[("VIBEBUDDY_STT", "cloudflare"), ("CLOUDFLARE_ACCOUNT_ID", "a"), ("CLOUDFLARE_AI_TOKEN", "t")]), None).is_some()); + assert!(configure(&lookup(&[("VIBEBUDDY_STT_BASE_URL", "http://localhost:8000/v1")]), None).is_some()); + assert!(configure(&lookup(&[("VIBEBUDDY_STT", "off")]), service()).is_none()); + assert!(configure(&lookup(&[("VIBEBUDDY_STT", "openai")]), service()).is_none()); + assert!(configure(&lookup(&[("VIBEBUDDY_STT", "service")]), None).is_none()); + assert!(configure(&lookup(&[("VIBEBUDDY_STT", "whisper.cpp")]), service()).is_none()); } #[test] - fn the_users_own_recognizer_wins_over_cloudflare() { - let both = configure(&lookup(&[ - ("CLOUDFLARE_ACCOUNT_ID", "a"), - ("CLOUDFLARE_AI_TOKEN", "t"), - ("VIBEBUDDY_STT_BASE_URL", "http://127.0.0.1:9/v1"), - ])) - .unwrap(); - // Nothing listens on port 9, so the error names the endpoint that was chosen. + fn the_users_own_recognizer_wins_over_the_voice_service() { + let both = configure(&lookup(&[("VIBEBUDDY_STT_BASE_URL", "http://127.0.0.1:9/v1")]), Some("http://127.0.0.1:8/".to_owned())).unwrap(); + // Nothing listens on ports 8 or 9, so the error names the endpoint that was chosen. let error = both.recognize(b"").unwrap_err(); assert!(error.contains("127.0.0.1:9"), "{error}"); } + #[test] + fn the_voice_service_gets_the_wav_as_the_body_and_the_language_in_the_query() { + let service = VoiceService { base_url: "https://api-staging.korekore.ai/".to_owned(), language: Some("zh".to_owned()) }; + let request = service.request(b"RIFFwav"); + assert_eq!(request.url, "https://api-staging.korekore.ai/v0/voice/transcribe?language=zh"); + assert_eq!(request.headers, [("Content-Type".to_owned(), "audio/wav".to_owned())]); + assert_eq!(request.body, b"RIFFwav"); + let detected = VoiceService { language: None, ..service }; + assert_eq!(detected.request(b"").url, "https://api-staging.korekore.ai/v0/voice/transcribe"); + + let heard = VoiceService::parse(200, r#"{"text":" 你好 ","language":"Chinese"}"#).unwrap(); + assert_eq!(heard, Transcript { text: "你好".to_owned(), language: Some("Chinese".to_owned()) }); + assert_eq!(VoiceService::parse(502, "recognition failed\n").unwrap_err(), "the voice service answered 502: recognition failed"); + } + #[test] fn the_settings_file_maps_onto_the_variables() { let settings = json!({"base_url": "https://stt.example/v1", "api_key": "k", "model": "m", "extra": 1}); diff --git a/daemon/src/synthesis.rs b/daemon/src/synthesis.rs index 76275131..445b796e 100644 --- a/daemon/src/synthesis.rs +++ b/daemon/src/synthesis.rs @@ -4,7 +4,8 @@ //! Behind [`Synthesizer`] so the backend can change. There is one: the voice service in `vibebuddy-cloud`, //! `POST {base}/v0/voice/speak` with `{"text","voice"}`, answering a chunked `audio/mpeg` stream (MPEG-2 Layer III, //! 24 kHz mono, 48 kbps) that starts as soon as there is audio. Builds other than releases use staging; a release -//! has none until the production service is deployed. `VIBEBUDDY_TTS_BASE_URL` sets the base by hand, or `off`. +//! has none until the production service is deployed. `VIBEBUDDY_TTS_BASE_URL` sets the base by hand, or `off`; +//! speech recognition ([`crate::speech`]) goes to the same service. use std::io::Read; use std::sync::Arc; @@ -169,10 +170,12 @@ pub(crate) fn voice_field<'a>(file: &'a str, key: &str) -> Option<&'a str> { /// The synthesizer the environment configures, if any. pub fn from_env() -> Option> { - configure(std::env::var(BASE_URL_VARIABLE).ok(), crate::relay::release()) + configure(service_base(std::env::var(BASE_URL_VARIABLE).ok(), crate::relay::release())) } -fn configure(base_url: Option, release: bool) -> Option> { +/// The voice service's base URL, which speech recognition ([`crate::speech`]) uses too: the variable's, else +/// staging outside a release. None when it is `off`, not http(s), or a release has none. +pub fn service_base(base_url: Option, release: bool) -> Option { let base_url = match base_url.map(|url| url.trim().to_owned()).filter(|url| !url.is_empty()) { Some(url) if url == "off" => return None, Some(url) => url, @@ -180,9 +183,14 @@ fn configure(base_url: Option, release: bool) -> Option STAGING_URL.to_owned(), }; if !base_url.starts_with("https://") && !base_url.starts_with("http://") { - warn!(%base_url, "the voice service's base URL is not http(s); spoken replies stay text only"); + warn!(%base_url, "the voice service's base URL is not http(s); it is not used"); return None; } + Some(base_url) +} + +fn configure(base_url: Option) -> Option> { + let base_url = base_url?; info!(%base_url, "speech synthesis: the voice service"); Some(Arc::new(VoiceService { base_url })) } @@ -223,9 +231,7 @@ mod tests { #[test] fn the_service_is_staging_outside_a_release_and_can_be_set_or_switched_off() { - let base = |url: Option<&str>, release| { - configure(url.map(str::to_owned), release).map(|_| ()).is_some() - }; + let base = |url: Option<&str>, release| configure(service_base(url.map(str::to_owned), release)).is_some(); assert!(base(None, false)); assert!(!base(None, true), "no production voice service yet"); assert!(base(Some("https://api.korekore.ai"), true)); diff --git a/docs/resident-session.md b/docs/resident-session.md index dcf1de5c..1da4622c 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -108,20 +108,21 @@ The end-to-end check is an ignored test, `e2e_a_real_recording_through_the_real_ ### Speech recognition -Behind the `Recognizer` trait, so the planned voice service in `vibebuddy-cloud` (called with the account session) can take over. For now there are two backends, picked at start: +Behind the `Recognizer` trait. Three backends, picked at start: | Backend | Settings | Used | | --- | --- | --- | -| The user's own recognizer: any OpenAI-compatible `POST {base}/audio/transcriptions` | `VIBEBUDDY_STT_BASE_URL`, `VIBEBUDDY_STT_API_KEY` (optional), `VIBEBUDDY_STT_MODEL` (default `whisper-1`) | When set; it wins over Cloudflare | -| Cloudflare Workers AI, `@cf/openai/whisper-large-v3-turbo` | `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_AI_TOKEN` | Otherwise; for development | +| The user's own recognizer: any OpenAI-compatible `POST {base}/audio/transcriptions` | `VIBEBUDDY_STT_BASE_URL`, `VIBEBUDDY_STT_API_KEY` (optional), `VIBEBUDDY_STT_MODEL` (default `whisper-1`) | When set | +| The voice service in `vibebuddy-cloud`: `POST {base}/v0/voice/transcribe` with the WAV as the body and `?language=` when set, answering `{"text","language"}`. It runs Groq's `whisper-large-v3-turbo` (about 1 s), falling back to Workers AI's | The base spoken replies use (`VIBEBUDDY_TTS_BASE_URL`, below): staging outside a release | Otherwise. A release has no voice service until production's is deployed with accounts, so a release has no recognizer unless the user sets their own | +| Cloudflare Workers AI, `@cf/openai/whisper-large-v3-turbo`, called directly | `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_AI_TOKEN` | Only when named; for development before the service recognized | -`VIBEBUDDY_STT=cloudflare|openai|off` forces the choice. `VIBEBUDDY_STT_LANGUAGE` (`zh`, `en`, …) tells the recognizer the language; unset, it detects it, and the log names what it detected. +`VIBEBUDDY_STT=service|openai|cloudflare|off` forces the choice. `VIBEBUDDY_STT_LANGUAGE` (`zh`, `en`, …) tells the recognizer the language; unset, it detects it, and the log names what it detected. Each setting is taken from the environment, else from `speech.json` in the config directory (`{"base_url","api_key","model","language"}`, the user's own recognizer), else from a `KEY=VALUE` file named by `VIBEBUDDY_STT_ENV_FILE` (development credentials, so they stay out of the daemon's environment and out of the repository). All are read when the daemon starts. `speech.json` is a file of its own, not part of `config.json`, because `PUT /v1/config` replaces the whole config and an App that doesn't know the field would wipe the key; whoever writes it (an App setting, later) writes it with mode 0600. Credentials are never logged, and errors name only the recognizer's host. ### Speech synthesis -Each spoken reply is also synthesized and streamed to the box (`daemon/src/synthesis.rs`, `Speaker` in `voice.rs`), behind the `Synthesizer` trait so the backend can change. There is one backend, the voice service in `vibebuddy-cloud`: `POST {base}/v0/voice/speak` with `{"text","voice"}` (text 1 to 1000 characters, `voice` as `:`, today `elevenlabs:`), answering a chunked `audio/mpeg` stream (MPEG-2 Layer III, 24 kHz mono, 48 kbps) that starts as soon as there is audio. v0 takes no credentials; the account session's header goes in `VoiceService::speak` when it does. +Each spoken reply is also synthesized and streamed to the box (`daemon/src/synthesis.rs`, `Speaker` in `voice.rs`), behind the `Synthesizer` trait so the backend can change. There is one backend, the voice service in `vibebuddy-cloud`: `POST {base}/v0/voice/speak` with `{"text","voice"}` (text 1 to 1000 characters, `voice` as `:`, today `elevenlabs:`), answering a chunked `audio/mpeg` stream (MPEG-2 Layer III, 24 kHz mono, 48 kbps) that starts as soon as there is audio. v0 takes no credentials, for synthesis or recognition; the account session's header goes in `VoiceService::speak` and `speech::VoiceService` when it does. | Setting | Effect | | --- | --- | From ad50a848e61b394ae68e3702fc497976170b4850 Mon Sep 17 00:00:00 2001 From: dragon Date: Thu, 15 Oct 2026 10:17:36 +0800 Subject: [PATCH 50/51] ADR-0017: recognition is the voice service's, Groq first Co-Authored-By: Claude Opus 5.5 --- ...ommands-go-to-a-resident-session-that-speaks-as-the-buddy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md b/docs/adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md index d7aa0d12..ed1d60cb 100644 --- a/docs/adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md +++ b/docs/adr/0017-voice-commands-go-to-a-resident-session-that-speaks-as-the-buddy.md @@ -23,7 +23,7 @@ The box has a microphone, and with Wi-Fi and the Relay (ADR-0012) it can sit awa - ADR-0007 still holds for lines: they are written in advance. Spoken replies are a second way the buddy speaks, written at the moment they are spoken, and they may talk about what the Agents are doing. The persona rule narrows to lines. - ADR-0009's App still calls no model. The recognition and synthesis service lives in `vibebuddy-cloud` beside the Relay but is not the Relay; it calls the TTS vendor of each Character's voice (ElevenLabs, Volcano Engine Doubao) with our keys, and stores neither audio nor text. -- Recognition is Workers AI Whisper (`whisper-large-v3-turbo`), which detects the language itself. A box linked over USB or the local network streams its audio over the link to the daemon, which calls recognition and sends the spoken reply's audio back the same way, so a box on the cable needs no Wi-Fi; over the Relay the box calls the voice service itself (ADR-0018). A user may set their own OpenAI-compatible transcription endpoint and key instead; the daemon calls it, the key never reaches us, and with it set audio goes over the link even over the Relay (ADR-0015 keeps it private). Box and computer messages: docs/protocol.md, "Voice commands". +- Recognition is the voice service's: Groq's Whisper (`whisper-large-v3-turbo`, about 1 s), with Workers AI's Whisper as the fallback; both detect the language themselves. A box linked over USB or the local network streams its audio over the link to the daemon, which calls the voice service and sends the spoken reply's audio back the same way, so a box on the cable needs no Wi-Fi; over the Relay the box calls the voice service itself (ADR-0018). A user may set their own OpenAI-compatible transcription endpoint and key instead; the daemon calls it, the key never reaches us, and with it set audio goes over the link even over the Relay (ADR-0015 keeps it private). Box and computer messages: docs/protocol.md, "Voice commands". - Voice is offered only to signed-in users, so accounts come first; whether it is paid is decided with accounts. Off by default. - Delivery is per Agent and belongs to its Adapter, which becomes two-way. For Codex the daemon is an app-server client: `thread/start` once, `thread/resume` after a restart, `turn/start` per voice command; the spoken reply arrives as a call to a client-defined `speak` tool (`dynamicTools`), so no MCP server is needed. The thread runs read-only with approvals declined. It lives in a Codex home of the daemon's own, sharing the user's login through a link to their `auth.json` and reading their Codex memories only through the daemon, because the ChatGPT App's app-server holds the writer lock of every thread in `~/.codex` it lists; the Codex App therefore can't open it, and `vibebuddy resident` prints its last exchanges instead. Verified on 2026-10-13 with codex-cli 0.162.0: existing ChatGPT login reused, about 10 s per turn, memory kept across a process restart, file writes refused. - Claude Code is the second brain, for users without Codex, authenticated only with the user's own Anthropic API key, never a claude.ai subscription or OAuth login, which [Anthropic's terms](https://code.claude.com/docs/en/legal-and-compliance) don't allow third-party products to use. The daemon drives the user's `claude` CLI headless (`-p` with stream-json, no Agent SDK), in a config dir of its own, with `speak` and `list_activities` from an MCP server of its own on a loopback port. The user picks Codex, Claude Code or automatic (Codex when it is ready); each keeps its own session for good. With it, the commands and what `list_activities` returns go to Anthropic, billed to the user's key. Details: docs/resident-session.md, "On Claude Code". From 13891e365ab45b3b1fb99dd6f37759c6973f5f17 Mon Sep 17 00:00:00 2001 From: dragon Date: Thu, 15 Oct 2026 10:22:26 +0800 Subject: [PATCH 51/51] Speak Doubao Characters in their own voices through the voice service A Character with ENGINE=volc now sends volc:, which the voice service speaks through Volcano Engine; only a Doubao 2.0 voice still gets the stock voice of its gender. Co-Authored-By: Claude Opus 5.5 --- daemon/src/synthesis.rs | 8 +++++--- docs/resident-session.md | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/daemon/src/synthesis.rs b/daemon/src/synthesis.rs index 445b796e..5f7bf1e5 100644 --- a/daemon/src/synthesis.rs +++ b/daemon/src/synthesis.rs @@ -144,8 +144,8 @@ pub(crate) const VOICE_FILES: &[(&str, &str)] = &[ /// The built-in default Character speaks with ElevenLabs' Jessica (voices/README.md); so does no Character pack, or /// one these files don't know. const DEFAULT_VOICE: &str = "elevenlabs:cgSgspJ2msm6clMCkdW9"; -/// ElevenLabs' stock Sarah and George, voices of no Character: one whose voice ElevenLabs doesn't have (the Doubao -/// ones, until the service speaks Doubao) gets the one of its `GENDER`, rather than another Character's voice. +/// ElevenLabs' stock Sarah and George, voices of no Character: one whose voice the service can't speak (a Doubao +/// 2.0 voice, which needs another resource id) gets the one of its `GENDER`, rather than another Character's voice. const STOCK_FEMALE: &str = "elevenlabs:EXAVITQu4vr4xnSDxMaL"; const STOCK_MALE: &str = "elevenlabs:JBFqnCBsd6RMkjVDRZzb"; @@ -158,6 +158,8 @@ pub fn voice_for(character: Option<&str>) -> String { let field = |key: &str| voice_field(file.1, key); match (field("ENGINE"), field("VOICE")) { (Some("elevenlabs"), Some(voice)) => format!("elevenlabs:{voice}"), + // The service speaks Doubao's 1.0 voices only. + (Some("volc"), Some(voice)) if field("VOLC_RESOURCE_ID").is_none() => format!("volc:{voice}"), _ if field("GENDER") == Some("male") => STOCK_MALE.to_owned(), _ => STOCK_FEMALE.to_owned(), } @@ -206,7 +208,7 @@ mod tests { fn each_character_speaks_in_the_voice_of_its_lines() { assert_eq!(voice_for(Some("ada")), "elevenlabs:tcSsCJFYrsHpK5rCQZMf"); assert_eq!(voice_for(Some("hank")), "elevenlabs:U6jH23hcPh0zH17nf3DT"); - assert_eq!(voice_for(Some("wanwanxiaohe")), STOCK_FEMALE, "a Doubao voice: the stock one of her gender"); + assert_eq!(voice_for(Some("wanwanxiaohe")), "volc:zh_female_wanwanxiaohe_moon_bigtts", "a Doubao 1.0 voice"); assert_eq!(voice_for(Some("robot")), DEFAULT_VOICE); assert_eq!(voice_for(None), DEFAULT_VOICE, "no Character pack"); assert_eq!(voice_for(Some("hsiaoyu")), DEFAULT_VOICE, "an old voice pack"); diff --git a/docs/resident-session.md b/docs/resident-session.md index 1da4622c..31b85dc4 100644 --- a/docs/resident-session.md +++ b/docs/resident-session.md @@ -122,7 +122,7 @@ Each setting is taken from the environment, else from `speech.json` in the confi ### Speech synthesis -Each spoken reply is also synthesized and streamed to the box (`daemon/src/synthesis.rs`, `Speaker` in `voice.rs`), behind the `Synthesizer` trait so the backend can change. There is one backend, the voice service in `vibebuddy-cloud`: `POST {base}/v0/voice/speak` with `{"text","voice"}` (text 1 to 1000 characters, `voice` as `:`, today `elevenlabs:`), answering a chunked `audio/mpeg` stream (MPEG-2 Layer III, 24 kHz mono, 48 kbps) that starts as soon as there is audio. v0 takes no credentials, for synthesis or recognition; the account session's header goes in `VoiceService::speak` and `speech::VoiceService` when it does. +Each spoken reply is also synthesized and streamed to the box (`daemon/src/synthesis.rs`, `Speaker` in `voice.rs`), behind the `Synthesizer` trait so the backend can change. There is one backend, the voice service in `vibebuddy-cloud`: `POST {base}/v0/voice/speak` with `{"text","voice"}` (text 1 to 1000 characters, `voice` as `:`: `elevenlabs:` or `volc:`), answering a chunked `audio/mpeg` stream (MPEG-2 Layer III, 24 kHz mono, 48 kbps) that starts as soon as there is audio. v0 takes no credentials, for synthesis or recognition; the account session's header goes in `VoiceService::speak` and `speech::VoiceService` when it does. | Setting | Effect | | --- | --- | @@ -133,7 +133,7 @@ Each spoken reply is also synthesized and streamed to the box (`daemon/src/synth - **Order.** Replies are synthesized one after another, in the order they were said, on a task of their own: a reply's `voice.reply` goes out the moment it is said and never waits for an earlier reply's audio, so the text of reply 1 may come between reply 0's audio pieces. Each reply's audio is forwarded as the bytes arrive, cut into pieces of at most 576 bytes (768 base64 characters), `seq` from 0, then `end`. `voice.done` waits for the last reply's `end`, so the turn is over only once everything has been sent; it is still the last message of the turn. - **Failures.** A status other than 200, or a stream cut before its end (the service closes the connection when it fails partway), ends that reply's audio at once with `"error":"synthesis"`, keeping what was sent; the box keeps the text. The next reply is still tried. When the resident session fails the turn, the reply being synthesized stops at its next piece, the rest are dropped, and `voice.error` follows. - **Long replies.** Over 1000 characters (counted in UTF-16, as the service counts), a reply is split at the end of a sentence, else at a comma or space; the pieces are requested one after another and stream as one reply, since MP3 frames stand alone. -- **Voices.** A reply is spoken in its Character's own voice, the one its lines were made with: `synthesis.rs` reads each Character's `characters//voice.env` at build time and sends `elevenlabs:` (our ElevenLabs account; the service uses `eleven_flash_v2_5`, multilingual, so a Chinese reply keeps an English Character's voice). A Character whose voice ElevenLabs doesn't have (the Chinese ones are Doubao, which the service doesn't speak yet) gets an ElevenLabs stock voice of its `GENDER` (from `voice.env`: Sarah or George) rather than another Character's voice. The robot and no pack get Jessica. The Character is the one the box reports (`VOICES`), else the one last written to it; a custom Character speaks in the voice of the Character lending it its lines, when the App said which (see "Persona and language"). +- **Voices.** A reply is spoken in its Character's own voice, the one its lines were made with: `synthesis.rs` reads each Character's `characters//voice.env` at build time and sends `elevenlabs:` (our ElevenLabs account; the service uses `eleven_flash_v2_5`, multilingual, so a Chinese reply keeps an English Character's voice). A Doubao Character (the Chinese ones, `ENGINE=volc`) sends `volc:`, which the service speaks through Volcano Engine; it speaks Doubao's 1.0 voices only, so a Character with `VOLC_RESOURCE_ID` set (a 2.0 voice) gets an ElevenLabs stock voice of its `GENDER` (from `voice.env`: Sarah or George) rather than another Character's voice. The robot and no pack get Jessica. The Character is the one the box reports (`VOICES`), else the one last written to it; a custom Character speaks in the voice of the Character lending it its lines, when the App said which (see "Persona and language"). - **Links.** Only where voice is offered, native USB and the local network: nothing goes over the Relay or the UART bridge. - **Timing.** Checked on 2026-10-13 against staging, from this computer through the daemon's own client (a new TLS connection each time): a Chinese sentence, first audio after 1.3 to 1.4 s and its `end` after 1.5 to 1.6 s for 4.5 s of speech; an English one, 0.5 to 1.8 s and 0.6 to 2.0 s for 3.8 s. `e2e_spoken_replies_through_the_real_voice_service` in `voice.rs` (ignored) repeats it and saves the reassembled MP3s.