Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 34 additions & 66 deletions Sources/PladderCore/DictationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,10 @@ public final class DictationCoordinator {
onEvent(.recordingStopped)
let engine = cycleEngine ?? loader.engine
cycleEngine = nil
let finish = DictationFinish(
pipeline: pipeline, disabledProcessors: settings.disabledProcessors,
appendTrailingSpace: settings.appendTrailingSpace, willPolish: willPolish, refiner: refiner,
minimumDuration: minimumDuration, minimumPolishWords: minimumPolishWords, output: output)
inFlight = Task { [weak self] in
guard let self else { return }
// The feed loop finishes handing over a chunk drained before the release. What
Expand All @@ -535,80 +539,44 @@ public final class DictationCoordinator {
let feedWait = stopped - handover
let audio = await self.capture.stop()
let captureStop = ContinuousClock.now - stopped
await self.finish(
audio, stream: stream, fedSamples: fed ?? 0, with: engine, submit: submit,
captureStop: captureStop, feedWait: feedWait)
let source: DictationFinish.Source = if let stream {
.utterance(stream.engine, stream.utterance)
} else {
.whole(engine)
}
let outcome = await finish.run(
DictationFinish.Input(
audio: audio, fedSamples: fed ?? 0, source: source, submit: submit,
captureStop: captureStop, feedWait: feedWait),
onStage: { self.enter($0) })
self.finished(outcome)
}
}

private func finish(
_ audio: CapturedAudio,
stream: Stream?,
fedSamples: Int,
with engine: any TranscriptionEngine,
submit: Bool,
captureStop: Duration,
feedWait: Duration
) async {
defer {
drainPendingUnloads()
willPolish = false
}
// A streaming engine was fed `fedSamples` while recording; only the tail came
// from `stop()`.
let totalDuration = Double(fedSamples + audio.samples.count) / CapturedAudio.sampleRate
guard totalDuration >= minimumDuration else {
if let stream { await stream.engine.abandonUtterance(stream.utterance) }
becomeIdle()
return
}
do {
var timing = CycleTiming(captureStop: captureStop, engine: .zero, processing: .zero, insert: .zero)
var started = ContinuousClock.now
var transcript = if let stream {
try await stream.engine.endUtterance(stream.utterance, tail: audio.samples)
} else {
try await engine.transcribe(audio.samples)
}
timing.engine = feedWait + (ContinuousClock.now - started)
if transcript.audioDuration == 0 {
transcript.audioDuration = totalDuration
}
started = ContinuousClock.now
let processed = pipeline.run(transcript.text, disabled: settings.disabledProcessors)
timing.processing = ContinuousClock.now - started
guard !processed.isEmpty else {
becomeIdle()
return
}
var final = processed
if willPolish, let refiner, processed.wordCount >= minimumPolishWords {
state = .polishing
started = ContinuousClock.now
if let polished = await refiner.refine(processed) { final = polished }
timing.polish = ContinuousClock.now - started
} else if willPolish {
timing.polish = .zero
}
private func enter(_ stage: DictationFinish.Stage) {
switch stage {
case .polishing:
state = .polishing
case .inserting(let transcript):
state = .inserting
let needsSpace = settings.appendTrailingSpace && final.last?.isWhitespace != true
let toInsert = needsSpace ? final + " " : final
// Before the insert, so a paste that fails leaves the menu's
// copy of it processed, which is when the user needs it.
transcript.text = final
lastTranscript = transcript
started = ContinuousClock.now
let result = try await output.insert(toInsert, submit: submit)
timing.insert = ContinuousClock.now - started
onEvent(.inserted(Insertion(
transcript: transcript, timing: timing, result: result,
submitted: submit && result == .pasted)))
if result == .copied { showCopied() } else { becomeIdle() }
} catch {
fail(DictationFailure(error))
}
}

private func finished(_ outcome: DictationFinish.Outcome) {
switch outcome {
case .tooShort, .empty:
becomeIdle()
case .inserted(let insertion):
onEvent(.inserted(insertion))
if insertion.result == .copied { showCopied() } else { becomeIdle() }
case .failed(let failure):
fail(failure)
}
drainPendingUnloads()
willPolish = false
}

private func becomeIdle() {
switch engineStatus {
case .ready: state = .idle
Expand Down
93 changes: 93 additions & 0 deletions Sources/PladderCore/DictationFinish.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import Foundation

// See docs/ARCHITECTURE.md, "Dictation flow".
@MainActor
struct DictationFinish {
enum Source {
case utterance(any StreamingTranscriptionEngine, Utterance)
case whole(any TranscriptionEngine)
}

struct Input {
var audio: CapturedAudio
// A streaming engine was fed these while recording; `audio` is only the tail.
var fedSamples: Int
var source: Source
var submit: Bool
var captureStop: Duration
var feedWait: Duration
}

enum Stage: Equatable {
case polishing
// Before the insert, so a paste that fails leaves the processed text as the
// last transcript, which is when the user needs it.
case inserting(Transcript)
}

enum Outcome: Equatable {
case tooShort
case empty
case inserted(DictationCoordinator.Insertion)
case failed(DictationFailure)
}

let pipeline: ProcessorPipeline
let disabledProcessors: Set<String>
let appendTrailingSpace: Bool
let willPolish: Bool
let refiner: (any TranscriptRefiner)?
let minimumDuration: TimeInterval
let minimumPolishWords: Int
let output: any TextOutput

func run(_ input: Input, onStage: (Stage) -> Void) async -> Outcome {
let totalDuration = Double(input.fedSamples + input.audio.samples.count) / CapturedAudio.sampleRate
guard totalDuration >= minimumDuration else {
if case .utterance(let engine, let utterance) = input.source {
await engine.abandonUtterance(utterance)
}
return .tooShort
}
do {
var timing = DictationCoordinator.CycleTiming(
captureStop: input.captureStop, engine: .zero, processing: .zero, insert: .zero)
var started = ContinuousClock.now
var transcript = switch input.source {
case .utterance(let engine, let utterance):
try await engine.endUtterance(utterance, tail: input.audio.samples)
case .whole(let engine):
try await engine.transcribe(input.audio.samples)
}
timing.engine = input.feedWait + (ContinuousClock.now - started)
if transcript.audioDuration == 0 {
transcript.audioDuration = totalDuration
}
started = ContinuousClock.now
let processed = pipeline.run(transcript.text, disabled: disabledProcessors)
timing.processing = ContinuousClock.now - started
guard !processed.isEmpty else { return .empty }
var final = processed
if willPolish, let refiner, processed.wordCount >= minimumPolishWords {
onStage(.polishing)
started = ContinuousClock.now
if let polished = await refiner.refine(processed) { final = polished }
timing.polish = ContinuousClock.now - started
} else if willPolish {
timing.polish = .zero
}
let needsSpace = appendTrailingSpace && final.last?.isWhitespace != true
let toInsert = needsSpace ? final + " " : final
transcript.text = final
onStage(.inserting(transcript))
started = ContinuousClock.now
let result = try await output.insert(toInsert, submit: input.submit)
timing.insert = ContinuousClock.now - started
return .inserted(DictationCoordinator.Insertion(
transcript: transcript, timing: timing, result: result,
submitted: input.submit && result == .pasted))
} catch {
return .failed(DictationFailure(error))
}
}
}
Loading
Loading