diff --git a/CLAUDE.md b/CLAUDE.md index be95339..7eceadc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,7 @@ swift build # Produces .build/debug/Parley and .build/debug/audio-capture-helper-xpc swift test --filter TranscriberTests -Xswiftc -F/Library/Developer/CommandLineTools/Library/Developer/Frameworks/ -Xlinker -rpath -Xlinker /Library/Developer/CommandLineTools/Library/Developer/Frameworks/ -Xlinker -rpath -Xlinker /Library/Developer/CommandLineTools/Library/Developer/usr/lib/ -# 874 tests across 99 suites (Config, ConfigManager, EngineID, WavFileWriter, AppState, FilenameUtils, CalendarEventPicker, PermissionManager, AudioDeviceEnumerator, InputLevelMonitor, RecordingSentinel, LaunchAgentManager, DiscoverSegments, SegmentNaming, SpeakerAssignment, SpeakerBoundarySplitTests, SpeakerReconciler, TranscriptMerger, ChunkSession, ChunkRecovery, AudioConverter, VadSpeechMap, ChunkRotator, ChunkProcessor, CLIParser, RecordingTimer, PathDisplay, OpenAISummaryProvider, LMStudioSummaryProvider, MeetingSummarizer, TokenRatioCache, EchoDeduplicator, etc.) +# 881 tests across 100 suites (Config, ConfigManager, EngineID, WavFileWriter, AppState, FilenameUtils, CalendarEventPicker, PermissionManager, AudioDeviceEnumerator, InputLevelMonitor, RecordingSentinel, LaunchAgentManager, DiscoverSegments, SegmentNaming, SpeakerAssignment, SpeakerBoundarySplitTests, SpeakerReconciler, TranscriptMerger, ChunkSession, ChunkRecovery, AudioConverter, VadSpeechMap, ChunkRotator, ChunkProcessor, CLIParser, RecordingTimer, PathDisplay, OpenAISummaryProvider, LMStudioSummaryProvider, MeetingSummarizer, TokenRatioCache, EchoDeduplicator, etc.) # Uses Swift Testing, not XCTest -- no Xcode installed, only CommandLineTools # Test path: SwiftTests/TranscriberTests/ (not Tests/ -- case collision with Python tests/ on APFS) ``` @@ -165,12 +165,12 @@ fault outright. - [docs/development-process.md](docs/development-process.md) -- How work gets from idea to release; when to bump MINOR vs PATCH - [docs/pipeline.md](docs/pipeline.md) -- End-to-end pipeline: recording → transcription → echo dedup → summary - [docs/parameters.md](docs/parameters.md) -- All tunable parameters with config keys and defaults -- [docs/gotchas.md](docs/gotchas.md) -- 64 platform-specific gotchas +- [docs/gotchas.md](docs/gotchas.md) -- 66 platform-specific gotchas - [docs/mic-capture-design.md](docs/mic-capture-design.md) -- Mic capture API choice (AVCaptureSession + Core Audio HAL) + auto-follow-default direction + when to revisit AVAudioEngine - [docs/benchmarks/](docs/benchmarks/) -- Dated benchmark reports ## Key Gotchas -See [docs/gotchas.md](docs/gotchas.md) -- 64 platform-specific gotchas (macOS APIs, ScreenCaptureKit, XPC, audio formats, TCC, Liquid Glass, engine quirks). New items are appended there. +See [docs/gotchas.md](docs/gotchas.md) -- 66 platform-specific gotchas (macOS APIs, ScreenCaptureKit, XPC, audio formats, TCC, Liquid Glass, engine quirks). New items are appended there. ## Debugging See [docs/pipeline.md](docs/pipeline.md#debugging) for full unified logging reference. diff --git a/README.md b/README.md index 1f1cb53..09cda5e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ![macOS](https://img.shields.io/badge/macOS-15%2B-black?logo=apple) ![Apple Silicon](https://img.shields.io/badge/Apple%20Silicon-M1–M5-black?logo=apple) ![Swift](https://img.shields.io/badge/Swift-5.9%2B-orange?logo=swift) -![Tests](https://img.shields.io/badge/tests-874%20passing-brightgreen) +![Tests](https://img.shields.io/badge/tests-881%20passing-brightgreen) ![Cloud](https://img.shields.io/badge/cloud-none-success) ![License](https://img.shields.io/badge/license-AGPL%203.0-blue) @@ -201,7 +201,7 @@ TranscriberApp/ SwiftUI menu-bar app (MenuBarExtra + Settings), XPC clien TranscriberCore/ engines, diarization, echo-dedup, speaker reconciliation, summaries, transcript I/O AudioCaptureHelper/ XPC audio-capture service (ScreenCaptureKit, dual-stream) AudioCaptureProtocol/ shared @objc XPC protocol -SwiftTests/ 874 tests across 99 suites +SwiftTests/ 881 tests across 100 suites tools/engine-benchmark/ WER + speed benchmark harness docs/ architecture, pipeline, parameters, gotchas ``` diff --git a/SwiftTests/TranscriberTests/PadRatioMonitorTests.swift b/SwiftTests/TranscriberTests/PadRatioMonitorTests.swift index becb048..7e70e14 100644 --- a/SwiftTests/TranscriberTests/PadRatioMonitorTests.swift +++ b/SwiftTests/TranscriberTests/PadRatioMonitorTests.swift @@ -119,6 +119,62 @@ import Testing #expect(firedAtSecond <= 45, "detection should stay prompt, fired at \(firedAtSecond)s") } + // MARK: - THE 2026-08-11 FALSE POSITIVE (device-observed) + + /// The first real-world firing of this detector was WRONG, and the recording was healthy. + /// + /// A call recorded on speaker: capture started at 11:59:44, the call audio began ~60s later. + /// Measured zero-sample ratio on the system track — 97.6% in 0-30s, 2.8% from 60-300s, 4.2% + /// overall. Nothing was wrong with it. But the monitor judged at exactly t=30s and saw 27s of + /// padding in 30s (ratio 0.911), clearing both the ratio threshold and the 15s absolute floor. + /// + /// The cause is a property of the Core Audio tap that SCK does not share: the tap delivers no + /// buffers at all while the output device is idle. Before the call connects there is genuinely + /// nothing to capture, so the padder fills wall clock and every one of those frames counts as + /// "fabricated". Starting a recording before joining the call is the single most common way to + /// use this app, so this fires on the ordinary case. + /// + /// The distinction that matters: padding BEFORE a track has ever delivered data is a start + /// offset, not a deficit. Only once frames are flowing does missing frames mean something. + @Test func leadingSilenceBeforeTheCallStartsIsNotCorruption() { + var monitor = PadRatioMonitor() + var fired: PadRatioMonitor.Verdict? + // 60s of pure padding — the tap delivers nothing while the output device is idle. + for _ in 0..<60 { + let v = monitor.record(padFrames: 48_000, dataFrames: 0, rate: Self.rate()) + if case .excessive = v, fired == nil { fired = v } + } + // Then the call connects and audio flows normally for 5 minutes. + for _ in 0..<300 { + let v = monitor.record(padFrames: 0, dataFrames: 48_000, rate: Self.rate()) + if case .excessive = v, fired == nil { fired = v } + } + #expect(fired == nil, "a recording started before the call must not be called corrupted") + } + + /// The corollary that must still hold: once a track HAS delivered data, a sustained deficit is + /// real and must fire. This is the 2026-08-04 shape — the tap was delivering (a call was + /// connected) at roughly half the declared rate. + @Test func deficitAfterDataStartsStillFires() { + var monitor = PadRatioMonitor() + var fired: PadRatioMonitor.Verdict? + // Leading silence first — must be ignored, not merely diluted. + for _ in 0..<60 { + _ = monitor.record(padFrames: 48_000, dataFrames: 0, rate: Self.rate()) + } + // Now frames flow, but only half of what the declared rate implies. + for _ in 0..<60 { + let v = monitor.record(padFrames: 24_000, dataFrames: 24_000, rate: Self.rate()) + if case .excessive = v, fired == nil { fired = v } + } + guard case .excessive(let ratio, _, _) = fired else { + Issue.record("a real deficit after data starts must still fire, got \(String(describing: fired))") + return + } + // The leading silence must not inflate the ratio either — this is ~0.5, not ~0.8. + #expect(ratio > 0.45 && ratio < 0.55, "leading silence must be excluded from the ratio, got \(ratio)") + } + // MARK: - Warm-up /// Leading silence dominates the ratio before any real audio arrives, so a verdict must wait for diff --git a/SwiftTests/TranscriberTests/SummaryErrorMessageTests.swift b/SwiftTests/TranscriberTests/SummaryErrorMessageTests.swift new file mode 100644 index 0000000..aaae7b5 --- /dev/null +++ b/SwiftTests/TranscriberTests/SummaryErrorMessageTests.swift @@ -0,0 +1,62 @@ +import Testing +import Foundation +@testable import TranscriberCore + +/// What the user is TOLD when a summary fails. +/// +/// This has been wrong twice in production. A `-1001` request timeout fell into a catch-all that +/// assumed any non-`SummaryError` was a file failure and reported "check disk space and +/// permissions" — sending two separate debugging sessions after a permissions problem that did not +/// exist (gotcha #66). A message that misdescribes the cause is worse than a vague one, because it +/// is actively followed. +@Suite struct SummaryErrorMessageTests { + + // MARK: - Auth failures must be actionable and must not leak the credential + + @Test func authFailureNamesTheApiKeyAndTheStatus() { + let message = SummaryError.authenticationFailed(status: 401).localizedDescription + #expect(message.contains("API key")) + #expect(message.contains("401")) + } + + /// The response body is deliberately NOT included: some servers echo the offending credential + /// back, and this text reaches a notification that can be on screen during a shared meeting. + /// The same concern already sanitizes `invalidEndpoint`. + @Test func authFailureNeverEchoesAServerBody() { + let leaky = "Bearer sk-lm-SECRETVALUE is not authorized" + let message = SummaryError.authenticationFailed(status: 403).localizedDescription + #expect(!message.contains("SECRETVALUE")) + #expect(!message.contains(leaky)) + #expect(!message.lowercased().contains("bearer")) + } + + @Test func forbiddenIsTreatedAsAnAuthFailureToo() { + #expect(SummaryError.authenticationFailed(status: 403).localizedDescription.contains("API key")) + } + + // MARK: - The distinctions that were missing + + /// Three different causes, three different instructions. Previously a timeout and an unreachable + /// server both produced the file-permissions message. + @Test func theThreeFailureKindsReadDifferently() { + let auth = SummaryError.authenticationFailed(status: 401).localizedDescription + let endpoint = SummaryError.invalidEndpoint("http://x").localizedDescription + let server = SummaryError.serverError(message: "model failed to load", code: nil).localizedDescription + + #expect(auth != endpoint) + #expect(auth != server) + #expect(endpoint != server) + // And none of them blames the filesystem. + for m in [auth, endpoint, server] { + #expect(!m.lowercased().contains("disk space")) + } + } + + /// The provider's own message survives for server-side faults — "model failed to load" is + /// exactly the kind of detail that makes a failure diagnosable (#134). + @Test func serverErrorForwardsTheProvidersOwnText() { + let message = SummaryError.serverError(message: "model failed to load", code: "500") + .localizedDescription + #expect(message.contains("model failed to load")) + } +} diff --git a/TranscriberCore/Config.swift b/TranscriberCore/Config.swift index aeb7a1f..63fd20d 100644 --- a/TranscriberCore/Config.swift +++ b/TranscriberCore/Config.swift @@ -25,6 +25,12 @@ public struct SummaryConfig: Codable, Equatable, Sendable { public var contextOverheadPercent: Int? /// Tokens reserved for the summary response (default 2048). public var maxOutputTokens: Int? + /// Seconds to wait for the model to produce a summary (default 600). + /// + /// URLSession's stock 60s is the wrong order of magnitude for a LOCAL model summarising a long + /// meeting: it accepts the request immediately, then generates for minutes. Two real recordings + /// failed at exactly 60s (#173) — the transcript was fine both times, only the summary was lost. + public var requestTimeoutSeconds: Int? public init( enabled: Bool, @@ -34,7 +40,8 @@ public struct SummaryConfig: Codable, Equatable, Sendable { model: String, contextLength: Int? = nil, contextOverheadPercent: Int? = nil, - maxOutputTokens: Int? = nil + maxOutputTokens: Int? = nil, + requestTimeoutSeconds: Int? = nil ) { self.enabled = enabled self.provider = provider @@ -44,6 +51,7 @@ public struct SummaryConfig: Codable, Equatable, Sendable { self.contextLength = contextLength self.contextOverheadPercent = contextOverheadPercent self.maxOutputTokens = maxOutputTokens + self.requestTimeoutSeconds = requestTimeoutSeconds } enum CodingKeys: String, CodingKey { @@ -55,6 +63,7 @@ public struct SummaryConfig: Codable, Equatable, Sendable { case contextLength = "context_length" case contextOverheadPercent = "context_overhead_percent" case maxOutputTokens = "max_output_tokens" + case requestTimeoutSeconds = "request_timeout_seconds" } public init(from decoder: Decoder) throws { diff --git a/TranscriberCore/LMStudioSummaryProvider.swift b/TranscriberCore/LMStudioSummaryProvider.swift index 7f5bc4b..e7c8081 100644 --- a/TranscriberCore/LMStudioSummaryProvider.swift +++ b/TranscriberCore/LMStudioSummaryProvider.swift @@ -21,7 +21,8 @@ public struct LMStudioSummaryProvider: SummaryProvider, Sendable { model: String, contextLength: Int? = nil, contextOverheadPercent: Int? = nil, - maxOutputTokens: Int? = nil + maxOutputTokens: Int? = nil, + requestTimeoutSeconds: Int? = nil ) { self.endpoint = endpoint self.apiKey = apiKey @@ -29,8 +30,15 @@ public struct LMStudioSummaryProvider: SummaryProvider, Sendable { self.contextLength = contextLength self.overheadPercent = contextOverheadPercent ?? Self.defaultOverheadPercent self.outputBuffer = maxOutputTokens ?? Self.defaultOutputBuffer + self.requestTimeoutSeconds = requestTimeoutSeconds ?? Self.defaultRequestTimeoutSeconds } + /// Generous by design: a local model accepts the request instantly and then generates for + /// minutes on a long transcript. Ten minutes is far past any real generation while still + /// bounded, so a genuinely dead server still fails rather than hanging forever. + public static let defaultRequestTimeoutSeconds = 600 + private let requestTimeoutSeconds: Int + public func summarize(segments: [SummarySegment], metadata: SummaryMetadata) async throws -> String { // Calibrate on first encounter with this model await calibrateIfNeeded() @@ -58,6 +66,10 @@ public struct LMStudioSummaryProvider: SummaryProvider, Sendable { return try await retryRequest(segments: segments, metadata: metadata) } + // 401/403 has a precise fix and a body that may echo the credential — classify it. + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw SummaryError.authenticationFailed(status: httpResponse.statusCode) + } throw SummaryError.requestFailed("HTTP \(httpResponse.statusCode): \(body.prefix(200))") } @@ -90,6 +102,9 @@ public struct LMStudioSummaryProvider: SummaryProvider, Sendable { if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { let body = String(data: data, encoding: .utf8) ?? "" + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw SummaryError.authenticationFailed(status: httpResponse.statusCode) + } throw SummaryError.requestFailed("HTTP \(httpResponse.statusCode) (after retry): \(body.prefix(200))") } @@ -156,6 +171,9 @@ public struct LMStudioSummaryProvider: SummaryProvider, Sendable { var request = URLRequest(url: url) request.httpMethod = "POST" + // A local model accepts instantly and then generates for minutes; the stock 60s timeout is + // the wrong order of magnitude and cost two real summaries (#173). + request.timeoutInterval = TimeInterval(requestTimeoutSeconds) request.setValue("application/json", forHTTPHeaderField: "Content-Type") if !apiKey.isEmpty { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") diff --git a/TranscriberCore/MeetingSummarizer.swift b/TranscriberCore/MeetingSummarizer.swift index 3d92398..32107e6 100644 --- a/TranscriberCore/MeetingSummarizer.swift +++ b/TranscriberCore/MeetingSummarizer.swift @@ -94,11 +94,30 @@ public enum MeetingSummarizer { // sanitized in the branch above. Logger.transcription.error("Summary generation failed: \(error.localizedDescription, privacy: .public)") return .failed(error.localizedDescription) + } catch let error as URLError { + // NOT a file failure. This branch exists because a URLError fell through to the + // catch-all below and was reported as "check disk space and permissions" — which read as + // a permissions problem and sent two separate debugging sessions down the wrong path + // (2026-08-04 and 2026-08-11, both actually `-1001` request timeouts against a local + // LM Studio that accepted the request and then generated for longer than the timeout). + // An error message that misdescribes the fault is worse than a vague one. + Logger.transcription.error( + "Summary generation failed: \(error.code.rawValue, privacy: .public) \(error.localizedDescription, privacy: .public)" + ) + switch error.code { + case .timedOut: + return .failed("The model took too long to respond — the transcript is safe; try a smaller model or raise the summary timeout") + case .cannotConnectToHost, .cannotFindHost, .networkConnectionLost, .notConnectedToInternet: + return .failed("Couldn't reach the summary endpoint — is your model server running?") + default: + return .failed("Summary request failed: \(error.localizedDescription)") + } } catch { - // A non-SummaryError here is a file read/write failure (transcript unreadable, summary - // write failed). CocoaError's description embeds the transcript/session filename, which - // would surface in the notification (visible during a screen-share) — so keep the detail - // in the local log and give the user a generic, actionable message (#134 review). + // A non-SummaryError, non-URLError here is a file read/write failure (transcript + // unreadable, summary write failed). CocoaError's description embeds the + // transcript/session filename, which would surface in the notification (visible during a + // screen-share) — so keep the detail in the local log and give the user a generic, + // actionable message (#134 review). Logger.transcription.error("Summary generation failed: \(error.localizedDescription)") return .failed("Couldn't read the transcript or write the summary — check disk space and permissions") } @@ -114,13 +133,15 @@ public enum MeetingSummarizer { model: summary.model, contextLength: summary.contextLength, contextOverheadPercent: summary.contextOverheadPercent, - maxOutputTokens: summary.maxOutputTokens + maxOutputTokens: summary.maxOutputTokens, + requestTimeoutSeconds: summary.requestTimeoutSeconds ) case .openai: return OpenAISummaryProvider( endpoint: summary.endpoint, apiKey: summary.apiKey, - model: summary.model + model: summary.model, + requestTimeoutSeconds: summary.requestTimeoutSeconds ) } } diff --git a/TranscriberCore/OpenAISummaryProvider.swift b/TranscriberCore/OpenAISummaryProvider.swift index 4e6048f..18b48c3 100644 --- a/TranscriberCore/OpenAISummaryProvider.swift +++ b/TranscriberCore/OpenAISummaryProvider.swift @@ -9,6 +9,14 @@ public enum SummaryError: LocalizedError { /// a completion. Surfacing the server's own message is what keeps a misconfigured endpoint /// (bad token, unknown model) diagnosable instead of masked as `emptyResponse` (#134). case serverError(message: String, code: String?) + /// The endpoint rejected our credentials (401/403). + /// + /// Separate from `requestFailed` for two reasons. It is the one failure with a precise, useful + /// instruction — "check the API key" — and a generic "HTTP 401: " buries that in noise. + /// And it must NOT echo the response body: some servers reflect the offending credential back, + /// and this text reaches a notification that may be on screen during a shared meeting. The same + /// concern already sanitizes `invalidEndpoint`. + case authenticationFailed(status: Int) public var errorDescription: String? { switch self { @@ -18,6 +26,8 @@ public enum SummaryError: LocalizedError { case .serverError(let message, let code): let suffix = code.map { " (\($0))" } ?? "" return "Summary provider error: \(message)\(suffix)" + case .authenticationFailed(let status): + return "The summary endpoint rejected the API key (HTTP \(status)) — check it in Settings" } } @@ -55,20 +65,28 @@ public struct OpenAISummaryProvider: SummaryProvider, Sendable { /// backoff and an over-large `Retry-After`. static let maxBackoffSeconds: Double = 30 - public init(endpoint: String, apiKey: String, model: String) { - self.init(endpoint: endpoint, apiKey: apiKey, model: model, session: .shared) + public init(endpoint: String, apiKey: String, model: String, requestTimeoutSeconds: Int? = nil) { + self.init(endpoint: endpoint, apiKey: apiKey, model: model, session: .shared, + requestTimeoutSeconds: requestTimeoutSeconds) } + /// See `LMStudioSummaryProvider.defaultRequestTimeoutSeconds` — same reasoning. + public static let defaultRequestTimeoutSeconds = 600 + /// Testable initializer: inject a `URLSession` (e.g. with a mock `URLProtocol`) and a /// shorter retry base delay so the 429 backoff path runs fast under test. - init(endpoint: String, apiKey: String, model: String, session: URLSession, retryBaseDelay: Double = 1.0) { + init(endpoint: String, apiKey: String, model: String, session: URLSession, + retryBaseDelay: Double = 1.0, requestTimeoutSeconds: Int? = nil) { self.endpoint = endpoint self.apiKey = apiKey self.model = model self.session = session self.retryBaseDelay = retryBaseDelay + self.requestTimeoutSeconds = requestTimeoutSeconds ?? Self.defaultRequestTimeoutSeconds } + private let requestTimeoutSeconds: Int + public func summarize(segments: [SummarySegment], metadata: SummaryMetadata) async throws -> String { let request = try buildRequest(segments: segments, metadata: metadata) @@ -96,6 +114,10 @@ public struct OpenAISummaryProvider: SummaryProvider, Sendable { } let body = String(data: data, encoding: .utf8) ?? "" + // 401/403 has a precise fix and a body that may echo the credential — classify it. + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw SummaryError.authenticationFailed(status: httpResponse.statusCode) + } throw SummaryError.requestFailed("HTTP \(httpResponse.statusCode): \(body.prefix(200))") } } @@ -146,6 +168,9 @@ public struct OpenAISummaryProvider: SummaryProvider, Sendable { var request = URLRequest(url: url) request.httpMethod = "POST" + // A local model accepts instantly and then generates for minutes; the stock 60s timeout is + // the wrong order of magnitude and cost two real summaries (#173). + request.timeoutInterval = TimeInterval(requestTimeoutSeconds) request.setValue("application/json", forHTTPHeaderField: "Content-Type") if !apiKey.isEmpty { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") diff --git a/TranscriberCore/PadRatioMonitor.swift b/TranscriberCore/PadRatioMonitor.swift index 3e652f7..4181711 100644 --- a/TranscriberCore/PadRatioMonitor.swift +++ b/TranscriberCore/PadRatioMonitor.swift @@ -49,6 +49,16 @@ public struct PadRatioMonitor { private var padFrames: Int64 = 0 private var totalFrames: Int64 = 0 private var reported = false + /// Whether this track has ever delivered real frames. + /// + /// Padding BEFORE the first delivered frame is a start offset, not a deficit: there was nothing + /// to capture, so nothing went missing. This distinction is what makes the detector usable — + /// the Core Audio tap delivers NO buffers while the output device is idle, so a recording + /// started before joining a call accrues pure padding until the call connects. Device-observed + /// 2026-08-11: a healthy recording read 97.6% zeros in its first 30s and fired at ratio 0.911 + /// on leading silence alone. Starting the recording first is the ordinary way to use the app, + /// so counting that as corruption makes the label worthless. + private var hasDeliveredData = false public init(threshold: Double = 0.10, minimumSeconds: Double = 30, minimumPaddedSeconds: Double = 15) { self.threshold = threshold @@ -60,6 +70,10 @@ public struct PadRatioMonitor { /// written alongside it. public mutating func record(padFrames newPad: Int64, dataFrames: Int64, rate: Double) -> Verdict { guard rate > 0, !reported else { return .notYet } + // Nothing counts until the track proves it can deliver. Once it has, everything counts — + // including padding, which is the whole point. + if dataFrames > 0 { hasDeliveredData = true } + guard hasDeliveredData else { return .notYet } padFrames += max(0, newPad) totalFrames += max(0, newPad) + max(0, dataFrames) @@ -79,5 +93,6 @@ public struct PadRatioMonitor { padFrames = 0 totalFrames = 0 reported = false + hasDeliveredData = false } } diff --git a/docs/gotchas.md b/docs/gotchas.md index 294fad2..d137a42 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -76,3 +76,7 @@ These are numbered for stable cross-referencing — new items are appended, neve 63. **Liveness must mean "we are recording", not "the OS is still calling us":** the #86 restart probe read a timestamp stamped on *every* arriving system buffer — including buffers the sticky format gate then dropped. A stream whose buffers were all being discarded therefore looked perfectly alive: the probe saw fresh arrivals, concluded frames had resumed, reset its restart budget, and blessed a stream writing nothing to disk. The stamp now sits **after** the gate, so only a buffer we can actually write counts as life. A muted remote is still counted alive, because muted audio passes the gate — only writer-*incompatible* buffers are excluded. Sustained drops now also record an anomaly rather than vanishing silently. 64. **A non-default clock anchor needs its own device-list listener:** `SystemTapSession` watched only `kAudioHardwarePropertyDefaultOutputDevice`. Once re-anchoring became common (any Bluetooth or virtual default output sends the clock elsewhere), the anchor is routinely a device the user never selected — and unplugging *that* fires no default-output notification. The IOProc then stalls forever, and a stall is invisible to `RateDriftMonitor` because that watchdog only runs when buffers arrive: zero callbacks means zero detection. The system track simply stops growing while the mic keeps recording and finalize reports success. Fixed by mirroring what `MicCaptureSession` has always done (gotcha #55): a second listener on `kAudioHardwarePropertyDevices` that rebuilds when the *anchor's* UID disappears — checked by UID, so unrelated device churn doesn't tear a hole in every recording. + +65. **The Core Audio tap delivers NO buffers while the output device is idle — so leading silence is pure padding, and a pad-ratio detector must ignore it (2026-08-11):** Unlike ScreenCaptureKit (whose stream keeps delivering zero-filled buffers, which is what the #86 liveness probe relies on), the tap's IOProc only runs while something holds the output device open. Start a recording *before* joining the call — the ordinary way to use the app — and the system track receives nothing until the call connects, so `timelineSilencePad` fabricates the entire span. Device-observed on the first real firing of `excessivePadding`: a perfectly healthy 30-minute recording measured **97.6% zero samples in its first 30s** (2.8% from 60-300s, 4.2% overall) and tripped the detector at ratio 0.911 with 27s of padding — clearing both the 10% ratio threshold and the 15s absolute floor, because leading silence trivially exceeds any absolute floor. The fix is not a bigger threshold but the right distinction: **padding before a track has ever delivered a frame is a START OFFSET, not a deficit** — nothing went missing because there was nothing to capture. `PadRatioMonitor` therefore accumulates nothing until its first `dataFrames > 0`. This preserves the #58 detection exactly, because in a genuine rate drift the device IS delivering (a call is connected, silence still arrives as zero-filled buffers at the wrong rate) — the monitor simply starts when the frames do. + +66. **A `URLError` from a summary provider is not a file error — map it explicitly (2026-08-11):** `MeetingSummarizer.runSummary`'s catch-all assumed "non-`SummaryError` ⇒ file read/write failure" and reported *"Couldn't read the transcript or write the summary — check disk space and permissions"*. `URLError` is not a `SummaryError`, so a request timeout landed there and told the user to check permissions. That misdirection cost two separate debugging sessions (2026-08-04 and 2026-08-11) before the real cause — a `-1001` timeout against a local LM Studio that accepted the request and then generated for longer than the 60s stock `URLSession` timeout — was found in the unified log. Two lessons: catch `URLError` before any catch-all that assigns a *cause*, and never let a stock network timeout govern a LOCAL model (a long transcript routinely needs minutes). `SummaryConfig.requestTimeoutSeconds` now defaults to 600s. The same lesson applies one level down: a bare `HTTP 401: ` is technically accurate and practically useless, so 401/403 is classified as `SummaryError.authenticationFailed` with a message naming the API key — and deliberately WITHOUT the response body, because some servers echo the offending credential back and this text reaches a notification that can be on screen during a shared meeting (the same reason `invalidEndpoint` is sanitized). Rule of thumb for this whole surface: a failure message should name the thing the user must change, and nothing else.