From 8bb40681ee4331c903cea02cab320cf99ce9a3c8 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Fri, 9 Oct 2026 13:25:09 -0700 Subject: [PATCH 1/2] Name the silent microphone on the pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the liveness gate fails closed, the pill said only "Error", with "The microphone didn't start." on hover — which sent users hunting for a Blurt bug when the cause was their audio setup (an aggregate whose USB interface was unplugged, leaving a silent BlackHole as the default input). The failure now carries the device name, and the pill says it: "AGGREGATE DEVICE ISN'T SENDING ANY AUDIO". - MicCaptureError.inputNeverDelivered(deviceName:), looked up only on the failure path; BlurtError drops the "Audio capture failed:" prefix for MicCaptureError, whose messages already read as sentences. - OverlayUIState.inputSilent owns the wording: pillNotice (subject + predicate) and the sentence form for VoiceOver/hover; 3 s dwell. - The overlay widens the panel for this notice only (centred, capped at 340 pt, clamped on screen), swaps under a panel fade because the window resizes a frame before SwiftUI redraws, and returns to the saved placement after. Long names truncate in the middle; the predicate never truncates and nothing shrinks. Co-Authored-By: Claude Opus 5.5 --- .../Blurt/Overlay/OverlayPillContent.swift | 26 +++++- App/Blurt/Blurt/Overlay/OverlayView.swift | 34 ++++++- .../Overlay/OverlayWindowController.swift | 91 ++++++++++++++++++- .../BlurtEngine/Audio/AudioInputDevices.swift | 8 ++ Sources/BlurtEngine/Audio/MicCapture.swift | 6 +- .../BlurtEngine/Audio/MicCaptureError.swift | 12 ++- Sources/BlurtEngine/BlurtError.swift | 4 + .../BlurtEngine/Pipeline/OverlayUIState.swift | 37 +++++++- .../BlurtEngine/Pipeline/PipelinePhase.swift | 5 +- .../MicCaptureFormatTests.swift | 19 +++- .../OverlayUIStateTests.swift | 38 +++++++- 11 files changed, 261 insertions(+), 19 deletions(-) diff --git a/App/Blurt/Blurt/Overlay/OverlayPillContent.swift b/App/Blurt/Blurt/Overlay/OverlayPillContent.swift index 068959dc..7f2fe633 100644 --- a/App/Blurt/Blurt/Overlay/OverlayPillContent.swift +++ b/App/Blurt/Blurt/Overlay/OverlayPillContent.swift @@ -16,19 +16,37 @@ struct StatusLineText: View { /// The text color — brand green for every state but `.error`, which the /// design gives its own orange rather than a red pill body. var color: Color = BlurtBrand.greenOnDark + /// Where an over-long line is cut. The tail, except for a device name, which + /// keeps its distinguishing end ("…Scarlett Solo USB"). + var truncation: Text.TruncationMode = .tail + /// Whether an over-long line shrinks before it truncates. Fine for a status + /// word; a sentence shrunk to 70% of 9 pt is unreadable, so a written-out + /// notice truncates at full size instead. + var shrinksToFit = true - init(_ text: String, color: Color = BlurtBrand.greenOnDark) { + init( + _ text: String, color: Color = BlurtBrand.greenOnDark, + truncation: Text.TruncationMode = .tail, shrinksToFit: Bool = true + ) { self.text = text self.color = color + self.truncation = truncation + self.shrinksToFit = shrinksToFit } + /// The status-line type, named so `OverlayWindowController.pillWidth(for:)` + /// measures a written-out notice with exactly what draws it. + static let fontSize: CGFloat = 9 + static let tracking: CGFloat = 0.9 + var body: some View { Text(text) - .font(.system(size: 9, weight: .semibold)) + .font(.system(size: Self.fontSize, weight: .semibold)) .textCase(.uppercase) - .tracking(0.9) + .tracking(Self.tracking) .lineLimit(1) - .minimumScaleFactor(0.7) + .truncationMode(truncation) + .minimumScaleFactor(shrinksToFit ? 0.7 : 1) .foregroundStyle(color) } } diff --git a/App/Blurt/Blurt/Overlay/OverlayView.swift b/App/Blurt/Blurt/Overlay/OverlayView.swift index de8e8001..b044dae5 100644 --- a/App/Blurt/Blurt/Overlay/OverlayView.swift +++ b/App/Blurt/Blurt/Overlay/OverlayView.swift @@ -34,7 +34,9 @@ struct OverlayView: View { var body: some View { content - .frame(width: pillSize.width, height: pillSize.height) + // The width is the bridge's: standard, or widened by the controller for a + // notice that writes its reason on the pill (`OverlayUIState.pillText`). + .frame(width: bridge.pillWidth, height: pillSize.height) // Solid, opaque capsule rather than Liquid Glass: a fixed fill never adapts // to the backdrop, so the pill fades in the same dark gray everywhere with // no first-frame flash (see `fillColor`). `.animation` below cross-fades the @@ -57,7 +59,10 @@ struct OverlayView: View { // edge rather than clipping into a line. .compositingGroup() .shadow(color: .black.opacity(0.25), radius: 10, y: 3) - .animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: state) + // No cross-fade for a change that resizes the pill — the controller fades + // the whole panel over that swap instead (`OverlayBridge.animatesStateChange`). + .animation( + reduceMotion || !bridge.animatesStateChange ? nil : .easeInOut(duration: 0.15), value: state) .contentShape(Rectangle()) // Transparent margin so the shadow has room to render without being // clipped by the panel's contentRect (most visible at the rounded ends). @@ -130,6 +135,27 @@ struct OverlayView: View { StatusLineText("Pasted") .transition(.opacity) .help(state.accessibilityLabel) + case .inputSilent: + // The silent-mic notice: the device name is the diagnosis, so it goes on + // the pill itself — the controller has widened the capsule to fit — in the + // same orange as the "Error" word. + // + // Two runs, so a device name too long for the capped pill is cut in its + // middle while "isn't sending any audio" — the part that says what's wrong — + // always shows in full, and neither shrinks. + if let notice = state.pillNotice { + HStack(spacing: 0) { + StatusLineText( + notice.subject + " ", color: BlurtBrand.errorOrange, + truncation: .middle, shrinksToFit: false) + StatusLineText(notice.predicate, color: BlurtBrand.errorOrange, shrinksToFit: false) + .fixedSize() + .layoutPriority(1) + } + .padding(.horizontal, Self.errorTextInset) + .transition(.opacity) + .help(state.accessibilityLabel) + } case .noTarget: // Quiet, informational notice: there was no text field to type into, so // the transcript went to the clipboard. Styled exactly like "Pasted" @@ -142,6 +168,10 @@ struct OverlayView: View { } } + /// Horizontal inset of a notice written out on the pill. Internal so + /// `OverlayWindowController.pillWidth(for:)` sizes the pill with the same value. + static let errorTextInset: CGFloat = 14 + /// The pill's inner inset and the gap between the orb and what follows it. private static let contentInset: CGFloat = 12 private static let contentSpacing: CGFloat = 8 diff --git a/App/Blurt/Blurt/Overlay/OverlayWindowController.swift b/App/Blurt/Blurt/Overlay/OverlayWindowController.swift index 59895afa..e8fafc42 100644 --- a/App/Blurt/Blurt/Overlay/OverlayWindowController.swift +++ b/App/Blurt/Blurt/Overlay/OverlayWindowController.swift @@ -9,6 +9,17 @@ final class OverlayBridge { /// The latest mic loudness, 0...1 (MicCapture.linearLevel). The overlay's /// voice bars track this current value — there is no scrolling history. var level: Float = 0 + /// The capsule's current width: `OverlayWindowController.pillSize.width`, + /// except while a notice that writes its reason on the pill is up (see + /// `OverlayWindowController.pillWidth(for:)`). + var pillWidth: CGFloat = OverlayWindowController.pillSize.width + /// Whether the view cross-fades its latest state change. False for a change + /// that also resizes the pill: the panel and the capsule can't move in step, + /// so that swap happens in one step under a fade of the whole panel instead. + /// A flag the view reads rather than a `withTransaction`, because this + /// `@Observable` reaches the view in a later update, after any transaction + /// set here has ended. + var animatesStateChange = true func pushLevel(_ value: Float) { // `value` arrives on the fixed 0...1 scale `MicCaptureProtocol.levels` @@ -52,6 +63,10 @@ final class OverlayWindowController { // `shadowMargin` is checked on both sides rather than only one. private static let panelSize = OverlayPlacement.panelSize( pillSize: pillSize, shadowMargin: shadowMargin) + // Ceiling for a pill that writes its reason out (`OverlayUIState.pillText`): + // room for the sentence with an ordinary device name; a longer one truncates + // rather than stretching the pill across the screen. + private static let maxPillWidth: CGFloat = 340 private let panel: NSPanel private let hosting: NSHostingView @@ -77,6 +92,11 @@ final class OverlayWindowController { self.hosting = NSHostingView(rootView: OverlayView(bridge: bridge)) self.hosting.wantsLayer = true self.hosting.layer?.backgroundColor = .clear + // This controller sizes the panel (`resizePanel(toPillWidth:)`), keeping the + // pill centred as it widens. Left at its default, the hosting view would also + // resize the window to fit the content whenever the width changed, anchored at + // a corner. + self.hosting.sizingOptions = [] self.panel = FloatingPanel.make( size: Self.panelSize, collectionBehavior: [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary], @@ -143,7 +163,20 @@ final class OverlayWindowController { // a value that hadn't moved. The notice handling below stays outside the guard: // a repeated notice still has to announce and re-arm its revert. if bridge.state != state { - bridge.state = state + let width = Self.pillWidth(for: state) + let resizes = width != bridge.pillWidth + bridge.animatesStateChange = !resizes + if resizes { + // The window resizes a frame before SwiftUI redraws at the new size, so + // swap under a transparent panel; `setVisible(true)` below fades it back + // in with the pill's usual appear ramp. + if panel.isVisible { panel.alphaValue = 0 } + bridge.pillWidth = width + bridge.state = state + resizePanel(toPillWidth: width) + } else { + bridge.state = state + } } // The red error flash and the neutral "copied" notice are both transient: the // pill is otherwise only up during active dictation, so they linger briefly to @@ -204,10 +237,12 @@ final class OverlayWindowController { /// the screen: an empty capsule still animating after the dismiss. The pill's /// only fade is the alpha ramp in `setVisible`, so this settle is instantaneous. private func settleContent() { + defer { resizePanel(toPillWidth: Self.pillSize.width) } var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { bridge.state = .idle + bridge.pillWidth = Self.pillSize.width // Clear the level too, or the pill's next appearance renders its bars at the // PREVIOUS dictation's loudness until the first new meter tick (~50 ms) // replaces it — a one-frame "already talking" flash at the start of every @@ -268,6 +303,55 @@ final class OverlayWindowController { bridge.pushLevel(value) } + // MARK: - Pill width + + /// The pill's width for `state`: the compact standard width, or — for a notice + /// that writes its reason out — wide enough for that text in the status-line + /// type, capped at `maxPillWidth`. Measured with the same font and tracking + /// `StatusLineText` draws with, so the text fits without scaling down. + static func pillWidth(for state: OverlayUIState) -> CGFloat { + guard let text = state.pillText else { return pillSize.width } + let measured = NSAttributedString( + string: text.uppercased(), + attributes: [ + .font: NSFont.systemFont(ofSize: StatusLineText.fontSize, weight: .semibold), + .kern: StatusLineText.tracking, + ] + ).size().width + // The same few points of headroom the standard width keeps for its longest + // word (see `pillSize`), so Bold Text or a font-metric change truncates + // nothing at an ordinary device name. + let width = (measured + OverlayView.errorTextInset * 2 + 8).rounded(.up) + return min(max(width, pillSize.width), maxPillWidth) + } + + /// How much wider than standard the panel is right now. The persisted drag + /// origin is always the *standard* panel's, so the two places that cross that + /// boundary — placing the panel and saving a drag — shift by half of this. + private var extraPanelWidth: CGFloat { panel.frame.width - Self.panelSize.width } + + /// Resizes the panel to hold a `width`-wide pill. Widening grows it evenly on + /// both sides, so the pill stays centred where it was, then clamps it onto the + /// screen — a pill dragged near an edge would otherwise push its notice off + /// it. Returning to the standard width goes back to the pill's own placement + /// (`reposition`), not the centre of a notice that may have been clamped. A + /// programmatic move either way, so it isn't persisted as a drag. + private func resizePanel(toPillWidth width: CGFloat) { + let delta = width + Self.shadowMargin * 2 - panel.frame.width + guard delta != 0 else { return } + var frame = panel.frame + frame.origin.x -= delta / 2 + frame.size.width += delta + if width > Self.pillSize.width, let screen = panel.screen ?? NSScreen.main { + frame.origin = OverlayPlacement.clamped( + origin: frame.origin, size: frame.size, into: screen.visibleFrame) + } + suppressOriginPersist = true + panel.setFrame(frame, display: false) + suppressOriginPersist = false + if width == Self.pillSize.width { reposition() } + } + private func reposition() { guard let screen = NSScreen.main else { return } // All the placement policy — default bottom-center, the clearance, the @@ -277,7 +361,7 @@ final class OverlayWindowController { let origin = OverlayPlacement.panelOrigin( panelSize: panel.frame.size, visibleFrame: screen.visibleFrame, - customOrigin: Self.originStore.origin, + customOrigin: Self.originStore.origin.map { CGPoint(x: $0.x - extraPanelWidth / 2, y: $0.y) }, shadowMargin: Self.shadowMargin) suppressOriginPersist = true panel.setFrameOrigin(origin) @@ -286,7 +370,8 @@ final class OverlayWindowController { private func handleDidMove() { guard !suppressOriginPersist else { return } - Self.originStore.origin = panel.frame.origin + let origin = panel.frame.origin + Self.originStore.origin = CGPoint(x: origin.x + extraPanelWidth / 2, y: origin.y) } /// Persistence for the dragged origin lives in the engine next to the clamping diff --git a/Sources/BlurtEngine/Audio/AudioInputDevices.swift b/Sources/BlurtEngine/Audio/AudioInputDevices.swift index 3f7bb08b..d36b82bd 100644 --- a/Sources/BlurtEngine/Audio/AudioInputDevices.swift +++ b/Sources/BlurtEngine/Audio/AudioInputDevices.swift @@ -59,6 +59,14 @@ public enum AudioInputDevices { systemDefaultDevice?.localizedName } + /// The name of the input a press records from — the pinned device, or the + /// system default when there is no pin (`MicCapture.ResolvedInput` has already + /// folded a missing pin into nil) — or nil when that device can't be found. + static func inputName(pinnedUID: String?) -> String? { + guard let pinnedUID else { return systemDefaultInputName() } + return device(forUID: pinnedUID)?.localizedName + } + /// The transport type of the device carrying this UID, or nil when no /// connected device does. /// diff --git a/Sources/BlurtEngine/Audio/MicCapture.swift b/Sources/BlurtEngine/Audio/MicCapture.swift index 0b8b76f4..213a3520 100644 --- a/Sources/BlurtEngine/Audio/MicCapture.swift +++ b/Sources/BlurtEngine/Audio/MicCapture.swift @@ -200,7 +200,11 @@ public actor MicCapture: MicCaptureProtocol { // so no stopGeneration bump — a concurrent stop() correctly sees no active // capture. guard gap != nil else { - throw BlurtError.audioCaptureFailed(underlying: MicCaptureError.inputNeverDelivered) + // Named only here, on the failure path, so a press that succeeds pays no + // extra device lookup. + throw BlurtError.audioCaptureFailed( + underlying: MicCaptureError.inputNeverDelivered( + deviceName: AudioInputDevices.inputName(pinnedUID: resolved.pinnedUID))) } activeRecorder = recorder diff --git a/Sources/BlurtEngine/Audio/MicCaptureError.swift b/Sources/BlurtEngine/Audio/MicCaptureError.swift index 1455abde..a85a2b70 100644 --- a/Sources/BlurtEngine/Audio/MicCaptureError.swift +++ b/Sources/BlurtEngine/Audio/MicCaptureError.swift @@ -18,12 +18,20 @@ enum MicCaptureError: LocalizedError { /// `MicLiveness`'s cap — the device delivered nothing (or only digital /// silence) for the whole wait, so the press fails closed rather than /// recording an utterance the mic isn't receiving. - case inputNeverDelivered + /// + /// Carries the silent device's name, because the device is usually the whole + /// story: an aggregate whose real interface is unplugged, or a loopback like + /// BlackHole set as the default input, records nothing while every other mic + /// works. "The microphone didn't start" sent users hunting for a Blurt bug; + /// naming the device points them at their audio settings. Nil when the name + /// can't be read (the device vanished mid-press). + case inputNeverDelivered(deviceName: String?) var errorDescription: String? { switch self { case .noInputDevice: "No microphone is available." - case .inputNeverDelivered: "The microphone didn't start." + case .inputNeverDelivered(let deviceName?): "\(deviceName) isn't sending any audio." + case .inputNeverDelivered(nil): "The microphone isn't sending any audio." } } } diff --git a/Sources/BlurtEngine/BlurtError.swift b/Sources/BlurtEngine/BlurtError.swift index d6a8c328..a1a4b00a 100644 --- a/Sources/BlurtEngine/BlurtError.swift +++ b/Sources/BlurtEngine/BlurtError.swift @@ -71,6 +71,10 @@ extension BlurtError: LocalizedError { case .apiKeyMissing: "Add your AssemblyAI API key in Settings to start dictating." case .sttFailed(let underlying): "Transcription failed: \(underlying.localizedDescription)" case .targetAppLost: "Target app lost focus or quit." + // `MicCaptureError`'s messages are already written for the user and name the + // problem, so the prefix would only bury the device name; anything else a + // capture throws keeps it as context. + case .audioCaptureFailed(let underlying as MicCaptureError): underlying.localizedDescription case .audioCaptureFailed(let underlying): "Audio capture failed: \(underlying.localizedDescription)" case .noEditableTarget: "No text field was focused — copied to the clipboard instead." } diff --git a/Sources/BlurtEngine/Pipeline/OverlayUIState.swift b/Sources/BlurtEngine/Pipeline/OverlayUIState.swift index 4b29e487..25d6fe6a 100644 --- a/Sources/BlurtEngine/Pipeline/OverlayUIState.swift +++ b/Sources/BlurtEngine/Pipeline/OverlayUIState.swift @@ -19,6 +19,13 @@ public enum OverlayUIState: Equatable, Sendable { /// tooltip and VoiceOver announcement so the failure isn't an unexplained /// red dot. case error(message: String) + /// The press failed because the input device delivered only silence (the + /// liveness gate's fail-closed outcome). Split from `.error` because the + /// device name is the whole diagnosis — an aggregate whose real interface is + /// unplugged, or a loopback like BlackHole set as the default input — and a + /// hover tooltip hides it. The shell widens the pill to show `pillText` for + /// this notice alone; every other failure keeps the compact "Error" pill. + case inputSilent(deviceName: String?) /// Transcription succeeded and the text was pasted into the focused field. The /// shell shows this as a brief, neutral "Pasted" notice before settling to /// `.idle` — the mirror of the `.noTarget` "Copied" notice for the paste path. @@ -39,6 +46,8 @@ public enum OverlayUIState: Equatable, Sendable { case .recording: "Recording." case .processing: "Processing." case .error(let message): message + case .inputSilent(let deviceName): + MicCaptureError.inputNeverDelivered(deviceName: deviceName).errorDescription ?? "" case .pasted: "Your dictation was pasted." case .noTarget: "No text field focused. Your dictation was copied to the clipboard." } @@ -55,9 +64,28 @@ public enum OverlayUIState: Equatable, Sendable { switch self { case .pasted: 0.8 case .error, .noTarget: 1.6 + // A sentence, not a word: long enough to read the device name. + case .inputSilent: 3.0 case .idle, .connecting, .recording, .processing: nil } } + + /// The words written on the pill for a notice that spells out its reason, or + /// nil for the states the pill shows as a status word or meter. Split so the + /// shell can truncate the subject (a device name of any length) and never the + /// predicate, which is what says what went wrong. A status line, so no closing + /// period — the sentence form is `accessibilityLabel`. + public var pillNotice: (subject: String, predicate: String)? { + switch self { + case .inputSilent(let deviceName): (deviceName ?? "The microphone", "isn't sending any audio") + case .idle, .connecting, .recording, .processing, .error, .pasted, .noTarget: nil + } + } + + /// `pillNotice` as the one line it reads as — what the shell measures. + public var pillText: String? { + pillNotice.map { "\($0.subject) \($0.predicate)" } + } } extension PipelinePhase { @@ -88,7 +116,14 @@ extension PipelinePhase { // classification is `PipelinePhase.setupBlocker`, so this and the shell's // navigation can't disagree about which failures are setup states. case .failed(let error) where error.isSetupBlocker: .idle - case .failed(let error): .error(message: error.errorDescription ?? "Dictation failed.") + case .failed(let error): + if case .audioCaptureFailed(let underlying as MicCaptureError) = error, + case .inputNeverDelivered(let deviceName) = underlying + { + .inputSilent(deviceName: deviceName) + } else { + .error(message: error.errorDescription ?? "Dictation failed.") + } case .pasted: .pasted case .noTarget: .noTarget } diff --git a/Sources/BlurtEngine/Pipeline/PipelinePhase.swift b/Sources/BlurtEngine/Pipeline/PipelinePhase.swift index 1911bc0f..948a1907 100644 --- a/Sources/BlurtEngine/Pipeline/PipelinePhase.swift +++ b/Sources/BlurtEngine/Pipeline/PipelinePhase.swift @@ -114,7 +114,10 @@ extension BlurtError: Equatable { // rather than `localizedDescription`. The description is human-facing copy: // comparing it would make equality silently depend on message wording, so a // localization or phrasing tweak could break a test. Domain + code is the - // stable identity of the underlying error. + // stable identity of the underlying error. Payloads are ignored too — + // `inputNeverDelivered` on two different devices compares equal — so don't + // dedupe phases on `==` where the device name matters; the overlay compares + // `OverlayUIState`, which carries it. let lhsError = lhsUnderlying as NSError let rhsError = rhsUnderlying as NSError return lhsError.domain == rhsError.domain && lhsError.code == rhsError.code diff --git a/Tests/BlurtEngineTests/MicCaptureFormatTests.swift b/Tests/BlurtEngineTests/MicCaptureFormatTests.swift index a7f3acd6..edf89bfe 100644 --- a/Tests/BlurtEngineTests/MicCaptureFormatTests.swift +++ b/Tests/BlurtEngineTests/MicCaptureFormatTests.swift @@ -71,10 +71,21 @@ struct MicCaptureFormatTests { #expect(MicCaptureError.noInputDevice.errorDescription == "No microphone is available.") } - @Test func inputNeverDeliveredHasHumanReadableMessage() { + @Test func inputNeverDeliveredNamesTheSilentDevice() { // The liveness gate's fail-closed outcome — same route to the pill as - // noInputDevice above, and the same sentence requirement. Together the two - // also keep MicCaptureError.swift's errorDescription fully covered. - #expect(MicCaptureError.inputNeverDelivered.errorDescription == "The microphone didn't start.") + // noInputDevice above, and the same sentence requirement. Naming the device + // is the point: a silent aggregate or loopback input is a settings problem, + // and the name is what tells the user which setting. + #expect( + MicCaptureError.inputNeverDelivered(deviceName: "Aggregate Device").errorDescription + == "Aggregate Device isn't sending any audio.") + } + + @Test func inputNeverDeliveredWithoutANameStillReadsAsASentence() { + // The device vanished before its name could be read. Together with the two + // above, keeps MicCaptureError.swift's errorDescription fully covered. + #expect( + MicCaptureError.inputNeverDelivered(deviceName: nil).errorDescription + == "The microphone isn't sending any audio.") } } diff --git a/Tests/BlurtEngineTests/OverlayUIStateTests.swift b/Tests/BlurtEngineTests/OverlayUIStateTests.swift index 7c9f3e43..a5f7b811 100644 --- a/Tests/BlurtEngineTests/OverlayUIStateTests.swift +++ b/Tests/BlurtEngineTests/OverlayUIStateTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import BlurtEngine @@ -53,7 +54,42 @@ struct OverlayUIStateTests { // the reason reaches the user (hover tooltip + VoiceOver) instead of an // unexplained red flash. let phase = PipelinePhase.failed(.audioCaptureFailed(underlying: MicCaptureError.noInputDevice)) - #expect(phase.overlayState == .error(message: "Audio capture failed: No microphone is available.")) + #expect(phase.overlayState == .error(message: "No microphone is available.")) + } + + @Test func silentMicFailureNamesTheDeviceOnThePill() { + // Its own state rather than `.error`, so the shell can widen the pill and + // show the device name — the whole diagnosis — instead of hiding it on hover. + let error = MicCaptureError.inputNeverDelivered(deviceName: "Aggregate Device") + let state = PipelinePhase.failed(.audioCaptureFailed(underlying: error)).overlayState + #expect(state == .inputSilent(deviceName: "Aggregate Device")) + #expect(state.pillText == "Aggregate Device isn't sending any audio") + #expect(state.pillNotice?.subject == "Aggregate Device") + #expect(state.pillNotice?.predicate == "isn't sending any audio") + #expect(state.accessibilityLabel == "Aggregate Device isn't sending any audio.") + #expect(state.noticeDwellSeconds != nil) + } + + @Test func silentMicFailureWithoutANameStillSaysWhatHappened() { + let state = OverlayUIState.inputSilent(deviceName: nil) + #expect(state.pillText == "The microphone isn't sending any audio") + #expect(state.accessibilityLabel == "The microphone isn't sending any audio.") + } + + @Test func onlyTheSilentMicNoticeWritesOnThePill() { + // Every other state is a status word or the meter; a pill-text there would + // widen the pill for it too. + let others: [OverlayUIState] = [ + .idle, .connecting, .recording, .processing, .error(message: "x"), .pasted, .noTarget, + ] + #expect(others.allSatisfy { $0.pillText == nil && $0.pillNotice == nil }) + } + + @Test func foreignCaptureErrorsKeepTheirContext() { + // A conformer's untyped error has no user-facing framing of its own. + let error = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Boom."]) + let phase = PipelinePhase.failed(.audioCaptureFailed(underlying: error)) + #expect(phase.overlayState == .error(message: "Audio capture failed: Boom.")) } @Test func missingKeyFailureMapsToIdle() { From 9373590f8b03510910a383c4f1e303b0303a9bcb Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Fri, 9 Oct 2026 14:33:02 -0700 Subject: [PATCH 2/2] Handle the silent-mic overlay state on the iPhone The new OverlayUIState.inputSilent case left three iPhone switches non-exhaustive. The iPhone has no widened pill, so it reads as an error whose message is the sentence naming the device. Also wraps an over-long .animation line for swift-format. Co-Authored-By: Claude Opus 5.5 --- App/Blurt/Blurt/Overlay/OverlayView.swift | 3 ++- .../BlurtiOS/Sources/DictationCoordinator.swift | 11 ++++++++--- App/BlurtiOS/BlurtiOSCore/HomeStatus.swift | 1 + App/BlurtiOS/BlurtiOSCore/VoiceState.swift | 2 ++ 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/App/Blurt/Blurt/Overlay/OverlayView.swift b/App/Blurt/Blurt/Overlay/OverlayView.swift index b044dae5..1158f572 100644 --- a/App/Blurt/Blurt/Overlay/OverlayView.swift +++ b/App/Blurt/Blurt/Overlay/OverlayView.swift @@ -62,7 +62,8 @@ struct OverlayView: View { // No cross-fade for a change that resizes the pill — the controller fades // the whole panel over that swap instead (`OverlayBridge.animatesStateChange`). .animation( - reduceMotion || !bridge.animatesStateChange ? nil : .easeInOut(duration: 0.15), value: state) + reduceMotion || !bridge.animatesStateChange ? nil : .easeInOut(duration: 0.15), value: state + ) .contentShape(Rectangle()) // Transparent margin so the shadow has room to render without being // clipped by the panel's contentRect (most visible at the rounded ends). diff --git a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift index 738df78f..4870cc27 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift @@ -281,12 +281,17 @@ final class DictationCoordinator { case .processing: .processing case .pasted: .pasted case .noTarget: .copied - case .error: .error + case .error, .inputSilent: .error } } private static func message(_ state: OverlayUIState) -> String? { - guard case .error(let message) = state else { return nil } - return message + switch state { + case .error(let message): message + // No wide pill on the iPhone: the silent mic reads as an error, its sentence + // (with the device name) as the message. + case .inputSilent: state.accessibilityLabel + case .idle, .connecting, .recording, .processing, .pasted, .noTarget: nil + } } } diff --git a/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift b/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift index 8205a17c..40fd24d3 100644 --- a/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift +++ b/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift @@ -38,6 +38,7 @@ package nonisolated struct HomeStatus: Equatable { case .pasted: "Pasted" case .noTarget: "Copied" case .error(let message): message + case .inputSilent: overlay.accessibilityLabel case .idle: windowOpen ? "Ready to dictate" : "Not listening" } } diff --git a/App/BlurtiOS/BlurtiOSCore/VoiceState.swift b/App/BlurtiOS/BlurtiOSCore/VoiceState.swift index 7f8e50f7..dc589efc 100644 --- a/App/BlurtiOS/BlurtiOSCore/VoiceState.swift +++ b/App/BlurtiOS/BlurtiOSCore/VoiceState.swift @@ -78,6 +78,8 @@ package nonisolated struct VoiceState: Equatable { case .pasted: self.init(phase: .pasted, isReady: windowOpen) case .noTarget: self.init(phase: .copied, isReady: windowOpen) case .error(let message): self.init(phase: .error, isReady: windowOpen, message: message) + case .inputSilent: + self.init(phase: .error, isReady: windowOpen, message: overlay.accessibilityLabel) } }