diff --git a/Sources/PladderCore/DictationCoordinator.swift b/Sources/PladderCore/DictationCoordinator.swift index 94f9176..195cb29 100644 --- a/Sources/PladderCore/DictationCoordinator.swift +++ b/Sources/PladderCore/DictationCoordinator.swift @@ -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 @@ -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 diff --git a/Sources/PladderCore/DictationFinish.swift b/Sources/PladderCore/DictationFinish.swift new file mode 100644 index 0000000..15357d9 --- /dev/null +++ b/Sources/PladderCore/DictationFinish.swift @@ -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 + 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)) + } + } +} diff --git a/Tests/PladderCoreTests/DictationFinishTests.swift b/Tests/PladderCoreTests/DictationFinishTests.swift new file mode 100644 index 0000000..b586499 --- /dev/null +++ b/Tests/PladderCoreTests/DictationFinishTests.swift @@ -0,0 +1,200 @@ +import Foundation +import Testing +@testable import PladderCore + +private struct Emptying: TextProcessor { + let id = "emptying" + func process(_ text: String) -> String { "" } +} + +private final class FixedRefiner: TranscriptRefiner, @unchecked Sendable { + private let lock = NSLock() + private var _calls: [String] = [] + private let result: String + var calls: [String] { lock.withLock { _calls } } + + init(result: String) { self.result = result } + + func prepare() async {} + + func refine(_ text: String) async -> String? { + lock.withLock { _calls.append(text) } + return result + } +} + +@MainActor +@Suite(.timeLimit(.minutes(1))) struct DictationFinishTests { + private static let second = CapturedAudio(samples: Array(repeating: 0.1, count: 16_000)) + + private func finish( + pipeline: ProcessorPipeline = ProcessorPipeline([WhitespaceNormalizer()]), + appendTrailingSpace: Bool = true, + refiner: FixedRefiner? = nil, + output: FakeOutput = FakeOutput() + ) -> DictationFinish { + DictationFinish( + pipeline: pipeline, disabledProcessors: [], appendTrailingSpace: appendTrailingSpace, + willPolish: refiner != nil, refiner: refiner, minimumDuration: 0.3, minimumPolishWords: 4, + output: output) + } + + private func loaded(_ text: String, delay: Duration = .zero) async throws -> EchoEngine { + let engine = EchoEngine(text: text, delay: delay) + try await engine.load() + return engine + } + + private func input( + _ source: DictationFinish.Source, audio: CapturedAudio = second, fed: Int = 0, + captureStop: Duration = .zero, feedWait: Duration = .zero + ) -> DictationFinish.Input { + DictationFinish.Input( + audio: audio, fedSamples: fed, source: source, submit: false, + captureStop: captureStop, feedWait: feedWait) + } + + private func run(_ finish: DictationFinish, _ input: DictationFinish.Input) async -> ( + DictationFinish.Outcome, [DictationFinish.Stage] + ) { + var stages: [DictationFinish.Stage] = [] + let outcome = await finish.run(input) { stages.append($0) } + return (outcome, stages) + } + + @Test func aRecordingUnderTheMinimumAbandonsTheUtteranceAndInsertsNothing() async throws { + let engine = FakeStreamingEngine() + try await engine.load() + let utterance = try await engine.beginUtterance() + let output = FakeOutput() + let short = CapturedAudio(samples: Array(repeating: 0.1, count: 3_000)) + let (outcome, stages) = await run( + finish(output: output), input(.utterance(engine, utterance), audio: short, fed: 1_000)) + #expect(outcome == .tooShort) + #expect(stages.isEmpty) + #expect(engine.log.last == "abandon") + #expect(engine.endCount == 0) + #expect(output.inserted.isEmpty) + } + + @Test func fedSamplesCountTowardsTheMinimum() async throws { + let engine = FakeStreamingEngine() + try await engine.load() + let utterance = try await engine.beginUtterance() + let tail = CapturedAudio(samples: Array(repeating: 0.1, count: 1_000)) + let (outcome, _) = await run(finish(), input(.utterance(engine, utterance), audio: tail, fed: 16_000)) + guard case .inserted(let insertion) = outcome else { + Issue.record("expected an insertion, got \(outcome)") + return + } + #expect(insertion.transcript.text == "final") + #expect(insertion.transcript.audioDuration == 17_000 / CapturedAudio.sampleRate) + } + + @Test func processedTextEmptyInsertsNothingAndReportsEmpty() async throws { + let output = FakeOutput() + let (outcome, stages) = await run( + finish(pipeline: ProcessorPipeline([Emptying()]), output: output), + input(.whole(try await loaded("um")))) + #expect(outcome == .empty) + #expect(stages.isEmpty) + #expect(output.inserted.isEmpty) + } + + @Test func polishRunsFromTheMinimumWordCountAndReportsItsStage() async throws { + let refiner = FixedRefiner(result: "Polished.") + let output = FakeOutput() + let (outcome, stages) = await run( + finish(refiner: refiner, output: output), input(.whole(try await loaded("one two three four")))) + #expect(refiner.calls == ["one two three four"]) + #expect(stages.count == 2) + #expect(stages.first == .polishing) + #expect(output.inserted == ["Polished. "]) + guard case .inserted(let insertion) = outcome else { + Issue.record("expected an insertion, got \(outcome)") + return + } + #expect(insertion.transcript.text == "Polished.") + #expect(insertion.timing.polish != nil) + } + + @Test func underTheMinimumPolishIsZeroNotNil() async throws { + let refiner = FixedRefiner(result: "Polished.") + let (outcome, stages) = await run(finish(refiner: refiner), input(.whole(try await loaded("one two three")))) + #expect(refiner.calls.isEmpty) + #expect(!stages.contains(.polishing)) + guard case .inserted(let insertion) = outcome else { + Issue.record("expected an insertion, got \(outcome)") + return + } + #expect(insertion.timing.polish == .zero) + } + + @Test func withoutPolishTheTimingHasNoPolishStage() async throws { + let (outcome, _) = await run(finish(), input(.whole(try await loaded("one two three four")))) + guard case .inserted(let insertion) = outcome else { + Issue.record("expected an insertion, got \(outcome)") + return + } + #expect(insertion.timing.polish == nil) + } + + @Test func theInsertingStageCarriesTheProcessedTranscriptBeforeTheInsert() async throws { + let output = FakeOutput() + output.shouldFail = true + let pipeline = ProcessorPipeline([ + DictionaryReplacer(entries: [DictionaryEntry(from: "hello world", to: "Hello, World!")]), + ]) + let (outcome, stages) = await run( + finish(pipeline: pipeline, output: output), input(.whole(try await loaded("hello world")))) + guard case .failed = outcome else { + Issue.record("expected a failure, got \(outcome)") + return + } + guard case .inserting(let transcript) = stages.last else { + Issue.record("expected the inserting stage, got \(stages)") + return + } + #expect(transcript.text == "Hello, World!") + } + + @Test func timingSumsTheStagesAndFeedWaitCountsAsEngineTime() async throws { + let engine = try await loaded("hello world", delay: .milliseconds(20)) + let (outcome, _) = await run( + finish(), input(.whole(engine), captureStop: .milliseconds(7), feedWait: .milliseconds(40))) + guard case .inserted(let insertion) = outcome else { + Issue.record("expected an insertion, got \(outcome)") + return + } + #expect(insertion.timing.captureStop == .milliseconds(7)) + #expect(insertion.timing.engine >= .milliseconds(60)) + #expect(insertion.timing.processing > .zero) + #expect(insertion.timing.insert > .zero) + } + + @Test func aTrailingSpaceIsAppendedOnlyWhenMissing() async throws { + let output = FakeOutput() + let noProcessors = ProcessorPipeline([]) + _ = await run(finish(pipeline: noProcessors, output: output), input(.whole(try await loaded("spaced ")))) + _ = await run(finish(pipeline: noProcessors, output: output), input(.whole(try await loaded("bare")))) + _ = await run( + finish(pipeline: noProcessors, appendTrailingSpace: false, output: output), + input(.whole(try await loaded("off")))) + #expect(output.inserted == ["spaced ", "bare ", "off"]) + } + + @Test func aThrowingInsertBecomesAFailure() async throws { + let output = FakeOutput() + output.shouldFail = true + output.failure = OutputError.eventCreationFailed + let (outcome, _) = await run(finish(output: output), input(.whole(try await loaded("hello world")))) + #expect(outcome == .failed(.pasteKeystroke)) + } + + @Test func aThrowingEngineBecomesAFailureBeforeAnyStage() async throws { + let engine = EchoEngine(text: "never loaded") + let (outcome, stages) = await run(finish(), input(.whole(engine))) + #expect(outcome == .failed(.engineNotLoaded)) + #expect(stages.isEmpty) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6fda1da..8632445 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,14 +67,21 @@ the rest runs in `inFlight`: release is still handed over, so every sample reaches the engine once and in order. This wait counts as engine time; 2. stop the capture, which returns only the samples after the last drain; -3. `finish`: drop a recording under 0.3 s, then `endUtterance` with the tail - (or `transcribe` the whole buffer), run the processor pipeline, polish if - the cycle asked for it and the text has four words or more, append the - trailing space, and `insert`. Words are counted by `String.wordCount` in - Core, the count the polishers cut their chunks by. `lastTranscript`, the - menu's "Last" line and its copy, is set to the processed text just before - `insert`, so a paste that fails leaves that text there, not the engine's; - a dictation the processors empty pastes nothing and leaves it as it was. +3. `DictationFinish.run`: drop a recording under 0.3 s, then `endUtterance` + with the tail (or `transcribe` the whole buffer), run the processor + pipeline, polish if the cycle asked for it and the text has four words or + more, append the trailing space, and `insert`. Words are counted by + `String.wordCount` in Core, the count the polishers cut their chunks by. + The finish is built at release, so the pipeline, the processors switched + off and the trailing space are the ones set then. It owns no state: it + reports the polishing and inserting stages as it reaches them, and the + coordinator flips its state on each, and it returns an outcome (too short, + empty, inserted with its timing, or failed) that the coordinator turns + into the event and the state after. `lastTranscript`, the menu's "Last" + line and its copy, is set to the processed text at the inserting stage, + before `insert`, so a paste that fails leaves that text there, not the + engine's; a dictation the processors empty pastes nothing and leaves it as + it was. `inserted` carries the timing of every stage; the app turns it into the release-to-paste log line (`TimingLine.swift`).