Skip to content
Merged
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
3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ let package = Package(
"BestASRKit",
"BestASRMCPCore",
"BestASRGUICore",
// CLI parse regression tests (#101: ArgumentParser negative-value
// form) — SwiftPM links executable targets into tests since 5.5.
"bestasr",
.product(name: "WhisperKit", package: "WhisperKit"),
],
swiftSettings: [.swiftLanguageMode(.v5)]
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,26 @@ harmless no-op for backends that never emit those strings. Pass
`--hallucination-filter off` (CLI) or `hallucination_filter: "off"` (the MCP
`transcribe` tool) to disable it.

`--hallucination-filter full` adds confidence gating on top of the denylist:
cues are also dropped by Whisper's per-segment signals — the joint silence rule
(`no_speech_prob > 0.6` **and** `avg_logprob < -1.0`, openai-whisper semantics)
and the repetition rule (`compression_ratio > 2.4`). WhisperKit populates the
signals; backends that don't (whisper.cpp, Parakeet) sail through untouched, so
`full` degrades to `denylist` per backend automatically.

Complementary decode-side knobs (WhisperKit only; CLI-only for now — the MCP
tool and GUI don't expose them) suppress hallucinations at the source instead
of filtering them afterwards — unset, they ride WhisperKit's own defaults. With
`--decode-deterministic` (no fallback retries), aggressive thresholds mark or
skip segments outright instead of triggering a re-decode:

```bash
bestasr transcribe meeting.m4a \
--no-speech-threshold 0.5 \
--compression-ratio-threshold 2.2 \
--logprob-threshold -1.0
```

### Transcribe any source (agent skill)

`bestasr transcribe` takes a local audio file. The **`transcript` agent skill**
Expand Down
10 changes: 8 additions & 2 deletions Sources/BestASRKit/CommandCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,10 @@ public struct CommandCore: Sendable {
formatName: String,
outputPath: String?,
diarize: Bool = false,
hallucinationFilter: HallucinationFilterMode = .denylist
hallucinationFilter: HallucinationFilterMode = .denylist,
noSpeechThreshold: Double? = nil,
compressionRatioThreshold: Double? = nil,
logProbThreshold: Double? = nil
) async throws -> TranscribeOutcome {
let format = try TranscriptWriter.format(named: formatName)
let audio = try AudioProber.probe(
Expand All @@ -301,7 +304,10 @@ public struct CommandCore: Sendable {
audioPath: audio.path,
options: TranscribeOptions(
model: rec.model, quantization: rec.quantization,
language: audio.language, prompt: context?.rendered.prompt)
language: audio.language, prompt: context?.rendered.prompt,
noSpeechThreshold: noSpeechThreshold,
compressionRatioThreshold: compressionRatioThreshold,
logProbThreshold: logProbThreshold)
)

// Cue-level diarization (#25, spec diarization): acoustic turns from the
Expand Down
15 changes: 13 additions & 2 deletions Sources/BestASRKit/Engines/Engine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,21 @@ public struct RawTranscription: Sendable {
public let end: Double
public let text: String
public let confidence: Double?
/// Whisper per-segment hallucination signals (#100); nil when the
/// backend does not compute them.
public let noSpeechProb: Double?
public let compressionRatio: Double?

public init(start: Double, end: Double, text: String, confidence: Double? = nil) {
public init(
start: Double, end: Double, text: String, confidence: Double? = nil,
noSpeechProb: Double? = nil, compressionRatio: Double? = nil
) {
self.start = start
self.end = end
self.text = text
self.confidence = confidence
self.noSpeechProb = noSpeechProb
self.compressionRatio = compressionRatio
}
}

Expand Down Expand Up @@ -109,7 +118,9 @@ extension Engine {
start: seg.start,
end: seg.end,
text: seg.text,
confidence: seg.confidence
confidence: seg.confidence,
noSpeechProb: seg.noSpeechProb,
compressionRatio: seg.compressionRatio
)
}
let text = segments.map(\.text).joined().trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
20 changes: 17 additions & 3 deletions Sources/BestASRKit/Engines/WhisperKitEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,21 @@ public struct WhisperKitEngine: Engine {
/// segment text, polluting transcripts and inflating WER (#6 — caught by
/// real-model verification against jfk.wav / OSR Harvard).
static func makeDecodeOptions(
language: String?, promptTokens: [Int]?, deterministic: Bool = false
language: String?, promptTokens: [Int]?, deterministic: Bool = false,
noSpeechThreshold: Double? = nil, compressionRatioThreshold: Double? = nil,
logProbThreshold: Double? = nil
) -> DecodingOptions {
var decodeOptions = DecodingOptions()
decodeOptions.language = language
decodeOptions.detectLanguage = language == nil
decodeOptions.skipSpecialTokens = true
// Decode-param knobs (#101): apply only when set — nil keeps
// WhisperKit's own defaults byte-for-byte (pre-#101 behavior).
if let noSpeechThreshold { decodeOptions.noSpeechThreshold = Float(noSpeechThreshold) }
if let compressionRatioThreshold {
decodeOptions.compressionRatioThreshold = Float(compressionRatioThreshold)
}
if let logProbThreshold { decodeOptions.logProbThreshold = Float(logProbThreshold) }
if deterministic {
// #34 regression gate: WhisperKit defaults to 5 temperature-
// fallback retries (0.2 increments) on low-quality segments —
Expand Down Expand Up @@ -143,15 +152,20 @@ public struct WhisperKitEngine: Engine {
}
let decodeOptions = Self.makeDecodeOptions(
language: options.language, promptTokens: promptTokens,
deterministic: options.deterministicDecode)
deterministic: options.deterministicDecode,
noSpeechThreshold: options.noSpeechThreshold,
compressionRatioThreshold: options.compressionRatioThreshold,
logProbThreshold: options.logProbThreshold)

let results = try await pipe.transcribe(audioPath: audioPath, decodeOptions: decodeOptions)
let segments = results.flatMap(\.segments).map { seg in
RawTranscription.RawSegment(
start: Double(seg.start),
end: Double(seg.end),
text: seg.text,
confidence: Double(seg.avgLogprob)
confidence: Double(seg.avgLogprob),
noSpeechProb: Double(seg.noSpeechProb),
compressionRatio: Double(seg.compressionRatio)
)
}
return RawTranscription(
Expand Down
25 changes: 24 additions & 1 deletion Sources/BestASRKit/Models/DataModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,16 +81,28 @@ public struct TranscribeOptions: Sendable, Equatable {
/// needs reproducibility more than the occasional rescue; normal
/// transcription keeps the fallback.
public let deterministicDecode: Bool
/// WhisperKit decode-param knobs (#101): nil rides WhisperKit's own
/// defaults. Other backends never read these — the knobs suppress
/// hallucinations at decode time and only WhisperKit's decoder has them
/// (complementary to the backend-agnostic post-decode filter, #98/#100).
public let noSpeechThreshold: Double?
public let compressionRatioThreshold: Double?
public let logProbThreshold: Double?

public init(
model: String, quantization: String, language: String? = nil, prompt: String? = nil,
deterministicDecode: Bool = false
deterministicDecode: Bool = false,
noSpeechThreshold: Double? = nil, compressionRatioThreshold: Double? = nil,
logProbThreshold: Double? = nil
) {
self.model = model
self.quantization = quantization
self.language = language
self.prompt = prompt
self.deterministicDecode = deterministicDecode
self.noSpeechThreshold = noSpeechThreshold
self.compressionRatioThreshold = compressionRatioThreshold
self.logProbThreshold = logProbThreshold
}
}

Expand All @@ -100,27 +112,38 @@ public struct TranscriptSegment: Sendable, Equatable {
public let end: Double
public let text: String
public let confidence: Double?
/// Whisper per-segment hallucination signals (#100): probability the
/// segment is silence, and the text's gzip compression ratio (repetition
/// marker). nil when the backend does not compute them (whisper.cpp /
/// Parakeet) — a nil signal can never trip the `full` filter's thresholds,
/// which is exactly the per-backend degradation the spec asks for.
public let noSpeechProb: Double?
public let compressionRatio: Double?
/// Cue-level diarization label (`SPEAKER_1`-based, order of first appearance;
/// #25). nil when diarization did not run or no turn overlapped this segment
/// — absent means "unknown", never a fabricated speaker (spec diarization).
public let speaker: String?

public init(
id: Int, start: Double, end: Double, text: String, confidence: Double? = nil,
noSpeechProb: Double? = nil, compressionRatio: Double? = nil,
speaker: String? = nil
) {
self.id = id
self.start = start
self.end = end
self.text = text
self.confidence = confidence
self.noSpeechProb = noSpeechProb
self.compressionRatio = compressionRatio
self.speaker = speaker
}

/// Same segment with a speaker label attached (assignment happens post-transcription).
public func withSpeaker(_ speaker: String?) -> TranscriptSegment {
TranscriptSegment(
id: id, start: start, end: end, text: text, confidence: confidence,
noSpeechProb: noSpeechProb, compressionRatio: compressionRatio,
speaker: speaker)
}
}
Expand Down
32 changes: 31 additions & 1 deletion Sources/BestASRKit/Output/HallucinationFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ public enum HallucinationFilterMode: String, Sendable, CaseIterable {
/// adjacent-duplicate cues. Backend-agnostic; the denylist content is
/// Whisper-family, so it is a no-op for backends that never emit it.
case denylist
/// Everything `denylist` does, plus confidence-gated drops on Whisper's
/// per-segment signals (#100): the joint silence rule (noSpeechProb AND
/// avgLogprob past threshold, openai-whisper semantics) and the repetition
/// rule (compressionRatio past threshold). Backends that don't populate the
/// signals degrade to `denylist` behavior — nil never trips a threshold.
case full
}

/// Post-decode cleanup pass. A pure function over a `Transcript` that never
Expand All @@ -19,6 +25,26 @@ public enum HallucinationFilterMode: String, Sendable, CaseIterable {
/// diarization, which makes it backend-agnostic and preserves speaker labels on
/// the cues that survive.
public enum HallucinationFilter {
/// Whisper's canonical thresholds (openai/whisper defaults). The silence
/// rule is deliberately a conjunction — a high no-speech probability alone
/// (or a low logprob alone) also occurs on quiet-but-real speech.
static let noSpeechThreshold = 0.6
static let logProbThreshold = -1.0
static let compressionRatioThreshold = 2.4

/// True when the segment's confidence signals mark it as a hallucination
/// (`full` mode only). nil signals never trip a rule.
static func isConfidenceFlagged(_ segment: TranscriptSegment) -> Bool {
if let noSpeech = segment.noSpeechProb, let logProb = segment.confidence,
noSpeech > noSpeechThreshold, logProb < logProbThreshold {
return true
}
if let compression = segment.compressionRatio, compression > compressionRatioThreshold {
return true
}
return false
}

/// Return `transcript` with hallucination cues removed per `mode`.
/// A no-op (`mode == .off`, or nothing matched) returns the input untouched
/// so ids and flat text stay byte-identical.
Expand All @@ -36,6 +62,8 @@ public enum HallucinationFilter {
if trimmed.isEmpty { continue }
// Known-boilerplate hallucination.
if denylist.matches(segment.text) { continue }
// Confidence-gated drop (full mode only, #100).
if mode == .full, isConfidenceFlagged(segment) { continue }
// Adjacent exact-duplicate cue (rolling caption / token echo).
if let previous = kept.last,
previous.text.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed {
Expand All @@ -54,7 +82,9 @@ public enum HallucinationFilter {
let reindexed = kept.enumerated().map { index, segment in
TranscriptSegment(
id: index + 1, start: segment.start, end: segment.end,
text: segment.text, confidence: segment.confidence, speaker: segment.speaker)
text: segment.text, confidence: segment.confidence,
noSpeechProb: segment.noSpeechProb, compressionRatio: segment.compressionRatio,
speaker: segment.speaker)
}
let rebuiltText = reindexed.map(\.text).joined()
.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
24 changes: 22 additions & 2 deletions Sources/bestasr/BestASRCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,26 @@ struct Transcribe: AsyncParsableCommand {
@Flag(help: "Label each cue with an acoustic speaker (SPEAKER_1…); downloads CoreML diarization models on first use")
var diarize = false

@Option(help: "Strip decoder hallucinations before writing: off | denylist (default denylist)")
@Option(help: "Strip decoder hallucinations before writing: off | denylist | full (confidence-gated, WhisperKit signals; default denylist)")
var hallucinationFilter: HallucinationFilterMode = .denylist

@Option(help: "Decode knob (WhisperKit only): mark segments above this no-speech probability as silence (default: WhisperKit's)")
var noSpeechThreshold: Double?

@Option(help: "Decode knob (WhisperKit only): treat segments above this compression ratio as failed/repetitive (default: WhisperKit's)")
var compressionRatioThreshold: Double?

// parsing: .unconditional — the meaningful domain is all-negative (it's a
// log-prob floor), and ArgumentParser's default strategy rejects a leading
// dash ("--logprob-threshold -1.0" → 'Missing value'). Unconditional
// consumes the next token as the value (verify #101 HIGH).
@Option(
parsing: .unconditional,
help:
"Decode knob (WhisperKit only): average-logprob floor below which a segment decode is retried/marked (negative, e.g. -1.0; default: WhisperKit's). Note: with --decode-deterministic, threshold trips mark/skip segments instead of triggering a fallback retry"
)
var logprobThreshold: Double?

func run() async throws {
try await runMapped {
let result = try await CommandCore.live().transcribe(
Expand All @@ -117,7 +134,10 @@ struct Transcribe: AsyncParsableCommand {
formatName: format,
outputPath: output,
diarize: diarize,
hallucinationFilter: hallucinationFilter
hallucinationFilter: hallucinationFilter,
noSpeechThreshold: noSpeechThreshold,
compressionRatioThreshold: compressionRatioThreshold,
logProbThreshold: logprobThreshold
)
print("Wrote \(result.format) transcript to \(result.outputPath)")
if explain {
Expand Down
52 changes: 52 additions & 0 deletions Tests/BestASRKitTests/DecodeKnobsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation
import Testing
import WhisperKit

@testable import BestASRKit
@testable import bestasr

/// spec asr-engine (#101): WhisperKit decode-param knobs. nil = ride
/// WhisperKit's own defaults (the pre-#101 behavior, byte-for-byte).
struct DecodeKnobsTests {
@Test func `set thresholds reach DecodingOptions`() {
let options = WhisperKitEngine.makeDecodeOptions(
language: "en", promptTokens: nil,
noSpeechThreshold: 0.5, compressionRatioThreshold: 2.0, logProbThreshold: -0.8)
#expect(options.noSpeechThreshold == 0.5)
#expect(options.compressionRatioThreshold == 2.0)
#expect(options.logProbThreshold == -0.8)
}

@Test func `unset thresholds keep WhisperKit defaults`() {
let ours = WhisperKitEngine.makeDecodeOptions(language: "en", promptTokens: nil)
let stock = DecodingOptions()
#expect(ours.noSpeechThreshold == stock.noSpeechThreshold)
#expect(ours.compressionRatioThreshold == stock.compressionRatioThreshold)
#expect(ours.logProbThreshold == stock.logProbThreshold)
}

// verify #101 HIGH: the log-prob domain is all-negative; the documented
// space form ("--logprob-threshold -1.0") must parse (parsing: .unconditional).
@Test func `negative logprob threshold parses in space form`() throws {
let command = try Transcribe.parse(["in.wav", "--logprob-threshold", "-1.0"])
#expect(command.logprobThreshold == -1.0)
// The positive-domain knobs stay on the default strategy and parse too.
let both = try Transcribe.parse(
["in.wav", "--no-speech-threshold", "0.5", "--compression-ratio-threshold", "2.2"])
#expect(both.noSpeechThreshold == 0.5)
#expect(both.compressionRatioThreshold == 2.2)
}

@Test func `TranscribeOptions carries the knobs with nil defaults`() {
let plain = TranscribeOptions(model: "base", quantization: "default")
#expect(plain.noSpeechThreshold == nil)
#expect(plain.compressionRatioThreshold == nil)
#expect(plain.logProbThreshold == nil)

let tuned = TranscribeOptions(
model: "base", quantization: "default",
noSpeechThreshold: 0.7, compressionRatioThreshold: 2.2, logProbThreshold: -1.2)
#expect(tuned.noSpeechThreshold == 0.7)
#expect(tuned != plain)
}
}
Loading
Loading