Skip to content

Commit eb1f2e6

Browse files
committed
fix: retry an ONNX decode that comes back empty
Padding the edges was not enough. A real 4s recording that this app dropped shows why: the audio decodes to nothing, and the same samples scaled by 1.001 decode to the full sentence while 0.999 still decode to nothing. Shifting the speech by 100ms flips the result either way. The decoders stop the moment they emit end-of-transcript and for some inputs emit it as their very first token; which inputs is not predictable from anything the app can measure, so no amount of tuning the padding fixes it. Retry instead. When a segment decodes to nothing, decode it again with the silence distributed differently — all of it in front, all behind, then split unevenly. Every layout adds exactly as much as the first attempt, so a retry can never push a segment past the one-pass limit the segmenter respects, and retries only ever follow an attempt that already produced nothing. Measured against 20 inputs derived from that recording, each confirmed to decode to nothing on this model: the ladder recovers all 20, including one that no single reframing in the search recovered on its own. The 11 clips that already worked are unchanged.
1 parent 50a76d6 commit eb1f2e6

4 files changed

Lines changed: 122 additions & 8 deletions

File tree

Sources/VocaMac/Services/SherpaAudioPreparation.swift

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,39 @@ enum SherpaAudioPreparation {
3434
}
3535
}
3636

37-
static func prepare(_ samples: [Float]) -> [Float] {
37+
/// Alternative ways to distribute the same silence, used to retry a decode
38+
/// that came back empty.
39+
///
40+
/// The decoders are chaotically sensitive to exact framing. Measured on a
41+
/// real 4s recording that decoded to nothing: scaling every sample by
42+
/// 1.001 recovered the whole sentence and 0.999 did not, and shifting the
43+
/// speech by 100ms flipped the result either way. No perturbation is right
44+
/// in general — but a decode that returned nothing has nothing to lose,
45+
/// and reframing recovers it.
46+
///
47+
/// Every layout adds exactly as much silence as the first attempt, so a
48+
/// retry can never push a segment past the one-pass limit the segmenter
49+
/// exists to respect.
50+
static let recoveryLayouts: [(lead: Int, tail: Int)] = [
51+
(lead: 2 * edgeSilenceSampleCount, tail: 0),
52+
(lead: 0, tail: 2 * edgeSilenceSampleCount),
53+
(lead: edgeSilenceSampleCount / 2, tail: 3 * edgeSilenceSampleCount / 2),
54+
]
55+
56+
static func prepare(
57+
_ samples: [Float],
58+
lead: Int = edgeSilenceSampleCount,
59+
tail: Int = edgeSilenceSampleCount
60+
) -> [Float] {
3861
// Do not ask generative decoders to invent words for digital silence.
3962
guard samples.contains(where: { $0 != 0 }) else { return [] }
4063

41-
let edge = repeatElement(Float.zero, count: edgeSilenceSampleCount)
42-
let paddedCount = samples.count + 2 * edgeSilenceSampleCount
64+
let paddedCount = samples.count + lead + tail
4365
var prepared: [Float] = []
4466
prepared.reserveCapacity(max(minimumSampleCount, paddedCount))
45-
prepared.append(contentsOf: edge)
67+
prepared.append(contentsOf: repeatElement(Float.zero, count: lead))
4668
prepared.append(contentsOf: samples)
47-
prepared.append(contentsOf: edge)
69+
prepared.append(contentsOf: repeatElement(Float.zero, count: tail))
4870

4971
if prepared.count < minimumSampleCount {
5072
prepared.append(

Sources/VocaMac/Services/SherpaService.swift

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -371,9 +371,7 @@ final class SherpaService: @unchecked Sendable {
371371
var detected = ""
372372
for segment in segments {
373373
try Task.checkCancellation()
374-
let samples = SherpaAudioPreparation.prepare(segment)
375-
guard !samples.isEmpty else { continue }
376-
let result = try decodeSegment(samples)
374+
guard let result = try decode(segment, with: decodeSegment) else { continue }
377375
try Task.checkCancellation()
378376
let piece = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
379377
if !piece.isEmpty { pieces.append(piece) }
@@ -383,6 +381,45 @@ final class SherpaService: @unchecked Sendable {
383381
return (joinTranscriptPieces(pieces, language: joinLanguage), detected)
384382
}
385383

384+
/// Decode one segment, retrying with a different silence layout when the
385+
/// model returns nothing.
386+
///
387+
/// These decoders stop as soon as they emit end-of-transcript, and for
388+
/// some inputs they emit it as their very first token — the recording is
389+
/// dropped with no error to show for it. Which inputs is not predictable:
390+
/// the same words shifted by a few milliseconds decode either perfectly or
391+
/// not at all. Retrying the same audio framed differently recovers it, and
392+
/// only ever runs after an attempt that produced nothing.
393+
///
394+
/// Returns nil when the segment held no audio to decode.
395+
static func decode(
396+
_ segment: [Float],
397+
with decodeSegment: ([Float]) throws -> (text: String, lang: String)
398+
) throws -> (text: String, lang: String)? {
399+
let samples = SherpaAudioPreparation.prepare(segment)
400+
guard !samples.isEmpty else { return nil }
401+
402+
let first = try decodeSegment(samples)
403+
guard first.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
404+
return first
405+
}
406+
407+
for layout in SherpaAudioPreparation.recoveryLayouts {
408+
try Task.checkCancellation()
409+
let retry = try decodeSegment(
410+
SherpaAudioPreparation.prepare(segment, lead: layout.lead, tail: layout.tail)
411+
)
412+
if !retry.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
413+
VocaLogger.info(
414+
.sherpaService,
415+
"Recovered an empty decode by reframing the segment"
416+
)
417+
return retry
418+
}
419+
}
420+
return first
421+
}
422+
386423
/// Join Chinese/Japanese segments without spaces. Korean, like Western
387424
/// languages, needs spaces between words. SenseVoice may wrap language
388425
/// tags in `<|…|>`.

Tests/VocaMacTests/SherpaAudioPreparationTests.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,27 @@ final class SherpaAudioPreparationTests: XCTestCase {
6363
}
6464
}
6565

66+
/// A retry must never buy its second chance by overrunning the model's
67+
/// one-pass limit, so every layout adds exactly as much as the first try.
68+
func testEveryRecoveryLayoutAddsTheSameSilenceAsTheFirstAttempt() {
69+
let speech = [Float](repeating: 0.1, count: 40_000)
70+
let first = SherpaAudioPreparation.prepare(speech)
71+
for layout in SherpaAudioPreparation.recoveryLayouts {
72+
let retry = SherpaAudioPreparation.prepare(
73+
speech, lead: layout.lead, tail: layout.tail
74+
)
75+
XCTAssertEqual(retry.count, first.count)
76+
XCTAssertEqual(Array(retry[layout.lead..<(layout.lead + speech.count)]), speech)
77+
}
78+
}
79+
80+
func testRecoveryLayoutsAreAllDifferentFromTheFirstAttempt() {
81+
let normal = SherpaAudioPreparation.edgeSilenceSampleCount
82+
for layout in SherpaAudioPreparation.recoveryLayouts {
83+
XCTAssertNotEqual(layout.lead, normal, "a retry that reframes nothing decodes the same")
84+
}
85+
}
86+
6687
func testEmptyAndDigitalSilenceDoNotReachDecoder() {
6788
XCTAssertEqual(SherpaAudioPreparation.prepare([]), [])
6889
XCTAssertEqual(SherpaAudioPreparation.prepare([Float](repeating: 0, count: 32_000)), [])

Tests/VocaMacTests/SherpaServiceTests.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,40 @@ final class SherpaServiceTests: XCTestCase {
185185
XCTAssertEqual(result.text, "안녕하세요 반갑습니다")
186186
}
187187

188+
func testEmptyDecodeIsRetriedWithADifferentFrameUntilItRecovers() {
189+
var attempts: [Int] = []
190+
let result = try? SherpaService.decodeSegments(
191+
[[Float](repeating: 0.2, count: 32_000)], language: "en"
192+
) { samples in
193+
attempts.append(samples.count)
194+
// Fail every framing but the last one on the ladder.
195+
let isLastLayout = attempts.count == SherpaAudioPreparation.recoveryLayouts.count + 1
196+
return (isLastLayout ? "recovered" : "", "en")
197+
}
198+
XCTAssertEqual(result?.text, "recovered")
199+
XCTAssertEqual(attempts.count, SherpaAudioPreparation.recoveryLayouts.count + 1)
200+
XCTAssertEqual(Set(attempts).count, 1, "a retry must not change the segment's length")
201+
}
202+
203+
func testASuccessfulDecodeIsNeverRetried() {
204+
var attempts = 0
205+
_ = try? SherpaService.decodeSegments([[0.3, 0.4]], language: "en") { _ in
206+
attempts += 1
207+
return ("got it", "en")
208+
}
209+
XCTAssertEqual(attempts, 1)
210+
}
211+
212+
func testAudioThatDecodesToNothingGivesUpAfterTheLadder() {
213+
var attempts = 0
214+
let result = try? SherpaService.decodeSegments([[0.3, 0.4]], language: "en") { _ in
215+
attempts += 1
216+
return ("", "en")
217+
}
218+
XCTAssertEqual(attempts, SherpaAudioPreparation.recoveryLayouts.count + 1)
219+
XCTAssertEqual(result?.text, "")
220+
}
221+
188222
func testFailedLaterSegmentDoesNotReturnPartialSuccess() {
189223
var calls = 0
190224
XCTAssertThrowsError(try SherpaService.decodeSegments([[0.1], [0.2]], language: "en") { _ in

0 commit comments

Comments
 (0)