From 33b3deff4c5deaff48bab6cade9a9ddd7b4188dc Mon Sep 17 00:00:00 2001 From: r3dbars Date: Thu, 3 Sep 2026 20:23:05 -0500 Subject: [PATCH 1/4] Never prompt for mic boost without AGC gain evidence The quiet-mic detector's gain-absent branch (Raw and Apple voice processing, where no software AGC runs) qualified a tick on raw level alone: 30 s under -26 dBFS with some room tone. That is what a normal meeting looks like while the user listens, so the branch fired the mic-boost prompt mid-meeting and stamped `micAttenuatedByCallApp` into the saved health metadata and the capture-health analytics for users whose mic was fine. The AGC path has real evidence: software gain pinned at max while the raw mic stays quiet means something upstream is holding the device down. With no gain to pin there is nothing to distinguish "held down" from "not speaking", so the detector now never fires without it. Default (auto-level) users are unchanged. Co-Authored-By: Claude Fable 5.1 --- .../Audio/QuietMicAttenuationDetector.swift | 42 +++++++++---------- .../QuietMicAttenuationDetectorTests.swift | 14 +++---- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/Sources/TranscriptedCore/Audio/QuietMicAttenuationDetector.swift b/Sources/TranscriptedCore/Audio/QuietMicAttenuationDetector.swift index e4530ba6c..1d89ff2ef 100644 --- a/Sources/TranscriptedCore/Audio/QuietMicAttenuationDetector.swift +++ b/Sources/TranscriptedCore/Audio/QuietMicAttenuationDetector.swift @@ -50,11 +50,10 @@ public struct QuietMicAttenuationDetector { /// replaces this one. /// /// Any non-qualifying tick (no buffers during engine restarts, zero raw - /// peak when muted, loud raw, usable processed, unpinned gain when AGC - /// is present) resets the streak. When gain is absent, a conservative - /// raw-level cue still qualifies if activity is present. A qualifying - /// streak may extend past the detection window while waiting for enough - /// activity ticks; it never resets while ticks keep qualifying. + /// peak when muted, loud raw, usable processed, unpinned or absent AGC + /// gain) resets the streak. A qualifying streak may extend past the + /// detection window while waiting for enough activity ticks; it never + /// resets while ticks keep qualifying. public mutating func consume( rawPeak: Float, processedPeak: Float, @@ -64,25 +63,22 @@ public struct QuietMicAttenuationDetector { ) -> Bool { guard !hasFired else { return false } - let qualifies: Bool - if let appliedGain, let agcMaxGain { - // Existing AGC-pinned path: Boost only when software gain is - // already maxed out and the raw mic is still quiet. - let gainPinnedAtMax = appliedGain >= Self.gainPinnedFraction * agcMaxGain - qualifies = sawBuffer - && gainPinnedAtMax - && rawPeak > 0 - && rawPeak < Self.quietMicRawPeakThreshold - && processedPeak < Self.usableMicProcessedPeakThreshold - } else { - // Raw/off: no AGC gain to pin. Use a conservative raw-level cue — - // sustained very-low peak while activity is present. Total silence - // is inactivity, not a quiet mic, so it must not qualify. - qualifies = sawBuffer - && rawPeak >= Self.activityRawPeakFloor - && rawPeak < Self.quietMicRawPeakThreshold - && processedPeak < Self.usableMicProcessedPeakThreshold + // Software gain pinned at max while the raw mic stays quiet is the + // evidence that something upstream is holding the device down. With + // no AGC (Raw or Apple voice processing) processedPeak ≈ rawPeak, and + // "quiet for 30 s with some room tone" is what a normal meeting looks + // like while the user listens, so there is nothing to fire on. + guard let appliedGain, let agcMaxGain else { + consecutiveAttenuatedTicks = 0 + activityTicksInStreak = 0 + return false } + let gainPinnedAtMax = appliedGain >= Self.gainPinnedFraction * agcMaxGain + let qualifies = sawBuffer + && gainPinnedAtMax + && rawPeak > 0 + && rawPeak < Self.quietMicRawPeakThreshold + && processedPeak < Self.usableMicProcessedPeakThreshold guard qualifies else { consecutiveAttenuatedTicks = 0 diff --git a/Tests/TranscriptedCoreTests/AudioTests/QuietMicAttenuationDetectorTests.swift b/Tests/TranscriptedCoreTests/AudioTests/QuietMicAttenuationDetectorTests.swift index 5238dd14a..15fd644da 100644 --- a/Tests/TranscriptedCoreTests/AudioTests/QuietMicAttenuationDetectorTests.swift +++ b/Tests/TranscriptedCoreTests/AudioTests/QuietMicAttenuationDetectorTests.swift @@ -73,18 +73,18 @@ final class QuietMicAttenuationDetectorTests: XCTestCase { } } - func testRawOffQuietActivityFiresBoostCue() { + func testRawOffQuietActivityNeverFires() { + // Without AGC gain there is no evidence the mic is being held down: + // 30 s of low raw level with room tone is simply a user listening. + // Firing here put a mic-boost prompt into normal meetings for Raw and + // voice-processed users and stamped micAttenuatedByCallApp on them. var detector = QuietMicAttenuationDetector() - for tick in 1..<150 { + for tick in 1...300 { XCTAssertFalse( qualifyingTick(&detector, appliedGain: nil, agcMaxGain: nil), - "raw/off quiet activity must wait for the 30s window (tick \(tick))" + "quiet raw activity without AGC gain must never prompt (tick \(tick))" ) } - XCTAssertTrue( - qualifyingTick(&detector, appliedGain: nil, agcMaxGain: nil), - "sustained very-low raw activity without AGC gain should still offer Boost" - ) } func testRawOffTotalSilenceNeverFires() { From 85c03d8ff6e1ca4327c12c95b44c0261a825bf41 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Thu, 3 Sep 2026 20:40:11 -0500 Subject: [PATCH 2/4] Stop-time attenuation needs AGC gain evidence, like the live detector Codex review: the live detector no longer fires without gain evidence, but the stop-time classifier in MeetingCaptureVolumeDiagnostics still labelled a quiet raw mic with no scalar drop as `voice_processed` whatever the processing mode, and that label is what stamps `micAttenuatedByCallApp` on the saved transcript. In Raw or Apple voice-processing mode the processed peak is the raw peak, so a user who listened through a meeting produced the label with no evidence. `attenuationKind` now takes the `realtime_agc` fact from the same stop context: quiet with no scalar drop is `voice_processed` only when software gain was there to fail at recovering the mic, and `unavailable` otherwise. The quiet-mic facts themselves are unchanged. The eight existing voice-processed fixtures state `realtime_agc: true`; a new suite covers the gain-absent and flag-absent cases. Co-Authored-By: Claude Fable 5.1 --- Sources/Meeting/MeetingCaptureSupport.swift | 19 +++++--- ...MeetingCaptureVolumeDiagnosticsTests.swift | 45 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/Sources/Meeting/MeetingCaptureSupport.swift b/Sources/Meeting/MeetingCaptureSupport.swift index 21a5cceba..5ef4847fb 100644 --- a/Sources/Meeting/MeetingCaptureSupport.swift +++ b/Sources/Meeting/MeetingCaptureSupport.swift @@ -513,7 +513,8 @@ enum MeetingCaptureVolumeDiagnostics { captured: capturedInputChange.droppedState, capturedContextPresent: capturedInputContextPresent, defaultInput: context["default_input_volume_dropped"] - ) + ), + agcActive: context["realtime_agc"] == "true" ) context["output_ducking_detected"] = outputDuckingState( @@ -606,10 +607,17 @@ enum MeetingCaptureVolumeDiagnostics { /// (Zoom, native WhatsApp, an empty Google Meet). Nonlinear, lossy, and /// only partially recoverable. This is issue #500's still-open case. /// - `none`: the mic was not quiet; no attenuation observed. - /// - `unavailable`: not enough signal (no mic peak data) to classify. + /// - `unavailable`: not enough signal to classify: no mic peak data, or + /// a quiet mic with no scalar drop while no software AGC ran. Without + /// AGC, "quiet raw and quiet processed" is what a listening user looks + /// like in Raw or Apple voice-processing mode; only gain that could + /// not recover the mic is evidence of voice-processing attenuation. + /// This mirrors the live `QuietMicAttenuationDetector`, which never + /// fires without gain evidence either. private static func attenuationKind( quietMic: (recovered: String, unrecovered: String), - inputVolumeDropped: String? + inputVolumeDropped: String?, + agcActive: Bool ) -> String { let micStateKnown = quietMic.recovered != "unavailable" || quietMic.unrecovered != "unavailable" @@ -621,8 +629,9 @@ enum MeetingCaptureVolumeDiagnostics { if inputVolumeDropped == "true" { return "scalar_drop" } // Quiet raw mic with no visible scalar drop (whether the scalar was - // readable-but-flat or unavailable) is voice-processing attenuation. - return "voice_processed" + // readable-but-flat or unavailable) is voice-processing attenuation, + // but only when software gain was there to fail at recovering it. + return agcActive ? "voice_processed" : "unavailable" } /// Issue #500 still-open case: quiet raw mic that gain could not recover, diff --git a/Tests/MeetingCaptureVolumeDiagnosticsTests.swift b/Tests/MeetingCaptureVolumeDiagnosticsTests.swift index 8defa3487..eae369c7b 100644 --- a/Tests/MeetingCaptureVolumeDiagnosticsTests.swift +++ b/Tests/MeetingCaptureVolumeDiagnosticsTests.swift @@ -104,6 +104,7 @@ func testMeetingCaptureVolumeDiagnostics() { "input_device_class": "built_in", "mic_processed_peak": "0.30000", "mic_raw_peak": "0.02000", + "realtime_agc": "true", "output_device_class": "bluetooth", "system_output_device_class": "bluetooth", ], @@ -147,6 +148,7 @@ func testMeetingCaptureVolumeDiagnostics() { let context = MeetingCaptureVolumeDiagnostics.annotatedStopContext( baseContext: [ "mic_raw_peak": "0.02000", + "realtime_agc": "true", "mic_processed_peak": "0.07000", ], afterStopContext: [:] @@ -174,6 +176,7 @@ func testMeetingCaptureVolumeDiagnostics() { baseContext: [ "default_input_volume_before": "0.800", "mic_raw_peak": "0.02000", + "realtime_agc": "true", "mic_processed_peak": "0.30000", ], afterStopContext: [ @@ -194,6 +197,7 @@ func testMeetingCaptureVolumeDiagnostics() { "captured_input_volume_before": "0.700", "captured_input_volume_during": "0.200", "mic_raw_peak": "0.02000", + "realtime_agc": "true", "mic_processed_peak": "0.30000", ], afterStopContext: [ @@ -214,6 +218,7 @@ func testMeetingCaptureVolumeDiagnostics() { "captured_input_volume_before": "0.700", "captured_input_volume_during": "0.700", "mic_raw_peak": "0.02000", + "realtime_agc": "true", "mic_processed_peak": "0.07000", ], afterStopContext: [ @@ -233,6 +238,7 @@ func testMeetingCaptureVolumeDiagnostics() { "captured_input_volume_before": "unavailable", "captured_input_volume_during": "unavailable", "mic_raw_peak": "0.02000", + "realtime_agc": "true", "mic_processed_peak": "0.07000", ], afterStopContext: [ @@ -252,6 +258,7 @@ func testMeetingCaptureVolumeDiagnostics() { "default_input_volume_before": "0.800", "default_input_volume_during": "0.800", "mic_raw_peak": "0.02000", + "realtime_agc": "true", "mic_processed_peak": "0.07000", ], afterStopContext: [ @@ -269,6 +276,7 @@ func testMeetingCaptureVolumeDiagnostics() { baseContext: [ "default_input_volume_before": "unavailable", "mic_raw_peak": "0.02000", + "realtime_agc": "true", "mic_processed_peak": "0.07000", ], afterStopContext: [:] @@ -327,6 +335,43 @@ func testMeetingCaptureVolumeDiagnostics() { assertEqual(notQuiet["quiet_mic_unrecovered"], "false", "raw peak at exactly 0.05 is not quiet (strict <)") } + runSuite("MeetingCaptureVolumeDiagnostics does not classify voice processing without AGC gain evidence") { + // Raw and Apple voice-processing modes run no software AGC, so a + // quiet raw peak with a quiet processed peak is just a user who + // listened. Only gain that failed to recover the mic is evidence of + // voice-processing attenuation; this mirrors the live detector. + let context = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + baseContext: [ + "default_input_volume_before": "0.800", + "default_input_volume_during": "0.800", + "mic_raw_peak": "0.02000", + "realtime_agc": "false", + "mic_processed_peak": "0.02000", + ], + afterStopContext: [ + "default_input_volume_after": "0.800", + ] + ) + + assertEqual(context["quiet_mic_unrecovered"], "true", "the raw facts are still recorded") + assertEqual(context["attenuation_kind"], "unavailable", "a quiet mic without AGC gain cannot be attributed to voice processing") + assertFalse( + MeetingCaptureVolumeDiagnostics.isVoiceProcessedUnrecovered(in: context), + "no gain evidence means no call-app attenuation hint on the saved transcript" + ) + + let missingFlag = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + baseContext: [ + "default_input_volume_before": "0.800", + "default_input_volume_during": "0.800", + "mic_raw_peak": "0.02000", + "mic_processed_peak": "0.02000", + ], + afterStopContext: ["default_input_volume_after": "0.800"] + ) + assertEqual(missingFlag["attenuation_kind"], "unavailable", "an absent realtime_agc flag is not evidence either") + } + runSuite("MeetingCaptureVolumeDiagnostics reports unavailable attenuation without mic peaks") { let context = MeetingCaptureVolumeDiagnostics.annotatedStopContext( baseContext: [ From e3fde3ec3d1a39c03ecfa3eb9671f3f9b2f2f202 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Thu, 3 Sep 2026 20:46:19 -0500 Subject: [PATCH 3/4] Stop-time voice-processing label requires the live detector's cue Codex round two: gating the stop-time label on `realtime_agc` was wrong in both directions. The flag only says an AGC object existed, not that gain was applied or pinned, so a quiet lifetime peak with AGC present still read as `voice_processed` on no evidence. And accepting the Boost prompt swaps the AGC out for voice processing, so at stop the flag was false and a detector-confirmed attenuation was erased from the saved transcript. The session-latched `micAttenuationCueObserved` (set when the live detector fired) is now the evidence: `annotatedStopContext` takes it from the capture bridge, and a quiet mic with no scalar drop is `voice_processed` only when the detector confirmed it, `unavailable` otherwise. The scalar-drop path and the quiet-mic facts are unchanged. Co-Authored-By: Claude Fable 5.1 --- Sources/Meeting/MeetingCaptureSupport.swift | 28 ++++--- .../Meeting/MeetingSessionController.swift | 3 +- ...MeetingCaptureVolumeDiagnosticsTests.swift | 81 +++++++++---------- 3 files changed, 57 insertions(+), 55 deletions(-) diff --git a/Sources/Meeting/MeetingCaptureSupport.swift b/Sources/Meeting/MeetingCaptureSupport.swift index 5ef4847fb..4f92b5dfc 100644 --- a/Sources/Meeting/MeetingCaptureSupport.swift +++ b/Sources/Meeting/MeetingCaptureSupport.swift @@ -461,7 +461,8 @@ enum MeetingCaptureVolumeDiagnostics { static func annotatedStopContext( baseContext: [String: String], - afterStopContext: [String: String] + afterStopContext: [String: String], + liveAttenuationCueObserved: Bool = false ) -> [String: String] { var context = baseContext.merging(afterStopContext, uniquingKeysWith: { _, new in new }) @@ -514,7 +515,7 @@ enum MeetingCaptureVolumeDiagnostics { capturedContextPresent: capturedInputContextPresent, defaultInput: context["default_input_volume_dropped"] ), - agcActive: context["realtime_agc"] == "true" + liveAttenuationCueObserved: liveAttenuationCueObserved ) context["output_ducking_detected"] = outputDuckingState( @@ -607,17 +608,18 @@ enum MeetingCaptureVolumeDiagnostics { /// (Zoom, native WhatsApp, an empty Google Meet). Nonlinear, lossy, and /// only partially recoverable. This is issue #500's still-open case. /// - `none`: the mic was not quiet; no attenuation observed. - /// - `unavailable`: not enough signal to classify: no mic peak data, or - /// a quiet mic with no scalar drop while no software AGC ran. Without - /// AGC, "quiet raw and quiet processed" is what a listening user looks - /// like in Raw or Apple voice-processing mode; only gain that could - /// not recover the mic is evidence of voice-processing attenuation. - /// This mirrors the live `QuietMicAttenuationDetector`, which never - /// fires without gain evidence either. + /// - `unavailable`: not enough evidence to classify: no mic peak data, or + /// a quiet mic with no scalar drop that the live detector never + /// confirmed. Lifetime peaks alone cannot tell voice-processing + /// attenuation from a user who listened, in any processing mode; the + /// live `QuietMicAttenuationDetector` can, because it watches software + /// gain fail to recover the mic over a sustained window. Its latched + /// cue is the evidence, and it survives the user accepting Boost + /// (which swaps the AGC out for voice processing before stop). private static func attenuationKind( quietMic: (recovered: String, unrecovered: String), inputVolumeDropped: String?, - agcActive: Bool + liveAttenuationCueObserved: Bool ) -> String { let micStateKnown = quietMic.recovered != "unavailable" || quietMic.unrecovered != "unavailable" @@ -629,9 +631,9 @@ enum MeetingCaptureVolumeDiagnostics { if inputVolumeDropped == "true" { return "scalar_drop" } // Quiet raw mic with no visible scalar drop (whether the scalar was - // readable-but-flat or unavailable) is voice-processing attenuation, - // but only when software gain was there to fail at recovering it. - return agcActive ? "voice_processed" : "unavailable" + // readable-but-flat or unavailable) is voice-processing attenuation + // only when the live detector confirmed it during the recording. + return liveAttenuationCueObserved ? "voice_processed" : "unavailable" } /// Issue #500 still-open case: quiet raw mic that gain could not recover, diff --git a/Sources/Meeting/MeetingSessionController.swift b/Sources/Meeting/MeetingSessionController.swift index b9d1bd0c4..9ea95add7 100644 --- a/Sources/Meeting/MeetingSessionController.swift +++ b/Sources/Meeting/MeetingSessionController.swift @@ -1122,7 +1122,8 @@ final class MeetingSessionController: ObservableObject { let afterStopVolumeContext = capture.routeVolumeDiagnosticsContext(currentPhase: "after") var stopCaptureDiagnostics = MeetingCaptureVolumeDiagnostics.annotatedStopContext( baseContext: meetingCaptureAnalyticsProperties(snapshot: recordingSnapshot.pipelineSnapshot), - afterStopContext: afterStopVolumeContext + afterStopContext: afterStopVolumeContext, + liveAttenuationCueObserved: capture.micAttenuationCueObserved ) // Read the prompt outcome before any state mutations below; it is only // reset at the NEXT recording start, so the value is stable through stop. diff --git a/Tests/MeetingCaptureVolumeDiagnosticsTests.swift b/Tests/MeetingCaptureVolumeDiagnosticsTests.swift index eae369c7b..40ac552cf 100644 --- a/Tests/MeetingCaptureVolumeDiagnosticsTests.swift +++ b/Tests/MeetingCaptureVolumeDiagnosticsTests.swift @@ -104,7 +104,6 @@ func testMeetingCaptureVolumeDiagnostics() { "input_device_class": "built_in", "mic_processed_peak": "0.30000", "mic_raw_peak": "0.02000", - "realtime_agc": "true", "output_device_class": "bluetooth", "system_output_device_class": "bluetooth", ], @@ -112,7 +111,8 @@ func testMeetingCaptureVolumeDiagnostics() { "default_input_volume_after": "0.500", "default_output_volume_after": "0.750", "default_system_output_volume_after": "0.400", - ] + ], + liveAttenuationCueObserved: true ) assertEqual(context["captured_input_volume_dropped"], "false", "selected mic scalar should not inherit output-route drops") @@ -148,7 +148,6 @@ func testMeetingCaptureVolumeDiagnostics() { let context = MeetingCaptureVolumeDiagnostics.annotatedStopContext( baseContext: [ "mic_raw_peak": "0.02000", - "realtime_agc": "true", "mic_processed_peak": "0.07000", ], afterStopContext: [:] @@ -176,7 +175,6 @@ func testMeetingCaptureVolumeDiagnostics() { baseContext: [ "default_input_volume_before": "0.800", "mic_raw_peak": "0.02000", - "realtime_agc": "true", "mic_processed_peak": "0.30000", ], afterStopContext: [ @@ -197,12 +195,12 @@ func testMeetingCaptureVolumeDiagnostics() { "captured_input_volume_before": "0.700", "captured_input_volume_during": "0.200", "mic_raw_peak": "0.02000", - "realtime_agc": "true", "mic_processed_peak": "0.30000", ], afterStopContext: [ "default_input_volume_after": "0.800", - ] + ], + liveAttenuationCueObserved: true ) assertEqual(context["default_input_volume_dropped"], "false", "the default input can stay flat when capture was redirected") @@ -218,7 +216,6 @@ func testMeetingCaptureVolumeDiagnostics() { "captured_input_volume_before": "0.700", "captured_input_volume_during": "0.700", "mic_raw_peak": "0.02000", - "realtime_agc": "true", "mic_processed_peak": "0.07000", ], afterStopContext: [ @@ -238,12 +235,12 @@ func testMeetingCaptureVolumeDiagnostics() { "captured_input_volume_before": "unavailable", "captured_input_volume_during": "unavailable", "mic_raw_peak": "0.02000", - "realtime_agc": "true", "mic_processed_peak": "0.07000", ], afterStopContext: [ "default_input_volume_after": "0.200", - ] + ], + liveAttenuationCueObserved: true ) assertEqual(context["default_input_volume_dropped"], "true", "default route diagnostics should still report its own drop") @@ -258,12 +255,12 @@ func testMeetingCaptureVolumeDiagnostics() { "default_input_volume_before": "0.800", "default_input_volume_during": "0.800", "mic_raw_peak": "0.02000", - "realtime_agc": "true", "mic_processed_peak": "0.07000", ], afterStopContext: [ "default_input_volume_after": "0.800", - ] + ], + liveAttenuationCueObserved: true ) assertEqual(context["default_input_volume_dropped"], "false", "a flat input scalar should not look like a drop") @@ -276,7 +273,6 @@ func testMeetingCaptureVolumeDiagnostics() { baseContext: [ "default_input_volume_before": "unavailable", "mic_raw_peak": "0.02000", - "realtime_agc": "true", "mic_processed_peak": "0.07000", ], afterStopContext: [:] @@ -335,41 +331,44 @@ func testMeetingCaptureVolumeDiagnostics() { assertEqual(notQuiet["quiet_mic_unrecovered"], "false", "raw peak at exactly 0.05 is not quiet (strict <)") } - runSuite("MeetingCaptureVolumeDiagnostics does not classify voice processing without AGC gain evidence") { - // Raw and Apple voice-processing modes run no software AGC, so a - // quiet raw peak with a quiet processed peak is just a user who - // listened. Only gain that failed to recover the mic is evidence of - // voice-processing attenuation; this mirrors the live detector. - let context = MeetingCaptureVolumeDiagnostics.annotatedStopContext( - baseContext: [ - "default_input_volume_before": "0.800", - "default_input_volume_during": "0.800", - "mic_raw_peak": "0.02000", - "realtime_agc": "false", - "mic_processed_peak": "0.02000", - ], - afterStopContext: [ - "default_input_volume_after": "0.800", - ] + runSuite("MeetingCaptureVolumeDiagnostics only classifies voice processing the live detector confirmed") { + // Lifetime peaks cannot tell a held-down mic from a user who listened, + // in any processing mode. The live detector can (it watches software + // gain fail to recover the mic over a sustained window), and its + // session-latched cue is what makes the stop-time label legitimate. + let quietFacts: [String: String] = [ + "default_input_volume_before": "0.800", + "default_input_volume_during": "0.800", + "mic_raw_peak": "0.02000", + "mic_processed_peak": "0.02000", + ] + let unconfirmed = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + baseContext: quietFacts.merging(["realtime_agc": "true"]) { _, new in new }, + afterStopContext: ["default_input_volume_after": "0.800"] ) - - assertEqual(context["quiet_mic_unrecovered"], "true", "the raw facts are still recorded") - assertEqual(context["attenuation_kind"], "unavailable", "a quiet mic without AGC gain cannot be attributed to voice processing") + assertEqual(unconfirmed["quiet_mic_unrecovered"], "true", "the raw facts are still recorded") + assertEqual(unconfirmed["attenuation_kind"], "unavailable", "AGC merely existing is not evidence; the detector never fired") assertFalse( - MeetingCaptureVolumeDiagnostics.isVoiceProcessedUnrecovered(in: context), - "no gain evidence means no call-app attenuation hint on the saved transcript" + MeetingCaptureVolumeDiagnostics.isVoiceProcessedUnrecovered(in: unconfirmed), + "no live cue means no call-app attenuation hint on the saved transcript" ) - let missingFlag = MeetingCaptureVolumeDiagnostics.annotatedStopContext( - baseContext: [ - "default_input_volume_before": "0.800", - "default_input_volume_during": "0.800", - "mic_raw_peak": "0.02000", - "mic_processed_peak": "0.02000", - ], + let rawMode = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + baseContext: quietFacts.merging(["realtime_agc": "false"]) { _, new in new }, afterStopContext: ["default_input_volume_after": "0.800"] ) - assertEqual(missingFlag["attenuation_kind"], "unavailable", "an absent realtime_agc flag is not evidence either") + assertEqual(rawMode["attenuation_kind"], "unavailable", "Raw and Apple voice-processing modes cannot self-certify attenuation") + + // Detector fired, user accepted Boost: the AGC is swapped out for + // voice processing before stop, so realtime_agc reads false, but the + // latched cue still proves the attenuation. + let boosted = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + baseContext: quietFacts.merging(["realtime_agc": "false"]) { _, new in new }, + afterStopContext: ["default_input_volume_after": "0.800"], + liveAttenuationCueObserved: true + ) + assertEqual(boosted["attenuation_kind"], "voice_processed", "a confirmed cue survives accepting Boost") + assertTrue(MeetingCaptureVolumeDiagnostics.isVoiceProcessedUnrecovered(in: boosted)) } runSuite("MeetingCaptureVolumeDiagnostics reports unavailable attenuation without mic peaks") { From c229577bba63dc9710640c4590af7c30e986ff12 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Thu, 3 Sep 2026 20:53:33 -0500 Subject: [PATCH 4/4] Live attenuation cue outranks lifetime peaks; every stop context carries it Codex round three on this branch: - The cue was consulted only after the lifetime-peak gate, so a confirmed episode followed by a loud stretch (or by the mic recovering once the user accepted Boost) read as `none` at save time. The cue is now checked first; a visible scalar drop still wins because that is the linear, gain-recoverable case the cue cannot distinguish. - The cancellation and live-inactivity stop contexts did not pass the cue; the parameter is now first in the signature and all three call sites pass it. - Two fixtures still expected `voice_processed` without the cue. `isVoiceProcessedUnrecovered` keeps requiring `quiet_mic_unrecovered`: the saved-transcript note it drives says the mic stayed muffled, which is false once Boost recovered it. The analytics record still carries `attenuation_kind: voice_processed` and `mic_boost_prompt: accepted`. Co-Authored-By: Claude Fable 5.1 --- Sources/Meeting/MeetingCaptureSupport.swift | 23 +++++++--- .../Meeting/MeetingSessionController.swift | 6 ++- ...MeetingCaptureVolumeDiagnosticsTests.swift | 44 ++++++++++++++----- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/Sources/Meeting/MeetingCaptureSupport.swift b/Sources/Meeting/MeetingCaptureSupport.swift index 4f92b5dfc..b741ef930 100644 --- a/Sources/Meeting/MeetingCaptureSupport.swift +++ b/Sources/Meeting/MeetingCaptureSupport.swift @@ -460,9 +460,9 @@ enum MeetingCaptureVolumeDiagnostics { ] static func annotatedStopContext( + liveAttenuationCueObserved: Bool = false, baseContext: [String: String], - afterStopContext: [String: String], - liveAttenuationCueObserved: Bool = false + afterStopContext: [String: String] ) -> [String: String] { var context = baseContext.merging(afterStopContext, uniquingKeysWith: { _, new in new }) @@ -621,6 +621,17 @@ enum MeetingCaptureVolumeDiagnostics { inputVolumeDropped: String?, liveAttenuationCueObserved: Bool ) -> String { + // The live cue is authoritative. It records a sustained attenuation + // episode during the recording; the peak facts below are lifetime + // maxima, so a later loud stretch (or the mic recovering after the + // user accepted Boost) must not turn a confirmed episode into `none`. + // A visible scalar drop still wins: that is the linear, gain- + // recoverable case and the cue cannot tell the two apart. + if inputVolumeDropped == "true", liveAttenuationCueObserved { + return "scalar_drop" + } + if liveAttenuationCueObserved { return "voice_processed" } + let micStateKnown = quietMic.recovered != "unavailable" || quietMic.unrecovered != "unavailable" guard micStateKnown else { return "unavailable" } @@ -630,10 +641,10 @@ enum MeetingCaptureVolumeDiagnostics { if inputVolumeDropped == "true" { return "scalar_drop" } - // Quiet raw mic with no visible scalar drop (whether the scalar was - // readable-but-flat or unavailable) is voice-processing attenuation - // only when the live detector confirmed it during the recording. - return liveAttenuationCueObserved ? "voice_processed" : "unavailable" + // Quiet raw mic with no visible scalar drop and no live confirmation: + // lifetime peaks alone cannot tell attenuation from a user who + // listened, so the kind is unknown rather than asserted. + return "unavailable" } /// Issue #500 still-open case: quiet raw mic that gain could not recover, diff --git a/Sources/Meeting/MeetingSessionController.swift b/Sources/Meeting/MeetingSessionController.swift index 9ea95add7..7aca4d509 100644 --- a/Sources/Meeting/MeetingSessionController.swift +++ b/Sources/Meeting/MeetingSessionController.swift @@ -1121,9 +1121,9 @@ final class MeetingSessionController: ObservableObject { let files = (micURL: stopResult.micURL, systemURL: stopResult.systemURL) let afterStopVolumeContext = capture.routeVolumeDiagnosticsContext(currentPhase: "after") var stopCaptureDiagnostics = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + liveAttenuationCueObserved: capture.micAttenuationCueObserved, baseContext: meetingCaptureAnalyticsProperties(snapshot: recordingSnapshot.pipelineSnapshot), - afterStopContext: afterStopVolumeContext, - liveAttenuationCueObserved: capture.micAttenuationCueObserved + afterStopContext: afterStopVolumeContext ) // Read the prompt outcome before any state mutations below; it is only // reset at the NEXT recording start, so the value is stable through stop. @@ -1623,6 +1623,7 @@ final class MeetingSessionController: ObservableObject { let files = (micURL: stopResult.micURL, systemURL: stopResult.systemURL) let afterStopVolumeContext = capture.routeVolumeDiagnosticsContext(currentPhase: "after") var cancelCaptureDiagnostics = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + liveAttenuationCueObserved: capture.micAttenuationCueObserved, baseContext: meetingCaptureAnalyticsProperties(snapshot: recordingSnapshot.pipelineSnapshot), afterStopContext: afterStopVolumeContext ) @@ -2650,6 +2651,7 @@ final class MeetingSessionController: ObservableObject { private func currentAudioInactivityDiagnostics() -> [String: String] { let snapshot = capture.pipelineDiagnosticsSnapshot() return MeetingCaptureVolumeDiagnostics.annotatedStopContext( + liveAttenuationCueObserved: capture.micAttenuationCueObserved, baseContext: meetingCaptureAnalyticsProperties(snapshot: snapshot), afterStopContext: [:] ) diff --git a/Tests/MeetingCaptureVolumeDiagnosticsTests.swift b/Tests/MeetingCaptureVolumeDiagnosticsTests.swift index 40ac552cf..7ad710596 100644 --- a/Tests/MeetingCaptureVolumeDiagnosticsTests.swift +++ b/Tests/MeetingCaptureVolumeDiagnosticsTests.swift @@ -92,6 +92,7 @@ func testMeetingCaptureVolumeDiagnostics() { runSuite("MeetingCaptureVolumeDiagnostics keeps mic/output mismatch facts separate") { let context = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + liveAttenuationCueObserved: true, baseContext: [ "captured_input_volume_before": "0.650", "captured_input_volume_during": "0.650", @@ -111,8 +112,7 @@ func testMeetingCaptureVolumeDiagnostics() { "default_input_volume_after": "0.500", "default_output_volume_after": "0.750", "default_system_output_volume_after": "0.400", - ], - liveAttenuationCueObserved: true + ] ) assertEqual(context["captured_input_volume_dropped"], "false", "selected mic scalar should not inherit output-route drops") @@ -189,6 +189,7 @@ func testMeetingCaptureVolumeDiagnostics() { runSuite("MeetingCaptureVolumeDiagnostics uses captured input scalar for overridden meeting input") { let context = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + liveAttenuationCueObserved: true, baseContext: [ "default_input_volume_before": "0.800", "default_input_volume_during": "0.800", @@ -199,8 +200,7 @@ func testMeetingCaptureVolumeDiagnostics() { ], afterStopContext: [ "default_input_volume_after": "0.800", - ], - liveAttenuationCueObserved: true + ] ) assertEqual(context["default_input_volume_dropped"], "false", "the default input can stay flat when capture was redirected") @@ -211,6 +211,7 @@ func testMeetingCaptureVolumeDiagnostics() { runSuite("MeetingCaptureVolumeDiagnostics does not let stale default input drops override captured input") { let context = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + liveAttenuationCueObserved: true, baseContext: [ "default_input_volume_before": "0.800", "captured_input_volume_before": "0.700", @@ -230,6 +231,7 @@ func testMeetingCaptureVolumeDiagnostics() { runSuite("MeetingCaptureVolumeDiagnostics does not fall back when captured scalar is unreadable") { let context = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + liveAttenuationCueObserved: true, baseContext: [ "default_input_volume_before": "0.800", "captured_input_volume_before": "unavailable", @@ -239,8 +241,7 @@ func testMeetingCaptureVolumeDiagnostics() { ], afterStopContext: [ "default_input_volume_after": "0.200", - ], - liveAttenuationCueObserved: true + ] ) assertEqual(context["default_input_volume_dropped"], "true", "default route diagnostics should still report its own drop") @@ -251,6 +252,7 @@ func testMeetingCaptureVolumeDiagnostics() { runSuite("MeetingCaptureVolumeDiagnostics classifies voice-processing attenuation when the scalar held (issue 500 Bug B)") { let context = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + liveAttenuationCueObserved: true, baseContext: [ "default_input_volume_before": "0.800", "default_input_volume_during": "0.800", @@ -259,8 +261,7 @@ func testMeetingCaptureVolumeDiagnostics() { ], afterStopContext: [ "default_input_volume_after": "0.800", - ], - liveAttenuationCueObserved: true + ] ) assertEqual(context["default_input_volume_dropped"], "false", "a flat input scalar should not look like a drop") @@ -270,6 +271,7 @@ func testMeetingCaptureVolumeDiagnostics() { runSuite("MeetingCaptureVolumeDiagnostics still classifies voice processing when the scalar is unreadable") { let context = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + liveAttenuationCueObserved: true, baseContext: [ "default_input_volume_before": "unavailable", "mic_raw_peak": "0.02000", @@ -363,12 +365,34 @@ func testMeetingCaptureVolumeDiagnostics() { // voice processing before stop, so realtime_agc reads false, but the // latched cue still proves the attenuation. let boosted = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + liveAttenuationCueObserved: true, baseContext: quietFacts.merging(["realtime_agc": "false"]) { _, new in new }, - afterStopContext: ["default_input_volume_after": "0.800"], - liveAttenuationCueObserved: true + afterStopContext: ["default_input_volume_after": "0.800"] ) assertEqual(boosted["attenuation_kind"], "voice_processed", "a confirmed cue survives accepting Boost") assertTrue(MeetingCaptureVolumeDiagnostics.isVoiceProcessedUnrecovered(in: boosted)) + + // Lifetime peaks are maxima: a loud stretch after the episode (or the + // mic recovering once Boost is on) must not erase a confirmed cue. + let loudLater = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + liveAttenuationCueObserved: true, + baseContext: quietFacts.merging(["mic_raw_peak": "0.30000", "mic_processed_peak": "0.45000"]) { _, new in new }, + afterStopContext: ["default_input_volume_after": "0.800"] + ) + assertEqual(loudLater["attenuation_kind"], "voice_processed", "the cue outranks lifetime peaks") + assertFalse( + MeetingCaptureVolumeDiagnostics.isVoiceProcessedUnrecovered(in: loudLater), + "a mic that recovered is not reported as unrecovered on the saved transcript" + ) + + // A visible scalar drop is the linear, gain-recoverable case and + // keeps its own label even when the detector also fired. + let scalarDrop = MeetingCaptureVolumeDiagnostics.annotatedStopContext( + liveAttenuationCueObserved: true, + baseContext: quietFacts.merging(["default_input_volume_during": "0.300"]) { _, new in new }, + afterStopContext: ["default_input_volume_after": "0.300"] + ) + assertEqual(scalarDrop["attenuation_kind"], "scalar_drop", "a scalar drop outranks the cue") } runSuite("MeetingCaptureVolumeDiagnostics reports unavailable attenuation without mic peaks") {