Skip to content

feat(app): per-channel signal indicator#286

Merged
pasrom merged 14 commits into
mainfrom
feat/per-channel-signal-indicator
May 19, 2026
Merged

feat(app): per-channel signal indicator#286
pasrom merged 14 commits into
mainfrom
feat/per-channel-signal-indicator

Conversation

@pasrom
Copy link
Copy Markdown
Owner

@pasrom pasrom commented May 17, 2026

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.

  • New pure state machine ChannelHealthMonitor debounces episodes (default 90 s, configurable 30..300) and latches one .started event per episode, one .recovered when the dead channel comes back.
  • AudioTapLib exposes thread-safe appLevelDBFS / micLevelDBFS on AudioCaptureSession. 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.
  • AppState runs a ~10 Hz polling task while WatchLoop.state == .recording, feeds the monitor, flips an observable asymmetricSilenceActive flag, and posts a channel-specific notification on the .started event.
  • MenuBarIcon adds an asymmetricSilenceOverlay: Bool parameter that paints the waveform bars in NSColor.systemRed (non-template path mirrors the existing permissionOverlay / recordOnlyOverlay route). Other overlays still compose on top — permission red-! beats the tint when both apply.
  • New "Per-Channel Indicator" section in Audio Settings: master toggle (default on) + 30..300 s threshold slider.

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

  • 34 new unit tests covering ChannelHealthMonitor state 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, MenuBarIcon red-tint rendering, AppSettings defaults + clamping at both bounds, and AppState defaults + message distinction.
  • swift build + swift test --parallel green on both Homebrew and App Store variants.
  • ./scripts/pre-push.sh --with-appstore green (release builds catch Sendable diagnostics debug-mode tolerates).
  • ./scripts/lint.sh clean (0 violations).
  • Live e2e-app workflow exercises the polling task end-to-end against a real recording.
  • Manual smoke: start recording with no app audio routed → expect red menu bar after 90 s + notification; restore audio → expect bars back to monochrome.

Open follow-ups (not in this PR)

  • DebugRPCServer exposure of appLevelDBFS / micLevelDBFS for tooling (requester suggested as follow-up).
  • The polling task could be reactive to settings.perChannelIndicatorEnabled toggling mid-recording. Today it's read once at .recording transition — toggling off mid-session takes effect on the next recording.

@github-actions github-actions Bot added the enhancement New feature or request label May 17, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 17, 2026

Codecov Report

❌ Patch coverage is 89.88764% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.4%. Comparing base (8e20130) to head (31ba368).

Files with missing lines Patch % Lines
app/MeetingTranscriber/Sources/AppState.swift 76.2% 23 Missing ⚠️
...eetingTranscriber/Sources/DualSourceRecorder.swift 60.0% 4 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@           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     
Flag Coverage Δ
appstore 76.4% <88.5%> (-0.1%) ⬇️
audiotap 48.9% <100.0%> (+13.4%) ⬆️
homebrew 76.2% <88.7%> (+0.3%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
app/MeetingTranscriber/Sources/AppSettings.swift 81.8% <100.0%> (+0.7%) ⬆️
app/MeetingTranscriber/Sources/AppState+RPC.swift 98.1% <100.0%> (+<0.1%) ⬆️
...tingTranscriber/Sources/ChannelHealthMonitor.swift 100.0% <100.0%> (ø)
...pp/MeetingTranscriber/Sources/DebugRPCServer.swift 86.0% <100.0%> (+0.2%) ⬆️
app/MeetingTranscriber/Sources/MenuBarIcon.swift 100.0% <100.0%> (ø)
app/MeetingTranscriber/Sources/PipelineQueue.swift 89.8% <100.0%> (-0.1%) ⬇️
.../MeetingTranscriber/Sources/RPCStateSnapshot.swift 100.0% <100.0%> (ø)
...anscriber/Sources/Settings/AudioSettingsView.swift 81.6% <100.0%> (+10.6%) ⬆️
...etingTranscriber/Sources/SnapshotWriterActor.swift 100.0% <100.0%> (ø)
app/MeetingTranscriber/Sources/WatchLoop.swift 92.6% <100.0%> (+<0.1%) ⬆️
... and 5 more

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@pasrom pasrom changed the title feat(app): per-channel signal indicator (closes #258) feat(app): per-channel signal indicator May 17, 2026
@pasrom pasrom force-pushed the feat/per-channel-signal-indicator branch 2 times, most recently from cfeca68 to b2cecbc Compare May 17, 2026 17:01
pasrom added 9 commits May 17, 2026 21:26
…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.
@pasrom pasrom force-pushed the feat/per-channel-signal-indicator branch from 132409a to 80d83ae Compare May 17, 2026 19:27
pasrom added 5 commits May 19, 2026 08:07
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.
@pasrom pasrom force-pushed the feat/per-channel-signal-indicator branch from 80d83ae to 31ba368 Compare May 19, 2026 06:34
@pasrom pasrom merged commit 93a079d into main May 19, 2026
12 checks passed
@pasrom pasrom deleted the feat/per-channel-signal-indicator branch May 19, 2026 08:14
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.
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).
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(app): per-channel signal activity in menu bar to catch asymmetric capture failures

1 participant