Диктовка: нативный подчёркнутый черновик
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user