import AVFoundation import Foundation struct RecordedChunk: Sendable { let url: URL let continuesPhrase: Bool } enum AudioChunking: Sendable { case stablePreviews case committed } 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 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 chunking = AudioChunking.committed func start( chunking: AudioChunking, 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.chunking = chunking recordingFormat = format try beginChunk(format: format) 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) } outputFile = nil currentURL = nil throw error } } func stop() throws -> URL? { 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 finalURL = currentFrames > 0 && currentChunkHasSpeech ? currentURL : nil if finalURL == nil, let currentURL { try? FileManager.default.removeItem(at: currentURL) } outputFile = nil currentURL = nil currentFrames = 0 currentChunkHasSpeech = false heardSpeech = false silentFrames = 0 chunkSilentFrames = 0 silenceTriggered = false return finalURL } 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 consume(_ buffer: AVAudioPCMBuffer, format: AVAudioFormat) { lock.lock() defer { lock.unlock() } guard let activeOutputFile = outputFile else { return } try? activeOutputFile.write(from: buffer) currentFrames += 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 } let reachedPause: Bool let reachedMaximum: Bool switch chunking { case .stablePreviews: reachedPause = false reachedMaximum = currentFrames >= AVAudioFramePosition(format.sampleRate * 1.25) case .committed: // Cloud dictation keeps the old larger chunks so it does not issue // overlapping paid requests. let minimumChunk = AVAudioFramePosition(format.sampleRate * 2.5) let pauseBoundary = AVAudioFramePosition(format.sampleRate * 1.0) let maximumChunk = AVAudioFramePosition(format.sampleRate * 4.0) reachedPause = currentFrames >= minimumChunk && chunkSilentFrames >= pauseBoundary 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 { try toWhisperWAV([input]) } static func toWhisperWAV(_ inputs: [URL]) throws -> URL { guard !inputs.isEmpty else { throw WhisperKeyError.processFailed("Нет аудиофрагментов для распознавания.") } 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) var arguments = ["-hide_banner", "-loglevel", "error", "-y"] for input in inputs { arguments.append(contentsOf: ["-i", input.path]) } if inputs.count > 1 { let streams = inputs.indices.map { "[\($0):a:0]" }.joined() arguments.append(contentsOf: [ "-filter_complex", "\(streams)concat=n=\(inputs.count):v=0:a=1[out]", "-map", "[out]", ]) } arguments.append(contentsOf: [ "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", output.path, ]) process.arguments = arguments 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 } }