Skip to content

Show transcription progress in the CLI - #113

Merged
sebsto merged 5 commits into
mainfrom
sebsto/cli-transcription-progress
Sep 17, 2026
Merged

sebsto merged 5 commits into
mainfrom
sebsto/cli-transcription-progress

Conversation

@sebsto

@sebsto sebsto commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Closes #112

What

wispr-cli now reports progress while it transcribes. On a TTY it draws one line, redrawn in place:

Using model: large-v3
⠴ Transcribing  ▕██████████░░░░░░░░░░░░░░▏  38%  19:23 / 37:41   elapsed 2:41   eta 4:22   11.8×

Non-TTY runs get periodic plain lines with no escape sequences, and only when asked for:

[progress] transcribing 38% 19:23/37:41 elapsed 2:41 eta 4:22 11.8x

New flags: --progress auto|always|never (also WISPR_PROGRESS) and --quiet. Default is auto — on when stderr is a terminal.

How

Engines report a position in the audio; the CLI owns all display policy. Nothing about presentation leaks into WisprCore.

WisprCore, strictly additive. A ProgressUpdate value type and a transcribe(_:language:onProgress:) overload added as a protocol requirement with a default implementation that forwards to the existing method. Every existing call site and conformance compiles and behaves as before — including MockTranscriptionEngine in the tests, which still implements only the two-argument version. AudioFileDecoder.decode gains a defaulted onProgress, so its call sites are source-compatible.

I considered having the CLI call WhisperKit and FluidAudio directly to avoid touching the shared module, but that duplicates model-path resolution, hallucination filtering and error mapping into a second path that will drift. The rejected alternatives are recorded in the spec.

Whisper's position comes from segmentCallback — TranscriptionSegment.end is an absolute timestamp, so max(end) is the authoritative audio position (~102 reports for a 50 minute file). The per-token callback is a liveness signal only and returns nil, so decoding is never interrupted. Parakeet drains FluidAudio's transcriptionProgressStream.

ProgressReporter actor. Engine callbacks are synchronous and @Sendable, so they cannot await into an actor, and a Task per token would be wasteful. They yield into an AsyncStream with .bufferingNewest(1): yield is non-blocking and a burst of token-rate events collapses to the latest, so there is no unbounded queue and no backpressure on the decoder. One render task merges that stream with a 100 ms ticker. That split is what keeps the spinner moving during the ~30 s of audio between engine reports, so slow is distinguishable from hung.

The stderr trap. In default mode the CLI dups /dev/null over STDERR_FILENO for the whole run to silence FluidAudio's INFO logging, so progress written to FileHandle.standardError would be discarded. Progress goes to the descriptor saved before the redirection, and isatty is checked against that same descriptor rather than STDERR_FILENO.

Also replaces the raw Duration interpolation: 22.556551125 seconds now reads 22.6s, and Decoded 48851727 samples reads Audio decoded in 12.4s (37:41 of audio).

Verification

  • Transcript unchanged: on a 37 minute file the output is byte-identical to main's (40 830 bytes both sides), so the reporting cannot have altered the result.
  • Interactive rendering captured through a pty: 1 hide-cursor and 1 matching show-cursor, 58 in-place redraws, exactly 1 newline in the whole progress region, no truncated frames at 60/80/100/140 columns.
  • 704 tests pass, including 11 new ones covering ProgressUpdate clamping, the protocol default implementation carrying a non-reporting engine, and composite forwarding to the engine owning the active model.
  • Mode matrix: TTY, 2>file, --quiet (verified byte-empty stderr), --progress never, --verbose, WISPR_PROGRESS=always, --list-models.
  • App source untouched — git diff --name-only main contains nothing under Sources/WisprApp/.

Bugs found while verifying

  • Plain-line throttle: the phase-start line set the emit timestamp while the fraction was still nil, and the delta rule required a previous non-nil fraction, so percentages were suppressed until the 5 s timer. A phase finishing inside 5 s reported 0% and nothing else.
  • The bar's width reserve was a fixed 62 columns, which truncated ETA and speed away at ~89 columns. The bar is now sized from the space actually left over.
  • Using model: was initially gated on the progress style, which silently dropped it in non-TTY runs. Status lines and the progress animation are now controlled separately; only --quiet silences both.

Not covered

The Whisper path is implemented against WhisperKit's documented callbacks but was not exercised end-to-end: only parakeet-v3 is downloaded on my machine, so the Parakeet path is the one verified for real. Worth a second pair of eyes, or a run by someone with a Whisper model installed.

wispr-cli went silent for the entire transcription of a long file. On a
50 minute recording it printed the model, the load time, the audio
duration and a sample count, then nothing for several minutes — with no
way to tell whether it was working or hung. --verbose did not help,
because nothing between "decoded" and "done" reported anything.

Three separate causes:

- Neither WhisperService nor ParakeetService surfaced the progress that
  WhisperKit and FluidAudio already expose internally.
- AudioFileDecoder.decode read the whole file in one pass with no tick.
- In its default mode the CLI dups /dev/null over STDERR_FILENO for the
  whole run to silence FluidAudio's INFO logging, so anything written to
  stderr during transcription was discarded.

