Ускорить локальную диктовку стабильным префиксом
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user