Skip to content

Commit 7d832ee

Browse files
committed
fix: recover empty short Whisper transcriptions
1 parent 0629ef1 commit 7d832ee

2 files changed

Lines changed: 64 additions & 0 deletions

File tree

Sources/VocaMac/Services/WhisperService.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,22 @@ final class WhisperService: @unchecked Sendable {
206206
fullText = Self.filterHallucinationTokens(rawText)
207207
}
208208

209+
// Whisper Tiny can return no tokens for valid sub-second speech.
210+
// Retry only an empty short result with trailing silence so normal
211+
// successful dictation keeps the single-pass fast path.
212+
if let paddedAudio = Self.paddedAudioForShortEmptyTranscription(
213+
audioData,
214+
transcription: fullText
215+
) {
216+
VocaLogger.warning(
217+
.whisperService,
218+
"Short transcription was empty for \(loadedModelName ?? "unknown model"); retrying with trailing silence"
219+
)
220+
results = try await kit.transcribe(audioArray: paddedAudio, decodeOptions: options)
221+
rawText = results.map { $0.text }.joined(separator: " ")
222+
fullText = Self.filterHallucinationTokens(rawText)
223+
}
224+
209225
let elapsed = CFAbsoluteTimeGetCurrent() - startTime
210226

211227
// Get detected language from first result
@@ -293,6 +309,24 @@ final class WhisperService: @unchecked Sendable {
293309
promptTokens != nil && rawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
294310
}
295311

312+
/// Some Whisper models return no tokens for otherwise valid sub-second
313+
/// speech. A small trailing-silence pad gives the retry more context without
314+
/// changing the reported audio length.
315+
static func paddedAudioForShortEmptyTranscription(
316+
_ audio: [Float],
317+
transcription: String
318+
) -> [Float]? {
319+
let minimumSampleCount = 17_600 // 1.1 seconds at 16 kHz
320+
guard transcription.isEmpty,
321+
!audio.isEmpty,
322+
audio.count < minimumSampleCount else {
323+
return nil
324+
}
325+
var padded = audio
326+
padded.append(contentsOf: repeatElement(0, count: minimumSampleCount - audio.count))
327+
return padded
328+
}
329+
296330
/// Encode custom vocabulary into WhisperKit conditioning tokens.
297331
/// Returns nil when there are no terms or the tokenizer isn't ready yet.
298332
/// Framed as a "Glossary:" prompt, which nudges Whisper to treat the terms

Tests/VocaMacTests/WhisperServiceTests.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,33 @@ final class WhisperServiceVocabularyTests: XCTestCase {
9696
XCTAssertFalse(WhisperService.shouldRetryWithoutVocabulary(rawText: "", promptTokens: nil))
9797
}
9898
}
99+
100+
// MARK: - WhisperService Short Audio Tests
101+
102+
final class WhisperServiceShortAudioTests: XCTestCase {
103+
func testEmptyShortTranscriptionPadsToMinimumDecoderWindow() throws {
104+
let audio: [Float] = [0.25, -0.5, 0.75]
105+
let padded = try XCTUnwrap(
106+
WhisperService.paddedAudioForShortEmptyTranscription(audio, transcription: "")
107+
)
108+
109+
XCTAssertEqual(padded.count, 17_600)
110+
XCTAssertEqual(Array(padded.prefix(audio.count)), audio)
111+
XCTAssertTrue(padded.dropFirst(audio.count).allSatisfy { $0 == 0 })
112+
}
113+
114+
func testSuccessfulOrLongTranscriptionDoesNotRetry() {
115+
XCTAssertNil(
116+
WhisperService.paddedAudioForShortEmptyTranscription([0.5], transcription: "Hello")
117+
)
118+
XCTAssertNil(
119+
WhisperService.paddedAudioForShortEmptyTranscription(
120+
Array(repeating: 0.5, count: 17_600),
121+
transcription: ""
122+
)
123+
)
124+
XCTAssertNil(
125+
WhisperService.paddedAudioForShortEmptyTranscription([], transcription: "")
126+
)
127+
}
128+
}

0 commit comments

Comments
 (0)