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
2 changes: 1 addition & 1 deletion .claude/skills/project-guardrails/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ the fast "don't" list.)
- **No local models / model downloads.** Transcription is a remote AssemblyAI
call. No on-device ASR/LLM, no model cache, no download UI.
- Don't reintroduce a "remove filler words (um, uh, like)" directive in the
prompt — `u3-sync-pro` ignores it; it was deliberately dropped.
prompt — `universal-3-5-pro` ignores it; it was deliberately dropped.

## App shape

Expand Down
47 changes: 46 additions & 1 deletion .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,14 @@ jobs:
if [ -z "$range" ]; then
echo "code=true" >>"$GITHUB_OUTPUT"; exit 0
fi
changed="$(git diff --name-only "$range")"
# `|| true` is the safety property, not tidiness: this step runs under
# `bash -e`, so a failing git diff (an unreachable base sha after a
# force-push, a fork edge case) fails the step, fails this job, and makes
# the `check` job SKIP — which GitHub reports to branch protection as a
# passing required check. The entire macOS gate would be bypassed on a
# green-looking PR. An empty `changed` falls through to code=true below,
# so failing open runs the full check instead.
changed="$(git diff --name-only "$range" || true)"
echo "Changed files in $range:"; echo "$changed"
# Capture non-docs lines and test emptiness (robust across grep impls).
nondocs="$(printf '%s\n' "$changed" | grep -vE '^docs/' || true)"
Expand Down Expand Up @@ -93,3 +100,41 @@ jobs:
- name: Run check.sh
working-directory: blurt
run: scripts/check.sh

