import AVFoundation import Foundation struct RecordedChunk: Sendable { let url: URL let continuesPhrase: Bool } struct RecordingResult: Sendable { let finalChunkURL: URL? let fullSessionURL: URL? } struct ChunkTiming: Sendable { let minimumDuration: TimeInterval let pauseDuration: TimeInterval let maximumDuration: TimeInterval static let committed = ChunkTiming( minimumDuration: 2.5, pauseDuration: 1.0, maximumDuration: 4.0 ) static let nativeDraft = ChunkTiming( minimumDuration: 1.5, pauseDuration: 0.65, maximumDuration: 2.25 ) } final class AudioRecorder: @unchecked Sendable { private var engine: AVAudioEngine? private let lock = NSLock() private var noSpeechTimeout: DispatchWorkItem? private var currentURL: URL? private var outputFile: AVAudioFile? private var sessionURL: URL? private var sessionFile: AVAudioFile? private var sessionFrames: AVAudioFramePosition = 0 private var currentFrames: AVAudioFramePosition = 0 private var currentChunkHasSpeech = false private var heardSpeech = false private var silentFrames: AVAudioFramePosition = 0 private var chunkSilentFrames: AVAudioFramePosition = 0 private var silenceTriggered = false private var onChunk: (@Sendable (RecordedChunk) -> Void)? private var onSilence: (@Sendable () -> Void)? private var recordingFormat: AVAudioFormat? private var chunkTiming = ChunkTiming.committed func start( chunkTiming: ChunkTiming, onChunk: @escaping @Sendable (RecordedChunk) -> Void, onSilence: @escaping @Sendable () -> Void ) throws { guard engine?.isRunning != true else { return } let engine = AVAudioEngine() self.engine = engine let input = engine.inputNode let format = input.outputFormat(forBus: 0) self.onChunk = onChunk self.onSilence = onSilence self.chunkTiming = chunkTiming recordingFormat = format do { try beginSession(format: format) try beginChunk(format: format) } catch { sessionFile = nil if let sessionURL { try? FileManager.default.removeItem(at: sessionURL) } self.sessionURL = nil throw error } input.installTap(onBus: 0, bufferSize: 4096, format: format) { [weak self] buffer, _ in self?.consume(buffer, format: format) } do { engine.prepare() try engine.start() scheduleNoSpeechTimeout() } catch { input.removeTap(onBus: 0) engine.reset() self.engine = nil if let currentURL { try? FileManager.default.removeItem(at: currentURL) } if let sessionURL { try? FileManager.default.removeItem(at: sessionURL) } outputFile = nil currentURL = nil sessionFile = nil sessionURL = nil throw error } } func stop() throws -> RecordingResult { noSpeechTimeout?.cancel() noSpeechTimeout = nil if let engine { if engine.isRunning { engine.inputNode.removeTap(onBus: 0) engine.stop() } engine.reset() self.engine = nil } lock.lock() defer { lock.unlock() } let finalChunkURL = currentFrames > 0 && currentChunkHasSpeech ? currentURL : nil let fullSessionURL = sessionFrames > 0 && heardSpeech ? sessionURL : nil if finalChunkURL == nil, let currentURL { try? FileManager.default.removeItem(at: currentURL) } if fullSessionURL == nil, let sessionURL { try? FileManager.default.removeItem(at: sessionURL) } outputFile = nil currentURL = nil sessionFile = nil sessionURL = nil sessionFrames = 0 currentFrames = 0 currentChunkHasSpeech = false heardSpeech = false silentFrames = 0 chunkSilentFrames = 0 silenceTriggered = false return RecordingResult( finalChunkURL: finalChunkURL, fullSessionURL: fullSessionURL ) } private func scheduleNoSpeechTimeout() { let work = DispatchWorkItem { [weak self] in guard let self else { return } lock.lock() let shouldStop = engine?.isRunning == true && !heardSpeech && !silenceTriggered if shouldStop { silenceTriggered = true } lock.unlock() if shouldStop { onSilence?() } } noSpeechTimeout = work DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 8, execute: work) } private func beginChunk(format: AVAudioFormat) throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent("whisperkey-\(UUID().uuidString).caf") outputFile = try AVAudioFile(forWriting: url, settings: format.settings) currentURL = url currentFrames = 0 currentChunkHasSpeech = false chunkSilentFrames = 0 } private func beginSession(format: AVAudioFormat) throws { let url = FileManager.default.temporaryDirectory .appendingPathComponent("whisperkey-session-\(UUID().uuidString).caf") sessionFile = try AVAudioFile(forWriting: url, settings: format.settings) sessionURL = url sessionFrames = 0 } private func consume(_ buffer: AVAudioPCMBuffer, format: AVAudioFormat) { lock.lock() defer { lock.unlock() } guard let activeOutputFile = outputFile else { return } try? activeOutputFile.write(from: buffer) try? sessionFile?.write(from: buffer) currentFrames += AVAudioFramePosition(buffer.frameLength) sessionFrames += AVAudioFramePosition(buffer.frameLength) let rms = bufferRMS(buffer) if rms > 0.006 { heardSpeech = true currentChunkHasSpeech = true silentFrames = 0 chunkSilentFrames = 0 } else if heardSpeech { silentFrames += AVAudioFramePosition(buffer.frameLength) if currentChunkHasSpeech { chunkSilentFrames += AVAudioFramePosition(buffer.frameLength) } } let silenceLimit = AVAudioFramePosition(format.sampleRate * 5.0) if heardSpeech, silentFrames >= silenceLimit, !silenceTriggered { silenceTriggered = true onSilence?() return } // Keep enough context for Whisper, but insert completed phrases while // the user is still dictating. Prefer a short natural pause; cap a // continuous phrase so the input never stays blank indefinitely. let minimumChunk = AVAudioFramePosition(format.sampleRate * chunkTiming.minimumDuration) let pauseBoundary = AVAudioFramePosition(format.sampleRate * chunkTiming.pauseDuration) let maximumChunk = AVAudioFramePosition(format.sampleRate * chunkTiming.maximumDuration) let reachedPause = currentFrames >= minimumChunk && chunkSilentFrames >= pauseBoundary let reachedMaximum = currentFrames >= maximumChunk let shouldRotate = currentChunkHasSpeech && (reachedPause || reachedMaximum) if shouldRotate, let finishedURL = currentURL { let finishedHasSpeech = currentChunkHasSpeech let continuesPhrase = reachedMaximum && !reachedPause outputFile = nil do { try beginChunk(format: format) if finishedHasSpeech { onChunk?(RecordedChunk(url: finishedURL, continuesPhrase: continuesPhrase)) } else { try? FileManager.default.removeItem(at: finishedURL) } } catch { try? FileManager.default.removeItem(at: finishedURL) silenceTriggered = true onSilence?() } } } private func bufferRMS(_ buffer: AVAudioPCMBuffer) -> Float { guard let channels = buffer.floatChannelData, buffer.frameLength > 0 else { return 0 } let samples = channels[0] var sum: Float = 0 for index in 0.. URL { let output = FileManager.default.temporaryDirectory.appendingPathComponent("whisperkey-\(UUID().uuidString).wav") let ffmpegCandidates = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"] guard let ffmpeg = ffmpegCandidates.first(where: FileManager.default.isExecutableFile(atPath:)) else { throw WhisperKeyError.missingDependency("ffmpeg") } let process = Process() process.executableURL = URL(fileURLWithPath: ffmpeg) process.arguments = ["-hide_banner", "-loglevel", "error", "-y", "-i", input.path, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", output.path] let stderr = Pipe() process.standardError = stderr try process.run() process.waitUntilExit() guard process.terminationStatus == 0 else { let text = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "ffmpeg error" throw WhisperKeyError.processFailed(text) } return output } }