Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions Sources/VocaMac/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -550,12 +550,14 @@ final class AppState: ObservableObject {

// Setup hotkey callbacks
hotKeyManager.onRecordingStart = { [weak self] in
PerformanceTrace.event("HotKeyStart")
Task { @MainActor in
await self?.startRecording()
}
}

hotKeyManager.onRecordingStop = { [weak self] in
PerformanceTrace.event("HotKeyStop")
Task { @MainActor in
await self?.stopRecordingAndTranscribe()
}
Expand Down Expand Up @@ -915,6 +917,8 @@ final class AppState: ObservableObject {
// MARK: - Recording Flow

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

func stopRecordingAndTranscribe(injectResult: Bool = true) async {
let interval = PerformanceTrace.begin("StopToResultQueued")
defer { PerformanceTrace.end(interval) }
// Accept stop if we're recording OR if the audio engine thinks
// it's recording (covers stuck-state recovery scenarios where
// isRecording and appStatus may be out of sync).
Expand Down
57 changes: 50 additions & 7 deletions Sources/VocaMac/Services/AudioEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,25 @@ enum AudioCapturePhase: Equatable {
var evaluatesStopConditions: Bool { self == .recording }
}

/// Reuses the sample-rate converter while the microphone format is stable.
/// A route change replaces it; each independent tap buffer resets its state.
final class AudioConverterCache {
private var converter: AVAudioConverter?
private(set) var creationCount = 0

func converter(from source: AVAudioFormat, to destination: AVAudioFormat) -> AVAudioConverter? {
if let converter,
converter.inputFormat == source,
converter.outputFormat == destination {
converter.reset()
return converter
}
converter = AVAudioConverter(from: source, to: destination)
if converter != nil { creationCount += 1 }
return converter
}
}

/// Tracks continuous silence independently of total recording time.
struct SilenceDetector {
private(set) var lastSoundTime: Date
Expand Down Expand Up @@ -74,6 +93,7 @@ final class AudioEngine {
private var engine: AVAudioEngine?
private var pendingEngineRelease: DispatchWorkItem?
private var audioBuffer: [Float] = []
private let converterCache = AudioConverterCache()
private var _isCurrentlyRecording = false
/// Realtime-safe capture lifecycle for the input tap.
///
Expand Down Expand Up @@ -1076,7 +1096,10 @@ final class AudioEngine {
guard let convertedBuffer = Self.convertToWhisperFormat(
buffer,
from: inputFormat,
inputChannel: preferredInputChannel
inputChannel: preferredInputChannel,
converterProvider: { [converterCache] source, destination in
converterCache.converter(from: source, to: destination)
}
) else {
return
}
Expand All @@ -1101,10 +1124,12 @@ final class AudioEngine {
if let channelData = convertedBuffer.floatChannelData {
let frameCount = Int(convertedBuffer.frameLength)
bufferQueue.sync {
audioBuffer.reserveCapacity(audioBuffer.count + frameCount)
for i in 0..<frameCount {
audioBuffer.append(isApplicationInputMuted ? 0 : channelData[0][i])
}
Self.appendCapturedSamples(
channelData[0],
count: frameCount,
muted: isApplicationInputMuted,
to: &audioBuffer
)
}
}

Expand Down Expand Up @@ -1149,7 +1174,8 @@ final class AudioEngine {
static func convertToWhisperFormat(
_ buffer: AVAudioPCMBuffer,
from inputFormat: AVAudioFormat,
inputChannel: Int = 0
inputChannel: Int = 0,
converterProvider: ((AVAudioFormat, AVAudioFormat) -> AVAudioConverter?)? = nil
) -> AVAudioPCMBuffer? {
let sourceBuffer: AVAudioPCMBuffer
if inputFormat.channelCount > 1 {
Expand All @@ -1176,7 +1202,8 @@ final class AudioEngine {
}

// Create a converter
guard let converter = AVAudioConverter(from: sourceFormat, to: whisperFormat) else {
guard let converter = converterProvider?(sourceFormat, whisperFormat)
?? AVAudioConverter(from: sourceFormat, to: whisperFormat) else {
VocaLogger.error(.audioEngine, "Failed to create audio format converter")
return nil
}
Expand Down Expand Up @@ -1218,6 +1245,22 @@ final class AudioEngine {
return outputBuffer
}

/// Bulk append keeps Array's geometric growth instead of reallocating to
/// the exact size for every realtime callback.
static func appendCapturedSamples(
_ samples: UnsafePointer<Float>,
count: Int,
muted: Bool,
to destination: inout [Float]
) {
guard count > 0 else { return }
if muted {
destination.append(contentsOf: repeatElement(Float.zero, count: count))
} else {
destination.append(contentsOf: UnsafeBufferPointer(start: samples, count: count))
}
}

/// Keeps a channel selection only while it still describes the live device layout.
private func syncPreferredInputChannel(
with deviceID: AudioDeviceID,
Expand Down
Loading
Loading