237 lines
8.5 KiB
Swift
237 lines
8.5 KiB
Swift
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..<Int(buffer.frameLength) {
|
|
let value = samples[index]
|
|
sum += value * value
|
|
}
|
|
return sqrt(sum / Float(buffer.frameLength))
|
|
}
|
|
}
|
|
|
|
enum AudioConverter {
|
|
static func toWhisperWAV(_ input: URL) throws -> 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
|
|
}
|
|
}
|