Ускорить локальную диктовку стабильным префиксом

This commit is contained in:
2026-07-29 16:35:25 +06:00
parent 67895c6157
commit 4712c3f56e
6 changed files with 402 additions and 22 deletions
+51 -10
View File
@@ -6,6 +6,11 @@ struct RecordedChunk: Sendable {
let continuesPhrase: Bool
}
enum AudioChunking: Sendable {
case stablePreviews
case committed
}
final class AudioRecorder: @unchecked Sendable {
private var engine: AVAudioEngine?
private let lock = NSLock()
@@ -21,8 +26,10 @@ final class AudioRecorder: @unchecked Sendable {
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 {
@@ -33,6 +40,7 @@ final class AudioRecorder: @unchecked Sendable {
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
@@ -130,15 +138,22 @@ final class AudioRecorder: @unchecked Sendable {
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 * 2.5)
let pauseBoundary = AVAudioFramePosition(format.sampleRate * 1.0)
let maximumChunk = AVAudioFramePosition(format.sampleRate * 4.0)
let reachedPause =
currentFrames >= minimumChunk && chunkSilentFrames >= pauseBoundary
let reachedMaximum = currentFrames >= maximumChunk
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 {
@@ -174,6 +189,13 @@ final class AudioRecorder: @unchecked Sendable {
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 {
@@ -181,7 +203,26 @@ enum AudioConverter {
}
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]
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()