diff --git a/docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md b/docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md index 9cea6c6..a4b8a95 100644 --- a/docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md +++ b/docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md @@ -1379,3 +1379,84 @@ On-device verification, docs, and the report are covered in the implementation report for this round; see there for verbatim frame captures, the chirp's real playback timestamp, and the snooze end-to-end sequence against the live device. + +### Correction (`dev/claude/chirp-snd-fix` round): the chirp was silent — `.wav` vs `.snd`, and a deferred, swallowed 200 + +**The "chirp's real playback timestamp" claim two paragraphs above was +wrong.** `play_audio` returning `True` at `2026-08-04T06:54:16.622887Z` +(quoted in this round's report) was real evidence a well-formed request +reached the device and got a `200` back -- it was NOT evidence of audible +playback, and in fact nothing was audible. This was caught by the +operator's own ear-testing after deploy, then root-caused via device +storage forensics, not by anything observable from this codebase or the +device's documented API alone. + +**Root cause, confirmed both by the operator listening and by a live `GET +/api/storage/list` of `/ext/apps_assets/shared/sounds` against the real +device:** + +1. **The firmware build pipeline converts `.wav` SOURCE files to `.snd` + at packaging time.** The runtime filenames under + `/ext/apps_assets/shared/sounds/` are `calendar_event_starts.snd`, + `calendar_reminder_ends.snd`, `volume_change.snd` -- never `.wav`. + Neither the firmware source tree (which stores `.wav` sources) nor the + OpenAPI spec (which never mentions the conversion at all) reveals + this; the ONLY way to find the real runtime filename is a live storage + listing against the actual device, not a source-tree grep or a spec + read. This is the same category of "the documented/source-visible + description doesn't match runtime reality" as Finding 1 and Finding 2 + in the "Probe findings" section above -- those two live in + `busybar/display.py`'s module docstring since they're display-priority + facts; this one (and #2 below) are audio-specific, so their + code-adjacent canonical copy is `BusyBarClient.play_audio`'s own + docstring in `src/busybar/client.py` instead. +2. **`POST /api/audio/play` returns `200` BEFORE the actual file open.** + Playback is queued behind a short amp holdoff (confirmed ~100ms); an + open failure at holdoff-fire (e.g. because the filename doesn't exist + at runtime) is logged device-side only and otherwise swallowed -- + nothing about the failure crosses back over the HTTP response. A + wrong `stock_path` is therefore indistinguishable from a correct one + at every software layer available to this codebase: the request is + well-formed, the response is `200`, `play_audio` returns `True`, and + the calling code (this project's own `should_chirp`/`commit_chirped` + logic) correctly commits the chirp as delivered -- with no sound at + all. This is a genuinely new class of firmware-doc gap for this + project: not "the doc's *description* of a rule is wrong" (Finding 1) + or "an *observable* transition is undocumented" (Finding 2), but "the + API's own success signal is emitted before the operation it describes + has actually been attempted," making software-only verification of + this endpoint structurally impossible. + +**Operator ear-test matrix (the evidence that actually resolved this):** + +| stock_path / asset | Audible? | +|---|---| +| `shared/calendar_event_starts.wav` (the original, wrong extension) | **No** | +| An uploaded `.wav` asset (via `path`, not `stock_path`) | Yes | +| `shared/calendar_event_starts.snd` (corrected) | Yes | + +The middle row is worth noting on its own: an *uploaded* `.wav` asset +plays fine (the upload pipeline evidently doesn't need the same `.snd` +conversion, or handles it transparently) -- the `.wav`-is-silent problem +is specific to referencing a *stock* asset by its (wrong, pre-conversion) +filename, not a general "this firmware can't play `.wav` audio" claim. + +**Fix:** `CHIRP_STOCK_PATH` corrected to +`"shared/calendar_event_starts.snd"`. `BusyBarClient.play_audio`'s +docstring now documents both the `.wav`→`.snd` runtime conversion and the +deferred/swallowed-`200` caveat explicitly, with the operator instruction +to verify any future stock filename against a live storage listing rather +than the source tree or the OpenAPI spec. The chirp call site +(`calendar_countdown/main.py`) now logs at `INFO` on *every* chirp +attempt, success or failure (`"chirp played (%s) -> %s"`) -- the original +bug was doubly silent (no sound AND no log line, since only failures were +previously logged, in `play_audio` itself), which is why it took storage +forensics rather than a log check to diagnose. A regression test pins +`CHIRP_STOCK_PATH.endswith(".snd")` directly so a future edit can't +silently reintroduce `.wav`. + +No further on-device round was performed for this fix specifically: the +operator directly ear-verified the corrected `.snd` call as part of +reporting the original bug, which is stronger evidence than a fresh +scripted probe could add (a scripted probe can only confirm HTTP `200` +again, exactly the signal already shown not to prove audibility). diff --git a/integrations/calendar_countdown/README.md b/integrations/calendar_countdown/README.md index 2ad00db..fb230ec 100644 --- a/integrations/calendar_countdown/README.md +++ b/integrations/calendar_countdown/README.md @@ -173,7 +173,9 @@ of these four keys. Independent of the priority ladder above, two more signals fire during the final `imminent_minutes` before an event starts (default: the last 1 minute): - **LED** (`led_notification_color`) blinks on *every* draw from `imminent_minutes` before start until the event actually starts, then stops (no LED once `in_progress`) -- turned off via an explicit off value on the exact transition poll, not by silently omitting the field (see "Guaranteed LED-off" below). The LED is a separate field from the drawn elements in the device's own API schema, and this integration is built on the ASSUMPTION -- **not independently verified** -- that it survives a BUSY/CUSTOM session's (`PRIORITY_SESSION`, 90) eviction of the panel the way the elements themselves don't. There is no API endpoint that exposes current LED state, so this can only be confirmed by a human watching the physical LED; see "Verifying the LED assumption" below. -- **Chirp**: a short audio tone plays exactly once per event, at the moment it starts (T-0) -- not during the final-minute countdown itself. It uses a firmware-shipped **stock sound** (`shared/calendar_event_starts.wav`), confirmed via a live on-device probe (`POST /api/audio/play` with that `stock_path` returned `200`) before this design was chosen -- no asset generation, upload, or repo-committed audio file is needed or used. Playback always uses whatever volume is currently configured on the device; this integration never reads or sets `/api/audio/volume`. Set `chirp = false` to disable audio entirely. +- **Chirp**: a short audio tone plays exactly once per event, at the moment it starts (T-0) -- not during the final-minute countdown itself. It uses a firmware-shipped **stock sound** (`shared/calendar_event_starts.snd`) -- no asset generation, upload, or repo-committed audio file is needed or used. Playback always uses whatever volume is currently configured on the device; this integration never reads or sets `/api/audio/volume`. Set `chirp = false` to disable audio entirely. + + **A note on that `.snd` extension.** The firmware build pipeline converts `.wav` source files to `.snd` at packaging time, so the *runtime* filename under a stock sound directory is never `.wav`, even though the source asset is -- this is invisible from the OpenAPI spec or the firmware source tree; the only way to find it is a live storage listing against the actual device. An earlier version of this feature shipped with the wrong (`.wav`) extension: `POST /api/audio/play` returned `200` for it every time (the endpoint queues playback behind a short amp holdoff and returns success *before* the actual file open, so a missing/wrong file at that point is logged device-side only and never reaches the HTTP response) -- so the chirp was **silently non-functional** end to end until an operator noticed no sound was playing. Every chirp attempt is now logged at `INFO` (`chirp played () -> `) specifically so a repeat of this -- a "successful" `play_audio` call that produces no actual sound -- at least leaves a trace to correlate against what you actually hear, since a `200`/`True` response alone does not prove audibility. If you ever change `CHIRP_STOCK_PATH` to a different stock sound, verify the exact filename against `GET /api/storage/list` on the real device first, not the firmware source tree. **Timing precision.** The main loop normally sleeps for a full `poll_seconds` between polls, but when an upcoming event's start is sooner than that, it sleeps exactly until that start instead -- so the poll that detects the transition (and fires the chirp) lands within about a second of the real start time, not up to a full `poll_seconds` late. diff --git a/integrations/calendar_countdown/logic.py b/integrations/calendar_countdown/logic.py index 2ffd450..a62bba1 100644 --- a/integrations/calendar_countdown/logic.py +++ b/integrations/calendar_countdown/logic.py @@ -329,14 +329,33 @@ def resolve_led_value(led_should_be_on: bool, led_was_on: bool) -> str | None: return None -CHIRP_STOCK_PATH = "shared/calendar_event_starts.wav" +CHIRP_STOCK_PATH = "shared/calendar_event_starts.snd" # ^ A firmware-shipped stock sound (see BusyBarClient.play_audio), not a -# generated/uploaded asset -- confirmed via a live on-device probe (POST -# /api/audio/play with this exact stock_path returned 200) before this -# was chosen. No asset generation, upload, or repo-committed binary is -# needed for the v1.5.2 chirp; see the spec doc's v1.5.2 section for the -# probe transcript and why the naming ("calendar event starts") is an +# generated/uploaded asset -- no asset generation, upload, or +# repo-committed binary is needed for the chirp; see the spec doc's +# v1.5.2 section for why the naming ("calendar event starts") is an # exact semantic match for the T-0 chirp this feature fires. +# +# v1.5.2.1 CORRECTION: this was originally ".wav" -- the v1.5.2 on-device +# probe (POST /api/audio/play with that stock_path) returned 200, which +# was WRONGLY taken as confirmation the chirp was audible. It was not. +# Root cause, confirmed by operator ear-testing plus device storage +# forensics (a live GET of /api/storage/list against +# /ext/apps_assets/shared/sounds -- nothing in the source tree or the +# OpenAPI spec reveals this): the firmware build pipeline converts .wav +# SOURCE files to **.snd** at packaging time; the runtime filenames are +# .snd (e.g. calendar_event_starts.snd), never .wav. Compounding this, +# /api/audio/play returns 200 BEFORE the actual (deferred) file open -- +# playback is queued behind a ~100ms amp holdoff, and an open failure at +# holdoff-fire is logged device-side only and otherwise swallowed. A +# `True` from play_audio therefore does NOT prove audible playback; a +# wrong filename (like the original ".wav" here) is indistinguishable +# from success at every software layer available to this codebase. The +# operator's ear-test matrix that confirmed this: stock ".wav" -> silent, +# an uploaded ".wav" asset -> audible, stock ".snd" -> audible. See +# BusyBarClient.play_audio's docstring for the same caveat stated at the +# API-client level, and the spec doc's firmware-facts section for the +# full writeup. def check_threshold_ordering(cfg: dict) -> str | None: diff --git a/integrations/calendar_countdown/main.py b/integrations/calendar_countdown/main.py index 29aa6db..eee6406 100644 --- a/integrations/calendar_countdown/main.py +++ b/integrations/calendar_countdown/main.py @@ -118,14 +118,27 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, # restart-safety and once-per-event reasoning. Placed after the # dry_run return so a dry run never plays real audio or touches the # chirp bookkeeping. + # + # Observability (v1.5.2.1): log EVERY attempt at INFO, success or + # failure -- not just failures (client.play_audio already logs those + # at WARNING). This is what the silent-.wav bug needed and didn't + # have: a 200/True response from play_audio does NOT prove audible + # playback (see play_audio's docstring for the full deferred-open/ + # swallowed-failure explanation), so a log line is the only trace + # this process can leave for later correlation against an operator's + # own ear-test -- a "successful" chirp that nobody heard was + # previously doubly silent (no sound AND no log line), which is why + # the root cause took device storage forensics to find instead of a + # five-second log check. if state is not None: if should_chirp(event, in_progress, now, state, c["chirp"]): - if client.play_audio(APP, stock_path=CHIRP_STOCK_PATH): + played = client.play_audio(APP, stock_path=CHIRP_STOCK_PATH) + log.info("chirp played (%s) -> %s", CHIRP_STOCK_PATH, played) + if played: commit_chirped(event, state) - # else: play_audio already logged the failure; leaving - # "chirped" uncommitted means the next poll (still - # in_progress, same event) retries rather than silently - # skipping the chirp forever. + # else: leaving "chirped" uncommitted means the next poll + # (still in_progress, same event) retries rather than + # silently skipping the chirp forever. if state is not None and state.get("in_progress") not in (None, in_progress): # clear()'s own success/failure is intentionally not checked here -- diff --git a/src/busybar/client.py b/src/busybar/client.py index 909e289..a335e76 100644 --- a/src/busybar/client.py +++ b/src/busybar/client.py @@ -48,7 +48,7 @@ def play_audio(self, application_name: str, stock_path: str | None = None, path: str | None = None) -> bool: """POST /api/audio/play (v1.5.2, added for calendar_countdown's event-start chirp). Exactly one of `stock_path` (a firmware-shipped - sound, e.g. "shared/calendar_event_starts.wav" -- pattern + sound, e.g. "shared/calendar_event_starts.snd" -- pattern `shared/[a-z0-9_.]+$`, no further subdirectories) or `path` (a file previously uploaded into this app's own assets directory) must be given, matching the device's own PlayAudio schema. Never touches @@ -57,6 +57,34 @@ def play_audio(self, application_name: str, stock_path: str | None = None, own volume setting; playback always uses whatever volume is currently configured on the device. + **Stock sound filenames are `.snd` at runtime, not `.wav`, even + though the source assets in the firmware repo are `.wav` files.** + The build pipeline converts `.wav` sources to `.snd` at packaging + time; the source tree and the OpenAPI spec never reveal this -- + the only way to find the real runtime filename is a live `GET + /api/storage/list` of the target directory (e.g. + `/ext/apps_assets/shared/sounds`) against the actual device. + Always verify a stock filename against that listing before + shipping it in a `stock_path`, not against the source repo or the + API docs. + + **A `True` return does NOT prove audible playback.** This + endpoint returns `200` BEFORE the actual file open -- playback is + queued behind a short amp holdoff (~100ms), and an open failure + at holdoff-fire (e.g. because the filename is wrong) is logged + device-side only and otherwise swallowed; nothing comes back over + this HTTP response either way. A wrong filename (confirmed with + the original, incorrect `.wav` stock_path used here before this + was diagnosed) is therefore indistinguishable from a correct one + at every layer this codebase can observe -- the request succeeds, + the response is `200`, and `play_audio` returns `True`, with no + actual sound. The only way to confirm real audibility is a human + listening on the actual hardware; log every attempt's outcome + (both `True` and `False`) at the call site so a silent-but- + "successful" chirp is at least visible in the log for later + correlation against an operator report, rather than doubly silent + (no sound AND no log line) the way the original bug was. + Returns True on a confirmed 200, False on anything else (network unreachable, 400 invalid path, 404 file not found, or any other non-200) -- best-effort, non-fatal by design: a caller should log diff --git a/tests/test_calendar_logic.py b/tests/test_calendar_logic.py index 7bd8756..9fd5e4e 100644 --- a/tests/test_calendar_logic.py +++ b/tests/test_calendar_logic.py @@ -602,9 +602,21 @@ def test_chirp_state_prunes_old_entries(): def test_chirp_stock_path_is_a_firmware_stock_sound_not_an_uploaded_asset(): # Documents the design choice (v1.5.2): no asset generation/upload - # needed -- see logic.py's module comment for the on-device probe that - # confirmed this exact stock_path works. - assert CHIRP_STOCK_PATH == "shared/calendar_event_starts.wav" + # needed -- see logic.py's module comment for why this is a + # firmware-shipped stock sound, not a repo-committed binary. + assert CHIRP_STOCK_PATH == "shared/calendar_event_starts.snd" + +def test_chirp_stock_path_is_snd_not_wav_regression(): + # Regression, v1.5.2.1: the runtime filename is .snd -- the firmware + # build pipeline converts .wav SOURCE files to .snd at packaging time, + # invisible from the source tree or the OpenAPI spec. The original + # ".wav" stock_path returned 200 from /api/audio/play (a silently + # accepted but wrong filename -- see play_audio's docstring for why a + # 200 doesn't prove audible playback) and produced no sound at all, + # confirmed by operator ear-testing. This test pins the extension + # directly so a future edit can't silently regress back to .wav. + assert CHIRP_STOCK_PATH.endswith(".snd") + assert not CHIRP_STOCK_PATH.endswith(".wav") # --- next_sleep_seconds: T-0 chirp precision -------------------------------------- diff --git a/tests/test_calendar_loop.py b/tests/test_calendar_loop.py index 7866c92..caec113 100644 --- a/tests/test_calendar_loop.py +++ b/tests/test_calendar_loop.py @@ -356,6 +356,41 @@ def test_run_once_chirps_exactly_once_on_start_transition(): run_once(client, lambda hours: [started], CFG, later + timedelta(seconds=10), dry_run=False, state=state) client.play_audio.assert_not_called() +def test_run_once_chirp_logs_info_on_successful_play(caplog): + # v1.5.2.1 observability fix: a "successful" (True-returning) chirp + # attempt must leave a log trace -- the silent-.wav bug was doubly + # silent (no sound AND no log line) precisely because success wasn't + # logged at all, only failures were (in client.play_audio itself). + import logging + client = Mock(); client.draw.return_value = DrawResult.DRAWN + client.play_audio.return_value = True + state: dict = {} + upcoming = make_event(0.2) + run_once(client, lambda hours: [upcoming], CFG, NOW, dry_run=False, state=state) + started = CalEvent(upcoming.title, upcoming.start, upcoming.start + timedelta(minutes=30), False) + later = upcoming.start + timedelta(seconds=1) + with caplog.at_level(logging.INFO, logger="calendar_countdown"): + run_once(client, lambda hours: [started], CFG, later, dry_run=False, state=state) + matches = [rec.message for rec in caplog.records if "chirp played" in rec.message] + assert len(matches) == 1 + assert CHIRP_STOCK_PATH in matches[0] + assert "True" in matches[0] + +def test_run_once_chirp_logs_info_on_failed_play(caplog): + import logging + client = Mock(); client.draw.return_value = DrawResult.DRAWN + client.play_audio.return_value = False + state: dict = {} + upcoming = make_event(0.2) + run_once(client, lambda hours: [upcoming], CFG, NOW, dry_run=False, state=state) + started = CalEvent(upcoming.title, upcoming.start, upcoming.start + timedelta(minutes=30), False) + later = upcoming.start + timedelta(seconds=1) + with caplog.at_level(logging.INFO, logger="calendar_countdown"): + run_once(client, lambda hours: [started], CFG, later, dry_run=False, state=state) + matches = [rec.message for rec in caplog.records if "chirp played" in rec.message] + assert len(matches) == 1 + assert "False" in matches[0] + def test_run_once_chirp_disabled_never_fires(): client = Mock(); client.draw.return_value = DrawResult.DRAWN client.play_audio.return_value = True