Skip to content

Commit 8874768

Browse files
committed
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.
1 parent 50a76d6 commit 8874768

3 files changed

Lines changed: 143 additions & 0 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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+
/// Save `samples` if the user asked for dumps. Returns the file written.
31+
@discardableResult
32+
static func save(_ samples: [Float], model: String, sampleRate: Int = 16_000) -> URL? {
33+
guard isEnabled, !samples.isEmpty else { return nil }
34+
35+
let stamp = ISO8601DateFormatter.dumpFormatter.string(from: Date())
36+
let url = directory.appendingPathComponent("\(stamp)-\(model).wav")
37+
38+
do {
39+
try FileManager.default.createDirectory(
40+
at: directory, withIntermediateDirectories: true
41+
)
42+
try wavData(from: samples, sampleRate: sampleRate).write(to: url)
43+
pruneOldest()
44+
VocaLogger.info(.sherpaService, "Saved the failed recording to \(url.path)")
45+
return url
46+
} catch {
47+
VocaLogger.error(.sherpaService, "Could not save the failed recording: \(error)")
48+
return nil
49+
}
50+
}
51+
52+
/// 16-bit mono PCM in a canonical 44-byte WAV container.
53+
static func wavData(from samples: [Float], sampleRate: Int) -> Data {
54+
let bytesPerSample = 2
55+
let dataBytes = samples.count * bytesPerSample
56+
var data = Data(capacity: 44 + dataBytes)
57+
58+
func append<T: FixedWidthInteger>(_ value: T) {
59+
withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) }
60+
}
61+
62+
data.append(contentsOf: Array("RIFF".utf8))
63+
append(UInt32(36 + dataBytes))
64+
data.append(contentsOf: Array("WAVE".utf8))
65+
66+
data.append(contentsOf: Array("fmt ".utf8))
67+
append(UInt32(16)) // PCM header size
68+
append(UInt16(1)) // PCM
69+
append(UInt16(1)) // mono
70+
append(UInt32(sampleRate))
71+
append(UInt32(sampleRate * bytesPerSample)) // byte rate
72+
append(UInt16(bytesPerSample)) // block align
73+
append(UInt16(16)) // bits per sample
74+
75+
data.append(contentsOf: Array("data".utf8))
76+
append(UInt32(dataBytes))
77+
for sample in samples {
78+
append(Int16(max(-1, min(1, sample)) * 32_767))
79+
}
80+
return data
81+
}
82+
83+
/// Keep the newest `maximumFiles` dumps so this cannot fill the disk.
84+
private static func pruneOldest() {
85+
let fileManager = FileManager.default
86+
guard let files = try? fileManager.contentsOfDirectory(
87+
at: directory, includingPropertiesForKeys: [.creationDateKey]
88+
) else { return }
89+
90+
let sorted = files
91+
.filter { $0.pathExtension == "wav" }
92+
.sorted { left, right in
93+
let leftDate = (try? left.resourceValues(forKeys: [.creationDateKey]))?.creationDate
94+
let rightDate = (try? right.resourceValues(forKeys: [.creationDateKey]))?.creationDate
95+
return (leftDate ?? .distantPast) > (rightDate ?? .distantPast)
96+
}
97+
98+
for file in sorted.dropFirst(maximumFiles) {
99+
try? fileManager.removeItem(at: file)
100+
}
101+
}
102+
}
103+
104+
private extension ISO8601DateFormatter {
105+
/// Colons are legal in HFS+ paths but confuse shell completion and tools.
106+
static let dumpFormatter: ISO8601DateFormatter = {
107+
let formatter = ISO8601DateFormatter()
108+
formatter.formatOptions = [.withYear, .withMonth, .withDay, .withTime]
109+
formatter.timeZone = .current
110+
return formatter
111+
}()
112+
}

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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
}

0 commit comments

Comments
 (0)