feat: voice bridge overhaul, scenario manager, agent docs, CI validation

- Rewrote mascarade_voice_bridge.py (+441 lines, OPUS support, hint routing)
- Added scenario_manager (h/cpp) for runtime puzzle orchestration
- Enhanced zacus.sh dev tooling (+370 lines)
- Updated agent docs (AGENTS, TODO, firmware, tests)
- Removed obsolete async_tcp_fork submodule
- Added voice-bridge requirements
- Fixed validate.yml CI workflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
L'électron rare
2026-03-27 04:31:05 +01:00
parent c7f4c3c104
commit f673822755
21 changed files with 1352 additions and 95 deletions
+35 -12
View File
@@ -1,4 +1,4 @@
name: Validate Zacus content
name: Validate Zacus refactor
on:
push:
@@ -11,19 +11,42 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
python-version: "3.x"
- name: Install validator dependencies
run: python -m pip install --upgrade pip -r tools/requirements/validators.txt
- name: Validate runtime bundle coherence
- name: Install docs dependencies
run: python -m pip install -r tools/requirements/docs.txt
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend-scratch-v2/package-lock.json
- name: Install frontend dependencies
run: npm --prefix frontend-scratch-v2 ci
- name: Run canonical content checks
run: bash tools/test/run_content_checks.sh
- name: Validate runtime bundle
run: python tools/scenario/validate_runtime_bundle.py
- name: Validate scenario
run: python tools/scenario/validate_scenario.py game/scenarios/zacus_v2.yaml
- name: Export scenario markdown briefs
run: python tools/scenario/export_md.py game/scenarios/zacus_v2.yaml
- name: Validate audio manifest
run: python tools/audio/validate_manifest.py audio/manifests/zacus_v2_audio.yaml
- name: Validate printables manifest
run: python tools/printables/validate_manifest.py printables/manifests/zacus_v2_printables.yaml
- name: Lint studio frontend
run: npm --prefix frontend-scratch-v2 run lint
- name: Test studio frontend
run: npm --prefix frontend-scratch-v2 run test
- name: Build studio frontend
run: npm --prefix frontend-scratch-v2 run build
- name: Build docs
run: python -m mkdocs build --strict
+2 -2
View File
@@ -81,8 +81,8 @@ Logs/artifacts:
- Toute modification de structure doit être répercutée dans la documentation et l'onboarding.
- Les grosses reviews sont autorisees (rapport detaille + recommandations).
- Les gates build/smoke sont recommandees, mais non obligatoires sauf demande explicite.
- Consult et mettez à jour `docs/AGENT_TODO.md` avant toute opération importante : cest le tracker canonique des tâches dagent, mentionnez-y les étapes réalisées, les artefacts produits et toute impasse matérielle pour guider les agents suivants.
- Les logs et artefacts (`logs/`, `artifacts/`, etc.) ne doivent pas être committés ; notez leur existence (chemins, horodatages) dans `docs/AGENT_TODO.md` ou dans le rapport final au lieu de les versionner.
- Consult et mettez à jour `hardware/firmware/docs/AGENT_TODO.md` avant toute opération importante : cest le tracker canonique des tâches dagent, mentionnez-y les étapes réalisées, les artefacts produits et toute impasse matérielle pour guider les agents suivants.
- Les logs et artefacts (`logs/`, `artifacts/`, etc.) ne doivent pas être committés ; notez leur existence (chemins, horodatages) dans `hardware/firmware/docs/AGENT_TODO.md` ou dans le rapport final au lieu de les versionner.
---
+3 -3
View File
@@ -4,7 +4,7 @@
Docs agents govern `docs/`, `README.md`, `esp32_audio/README.md`, and any onboarding / briefing files (`.github/agents/`, `docs/_generated/`).
## Doit
- Avant toute mise à jour, lire `docs/AGENT_TODO.md` pour ne pas dupliquer les efforts de coordination.
- Avant toute mise à jour, lire `hardware/firmware/docs/AGENT_TODO.md` pour ne pas dupliquer les efforts de coordination.
- Refléter tout changement dorganisation ou de procédure dans `docs/TEST_SCRIPT_COORDINATOR.md`, `docs/TEST_COHERENCE_AUDIT_RUNBOOK.md`, et les AGENTS concernés.
- Maintenir `docs/_generated/COCKPIT_COMMANDS.md` en cohérence avec `tools/dev/cockpit_commands.yaml` via `python3 tools/dev/gen_cockpit_docs.py`.
- Documenter les nouveaux artefacts (gates/tests) dans la section adéquate du runbook et mentionner les chemins dans le reporting template.
@@ -12,7 +12,7 @@ Docs agents govern `docs/`, `README.md`, `esp32_audio/README.md`, and any onboar
## Artefacts
- Les docs nintègrent pas dartefacts binaires ; citez plutôt les dossiers `artifacts/` lorsque vous décrivez un protocole ou un gate réussi.
- Pour les instructions “TODO”, mettez à jour `docs/AGENT_TODO.md` (bloc 4/5) en parallèle des docs.
- Pour les instructions “TODO”, mettez à jour `hardware/firmware/docs/AGENT_TODO.md` (bloc 4/5) en parallèle des docs.
## Bonnes pratiques
- Chaque mise à jour de runbook ou guide doit inclure un rappel sur la nécessité de respecter `docs/AGENT_TODO.md` comme tracker unique.
- Chaque mise à jour de runbook ou guide doit inclure un rappel sur la nécessité de respecter `hardware/firmware/docs/AGENT_TODO.md` comme tracker unique.
+5 -5
View File
@@ -4,15 +4,15 @@
Firmware agents cover `esp32_audio/`, `ui/esp8266_oled/`, `ui/rp2040_tft/`, and shared `protocol/` code that drives the MCU binaries.
## Doit
- Lire puis mettre à jour `docs/AGENT_TODO.md` avant dagir (le tracker unique garde la trace de l’état de chaque gate).
- Lire puis mettre à jour `hardware/firmware/docs/AGENT_TODO.md` avant dagir (le tracker unique garde la trace de l’état de chaque gate).
- Respecter le contrat global (`AGENTS.md`) et les règles doutillage de `tools/dev/AGENTS.md` (gates/scripts centralisés, artefacts hors git).
- Sassurer que tout build se passe via `platformio.ini`/`build_all.sh` ou `Makefile fast-*`, et documenter les artefacts dans `docs/AGENT_TODO.md`.
- Pour les modifications de structure, ajouter un commentaire dans `docs/AGENT_TODO.md` + mentionner le commit dans `docs/TEST_SCRIPT_COORDINATOR.md`.
- Toujours signaler l’état des UI Link / LittleFS / I2S dans `docs/AGENT_TODO.md:6-14` lorsquon manipule ces composants.
- Sassurer que tout build se passe via `platformio.ini`/`build_all.sh` ou `Makefile fast-*`, et documenter les artefacts dans `hardware/firmware/docs/AGENT_TODO.md`.
- Pour les modifications de structure, ajouter un commentaire dans `hardware/firmware/docs/AGENT_TODO.md` + mentionner le commit dans `docs/TEST_SCRIPT_COORDINATOR.md`.
- Toujours signaler l’état des UI Link / LittleFS / I2S dans `hardware/firmware/docs/AGENT_TODO.md:6-14` lorsquon manipule ces composants.
## Reporting
- Chaque run doit produire des artefacts `artifacts/<phase>/<timestamp>` ; les chemins doivent figurer dans la case correspondante du TODO.
- Données hardware (logs, ports) restent hors git : référencer les fichiers dans `docs/AGENT_TODO.md` ou le rapport final.
- Données hardware (logs, ports) restent hors git : référencer les fichiers dans `hardware/firmware/docs/AGENT_TODO.md` ou le rapport final.
## References
- Branche principale : `docs/SPRINT_RECOMMENDATIONS.md`, `README.md`, `protocol/ui_link_v2.md`
+3 -3
View File
@@ -4,15 +4,15 @@
Tests agents focus on files under `tools/dev/`, `tools/test/`, `esp32_audio/tests/`, and the artifacts/log directories (`artifacts/`, `logs/`).
## Doit
- Toujours lire/mettre à jour `docs/AGENT_TODO.md` pour annoncer les gates en cours ou bloqués (ex. smoke, stress, audit).
- Toujours lire/mettre à jour `hardware/firmware/docs/AGENT_TODO.md` pour annoncer les gates en cours ou bloqués (ex. smoke, stress, audit).
- Exécuter les calibrations/gates via les scripts officiels (`tools/dev/run_matrix_and_smoke.sh`, `tools/dev/run_smoke_tests.sh`, `tools/dev/run_stress_tests.py`, `tools/test/audit_coherence.py`), puis consigner les artefacts sous `artifacts/`.
- Tenir `docs/TEST_SCRIPT_COORDINATOR.md` à jour avec les résultats et les chemins d’évidence (section “Exécutions récentes” + reporting template).
- Valider les commandes cockpit via `tools/dev/cockpit_commands.yaml` et `docs/_generated/COCKPIT_COMMANDS.md` (regénération avec `python3 tools/dev/gen_cockpit_docs.py` si besoin).
- Mentionner les échecs réseau (HTTP/WebSocket/WiFi) dans `docs/AGENT_TODO.md` et `docs/TEST_SCRIPT_COORDINATOR.md` pour la coordination.
- Mentionner les échecs réseau (HTTP/WebSocket/WiFi) dans `hardware/firmware/docs/AGENT_TODO.md` et `docs/TEST_SCRIPT_COORDINATOR.md` pour la coordination.
## Artefacts
- Les logs exigent `meta.json`, `git.txt`, `commands.txt`, `summary.md` (au minimum) ; vérifiez les répertoires demandés dans `docs/TEST_SCRIPT_COORDINATOR.md`.
- Pas de commit dartefacts ; les chemins doivent être mentionnés dans le TODO.
## Bonnes pratiques
- En cas de panne persistante (UI link, stress panic, scénario manquant), capturez les traces dans `artifacts/rc_live/<timestamp>` et notez la prochaine action dans `docs/AGENT_TODO.md`.
- En cas de panne persistante (UI link, stress panic, scénario manquant), capturez les traces dans `artifacts/rc_live/<timestamp>` et notez la prochaine action dans `hardware/firmware/docs/AGENT_TODO.md`.
Submodule hardware/firmware/async_tcp_fork deleted from 63d953d72c
+291
View File
@@ -1,3 +1,294 @@
## [2026-03-16] Graph-first cleanup slice
- Objective:
- keep enchaîning the Runtime 3 migration with a surgical cleanup pass,
- remove the canonical `steps_reference_order` bridge from the scenario YAML,
- align the studio and compiler helpers on graph-first `firmware.steps`.
- Changes applied:
- Canonical scenario cleanup:
- removed `firmware.steps_reference_order` from `game/scenarios/zacus_v2.yaml`.
- Studio/runtime alignment:
- `frontend-scratch-v2/src/lib/scenario.ts` now treats `firmware.steps` as canonical and only falls back to `firmware.steps_reference_order`,
not a top-level `steps_reference_order`.
- `frontend-scratch-v2/src/lib/runtime3.ts` now resolves the Runtime 3 entry step from nested firmware data only.
- `frontend-scratch-v2/src/types.ts` moves the compatibility bridge under `firmware.steps_reference_order`.
- `frontend-scratch-v2/tests/scenario-runtime3.test.ts` now asserts the builder no longer emits a top-level
`steps_reference_order`.
- Runtime 3 compiler compatibility:
- `tools/scenario/runtime3_common.py` now prefers `firmware.steps_reference_order` over a legacy top-level fallback
when it needs an ordered import path.
- Spec/todo refresh:
- updated active plan/todo/spec files to record that the canonical scenario is now graph-only.
- Validation:
- `bash tools/test/run_content_checks.sh --install-missing` -> PASS
- `npm --prefix frontend-scratch-v2 run test` -> PASS
- Evidence artifacts:
- Safety checkpoint:
- `/tmp/zacus_checkpoint/20260316-073226_wip.patch`
- `/tmp/zacus_checkpoint/20260316-073226_status.txt`
- Remaining next steps:
- remove the last compatibility bridge from non-canonical import/export paths,
- wire native Runtime 3 deploy actions once firmware accepts native IR payloads,
- run the full build gate matrix after this cleanup slice.
## [2026-03-16] Frontend regression gate wiring
- Objective:
- harden the studio migration with a first-class frontend test gate,
- keep graph-first behavior protected in CI and local shells,
- align the local AI gateway prompt with the new canonical YAML shape.
- Changes applied:
- Added frontend regression tests in:
- `frontend-scratch-v2/tests/scenario-runtime3.test.ts`
- Added frontend test tooling:
- `frontend-scratch-v2/package.json` -> `npm test`
- `Makefile` -> `frontend-test`
- `tools/dev/zacus.sh` -> `frontend-test`
- `.github/workflows/validate.yml` -> frontend test step
- The new tests cover:
- graph-first YAML validation without `steps_reference_order`,
- YAML parsing from `firmware.steps`,
- Runtime 3 entry-step compilation from `firmware.initial_step`,
- builder output compatibility.
- Updated `tools/dev/local_studio_ai_gateway.py` so the generation prompt now asks for canonical Zacus V2 YAML with `firmware.initial_step` and `firmware.steps`.
- Validation:
- `npm --prefix frontend-scratch-v2 install` -> PASS
- Note: npm reported `3 vulnerabilities` (`2 moderate`, `1 high`) but install completed.
- `npm --prefix frontend-scratch-v2 run test` -> PASS
- `bash tools/dev/zacus.sh frontend-test` -> PASS
- `make frontend-test` -> PASS
- `npm --prefix frontend-scratch-v2 run lint` -> PASS
- `npm --prefix frontend-scratch-v2 run build` -> PASS
- Warning only: Vite bundle size advisory still present.
- `bash tools/test/run_content_checks.sh --install-missing` -> PASS
- `python3 -m mkdocs build --strict` -> PASS
- Evidence artifacts:
- Safety checkpoint:
- `/tmp/zacus_checkpoint/20260316-065010_wip.patch`
- `/tmp/zacus_checkpoint/20260316-065010_status.txt`
- Remaining next steps:
- decide whether to address the npm audit findings in a dedicated dependency hygiene pass,
- remove the remaining `steps_reference_order` bridge from consumers,
- wire native Runtime 3 deploy actions into the studio once firmware accepts them.
## [2026-03-16] Studio graph adoption + runbook cleanup
- Objective:
- continue the migration after the canonical graph landing,
- make the studio consume graph-first scenario data without a hard dependency on `steps_reference_order`,
- clean more active runbook/agent references still pointing at the old tracker path.
- Changes applied:
- Frontend graph adoption:
- `frontend-scratch-v2/src/lib/scenario.ts` now validates scenario order from `firmware.steps` first,
while still tolerating `steps_reference_order` as a compatibility bridge.
- `frontend-scratch-v2/src/lib/runtime3.ts` now resolves `entry_step_id` from `firmware.initial_step`
or the first firmware step before falling back to older fields.
- `frontend-scratch-v2/src/types.ts` makes `steps_reference_order` optional.
- Studio UX:
- `frontend-scratch-v2/src/components/Dashboard.tsx` now exposes a Runtime 3 adapter card
showing contract, load status, scenario id, entry step, migration mode, counts, schema, and bundle path.
- Active cleanup:
- updated more active agent/runbook files that still referenced `docs/AGENT_TODO.md`:
- `.github/agents/ALIGNMENT_COMPLETE.md`
- `.github/agents/PHASE_LAUNCH_PLAN.md`
- `hardware/firmware/AGENTS_DOCS.md`
- `hardware/firmware/AGENTS_FIRMWARE.md`
- `hardware/firmware/AGENTS_TESTS.md`
- `hardware/firmware/docs/QUICKSTART.md`
- `hardware/ui_freenove_allinone/RC_FINAL_BOARD.md`
- Validation:
- `bash tools/test/run_content_checks.sh --install-missing` -> PASS
- `npm --prefix frontend-scratch-v2 run lint` -> PASS
- `npm --prefix frontend-scratch-v2 run build` -> PASS
- Warning only: Vite bundle size advisory still present.
- `python3 -m mkdocs build --strict` -> PASS
- `pio run -d hardware/firmware -e freenove_esp32s3` -> PASS
- RAM: `84.3%` (`276264 / 327680`)
- Flash: `39.7%` (`2496997 / 6291456`)
- `pio run -d hardware/firmware -e esp8266_oled` -> PASS
- Evidence artifacts:
- Safety checkpoint:
- `/tmp/zacus_checkpoint/20260316-063851_wip.patch`
- `/tmp/zacus_checkpoint/20260316-063851_status.txt`
- Remaining next steps:
- remove `steps_reference_order` once the remaining consumers are migrated,
- wire Runtime 3 deploy/refresh actions in the studio when the firmware adapter accepts native IR payloads,
- continue the cleanup pass on non-critical historical docs only after isolating it from the dirty worktree.
## [2026-03-16] Runtime 3 graph migration + active cleanup pass
- Objective:
- continue the refactor waves with the next real runtime slice,
- migrate the canonical scenario from ordered steps to explicit `firmware.steps`,
- clean active root-level legacy references without triggering a destructive cleanup pass.
- Changes applied:
- Migrated `game/scenarios/zacus_v2.yaml` to an explicit firmware graph:
- added `firmware.initial_step`,
- added `firmware.steps`,
- preserved `firmware.steps_reference_order` temporarily as a compatibility bridge.
- Canonical transitions are now explicit in YAML:
- `STEP_LA_DETECTOR` uses `unlock`,
- `STEP_RTC_ESP_ETAPE1` and `STEP_RTC_ESP_ETAPE2` use `espnow`,
- `STEP_WIN_ETAPE1` uses `audio_done`,
- `STEP_QR_DETECTOR` uses `unlock`.
- Tightened validators and exports:
- `tools/scenario/validate_scenario.py` now validates `firmware.steps`,
- `tools/scenario/export_md.py` now prefers `firmware.steps`,
- `tools/scenario/verify_runtime3_pivots.py` now requires `migration_mode=firmware_import`.
- Runtime 3 tests now assert the canonical scenario compiles in `firmware_import` mode and preserves unlock semantics on the key detectors.
- CI cleanup:
- `.github/workflows/validate.yml` now installs docs dependencies and runs `mkdocs build --strict`.
- Active legacy cleanup:
- updated root agent/spec references still pointing to `zacus_v1` or stale `docs/AGENT_TODO.md` paths in active coordination files.
- Validation:
- `bash tools/test/run_content_checks.sh --install-missing` -> PASS
- Runtime 3 compile result now shows `migration_mode=firmware_import`
- `python3 tools/scenario/export_runtime3_firmware_bundle.py game/scenarios/zacus_v2.yaml` -> PASS
- `npm --prefix frontend-scratch-v2 run lint` -> PASS
- `npm --prefix frontend-scratch-v2 run build` -> PASS
- Warning only: Vite bundle size advisory still present.
- `python3 -m mkdocs build --strict` -> PASS
- `pio run -d hardware/firmware -e freenove_esp32s3` -> PASS
- RAM: `84.3%` (`276264 / 327680`)
- Flash: `39.7%` (`2496997 / 6291456`)
- `pio run -d hardware/firmware -e esp8266_oled` -> PASS
- Evidence artifacts:
- Safety checkpoint:
- `/tmp/zacus_checkpoint/20260316-063356_wip.patch`
- `/tmp/zacus_checkpoint/20260316-063356_status.txt`
- Firmware Runtime 3 artifact:
- `hardware/firmware/data/story/runtime3/DEFAULT.json`
- Remaining next steps:
- remove the transitional `steps_reference_order` bridge after consumers fully adopt `firmware.steps`,
- surface Runtime 3 metadata as a first-class panel in the studio,
- validate the Runtime 3 adapter route on real hardware with flash/smoke evidence.
## [2026-03-16] Runtime 3 adapter follow-through + validation closure
- Objective:
- continue the Zacus refactor waves after the baseline slice,
- close the Runtime 3 adapter TODOs that were still open,
- keep the hardware evidence tracker aligned with the new canonical validation flow.
- Changes applied:
- Added Runtime 3 regression tests in `tests/runtime3/test_runtime3_routes.py`.
- Covers the canonical route:
- `STEP_U_SON_PROTO`
- `STEP_LA_DETECTOR`
- `STEP_RTC_ESP_ETAPE1`
- `STEP_WIN_ETAPE1`
- `STEP_WARNING`
- `STEP_LEFOU_DETECTOR`
- `STEP_RTC_ESP_ETAPE2`
- `STEP_QR_DETECTOR`
- `STEP_FINAL_WIN`
- Covers explicit graph-priority behavior for `firmware.steps` fixtures.
- Added `runtime3-test` to:
- `Makefile`
- `tools/test/run_content_checks.sh`
- `tools/dev/zacus.sh`
- Exported the firmware Runtime 3 bundle to:
- `hardware/firmware/data/story/runtime3/DEFAULT.json`
- Firmware adapter path now exposes:
- `GET /api/runtime3/status`
- `GET /api/runtime3/document`
- `/api/status` now reports `story.runtime_contract` and `runtime3` metadata.
- Updated coordination docs/todos/plans to reflect:
- Runtime 3 adapter discovery is landed,
- canonical validation now includes Runtime 3 regression tests,
- remaining next slice is the full `firmware.steps` graph migration.
- Corrected top-level agent/doc references that still pointed to `docs/AGENT_TODO.md` or `zacus_v1` in the active coordination path.
- Validation:
- `python3 tools/scenario/verify_runtime3_pivots.py --help` -> PASS
- `python3 tools/scenario/export_runtime3_firmware_bundle.py --help` -> PASS
- `bash tools/dev/zacus.sh --help` -> PASS
- `bash tools/test/run_content_checks.sh --help` -> PASS
- `bash tools/test/run_content_checks.sh --install-missing` -> PASS
- `python3 tools/scenario/export_runtime3_firmware_bundle.py game/scenarios/zacus_v2.yaml` -> PASS
- `bash tools/dev/zacus.sh runtime3-verify` -> PASS
- `bash tools/dev/zacus.sh runtime3-firmware-bundle` -> PASS
- `npm --prefix frontend-scratch-v2 run lint` -> PASS
- `npm --prefix frontend-scratch-v2 run build` -> PASS
- Warning only: Vite bundle still exceeds the 500 kB advisory threshold.
- `python3 -m mkdocs build --strict` -> PASS
- `pio run -d hardware/firmware -e freenove_esp32s3` -> PASS
- RAM: `84.3%` (`276264 / 327680`)
- Flash: `39.7%` (`2496997 / 6291456`)
- `pio run -d hardware/firmware -e esp8266_oled` -> PASS
- Evidence artifacts:
- Safety checkpoint:
- `/tmp/zacus_checkpoint/20260316-054341_wip.patch`
- `/tmp/zacus_checkpoint/20260316-054341_status.txt`
- Firmware Runtime 3 artifact:
- `hardware/firmware/data/story/runtime3/DEFAULT.json`
- Remaining next steps:
- migrate `game/scenarios/zacus_v2.yaml` from `firmware.steps_reference_order` to explicit `firmware.steps`,
- render Runtime 3 adapter metadata as a first-class panel in the studio,
- flash and smoke the Runtime 3 adapter route on real hardware.
## [2026-03-16] Zacus refactor baseline + build recovery
- Objective:
- land the first Runtime 3/tooling/docs/studio slice of the Zacus refactor,
- recover the broken React/Blockly build,
- restore Freenove build margin after the DRAM overflow.
- Changes applied:
- Added refactor coordination canon under `memory/`, `plans/`, and `todos/`.
- Added Runtime 3 tooling:
- `tools/scenario/runtime3_common.py`
- `tools/scenario/compile_runtime3.py`
- `tools/scenario/simulate_runtime3.py`
- Updated `frontend-scratch-v2` for:
- Blockly transition authoring,
- YAML + Runtime 3 IR previews,
- TypeScript-safe API/runtime typing.
- Updated root docs/specs and MkDocs architecture maps.
- Updated `tools/test/run_content_checks.sh`, `tools/dev/zacus.sh`, `Makefile`, and CI validation flow.
- Reduced `UI_LV_MEM_SIZE_KB` from `128` to `120` in `hardware/firmware/platformio.ini`.
- Validation:
- `bash tools/test/run_content_checks.sh --install-missing` -> PASS
- `bash tools/dev/zacus.sh runtime3-compile` -> PASS
- `npm --prefix frontend-scratch-v2 run lint` -> PASS
- `npm --prefix frontend-scratch-v2 run build` -> PASS
- `python3 -m mkdocs build --strict` -> PASS
- `pio run -d hardware/firmware -e freenove_esp32s3` -> PASS
- RAM: `84.3%` (`276120 / 327680`)
- Flash: `39.6%` (`2491997 / 6291456`)
- `pio run -d hardware/firmware -e esp8266_oled` -> PASS
- Evidence artifacts:
- Safety checkpoint:
- `/tmp/zacus_checkpoint/20260316-051424_wip.patch`
- `/tmp/zacus_checkpoint/20260316-051424_status.txt`
- Runtime 3 artifact:
- `artifacts/runtime3/20260316-043303/`
- Remaining next steps:
- load Runtime 3 artifacts from firmware instead of relying on migration-only generation,
- add fixture-based tests for `LA 440`, `LEFOU`, and `QR WIN`,
- continue quarantining legacy frontend/spec references before deletion.
## [2026-03-01] RESOLVED: A252 Audio Optimizations (3 Critical Fixes)
**Status: ✅ COMPLETE & DEPLOYED**
+1 -1
View File
@@ -215,4 +215,4 @@ SYS_RTOS_STATUS
- Mapping I2S out Freenove: `BCK=42`, `WS=41`, `DOUT=1` (`FREENOVE_I2S_BCK`, `FREENOVE_I2S_WS`, `FREENOVE_I2S_DOUT`).
- Mapping micro I2S in: `SCK=3`, `WS=14`, `DIN=46` (`FREENOVE_I2S_IN_SCK`, `FREENOVE_I2S_IN_WS`, `FREENOVE_I2S_IN_DIN`).
- Tester avec `MIC_TUNER_STATUS ON 200` et `HW_STATUS_JSON` pour vérifier fréquence/confiance/niveau.
- En cas d’échec, consigner levidence (artefact + log + commande) dans `docs/AGENT_TODO.md`.
- En cas d’échec, consigner levidence (artefact + log + commande) dans `hardware/firmware/docs/AGENT_TODO.md`.
@@ -4,20 +4,20 @@
- Display: `320x480`
- Tick: `33 ms` (`~30 FPS` target)
- Color mode default: `8bpp` (`LV_COLOR_DEPTH=8`)
- `LV_MEM_SIZE`: `UI_LV_MEM_SIZE_KB` (default `160 KB`)
- `LV_MEM_SIZE`: `UI_LV_MEM_SIZE_KB` (current target `120 KB`)
## Buffers
### Draw buffers
Default (`UI_DRAW_BUF_LINES=40`):
- Per buffer size = `320 * 40 * sizeof(lv_color_t)`
- In 8bpp mode: `~12.5 KB` per buffer
- Double-buffer total: `~25 KB`
Default (`UI_DRAW_BUF_LINES=16`):
- Per buffer size = `320 * 16 * sizeof(lv_color_t)`
- In 8bpp mode: `~5 KB` per buffer
- Double-buffer total: `~10 KB`
### Transfer buffer
- `UI_DMA_TRANS_BUF_LINES=40`
- `UI_DMA_TRANS_BUF_LINES=16`
- Buffer type: RGB565 transfer (`uint16_t`)
- Size = `320 * 40 * 2` = `~25 KB`
- Size = `320 * 16 * 2` = `~10 KB`
### Full-frame bench (optional)
- 8bpp full frame: `320 * 480 * 1` = `~150 KB`
@@ -38,4 +38,3 @@ Use serial commands:
2. Increase `UI_DRAW_BUF_LINES` only when internal free DMA heap is stable.
3. Increase `UI_LV_MEM_SIZE_KB` only if runtime LVGL monitor shows low free headroom.
4. Use PSRAM line buffers only when required, with `dma_trans_buf_` still in internal DMA SRAM.
@@ -15,13 +15,13 @@ bool sameText(const char* lhs, const char* rhs) {
} // namespace
int8_t storyFindStepIndex(const ScenarioDef& scenario, const char* stepId) {
int16_t storyFindStepIndex(const ScenarioDef& scenario, const char* stepId) {
if (scenario.steps == nullptr || scenario.stepCount == 0U || stepId == nullptr || stepId[0] == '\0') {
return -1;
}
for (uint8_t i = 0U; i < scenario.stepCount; ++i) {
if (sameText(scenario.steps[i].id, stepId)) {
return static_cast<int8_t>(i);
return static_cast<int16_t>(i);
}
}
return -1;
@@ -95,5 +95,5 @@ struct StorySnapshot {
uint8_t queuedEvents;
};
int8_t storyFindStepIndex(const ScenarioDef& scenario, const char* stepId);
int16_t storyFindStepIndex(const ScenarioDef& scenario, const char* stepId);
bool storyValidateScenarioDef(const ScenarioDef& scenario, StoryValidationError* outError);
+1 -1
View File
@@ -133,7 +133,7 @@ build_flags =
-DUI_FX_SPRITE_H=120
-DUI_FX_TARGET_FPS=18
-DFREENOVE_ENABLE_TASK_TOPOLOGY=0
-DUI_LV_MEM_SIZE_KB=128
-DUI_LV_MEM_SIZE_KB=120
-DUI_FONT_PIXEL_ENABLE=1
-DUI_FONT_TITLE_XL_ENABLE=1
-DUI_ENABLE_MEM_MONITOR=0
@@ -61,7 +61,7 @@ Recommandation: ajouter des commentaires dans ui_freenove_config.h et tester
3. **Utiliser le fallback I2S**: remplacer tout appel à `dacWrite` par une routine I2S (`i2sWriteSample`).
4. **Initialiser I2S**: suivre lexemple Espressif pour la configuration du canal, du clock, des slots et des GPIO.
5. **Tester laudio**: utiliser un signal simple (ex: sinusoïde) pour valider la sortie.
6. **Documenter la traçabilité**: reporter les étapes, patchs et artefacts dans [docs/AGENT_TODO.md](docs/AGENT_TODO.md).
6. **Documenter la traçabilité**: reporter les étapes, patchs et artefacts dans `hardware/firmware/docs/AGENT_TODO.md`.
### Liens techniques
@@ -109,7 +109,7 @@ Recommandation: ajouter des commentaires dans ui_freenove_config.h et tester
# UI link : retry et diagnostic
- Le check UI link (connexion UI ESP32/ESP8266) intègre désormais une logique de retry automatique (3 essais par défaut, paramétrable via `ZACUS_UI_LINK_RETRIES`).
- En cas d’échec, le script logue chaque tentative, synchronise l’évidence dans `docs/AGENT_TODO.md`, et produit un rapport détaillé dans les artefacts.
- En cas d’échec, le script logue chaque tentative, synchronise l’évidence dans `hardware/firmware/docs/AGENT_TODO.md`, et produit un rapport détaillé dans les artefacts.
- Toute évolution ou échec du protocole UI link est traçable dans les logs et la documentation.
# cockpit.sh : automatisation smoke test
@@ -119,7 +119,7 @@ Recommandation: ajouter des commentaires dans ui_freenove_config.h et tester
# Synchronisation evidence/logs
- Chaque run (succès ou échec) synchronise l’évidence (artefacts, logs, verdict) dans `docs/AGENT_TODO.md`.
- Chaque run (succès ou échec) synchronise l’évidence (artefacts, logs, verdict) dans `hardware/firmware/docs/AGENT_TODO.md`.
- Les artefacts et logs sont produits dans `hardware/firmware/artifacts/` et `hardware/firmware/logs/`.
- La traçabilité est assurée pour chaque run, avec horodatage, chemin, et verdict.
@@ -16,6 +16,23 @@ struct ScenarioSnapshot {
bool mp3_gate_open = false;
};
struct Runtime3ArtifactInfo {
bool discovered = false;
bool loaded = false;
size_t size_bytes = 0U;
uint32_t scenario_version = 0U;
uint16_t step_count = 0U;
uint16_t transition_count = 0U;
String path;
String schema_version;
String scenario_id;
String entry_step_id;
String source_kind;
String generated_by;
String migration_mode;
String error;
};
class ScenarioManager {
public:
static const char* readScenarioField(JsonVariantConst root,
@@ -39,6 +56,7 @@ class ScenarioManager {
bool gotoScene(const char* scene_id, uint32_t now_ms, const char* source);
ScenarioSnapshot snapshot() const;
const Runtime3ArtifactInfo& runtime3Artifact() const;
bool consumeSceneChanged();
bool consumeAudioRequest(String* out_audio_pack_id);
uint32_t transitionEventMask() const;
@@ -59,6 +77,8 @@ class ScenarioManager {
static constexpr uint8_t kMaxStepResourceOverrides = 24U;
void clearStepResourceOverrides();
void clearRuntime3ArtifactInfo();
void loadRuntime3ArtifactInfo(const char* scenario_file_path, const char* scenario_id);
void loadStepResourceOverrides(const char* scenario_file_path);
const StepResourceOverride* findStepResourceOverride(const char* step_id) const;
void applyStepResourceOverride(const StepDef* step,
@@ -93,4 +113,5 @@ class ScenarioManager {
String initial_step_override_;
StepResourceOverride step_resource_overrides_[kMaxStepResourceOverrides];
uint8_t step_resource_override_count_ = 0U;
Runtime3ArtifactInfo runtime3_artifact_;
};
@@ -5180,12 +5180,31 @@ bool webDispatchAction(const String& action_raw) {
return dispatchControlAction(action_raw, millis(), nullptr);
}
void webFillRuntime3Status(JsonObject runtime3) {
const Runtime3ArtifactInfo& artifact = g_scenario.runtime3Artifact();
runtime3["discovered"] = artifact.discovered;
runtime3["loaded"] = artifact.loaded;
runtime3["path"] = artifact.path;
runtime3["schema_version"] = artifact.schema_version;
runtime3["scenario_id"] = artifact.scenario_id;
runtime3["scenario_version"] = artifact.scenario_version;
runtime3["entry_step_id"] = artifact.entry_step_id;
runtime3["source_kind"] = artifact.source_kind;
runtime3["generated_by"] = artifact.generated_by;
runtime3["migration_mode"] = artifact.migration_mode;
runtime3["step_count"] = artifact.step_count;
runtime3["transition_count"] = artifact.transition_count;
runtime3["size_bytes"] = artifact.size_bytes;
runtime3["error"] = artifact.error;
}
void webBuildStatusDocument(StaticJsonDocument<4096>* out_document) {
if (out_document == nullptr) {
return;
}
const NetworkManager::Snapshot net = g_network.snapshot();
const ScenarioSnapshot scenario = g_scenario.snapshot();
const Runtime3ArtifactInfo& runtime3_artifact = g_scenario.runtime3Artifact();
out_document->clear();
JsonObject network = (*out_document)["network"].to<JsonObject>();
@@ -5214,6 +5233,7 @@ void webBuildStatusDocument(StaticJsonDocument<4096>* out_document) {
story["step"] = stepIdFromSnapshot(scenario);
story["screen"] = (scenario.screen_scene_id != nullptr) ? scenario.screen_scene_id : "";
story["audio_pack"] = (scenario.audio_pack_id != nullptr) ? scenario.audio_pack_id : "";
story["runtime_contract"] = runtime3_artifact.loaded ? "runtime3+story_v2_adapter" : "story_v2";
JsonObject audio = (*out_document)["audio"].to<JsonObject>();
audio["playing"] = g_audio.isPlaying();
@@ -5234,6 +5254,9 @@ void webBuildStatusDocument(StaticJsonDocument<4096>* out_document) {
JsonObject media = (*out_document)["media"].to<JsonObject>();
webFillMediaStatus(media, millis());
JsonObject runtime3 = (*out_document)["runtime3"].to<JsonObject>();
webFillRuntime3Status(runtime3);
const runtime::resource::ResourceCoordinatorSnapshot resource_snapshot = g_resource_coordinator.snapshot();
const UiMemorySnapshot ui_snapshot = g_ui.memorySnapshot();
JsonObject resource = (*out_document)["resource"].to<JsonObject>();
@@ -5261,6 +5284,27 @@ void webSendStatus() {
webSendJsonDocument(document);
}
void webSendRuntime3Status() {
StaticJsonDocument<1024> document;
webFillRuntime3Status(document.to<JsonObject>());
webSendJsonDocument(document);
}
void webSendRuntime3Document() {
const Runtime3ArtifactInfo& artifact = g_scenario.runtime3Artifact();
if (!artifact.loaded || artifact.path.isEmpty()) {
g_web_server.send(404, "application/json", "{\"ok\":false,\"error\":\"runtime3_not_loaded\"}");
return;
}
File file = LittleFS.open(artifact.path.c_str(), "r");
if (!file) {
g_web_server.send(500, "application/json", "{\"ok\":false,\"error\":\"runtime3_open_failed\"}");
return;
}
g_web_server.streamFile(file, "application/json");
file.close();
}
void webSendStatusSse() {
StaticJsonDocument<4096> document;
webBuildStatusDocument(&document);
@@ -5307,6 +5351,14 @@ void setupWebUiImpl() {
webSendStatus();
});
webOnApi("/api/runtime3/status", HTTP_GET, []() {
webSendRuntime3Status();
});
webOnApi("/api/runtime3/document", HTTP_GET, []() {
webSendRuntime3Document();
});
webOnApi("/api/stream", HTTP_GET, []() {
webSendStatusSse();
});
@@ -89,6 +89,48 @@ bool loadScenarioIdFromFile(const char* scenario_file_path, String* out_scenario
return true;
}
bool loadRuntime3PathFromFile(const char* scenario_file_path, String* out_runtime3_path) {
if (scenario_file_path == nullptr || scenario_file_path[0] == '\0' || out_runtime3_path == nullptr) {
return false;
}
if (!LittleFS.exists(scenario_file_path)) {
return false;
}
File file = LittleFS.open(scenario_file_path, "r");
if (!file) {
return false;
}
StaticJsonDocument<96> filter;
filter["runtime3_file"] = true;
filter["runtime3_path"] = true;
filter["story"]["runtime3_file"] = true;
filter["story"]["runtime3_path"] = true;
StaticJsonDocument<512> document;
const DeserializationError error =
deserializeJson(document, file, DeserializationOption::Filter(filter));
file.close();
if (error) {
return false;
}
const char* const runtime3_keys[] = {"runtime3_file", "runtime3_path"};
const char* runtime3_path = ScenarioManager::readScenarioField(
document.as<JsonVariantConst>(), runtime3_keys, sizeof(runtime3_keys) / sizeof(runtime3_keys[0]));
if (runtime3_path == nullptr) {
runtime3_path = ScenarioManager::readScenarioField(
document["story"].as<JsonVariantConst>(), runtime3_keys, sizeof(runtime3_keys) / sizeof(runtime3_keys[0]));
}
if (runtime3_path == nullptr || runtime3_path[0] == '\0') {
return false;
}
*out_runtime3_path = runtime3_path;
return true;
}
} // namespace
const char* ScenarioManager::readScenarioField(JsonVariantConst root,
@@ -123,6 +165,7 @@ bool ScenarioManager::begin(const char* scenario_file_path) {
debug_transition_bypass_enabled_ = false;
initial_step_override_.remove(0);
clearStepResourceOverrides();
clearRuntime3ArtifactInfo();
String selected_scenario_id;
if (loadScenarioIdFromFile(scenario_file_path, &selected_scenario_id)) {
scenario_ = storyScenarioV2ById(selected_scenario_id.c_str());
@@ -157,6 +200,7 @@ bool ScenarioManager::begin(const char* scenario_file_path) {
}
loadStepResourceOverrides(scenario_file_path);
loadRuntime3ArtifactInfo(scenario_file_path, scenario_->id);
reset();
return true;
}
@@ -166,6 +210,7 @@ bool ScenarioManager::beginById(const char* scenario_id) {
debug_transition_bypass_enabled_ = false;
initial_step_override_.remove(0);
clearStepResourceOverrides();
clearRuntime3ArtifactInfo();
if (scenario_id != nullptr && scenario_id[0] != '\0') {
scenario_ = storyScenarioV2ById(scenario_id);
@@ -183,6 +228,7 @@ bool ScenarioManager::beginById(const char* scenario_id) {
} else {
Serial.printf("[SCENARIO] warning: validation failed for %s\n", scenario_->id);
}
loadRuntime3ArtifactInfo(nullptr, scenario_->id);
reset();
return true;
}
@@ -378,6 +424,10 @@ ScenarioSnapshot ScenarioManager::snapshot() const {
return out;
}
const Runtime3ArtifactInfo& ScenarioManager::runtime3Artifact() const {
return runtime3_artifact_;
}
bool ScenarioManager::consumeSceneChanged() {
const bool changed = scene_changed_;
scene_changed_ = false;
@@ -662,6 +712,94 @@ void ScenarioManager::clearStepResourceOverrides() {
step_resource_override_count_ = 0U;
}
void ScenarioManager::clearRuntime3ArtifactInfo() {
runtime3_artifact_ = Runtime3ArtifactInfo{};
}
void ScenarioManager::loadRuntime3ArtifactInfo(const char* scenario_file_path, const char* scenario_id) {
clearRuntime3ArtifactInfo();
String runtime3_path;
if (!loadRuntime3PathFromFile(scenario_file_path, &runtime3_path)) {
if (scenario_id == nullptr || scenario_id[0] == '\0') {
return;
}
runtime3_path = "/story/runtime3/";
runtime3_path += scenario_id;
runtime3_path += ".json";
}
runtime3_artifact_.discovered = true;
runtime3_artifact_.path = runtime3_path;
if (!LittleFS.exists(runtime3_path.c_str())) {
runtime3_artifact_.error = "missing";
Serial.printf("[RUNTIME3] artifact missing path=%s\n", runtime3_path.c_str());
return;
}
File file = LittleFS.open(runtime3_path.c_str(), "r");
if (!file) {
runtime3_artifact_.error = "open_failed";
Serial.printf("[RUNTIME3] open failed path=%s\n", runtime3_path.c_str());
return;
}
const size_t file_size = static_cast<size_t>(file.size());
runtime3_artifact_.size_bytes = file_size;
if (file_size == 0U || file_size > 32768U) {
runtime3_artifact_.error = "size_out_of_range";
file.close();
Serial.printf("[RUNTIME3] unexpected artifact size path=%s bytes=%u\n",
runtime3_path.c_str(),
static_cast<unsigned int>(file_size));
return;
}
DynamicJsonDocument document(file_size + 1024U);
const DeserializationError error = deserializeJson(document, file);
file.close();
if (error) {
runtime3_artifact_.error = error.c_str();
Serial.printf("[RUNTIME3] parse failed path=%s error=%s\n",
runtime3_path.c_str(),
runtime3_artifact_.error.c_str());
return;
}
JsonObjectConst scenario = document["scenario"].as<JsonObjectConst>();
JsonArrayConst steps = document["steps"].as<JsonArrayConst>();
JsonObjectConst metadata = document["metadata"].as<JsonObjectConst>();
if (scenario.isNull() || steps.isNull()) {
runtime3_artifact_.error = "invalid_document";
Serial.printf("[RUNTIME3] invalid artifact path=%s\n", runtime3_path.c_str());
return;
}
runtime3_artifact_.schema_version = stringOrNull(document["schema_version"]);
runtime3_artifact_.scenario_id = stringOrNull(scenario["id"]);
runtime3_artifact_.entry_step_id = stringOrNull(scenario["entry_step_id"]);
runtime3_artifact_.source_kind = stringOrNull(scenario["source_kind"]);
runtime3_artifact_.generated_by = stringOrNull(metadata["generated_by"]);
runtime3_artifact_.migration_mode = stringOrNull(metadata["migration_mode"]);
runtime3_artifact_.scenario_version = scenario["version"].is<uint32_t>() ? scenario["version"].as<uint32_t>() : 0U;
runtime3_artifact_.step_count = static_cast<uint16_t>(steps.size());
runtime3_artifact_.transition_count = 0U;
for (JsonVariantConst step_variant : steps) {
JsonArrayConst transitions = step_variant["transitions"].as<JsonArrayConst>();
if (!transitions.isNull()) {
runtime3_artifact_.transition_count =
static_cast<uint16_t>(runtime3_artifact_.transition_count + transitions.size());
}
}
runtime3_artifact_.loaded = true;
runtime3_artifact_.error.remove(0);
Serial.printf("[RUNTIME3] loaded path=%s steps=%u transitions=%u entry=%s\n",
runtime3_path.c_str(),
static_cast<unsigned int>(runtime3_artifact_.step_count),
static_cast<unsigned int>(runtime3_artifact_.transition_count),
runtime3_artifact_.entry_step_id.c_str());
}
void ScenarioManager::loadStepResourceOverrides(const char* scenario_file_path) {
clearStepResourceOverrides();
if (scenario_file_path == nullptr || scenario_file_path[0] == '\0') {
+6 -5
View File
@@ -388,15 +388,16 @@ def build_story_prompt(blueprint: dict[str, Any]) -> str:
normalized_json = json.dumps(normalized, ensure_ascii=False, indent=2)
return (
"Génère un YAML de scenario Zacus compatible Story V2 (format frontend).\n"
"Le YAML doit contenir id, version, title, steps, app_bindings, initial_step.\n"
"Génère un YAML de scénario Zacus V2 canonique, compatible studio + Runtime 3.\n"
"Le YAML doit contenir au minimum id, version, title, players, duration, canon, steps_narrative, firmware.initial_step et firmware.steps.\n"
"Conserver le flow par défaut décrit ci-dessous (noms d'événements inchangés).\n"
"Si certains champs manquent, utilise les valeurs ci-dessous.\n\n"
f"{normalized_json}\n\n"
"Règles techniques:\n"
"- transitions: trigger, event_type, event_name, target_step_id, after_ms, priority.\n"
"- event_name respecte exactement: BTN,NEXT,AUDIO_DONE,ACK_*,UNLOCK,FORCE_*,ETAPE2_DUE,QR_TIMEOUT,WIN_DUE.\n"
"- target_step_id accepte aussi SCENE_MEDIA_MANAGER.\n"
"- firmware.steps[*] contient: step_id, screen_scene_id, audio_pack_id, actions, apps, transitions.\n"
"- transitions: event_type, event_name, target_step_id, after_ms, priority.\n"
"- event_name respecte exactement: BTN_NEXT, AUDIO_DONE, ACK_*, UNLOCK_*, QR_TIMEOUT.\n"
"- garder un ordre cohérent entre steps_narrative et firmware.steps.\n"
)
+406 -33
View File
@@ -14,18 +14,31 @@ Protocol (XiaoZhi-inspired):
3. Device sends text_query or binary OPUS audio
4. Server: ASR -> LLM -> TTS -> stream audio back
Requires: fastapi, uvicorn, httpx, websockets
Install: pip install fastapi uvicorn httpx websockets
Listen protocol (OPUS audio path):
- Client -> {"type":"listen","state":"detect","text":"Hi ESP"} (wake word)
- Server -> {"type":"listen_ack","state":"ready"} (ready to receive)
- Client -> binary OPUS frames (20ms each, 16kHz mono)
- Client -> {"type":"listen","state":"stop"} (end of speech)
- Server -> {"type":"stt","text":"transcribed text"} (STT result)
- Server -> {"type":"tts","state":"start","text":"response"}
- Server -> binary OPUS frames (TTS audio)
- Server -> {"type":"tts","state":"stop"}
Requires: fastapi, uvicorn, httpx, websockets, opuslib, webrtcvad
Install: pip install fastapi uvicorn httpx websockets opuslib webrtcvad
"""
import argparse
import asyncio
import io
import json
import logging
import os
import re
import struct
import sys
import time
import wave
from typing import Optional
try:
@@ -34,10 +47,26 @@ try:
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from starlette.websockets import WebSocketState
except ImportError:
print("Missing dependencies. Install with:")
print("Missing core dependencies. Install with:")
print(" pip install fastapi uvicorn httpx websockets")
sys.exit(1)
# OPUS codec (optional — needed for audio path)
try:
import opuslib
import opuslib.api.encoder
import opuslib.api.decoder
HAS_OPUS = True
except ImportError:
HAS_OPUS = False
# VAD (optional — needed for audio path)
try:
import webrtcvad
HAS_VAD = True
except ImportError:
HAS_VAD = False
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
@@ -46,9 +75,20 @@ TTS_URL = os.environ.get("ZACUS_TTS_URL", "http://192.168.0.120:8001/v1/audio/sp
TTS_VOICE = os.environ.get("ZACUS_TTS_VOICE", "alloy")
LLM_URL = os.environ.get("ZACUS_LLM_URL", "http://localhost:8100/v1/chat/completions")
LLM_MODEL = os.environ.get("ZACUS_LLM_MODEL", "default")
STT_URL = os.environ.get("ZACUS_STT_URL", "http://192.168.0.120:8901/v1/audio/transcriptions")
AUTH_TOKEN = os.environ.get("ZACUS_VOICE_TOKEN", "")
LOG_LEVEL = os.environ.get("ZACUS_VOICE_LOG", "INFO")
# Audio constants
OPUS_SAMPLE_RATE = 16000 # 16kHz mono
OPUS_CHANNELS = 1
OPUS_FRAME_MS = 20 # 20ms frames from ESP32
OPUS_FRAME_SAMPLES = OPUS_SAMPLE_RATE * OPUS_FRAME_MS // 1000 # 320 samples
OPUS_APPLICATION = "voip" # opuslib application type
TTS_OPUS_BITRATE = 24000 # 24kbps for TTS back to ESP32
VAD_AGGRESSIVENESS = 2 # webrtcvad 0-3 (2 = balanced)
VAD_SILENCE_FRAMES = 30 # ~600ms of silence to trigger end-of-speech
PROFESSOR_ZACUS_PROMPT = (
"Tu es le Professeur Zacus, un scientifique excentrique et bienveillant. "
"Tu parles en francais. Tu donnes des indices cryptiques pour aider les "
@@ -68,6 +108,224 @@ logging.basicConfig(
)
logger = logging.getLogger("voice_bridge")
# ---------------------------------------------------------------------------
# Audio Session Manager
# ---------------------------------------------------------------------------
class AudioSessionManager:
"""Manages per-device audio sessions for OPUS decode, VAD, and STT.
Lifecycle:
1. start() — called on listen/detect, creates decoder, resets buffers
2. feed_opus(frame) — decode OPUS frame, run VAD, buffer PCM
3. stop() -> str — finalize, send accumulated PCM to Whisper STT
"""
def __init__(self, device_id: str):
self.device_id = device_id
self._pcm_buffer = bytearray()
self._active = False
self._decoder = None
self._vad = None
self._silence_count = 0
self._speech_detected = False
def start(self):
"""Start a new recording session."""
self._pcm_buffer = bytearray()
self._active = True
self._silence_count = 0
self._speech_detected = False
if HAS_OPUS:
self._decoder = opuslib.Decoder(OPUS_SAMPLE_RATE, OPUS_CHANNELS)
else:
self._decoder = None
logger.warning("[%s] opuslib not available — raw PCM passthrough", self.device_id)
if HAS_VAD:
self._vad = webrtcvad.Vad(VAD_AGGRESSIVENESS)
else:
self._vad = None
logger.info("[%s] Audio session started (opus=%s, vad=%s)",
self.device_id, HAS_OPUS, HAS_VAD)
def feed_opus(self, frame: bytes) -> bool:
"""Decode one OPUS frame and buffer the PCM.
Returns True if VAD detected end-of-speech (enough trailing silence).
"""
if not self._active:
return False
# Decode OPUS -> 16-bit PCM
if self._decoder and HAS_OPUS:
try:
pcm = self._decoder.decode(frame, OPUS_FRAME_SAMPLES)
except Exception as exc:
logger.debug("[%s] OPUS decode error: %s", self.device_id, exc)
return False
else:
# Fallback: treat as raw 16-bit PCM
pcm = frame
self._pcm_buffer.extend(pcm)
# VAD check
if self._vad:
try:
# webrtcvad needs exactly 10/20/30ms of 16-bit PCM at supported rates
is_speech = self._vad.is_speech(pcm, OPUS_SAMPLE_RATE)
except Exception:
is_speech = True # assume speech on VAD error
if is_speech:
self._speech_detected = True
self._silence_count = 0
else:
self._silence_count += 1
if self._speech_detected and self._silence_count >= VAD_SILENCE_FRAMES:
logger.info("[%s] VAD: end-of-speech detected", self.device_id)
return True
else:
# No VAD: simple energy-based detection
self._speech_detected = True
if len(pcm) >= 2:
energy = _rms_energy(pcm)
if energy < 200: # silence threshold
self._silence_count += 1
if self._silence_count >= VAD_SILENCE_FRAMES:
logger.info("[%s] Energy VAD: end-of-speech", self.device_id)
return True
else:
self._silence_count = 0
return False
async def stop_and_transcribe(self) -> str:
"""Stop session and transcribe accumulated PCM via Whisper."""
self._active = False
pcm_bytes = bytes(self._pcm_buffer)
if not pcm_bytes:
logger.warning("[%s] Empty audio buffer, nothing to transcribe", self.device_id)
return ""
duration_s = len(pcm_bytes) / (OPUS_SAMPLE_RATE * 2) # 16-bit = 2 bytes/sample
logger.info("[%s] Transcribing %.1fs of audio (%d bytes PCM)",
self.device_id, duration_s, len(pcm_bytes))
# Wrap PCM in WAV container for Whisper
wav_bytes = _pcm_to_wav(pcm_bytes, OPUS_SAMPLE_RATE, OPUS_CHANNELS)
# POST to Whisper STT endpoint (OpenAI-compatible)
text = await _transcribe_audio(wav_bytes, self.device_id)
return text
@property
def active(self) -> bool:
return self._active
def _rms_energy(pcm: bytes) -> float:
"""Compute RMS energy of 16-bit PCM samples."""
n_samples = len(pcm) // 2
if n_samples == 0:
return 0.0
samples = struct.unpack(f"<{n_samples}h", pcm[:n_samples * 2])
return (sum(s * s for s in samples) / n_samples) ** 0.5
def _pcm_to_wav(pcm: bytes, sample_rate: int, channels: int) -> bytes:
"""Wrap raw 16-bit PCM in a WAV container."""
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(channels)
wf.setsampwidth(2) # 16-bit
wf.setframerate(sample_rate)
wf.writeframes(pcm)
return buf.getvalue()
def _wav_to_pcm(wav_data: bytes) -> tuple[bytes, int, int]:
"""Extract raw PCM, sample_rate, channels from WAV bytes."""
buf = io.BytesIO(wav_data)
with wave.open(buf, "rb") as wf:
pcm = wf.readframes(wf.getnframes())
return pcm, wf.getframerate(), wf.getnchannels()
def _encode_pcm_to_opus_frames(pcm: bytes, sample_rate: int, channels: int) -> list[bytes]:
"""Encode raw PCM to a list of OPUS frames suitable for streaming.
Resamples to 16kHz mono if needed (simple nearest-neighbor).
"""
if not HAS_OPUS:
logger.warning("opuslib not available — cannot encode OPUS")
return []
# Resample to target rate if needed (simple decimation)
if sample_rate != OPUS_SAMPLE_RATE or channels != OPUS_CHANNELS:
pcm = _resample_pcm(pcm, sample_rate, channels, OPUS_SAMPLE_RATE, OPUS_CHANNELS)
encoder = opuslib.Encoder(OPUS_SAMPLE_RATE, OPUS_CHANNELS, OPUS_APPLICATION)
frame_size_bytes = OPUS_FRAME_SAMPLES * 2 # 16-bit mono
frames = []
offset = 0
while offset + frame_size_bytes <= len(pcm):
chunk = pcm[offset:offset + frame_size_bytes]
try:
opus_frame = encoder.encode(chunk, OPUS_FRAME_SAMPLES)
frames.append(opus_frame)
except Exception as exc:
logger.debug("OPUS encode error at offset %d: %s", offset, exc)
offset += frame_size_bytes
logger.debug("Encoded %d OPUS frames from %d bytes PCM", len(frames), len(pcm))
return frames
def _resample_pcm(pcm: bytes, src_rate: int, src_ch: int,
dst_rate: int, dst_ch: int) -> bytes:
"""Simple nearest-neighbor resample. Good enough for voice."""
src_sample_size = 2 * src_ch # 16-bit per channel
n_src_samples = len(pcm) // src_sample_size
ratio = dst_rate / src_rate
n_dst_samples = int(n_src_samples * ratio)
result = bytearray()
for i in range(n_dst_samples):
src_idx = int(i / ratio)
if src_idx >= n_src_samples:
src_idx = n_src_samples - 1
offset = src_idx * src_sample_size
if dst_ch == 1 and src_ch == 2:
# Stereo to mono: average
l = struct.unpack_from("<h", pcm, offset)[0]
r = struct.unpack_from("<h", pcm, offset + 2)[0]
result.extend(struct.pack("<h", (l + r) // 2))
elif dst_ch == 1 and src_ch == 1:
result.extend(pcm[offset:offset + 2])
else:
# Just take first channel
result.extend(pcm[offset:offset + 2])
return bytes(result)
# Per-device session registry
_audio_sessions: dict[str, AudioSessionManager] = {}
def _get_session(device_id: str) -> AudioSessionManager:
"""Get or create an AudioSessionManager for a device."""
if device_id not in _audio_sessions:
_audio_sessions[device_id] = AudioSessionManager(device_id)
return _audio_sessions[device_id]
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
@@ -78,7 +336,15 @@ app = FastAPI(title="Zacus Voice Bridge", version="1.0.0")
@app.get("/health")
async def health():
"""Health check."""
return {"status": "ok", "service": "voice_bridge", "tts_url": TTS_URL}
return {
"status": "ok",
"service": "voice_bridge",
"tts_url": TTS_URL,
"stt_url": STT_URL,
"opus": HAS_OPUS,
"vad": HAS_VAD,
"active_sessions": len(_audio_sessions),
}
@app.websocket("/voice/ws")
@@ -103,10 +369,16 @@ async def voice_websocket(websocket: WebSocket, token: str = ""):
device_id = raw.get("device_id", "unknown")
logger.info("Handshake from device: %s", device_id)
capabilities = ["tts", "llm", "text_query"]
if HAS_OPUS:
capabilities.append("opus_audio")
if HAS_VAD:
capabilities.append("vad")
await websocket.send_json({
"type": "hello_ack",
"version": 1,
"capabilities": ["tts", "llm", "text_query"],
"version": 2,
"capabilities": capabilities,
})
# --- Main loop ---
@@ -142,10 +414,40 @@ async def _handle_message(ws: WebSocket, msg: dict, device_id: str):
if msg_type == "listen":
state = msg.get("state", "")
if state == "detect":
logger.info("[%s] Wake word detected: %s", device_id, msg.get("text", ""))
wake_text = msg.get("text", "")
logger.info("[%s] Wake word detected: %s", device_id, wake_text)
# Start audio session for this device
session = _get_session(device_id)
session.start()
await ws.send_json({"type": "listen_ack", "state": "ready"})
elif state == "stop":
logger.info("[%s] Stopped listening", device_id)
logger.info("[%s] Listen stop received — transcribing", device_id)
session = _get_session(device_id)
t0 = time.monotonic()
transcription = await session.stop_and_transcribe()
t_stt = time.monotonic() - t0
if transcription:
logger.info("[%s] STT (%.1fs): %s", device_id, t_stt, transcription[:80])
await ws.send_json({"type": "stt", "text": transcription})
# Feed transcription into the LLM pipeline
# Detect hint routing: [HINT:puzzle_id:level] prefix
hint_match = re.match(r'^\[HINT:(\w+):(\d)\]\s*(.*)', transcription)
if hint_match:
puzzle_id = hint_match.group(1)
hint_level = int(hint_match.group(2))
question = hint_match.group(3) or "Give me a hint"
response = await _query_hints(puzzle_id, question, hint_level, device_id)
else:
response = await _query_llm(transcription)
# TTS response — encode to OPUS if available
await _send_tts_response(ws, response, device_id)
else:
logger.warning("[%s] STT returned empty text", device_id)
await ws.send_json({"type": "stt", "text": ""})
elif msg_type == "text_query":
query = msg.get("text", "").strip()
@@ -166,21 +468,9 @@ async def _handle_message(ws: WebSocket, msg: dict, device_id: str):
response = await _query_hints(puzzle_id, question, hint_level, device_id)
else:
response = await _query_llm(query)
t_llm = time.monotonic() - t0
# TTS
await ws.send_json({"type": "tts", "state": "start", "text": response})
audio = await _text_to_speech(response)
t_total = time.monotonic() - t0
if audio:
await ws.send_bytes(audio)
logger.info("[%s] Response sent: LLM=%.1fs, total=%.1fs, audio=%dKB",
device_id, t_llm, t_total, len(audio) // 1024)
else:
logger.warning("[%s] TTS failed, text-only response", device_id)
await ws.send_json({"type": "tts", "state": "stop"})
# TTS response (OPUS-encoded if available)
await _send_tts_response(ws, response, device_id)
elif msg_type == "abort":
logger.info("[%s] Playback aborted", device_id)
@@ -190,17 +480,37 @@ async def _handle_message(ws: WebSocket, msg: dict, device_id: str):
async def _handle_audio(ws: WebSocket, frame: bytes, device_id: str):
"""Handle binary audio frame.
"""Handle binary OPUS audio frame from ESP32.
MVP: Log frame size. Full implementation will:
1. Decode OPUS
2. Buffer until VAD end-of-speech
3. Run ASR (faster-whisper)
4. Query LLM
5. TTS + stream back
Decodes OPUS, buffers PCM, runs VAD. On end-of-speech detection,
automatically triggers transcription and LLM response.
"""
logger.debug("[%s] Audio frame: %d bytes", device_id, len(frame))
# TODO: OPUS decode + ASR pipeline
session = _get_session(device_id)
if not session.active:
logger.debug("[%s] Audio frame received but no active session (%d bytes)",
device_id, len(frame))
return
end_of_speech = session.feed_opus(frame)
if end_of_speech:
# VAD triggered end-of-speech — transcribe automatically
logger.info("[%s] VAD end-of-speech — auto-transcribing", device_id)
t0 = time.monotonic()
transcription = await session.stop_and_transcribe()
t_stt = time.monotonic() - t0
if transcription:
logger.info("[%s] Auto-STT (%.1fs): %s", device_id, t_stt, transcription[:80])
await ws.send_json({"type": "stt", "text": transcription})
# Feed into LLM pipeline
response = await _query_llm(transcription)
await _send_tts_response(ws, response, device_id)
else:
logger.warning("[%s] Auto-STT returned empty", device_id)
await ws.send_json({"type": "stt", "text": ""})
# ---------------------------------------------------------------------------
@@ -259,7 +569,7 @@ async def _query_llm(text: str) -> str:
async def _text_to_speech(text: str) -> Optional[bytes]:
"""Convert text to speech via Piper/OpenedAI-Speech API."""
"""Convert text to speech via Piper/OpenedAI-Speech API. Returns WAV bytes."""
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
@@ -280,6 +590,64 @@ async def _text_to_speech(text: str) -> Optional[bytes]:
return None
async def _transcribe_audio(wav_bytes: bytes, device_id: str) -> str:
"""Send WAV audio to Whisper STT (OpenAI-compatible) and return text."""
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
STT_URL,
files={"file": ("audio.wav", wav_bytes, "audio/wav")},
data={"model": "whisper-1", "language": "fr"},
)
if resp.status_code == 200:
data = resp.json()
return data.get("text", "").strip()
logger.error("[%s] STT HTTP %d: %s", device_id, resp.status_code, resp.text[:200])
except Exception as exc:
logger.error("[%s] STT error: %s", device_id, exc)
return ""
async def _send_tts_response(ws: WebSocket, text: str, device_id: str):
"""Get TTS audio, optionally encode to OPUS, and stream to client.
Protocol:
1. {"type":"tts","state":"start","text":"..."}
2. Binary OPUS frames (or single WAV blob if OPUS unavailable)
3. {"type":"tts","state":"stop"}
"""
t0 = time.monotonic()
await ws.send_json({"type": "tts", "state": "start", "text": text})
wav_audio = await _text_to_speech(text)
t_tts = time.monotonic() - t0
if wav_audio:
if HAS_OPUS:
# Encode WAV -> OPUS frames and stream
try:
pcm, rate, channels = _wav_to_pcm(wav_audio)
opus_frames = _encode_pcm_to_opus_frames(pcm, rate, channels)
for frame in opus_frames:
await ws.send_bytes(frame)
logger.info("[%s] TTS OPUS sent: %.1fs, %d frames",
device_id, t_tts, len(opus_frames))
except Exception as exc:
logger.error("[%s] OPUS encode failed, falling back to WAV: %s",
device_id, exc)
await ws.send_bytes(wav_audio)
else:
# No OPUS encoder — send raw WAV blob
await ws.send_bytes(wav_audio)
logger.info("[%s] TTS WAV sent: %.1fs, %dKB",
device_id, t_tts, len(wav_audio) // 1024)
else:
logger.warning("[%s] TTS failed, text-only response", device_id)
await ws.send_json({"type": "tts", "state": "stop"})
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
@@ -290,17 +658,22 @@ def main():
parser.add_argument("--host", default="0.0.0.0", help="Bind address")
parser.add_argument("--tts-url", default=None, help="TTS API URL")
parser.add_argument("--llm-url", default=None, help="LLM API URL")
parser.add_argument("--stt-url", default=None, help="Whisper STT API URL")
args = parser.parse_args()
global TTS_URL, LLM_URL
global TTS_URL, LLM_URL, STT_URL
if args.tts_url:
TTS_URL = args.tts_url
if args.llm_url:
LLM_URL = args.llm_url
if args.stt_url:
STT_URL = args.stt_url
logger.info("Starting Voice Bridge on %s:%d", args.host, args.port)
logger.info(" TTS: %s", TTS_URL)
logger.info(" LLM: %s", LLM_URL)
logger.info(" STT: %s", STT_URL)
logger.info(" OPUS: %s | VAD: %s", HAS_OPUS, HAS_VAD)
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
+12
View File
@@ -0,0 +1,12 @@
# Voice Bridge dependencies
# Core (required)
fastapi>=0.100
uvicorn>=0.23
httpx>=0.24
websockets>=11.0
# OPUS audio codec (required for audio path)
opuslib>=3.0.1
# Voice Activity Detection (required for audio path)
webrtcvad>=2.0.10
+356 -14
View File
@@ -12,6 +12,9 @@ ARTIFACT_ROOT="$REPO_ROOT/artifacts"
PORTS_ARTIFACT_ROOT="$ARTIFACT_ROOT/ports"
LATEST_PORTS_JSON="$PORTS_ARTIFACT_ROOT/latest_ports_resolve.json"
CODEX_ARTIFACT_ROOT="$ARTIFACT_ROOT/codex"
RUNTIME3_ARTIFACT_ROOT="$ARTIFACT_ROOT/runtime3"
FRONTEND_ROOT="$REPO_ROOT/frontend-scratch-v2"
DEFAULT_SCENARIO="$REPO_ROOT/game/scenarios/zacus_v2.yaml"
DEFAULT_PORTS_FIXTURE="$REPO_ROOT/tools/test/fixtures/ports_list_macos.txt"
LAST_RESOLVED_JSON=""
@@ -33,6 +36,18 @@ usage() {
cat <<'USAGE'
Usage: zacus.sh <command> [args]
Commands:
content-checks Run canonical validators, Runtime 3 compile/sim, and export
runtime3-compile Compile a scenario YAML to Runtime 3 IR JSON
runtime3-simulate Simulate the Runtime 3 route from a scenario YAML
runtime3-verify Verify Runtime 3 pivot coverage on the canonical scenario
runtime3-test Run Runtime 3 unit tests
runtime3-firmware-bundle Export Runtime 3 JSON into the firmware LittleFS tree
frontend-lint Run the React/Blockly studio lint gate
frontend-test Run the React/Blockly studio test gate
frontend-build Run the React/Blockly studio build gate
docs-build Build the MkDocs site in strict mode
artifacts-summary Show recent Zacus artifact directories
artifacts-prune Delete old Zacus artifact directories (explicit --yes required)
rc Run the RC live gate (always strict)
smoke Run the serial smoke helper (pass --role ... args)
ports Resolve ports once and show summary
@@ -43,6 +58,10 @@ Commands:
USAGE
}
have_gum() {
[[ -t 0 && -t 1 ]] && command -v gum >/dev/null 2>&1
}
print_ports_summary() {
local json_file="$1"
if [[ ! -f "$json_file" ]]; then
@@ -157,6 +176,239 @@ cmd_ports() {
fi
}
cmd_content_checks() {
info "running content checks"
(cd "$REPO_ROOT" && bash tools/test/run_content_checks.sh "$@")
}
cmd_runtime3_compile() {
local scenario="$DEFAULT_SCENARIO"
local out_file=""
while [[ $# -gt 0 ]]; do
case "$1" in
--scenario)
shift
scenario="${1:-}"
;;
-o|--out)
shift
out_file="${1:-}"
;;
--help|-h)
cat <<'HELP'
Usage: zacus.sh runtime3-compile [--scenario <yaml>] [-o <json>]
Compiles the canonical YAML into Zacus Runtime 3 IR JSON.
HELP
return 0
;;
*)
die "unexpected option: $1"
;;
esac
shift
done
mkdir -p "$RUNTIME3_ARTIFACT_ROOT"
local ts
ts=$(date -u +%Y%m%d-%H%M%S)
local art_dir="$RUNTIME3_ARTIFACT_ROOT/$ts"
mkdir -p "$art_dir"
if [[ -z "$out_file" ]]; then
out_file="$art_dir/runtime3.json"
fi
printf 'command=runtime3-compile\nscenario=%s\nout=%s\n' "$scenario" "$out_file" > "$art_dir/meta.txt"
printf 'python3 tools/scenario/compile_runtime3.py %q -o %q\n' "$scenario" "$out_file" > "$art_dir/commands.txt"
info "compiling Runtime 3 (out=$out_file)"
(cd "$REPO_ROOT" && python3 tools/scenario/compile_runtime3.py "$scenario" -o "$out_file")
cat > "$art_dir/summary.md" <<EOF
# Runtime 3 Compile
- Scenario: \`$scenario\`
- Output: \`$out_file\`
- Status: success
EOF
printf '[runtime3] artifact directory: %s\n' "$art_dir"
}
cmd_runtime3_simulate() {
local scenario="$DEFAULT_SCENARIO"
local max_steps="16"
while [[ $# -gt 0 ]]; do
case "$1" in
--scenario)
shift
scenario="${1:-}"
;;
--max-steps)
shift
max_steps="${1:-}"
;;
--help|-h)
cat <<'HELP'
Usage: zacus.sh runtime3-simulate [--scenario <yaml>] [--max-steps <n>]
Simulates the Runtime 3 route from the given scenario YAML.
HELP
return 0
;;
*)
die "unexpected option: $1"
;;
esac
shift
done
mkdir -p "$RUNTIME3_ARTIFACT_ROOT"
local ts
ts=$(date -u +%Y%m%d-%H%M%S)
local art_dir="$RUNTIME3_ARTIFACT_ROOT/$ts"
mkdir -p "$art_dir"
printf 'command=runtime3-simulate\nscenario=%s\nmax_steps=%s\n' "$scenario" "$max_steps" > "$art_dir/meta.txt"
printf 'python3 tools/scenario/simulate_runtime3.py %q --max-steps %q\n' "$scenario" "$max_steps" > "$art_dir/commands.txt"
info "simulating Runtime 3 (scenario=$scenario max_steps=$max_steps)"
(cd "$REPO_ROOT" && python3 tools/scenario/simulate_runtime3.py "$scenario" --max-steps "$max_steps") | tee "$art_dir/simulate.log"
cat > "$art_dir/summary.md" <<EOF
# Runtime 3 Simulation
- Scenario: \`$scenario\`
- Max steps: \`$max_steps\`
- Status: success
EOF
printf '[runtime3] artifact directory: %s\n' "$art_dir"
}
cmd_runtime3_verify() {
local scenario="$DEFAULT_SCENARIO"
while [[ $# -gt 0 ]]; do
case "$1" in
--scenario)
shift
scenario="${1:-}"
;;
--help|-h)
cat <<'HELP'
Usage: zacus.sh runtime3-verify [--scenario <yaml>]
Checks Runtime 3 pivots and route order on the given scenario YAML.
HELP
return 0
;;
*)
die "unexpected option: $1"
;;
esac
shift
done
info "verifying Runtime 3 pivots (scenario=$scenario)"
(cd "$REPO_ROOT" && python3 tools/scenario/verify_runtime3_pivots.py "$scenario")
}
cmd_runtime3_test() {
while [[ $# -gt 0 ]]; do
case "$1" in
--help|-h)
cat <<'HELP'
Usage: zacus.sh runtime3-test
Runs the Runtime 3 unit test suite.
HELP
return 0
;;
*)
die "unexpected option: $1"
;;
esac
shift
done
info "running Runtime 3 unit tests"
(cd "$REPO_ROOT" && python3 -m unittest discover -s tests/runtime3 -p 'test_*.py')
}
cmd_runtime3_firmware_bundle() {
local scenario="$DEFAULT_SCENARIO"
local out_file="$REPO_ROOT/hardware/firmware/data/story/runtime3/DEFAULT.json"
while [[ $# -gt 0 ]]; do
case "$1" in
--scenario)
shift
scenario="${1:-}"
;;
-o|--out)
shift
out_file="${1:-}"
;;
--help|-h)
cat <<'HELP'
Usage: zacus.sh runtime3-firmware-bundle [--scenario <yaml>] [-o <json>]
Exports Runtime 3 JSON into the firmware LittleFS tree.
HELP
return 0
;;
*)
die "unexpected option: $1"
;;
esac
shift
done
info "exporting Runtime 3 firmware bundle (out=$out_file)"
(cd "$REPO_ROOT" && python3 tools/scenario/export_runtime3_firmware_bundle.py "$scenario" -o "$out_file")
}
cmd_frontend_lint() {
info "running frontend lint"
(cd "$FRONTEND_ROOT" && npm run lint)
}
cmd_frontend_test() {
info "running frontend tests"
(cd "$FRONTEND_ROOT" && npm run test)
}
cmd_frontend_build() {
info "running frontend build"
(cd "$FRONTEND_ROOT" && npm run build)
}
cmd_docs_build() {
info "building MkDocs site"
(cd "$REPO_ROOT" && python3 -m mkdocs build --strict)
}
cmd_artifacts_summary() {
mkdir -p "$ARTIFACT_ROOT"
info "recent artifact directories"
find "$ARTIFACT_ROOT" -mindepth 1 -maxdepth 2 -type d | sort | tail -n 20 | while IFS= read -r path; do
du -sh "$path"
done
}
cmd_artifacts_prune() {
local keep_days="7"
local confirmed="0"
while [[ $# -gt 0 ]]; do
case "$1" in
--keep-days)
shift
keep_days="${1:-}"
;;
--yes)
confirmed="1"
;;
--help|-h)
cat <<'HELP'
Usage: zacus.sh artifacts-prune [--keep-days <n>] --yes
Deletes artifact directories older than the retention window under artifacts/.
HELP
return 0
;;
*)
die "unexpected option: $1"
;;
esac
shift
done
if [[ "$confirmed" != "1" ]]; then
die "artifacts-prune is destructive; re-run with --yes"
fi
info "pruning artifacts older than $keep_days days"
find "$ARTIFACT_ROOT" -mindepth 2 -maxdepth 2 -type d -mtime +"$keep_days" -print -exec rm -rf {} +
}
cmd_rc() {
info "running RC live"
(cd "$FW_ROOT" && "$RC_RUNNER")
@@ -266,6 +518,10 @@ menu_select_prompt() {
if [[ ${#choices[@]} -eq 0 ]]; then
die "no prompt files found under $PROMPT_DIR"
fi
if have_gum; then
gum choose --header "Select Codex prompt" "${choices[@]}"
return 0
fi
printf 'Available prompts:\n'
local idx=1
for prompt in "${choices[@]}"; do
@@ -334,31 +590,81 @@ cmd_menu() {
while true; do
printf '\n=== Zacus cockpit ===\n'
show_ports_context || true
printf '[menu] options:\n'
printf ' 1) RC live\n'
printf ' 2) Serial smoke\n'
printf ' 3) Codex (run prompt)\n'
printf ' 4) Ports: resolve now\n'
printf ' 5) ZeroClaw preflight\n'
printf ' 0) Exit\n'
read -rp 'Choice: ' choice
if have_gum; then
choice="$(gum choose \
"RC live" \
"Serial smoke" \
"Content checks" \
"Runtime 3 compile" \
"Runtime 3 simulate" \
"Runtime 3 verify" \
"Runtime 3 tests" \
"Runtime 3 firmware bundle" \
"Frontend tests" \
"Frontend build" \
"Codex (run prompt)" \
"Ports: resolve now" \
"ZeroClaw preflight" \
"Exit")"
else
printf '[menu] options:\n'
printf ' 1) RC live\n'
printf ' 2) Serial smoke\n'
printf ' 3) Content checks\n'
printf ' 4) Runtime 3 compile\n'
printf ' 5) Runtime 3 simulate\n'
printf ' 6) Runtime 3 verify\n'
printf ' 7) Runtime 3 tests\n'
printf ' 8) Runtime 3 firmware bundle\n'
printf ' 9) Frontend tests\n'
printf ' 10) Frontend build\n'
printf ' 11) Codex (run prompt)\n'
printf ' 12) Ports: resolve now\n'
printf ' 13) ZeroClaw preflight\n'
printf ' 0) Exit\n'
read -rp 'Choice: ' choice
fi
case "$choice" in
1)
1|"RC live")
cmd_rc
;;
2)
2|"Serial smoke")
cmd_smoke
;;
3)
3|"Content checks")
cmd_content_checks
;;
4|"Runtime 3 compile")
cmd_runtime3_compile
;;
5|"Runtime 3 simulate")
cmd_runtime3_simulate
;;
6|"Runtime 3 verify")
cmd_runtime3_verify
;;
7|"Runtime 3 tests")
cmd_runtime3_test
;;
8|"Runtime 3 firmware bundle")
cmd_runtime3_firmware_bundle
;;
9|"Frontend tests")
cmd_frontend_test
;;
10|"Frontend build")
cmd_frontend_build
;;
11|"Codex (run prompt)")
menu_run_codex
;;
4)
12|"Ports: resolve now")
cmd_ports
;;
5)
13|"ZeroClaw preflight")
cmd_zeroclaw_preflight --require-port
;;
0)
0|"Exit")
return 0
;;
*)
@@ -371,6 +677,42 @@ cmd_menu() {
command="${1:-help}"
shift || true
case "$command" in
content-checks)
cmd_content_checks "$@"
;;
runtime3-compile)
cmd_runtime3_compile "$@"
;;
runtime3-simulate)
cmd_runtime3_simulate "$@"
;;
runtime3-verify)
cmd_runtime3_verify "$@"
;;
runtime3-test)
cmd_runtime3_test "$@"
;;
runtime3-firmware-bundle)
cmd_runtime3_firmware_bundle "$@"
;;
frontend-lint)
cmd_frontend_lint "$@"
;;
frontend-test)
cmd_frontend_test "$@"
;;
frontend-build)
cmd_frontend_build "$@"
;;
docs-build)
cmd_docs_build "$@"
;;
artifacts-summary)
cmd_artifacts_summary "$@"
;;
artifacts-prune)
cmd_artifacts_prune "$@"
;;
rc)
cmd_rc
;;
+7 -1
View File
@@ -102,7 +102,13 @@ def to_export_model(scenario: dict) -> dict:
firmware_steps = []
firmware = scenario.get("firmware")
if isinstance(firmware, dict) and isinstance(firmware.get("steps_reference_order"), list):
if isinstance(firmware, dict) and isinstance(firmware.get("steps"), list):
firmware_steps = [
str(step.get("step_id", ""))
for step in firmware["steps"]
if isinstance(step, dict) and step.get("step_id")
]
elif isinstance(firmware, dict) and isinstance(firmware.get("steps_reference_order"), list):
firmware_steps = [str(step) for step in firmware["steps_reference_order"] if step]
proof = []