Skip to content

Commit c71f20d

Browse files
feat: save the audio behind an ONNX decode that returns nothing (#258)
* feat: save the audio behind an ONNX decode that returns nothing An empty decode is the one failure with nothing to debug: no error, no text, and the audio is gone the moment the buffer is released. The bug in #256 took two wrong fixes before a dumped recording showed what was actually happening — the decode turned out to be so sensitive to its input that scaling the same samples by 1.001 changed the result, which no synthetic clip reproduced and no log line could have revealed. Write the samples to a WAV next to the logs so the failure can be replayed through --transcribe-file. Off unless asked for, since it puts recorded speech on disk: defaults write com.vocamac.app vocamac.debug.saveFailedAudio -bool true Keeps the newest 20 recordings so it cannot fill the disk, and only fires for audio that actually reached the decoder — digital silence is skipped before inference and is not a failure. * Avoid colliding failed-audio dump filenames Include a short UUID token so two empty ONNX dumps in the same second keep both WAV files. Sanitize model path characters too. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
1 parent 002a31c commit c71f20d

3 files changed

Lines changed: 182 additions & 0 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// FailedAudioDump.swift
2+
// VocaMac
3+
//
4+
// Saves the audio behind a transcription that came back empty, so a failure
5+
// that only happens with a real microphone can be replayed through
6+
// `--transcribe-file` and debugged offline.
7+
8+
import Foundation
9+
10+
/// Writes the samples behind an empty transcription to a WAV file.
11+
///
12+
/// Off unless the user turns it on, because it puts recorded speech on disk:
13+
///
14+
/// defaults write com.vocamac.app vocamac.debug.saveFailedAudio -bool true
15+
enum FailedAudioDump {
16+
17+
static let preferenceKey = "vocamac.debug.saveFailedAudio"
18+
19+
/// Most recordings kept before the oldest are removed.
20+
static let maximumFiles = 20
21+
22+
static var directory: URL {
23+
VocaLogger.logDirectory().appendingPathComponent("failed-audio", isDirectory: true)
24+
}
25+
26+
static var isEnabled: Bool {
27+
UserDefaults.standard.bool(forKey: preferenceKey)
28+
}
29+
30+
/// Build a dump filename that cannot collide within the same second.
31+
static func makeFilename(
32+
model: String,
33+
date: Date = Date(),
34+
uniqueID: String = UUID().uuidString
35+
) -> String {
36+
let stamp = ISO8601DateFormatter.dumpFormatter.string(from: date)
37+
let token = String(uniqueID.replacingOccurrences(of: "-", with: "").prefix(8))
38+
let safeModel = model
39+
.replacingOccurrences(of: "/", with: "-")
40+
.replacingOccurrences(of: ":", with: "-")
41+
return "\(stamp)-\(token)-\(safeModel).wav"
42+
}
43+
44+
/// Save `samples` if the user asked for dumps. Returns the file written.
45+
@discardableResult
46+
static func save(_ samples: [Float], model: String, sampleRate: Int = 16_000) -> URL? {
47+
guard isEnabled, !samples.isEmpty else { return nil }
48+
49+
let url = directory.appendingPathComponent(makeFilename(model: model))
50+
51+
do {
52+
try FileManager.default.createDirectory(
53+
at: directory, withIntermediateDirectories: true
54+
)
55+
try wavData(from: samples, sampleRate: sampleRate).write(to: url)
56+
pruneOldest()
57+
VocaLogger.info(.sherpaService, "Saved the failed recording to \(url.path)")
58+
return url
59+
} catch {
60+
VocaLogger.error(.sherpaService, "Could not save the failed recording: \(error)")
61+
return nil
62+
}
63+
}
64+
65+
/// 16-bit mono PCM in a canonical 44-byte WAV container.
66+
static func wavData(from samples: [Float], sampleRate: Int) -> Data {
67+
let bytesPerSample = 2
68+
let dataBytes = samples.count * bytesPerSample
69+
var data = Data(capacity: 44 + dataBytes)
70+
71+
func append<T: FixedWidthInteger>(_ value: T) {
72+
withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) }
73+
}
74+
75+
data.append(contentsOf: Array("RIFF".utf8))
76+
append(UInt32(36 + dataBytes))
77+
data.append(contentsOf: Array("WAVE".utf8))
78+
79+
data.append(contentsOf: Array("fmt ".utf8))
80+
append(UInt32(16)) // PCM header size
81+
append(UInt16(1)) // PCM
82+
append(UInt16(1)) // mono
83+
append(UInt32(sampleRate))
84+
append(UInt32(sampleRate * bytesPerSample)) // byte rate
85+
append(UInt16(bytesPerSample)) // block align
86+
append(UInt16(16)) // bits per sample
87+
88+
data.append(contentsOf: Array("data".utf8))
89+
append(UInt32(dataBytes))
90+
for sample in samples {
91+
append(Int16(max(-1, min(1, sample)) * 32_767))
92+
}
93+
return data
94+
}
95+
96+
/// Keep the newest `maximumFiles` dumps so this cannot fill the disk.
97+
private static func pruneOldest() {
98+
let fileManager = FileManager.default
99+
guard let files = try? fileManager.contentsOfDirectory(
100+
at: directory, includingPropertiesForKeys: [.creationDateKey]
101+
) else { return }
102+
103+
let sorted = files
104+
.filter { $0.pathExtension == "wav" }
105+
.sorted { left, right in
106+
let leftDate = (try? left.resourceValues(forKeys: [.creationDateKey]))?.creationDate
107+
let rightDate = (try? right.resourceValues(forKeys: [.creationDateKey]))?.creationDate
108+
return (leftDate ?? .distantPast) > (rightDate ?? .distantPast)
109+
}
110+
111+
for file in sorted.dropFirst(maximumFiles) {
112+
try? fileManager.removeItem(at: file)
113+
}
114+
}
115+
}
116+
117+
private extension ISO8601DateFormatter {
118+
/// Colons are legal in HFS+ paths but confuse shell completion and tools.
119+
static let dumpFormatter: ISO8601DateFormatter = {
120+
let formatter = ISO8601DateFormatter()
121+
formatter.formatOptions = [.withYear, .withMonth, .withDay, .withTime]
122+
formatter.timeZone = .current
123+
return formatter
124+
}()
125+
}

