Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
29 changes: 27 additions & 2 deletions Sources/VocaMac/Services/SherpaAudioPreparation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@
enum SherpaAudioPreparation {
static let minimumSampleCount = 16_000

/// Silence added on both sides of every segment — 200ms at 16kHz.
///
/// A push-to-talk recording starts on the first frame the tap delivers, so
/// speech often begins in sample zero. The NeMo-derived models decode that
/// as an utterance already in progress and their attention decoder emits
/// end-of-transcript as its very first token, which comes back as an empty
/// result for perfectly good audio — the recording is simply dropped.
/// Measured against Canary 180M, a lead-in as short as 50ms is enough to
/// recover every clip that failed this way; 200ms leaves margin, and the
/// matching tail keeps a final word from being cut off mid-decode.
static let edgeSilenceSampleCount = 3_200

/// Reject malformed input before segmentation or any native inference.
static func validate(_ samples: [Float]) throws {
guard !samples.isEmpty else { throw SherpaError.emptyAudio }
Expand All @@ -19,7 +31,20 @@ enum SherpaAudioPreparation {
static func prepare(_ samples: [Float]) -> [Float] {
// Do not ask generative decoders to invent words for digital silence.
guard samples.contains(where: { $0 != 0 }) else { return [] }
guard samples.count < minimumSampleCount else { return samples }
return samples + [Float](repeating: 0, count: minimumSampleCount - samples.count)

let edge = repeatElement(Float.zero, count: edgeSilenceSampleCount)
let paddedCount = samples.count + 2 * edgeSilenceSampleCount
var prepared: [Float] = []
prepared.reserveCapacity(max(minimumSampleCount, paddedCount))
prepared.append(contentsOf: edge)
prepared.append(contentsOf: samples)
prepared.append(contentsOf: edge)
Comment thread
greptile-apps[bot] marked this conversation as resolved.

if prepared.count < minimumSampleCount {
prepared.append(
contentsOf: repeatElement(Float.zero, count: minimumSampleCount - prepared.count)
)
}
return prepared
}
}
13 changes: 12 additions & 1 deletion Sources/VocaMac/Services/SherpaService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,18 @@ final class SherpaService: @unchecked Sendable {
let text = decoded.text.trimmingCharacters(in: .whitespacesAndNewlines)

VocaLogger.info(.sherpaService, "ONNX transcription completed in \(String(format: "%.2f", elapsed))s")
VocaLogger.info(.sherpaService, "Result: \(text.prefix(100))...")
if text.isEmpty {
// The decoders return an empty string rather than an error when
// they stop on their first token, so nothing else marks a dropped
// recording. Say so plainly — an INFO line reading "Result: ..."
// is indistinguishable from a successful decode in a log.
VocaLogger.warning(
.sherpaService,
"ONNX decode returned no text for \(String(format: "%.1f", audioLengthSeconds))s of audio"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
)
} else {
VocaLogger.info(.sherpaService, "Result: \(text.prefix(100))...")
}

// SenseVoice reports the detected language; other models are
// monolingual or fixed at load time.
Expand Down
37 changes: 29 additions & 8 deletions Tests/VocaMacTests/SherpaAudioPreparationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import XCTest
@testable import VocaMac

final class SherpaAudioPreparationTests: XCTestCase {
private let edge = SherpaAudioPreparation.edgeSilenceSampleCount

func testValidationRejectsEmptyAndNonFiniteInput() {
let invalid: [[Float]] = [[], [.nan], [.infinity], [-.infinity], [0.1, .nan]]
for samples in invalid {
Expand All @@ -10,12 +12,29 @@ final class SherpaAudioPreparationTests: XCTestCase {
XCTAssertNoThrow(try SherpaAudioPreparation.validate([0, 0.000001, -1, 1]))
}

func testShortSpeechPreservesEverySampleAndAddsTrailingSilence() {
func testShortSpeechIsPaddedToTheMinimumSampleCount() {
let speech: [Float] = [0.1, -0.3, 0.2]
let prepared = SherpaAudioPreparation.prepare(speech)
XCTAssertEqual(prepared.count, 16_000)
XCTAssertEqual(Array(prepared.prefix(speech.count)), speech)
XCTAssertTrue(prepared.dropFirst(speech.count).allSatisfy { $0 == 0 })
XCTAssertEqual(Array(prepared[edge..<(edge + speech.count)]), speech)
XCTAssertTrue(prepared.dropFirst(edge + speech.count).allSatisfy { $0 == 0 })
}

/// Speech that starts in sample zero makes the NeMo decoders emit
/// end-of-transcript immediately and return nothing at all, so every
/// segment gets a silent lead-in.
func testSpeechNeverStartsInTheFirstSample() {
let cases: [[Float]] = [
[0.4],
[Float](repeating: 0.1, count: 16_001),
[Float](repeating: -0.2, count: 320_000),
]
for samples in cases {
let prepared = SherpaAudioPreparation.prepare(samples)
XCTAssertEqual(Array(prepared.prefix(edge)), [Float](repeating: 0, count: edge))
XCTAssertEqual(Array(prepared.suffix(edge)), [Float](repeating: 0, count: edge))
XCTAssertEqual(Array(prepared[edge..<(edge + samples.count)]), samples)
}
}

func testEmptyAndDigitalSilenceDoNotReachDecoder() {
Expand All @@ -24,12 +43,14 @@ final class SherpaAudioPreparationTests: XCTestCase {
}

func testQuietSpeechIsNotDiscarded() {
XCTAssertEqual(SherpaAudioPreparation.prepare([0.000001]).first, 0.000001)
XCTAssertEqual(SherpaAudioPreparation.prepare([0.000001])[edge], 0.000001)
}

func testNormalLengthAudioIsUnchanged() {
let samples = [Float](repeating: 0.1, count: 16_001)
XCTAssertEqual(SherpaAudioPreparation.prepare(samples), samples)
func testLongAudioKeepsEverySampleAndOnlyGainsEdgeSilence() {
let samples = (0..<16_001).map { Float($0 % 7) * 0.1 + 0.01 }
let prepared = SherpaAudioPreparation.prepare(samples)
XCTAssertEqual(prepared.count, samples.count + 2 * edge)
XCTAssertEqual(Array(prepared[edge..<(edge + samples.count)]), samples)
}

func testShortFinalSegmentAlsoReceivesPadding() {
Expand All @@ -40,7 +61,7 @@ final class SherpaAudioPreparationTests: XCTestCase {
for segment in segments {
let prepared = SherpaAudioPreparation.prepare(segment)
XCTAssertGreaterThanOrEqual(prepared.count, 16_000)
XCTAssertEqual(Array(prepared.prefix(segment.count)), segment)
XCTAssertEqual(Array(prepared[edge..<(edge + segment.count)]), segment)
}
}
}
3 changes: 2 additions & 1 deletion Tests/VocaMacTests/SherpaServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ final class SherpaServiceTests: XCTestCase {
counts.append(samples.count)
return counts.count == 1 ? ("안녕하세요", "ko") : ("반갑습니다", "ko")
}
XCTAssertEqual(counts, [32_000, 16_000])
let edge = SherpaAudioPreparation.edgeSilenceSampleCount
XCTAssertEqual(counts, [32_000 + 2 * edge, 16_000])
XCTAssertEqual(result.text, "안녕하세요 반갑습니다")
}

Expand Down
Loading