feat(app): per-channel signal indicator#286
Merged
Merged
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #286 +/- ##
=======================================
+ Coverage 74.8% 75.4% +0.6%
=======================================
Files 70 74 +4
Lines 7631 7862 +231
=======================================
+ Hits 5709 5934 +225
- Misses 1922 1928 +6
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
cfeca68 to
b2cecbc
Compare
…ction Pure value-type state machine that detects when one capture channel is silent (≤ silenceThreshold) while the other carries speech (≥ speechThreshold) continuously for ≥ debounceSeconds, and fires once per episode. Fires .recovered when the dead channel returns above the speech threshold. Standalone — not yet wired into the audio path. Slice 1 of a five-step introduction of per-channel signal indicators in the menu bar (see issue #258). Configurable thresholds default to -60 dBFS (silent) / -50 dBFS (speech) / 90 s (debounce) — matches the proposal in the issue. A 14-case test suite covers warm-up, both-quiet, both-speaking, ambiguous mid-range, asymmetric-below-debounce, asymmetric-at-debounce, latch (no re-fire), recovery, mid-episode channel switch, reset, and symmetry between mic-dead and app-dead cases.
Adds `lastLevelDBFS: Double` — the per-add() RMS reading in dBFS, updated independently of the throttled `tick()` aggregation path. Floors at -120 dBFS for empty or zero-energy inputs. Slice 2 of the per-channel signal indicator (issue #258): UI consumers will poll this at ~10 Hz from `AudioCaptureSession` to drive menu-bar bar-height modulation, without disturbing the existing 5-second debug-log snapshot. The new `guard samples > 0` in `add()` is defensive: both call sites (AppAudioCapture, MicCaptureHandler) already gate on non-zero frame counts before invoking, so this is a no-op for production paths and a safety net for callers that don't. 6-case test suite covers floor, instantaneous mapping, zero-sample short-circuit, no-op for empty adds, consecutive-add tracking, and independence from `tick()` throttling.
Adds thread-safe `appLevelDBFS` and `micLevelDBFS` getters that expose the most recent per-buffer RMS reading from the IOProc / AVAudioEngine tap callback. Readings decay to -120 dBFS within 0.5 s of the last buffer arrival so a dead tap looks silent rather than carrying a stale value. Slice 3 of issue #258. Drives the asymmetric-silence detection that flips the menu-bar waveform red when one capture channel goes silent while the other still carries speech. Changes: - Decouple per-buffer accumulate + tick() drain from `debugLogging` flag. Reporter accumulators now stay bounded even with debug logging off (5-s log line stays gated). - Add a lock-protected `LevelSlot` (level + lastUpdateTicks) written from the audio callback, read from the UI thread via `OSAllocatedUnfairLock`. - Extract the staleness math as a pure `currentLevel(...)` function so freshness logic is unit-testable without sleeps or mach-clock mocking. 4 boundary tests cover never-updated, fresh, stale, and at-threshold cases.
Extends `RecordingProvider` with `appLevelDBFS` and `micLevelDBFS` getters that surface the running level from the active capture session. Default implementations return -120 (silence), so test mocks without simulated audio remain compile-compat without explicit overrides. `DualSourceRecorder` forwards to its underlying `AudioCaptureSession`, guarded by the existing macOS 14.2 availability dance. Completes Slice 3 of issue #258. The next slice wires this into AppState's ChannelHealthMonitor poller and the menu-bar red-tint overlay. Regression test: a fresh recorder (no started session) reports -120 on both channels.
Wires the ChannelHealthMonitor + per-channel levels (slices 1-3) into the live recording path: - `WatchLoop.activeRecorder` exposed as `private(set)` so AppState's level monitor can read `appLevelDBFS` / `micLevelDBFS` without recorder-lifecycle ownership leaking. - `AppState` adds an observable `asymmetricSilenceActive` flag plus a ~10 Hz polling task. Started on the WatchLoop `.recording` transition, stopped on any other (including `.error`) — also resets the monitor's internal latch so the next episode starts from a clean slate. - `MenuBarIcon.image(...)` accepts `asymmetricSilenceOverlay` and, when true, fills the waveform bars in `NSColor.systemRed` instead of the template-monochrome path. Mirrors the existing `permissionOverlay` / `recordOnlyOverlay` non-template route. Other overlays still paint their small dot/exclamation on top — additive, not exclusive. - `AnimatedMenuBarIcon` + the menu-bar scene body thread the new flag through from `appState.asymmetricSilenceActive`. Three MenuBarIcon tests cover non-template output, default-off template stability, and pixel-level difference between tinted and default renders. One AppState test pins the default-off invariant. Lifecycle (monitor start/stop on real recordings) is exercised live via `e2e-app.yml`.
Wires the menu-bar red-tint indicator (slices 1-4) to user-facing configuration and surfaces the event as a macOS notification so it reaches the user when the icon isn't visible: - `AppSettings.perChannelIndicatorEnabled` (default true) gates the whole feature. When off, `startChannelHealthMonitoring()` becomes a no-op and no polling task runs. - `AppSettings.asymmetricSilenceWarningSeconds` (default 90, clamped to 30..300) becomes the debounce passed to `ChannelHealthMonitor` at each `.recording` transition. Conditional clamping in `didSet` avoids the infinite-recursion trap under `@Observable`. - `AppState` fires `notifier.notify(title: "Capture Channel Silent", body: ...)` on the monitor's `.started` event, with a channel-specific body so the user knows whether to check the mic device or the meeting app's audio routing. - `AudioSettingsView` gets a "Per-Channel Indicator" section with the master toggle and a 30..300 s slider that hides when the toggle is off — mirrors the VAD section's shape. Tests: defaults pinned (perChannelIndicatorEnabled=true, asymmetricSilenceWarningSeconds=90), clamp at both bounds and a valid mid-range value, plus the per-channel message-distinction invariant (app message mentions "app-audio", mic message mentions "microphone"). Closes the implementation for issue #258 — live end-to-end behavior is exercised via the e2e-app workflow.
`Task.detached(priority: .utility) { ... }` has a synchronous-start
optimization in Swift Concurrency: the body begins running on the
caller's thread up to the first real suspension. The previous
implementation's first await — `self?.takeNextSnapshotBatch()` — is
`@MainActor`-isolated, so when invoked from MainActor (every
production caller) it's effectively a no-op hop. The subsequent
`try writer(next, dir)` call therefore runs on the main thread,
which can wedge for seconds inside `renamex_np` when Spotlight's
`mds_stores` indexer is racing against the destination file.
Wrap the writer call in an explicit `DispatchQueue.async` on a
dedicated serial queue (`com.meetingtranscriber.PipelineQueue.snapshot`,
qos `.utility`). `withCheckedContinuation` keeps the detached task
awaiting the dispatch completion so `awaitSnapshotFlush()` semantics
stay intact for tests.
Result: a stalled `renamex_np` can still block this queue, but the
UI, RPC, watch-loop, and per-channel level monitor keep running.
Reproduced live: a meeting-end on this branch hung the menu bar app
inside `PipelineQueue.enqueue → saveSnapshot → renamex_np` on the
main thread, blocking the entire UI until the kernel released the
Spotlight lock. With this change the same syscall lands on the
dedicated dispatch queue and the rest of the app stays responsive.
… injection Adds the integration-layer coverage that was the largest remaining gap: the polling-task body that turns ChannelHealthMonitor events into the observable `micSilentActive` / `appSilentActive` flags and the notification side-effect. - `AppState.applyChannelHealthTick(recorder:now:)` is now the testable seam that the production `tickChannelHealth()` wraps. Both call into the same monitor + flag-flip + notify logic; tests drive it with synthetic dates and a `MockRecorder` whose per-channel levels are mutable. - `MockRecorder` grows `micLevelDBFS` / `appLevelDBFS` overrides (default -120 to match the protocol extension) so existing tests stay unchanged. - `ChannelHealthMonitor.update` now surfaces `.recovered(channel: old)` when an already-`.started` episode is replaced by a channel switch (mic dies + app dies in the same tick). Previously the switch was silent — the old flag stayed sticky in AppState until the new channel's debounce elapsed and its `.started` wiped it. New monitor test pins this; existing mid-episode-without-`.started` switch test continues to expect nil and still passes. - `ChannelHealthIntegrationTests` covers the production scenarios: user-mutes-mic-with-app-speech, user-talks-while-app-audio-dies, one-shot notification per episode, recovery clearing both flags, channel switch clearing the stale flag, and both-active / both-silent symmetric cases never firing. - AppState now constructs `channelHealthMonitor` with the settings debounce at init, so callers that bypass `startChannelHealthMonitoring` (tests, future single-tick callers) see the configured threshold. Also answers the live question from PR review — "wenn ich mic mute, ist app auch gemuted?" architecturally no (separate AVAudioEngine input vs CATapDescription paths), and the integration test now proves the glue handles the resulting mic=-80 / app=-25 asymmetry correctly.
Closes the live-e2e gap that unit tests can't cover: the render path
from `AppState.micSilentActive` / `appSilentActive` through the
SwiftUI scene body, `AnimatedMenuBarIcon`, and `MenuBarIcon.image`
all the way to the actual rendered menu-bar pixels.
- `RPCStateSnapshot` gains a `channelHealth: { micSilent, appSilent }`
block. Driver scripts can poll `/state` and assert on the flag flip
without screenshot OCR — primary observability hook for the
e2e-app workflow.
- `AppState.init` reads `MEETINGTRANSCRIBER_DEBUG_FORCE_MIC_SILENT` /
`_APP_SILENT` env vars (`#if !APPSTORE`) and forces the matching
flag on at launch. Gives a deterministic shortcut into the render
pipeline that doesn't need real audio asymmetry.
- `MeetingTranscriberApp.init` honours
`MEETINGTRANSCRIBER_DEBUG_SUPPRESS_AUTOWATCH=1` to skip the +3 s
auto-watch trigger. Drivers that force a channel flag need this:
otherwise the auto-watch → `toggleWatching` → `.recording`-exit
→ `stopChannelHealthMonitoring()` chain resets the forced flag
before a screenshot lands.
Verified live: launching with both env vars set renders the
mic-silent waveform (top-half red) in the actual menu bar — the
last untested link in the chain unit tests cover everything up
to but not including. Same observability is now reachable via
`mt-cli state | jq .channelHealth`.
No behaviour change in production paths: the env-var checks live
inside `#if !APPSTORE`, and the auto-watch suppression only kicks
in when the explicit debug env var is set.
132409a to
80d83ae
Compare
Both manual and auto-watch recording start a recorder via
recorderFactory() that AppState's channel-health polling task needs
to read levels from each tick. Assign the freshly-started recorder
to self.activeRecorder in both startManualRecording and handleMeeting;
guard with defer { activeRecorder = nil } in the auto-watch path so it
clears on normal return and on throw.
The manual recording path was wired correctly here; the auto-watch
path missed the symmetric assignment, so AppState's polling task saw
nil on every tick and the red-tint indicator never fired for
auto-detected meetings.
Regression test in WatchLoopActiveRecorderTests drives handleMeeting
against a mock recorder + immediately-inactive detector and asserts
activeRecorder is observable from inside the recording lifecycle.
Closes the live-render gap for issue #258 with a self-contained e2e that validates the env-hook → AppState flag → SwiftUI scene body → MenuBarIcon render → DebugRPCServer observability chain. - `scripts/e2e-channel-health.sh` launches the dev .app with `MEETINGTRANSCRIBER_DEBUG_FORCE_MIC_SILENT=1` plus the `_SUPPRESS_AUTOWATCH` partner (which keeps the +3 s auto-watch from clearing the forced flag through `stopChannelHealthMonitoring()`), waits for `/healthz`, and asserts `/state.channelHealth.micSilent == true` via `mt-cli`. Optional `--screenshot` flag captures the menubar region for visual regression evidence. - Wired into `e2e-app.yml` as a new step after the existing transcript / record-only / reimport lanes. Reuses the dev .app deployment from the earlier lanes via `--no-build`. 3 min timeout — enough for build + launch + 30 s RPC wait, generous margin. - The menubar screenshot is uploaded as an artifact (always, not just on failure) so the visual is observable on every run. - `DebugRPCServer` listener now sets `NWParameters.allowLocalEndpointReuse = true` — equivalent to `SO_REUSEADDR`. Without it, back-to-back launch cycles (kill -9 → relaunch within seconds) hit "Address already in use" because the kernel's TIME_WAIT on the previous owner hasn't expired. The e2e script kill-restart cadence depends on this; also improves dev ergonomics during local iteration. Coverage left for follow-up PRs (backlog notes filed): - Live-audio e2e: real CATapDescription tap → LevelPublisher → polling task → flag flip, mic muted via osascript, asserted via RPC. Needs new RPC actions `startManualRecording` / `stopManualRecording` and a sine-wave fixture. Self-hosted Mac mini only. - Fixture-based xctest e2e: needs clock injection on the polling Task so a deterministic TestClock can drive the loop without real time.
Mac mini CI runner has no attached display, so `screencapture -R 0,0,3000,30` fails with "could not create image from rect" — `set -e` then propagates that as a script failure even though the actual RPC assertion already passed (verified live: "channelHealth: micSilent=true appSilent=false" on run 25999551294 before the screencap step crashed it). Wrap the screencapture in a conditional so a failed capture (no display) logs a skip line and continues. The flag-state assertion above remains the authoritative pass/fail signal.
Honest take on the earlier commit: the Layer-1 e2e proved the env-hook to RPC chain but never actually verified the menubar pixels were red. A SwiftUI/MenuBarIcon render regression that kept the AppState flag true but stopped emitting the red tint would have passed. Adds `scripts/assert-red-pixels.swift` — a CoreGraphics-based pixel counter. Loads the menubar screenshot, counts pixels with R > 150 and R > G + 20 (empirically tuned against the Mac mini's actual output: systemRed in the menubar context blends to ~(187, 94, 104) once the dark-mode background, display profile, and downscaling are applied). Threshold of 30 pixels comfortably catches the real render (observed 55 on artifact from run 25999682586) without false-positiving on the macOS recording indicator or apple logo. `scripts/e2e-channel-health.sh` calls the assertion when --screenshot mode succeeds. Both pieces (AppState flag via RPC + actual red pixels via screencap) must now pass for the e2e step to be green. Also corrects the final OK line. The previous "verified end-to-end" was overstating: this exercise covers env-hook + scene-body wiring + RPC observability + pixel rendering, but does NOT cover the real audio path (CATapDescription → LevelPublisher → polling task → flag flip), the `.recovered` path, the notification flow, or the polling-task lifecycle. Those are explicitly listed in the header and tracked in `2026-05-17-channel-health-live-e2e.md`.
…us dips Natural speech RMS dips through the dead zone between silenceThreshold (-60 dBFS) and speechThreshold (-50 dBFS) at every micro-pause between syllables. The old clearEpisode logic discarded the in-flight episode on any tick where asymmetricChannel returned nil, so the debounce timer reset every few hundred milliseconds and never accumulated 30 seconds of continuous asymmetric signal — the trigger effectively could not fire in a real meeting. Replace clearEpisode with handleNonAsymmetricTick: the episode is only discarded when the channel believed to be silent crosses back above the speech threshold (positive recovery). Ambiguous reads on either side are treated as transient and keep the timer running. The latched- episode branch keeps its existing semantics (recovery event on positive crossing only) so the UI flag doesn't get stranded by a phantom dip. Adds four hysteresis tests: active-side dip mid-debounce, silent-side ambiguous mid-debounce, positive silent-side recovery clears unstarted episode, latched episode survives active-side ambiguous dip.
80d83ae to
31ba368
Compare
3 tasks
pasrom
added a commit
that referenced
this pull request
May 19, 2026
Expands the architecture doc with a self-contained walkthrough of every visual state the menu-bar icon can be in, so the rendering layer is discoverable without grepping for `MenuBarIcon.image(...)`. Covers: - Animated primary states (idle, recording, transcribing, diarizing, protocol) — each tied to the `BadgeKind.compute(...)` input it maps to and the `PipelineJob.state` / `WatchLoop.state` that drives it. - Permission problem badge — red exclamation overlay anchored on the primary state, composited by `drawExclamationBadge`. - Record-only mode badge — persistent red dot for `AppSettings.recordOnly == true`. - Per-channel asymmetric-silence indicator (new in PR #286) — red top / bottom half tint driven by `AppState.{mic,app}SilentActive`, configurable in Settings → Audio → Per-Channel Indicator. Includes the polling-chain summary (10 Hz tick reads `WatchLoop.activeRecorder.{mic,app}LevelDBFS` through the `ChannelHealthMonitor` state machine) and the hysteresis behaviour that prevents natural speech-pause dead-zone dips from resetting the debounce timer. - Explicit precedence ordering when multiple overlays apply simultaneously (permission > channel-silent > record-only > primary animation). No code change.
pasrom
added a commit
that referenced
this pull request
May 19, 2026
…GIFs The Menu Bar Icon section listed every existing state animation + overlay (idle / recording / transcribing / diarizing / protocol, permission badge, record-only badge) but never mentioned the per-channel red-tint added in #286. Closes that gap with two animated GIFs — bottom-half-red (app-audio dead) and top-half-red (mic dead) — plus a short explanation of when each fires, what the user should read into it (broken tap vs muted mic vs both), and how the dual threshold + hysteresis design prevents natural speech-pause false-positives. Extends `scripts/generate_menu_bar_gifs.swift` with a `drawTintedHalf` helper that mirrors the production `MenuBarIcon.drawTintedHalf` (clip to half + redraw the badge body with red fill), then produces `menu-bar-channel-silent-app.gif` and `menu-bar-channel-silent-mic.gif` in the same recording-animation cadence as the existing GIFs. No code change to the app itself.
pasrom
added a commit
that referenced
this pull request
May 19, 2026
…GIFs The Menu Bar Icon section listed every existing state animation + overlay (idle / recording / transcribing / diarizing / protocol, permission badge, record-only badge) but never mentioned the per-channel red-tint added in #286. Closes that gap with two animated GIFs — bottom-half-red (app-audio dead) and top-half-red (mic dead) — plus a short explanation of when each fires, what the user should read into it (broken tap vs muted mic vs both), and how the dual threshold + hysteresis design prevents natural speech-pause false-positives. Extends `scripts/generate_menu_bar_gifs.swift` with a `drawTintedHalf` helper that mirrors the production `MenuBarIcon.drawTintedHalf` (clip to half + redraw the badge body with red fill), then produces `menu-bar-channel-silent-app.gif` and `menu-bar-channel-silent-mic.gif` in the same recording-animation cadence as the existing GIFs. No code change to the app itself.
8 tasks
pasrom
added a commit
that referenced
this pull request
May 19, 2026
Plugs the new monitor into the existing 10 Hz polling task that already drives `ChannelHealthMonitor` — both monitors share the same per-tick mic/app dBFS readings, the same lifecycle (start on `.recording`, reset on default), and the same debounce setting (`settings.asymmetricSilenceWarningSeconds`, one slider for both detectors per the design decision in PR #295's brainstorming). - New observable `AppState.recordingSilentActive` — flipped true on `.started`, cleared on `.recovered`, cleared on recording end. - Notification text: `"Recording Appears Silent"` with a body that surfaces the most likely root causes (mic claimed in HFP mode by the meeting app, system input muted) so the user has a concrete next step instead of a generic "check audio routing". - Menu-bar overlay: the icon-wiring call site expands `recordingSilentActive` into BOTH `micSilentOverlay` and `appSilentOverlay`, so `MenuBarIcon` doesn't need a third parameter. Visual outcome: full red bars (both halves tinted), distinct from the asymmetric mic-only / app-only half-tints. - RPC: `channelHealth.recordingSilent` is added to `RPCStateSnapshot.ChannelHealth` so e2e drivers can assert on the polling chain end-to-end (closes the bypass concern from PR #286's env-var-shortcut e2e — the new flag is set by the real production path). Tests: `testBothChannelsSilentDoesNotFire` renamed to `…DoesNotFireAsymmetricFlags` and updated to reflect the new contract (the asymmetric monitor still ignores symmetric silence, which is its job). Two new tests pin the new behaviour: `testBothChannelsSilentFiresRecordingSilentAfterDebounce` and `testRecordingSilentRecoversWhenAnyChannelReturnsToSpeech`.
pasrom
added a commit
that referenced
this pull request
May 19, 2026
…hain Unlike `scripts/e2e-channel-health.sh`, this driver does not use a debug env-hook to set the AppState observable directly — it drives the production code path end-to-end: 1. Build dev `.app`, kill any running instance, snapshot + override relevant UserDefaults (`autoWatch=true`, threshold=30 s, `perChannelIndicatorEnabled=true`). 2. Deploy + re-sign the dev `.app` to the stable `~/Applications/MeetingTranscriber-Dev.app` path so the runner's PPPC profile keeps granting Microphone + Screen Recording (without this the recorder runs but emits zero-byte WAVs because TCC denies the capture stack — same pattern as `scripts/e2e-app.sh`). 3. Launch the app with the RPC server enabled. 4. Launch `tools/meeting-simulator` with `--silent` (passing the fixture path explicitly because the simulator's `findFixture()` walks a stale path; not in scope to fix here). 5. The app's MeetingDetector / PowerAssertionDetector picks up the simulator, WatchLoop transitions to `.recording`, the dual-source recorder taps the silent system output + silent mic. 6. AppState's 10 Hz polling task feeds the (-120, -120) readings into `SilentRecordingMonitor`. 7. After `asymmetricSilenceWarningSeconds` of sustained both-silent, `.started` fires → `recordingSilentActive = true`. 8. The script polls `/state.channelHealth.recordingSilent` and asserts true. Crucially: reverting `activeRecorder = recorder` in `WatchLoop.handleMeeting` OR removing the `SilentRecordingMonitor` wiring in AppState will make this assertion fail. That's the contract this e2e enforces — the full production chain must work end-to-end. Addresses the bypass gap from PR #286's env-var-shortcut e2e. Implementation notes: - The two-tool build (`meeting-simulator` + `mt-cli`) runs in parallel — they share no targets and have separate `.build/` dirs, cuts 5–15 s on cold caches. - Defaults snapshot/restore uses a `restore_bool` helper because `defaults read` returns `0`/`1` but `-bool` only accepts `true`/`false`/`yes`/`no` — a previous version printed the defaults usage screen and bailed on the first restore call. - Dev-cert SHA is resolved live from the keychain rather than from a volatile `/tmp/.../dev-cert.crt` PEM file (more robust than `e2e-app.sh`'s current approach — worth porting back as a separate refactor).
pasrom
added a commit
that referenced
this pull request
May 19, 2026
Plugs the new monitor into the existing 10 Hz polling task that already drives `ChannelHealthMonitor` — both monitors share the same per-tick mic/app dBFS readings, the same lifecycle (start on `.recording`, reset on default), and the same debounce setting (`settings.asymmetricSilenceWarningSeconds`, one slider for both detectors per the design decision in PR #295's brainstorming). - New observable `AppState.recordingSilentActive` — flipped true on `.started`, cleared on `.recovered`, cleared on recording end. - Notification text: `"Recording Appears Silent"` with a body that surfaces the most likely root causes (mic claimed in HFP mode by the meeting app, system input muted) so the user has a concrete next step instead of a generic "check audio routing". - Menu-bar overlay: the icon-wiring call site expands `recordingSilentActive` into BOTH `micSilentOverlay` and `appSilentOverlay`, so `MenuBarIcon` doesn't need a third parameter. Visual outcome: full red bars (both halves tinted), distinct from the asymmetric mic-only / app-only half-tints. - RPC: `channelHealth.recordingSilent` is added to `RPCStateSnapshot.ChannelHealth` so e2e drivers can assert on the polling chain end-to-end (closes the bypass concern from PR #286's env-var-shortcut e2e — the new flag is set by the real production path). Tests: `testBothChannelsSilentDoesNotFire` renamed to `…DoesNotFireAsymmetricFlags` and updated to reflect the new contract (the asymmetric monitor still ignores symmetric silence, which is its job). Two new tests pin the new behaviour: `testBothChannelsSilentFiresRecordingSilentAfterDebounce` and `testRecordingSilentRecoversWhenAnyChannelReturnsToSpeech`.
pasrom
added a commit
that referenced
this pull request
May 19, 2026
…hain Unlike `scripts/e2e-channel-health.sh`, this driver does not use a debug env-hook to set the AppState observable directly — it drives the production code path end-to-end: 1. Build dev `.app`, kill any running instance, snapshot + override relevant UserDefaults (`autoWatch=true`, threshold=30 s, `perChannelIndicatorEnabled=true`). 2. Deploy + re-sign the dev `.app` to the stable `~/Applications/MeetingTranscriber-Dev.app` path so the runner's PPPC profile keeps granting Microphone + Screen Recording (without this the recorder runs but emits zero-byte WAVs because TCC denies the capture stack — same pattern as `scripts/e2e-app.sh`). 3. Launch the app with the RPC server enabled. 4. Launch `tools/meeting-simulator` with `--silent` (passing the fixture path explicitly because the simulator's `findFixture()` walks a stale path; not in scope to fix here). 5. The app's MeetingDetector / PowerAssertionDetector picks up the simulator, WatchLoop transitions to `.recording`, the dual-source recorder taps the silent system output + silent mic. 6. AppState's 10 Hz polling task feeds the (-120, -120) readings into `SilentRecordingMonitor`. 7. After `asymmetricSilenceWarningSeconds` of sustained both-silent, `.started` fires → `recordingSilentActive = true`. 8. The script polls `/state.channelHealth.recordingSilent` and asserts true. Crucially: reverting `activeRecorder = recorder` in `WatchLoop.handleMeeting` OR removing the `SilentRecordingMonitor` wiring in AppState will make this assertion fail. That's the contract this e2e enforces — the full production chain must work end-to-end. Addresses the bypass gap from PR #286's env-var-shortcut e2e. Implementation notes: - The two-tool build (`meeting-simulator` + `mt-cli`) runs in parallel — they share no targets and have separate `.build/` dirs, cuts 5–15 s on cold caches. - Defaults snapshot/restore uses a `restore_bool` helper because `defaults read` returns `0`/`1` but `-bool` only accepts `true`/`false`/`yes`/`no` — a previous version printed the defaults usage screen and bailed on the first restore call. - Dev-cert SHA is resolved live from the keychain rather than from a volatile `/tmp/.../dev-cert.crt` PEM file (more robust than `e2e-app.sh`'s current approach — worth porting back as a separate refactor).
6 tasks
pasrom
added a commit
that referenced
this pull request
May 20, 2026
PR #295 introduced `scripts/e2e-silent-recording.sh` but didn't wire it into CI — the script ran on-demand only. Adding it as a new lane inside `e2e-app.yml` rather than a separate workflow because: - It needs the same Developer ID / signing-keychain setup as the other lanes. - It needs the same audio-routing runner (`audio` label). - `--no-build` reuses the bundle + simulator + mt-cli the prior lanes already built (~30 s saved vs cold). - The Mini is the bottleneck, so a sibling workflow would just serialise behind this one anyway via the `audio` label — separate workflow buys nothing operationally and adds a second status check + schedule + concurrency group to manage. What this lane catches that the existing channel-health lane doesn't: `e2e-channel-health.sh` uses `MEETINGTRANSCRIBER_DEBUG_FORCE_MIC_SILENT=1` to set the AppState observable directly — it verifies the env-hook → SwiftUI → RPC chain but NOT the polling-task chain that turns real audio data into observable state. Reverting `activeRecorder = recorder` in `WatchLoop.handleMeeting` or removing the `SilentRecordingMonitor.update(...)` call in `applyChannelHealthTick` makes the channel-health lane keep passing but breaks the silent-recording lane — that's the contract this lane enforces, and the polling-chain bypass gap from #286 it closes. 3 min timeout covers the worst case: 75 s simulator playback + 30 s sustained-silence debounce + 15 s slack for the polling task to flip `recordingSilentActive` + ~30 s of build/launch overhead. Verified end-to-end on the Mini runner during PR #295 (three live runs, all green) — promoting from on-demand to automatic.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #258. Makes asymmetric capture failures visible at a glance: when one capture channel goes silent while the other still carries audio, the menu-bar waveform turns red and a system notification fires.
ChannelHealthMonitordebounces episodes (default 90 s, configurable 30..300) and latches one.startedevent per episode, one.recoveredwhen the dead channel comes back.AudioTapLibexposes thread-safeappLevelDBFS/micLevelDBFSonAudioCaptureSession. Levels self-decay to -120 dBFS within 0.5 s of the last buffer (OSAllocatedUnfairLock-protected slot + freshness check) so a dead tap actually looks silent to the monitor.AppStateruns a ~10 Hz polling task whileWatchLoop.state == .recording, feeds the monitor, flips an observableasymmetricSilenceActiveflag, and posts a channel-specific notification on the.startedevent.MenuBarIconadds anasymmetricSilenceOverlay: Boolparameter that paints the waveform bars inNSColor.systemRed(non-template path mirrors the existingpermissionOverlay/recordOnlyOverlayroute). Other overlays still compose on top — permission red-! beats the tint when both apply.Sliced into 7 atomic commits — slices 1-3 stand alone (pure logic + AudioTapLib API), slices 4-5 wire everything to the live recording path and UI. Detailed slice breakdown in commit bodies.
Test plan
ChannelHealthMonitorstate machine (warm-up, both quiet/speech/ambiguous, debounce, latch, recovery, channel switch, reset, symmetry, custom thresholds),DebugRMSReporter.lastLevelDBFS(floor, instantaneous mapping, tick independence),currentLevel(...)freshness math,MenuBarIconred-tint rendering,AppSettingsdefaults + clamping at both bounds, andAppStatedefaults + message distinction.swift build+swift test --parallelgreen on both Homebrew and App Store variants../scripts/pre-push.sh --with-appstoregreen (release builds catch Sendable diagnostics debug-mode tolerates)../scripts/lint.shclean (0 violations).e2e-appworkflow exercises the polling task end-to-end against a real recording.Open follow-ups (not in this PR)
DebugRPCServerexposure ofappLevelDBFS/micLevelDBFSfor tooling (requester suggested as follow-up).settings.perChannelIndicatorEnabledtoggling mid-recording. Today it's read once at.recordingtransition — toggling off mid-session takes effect on the next recording.