Engines now report a position in the audio; the CLI owns all display
policy. WisprCore gains a ProgressUpdate value type and a
transcribe(_:language:onProgress:) overload added as a protocol
requirement with a default implementation, so every existing call site
and conformance compiles and behaves exactly as before. The GUI app is
untouched.

WhisperService reports from WhisperKit's segmentCallback (absolute
segment timestamps give the authoritative audio position) with the
per-token callback as a liveness signal only, returning nil so decoding
is never interrupted. ParakeetService drains FluidAudio's
transcriptionProgressStream. AudioFileDecoder takes a defaulted
onProgress, throttled to a quarter second of audio.

In the CLI, a ProgressReporter actor takes engine events through an
AsyncStream with bufferingNewest(1) — yield is non-blocking and a burst
of token-rate events collapses to the latest, so there is no queue and
no backpressure on the decoder — and merges them with a 100 ms ticker.
That split is what keeps the display animated during the ~30 s gaps
between engine reports, so slow is distinguishable from hung. Output
goes to the descriptor saved before the /dev/null redirection, and
isatty is checked against that same descriptor.

Adds --progress auto|always|never (also WISPR_PROGRESS) and --quiet.
A TTY gets a single line redrawn in place with a bar, position, elapsed,
ETA and realtime factor; a non-TTY gets periodic plain lines with no
escape sequences; stdout stays a clean transcript pipe. Durations are
now human-readable, so "22.556551125 seconds" reads "22.6s".

Verified on a 37 minute file: the transcript is byte-identical to main's,
so the reporting cannot have altered the result.
Copilot AI lite review requested due to automatic review settings September 17, 2026 15:12

@sebsto sebsto left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary — excellent work, but it does not build under Xcode as-is; one blocking fix before merge.

This closes #112: wispr-cli now shows transcription progress — an in-place redrawn line on a TTY, throttled plain lines otherwise, gated by --progress auto|always|never / --quiet. The layering is the highlight: engines only report a position in the audio, and every display decision (throttle, ETA, TTY, narrowing) lives in the CLI, so nothing about presentation leaks into WisprCore.

What I checked hardest, all sound:

  • Strictly additive and source-compatible. The new transcribe(_:language:onProgress:) is a protocol requirement with a default implementation forwarding to the existing method, so every current conformer — including MockTranscriptionEngine, which still implements only the two-arg form — compiles and behaves unchanged.
  • The transcript cannot have changed. Whisper's token callback returns nil (never interrupts decoding) and segmentCallback is read-only; Parakeet drains FluidAudio's transcriptionProgressStream in a pump Task cancelled on defer. The byte-identical-output verification backs this up.
  • No backpressure on the decoder. Synchronous @Sendable engine callbacks yield into an AsyncStream with .bufferingNewest(1), collapsing token-rate bursts to the latest — no unbounded queue, no await from the callback.
  • The stderr trap is handled. Progress writes to the descriptor saved before /dev/null is duped over STDERR_FILENO, and isatty is checked against that same fd. The ^C cursor restore uses a DispatchSourceSignal (not a raw C signal handler) then SIG_DFL + re-raise — the correct pattern.

One blocking finding (build), one house-style concurrency flag, one Low. The Whisper path is implemented against WhisperKit's documented callbacks but the author discloses it was not run end-to-end (only parakeet-v3 installed) — worth a human run with a Whisper model before release, but not a code issue.

Comment thread Sources/WisprCLI/ProgressReporter.swift Resolved
swift build passed but xcodebuild failed with "cannot find type
'TranscriptionProgressHandler' in scope". WisprCore and WisprCLI use
explicit file lists in project.pbxproj — only the test targets are
PBXFileSystemSynchronizedRootGroups — so the three new files were
invisible to the Xcode build and the types they declare did not exist.

Adds the same four entries per file that #108 used for FileAccess.swift:
a PBXBuildFile, a PBXFileReference, a group child, and a Sources build
phase member. ProgressUpdate.swift goes to WisprCore, DurationFormat.swift
and ProgressReporter.swift to WisprCLI. wisprTests needs nothing, being a
synchronized group.

Verified with CI's exact invocation: BUILD SUCCEEDED.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical project-registration and compile issues remain, along with unresolved behavioral issues requiring fixes.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds end-to-end transcription progress reporting to wispr-cli, including engine callbacks, terminal/plain rendering, new flags, and readable timing output.

Changes:

  • Adds progress APIs and forwarding across WisprCore engines and decoding.
  • Adds CLI progress rendering, --progress, WISPR_PROGRESS, --quiet, and duration formatting.
  • Adds progress tests and feature documentation.
