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
32 changes: 27 additions & 5 deletions Sources/VocaMac/Services/SherpaAudioPreparation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,39 @@ enum SherpaAudioPreparation {
}
}

static func prepare(_ samples: [Float]) -> [Float] {
/// Alternative ways to distribute the same silence, used to retry a decode
/// that came back empty.
///
/// The decoders are chaotically sensitive to exact framing. Measured on a
/// real 4s recording that decoded to nothing: scaling every sample by
/// 1.001 recovered the whole sentence and 0.999 did not, and shifting the
/// speech by 100ms flipped the result either way. No perturbation is right
/// in general — but a decode that returned nothing has nothing to lose,
/// and reframing recovers it.
///
/// Every layout adds exactly as much silence as the first attempt, so a
/// retry can never push a segment past the one-pass limit the segmenter
/// exists to respect.
static let recoveryLayouts: [(lead: Int, tail: Int)] = [
(lead: 2 * edgeSilenceSampleCount, tail: 0),
(lead: 0, tail: 2 * edgeSilenceSampleCount),
(lead: edgeSilenceSampleCount / 2, tail: 3 * edgeSilenceSampleCount / 2),
]

static func prepare(
_ samples: [Float],
lead: Int = edgeSilenceSampleCount,
tail: Int = edgeSilenceSampleCount
) -> [Float] {
// Do not ask generative decoders to invent words for digital silence.
guard samples.contains(where: { $0 != 0 }) else { return [] }

let edge = repeatElement(Float.zero, count: edgeSilenceSampleCount)
let paddedCount = samples.count + 2 * edgeSilenceSampleCount
let paddedCount = samples.count + lead + tail
var prepared: [Float] = []
prepared.reserveCapacity(max(minimumSampleCount, paddedCount))
prepared.append(contentsOf: edge)
prepared.append(contentsOf: repeatElement(Float.zero, count: lead))
prepared.append(contentsOf: samples)
prepared.append(contentsOf: edge)
prepared.append(contentsOf: repeatElement(Float.zero, count: tail))

if prepared.count < minimumSampleCount {
prepared.append(
Expand Down
43 changes: 40 additions & 3 deletions Sources/VocaMac/Services/SherpaService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -371,9 +371,7 @@ final class SherpaService: @unchecked Sendable {
var detected = ""
for segment in segments {
try Task.checkCancellation()
let samples = SherpaAudioPreparation.prepare(segment)
guard !samples.isEmpty else { continue }
let result = try decodeSegment(samples)
guard let result = try decode(segment, with: decodeSegment) else { continue }
try Task.checkCancellation()
let piece = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !piece.isEmpty { pieces.append(piece) }
Expand All @@ -383,6 +381,45 @@ final class SherpaService: @unchecked Sendable {
return (joinTranscriptPieces(pieces, language: joinLanguage), detected)
}

/// Decode one segment, retrying with a different silence layout when the
/// model returns nothing.
///
/// These decoders stop as soon as they emit end-of-transcript, and for
/// some inputs they emit it as their very first token — the recording is
/// dropped with no error to show for it. Which inputs is not predictable:
/// the same words shifted by a few milliseconds decode either perfectly or
/// not at all. Retrying the same audio framed differently recovers it, and
/// only ever runs after an attempt that produced nothing.
///
/// Returns nil when the segment held no audio to decode.
static func decode(
_ segment: [Float],
with decodeSegment: ([Float]) throws -> (text: String, lang: String)
) throws -> (text: String, lang: String)? {
let samples = SherpaAudioPreparation.prepare(segment)
guard !samples.isEmpty else { return nil }

let first = try decodeSegment(samples)
guard first.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return first
}

for layout in SherpaAudioPreparation.recoveryLayouts {
try Task.checkCancellation()
let retry = try decodeSegment(
SherpaAudioPreparation.prepare(segment, lead: layout.lead, tail: layout.tail)
)
if !retry.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
VocaLogger.info(
.sherpaService,
"Recovered an empty decode by reframing the segment"
)
return retry
}
}
return first
}

/// Join Chinese/Japanese segments without spaces. Korean, like Western
/// languages, needs spaces between words. SenseVoice may wrap language
/// tags in `<|…|>`.
Expand Down
21 changes: 21 additions & 0 deletions Tests/VocaMacTests/SherpaAudioPreparationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,27 @@ final class SherpaAudioPreparationTests: XCTestCase {
}
}

/// A retry must never buy its second chance by overrunning the model's
/// one-pass limit, so every layout adds exactly as much as the first try.
func testEveryRecoveryLayoutAddsTheSameSilenceAsTheFirstAttempt() {
let speech = [Float](repeating: 0.1, count: 40_000)
let first = SherpaAudioPreparation.prepare(speech)
for layout in SherpaAudioPreparation.recoveryLayouts {
let retry = SherpaAudioPreparation.prepare(
speech, lead: layout.lead, tail: layout.tail
)
XCTAssertEqual(retry.count, first.count)
XCTAssertEqual(Array(retry[layout.lead..<(layout.lead + speech.count)]), speech)
}
}

func testRecoveryLayoutsAreAllDifferentFromTheFirstAttempt() {
let normal = SherpaAudioPreparation.edgeSilenceSampleCount
for layout in SherpaAudioPreparation.recoveryLayouts {
XCTAssertNotEqual(layout.lead, normal, "a retry that reframes nothing decodes the same")
}
}

func testEmptyAndDigitalSilenceDoNotReachDecoder() {
XCTAssertEqual(SherpaAudioPreparation.prepare([]), [])
XCTAssertEqual(SherpaAudioPreparation.prepare([Float](repeating: 0, count: 32_000)), [])
Expand Down
34 changes: 34 additions & 0 deletions Tests/VocaMacTests/SherpaServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,40 @@ final class SherpaServiceTests: XCTestCase {
XCTAssertEqual(result.text, "안녕하세요 반갑습니다")
}

func testEmptyDecodeIsRetriedWithADifferentFrameUntilItRecovers() {
var attempts: [Int] = []
let result = try? SherpaService.decodeSegments(
[[Float](repeating: 0.2, count: 32_000)], language: "en"
) { samples in
attempts.append(samples.count)
// Fail every framing but the last one on the ladder.
let isLastLayout = attempts.count == SherpaAudioPreparation.recoveryLayouts.count + 1
return (isLastLayout ? "recovered" : "", "en")
}
XCTAssertEqual(result?.text, "recovered")
XCTAssertEqual(attempts.count, SherpaAudioPreparation.recoveryLayouts.count + 1)
XCTAssertEqual(Set(attempts).count, 1, "a retry must not change the segment's length")
}

func testASuccessfulDecodeIsNeverRetried() {
var attempts = 0
_ = try? SherpaService.decodeSegments([[0.3, 0.4]], language: "en") { _ in
attempts += 1
return ("got it", "en")
}
XCTAssertEqual(attempts, 1)
}

func testAudioThatDecodesToNothingGivesUpAfterTheLadder() {
var attempts = 0
let result = try? SherpaService.decodeSegments([[0.3, 0.4]], language: "en") { _ in
attempts += 1
return ("", "en")
}
XCTAssertEqual(attempts, SherpaAudioPreparation.recoveryLayouts.count + 1)
XCTAssertEqual(result?.text, "")
}

func testFailedLaterSegmentDoesNotReturnPartialSuccess() {
var calls = 0
XCTAssertThrowsError(try SherpaService.decodeSegments([[0.1], [0.2]], language: "en") { _ in
Expand Down
Loading