Skip to content

Commit fa67413

Browse files
committed
Merge main into Gateway settings pane
Resolve MenuBarView conflict. Keep Pair phone durable open and main menu bar changes.
2 parents ae30393 + 726cb3d commit fa67413

43 files changed

Lines changed: 1635 additions & 196 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ Version-bump changelog tables go in the **PR description**, not a tracked file.
190190
|------------|---------|-----|
191191
| [WhisperKit](https://github.com/argmaxinc/WhisperKit) | Whisper CoreML | `from: "0.9.4"` |
192192
| [FluidAudio](https://github.com/FluidInference/FluidAudio) | Parakeet CoreML / ANE | `.upToNextMinor(from: "0.15.5")` (pre-1.0) |
193-
| [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) | Specialized ONNX, CPU | revision pin (SPM not in a tagged release; xcframework v1.13.4) |
193+
| [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) | Specialized ONNX, CPU | exact `1.13.7` (matching xcframework) |
194194

195195
Keep dependencies minimal. Do not bump FluidAudio across a minor without checking `AsrManager.loadModels` / TDT decoder APIs.
196196

Package.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,10 @@ let package = Package(
2727
.package(url: "https://github.com/FluidInference/FluidAudio.git", .upToNextMinor(from: "0.15.5")),
2828
// sherpa-onnx — specialized ONNX models (Moonshine, SenseVoice,
2929
// GigaAM, Canary) via ONNX Runtime, CPU-only.
30-
// Pinned to a revision: the SPM manifest is not in a tagged release
31-
// yet; the pinned manifest references the v1.13.4 binary xcframework.
30+
// Pin the release and its matching binary xcframework for reproducible builds.
3231
.package(
3332
url: "https://github.com/k2-fsa/sherpa-onnx",
34-
revision: "00ad9a19a63751a6c4b12050a00eacfeb204814e"
33+
exact: "1.13.7"
3534
),
3635
],
3736
targets: [
@@ -62,7 +61,8 @@ let package = Package(
6261
.testTarget(
6362
name: "VocaMacTests",
6463
dependencies: ["VocaMac"],
65-
path: "Tests/VocaMacTests"
64+
path: "Tests/VocaMacTests",
65+
exclude: ["Fixtures"]
6666
)
6767
]
6868
)

Sources/VocaMac/Models/AppState.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,10 @@ final class AppState: ObservableObject {
324324
/// Set to `true` in tests to avoid side effects.
325325
let skipSystemIntegration: Bool
326326

327+
/// Pre-load memory gate. Production defaults to SystemInfo; tests stub this
328+
/// so CI free+inactive pages cannot flake medium/large mock loads.
329+
var modelFitsInMemory: (ModelSize) -> Bool = { SystemInfo.canFitModelInMemory($0) }
330+
327331
// MARK: - Initialization
328332

329333
init(
@@ -550,12 +554,14 @@ final class AppState: ObservableObject {
550554

551555
// Setup hotkey callbacks
552556
hotKeyManager.onRecordingStart = { [weak self] in
557+
PerformanceTrace.event("HotKeyStart")
553558
Task { @MainActor in
554559
await self?.startRecording()
555560
}
556561
}
557562

558563
hotKeyManager.onRecordingStop = { [weak self] in
564+
PerformanceTrace.event("HotKeyStop")
559565
Task { @MainActor in
560566
await self?.stopRecordingAndTranscribe()
561567
}
@@ -915,6 +921,8 @@ final class AppState: ObservableObject {
915921
// MARK: - Recording Flow
916922

917923
func startRecording() async {
924+
let interval = PerformanceTrace.begin("RecordingStart")
925+
defer { PerformanceTrace.end(interval) }
918926
// If we're already recording, this is a recovery attempt — the user
919927
// pressed the hotkey again because a previous key-up was missed.
920928
// Stop the current recording and transcribe what we have.
@@ -1025,6 +1033,8 @@ final class AppState: ObservableObject {
10251033
}
10261034

10271035
func stopRecordingAndTranscribe(injectResult: Bool = true) async {
1036+
let interval = PerformanceTrace.begin("StopToResultQueued")
1037+
defer { PerformanceTrace.end(interval) }
10281038
// Accept stop if we're recording OR if the audio engine thinks
10291039
// it's recording (covers stuck-state recovery scenarios where
10301040
// isRecording and appStatus may be out of sync).
@@ -1252,6 +1262,20 @@ final class AppState: ObservableObject {
12521262
// we don't know yet — we'll detect it after loading completes.
12531263
let targetSize = size
12541264

1265+
// Refuse known-too-large loads before WhisperKit/CoreML can hang the
1266+
// UI spinner under memory pressure (vocamac#250). Leave any already
1267+
// loaded model alone — we never started a load, so do not restore/clear.
1268+
if let targetSize,
1269+
!modelFitsInMemory(targetSize) {
1270+
let needed = String(format: "%.1f", targetSize.ramRequiredGB)
1271+
let failureMessage =
1272+
"Not enough free memory to load \(targetSize.displayName) "
1273+
+ "(~\(needed) GB needed). Free RAM or choose a smaller model."
1274+
showTemporaryError(failureMessage)
1275+
VocaLogger.error(.appState, failureMessage)
1276+
return
1277+
}
1278+
12551279
// Mark the model as loading in the UI
12561280
if let targetSize = targetSize, let idx = availableModels.firstIndex(where: { $0.size == targetSize }) {
12571281
availableModels[idx].isLoading = true

Sources/VocaMac/Services/AudioEngine.swift

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,25 @@ enum AudioCapturePhase: Equatable {
2626
var evaluatesStopConditions: Bool { self == .recording }
2727
}
2828

29+
/// Reuses the sample-rate converter while the microphone format is stable.
30+
/// A route change replaces it; each independent tap buffer resets its state.
31+
final class AudioConverterCache {
32+
private var converter: AVAudioConverter?
33+
private(set) var creationCount = 0
34+
35+
func converter(from source: AVAudioFormat, to destination: AVAudioFormat) -> AVAudioConverter? {
36+
if let converter,
37+
converter.inputFormat == source,
38+
converter.outputFormat == destination {
39+
converter.reset()
40+
return converter
41+
}
42+
converter = AVAudioConverter(from: source, to: destination)
43+
if converter != nil { creationCount += 1 }
44+
return converter
45+
}
46+
}
47+
2948
/// Tracks continuous silence independently of total recording time.
3049
struct SilenceDetector {
3150
private(set) var lastSoundTime: Date
@@ -74,6 +93,7 @@ final class AudioEngine {
7493
private var engine: AVAudioEngine?
7594
private var pendingEngineRelease: DispatchWorkItem?
7695
private var audioBuffer: [Float] = []
96+
private let converterCache = AudioConverterCache()
7797
private var _isCurrentlyRecording = false
7898
/// Realtime-safe capture lifecycle for the input tap.
7999
///
@@ -1076,7 +1096,10 @@ final class AudioEngine {
10761096
guard let convertedBuffer = Self.convertToWhisperFormat(
10771097
buffer,
10781098
from: inputFormat,
1079-
inputChannel: preferredInputChannel
1099+
inputChannel: preferredInputChannel,
1100+
converterProvider: { [converterCache] source, destination in
1101+
converterCache.converter(from: source, to: destination)
1102+
}
10801103
) else {
10811104
return
10821105
}
@@ -1101,10 +1124,12 @@ final class AudioEngine {
11011124
if let channelData = convertedBuffer.floatChannelData {
11021125
let frameCount = Int(convertedBuffer.frameLength)
11031126
bufferQueue.sync {
1104-
audioBuffer.reserveCapacity(audioBuffer.count + frameCount)
1105-
for i in 0..<frameCount {
1106-
audioBuffer.append(isApplicationInputMuted ? 0 : channelData[0][i])
1107-
}
1127+
Self.appendCapturedSamples(
1128+
channelData[0],
1129+
count: frameCount,
1130+
muted: isApplicationInputMuted,
1131+
to: &audioBuffer
1132+
)
11081133
}
11091134
}
11101135

@@ -1149,7 +1174,8 @@ final class AudioEngine {
11491174
static func convertToWhisperFormat(
11501175
_ buffer: AVAudioPCMBuffer,
11511176
from inputFormat: AVAudioFormat,
1152-
inputChannel: Int = 0
1177+
inputChannel: Int = 0,
1178+
converterProvider: ((AVAudioFormat, AVAudioFormat) -> AVAudioConverter?)? = nil
11531179
) -> AVAudioPCMBuffer? {
11541180
let sourceBuffer: AVAudioPCMBuffer
11551181
if inputFormat.channelCount > 1 {
@@ -1176,7 +1202,8 @@ final class AudioEngine {
11761202
}
11771203

11781204
// Create a converter
1179-
guard let converter = AVAudioConverter(from: sourceFormat, to: whisperFormat) else {
1205+
guard let converter = converterProvider?(sourceFormat, whisperFormat)
1206+
?? AVAudioConverter(from: sourceFormat, to: whisperFormat) else {
11801207
VocaLogger.error(.audioEngine, "Failed to create audio format converter")
11811208
return nil
11821209
}
@@ -1218,6 +1245,22 @@ final class AudioEngine {
12181245
return outputBuffer
12191246
}
12201247

1248+
/// Bulk append keeps Array's geometric growth instead of reallocating to
1249+
/// the exact size for every realtime callback.
1250+
static func appendCapturedSamples(
1251+
_ samples: UnsafePointer<Float>,
1252+
count: Int,
1253+
muted: Bool,
1254+
to destination: inout [Float]
1255+
) {
1256+
guard count > 0 else { return }
1257+
if muted {
1258+
destination.append(contentsOf: repeatElement(Float.zero, count: count))
1259+
} else {
1260+
destination.append(contentsOf: UnsafeBufferPointer(start: samples, count: count))
1261+
}
1262+
}
1263+
12211264
/// Keeps a channel selection only while it still describes the live device layout.
12221265
private func syncPreferredInputChannel(
12231266
with deviceID: AudioDeviceID,

Sources/VocaMac/Services/AudioSegmenter.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@ enum AudioSegmenter {
9696
guard !energies.isEmpty else { return fallback }
9797

9898
let sorted = energies.sorted()
99-
let quietThreshold = sorted[sorted.count / 4]
99+
// A percentile alone marks flat/continuous sound as quiet and can
100+
// bury short pauses when they occupy less than a quarter of the window.
101+
// Require a 10 dB energy drop relative to the louder frames as well.
102+
let quietThreshold = min(sorted[sorted.count / 4], sorted[sorted.count * 3 / 4] * 0.1)
100103

101104
var bestStart = 0, bestLength = 0
102105
var runStart = 0, runLength = 0
@@ -118,7 +121,11 @@ enum AudioSegmenter {
118121
return frameOffsets[min(middle, frameOffsets.count - 1)] + frameLength / 2
119122
}
120123

121-
// No real pause — fall back to the single quietest frame.
124+
// With no meaningful energy dip, use the full window instead of
125+
// inventing an early boundary in continuous sound.
126+
guard let minimum = sorted.first, minimum <= quietThreshold else { return fallback }
127+
128+
// No sustained pause — fall back to the single quietest frame.
122129
var quietestIndex = 0
123130
for (index, energy) in energies.enumerated() where energy < energies[quietestIndex] {
124131
quietestIndex = index
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/LoadSerializer.swift

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,22 @@ actor LoadSerializer {
1919
/// Run `operation` after every operation queued before it has settled.
2020
///
2121
/// A failure in one operation does not prevent later ones from running;
22-
/// the error is delivered to whoever queued that operation.
22+
/// the error is delivered to whoever queued that operation. Cancellation
23+
/// reaches queued/running work, but it still owns its queue position until
24+
/// it finishes. Cleanup can opt out so cancellation cannot skip teardown.
2325
func run<T: Sendable>(
26+
cancellable: Bool = true,
2427
_ operation: @escaping @Sendable () async throws -> T
2528
) async throws -> T {
2629
let previous = tail
2730

2831
let task = Task<Result<T, Error>, Never> {
2932
await previous?.value
3033
do {
31-
return .success(try await operation())
34+
if cancellable { try Task.checkCancellation() }
35+
let value = try await operation()
36+
if cancellable { try Task.checkCancellation() }
37+
return .success(value)
3238
} catch {
3339
return .failure(error)
3440
}
@@ -38,7 +44,12 @@ actor LoadSerializer {
3844
// a thrown error cannot cancel the queue.
3945
tail = Task { _ = await task.value }
4046

41-
switch await task.value {
47+
let result = await withTaskCancellationHandler {
48+
await task.value
49+
} onCancel: {
50+
if cancellable { task.cancel() }
51+
}
52+
switch result {
4253
case .success(let value):
4354
return value
4455
case .failure(let error):

0 commit comments

Comments
 (0)