# Always-runs summary job, so a *skipped* `check` can't be mistaken for a pass.
# GitHub reports a skipped required check to branch protection as successful, so
# anything that makes `check` skip — the `changes` filter erroring, a `needs`
# failure — silently bypasses the entire macOS gate on a green-looking PR.
# This job runs `if: always()` and fails unless `check` either succeeded or was
# skipped *by the docs-only design* (changes.outputs.code == 'false').
#
# NOTE: this only protects the repo once branch protection requires `gate`
# instead of (or as well as) `check` — that's a repo settings change.
gate:
needs: [changes, check]
if: always()
runs-on: ubuntu-latest
steps:
- name: Assert the macOS gate ran or was intentionally skipped
env:
CHECK_RESULT: ${{ needs.check.result }}
CHANGES_RESULT: ${{ needs.changes.result }}
CODE_CHANGED: ${{ needs.changes.outputs.code }}
run: |
echo "changes=$CHANGES_RESULT code=$CODE_CHANGED check=$CHECK_RESULT"
if [ "$CHANGES_RESULT" != "success" ]; then
echo "error: the changes filter did not succeed, so the gate cannot be trusted" >&2
exit 1
fi
case "$CHECK_RESULT" in
success) echo "macOS gate passed" ;;
skipped)
if [ "$CODE_CHANGED" = "false" ]; then
echo "docs-only change; macOS gate intentionally skipped"
else
echo "error: check was skipped but code changed" >&2
exit 1
fi
;;
*) echo "error: macOS gate result was '$CHECK_RESULT'" >&2; exit 1 ;;
esac
25 changes: 13 additions & 12 deletions AGENTS.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions App/Blurt/Blurt/AppCoordinator+UITesting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
// XCUITest can't synthesize the lone right-modifier `flagsChanged` event the
// real trigger relies on — and the `CGEventTap` that reads it needs the
// process to be Accessibility-trusted, which the CI test host isn't. The test
// harness window instead calls `beginDictation`/`endDictation`/
// `cancelDictation`, which drive the *same* `DictationSession` (and its
// harness window instead calls `session.press()`/`release()`/`cancel()`
// directly, driving the *same* `DictationSession` (and its
// press-time missing-key readiness check) the key tap's closures reach via
// `session.submit(_:)`. Compiled only in Debug, so nothing here ships in the
// notarized Release build.
Expand Down
71 changes: 30 additions & 41 deletions App/Blurt/Blurt/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ final class AppCoordinator {
/// so the panel and its SwiftUI host aren't built until the app is fully
/// configured and the pill is about to appear. Stays nil through onboarding.
private var overlay: OverlayWindowController?
/// Invoked when the user triggers dictation without a saved API key. The app
/// shell wires this to bring the setup/settings window forward so the user can
/// add a key — the actionable fix — rather than flashing a message that
/// disappears.
let onMissingAPIKey: @MainActor () -> Void
/// Invoked when a press is refused because setup isn't finished — today a
/// missing API key, and whatever else the engine classifies as a
/// `PipelinePhase.setupBlocker`. The app shell wires this to bring the
/// setup/settings window forward so the user lands on the actionable fix rather
/// than seeing a message that disappears.
let onSetupBlocked: @MainActor () -> Void

let session: DictationSession
/// The mic seam, kept beyond session construction for its two side features —
Expand Down Expand Up @@ -50,11 +51,11 @@ final class AppCoordinator {
/// in-memory store with an offline validator — the engine's `APIKeySubmission`
/// still owns the never-persist-an-unverified-key invariant either way.
init(
onMissingAPIKey: @escaping @MainActor () -> Void,
onSetupBlocked: @escaping @MainActor () -> Void,
components: DictationComponents = .production(),
apiKey: APIKeyModel = APIKeyModel()
) {
self.onMissingAPIKey = onMissingAPIKey
self.onSetupBlocked = onSetupBlocked
self.apiKey = apiKey

// Unbounded: the Recent list is append-only, so every transcript must survive
Expand Down Expand Up @@ -109,7 +110,7 @@ final class AppCoordinator {
/// `submit(_:)` command feed (see its doc for the FIFO-ordering guarantee
/// that rules out spawning a `Task {}` per callback). Drives the
/// hold-to-dictate hotkey from a CGEventTap (see `DictationKeyTap`) rather
/// than a Carbon global hotkey: the latter leaks the chord's auto-repeat key
/// than a Carbon global hotkey: the latter leaks the trigger's auto-repeat key
/// events into the focused app while held.
private func startDictationDriver() {
let session = session
Expand Down Expand Up @@ -191,33 +192,10 @@ final class AppCoordinator {
overlay?.hide()
}

// MARK: - Dictation drivers
//
// The await-able begin/end/cancel path used by the UI-test harness
// (`UITestSupport`). The key tap drives the same `DictationSession` through
// its synchronous `submit(_:)` feed, so both paths hit the same engine
// guards and race rules.

/// Begins a dictation as the hotkey would (the missing-key gate is the
/// session's `readinessCheck`; `render(_:)` routes the refusal to Settings).
func beginDictation() async {
await session.press()
}

/// Stops recording and runs transcribe→inject.
func endDictation() async {
await session.release()
}

/// Abandons the in-flight dictation.
func cancelDictation() async {
await session.cancel()
}

/// Called when the user rebinds (or clears) the dictation shortcut in the
/// recorder, so the event tap starts matching the new chord. The shortcut no
/// longer gates readiness (it has a default and lives in Settings), so there's
/// nothing else to re-evaluate here.
/// Called when the user rebinds the dictation trigger in the Shortcut picker,
/// so the event tap starts matching the new key. The shortcut no longer gates
/// readiness (it has a default and lives in Settings), so there's nothing else
/// to re-evaluate here.
func dictationBindingChanged() {
keyTap?.refreshBinding()
}
Expand All @@ -234,12 +212,13 @@ final class AppCoordinator {
}

private func render(_ phase: PipelinePhase) {
// A missing key is a setup state, not a fault: the engine projections
// below render it as calm idle (no red flash) and Monitoring ignores it —
// the only app-level part is the navigation side effect, bringing the
// settings window forward so the user lands on the fix.
if case .failed(.apiKeyMissing) = phase {
onMissingAPIKey()
// A setup blocker is a state, not a fault: the engine projections below
// render it as calm idle (no red flash) and the menu bar ignores it — the only
// app-level part is the navigation side effect, bringing the settings window
// forward so the user lands on the fix. Which failures count as setup is the
// engine's call (`PipelinePhase.setupBlocker`), not re-derived here.
if phase.setupBlocker != nil {
onSetupBlocked()
}
// Reveal the pill first, then fire the cue: the sound must never sit in
// front of the visual state change. Pure phase→pill mapping lives in the
Expand All @@ -251,5 +230,15 @@ final class AppCoordinator {
menuBarStatus = phase.menuBarStatus

cues.transition(for: phase)

// A dictation that ended without a key event (auto-release cap, a refused or
// failed press) leaves the trigger's gate latched, which would swallow the
// user's next press entirely. Clearing it here — the one place that sees every
// phase — keeps the tap's state honest without the gate needing to know about
// pipeline phases. No-op whenever the gate is already idle, which is every
// normal flow.
if phase.isTerminal {
keyTap?.syncAfterTerminalPhase()
}
}
}
8 changes: 4 additions & 4 deletions App/Blurt/Blurt/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// launch), so it's set by the time the hotkey fires.
// The overlay pill isn't built here — `AppCoordinator` creates it lazily in
// `showOverlay()` once the app is fully configured.
let onMissingAPIKey: @MainActor () -> Void = { [weak self] in self?.surfaceMainWindow() }
let onSetupBlocked: @MainActor () -> Void = { [weak self] in self?.surfaceMainWindow() }
let coord: AppCoordinator
#if UITEST_HOOKS
// Under UI testing, compose the app with offline stub collaborators and an
Expand All @@ -114,7 +114,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
defaults.removeObject(forKey: key)
}
coord = AppCoordinator(
onMissingAPIKey: onMissingAPIKey,
onSetupBlocked: onSetupBlocked,
components: .uiTest(),
apiKey: APIKeyModel(
keyStore: InMemoryAPIKeyStore(),
Expand All @@ -124,10 +124,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// `lazy` default is ever read (first check), so it replaces it cleanly.
updateCheckModel = .uiTest()
} else {
coord = AppCoordinator(onMissingAPIKey: onMissingAPIKey)
coord = AppCoordinator(onSetupBlocked: onSetupBlocked)
}
#else
coord = AppCoordinator(onMissingAPIKey: onMissingAPIKey)
coord = AppCoordinator(onSetupBlocked: onSetupBlocked)
#endif
self.coordinator = coord

Expand Down
34 changes: 34 additions & 0 deletions App/Blurt/Blurt/Hotkey/DictationKeyTap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,40 @@ final class DictationKeyTap {
return true
}

/// Discard stale gate state after the session reached a terminal phase, so the
/// user's next trigger press isn't swallowed.
///
/// A dictation can end *without* a key event ending it: the auto-release cap
/// fires on a long hold, or the press is refused (no API key) / fails (no input
/// device). The gate is then left `.latched` — or `.armed`, which a quick release
/// promotes to `.latched` — and nothing can clear it, because
/// `DictationKeyGate.modifierDown` from `.latched` returns `.none` (so the next
/// tap starts nothing) and the `modifierUp` after it returns `.stop`, which
/// no-ops on an already-terminal session. The user gets one completely dead
/// press: no pill, no chime, nothing — and if that press was a hold, a whole
/// utterance spoken into a session that never started.
///
/// Resets unconditionally, unlike the disabled-tap recovery above. That guard
/// exists to preserve a *live* recording whose key events were dropped; here the
/// dictation is already over, so there is no state worth keeping even if the
/// trigger is still physically held — a later key-up just finds
/// `modifierIsDown == false` and routes to `.none`.
///
/// Acts on `reset()`'s discarded-recording result the same way `refreshBinding`
/// does. In the stale case the session is already terminal, so the
/// `cancelRecording` is a no-op. It matters for the one residual race: `render`
/// consumes `phaseStream()` asynchronously, so if that loop falls behind,
/// dictation N's terminal phase can arrive *after* the gate has armed for
/// dictation N+1 — the reset then clears N+1's live state, its key-up routes to
/// `.none`, and the recording would otherwise run to the ~115 s auto-release cap.
/// Cancelling it turns a silent two-minute hang into a clean stop.
///
/// A no-op in every normal flow, where the gate is already idle by the time a
/// terminal phase lands.
func syncAfterTerminalPhase() {
if router.reset() { onRecordingDiscarded() }
}

/// Re-read the bound trigger key into the router. Call after the user
/// rebinds. The router's reset reports a discarded live recording: rebinding
/// mid-dictation means the old key's up-event will never match, so the capture
Expand Down
77 changes: 36 additions & 41 deletions App/Blurt/Blurt/Overlay/OverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ private enum OverlayBrandPalette {
struct OverlayView: View {
// The single source of pill state. The live mic level is *not* read in this
// body: that would rebuild the whole pill (capsule, shadow, REC tag) on
// every ~30 Hz meter tick. Only `bridge.state` is read here (via `state`
// every ~20 Hz meter tick. Only `bridge.state` is read here (via `state`
// below); the leaf bar view (`WaveformBarsLevel`) reads `bridge.level`, so
// @Observable confines the per-tick invalidation to the bars — the rest of
// the pill stays stable.
Expand Down Expand Up @@ -176,6 +176,34 @@ private struct StatusLineText: View {
}
}

/// Redraw cap for the pill's continuous animations — the REC dot's pulse, the
/// "Transcribing…" breath, and the waveform's idle wave.
///
/// Read from the engine rather than restated: the cap exists *because* of the mic
/// meter's cadence (the level feed and these slow sines can't show anything
/// faster, so rendering at the display's full refresh rate — up to 120 Hz on
/// ProMotion — would only burn energy), so it tracks that one number instead of
/// duplicating it behind a comment that could go stale.
private let overlayAnimationInterval = MicCapture.meterIntervalSeconds

extension View {
/// Raised-cosine opacity breathing over `period`: 1 → `minOpacity` → 1, so the
/// view eases through the dim point instead of bouncing off it. Shared by the
/// REC dot and the "Transcribing…" label so the pill's two heartbeats stay one
/// curve. With `animated` false (Reduce Motion) the view renders untouched.
@ViewBuilder
func pulsingOpacity(period: Double, minOpacity: Double, animated: Bool) -> some View {
if animated {
TimelineView(.animation(minimumInterval: overlayAnimationInterval)) { timeline in
let osc = (cos(timeline.date.timeIntervalSinceReferenceDate / period * 2 * .pi) + 1) / 2 // 0...1
self.opacity(minOpacity + (1 - minOpacity) * osc)
}
} else {
self
}
}
}

/// The "Transcribing…" status line with a slow breathing pulse — the processing
/// counterpart of the recording bars' idle shimmer, so the pill keeps visibly
/// working while the app waits on the Sync API and pastes the result. Driven by
Expand All @@ -195,30 +223,14 @@ private struct TranscribingLabel: View {
private let minOpacity: Double = 0.55

var body: some View {
if animated {
// ~20 Hz is plenty for a 1.8 s opacity ramp — rendering at the display's
// full refresh rate would only burn energy (same reasoning as the 30 Hz
// cap on WaveformBars).
TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { timeline in
label.opacity(breathOpacity(at: timeline.date.timeIntervalSinceReferenceDate))
}
} else {
label
}
label.pulsingOpacity(period: breathPeriod, minOpacity: minOpacity, animated: animated)
}

// Shared with the "Pasted" notice (OverlayView's `.pasted` case) so the
// processing → pasted hand-off reads as one status line.
private var label: some View {
StatusLineText("Transcribing…")
}

/// Raised cosine over `breathPeriod`: 1 → `minOpacity` → 1, so the label
/// eases through the dim point instead of bouncing off it.
private func breathOpacity(at time: TimeInterval) -> Double {
let osc = (cos(time / breathPeriod * 2 * .pi) + 1) / 2 // 0...1
return minOpacity + (1 - minOpacity) * osc
}
}

/// The "● REC" recording tag: a pulsing magenta dot + "REC" caption, sitting to
Expand Down Expand Up @@ -250,32 +262,18 @@ private struct RecordingTag: View {
}

/// The magenta record dot, breathing while recording.
@ViewBuilder private var dot: some View {
if animated {
// ~20 Hz is plenty for a 1.2 s opacity ramp (same cap as WaveformBars).
TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { timeline in
circle.opacity(pulseOpacity(at: timeline.date.timeIntervalSinceReferenceDate))
}
} else {
circle
}
private var dot: some View {
circle.pulsingOpacity(period: pulsePeriod, minOpacity: minOpacity, animated: animated)
}

private var circle: some View {
Circle()
.fill(OverlayBrandPalette.magenta)
.frame(width: 5, height: 5)
}

/// Raised cosine over `pulsePeriod`: 1 → `minOpacity` → 1, easing through the
/// dim point instead of bouncing off it (matches TranscribingLabel).
private func pulseOpacity(at time: TimeInterval) -> Double {
let osc = (cos(time / pulsePeriod * 2 * .pi) + 1) / 2 // 0...1
return minOpacity + (1 - minOpacity) * osc
}
}

/// The only view that reads `bridge.level`, so @Observable scopes the ~30 Hz
/// The only view that reads `bridge.level`, so @Observable scopes the ~20 Hz
/// meter invalidation to this leaf (and its `WaveformBars` child) instead of the
/// enclosing `OverlayView`. `WaveformBars` stays a pure value view — easy to
/// reason about and drive from a fixed level — with the observation isolated
Expand Down Expand Up @@ -331,11 +329,8 @@ private struct WaveformBars: View {
Group {
if animated {
// Continuous clock so the idle breathing is smooth and never depends
// on a one-shot state toggle. Capped at ~20 Hz to match the mic meter
// (MicCapture.meterInterval) — the level feed and the slow breathing
// sine can't show anything faster, so rendering at the display's full
// refresh rate (up to 120 Hz on ProMotion) would only burn energy.
TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { timeline in
// on a one-shot state toggle; capped at `overlayAnimationInterval`.
TimelineView(.animation(minimumInterval: overlayAnimationInterval)) { timeline in
row(count: count, maxBarHeight: maxBarHeight, time: timeline.date.timeIntervalSinceReferenceDate)
}
} else {
Expand All @@ -361,7 +356,7 @@ private struct WaveformBars: View {
let weight = envelopeWeight(index, count: count)
// Voice-driven height: the current level, gamma-shaped, scaled by this bar's
// envelope weight so the middle leads.
let voice = pow(CGFloat(max(0, min(1, level))), levelGamma) * weight
let voice = pow(CGFloat(level), levelGamma) * weight
var breath: CGFloat = 0
// Full breathing when quiet, fading to none once the voice is moderate. Once
// it's faded out (voice ≥ 0.25) the per-bar sine below would multiply to ~0,
Expand Down
Loading