Диктовка: нативный подчёркнутый черновик

This commit is contained in:
2026-07-29 15:09:17 +06:00
parent 5751743277
commit 81dc5db6e4
11 changed files with 607 additions and 21 deletions
+67 -8
View File
@@ -6,12 +6,37 @@ struct RecordedChunk: Sendable {
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
@@ -21,8 +46,10 @@ final class AudioRecorder: @unchecked Sendable {
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 {
@@ -33,8 +60,17 @@ final class AudioRecorder: @unchecked Sendable {
let format = input.outputFormat(forBus: 0)
self.onChunk = onChunk
self.onSilence = onSilence
self.chunkTiming = chunkTiming
recordingFormat = format
try beginChunk(format: 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)
}
@@ -47,13 +83,16 @@ final class AudioRecorder: @unchecked Sendable {
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 -> URL? {
func stop() throws -> RecordingResult {
noSpeechTimeout?.cancel()
noSpeechTimeout = nil
if let engine {
@@ -66,19 +105,29 @@ final class AudioRecorder: @unchecked Sendable {
}
lock.lock()
defer { lock.unlock() }
let finalURL = currentFrames > 0 && currentChunkHasSpeech ? currentURL : nil
if finalURL == nil, let currentURL {
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 finalURL
return RecordingResult(
finalChunkURL: finalChunkURL,
fullSessionURL: fullSessionURL
)
}
private func scheduleNoSpeechTimeout() {
@@ -103,12 +152,22 @@ final class AudioRecorder: @unchecked Sendable {
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 {
@@ -133,9 +192,9 @@ final class AudioRecorder: @unchecked Sendable {
// 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 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