Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions Sources/quill/Audio/MicRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions Sources/quill/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/// }
///
Expand Down Expand Up @@ -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.
Expand Down
80 changes: 80 additions & 0 deletions Sources/quill/Transcription/EchoFilter.swift
Original file line number Diff line number Diff line change
@@ -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]
}
}
10 changes: 9 additions & 1 deletion Sources/quill/Transcription/TranscriptionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down