Sources/VocaMac/Services/SherpaService.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ final class SherpaService: @unchecked Sendable {
330330
.sherpaService,
331331
"ONNX decode returned no text for \(String(format: "%.1f", audioLengthSeconds))s of audio"
332332
)
333+
FailedAudioDump.save(audioData, model: size.rawValue)
333334
}
334335
} else {
335336
VocaLogger.info(.sherpaService, "Result: \(text.prefix(100))...")
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import XCTest
2+
@testable import VocaMac
3+
4+
final class FailedAudioDumpTests: XCTestCase {
5+
func testWavDataIsAReadableSixteenBitMonoContainer() {
6+
let samples: [Float] = [0, 0.5, -0.5, 1, -1]
7+
let data = FailedAudioDump.wavData(from: samples, sampleRate: 16_000)
8+
9+
XCTAssertEqual(data.count, 44 + samples.count * 2)
10+
XCTAssertEqual(String(data: data[0..<4], encoding: .ascii), "RIFF")
11+
XCTAssertEqual(String(data: data[8..<12], encoding: .ascii), "WAVE")
12+
XCTAssertEqual(String(data: data[36..<40], encoding: .ascii), "data")
13+
14+
func int16(at offset: Int) -> Int16 {
15+
data.withUnsafeBytes { $0.loadUnaligned(fromByteOffset: offset, as: Int16.self) }
16+
}
17+
XCTAssertEqual(int16(at: 22), 1, "mono")
18+
XCTAssertEqual(int16(at: 34), 16, "bits per sample")
19+
XCTAssertEqual(int16(at: 44), 0)
20+
XCTAssertEqual(int16(at: 46), 16_383)
21+
XCTAssertEqual(int16(at: 50), 32_767)
22+
XCTAssertEqual(int16(at: 52), -32_767)
23+
}
24+
25+
func testDumpsAreOffUnlessTurnedOn() {
26+
UserDefaults.standard.removeObject(forKey: FailedAudioDump.preferenceKey)
27+
XCTAssertFalse(FailedAudioDump.isEnabled)
28+
XCTAssertNil(FailedAudioDump.save([0.1, 0.2], model: "canary-180m-flash"))
29+
}
30+
}
31+
32+
func testDumpFilenamesDoNotCollideWithinTheSameSecond() {
33+
let date = Date(timeIntervalSince1970: 1_700_000_000)
34+
let left = FailedAudioDump.makeFilename(
35+
model: "canary-180m-flash", date: date, uniqueID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
36+
)
37+
let right = FailedAudioDump.makeFilename(
38+
model: "canary-180m-flash", date: date, uniqueID: "ffffffff-1111-2222-3333-444444444444"
39+
)
40+
XCTAssertNotEqual(left, right)
41+
XCTAssertTrue(left.hasSuffix("-canary-180m-flash.wav"))
42+
XCTAssertTrue(left.contains("aaaaaaaa"))
43+
XCTAssertTrue(right.contains("ffffffff"))
44+
}
45+
46+
func testDumpFilenameSanitizesModelPathCharacters() {
47+
let name = FailedAudioDump.makeFilename(
48+
model: "path/with:chars",
49+
date: Date(timeIntervalSince1970: 0),
50+
uniqueID: "12345678-0000-0000-0000-000000000000"
51+
)
52+
XCTAssertFalse(name.contains("/"))
53+
XCTAssertFalse(name.contains(":"))
54+
XCTAssertTrue(name.hasSuffix("-path-with-chars.wav"))
55+
}
56+

0 commit comments

Comments
 (0)