File summaries
File Summary and final review comments
wisprTests/TranscriptionProgressTests.swift Adds progress plumbing tests. No final comments.
Sources/WisprCore/Services/WhisperService.swift Adds Whisper progress reporting. moderate (1 vote): callback mapping lacks automated coverage and was not tested end-to-end.
Sources/WisprCore/Services/TranscriptionEngine.swift Adds the additive progress API. No final comments.
Sources/WisprCore/Services/ProgressUpdate.swift Defines progress types. critical (3 votes): missing Xcode project registration prevents these types from being included in project builds.
Sources/WisprCore/Services/ParakeetService.swift Forwards Parakeet progress. moderate (1 vote): the supported EOU model bypasses progress reporting.
Sources/WisprCore/Services/CompositeTranscriptionEngine.swift Forwards progress to the active engine. No final comments.
Sources/WisprCore/Services/AudioFileDecoder.swift Adds decode progress callbacks. No final comments.
Sources/WisprCLI/WisprCLI.swift Wires CLI options and orchestration. critical (1 vote): an internal ProgressPreference is exposed through an invalid public member. moderate (2 votes): --quiet --verbose still emits diagnostics. moderate (2 votes): untagged decode events can corrupt transcription-phase progress. nit (2 votes): silent mode still passes a non-nil handler. nit (1 vote): verbose non-TTY behavior conflicts with the documented auto mode.
Sources/WisprCLI/ProgressReporter.swift Implements rendering and lifecycle. critical (3 votes): missing Xcode target registration. moderate (2 votes): lacks focused renderer and formatting tests. moderate (1 vote): plain output can remain silent for up to five seconds. moderate (1 vote): cancellation can enqueue stale frames after finishing. nit (1 vote): ProgressStyle lacks Equatable. nit (1 vote): ProgressPreference lacks Equatable.
Sources/WisprCLI/DurationFormat.swift Adds duration formatting. critical (1 vote): missing Xcode target registration. nit (1 vote): formatting boundary cases lack automated tests.
.kiro/specs/cli-transcription-progress/tasks.md Documents implementation tasks. No final comments.
.kiro/specs/cli-transcription-progress/requirements.md Documents requirements. No final comments.
.kiro/specs/cli-transcription-progress/design.md Documents the design. No final comments.
Review details

Suppressed comments (8)

Sources/WisprCLI/DurationFormat.swift:21

  • The new duration-formatting functions have no automated tests, while the existing Swift suite tests analogous duration boundaries (for example, wisprTests/TranscriptStoreTests.swift:290-294). Add focused cases for sub-minute rounding, 60/3600-second transitions, invalid values, and clock formatting so changes to terminal output are caught.
