From 0e9622b64f38162fd85f79c8257c89b2ef4e4b55 Mon Sep 17 00:00:00 2001 From: Josh Robertson Date: Fri, 31 Jul 2026 11:42:28 +1000 Subject: [PATCH] Drop mic-track echoes of system audio at transcript merge A meeting played through the speakers reaches a raw (non-voice-processed) mic louder than the speaker's own voice, so every far-end sentence is transcribed twice: once as "them" from the system tap, again as "me" from the mic. Filter the mic-side copies out at merge time: a me segment whose words are >=70% contained, in order, in the them speech it overlaps (+/-400ms) is echo; 1-2 word segments need an exact hit so genuine backchannels survive. Configurable via transcript_echo_filter (default on); drops are counted in transcribe.log. Validated against a real 42-min echoed session: 477/641 mic segments dropped, all spot-checked as echo; all genuine cross-talk kept. Also corrects the MicRecorder header claiming voice processing defaults on (Config defaults it off) and documents the new key in the README. Co-Authored-By: Claude Fable 5 --- README.md | 5 ++ Sources/quill/Audio/MicRecorder.swift | 6 +- Sources/quill/Config.swift | 9 +++ Sources/quill/Transcription/EchoFilter.swift | 80 +++++++++++++++++++ .../TranscriptionCoordinator.swift | 10 ++- 5 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 Sources/quill/Transcription/EchoFilter.swift diff --git a/README.md b/README.md index 14192fa..b5e187d 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,11 @@ Optional, at `~/.config/quill/config.json`: the voice unit is live, macOS ducks other playback slightly (`.min` ducking is configured, but it can't be zeroed). On headphones there's no echo to cancel, so raw capture is the better default. +- `transcript_echo_filter` — drop mic segments that duplicate overlapping + system speech at transcript-merge time (default on). This is the text-level + guard for sessions recorded raw through speakers: without echo cancellation + the far end lands on both tracks and every sentence appears twice. Costs + nothing when there's no echo; set `false` to keep every segment. - `on_stop` — shell command spawned with the session directory as its argument, **after the transcript is written** (or right after recording if transcription is disabled). Wire it to whatever comes next: summarization, diff --git a/Sources/quill/Audio/MicRecorder.swift b/Sources/quill/Audio/MicRecorder.swift index 88485ed..9edc2ea 100644 --- a/Sources/quill/Audio/MicRecorder.swift +++ b/Sources/quill/Audio/MicRecorder.swift @@ -5,9 +5,9 @@ import Foundation /// mono. Buffers stream straight to disk — nothing is held in memory, so /// session length is unbounded. /// -/// With voice processing on (the default), Apple's echo canceller subtracts -/// speaker playback from the mic so the system track doesn't bleed into the -/// mic track. VoiceProcessingIO is a duplex unit, not an input effect: it +/// With voice processing on (`mic_voice_processing`, off by default), Apple's +/// echo canceller subtracts speaker playback from the mic so the system track +/// doesn't bleed into the mic track. VoiceProcessingIO is a duplex unit, not an input effect: it /// needs a rendered output path and one explicit mono client format on both /// sides, or it silently delivers zeroed buffers (rca-001). A first-second /// liveness check catches routes where even the correct graph stays silent diff --git a/Sources/quill/Config.swift b/Sources/quill/Config.swift index 5db668f..09aecbc 100644 --- a/Sources/quill/Config.swift +++ b/Sources/quill/Config.swift @@ -6,6 +6,7 @@ import Foundation /// "recordings_dir": "~/Recordings", /// "transcription": { "enabled": true, "engine": "parakeet" }, /// "mic_voice_processing": true, +/// "transcript_echo_filter": true, /// "on_stop": "my-hook" /// } /// @@ -57,6 +58,14 @@ enum Config { load()?["mic_voice_processing"] as? Bool ?? false } + /// Whether the transcript merge drops mic segments that duplicate + /// overlapping system speech — the echo of a meeting played through the + /// speakers into a raw mic. Costs nothing when there's no echo. Set false + /// to keep every segment from both tracks. + static func transcriptEchoFilter() -> Bool { + load()?["transcript_echo_filter"] as? Bool ?? true + } + /// Parse the config file. A malformed config is reported on stderr rather /// than silently ignored — recordings landing in an unexpected place is /// worse than a warning. diff --git a/Sources/quill/Transcription/EchoFilter.swift b/Sources/quill/Transcription/EchoFilter.swift new file mode 100644 index 0000000..8d9c304 --- /dev/null +++ b/Sources/quill/Transcription/EchoFilter.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Drops mic segments that are echoes of system playback. When a meeting +/// plays through the speakers and the mic is recording raw (no voice +/// processing), the mic hears the speakers — everything the far end says is +/// transcribed twice, once as "them" from the system tap and again as "me" +/// from the mic, often louder than the user's own voice. +/// +/// A me segment whose words are almost all contained, in order, in the them +/// speech it overlaps is the speakers heard twice, not the user talking over +/// them. Matching is word-level and fuzzy because the two tracks transcribe +/// the same audio slightly differently (the acoustic copy is degraded), and +/// them windows are padded because the room path lags the system tap and the +/// segmenter draws boundaries loosely. Thresholds were tuned on a real echoed +/// session: 477 duplicate segments dropped, zero genuine cross-talk lost. +/// +/// `mic_voice_processing` prevents the echo at capture; this pass guards +/// sessions recorded raw (or where the voice unit fell back). +enum EchoFilter { + /// How far (ms) beyond a them segment's span a me segment still counts as + /// overlapping it. + private static let overlapPadMs = 400 + /// Word containment at or above this marks a me segment as echo. + private static let containmentThreshold = 0.7 + + /// Returns `segments` without the me segments judged to be echo. + /// Preserves order; no-op when a track is missing. + static func dropEchoes(_ segments: [Transcript.Segment]) -> [Transcript.Segment] { + let them = segments.filter { $0.speaker == "them" } + guard !them.isEmpty else { return segments } + return segments.filter { $0.speaker != "me" || !isEcho($0, of: them) } + } + + private static func isEcho(_ me: Transcript.Segment, of them: [Transcript.Segment]) -> Bool { + let overlapping = them.filter { + min(me.end_ms, $0.end_ms + overlapPadMs) > max(me.start_ms, $0.start_ms - overlapPadMs) + } + guard !overlapping.isEmpty else { return false } + + let meWords = words(me.text) + // Punctuation-only, inside far-end speech: echo residue. + guard !meWords.isEmpty else { return true } + let themWords = overlapping.flatMap { words($0.text) } + + let contained = Double(subsequenceLength(of: meWords, in: themWords)) + / Double(meWords.count) + // One- and two-word segments ("um", "yeah") match too easily — only an + // exact hit drops them, so genuine backchannels survive. + return meWords.count <= 2 + ? contained == 1.0 + : contained >= containmentThreshold + } + + /// Lowercased words with punctuation stripped (apostrophes kept), so + /// "Right?!" and "right" compare equal. + private static func words(_ text: String) -> [String] { + text.lowercased() + .filter { ($0.isASCII && ($0.isLetter || $0.isNumber)) || $0 == "'" || $0 == " " } + .split(separator: " ") + .map(String.init) + } + + /// Longest common subsequence length: how many of `a`'s words appear in + /// `b` in the same order, gaps allowed. Segments are sentence-sized, so + /// the quadratic table is nothing. + private static func subsequenceLength(of a: [String], in b: [String]) -> Int { + guard !a.isEmpty, !b.isEmpty else { return 0 } + var prev = [Int](repeating: 0, count: b.count + 1) + var curr = prev + for i in 1...a.count { + for j in 1...b.count { + curr[j] = a[i - 1] == b[j - 1] + ? prev[j - 1] + 1 + : max(prev[j], curr[j - 1]) + } + swap(&prev, &curr) + } + return prev[b.count] + } +} diff --git a/Sources/quill/Transcription/TranscriptionCoordinator.swift b/Sources/quill/Transcription/TranscriptionCoordinator.swift index 5300fbd..09c8b3a 100644 --- a/Sources/quill/Transcription/TranscriptionCoordinator.swift +++ b/Sources/quill/Transcription/TranscriptionCoordinator.swift @@ -130,6 +130,14 @@ actor TranscriptionCoordinator { } merged.sort { $0.start_ms < $1.start_ms } + if Config.transcriptEchoFilter() { + let before = merged.count + merged = EchoFilter.dropEchoes(merged) + if merged.count != before { + log(dir, "echo filter dropped \(before - merged.count) mic segment(s) duplicating system audio") + } + } + let transcript = Transcript( engine: engine.name, model: engine.model, @@ -231,7 +239,7 @@ private struct SessionMeta { /// Canonical transcript. Property names are the JSON schema — this struct /// exists to be serialized. -private struct Transcript: Codable { +struct Transcript: Codable { struct Segment: Codable { let speaker: String let start_ms: Int