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

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
+155 -8
View File
@@ -9,12 +9,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let continuesPhrase: Bool
}
private struct PendingSnapshot {
let urls: [URL]
let isFinal: Bool
let createdAt: ContinuousClock.Instant
}
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
private let recorder = AudioRecorder()
private let hotKeys = HotKeyManager()
private let settings = AppSettings.shared
private var activeMode: TranscriptionMode?
private var pendingChunks: [PendingChunk] = []
private var pendingSnapshots: [PendingSnapshot] = []
private var stableSegments: [URL] = []
private var stableTranscript = StableTranscript()
private var processingChunk = false
private var stopRequested = false
private var stitchNextChunk = false
@@ -91,16 +100,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
await stopAndTranscribe()
return
}
guard !stopRequested else { return }
do {
activeMode = mode
lastMode = mode
pendingChunks.removeAll()
pendingSnapshots.removeAll()
stableSegments.forEach { try? FileManager.default.removeItem(at: $0) }
stableSegments.removeAll()
stableTranscript = StableTranscript()
processingChunk = false
stopRequested = false
stitchNextChunk = false
try recorder.start(
chunking: mode == .local ? .stablePreviews : .committed,
onChunk: { [weak self] chunk in
Task { @MainActor in self?.enqueue(chunk) }
Task { @MainActor in
if mode == .local {
self?.enqueueStableSegment(chunk.url)
} else {
self?.enqueueCommitted(chunk)
}
}
},
onSilence: { [weak self] in
Task { @MainActor in self?.finishLiveSession() }
@@ -126,24 +147,74 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
setStatus("обрабатываю…")
do {
if let final = try recorder.stop() {
enqueue(RecordedChunk(url: final, continuesPhrase: false))
if lastMode == .local {
stableSegments.append(final)
} else {
enqueueCommitted(RecordedChunk(url: final, continuesPhrase: false))
}
}
if lastMode == .local {
Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(100))
self?.enqueueFinalStableSnapshot()
}
} else if pendingChunks.isEmpty, !processingChunk {
completeLiveSession()
}
if pendingChunks.isEmpty, !processingChunk { completeLiveSession() }
} catch {
setStatus("ошибка")
showError("Диктовка не удалась", error.localizedDescription)
}
}
private func enqueue(_ chunk: RecordedChunk) {
private func enqueueFinalStableSnapshot() {
pendingSnapshots.removeAll(where: { !$0.isFinal })
if stableSegments.isEmpty {
completeLiveSession()
} else {
enqueueStableSnapshot(
PendingSnapshot(
urls: stableSegments,
isFinal: true,
createdAt: ContinuousClock.now
)
)
}
}
private func enqueueCommitted(_ chunk: RecordedChunk) {
pendingChunks.append(PendingChunk(
url: chunk.url,
continuesPhrase: chunk.continuesPhrase
))
processNextChunk()
processNextCommittedChunk()
}
private func processNextChunk() {
private func enqueueStableSegment(_ url: URL) {
stableSegments.append(url)
guard !stopRequested else { return }
enqueueStableSnapshot(
PendingSnapshot(
urls: Array(stableSegments.suffix(6)),
isFinal: false,
createdAt: ContinuousClock.now
)
)
}
private func enqueueStableSnapshot(_ snapshot: PendingSnapshot) {
if snapshot.isFinal {
pendingSnapshots.removeAll(where: { !$0.isFinal })
pendingSnapshots.append(snapshot)
} else if let index = pendingSnapshots.lastIndex(where: { !$0.isFinal }) {
pendingSnapshots[index] = snapshot
} else {
pendingSnapshots.append(snapshot)
}
processNextStableSnapshot()
}
private func processNextCommittedChunk() {
guard !processingChunk, !pendingChunks.isEmpty else { return }
processingChunk = true
let chunk = pendingChunks.removeFirst()
@@ -185,11 +256,83 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
if pendingChunks.isEmpty {
if stopRequested { completeLiveSession() }
} else {
processNextChunk()
processNextCommittedChunk()
}
}
}
private func processNextStableSnapshot() {
guard !processingChunk, !pendingSnapshots.isEmpty else { return }
processingChunk = true
let snapshot = pendingSnapshots.removeFirst()
let mode = activeMode ?? (stopRequested ? lastMode : nil)
guard let mode else {
processingChunk = false
return
}
Task {
do {
let processingStartedAt = ContinuousClock.now
let wav = try AudioConverter.toWhisperWAV(snapshot.urls)
if snapshot.isFinal { saveDiagnosticRecording(wav) }
defer { try? FileManager.default.removeItem(at: wav) }
let raw: String
switch mode {
case .local:
raw = try await LocalWhisper.transcribe(wav: wav, settings: settings)
case .cloud:
raw = try await OpenAITranscriber.transcribe(wav: wav, settings: settings)
}
let cleaned = TextCleaner.clean(
raw,
removeFillers: settings.removeFillers,
glossary: settings.glossary
)
if let update = stableTranscript.ingest(cleaned, final: snapshot.isFinal) {
try insertStableUpdate(update)
NSLog(
"WhisperKey stable commit: final=%d chars=%ld total=%@ processing=%@",
snapshot.isFinal ? 1 : 0,
(update.text as NSString).length,
String(describing: snapshot.createdAt.duration(to: .now)),
String(describing: processingStartedAt.duration(to: .now))
)
}
} catch {
if snapshot.isFinal,
let update = stableTranscript.ingest("", final: true) {
try? insertStableUpdate(update)
}
if snapshot.isFinal {
showError("Финальный фрагмент диктовки не распознан", error.localizedDescription)
} else {
NSLog("WhisperKey preview failed: %@", error.localizedDescription)
}
}
processingChunk = false
if pendingSnapshots.isEmpty {
if stopRequested, snapshot.isFinal { completeLiveSession() }
} else {
processNextStableSnapshot()
}
}
}
private func insertStableUpdate(_ update: StableTranscriptUpdate) throws {
let text = TranscriptJoiner.prepare(
update.text,
continuesPrevious: update.continuesPrevious,
continuesNext: false,
glossary: settings.glossary
)
if !text.isEmpty {
try TextInserter.insert(text + " ")
}
}
private func saveDiagnosticRecording(_ wav: URL) {
let directory = FileManager.default.urls(
for: .applicationSupportDirectory,
@@ -208,6 +351,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
stopRequested = false
lastMode = nil
stitchNextChunk = false
pendingSnapshots.removeAll()
stableSegments.forEach { try? FileManager.default.removeItem(at: $0) }
stableSegments.removeAll()
stableTranscript = StableTranscript()
setStatus("готово")
}
@@ -249,7 +396,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
enum TranscriptionMode {
enum TranscriptionMode: Sendable, Equatable {
case local
case cloud
}