nonisolated func formatDuration(_ seconds: Double) -> String {

Sources/WisprCLI/ProgressReporter.swift:50

  • ProgressStyle is compared with == and != in this file and from WisprCLI, but it does not conform to Equatable. The CLI target therefore fails to compile; add the conformance.
nonisolated enum ProgressStyle: Sendable {

Sources/WisprCLI/ProgressReporter.swift:60

  • ProgressPreference is compared with == in WisprCLI.swift, but it does not conform to Equatable. This also prevents the CLI target from compiling.
nonisolated enum ProgressPreference: String, Sendable, CaseIterable {

Sources/WisprCLI/ProgressReporter.swift:172

  • When plain output is enabled for a non-TTY, this five-second interval allows progress to go silent for up to five seconds whenever the fraction changes by less than 5%. The 100 ms ticker only calls renderPlain; it does not bypass this guard, so --verbose and --progress always do not meet the required once-per-second reporting cadence. Use a one-second-or-shorter maximum interval (or otherwise enforce that bound).
    private static let plainMinInterval = Duration.seconds(5)

Sources/WisprCLI/ProgressReporter.swift:284

  • Cancelling these tasks is fire-and-forget. A render task that has already passed its cancellation check can still enqueue tick() after finish(); because the phase state is left populated, that tick may redraw after the cursor is shown and interleave a stale progress frame with the transcript or error output. Add a finished/generation guard and wait for already-queued render work to quiesce before returning.
        intakeTask?.cancel()
        renderTask?.cancel()
        intakeTask = nil
        renderTask = nil
        eventSink.finish()

Sources/WisprCLI/WisprCLI.swift:129

  • The option help says auto is enabled only when stderr is a terminal, but progressStyle returns .plainLines for .auto when verbose is true and stderr is non-TTY (line 305). Therefore --verbose 2>file emits progress despite the documented rule. Clarify the --verbose exception in the help or change the style logic so the public contract is consistent.
            Progress indicator: auto (only when stderr is a terminal), always, \
            or never. Also settable via WISPR_PROGRESS.

Sources/WisprCore/Services/ParakeetService.swift:493

  • This early return is reachable from the CLI: discoverDownloadedModels() exposes parakeet-eou-160ms, and --model accepts that name. It bypasses onProgress entirely, so a supported EOU CLI transcription stays indeterminate for its whole run despite the progress contract covering Parakeet models; the comment that the CLI never uses EOU is not true. Either report position while processing EOU audio or explicitly disallow this model in the CLI.
        if activeModelName == ModelInfo.KnownID.parakeetEou {
            return try await transcribeWithEou(audioSamples)

Sources/WisprCore/Services/WhisperService.swift:470

  • The new Whisper callback path has no automated coverage: the existing WhisperService tests stop at missing-model/error paths, while the added progress tests use fake engines and never exercise WhisperKit's callback-to-position mapping. Since this integration was also not run end-to-end per the PR description, a callback-level test seam or an integration test is needed to catch a wrong callback contract or timestamp/window mapping while still allowing model-free CI.
            let tokenCallback: TranscriptionCallback = onProgress.map { report in
                { progress in
                    // Coarse lower bound: the start of the window being decoded.
                    // Consumers clamp monotonically, so this never walks the
                    // displayed position backwards after a segment report.
  • Files reviewed: 14/14 changed files
  • Comments generated: 8
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Sources/WisprCLI/DurationFormat.swift Resolved
Comment thread Sources/WisprCLI/ProgressReporter.swift Resolved
Comment thread Sources/WisprCLI/WisprCLI.swift Resolved
Comment thread Sources/WisprCLI/WisprCLI.swift Resolved
Comment thread Sources/WisprCLI/WisprCLI.swift Resolved
Comment thread Sources/WisprCLI/WisprCLI.swift Resolved
Copilot AI review requested due to automatic review settings September 17, 2026 15:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved compile, progress lifecycle, output-suppression, throttling, and model-coverage issues remain.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (9)

Sources/WisprCLI/ProgressReporter.swift:60

  • progressStyle compares ProgressPreference with .never, but the enum has no Equatable conformance, so this new comparison also fails to compile. Add Equatable to the enum declaration.
nonisolated enum ProgressPreference: String, Sendable, CaseIterable {

Sources/WisprCLI/ProgressReporter.swift:281

  • If an error occurs while a phase is active, cancelling renderTask does not retract a tick that is already queued on the actor. Because finish() leaves phase and phaseStart set, that tick can redraw after the cursor is shown and after transcript/error output begins. Clear the phase state before cancelling the tasks, or add a finished guard checked by tick().
    func finish() {
        intakeTask?.cancel()
        renderTask?.cancel()

Sources/WisprCLI/ProgressReporter.swift:223

  • The stream intake is independent of phase lifecycle calls: yield only enqueues an event, so endPhase() can run before the final decoder/engine update is applied. That queued update may then be applied after the next begin, carrying the previous phase's position and speed into the new phase. Add a phase-generation tag or an awaited drain/barrier before transitioning phases.
            for await update in stream {
                await self?.apply(update)
            }

Sources/WisprCLI/WisprCLI.swift:196

  • --quiet does not suppress the SDK's stderr when it is combined with --verbose: this branch skips suppressStderr(), and logsEnabled only silences ProgressReporter, not FluidAudio/Whisper logging. Since quiet is documented to suppress all stderr output, make stderr suppression also apply whenever config.quiet is true (while still restoring it before reporting errors).
        let savedFd = config.verbose ? Int32(-1) : suppressStderr()
        defer { if !config.verbose { restoreStderr(savedFd) } }

Sources/WisprCLI/WisprCLI.swift:283

  • This always passes a non-nil handler, even when style is .silent (--quiet, --progress never, or default non-TTY). Whisper therefore still constructs a progress update and performs an AsyncStream.yield for every decoded token with no consumer, adding avoidable token-rate overhead in the modes that disabled progress. Pass nil to the engine when reporting is disabled, or make these handler accessors return an optional that is nil for the silent style.
        let result = try await engine.transcribe(
            samples,
            language: language,
            onProgress: reporter.transcriptionHandler()
        )

Sources/WisprCLI/WisprCLI.swift:135

  • The new --quiet flag is documented to suppress all stderr status output, but the pre-run diagnostic above still prints Models root whenever --verbose is combined with --quiet. Gate that diagnostic on !quiet as well so this mode is actually silent.
    @Flag(name: [.long, .short], help: "Suppress all progress and status output on stderr.")
    var quiet = false

Sources/WisprCLI/WisprCLI.swift:299

  • The added tests cover only ProgressUpdate and engine forwarding; none exercise progressStyle or ProgressReporter, which contain the new TTY/plain/silent mode behavior. Add focused CLI tests for these mode boundaries so regressions in --quiet, redirected stderr, and redraw/plain-line formatting are not left to manual verification.
    static func progressStyle(
        preference: ProgressPreference,
        quiet: Bool,
        verbose: Bool,
        output: ProgressOutput

Sources/WisprCLI/WisprCLI.swift:282

  • The intake task consumes events independently of this phase-transition call, so the final decode callback can still be buffered when decode returns. begin(.transcribing, ...) resets the reporter, but does not drain or tag queued events; a stale decode update can then be applied to transcription, making it start at 100% and suppressing the real engine positions. Synchronize the event stream at phase boundaries or associate each update with its phase before resetting state.
        await reporter.begin(.transcribing, total: audioSeconds)
        let result = try await engine.transcribe(
            samples,
            language: language,
            onProgress: reporter.transcriptionHandler()

Sources/WisprCore/Services/ParakeetService.swift:494

  • The CLI can reach this branch: ParakeetService.availableModels() exposes parakeet-eou-160ms, and WisprCLI accepts/discovers that model. Returning through transcribeWithEou without invoking onProgress therefore leaves CLI progress indeterminate for a supported model, contrary to the determinate progress path. Either report progress for EOU transcription or exclude/reject this model in the CLI.
        if activeModelName == ModelInfo.KnownID.parakeetEou {
            return try await transcribeWithEou(audioSamples)
        }
  • Files reviewed: 14/14 changed files
  • Comments generated: 2
  • Review effort level: Lite

Fixes a real correctness bug, closes the test gap, and tightens --quiet.

Stale-event race: event intake is asynchronous, so the decoder's final
report — a position equal to the whole audio length — could be applied
after begin(.transcribing) had reset the state. That pinned the bar at
100%, and the monotonic clamp then rejected every real engine update for
the rest of the run, silently defeating the feature. Events now carry the
generation of the phase that produced them and stale ones are dropped.

No per-token cost when progress is off: the handler factories return nil
for the silent style, so the engines build nil backend callbacks and
WhisperKit does no per-token work. A no-op closure would still have cost
a call per decoded token.

--quiet is now authoritative for stderr. It gates the "Models root:"
diagnostic, and — found while testing that — --verbose had also disabled
the SDK-log suppression, so `--quiet --verbose` printed 20 lines of
FluidAudio INFO logging. A flag promising to suppress all stderr output
has to beat one that merely asks for more of it.

Replaces the nonisolated(unsafe) signal-source storage with a Mutex. The
write-once discipline made the unchecked version sound, but this costs
nothing and keeps the file free of concurrency escape hatches.

Adds 33 tests for the CLI layer, previously covered only by manual
terminal captures: duration and clock formatting boundaries, style
resolution from the flags, preference parsing, SDK-log suppression
precedence, the reporter state machine (monotonic clamping, clamping to
total, phase reset, generation tagging) and bar geometry. This needed
WisprCLI on the SPM test target; the Xcode scheme's Build action contains
only WisprApp, so build-xcode is unaffected.

Renames ProgressReporter to TerminalProgressReporter: Foundation ships
its own ProgressReporter in the macOS 26 SDK, which is unambiguous inside
the module but not from the test target. Same collision-avoidance already
applied to ProgressUpdate and TranscriptionProgressHandler.

Also corrects a wrong value repeated in the design table and in a test:
formatDuration(0.85) is "0.8s", not "0.9s", because 0.85's nearest Double
is below 0.85.

Transcript remains byte-identical to main across 13 runs including
--verbose --progress always with the pump task active.
Copilot AI review requested due to automatic review settings September 17, 2026 15:50
@sebsto

sebsto commented Sep 17, 2026

Copy link
Copy Markdown
Owner Author

Review round complete — 13 threads, all replied and resolved

Two commits: ea94c07 (Xcode registration) and e96e315 (everything else).

Implemented — 9

# Comment What changed
1 Xcode registration (blocking, ×4 threads) Four pbxproj entries per file, following FileAccess.swift. build-xcode green.
2 Stale-event race Events now carry a phase generation; stale ones dropped. Real bug — details below.
3 Non-nil handler when silent Factories return nil, so WhisperKit gets nil callbacks and does no per-token work.
4 --quiet didn't suppress Models root: Gated — and it exposed a bigger leak, below.
5 nonisolated(unsafe) signal storage Replaced with Mutex.
6 No tests for the CLI layer 33 tests. Suite now 737 passing.

Dismissed as factually incorrect — 3. Each rebutted with a standalone repro in its thread rather than a bare assertion: empty NO_COLOR already disables colour (ProcessInfo returns "", not nil); a public member in an internal type is legal Swift, not a compile error; and payload-free enums are implicitly Equatable. In all three cases both CI jobs were already green on the code in question. I've left that code alone rather than change correct code to silence a report.

Partially implemented — 1. The 5 s non-TTY throttle vs the once-per-second requirement was a genuine spec/code inconsistency, but I fixed the spec: the once-per-second guarantee belongs to the interactive display (100 ms ticker), while the non-TTY channel is a log stream where one line per second means ~3 000 lines for a 50-minute file. R1.1 now scopes it and R1.1a states the log cadence. Happy to tighten the constant if you disagree.

The one that mattered

The race would have silently defeated the entire feature. AudioFileDecoder.decode ends with a final report equal to the whole audio length; intake is async, so that event could land after begin(.transcribing) reset the state, be accepted via !hasPosition, and pin the bar at 100% — after which the monotonic clamp discarded every real engine update for the rest of the run. It never reproduced in my manual runs, which is a fair argument for the tests that were also requested. staleEventFromPreviousPhaseIsDropped drives the exact interleaving and fails against the old code.

Second --quiet leak, found while fixing the first

--verbose also disabled the SDK-log suppression, so --quiet --verbose emitted 20 lines of FluidAudio INFO logging — much more than the single line reported. --quiet is now authoritative for stderr:

invocation stderr
--quiet 0 bytes
--quiet --verbose 0 bytes (was 2 863)
--verbose SDK logs + phase lines + Models root
default Using model: parakeet-v3

Also worth flagging

ProgressReporter is now TerminalProgressReporter. Foundation ships its own ProgressReporter in the macOS 26 SDK — fine inside the module, ambiguous once the test target imported WisprCLI. Same collision-avoidance already applied to ProgressUpdate and TranscriptionProgressHandler.

The tests also caught a wrong value I'd written into the design doc: formatDuration(0.85) is 0.8s, not 0.9s, since 0.85's nearest Double is below 0.85. Corrected in the doc and the test.

Determinism

One run this round differed from main by a single word while the 737-test suite was executing concurrently. It did not reproduce. 13 subsequent controlled runs — idle, 4-way concurrent, during the full test suite, and with progress fully enabled (--verbose --progress always, pump task active) — are byte-identical to main on both branches (md5 5fb684d7…, 40 830 bytes). I could not attribute it to this change and could not reproduce it; flagging it rather than leaving it unsaid.

Still true from the original PR: only parakeet-v3 is downloaded here, so the Whisper progress path is compile-verified but not exercised end-to-end.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved moderate findings affect terminal rendering and supported-model progress forwarding, while the Whisper callback path lacks direct coverage.

Review details

Suppressed comments (7)

Previously missed (2) — in code that hasn't changed since the last review.

.kiro/specs/cli-transcription-progress/requirements.md:96

  • This requirement is inconsistent with the implemented and documented CLI contract: progressStyle emits plain lines for non-TTY output when --progress always is set, not only for --verbose. Please include --progress always here so the requirements do not describe a mode the CLI intentionally supports as silent.
    Sources/WisprCore/Services/WhisperService.swift:489
  • The backend-specific progress path is not covered by the repository's WhisperService tests: those tests stop before a model is loaded, while the new progress tests use synthetic engines, so neither the segment position mapping nor the nil-returning token callback is exercised. Please add an injectable/fake WhisperKit seam or an integration fixture that invokes this overload and asserts the forwarded updates; otherwise this path can regress while the suite remains green.

.kiro/specs/cli-transcription-progress/design.md:248

  • This rendering section still says non-TTY plain lines are --verbose-only and marks every other non-TTY run silent, but the CLI also intentionally supports --progress always (and the PR description advertises that mode). Update both the Plain lines and Silent descriptions to match the actual option behavior.
**Plain lines (non-TTY, `--verbose`).** No `\r`, no escapes. Emitted on a 5 s
interval or a 5 % delta, whichever comes first — a log cadence, deliberately not
the interactive channel's 10 Hz refresh (see R1.1a):

[progress] transcribing 38% 19:23/50:53 elapsed 2:41 eta 4:22 11.8x


**Silent.** `--quiet`, `--progress never`, or non-TTY without `--verbose`.

.kiro/specs/cli-transcription-progress/tasks.md:28

  • This checklist still points to Sources/WisprCLI/ProgressReporter.swift, but the implementation was renamed to TerminalProgressReporter.swift and the later review note documents that rename. Leaving the old path here sends future contributors to a file that does not exist.
- [x] 2.2 New `Sources/WisprCLI/ProgressReporter.swift`: phases, actor state,

Sources/WisprCLI/TerminalProgressReporter.swift:324

  • Cancelling the render task does not remove a tick that is already queued at await self?.tick(). On an error path phase and phaseStart remain set, so that tick can redraw the stale progress line after finish() shows the cursor and interleave it with the error output. Clear the phase state (or add a finished guard) before returning from finish().
        eventSink.finish()
        eraseLine()

Sources/WisprCLI/WisprCLI.swift:205

  • In verbose TTY runs, savedFd is -1, so FluidAudio's INFO writes and the reporter's redraws share the same terminal descriptor without coordination. An SDK log can arrive while the progress line is active; it appends a newline before the next \r redraw, leaving a corrupted progress frame on the previous row instead of the advertised single-line display. Suppress/redirect SDK logs while interactive progress is enabled, or route verbose logs through a coordinated output path.
        let suppressingSDKLogs = Self.shouldSuppressSDKLogs(
            verbose: config.verbose,
            quiet: config.quiet
        )
        let savedFd = suppressingSDKLogs ? suppressStderr() : Int32(-1)

Sources/WisprCore/Services/ParakeetService.swift:494

  • This branch is reachable from the CLI: WisprCLI.discoverDownloadedModels includes parakeet-eou-160ms, and model resolution can select it. Returning through transcribeWithEou drops onProgress, so that supported CLI model shows no position or determinate progress at all despite the progress contract. Either forward progress through the EOU path or explicitly exclude this model from CLI selection and document the limitation.
        if activeModelName == ModelInfo.KnownID.parakeetEou {
            return try await transcribeWithEou(audioSamples)
        }
  • Files reviewed: 16/17 changed files
  • Comments generated: 1
  • Review effort level: Lite

With tiny downloaded, the WhisperKit callback path is now verified
end-to-end rather than only compile-checked: positions advance in exact
30 s increments from segmentCallback's absolute segment timestamps, and
the interactive line renders in full at 110 columns.

Establishing transcript identity needed care. tiny is non-deterministic
on repetitive synthetic TTS -- on main, independently of this change:
five main runs on one such file gave five different transcripts spanning
1334-3678 bytes. On natural speech main is exactly reproducible, and
against that baseline every branch configuration (silent, progress
enabled, verbose+progress) matches byte for byte on a 504 s file where
the callbacks fire ~17 times.
Copilot AI review requested due to automatic review settings September 17, 2026 16:26
@sebsto

sebsto commented Sep 17, 2026

Copy link
Copy Markdown
Owner Author

Whisper path now verified end-to-end

You downloading tiny closed the one gap I'd flagged. The WhisperKit path was previously only compile-checked, since parakeet-v3 was the only model on this machine. It works.

Positions advance in exact 30 s increments, which confirms segmentCallback's absolute segment timestamps are driving it rather than the coarse per-token fallback:

[progress] transcribing   5% 00:30/08:24 elapsed 0.4s eta 3.8s 123.2x
[progress] transcribing  11% 01:00/08:24 elapsed 0.7s eta 3.6s 122.8x
[progress] transcribing  17% 01:30/08:24 elapsed 0.9s eta 3.3s 125.7x
…
[progress] transcribing  95% 08:00/08:24 elapsed 4.0s eta 0.2s 121.2x

Interactive line at a 110-column pty, full width, nothing truncated, reaching 100%:

⠙ Transcribing  ▕███████████████████████████████████▏  100%  08:24 / 08:24   elapsed 4.6s   eta 0.0s   112.2×

--verbose on this path:

✓ Model tiny loaded in 0.4s
✓ Audio decoded in 0.1s (08:24 of audio)
✓ Transcribed in 4.7s

R6.2 — and a false alarm worth reporting

My first comparison on the Whisper path looked like a regression: main with tiny gave 30 525 bytes, the branch 32 890. It isn't a regression — tiny is non-deterministic on some inputs, on main, independently of this PR. Five main runs on 161 s of repetitive synthetic TTS produced five different transcripts spanning 1 334–3 678 bytes, a 2.75× spread. That input is pathological for a small model (repetitive TTS provokes hallucination loops and temperature fallback), so byte-identity there measures nothing.

On natural, non-repetitive speech main is exactly reproducible, which gives a real baseline:

input main runs result
25 s natural speech 5 all identical (5000b8da…)
504 s varied speech (~17 windows) 2 identical (e6033dea…)

Against those, every branch configuration matches byte for byte:

configuration 25 s 504 s
silent (nil callbacks) 3/3 match match
--progress always (both callbacks active) 3/3 match match
--verbose --progress always — match

So the callbacks don't alter the transcript, on a file long enough for segmentCallback to fire ~17 times. Both engine paths are now verified for real, and the PR no longer carries an unverified-path caveat.

Recorded in the spec in 59d6f29. One incidental note: --output writes no trailing newline where the stdout path adds one via print — pre-existing, and the reason the two hashes differ by one byte in my notes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved moderate findings remain in progress cleanup, duration formatting, input validation, and documentation.

Review details

Suppressed comments (7)

Previously missed (2) — in code that hasn't changed since the last review.

.kiro/specs/cli-transcription-progress/tasks.md:30

  • This checklist still names Sources/WisprCLI/ProgressReporter.swift, but the implementation and Xcode registration are TerminalProgressReporter.swift. The completed task therefore points at a file that does not exist; update the checklist (and the corresponding design section) to the actual implementation name.
    Sources/WisprCore/Services/WhisperService.swift:470
  • The new WhisperKit callback wiring is not covered by an automated success-path test: the added tests exercise fake TranscriptionEngine conformers, while the existing WhisperServiceTests only reach the no-model guards. Because segmentCallback/callback determine whether real positions are emitted and the callback must return nil without changing the transcript, add a fixture or injectable backend test that exercises this overload with a loaded Whisper model.

.kiro/specs/cli-transcription-progress/design.md:21

  • This design still refers to ProgressReporter and ProgressReporter.swift, while the shipped implementation is TerminalProgressReporter in Sources/WisprCLI/TerminalProgressReporter.swift (the rename was needed to avoid the SDK collision). The diagram and API section now direct readers to a nonexistent type/file; update these references to match the implementation.
┌─ WisprCLI ────────────────────────────────────────────────────────┐
│  ProgressReporter (actor)                                         │
│    ├── AsyncStream<Event>, .bufferingNewest(1)   ← engine events  │
│    ├── 100 ms ticker                             ← liveness       │
│    ├── state: phase, position, total, phase start, run start      │
│    └── TerminalRenderer (TTY single-line │ plain lines │ silent)  │

.kiro/specs/cli-transcription-progress/requirements.md:96

  • The requirements say non-TTY progress is emitted only with --verbose, but the implemented and tested behavior also emits plain lines for the explicit --progress always option (WisprCLI.swift:322-326). Since always is a public mode and the CLI help promises it, this requirement is now contradictory; document the explicit opt-in as an allowed case.
4.4 When stderr is not a TTY (piped, redirected, CI), the CLI SHALL NOT emit
ANSI escapes or carriage returns. It SHALL emit periodic plain one-line updates
instead, and only when `--verbose` is set.

Sources/WisprCLI/DurationFormat.swift:28

  • When seconds is just below 60 (for example, 59.96), this branch is selected before rounding but %.1f rounds the value to 60.0s. That produces an invalid boundary format and contradicts the 60-second case (1m 0s); choose the unit after rounding so the display never emits 60.0s.
    if seconds < 60 {
        return String(format: "%.1fs", seconds)
    }

    let total = Int(seconds.rounded())

Sources/WisprCLI/TerminalProgressReporter.swift:323

  • Cancellation here is not a join: the render loop checks Task.isCancelled before the separate await self?.tick() hop, so it can pass that check, be cancelled while suspended, and enqueue a tick after finish() has erased the line and shown the cursor. On an error path the phase is still set, so that late tick can redraw progress after the CLI reports the error. Track a finished state that tick/render honor, or await the render task's completion before returning from finish().
        intakeTask?.cancel()
        renderTask?.cancel()
        intakeTask = nil
        renderTask = nil
        eventSink.finish()

Sources/WisprCore/Services/ProgressUpdate.swift:44

  • fraction is documented to return either nil or a value in 0...1, but a caller or backend can construct ProgressUpdate(processedSeconds: .nan, totalSeconds: 1) and the min(max(...)) expression returns NaN. Treat non-finite positions (and totals) as invalid before calculating the ratio so the public value type preserves its stated invariant.
        guard let totalSeconds, totalSeconds > 0 else { return nil }
        return min(max(processedSeconds / totalSeconds, 0), 1)
  • Files reviewed: 16/17 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

The design still named Sources/WisprCLI/ProgressReporter.swift and the
ProgressReporter actor, so a reader following it would look for a file and
type that no longer exist. Updates the architecture diagram, the new-file
heading, the actor sketch, the verification reference, and task 2.2.

The one remaining mention of a bare ProgressReporter is deliberate: it
describes Foundation's own type, which is why ours was renamed.
Copilot AI review requested due to automatic review settings September 17, 2026 17:42
@sebsto
sebsto merged commit b94bb50 into main Sep 17, 2026
4 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved moderate findings affect duration formatting, reporter shutdown safety, and quiet-mode error suppression; Whisper callback coverage is also incomplete.

Review details

Suppressed comments (9)

Previously missed (1) — in code that hasn't changed since the last review.

Sources/WisprCLI/TerminalProgressReporter.swift:324

  • Cancelling renderTask is cooperative: a tick that has already passed its cancellation check can still be queued on this actor and run after finish() returns. Because phase remains set, that tick can redraw after writeOutput or after the cursor is shown, corrupting the transcript/prompt. Clear the phase state (or add a finished guard) during finish() after erasing the current line.

.kiro/specs/cli-transcription-progress/design.md:242

  • The design repeats the same mismatch: it labels plain output as non-TTY/--verbose and lists every non-TTY/no-verbose run as silent, although an explicit --progress always correctly selects .plainLines even without --verbose. Update this rendering section to document that explicit exception, matching the CLI and the requirements.
**Plain lines (non-TTY, `--verbose`).** No `\r`, no escapes. Emitted on a 5 s
interval or a 5 % delta, whichever comes first — a log cadence, deliberately not
the interactive channel's 10 Hz refresh (see R1.1a):

.kiro/specs/cli-transcription-progress/design.md:248

  • This silent-mode list also omits the explicit non-TTY --progress always exception implemented by progressStyle, so the design's mode matrix is internally contradictory. Include --progress always in the non-silent cases.
**Silent.** `--quiet`, `--progress never`, or non-TTY without `--verbose`.

.kiro/specs/cli-transcription-progress/requirements.md:96

  • This requirement says redirected/non-TTY progress is emitted only with --verbose, but the implementation intentionally emits plain lines for an explicit --progress always as well (progressStyle handles .always). Since always is the documented way to request progress in scripted runs, update the requirement (and matching design text) to include that explicit preference so the acceptance criteria do not contradict the CLI.
4.4 When stderr is not a TTY (piped, redirected, CI), the CLI SHALL NOT emit
ANSI escapes or carriage returns. It SHALL emit periodic plain one-line updates
instead, and only when `--verbose` is set.

Sources/WisprCLI/DurationFormat.swift:25

  • For values just below one minute, this branch rounds to one decimal before deciding on units, so formatDuration(59.96) returns 60.0s while formatDuration(60) returns 1m 0s. Promote after rounding (or base the branch on rounded tenths) so the formatter does not emit a misleading 60-second sub-minute value.
    if seconds < 60 {
        return String(format: "%.1fs", seconds)

Sources/WisprCLI/WisprCLI.swift:187

  • Because the --quiet redirection is installed only after the file existence check and resolveModel, failures in either preflight step escape the quiet path and are printed by ArgumentParser to fd 2. That contradicts R5.2 and the new help/comment promise of suppressing all stderr; start the saved-fd redirection before these checks, or explicitly define errors as an exception and update the requirement/help.
        // Resolve the model before any stderr redirection, so a failure here is
        // reported normally.
        let modelName = try resolveModel(config.modelName)

Sources/WisprCLI/WisprCLI.swift:130

  • The help text says auto emits progress only when stderr is a terminal, but progressStyle returns .plainLines for auto when verbose is true on a non-TTY. Thus --verbose 2>file emits progress under auto; update the help text or remove the override so the documented flag behavior matches the implementation.
            Progress indicator: auto (only when stderr is a terminal), always, \
            or never. Also settable via WISPR_PROGRESS.
            """

Sources/WisprCore/Services/ParakeetService.swift:486

  • This statement is not true for the CLI: resolveModel discovers parakeet-eou-160ms and --model can select it, so transcribeWithEou is reachable from this command. That path currently emits no progress; document it as an intentional indeterminate fallback (or add/reject support) rather than claiming the CLI never uses it.
    /// The EOU model takes the realtime path (`transcribeWithEou`), which the CLI
    /// never uses, so it reports nothing.

Sources/WisprCore/Services/WhisperService.swift:470

  • The new tokenCallback/segmentCallback wiring is only reached after a loaded WhisperKit model, but the existing WhisperServiceTests stop at the no-model error path and the PR explicitly leaves this path unverified. Add a deterministic backend seam or a model-backed test that invokes both callbacks (including the nil return), otherwise a callback API mismatch can ship unnoticed.
            let tokenCallback: TranscriptionCallback = onProgress.map { report in
                { progress in
                    // Coarse lower bound: the start of the window being decoded.
                    // Consumers clamp monotonically, so this never walks the
                    // displayed position backwards after a segment report.
  • Files reviewed: 16/17 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wispr-cli gives no progress indication while transcribing

2 participants