Skip to content

Commit 9ef8fb8

Browse files
committed
perf: reduce runtime capture and diagnostics overhead
1 parent 0629ef1 commit 9ef8fb8

11 files changed

Lines changed: 427 additions & 81 deletions

Sources/VocaMac/Models/AppState.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,12 +550,14 @@ final class AppState: ObservableObject {
550550

551551
// Setup hotkey callbacks
552552
hotKeyManager.onRecordingStart = { [weak self] in
553+
PerformanceTrace.event("HotKeyStart")
553554
Task { @MainActor in
554555
await self?.startRecording()
555556
}
556557
}
557558

558559
hotKeyManager.onRecordingStop = { [weak self] in
560+
PerformanceTrace.event("HotKeyStop")
559561
Task { @MainActor in
560562
await self?.stopRecordingAndTranscribe()
561563
}
@@ -915,6 +917,8 @@ final class AppState: ObservableObject {
915917
// MARK: - Recording Flow
916918

917919
func startRecording() async {
920+
let interval = PerformanceTrace.begin("RecordingStart")
921+
defer { PerformanceTrace.end(interval) }
918922
// If we're already recording, this is a recovery attempt — the user
919923
// pressed the hotkey again because a previous key-up was missed.
920924
// Stop the current recording and transcribe what we have.
@@ -1025,6 +1029,8 @@ final class AppState: ObservableObject {
10251029
}
10261030

10271031
func stopRecordingAndTranscribe(injectResult: Bool = true) async {
1032+
let interval = PerformanceTrace.begin("StopToResultQueued")
1033+
defer { PerformanceTrace.end(interval) }
10281034
// Accept stop if we're recording OR if the audio engine thinks
10291035
// it's recording (covers stuck-state recovery scenarios where
10301036
// isRecording and appStatus may be out of sync).

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,

0 commit comments

Comments
 (0)