diff --git a/CONTEXT.md b/CONTEXT.md index 38aeffe..6a5eabc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -111,7 +111,7 @@ _Avoid_: Transcript history, cloud analytics - Releasing the **Dictation Trigger** before 250 milliseconds counts as a quick tap - A quick tap leaves **Direct Dictation** active until the next trigger press - The **Direct Dictation** HUD shows live draft text while speech continues, unless the selected voice visual replaces it; with Reduce Motion the draft text always shows -- The HUD renders volatile recognition results immediately +- The HUD renders volatile recognition results immediately, so new text can show before it commits. Compact and the Reduce Motion band draw them lighter than finalized words; Edge Glow + Draft paints both the same, because that line is a glance, not a proofread - **Direct Dictation** inserts finalized recognition text plus the latest volatile result when recognition ends before finalizing it - The **Direct Dictation** HUD expands from the physical notch on the focused input's display - Settings exposes **Appearance**, **Sounds**, and **Insights** in that order @@ -128,7 +128,7 @@ _Avoid_: Transcript history, cloud analytics - **HUD size** is a Settings slider from 20% to 100% in 5% steps, defaulting to 100%, and applies on every display rather than only where there is no notch - A display raises its own **HUD size** floor when the picked size would leave the shape narrower than the housing it descends from plus its fillets: a shape narrower than its housing reads as a tab floating under the notch, and its fillets land inside the cutout with no bezel to meet - That floor cannot be one number: a notch is a fixed physical width but its width in points moves with the scaled display mode, so the same MacBook reports roughly 155 points under More Space and 273 under Larger Text; a display with no notch keeps the slider's own minimum, which is where a smaller HUD is wanted because every point the shape covers is screen the user was working in -- A HUD that will show draft text has a second floor at 40%, where the draft is still 6 points: Compact is built around the live draft and Reduce Motion restores it for every visual, so both stop there while Waveform and Edge Glow, which replace the draft entirely, go down to 20% +- A HUD that will show draft text has a second floor at 40%, where the draft is still 6 points: Compact and Edge Glow + Draft are built around the live draft and Reduce Motion restores it for every visual, so those stop there while Waveform and Edge Glow, which replace the draft entirely, go down to 20% - The **HUD size** slider offers only the sizes the selected voice visual can be shown at, and the higher of the two floors wins whenever both apply - A size below the selected visual's floor stays persisted and returns when a visual that can show it is picked again - The housing band and the fillets never scale with **HUD size**: the band's height is the physical notch on a notched display and the menu bar's clearance elsewhere, and a fillet exists to meet a physical bezel @@ -142,7 +142,7 @@ _Avoid_: Transcript history, cloud analytics - HUD status and error messages dismiss themselves after about two seconds - The HUD writes nothing while it retracts and its bands do not resize: the shape leaves exactly as it stood, and its text and layout reset only once it is off screen - The wait between the last word and the recognized text is silent — no label stands in for it, and the voice visual comes to rest rather than reading the silence as a dead microphone -- Compact opens with no text at all, because it is built around the live draft and a placeholder would be words nobody spoke +- Compact and Edge Glow + Draft open with no text at all, because they are built around the live draft and a placeholder would be words nobody spoke - The HUD's shaders and the particle cloud's compute kernels are compiled at launch, so the cost never lands on the first frames of a session - Talkify holds a user-initiated activity assertion while a **Direct Dictation** session or a **Drop Transcription** job runs, and holds none while idle: a menu-bar-only app with no window is what App Nap targets, and a napped process draws the HUD's animation clock too slowly to watch - The assertion is taken before the begin guards run, because the frames a napped process draws badly are the first ones; every guard that refuses a session releases it again @@ -157,8 +157,9 @@ _Avoid_: Transcript history, cloud analytics - The device that was lowered is the one restored, not whichever output is default when the session ends: the default can change mid-session, and the volume that needs putting back belongs to the device that was quieted - An output with no settable volume does not duck, and neither does one already at silence - A third voice visual, Compact, shows a small five-bar voice indicator beside the leading-aligned live draft inside the shape, after the iOS Dynamic Island caption look; the shape grows with the draft -- Compact is the only visual that shows the live draft while listening; Waveform and Edge Glow replace the draft text entirely -- An In the Shape live-draft placement for Waveform and Edge Glow was prototyped and removed; the draft-in-shape presentation belongs to Compact alone +- A fourth voice visual, Edge Glow + Draft, keeps a live draft of the last eight word tokens at 24-point type, centered in the island (housing plus a 40-point stage), and wraps the shape in Edge Glow. It has no Chart Line ribbon, particle cloud, or orb. Each visible word keeps a stable identity: a new token slides in from the right, a departing token slides off to the left, and a finalization rewrites the same token in place. A line that does not fit clips the oldest words rather than ellipsizing each one. Insertion still uses the full transcript. +- Compact and Edge Glow + Draft show the live draft while listening; Waveform and Edge Glow replace the draft text entirely +- An In the Shape live-draft placement for Waveform and Edge Glow was prototyped and removed; putting words on the concert waveform stays rejected. Edge Glow + Draft is the chosen live-draft direction: the draft has its own hanging stage in one shape, without a Chart Line ribbon - A detached child pill under the island (the uni-pills goo bubble) was prototyped as a third placement and rejected by feel: a second detached surface does not fit the single-shape design - Reduce Motion overrides every live draft placement with the plain text band and quiet level meter - `AppSettings` is the only persisted source of truth for preferences; the Dictation controller creates Dictation session settings for the HUD at session start @@ -276,7 +277,7 @@ _Avoid_: Transcript history, cloud analytics - **Insights** shows words, sessions, speaking time, weighted words per minute, Voice Momentum, 14-day activity, streaks, and a 16-week heatmap - Settings lets the user choose the session sounds, mute them, adjust their volume, choose the voice-reactive visual, the waveform style, and the edge glow's color palette - Insertion latency must be benchmarked before choosing permanent per-application defaults -- Mid-session insertion was prototyped (streamed finalized chunks; a ghost overlay over the focused input) and rejected: insertion stays at session end, and no live-draft direction for Waveform and Edge Glow is chosen yet +- Mid-session insertion was prototyped (streamed finalized chunks; a ghost overlay over the focused input) and rejected: insertion stays at session end. The live-draft direction for combining Waveform and Edge Glow with the draft is Edge Glow + Draft, not mid-session insertion and not words over the concert waveform - A marked-text input method (system-dictation-style provisional text in any field) remains a considered route, deferred: it needs a separate installable input-source bundle, System Settings enablement, and cross-process wiring - **Read Aloud** speaks the focused application's selected text with Apple speech synthesis, on-device and offline; Siri voices are unavailable to third-party apps - Read Aloud reads the selection through Accessibility, and by copying it when Accessibility answers nothing, which is what every browser does; if neither finds anything it shows "No text selected" through the HUD and speaks nothing @@ -406,7 +407,7 @@ _Avoid_: Transcript history, cloud analytics - The sidebar retains the `SETTINGS` group label even when the header also shows Settings; it distinguishes navigation groups - The Talkify ghost is the app icon, the status item icon (template-rendered so it follows the menu bar theme), and the Settings header icon - While Direct Dictation listens, the status item ghost pulses between full and dimmed tint; the pulse stops when the session ends -- An Edge Glow session tints the status ghost pulse with its captured palette's accent; Waveform and Compact sessions keep the theme tint, and the idle ghost stays template-rendered +- An Edge Glow or Edge Glow + Draft session tints the status ghost pulse with its captured palette's accent; Waveform and Compact sessions keep the theme tint, and the idle ghost stays template-rendered - The Settings surface uses a 16-point continuous radius, a one-pixel low-opacity border, and a soft shadow - Settings may open during Direct Dictation; the active session remains on its captured settings and changes apply next session - While Direct Dictation is active, Settings shows a small contextual notice that changes apply to the next session; idle Settings has no notice @@ -423,5 +424,5 @@ _Avoid_: Transcript history, cloud analytics - Settings navigation uses a fixed Talkify blue accent; selected Glow palettes do not recolor the Settings shell - "subtitles" was used for text displayed during a Google Meet call — resolved: this is **Live Captions**. -- HUD behavior for long drafts — resolved: the downward-growing panel won the feel test against tail-only truncation and shrink-to-fit. The HUD wraps long drafts and grows downward, capped at four lines; the other two variants remain selectable for the Settings picker. -- The voice-reactive visual — resolved: both prototyped variants ship, user-selectable from Settings, and both replace draft text while listening. The waveform (default: the Chart Line conveyor with metallic silver treatment, plus seven alternate styles) fills the visual band; the edge glow is a three-part animation — a palette-gradient beam whose bright region blooms in from under the housing on session start, sweeps the shape's open silhouette while listening, follows the voice in brightness and stroke thickness (the feel-test pick over reach, particle count, particle size, and syllable bursts), and drains back when the session ends, a one-shot ripple wave rolling across the housing at session start, and a particle cloud in the palette's colors chasing the beam's sweeping origin. The palette (Spectrum, Silver, Aurora, Sunset, Ocean, Mono) is a Settings pick coloring beam and particles together. +- HUD behavior for long drafts — resolved: the downward-growing panel won the feel test against tail-only truncation and shrink-to-fit. The HUD wraps long drafts and grows downward, capped at four lines; the other two variants remain selectable for the Settings picker. Edge Glow + Draft is the exception: it shows the last eight word tokens on one 24-point centered line instead of those behaviors, because wrapping fights a larger type in the stage. Reduce Motion restores the plain band, where the global pick still applies. +- The voice-reactive visual — resolved: Waveform and Edge Glow ship and replace draft text while listening; Compact shows the live draft with a five-bar indicator; Edge Glow + Draft is the fourth pick, live draft plus Edge Glow chrome with no glow center and no Chart Line. The waveform (default: the Chart Line conveyor with metallic silver treatment, plus seven alternate styles) fills the visual band; Edge Glow + Draft does not offer those styles. The edge glow is a three-part animation — a palette-gradient beam whose bright region blooms in from under the housing on session start, sweeps the shape's open silhouette while listening, follows the voice in brightness and stroke thickness (the feel-test pick over reach, particle count, particle size, and syllable bursts), and drains back when the session ends, a one-shot ripple wave rolling across the housing at session start, and a particle cloud in the palette's colors chasing the beam's sweeping origin. Edge Glow + Draft keeps the beam and the ripple and drops the center. The palette (Spectrum, Silver, Aurora, Sunset, Ocean, Mono) is a Settings pick coloring beam and particles together, and the shaping caption. diff --git a/Talkify/App/AppDelegate.swift b/Talkify/App/AppDelegate.swift index 9ea5b88..bf553d5 100644 --- a/Talkify/App/AppDelegate.swift +++ b/Talkify/App/AppDelegate.swift @@ -102,7 +102,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { dictationController.onRecordingStateChange = { [weak statusItemController, weak settingsRuntimeState] isRecording, session in let accent = session.flatMap { - $0.voiceVisual == .glow ? $0.glowPalette.statusAccent : nil + $0.voiceVisual.usesEdgeGlow ? $0.glowPalette.statusAccent : nil } statusItemController?.setRecording(isRecording, accent: accent) settingsRuntimeState?.isDictating = isRecording diff --git a/Talkify/App/AppSettings.swift b/Talkify/App/AppSettings.swift index 6030003..4139e61 100644 --- a/Talkify/App/AppSettings.swift +++ b/Talkify/App/AppSettings.swift @@ -470,11 +470,12 @@ struct DictationSessionSettings: Equatable { let hudMetrics: HUDMetrics /// The colour a Drop Transcription wears — on the HUD's target and card, and - /// on the status ghost while a file job fills it. Edge Glow lends its - /// palette's own hue; every other visual keeps Talkify blue. One definition, - /// so the shape and the menu bar can never drift apart (CONTEXT.md). + /// on the status ghost while a file job fills it. Edge Glow and Edge Glow + + /// Draft lend the palette's own hue; every other visual keeps Talkify blue. + /// One definition, so the shape and the menu bar can never drift apart + /// (CONTEXT.md). var dropAccent: NSColor { - voiceVisual == .glow ? glowPalette.statusAccent : SettingsTheme.accentColor + voiceVisual.usesEdgeGlow ? glowPalette.statusAccent : SettingsTheme.accentColor } @MainActor diff --git a/Talkify/CoreHUD/HUDMetrics.swift b/Talkify/CoreHUD/HUDMetrics.swift index 0a82e86..05882cf 100644 --- a/Talkify/CoreHUD/HUDMetrics.swift +++ b/Talkify/CoreHUD/HUDMetrics.swift @@ -27,15 +27,15 @@ struct HUDMetrics: Equatable { /// sizes beneath it are for the visuals that replace the draft entirely. static let minimumReadableScale: CGFloat = 0.4 - /// The floor for a given voice visual. Compact is built around the live - /// draft, and Reduce Motion restores the draft for every visual, so both stop - /// where the text stops being legible. Waveform and Edge Glow replace the - /// draft entirely and go all the way down. + /// The floor for a given voice visual. Compact and Edge Glow + Draft are + /// built around the live draft, and Reduce Motion restores the draft for + /// every visual, so those stop where the text stops being legible. Waveform + /// and Edge Glow replace the draft entirely and go all the way down. /// /// Read from the session's own settings rather than from what the HUD happens /// to be showing, so a shape cannot change size partway through a session. static func minimumScale(for visual: HUDVoiceVisualStyle, reduceMotion: Bool) -> CGFloat { - visual == .compact || reduceMotion ? minimumReadableScale : minimumScale + visual.showsDraftWhileListening || reduceMotion ? minimumReadableScale : minimumScale } /// The unscaled shape. The host window is sized from this, so the window @@ -64,6 +64,11 @@ struct HUDMetrics: Equatable { /// shown between the housing and the text band. var visualBandHeight: CGFloat { 24 * scale } + /// Edge Glow + Draft hanging band: 24-point type plus 8 points above + /// and below. Tighter than the 36+24 stage that left empty air in the + /// pill; still tall enough that the line does not kiss the stroke. + var glowDraftStageHeight: CGFloat { 40 * scale } + /// Height of the waveform band. The waveform variant replaces the draft /// text entirely, so it gets room to breathe. var waveBandHeight: CGFloat { 64 * scale } diff --git a/Talkify/CoreHUD/HUDStage.swift b/Talkify/CoreHUD/HUDStage.swift index 3fea830..5352d0f 100644 --- a/Talkify/CoreHUD/HUDStage.swift +++ b/Talkify/CoreHUD/HUDStage.swift @@ -147,6 +147,7 @@ final class HUDStage { // through the retract. dictationContent.isDismissing = false dictationContent.text = "" + dictationContent.volatileText = "" dropContent.mode = .none dropContent.heldIcon = nil dropContent.transcript = nil @@ -168,8 +169,14 @@ final class HUDStage { func showMessage(_ text: String, on displayID: CGDirectDisplayID? = nil) { guard let screen = screen(preferring: displayID) else { return } claim(.message, on: screen) + // hide() pins these for the retract, and claim() cancels that order-out. + // A message is a new occupant and must not inherit them. + dictationContent.isDismissing = false + dictationContent.shapingName = nil + dictationContent.shapingChoiceLabel = nil dictationContent.showsVoiceVisual = false dictationContent.text = text + dictationContent.volatileText = "" revealDictation() messageDismissTask?.cancel() diff --git a/Talkify/Dictation/DirectDictationController+Dependencies.swift b/Talkify/Dictation/DirectDictationController+Dependencies.swift index d60e190..b04bfd5 100644 --- a/Talkify/Dictation/DirectDictationController+Dependencies.swift +++ b/Talkify/Dictation/DirectDictationController+Dependencies.swift @@ -52,7 +52,7 @@ extension DirectDictationController { _ languageTag: String? ) -> Void let showLatched: @MainActor () -> Void - let showLiveText: @MainActor (String) -> Void + let showLiveText: @MainActor (_ committed: String, _ volatile: String) -> Void let showFinalizing: @MainActor () -> Void /// The visible shaping phase: the HUD stays up saying which prompt is /// rewriting the finished words instead of dismissing into silence. @@ -138,7 +138,7 @@ extension DirectDictationController { ) }, showLatched: { hudController.showLatched() }, - showLiveText: { hudController.showLiveText($0) }, + showLiveText: { hudController.showLiveText($0, volatile: $1) }, showFinalizing: { hudController.showFinalizing() }, showShaping: { hudController.showShaping(with: $0) }, showShapingChoice: { hudController.showShapingChoice($0) }, diff --git a/Talkify/Dictation/DirectDictationController.swift b/Talkify/Dictation/DirectDictationController.swift index 6c13395..ecc0322 100644 --- a/Talkify/Dictation/DirectDictationController.swift +++ b/Talkify/Dictation/DirectDictationController.swift @@ -548,7 +548,7 @@ final class DirectDictationController { dependencies.showLatched() case .showLiveText: if let pendingLiveText { - dependencies.showLiveText(pendingLiveText) + dependencies.showLiveText(pendingLiveText, pendingVolatileText) } case .showFinalizing: dependencies.showFinalizing() @@ -576,6 +576,7 @@ final class DirectDictationController { /// The text carried alongside the current `updateReceived` action; the /// machine decides whether it shows, the controller remembers what. private var pendingLiveText: String? + private var pendingVolatileText = "" private func checkAndBegin() { // Held here rather than once recording starts: the first HUD frames are @@ -675,9 +676,11 @@ final class DirectDictationController { let hasVisibleText = !displayText .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty - pendingLiveText = displayText + pendingLiveText = update.finalizedText + pendingVolatileText = update.volatileText send(.updateReceived(hasVisibleText: hasVisibleText)) pendingLiveText = nil + pendingVolatileText = "" } /// Writes the session's entry, after any translation and before the diff --git a/Talkify/Dictation/HUD/DictationHUDContent.swift b/Talkify/Dictation/HUD/DictationHUDContent.swift index e091fb5..1dafe54 100644 --- a/Talkify/Dictation/HUD/DictationHUDContent.swift +++ b/Talkify/Dictation/HUD/DictationHUDContent.swift @@ -7,6 +7,10 @@ import Observation @Observable final class DictationHUDContent { var text = "" + /// The recognizer's current guess, not yet finalized. Drawn lighter so + /// new words can show before they commit (CONTEXT.md: volatile results + /// render immediately). + var volatileText = "" /// Drives the reveal/dismiss animation. var isRevealed = false /// True only while listening — the visuals react to the microphone, so diff --git a/Talkify/Dictation/HUD/DictationHUDController.swift b/Talkify/Dictation/HUD/DictationHUDController.swift index f8e9a1e..5feb8a5 100644 --- a/Talkify/Dictation/HUD/DictationHUDController.swift +++ b/Talkify/Dictation/HUD/DictationHUDController.swift @@ -80,27 +80,28 @@ final class DictationHUDController { // and the transcription keeps running with the status item carrying it. stage.claim(.dictation, on: screen, rendering: settings) startVoiceVisual() - content.text = placeholder + setDraft(placeholder) stage.revealDictation() } func showLatched() { guard isListening else { return } sessionIsLatched = true - content.text = placeholder + setDraft(placeholder) } /// Exposed so the placeholder rules can be asserted without a window. - var textForTesting: String { content.text } + var textForTesting: String { content.text + content.volatileText } /// What the band says before any words arrive. /// - /// Empty for Compact: it is the one visual built around the live draft, so a - /// placeholder there is words nobody spoke. Every path that would write one - /// asks here, rather than each deciding for itself — which is how "Listening - /// (latched)" kept coming back after the opening text was handled. + /// Empty for Compact and Edge Glow + Draft: both are built around the live + /// draft, so a placeholder there is words nobody spoke. Every path that + /// would write one asks here, rather than each deciding for itself — which + /// is how "Listening (latched)" kept coming back after the opening text was + /// handled. private var placeholder: String { - guard sessionSettings.voiceVisual != .compact else { return "" } + guard !sessionSettings.voiceVisual.showsDraftWhileListening else { return "" } return sessionIsLatched ? Self.latchedText : Self.listeningText } @@ -109,12 +110,12 @@ final class DictationHUDController { /// restores whatever the session was saying before. func showModelDownload(_ text: String?) { guard isListening else { return } - content.text = text ?? placeholder + setDraft(text ?? placeholder) } - func showLiveText(_ text: String) { - guard isListening, !text.isEmpty else { return } - content.text = text + func showLiveText(_ committed: String, volatile: String = "") { + guard isListening, !(committed + volatile).isEmpty else { return } + setDraft(committed, volatile: volatile) } /// Speech has stopped but the recognized text has not arrived yet. @@ -148,10 +149,15 @@ final class DictationHUDController { // the caption below it says what is actually happening. A draft the user // really spoke stays, because those are the words being rewritten. if content.text == Self.listeningText || content.text == Self.latchedText { - content.text = "" + setDraft("") } } + private func setDraft(_ committed: String, volatile: String = "") { + content.text = committed + content.volatileText = volatile + } + /// The session's shaping pick by name; nil clears its tag. Cycling arrives /// mid-session, so this only speaks while the dictation session holds the /// shape. diff --git a/Talkify/Dictation/HUD/DictationHUDShellView.swift b/Talkify/Dictation/HUD/DictationHUDShellView.swift index a9f5a2f..f396706 100644 --- a/Talkify/Dictation/HUD/DictationHUDShellView.swift +++ b/Talkify/Dictation/HUD/DictationHUDShellView.swift @@ -47,7 +47,12 @@ struct DictationHUDShellView: View { HUDNotchGeometry.contentSize( for: screen, metrics: metrics, - visualBandHeight: visualBandHeight, + // Edge Glow + Draft has no visual band; its hanging stage is 40 + // points, 4 more than the ordinary text band. That extra has to be + // in the declared size or Slide parks short of hiding the island. + visualBandHeight: visualBandHeight + + (showsRecentDraft + ? metrics.glowDraftStageHeight - metrics.textBandHeight : 0), includesTextBand: showsTextBand, shapingBandHeight: showsShapingLabel ? metrics.shapingBandHeight : 0 ) @@ -74,28 +79,27 @@ struct DictationHUDShellView: View { return nil } - /// Reduce Motion always shows the quiet level meter in its slim band; - /// otherwise both animated visuals get the tall band — the waveform fills - /// it, the glow keeps it as an empty stage so the silhouette has flanks - /// for the light to wrap. In the Shape live draft trades band for text: - /// the glow drops the band entirely (the beam wraps the text band), the - /// waveform compresses to the slim strip so strip + wrapped text still - /// fit the fixed window. + /// Reduce Motion always shows the quiet level meter in its slim band. + /// Compact and Edge Glow + Draft have no band of their own: Compact's + /// indicator lives in the text band, and Edge Glow + Draft's words overlay + /// the island. Waveform and Edge Glow keep the tall band — + /// the waveform fills it, the glow keeps it as an empty stage so the + /// silhouette has flanks for the light to wrap. private var visualBandHeight: CGFloat { guard keepsVisualLayout else { return 0 } if reduceMotion { return metrics.visualBandHeight } - // Compact has no band of its own: its indicator lives inside the - // text band, beside the draft. - if settings.voiceVisual == .compact { return 0 } - return metrics.waveBandHeight + switch settings.voiceVisual { + case .compact, .glowDraft: return 0 + case .waveform, .glow: return metrics.waveBandHeight + } } /// Waveform and Edge Glow replace the draft text entirely while - /// listening; Compact is built around it. With Reduce Motion the draft - /// text always shows. + /// listening; Compact and Edge Glow + Draft are built around it. With + /// Reduce Motion the draft text always shows. private var showsTextBand: Bool { if !keepsVisualLayout || reduceMotion { return true } - return settings.voiceVisual == .compact + return settings.voiceVisual.showsDraftWhileListening } /// Whether the bands stay as a listening session laid them out. Held through @@ -104,15 +108,14 @@ struct DictationHUDShellView: View { content.showsVoiceVisual || content.isDismissing } - /// Whether the text band renders the Compact layout: the voice indicator - /// beside a leading-aligned draft. Not gated on the listening state — - /// swapping the band's structure at finalize reads as a glitch mid - /// retract, so the layout stays and the indicator settles instead. - private var showsCompactBand: Bool { + /// Whether the text band is Compact's leading-aligned draft. Not gated on + /// the listening state — swapping the band's structure at finalize reads + /// as a glitch mid retract, so the layout stays and the indicator settles + /// instead. Edge Glow + Draft has its own centered stage. + private var showsLeadingDraft: Bool { settings.voiceVisual == .compact && !reduceMotion } - private var filletSize: CGFloat { HUDNotchGeometry.filletSize(for: screen) } @@ -133,18 +136,23 @@ struct DictationHUDShellView: View { isRevealed: content.isRevealed, size: size, rippleTrigger: content.sessionEpoch, - rippleEnabled: settings.voiceVisual == .glow, + rippleEnabled: settings.voiceVisual.usesEdgeGlow, content: { bands // The tag hangs off the shell, not the text band: Waveform and Edge // Glow hide the band for the whole listening phase, which is exactly // when the live language needs naming. Padded below the housing so it // clears the camera, and an overlay so it never changes the fixed - // window's size. - .overlay(alignment: .topLeading) { languageTag } + // window's size. Edge Glow + Draft places its own tag on the island. + .overlay(alignment: .topLeading) { + if !showsRecentDraft { languageTag } + } + // Grow Down springs the island's height. Glyphs opt out so new + // words land immediately; the token is coarse so a wrap is one spring. .animation( - settings.longDraftStyle == .growDown ? .spring(duration: 0.25, bounce: 0) : nil, - value: content.text + settings.longDraftStyle == .growDown && !showsRecentDraft + ? .spring(duration: 0.18, bounce: 0) : nil, + value: draftHeightToken ) .animation(.spring(duration: 0.25, bounce: 0), value: visualBandHeight) }, @@ -157,9 +165,49 @@ struct DictationHUDShellView: View { } private var bands: some View { + VStack(spacing: 0) { + island + if let label = shapingLabel { + HUDShapingLabel( + text: label.text, + palette: settings.glowPalette, + scale: metrics.scale, + activity: label.activity, + reduceMotion: reduceMotion + ) + .frame(height: metrics.shapingBandHeight) + } + } + } + + /// Housing plus the bands below it. Edge Glow + Draft's recent-word line + /// is an overlay on this silhouette, not a ZStack sibling: a + /// max-height-infinity child in a ZStack expands to the host window and + /// the black shape follows it. Overlay stays the stacked size, and the + /// words center in the box the glow wraps — including the housing + /// flanks, which are visible even though the camera sits in the middle. + private var island: some View { + stackedBands + .overlay { + if showsRecentDraft { + HUDRecentDraftText( + committed: content.text, + volatile: content.volatileText, + scale: metrics.scale + ) + .padding(.horizontal, tagInset * metrics.scale) + } + } + .overlay(alignment: .leading) { + if showsRecentDraft { languageTag } + } + } + + private var stackedBands: some View { VStack(spacing: 0) { // Strip level with the housing: kept empty so text never collides - // with the camera. + // with the camera. Edge Glow + Draft still counts it in the box the + // words are centered in, because the flanks of that strip are visible. Color.clear .frame(height: HUDNotchGeometry.closedSize(for: screen).height) if visualBandHeight > 0 { @@ -179,43 +227,63 @@ struct DictationHUDShellView: View { if showsTextBand { textBand } - if let label = shapingLabel { - HUDShapingLabel( - text: label.text, - palette: settings.glowPalette, - scale: metrics.scale, - activity: label.activity, - reduceMotion: reduceMotion - ) - .frame(height: metrics.shapingBandHeight) - } } } /// Draft text lives in the band below the housing. Compact puts its /// voice indicator on the leading side, Dynamic Island-style, with the - /// draft leading-aligned beside it; the other visuals center the draft. + /// draft leading-aligned beside it. Edge Glow + Draft only reserves the + /// hanging stage here; the words themselves overlay the whole island. + /// The other visuals center the draft. @ViewBuilder private var textBand: some View { - Group { - if showsCompactBand { - HStack(alignment: .top, spacing: 10 * metrics.scale) { - HUDCompactIndicatorView(content: content, scale: metrics.scale) - .padding(.top, 3 * metrics.scale) - compactDraftText - .frame(maxWidth: .infinity, alignment: .leading) + if showsRecentDraft { + Color.clear + .frame(height: metrics.glowDraftStageHeight) + } else { + Group { + if showsLeadingDraft { + HStack(alignment: .top, spacing: 10 * metrics.scale) { + HUDCompactIndicatorView(content: content, scale: metrics.scale) + .padding(.top, 3 * metrics.scale) + compactDraftText + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + draftText } - } else { - draftText } + .font(.system(size: 15 * metrics.scale, weight: .medium)) + .foregroundStyle(.white) + // The tag sits in the inset rather than in the flow, and the inset grows + // on both sides, so centered drafts stay centered and nothing overlaps. + .padding(.horizontal, tagInset * metrics.scale) + .padding(.top, 9 * metrics.scale) + .padding(.bottom, 9 * metrics.scale) + .frame(minHeight: metrics.textBandHeight) } - .font(.system(size: 15 * metrics.scale, weight: .medium)) - .foregroundStyle(.white) - // The tag sits in the inset rather than in the flow, and the inset grows - // on both sides, so centered drafts stay centered and nothing overlaps. - .padding(.horizontal, tagInset * metrics.scale) - .padding(.vertical, 9 * metrics.scale) - .frame(minHeight: metrics.textBandHeight) + } + + private var showsRecentDraft: Bool { + Self.showsRecentDraft( + visual: settings.voiceVisual, + listening: content.showsVoiceVisual, + dismissing: content.isDismissing, + shaping: content.shapingName != nil, + reduceMotion: reduceMotion + ) + } + + /// Edge Glow + Draft's recent-word line: listening, retracting, and + /// shaping keep it; a status message does not. + static func showsRecentDraft( + visual: HUDVoiceVisualStyle, + listening: Bool, + dismissing: Bool, + shaping: Bool, + reduceMotion: Bool + ) -> Bool { + visual == .glowDraft && !reduceMotion && (listening || dismissing || shaping) } /// The room the language tag needs on each side. @@ -256,6 +324,11 @@ struct DictationHUDShellView: View { if let tag = content.languageTag { tagCapsule(tag) .padding(.leading, 12 * metrics.scale) + // Edge Glow + Draft overlays the tag on the whole island, so it + // shares the draft's vertical center. Other visuals pin it below + // the housing from the shell. + .padding(.top, showsRecentDraft ? 0 : tagTopInset) + .allowsHitTesting(false) } } @@ -272,9 +345,35 @@ struct DictationHUDShellView: View { .padding(.horizontal, 4 * metrics.scale) .padding(.vertical, 1.5 * metrics.scale) .background(Capsule(style: .continuous).fill(.white.opacity(0.13))) - // Clears the housing, so a tag never sits beside the camera. - .padding(.top, HUDNotchGeometry.closedSize(for: screen).height + 5 * metrics.scale) - .allowsHitTesting(false) + } + + private var tagTopInset: CGFloat { + let housing = HUDNotchGeometry.closedSize(for: screen).height + let belowHousing: CGFloat + if showsTextBand, visualBandHeight > 0 { + belowHousing = visualBandHeight + 5 * metrics.scale + } else { + belowHousing = 5 * metrics.scale + } + return housing + belowHousing + } + + /// Coarse height for Grow Down's spring: one step per ~40 characters, so + /// wrapping animates the island without tweening every volatile letter. + private var draftHeightToken: Int { + (content.text.count + content.volatileText.count) / 40 + } + + /// Committed draft in full white, the current guess lighter, so new words + /// show as soon as the recognizer emits them. Glyphs opt out of Grow Down's + /// height spring so they do not fade in with it. + private var liveDraft: some View { + let committed = AttributedString(content.text) + var guess = AttributedString(content.volatileText) + guess.font = .system(size: 15 * metrics.scale, weight: .regular) + guess.foregroundColor = Color.white.opacity(0.55) + return Text(committed + guess) + .transaction { $0.animation = nil } } /// The Compact draft: the same long-draft semantics, leading-aligned so @@ -283,15 +382,15 @@ struct DictationHUDShellView: View { private var compactDraftText: some View { switch settings.longDraftStyle { case .tailOnly: - Text(content.text) + liveDraft .lineLimit(1) .truncationMode(.head) case .growDown: - Text(content.text) + liveDraft .lineLimit(4) .multilineTextAlignment(.leading) case .shrinkToFit: - Text(content.text) + liveDraft .lineLimit(1) .truncationMode(.head) .minimumScaleFactor(0.55) @@ -306,15 +405,15 @@ struct DictationHUDShellView: View { private var draftText: some View { switch settings.longDraftStyle { case .tailOnly: - Text(content.text) + liveDraft .lineLimit(1) .truncationMode(.head) case .growDown: - Text(content.text) + liveDraft .lineLimit(4) .multilineTextAlignment(.center) case .shrinkToFit: - Text(content.text) + liveDraft .lineLimit(1) .truncationMode(.head) .minimumScaleFactor(0.55) @@ -358,7 +457,7 @@ struct DictationHUDShellView: View { /// ends; the view disables its shader once the ramp reaches zero. @ViewBuilder private var edgeGlow: some View { - if !reduceMotion, settings.voiceVisual == .glow { + if !reduceMotion, settings.voiceVisual.usesEdgeGlow { HUDEdgeGlowView( content: content, settings: settings, @@ -402,3 +501,11 @@ struct DictationHUDShellView: View { HUDShellPreviewHarness(visual: .compact, shapingChoice: "Remove filler words") } +#Preview("Edge Glow + Draft") { + HUDShellPreviewHarness(visual: .glowDraft) +} + +#Preview("Edge Glow + Draft · dead mic") { + HUDShellPreviewHarness(visual: .glowDraft, micAlive: false) +} + diff --git a/Talkify/Dictation/HUD/HUDRecentDraftText.swift b/Talkify/Dictation/HUD/HUDRecentDraftText.swift new file mode 100644 index 0000000..7fce105 --- /dev/null +++ b/Talkify/Dictation/HUD/HUDRecentDraftText.swift @@ -0,0 +1,195 @@ +import NaturalLanguage +import SwiftUI + +/// The visible suffix of a live draft: the last `wordLimit` word tokens of +/// the joined committed and volatile strings. Display only — insertion still +/// uses the full transcript. +struct HUDRecentDraftWindow: Equatable { + static let wordLimit = 8 + + struct VisibleToken: Equatable, Identifiable { + /// Index in the full joined draft, not in the window, so a word that + /// stays on screen keeps its identity across evictions and rewrites. + let id: Int + let text: String + } + + let committed: String + let volatile: String + /// Character offset of the visible suffix in the joined draft. + /// Advances when an old word is evicted; a volatile rewrite can move it back. + let evictionOffset: Int + /// Word tokens in the joined draft, not the clipped window. A new word + /// raises this; an in-token volatile rewrite does not. + let tokenCount: Int + /// Visible slices, each carrying trailing whitespace through the next + /// token so a ForEach can keep surviving words in place. + let visibleTokens: [VisibleToken] + + /// The visible string, committed and volatile together. Edge Glow + Draft + /// paints them the same: the line is a glance, not a proofread. + var displayString: String { committed + volatile } + + var tokenIDs: [Int] { visibleTokens.map(\.id) } + + /// A new word or an eviction, not a volatile rewrite inside the current + /// token. Backward corrections snap; they must not linger behind a fade. + func advances(from previous: Self) -> Bool { + tokenCount > previous.tokenCount || evictionOffset > previous.evictionOffset + } + + init(committed: String, volatile: String, limit: Int = Self.wordLimit) { + let joined = committed + volatile + guard !joined.isEmpty else { + self.committed = "" + self.volatile = "" + self.evictionOffset = 0 + self.tokenCount = 0 + self.visibleTokens = [] + return + } + + let tokenizer = NLTokenizer(unit: .word) + tokenizer.string = joined + var tokens: [Range] = [] + tokenizer.enumerateTokens(in: joined.startIndex..= boundary { + self.committed = "" + self.volatile = String(joined[start...]) + } else { + self.committed = String(joined[start.. CGFloat { + if bounds.width > 0, contentWidth > bounds.width { + return bounds.maxX - contentWidth + } + return bounds.midX - contentWidth / 2 + } +} + +/// Places each token at its ideal width so SwiftUI cannot compress them +/// separately. Overflow is then one clip of the oldest words, not eight +/// ellipses. +private struct HUDRecentDraftLineLayout: Layout { + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Void + ) -> CGSize { + let content = contentSize(subviews) + return CGSize( + width: proposal.width ?? content.width, + height: proposal.height ?? content.height + ) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Void + ) { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + let contentWidth = sizes.reduce(CGFloat.zero) { $0 + $1.width } + var x = HUDRecentDraftLine.originX(contentWidth: contentWidth, in: bounds) + for (subview, size) in zip(subviews, sizes) { + subview.place( + at: CGPoint(x: x, y: bounds.midY - size.height / 2), + anchor: .topLeading, + proposal: ProposedViewSize(size) + ) + x += size.width + } + } + + private func contentSize(_ subviews: Subviews) -> CGSize { + subviews.reduce(into: CGSize.zero) { acc, subview in + let size = subview.sizeThatFits(.unspecified) + acc.width += size.width + acc.height = max(acc.height, size.height) + } + } +} + +/// Edge Glow + Draft's live draft: one 24-point line of recent words, +/// centered in the island when it fits. Each word keeps a stable id, so +/// only a new or departing token slides; a finalization rewrites in place. +struct HUDRecentDraftText: View { + static let pointSize: CGFloat = 24 + + let committed: String + let volatile: String + var scale: CGFloat = 1 + + @State private var lastWindow: HUDRecentDraftWindow? + + private var window: HUDRecentDraftWindow { + HUDRecentDraftWindow(committed: committed, volatile: volatile) + } + + var body: some View { + let window = self.window + let animate = lastWindow.map(window.advances(from:)) ?? false + HUDRecentDraftLineLayout { + ForEach(window.visibleTokens) { token in + Text(token.text) + .font(.system(size: Self.pointSize * scale, weight: .medium)) + .foregroundStyle(.white) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + .animation(animate ? .easeOut(duration: 0.14) : nil, value: window.tokenIDs) + .onChange(of: window) { _, new in + lastWindow = new + } + } +} diff --git a/Talkify/Dictation/HUD/Visuals/ChartLineWaveView.swift b/Talkify/Dictation/HUD/Visuals/ChartLineWaveView.swift index 09c6ae4..a13d3e8 100644 --- a/Talkify/Dictation/HUD/Visuals/ChartLineWaveView.swift +++ b/Talkify/Dictation/HUD/Visuals/ChartLineWaveView.swift @@ -17,18 +17,27 @@ struct ChartLineWaveView: View { /// Measured time between level ticks, smoothed; scroll speed follows it. @State private var tickInterval: Double = 0.022 + private var live: Bool { + content.showsVoiceVisual && content.isAudioAlive + } + var body: some View { - TimelineView(.animation) { context in + // Paused when the session is over or the microphone is dead, so the + // amber line is motionless (CONTEXT.md: dead ≠ silent). + TimelineView(.animation(paused: !live)) { context in let progress = min(1, max(0, context.date.timeIntervalSince(lastTick) / tickInterval)) let level = content.audioLevel - let shape = SmoothLineShape(samples: samples, scrollProgress: progress) + let shape = SmoothLineShape( + samples: samples, + scrollProgress: (live || lastTick != .distantPast) ? progress : 0 + ) if content.isAudioAlive { ZStack { // Ambient under-glow: a wide soft pool of light beneath // the line, breathing with the voice. Ellipse() - .fill(Self.silver) + .fill(HUDVisualTokens.chartLineSilver) .frame(height: 26) .padding(.horizontal, 60) .blur(radius: 22) @@ -38,7 +47,7 @@ struct ChartLineWaveView: View { // rest line, soft and fading downward. shape .stroke( - Self.silver, + HUDVisualTokens.chartLineSilver, style: StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round) ) .scaleEffect(y: -1) @@ -56,7 +65,7 @@ struct ChartLineWaveView: View { // bloom reads as light on glass rather than color. shape .stroke( - Self.silver, + HUDVisualTokens.chartLineSilver, style: StrokeStyle( lineWidth: 4 + 8 * level, lineCap: .round, @@ -68,7 +77,7 @@ struct ChartLineWaveView: View { // Tight bloom hugging the core. shape .stroke( - Self.silver, + HUDVisualTokens.chartLineSilver, style: StrokeStyle( lineWidth: 2.5 + 3 * level, lineCap: .round, @@ -117,20 +126,6 @@ struct ChartLineWaveView: View { } } - /// Metallic silver: white body cooling into a faint blue-gray at the - /// ends — the edge glow's palette, no saturated hues. - private static let silver = LinearGradient( - colors: [ - Color(red: 0.68, green: 0.74, blue: 0.88).opacity(0.85), - .white, - Color(red: 0.82, green: 0.86, blue: 0.95), - .white, - Color(red: 0.68, green: 0.74, blue: 0.88).opacity(0.85), - ], - startPoint: .leading, - endPoint: .trailing - ) - /// A soft bright band ping-ponging across the strip; masking the extra /// specular stroke with it makes the light travel along the line. private func specularBand(at date: Date) -> some View { diff --git a/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift b/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift index b5340a7..1fee89d 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift @@ -43,6 +43,11 @@ struct HUDEdgeGlowView: View { /// directly under the housing. @State private var sweepStart = Date() + /// When the microphone went quiet. Elapsed time subtracts this so the + /// first unpaused frame does not jump; onChange then folds it into + /// `sweepStart`. + @State private var quietSince: Date? + /// Mirrors `content.showsVoiceVisual` for the ramp's keyframe trigger. /// The trigger must *change* for the keyframes to play; a view mounted /// while listening is already true (the Settings preview switching back @@ -53,7 +58,7 @@ struct HUDEdgeGlowView: View { var body: some View { GeometryReader { proxy in let listening = content.showsVoiceVisual - TimelineView(.animation(paused: !listening)) { context in + TimelineView(.animation(paused: !listening || !content.isAudioAlive)) { context in // Plain values for the @Sendable keyframeAnimator content // closure; the body re-evaluates on every level tick and // timeline frame, so they stay fresh. @@ -68,7 +73,7 @@ struct HUDEdgeGlowView: View { let lineWidth = Self.lineWidth * Double(metrics.scale) * (1 + level) let blurRadius = Self.blurRadius * Double(metrics.scale) * (1 + 0.5 * level) let origin = Self.sweepOrigin( - at: context.date.timeIntervalSince(sweepStart), + at: sweepElapsed(at: context.date), in: size, cornerRadius: metrics.bottomCornerRadius, topFilletRadius: topFilletRadius, @@ -115,13 +120,28 @@ struct HUDEdgeGlowView: View { .allowsHitTesting(false) .accessibilityHidden(true) .onChange(of: content.sessionEpoch) { - sweepStart = .now + sweepStart = Date.now + quietSince = nil + } + .onChange(of: content.isAudioAlive, initial: true) { _, alive in + let now = Date.now + if alive, let quietSince { + sweepStart = sweepStart.addingTimeInterval(now.timeIntervalSince(quietSince)) + } + quietSince = alive ? nil : now } .onChange(of: content.showsVoiceVisual, initial: true) { _, listening in ramped = listening } } + /// Wall-clock elapsed, minus any open dead-mic pause, so the first + /// unpaused frame is already at the freeze point. + private func sweepElapsed(at date: Date) -> TimeInterval { + date.timeIntervalSince(sweepStart) + - (quietSince.map { date.timeIntervalSince($0) } ?? 0) + } + /// The eased ping-pong position along the silhouette at time `t` (left /// tip → bottom → right tip → back), phase-shifted so t = 0 lands at /// bottom-center. Shared with the particle cloud so the motes chase the diff --git a/Talkify/Dictation/HUD/Visuals/HUDVisualTokens.swift b/Talkify/Dictation/HUD/Visuals/HUDVisualTokens.swift index ecfd0f4..9fcbdce 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDVisualTokens.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDVisualTokens.swift @@ -7,4 +7,19 @@ enum HUDVisualTokens { /// visuals keep their own dimmer orange variants, tuned per visual /// during feel tests. static let deadMicAmber = Color(red: 1.0, green: 0.6, blue: 0.16) + + /// The Chart Line treatment's metallic silver: white body cooling into a + /// faint blue-gray at the ends — the edge glow's palette, no saturated + /// hues. Shared so every Chart Line is recognisably one treatment. + static let chartLineSilver = LinearGradient( + colors: [ + Color(red: 0.68, green: 0.74, blue: 0.88).opacity(0.85), + .white, + Color(red: 0.82, green: 0.86, blue: 0.95), + .white, + Color(red: 0.68, green: 0.74, blue: 0.88).opacity(0.85), + ], + startPoint: .leading, + endPoint: .trailing + ) } diff --git a/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift b/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift index 84197fb..33e94f7 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift @@ -1,7 +1,7 @@ /// The voice-reactive visual shown while listening, user-selectable from -/// Settings. Waveform and Edge Glow replace the draft text unless the Live -/// draft pick keeps it; Compact is built around the draft text. Reduce -/// Motion overrides all of them with a quiet level meter. +/// Settings. Waveform and Edge Glow replace the draft text; Compact and +/// Edge Glow + Draft keep it. Reduce Motion overrides all of them with a +/// quiet level meter. enum HUDVoiceVisualStyle: String, CaseIterable { /// A Metal shader waveform that fills the HUD's visual band. case waveform = "Waveform" @@ -10,8 +10,23 @@ enum HUDVoiceVisualStyle: String, CaseIterable { /// when the session ends. Its center content is a separate pick /// (HUDGlowCenterStyle). case glow = "Edge Glow" + /// Centered recent-word live draft and Edge Glow on the silhouette. No + /// Chart Line, five-bar indicator, particle cloud, or orb. The concert + /// waveform styles stay on Waveform. + case glowDraft = "Edge Glow + Draft" /// The Dynamic Island caption look: a small voice indicator beside the /// live draft text inside the shape (HUDCompactIndicatorView); the shape /// grows with the draft. case compact = "Compact" + + /// Compact and Edge Glow + Draft are built around the live draft, so a + /// placeholder would be words nobody spoke. + var showsDraftWhileListening: Bool { + self == .compact || self == .glowDraft + } + + /// The beam, start ripple, shaping caption, and status-ghost accent. + var usesEdgeGlow: Bool { + self == .glow || self == .glowDraft + } } diff --git a/Talkify/Dictation/HUD/Visuals/WaveShapes.swift b/Talkify/Dictation/HUD/Visuals/WaveShapes.swift index 211b21e..f680c6f 100644 --- a/Talkify/Dictation/HUD/Visuals/WaveShapes.swift +++ b/Talkify/Dictation/HUD/Visuals/WaveShapes.swift @@ -5,16 +5,27 @@ import SwiftUI struct SmoothLineShape: Shape { let samples: [Float] let scrollProgress: Double + /// Fraction of the rect height a full-scale sample occupies, measured + /// up from the rest line. Concert Chart Line keeps 0.5 so the reflection + /// can sit in the lower half; the ribbon uses more so a 12-point strip + /// is not half empty. + var fill: CGFloat = 0.5 nonisolated func path(in rect: CGRect) -> Path { guard samples.count > 2 else { return Path() } let step = rect.width / CGFloat(samples.count - 2) let offset = CGFloat(scrollProgress) * step + let span = rect.height * fill + let restY = rect.minY + span + // Concert's 0.75pt floor at 64pt. Scaled with the span so a 12-point + // ribbon (and the 40% HUD-size floor) does not clamp quiet speech + // to a straight line. + let lift = min(0.75, span * 0.025) let points = samples.enumerated().map { index, sample in CGPoint( x: rect.minX + CGFloat(index) * step - offset, - y: rect.midY - max(0.75, CGFloat(sample) * rect.height / 2) + y: restY - max(lift, CGFloat(sample) * span) ) } diff --git a/Talkify/Dictation/SpeechRecognitionService.swift b/Talkify/Dictation/SpeechRecognitionService.swift index ad36380..158d710 100644 --- a/Talkify/Dictation/SpeechRecognitionService.swift +++ b/Talkify/Dictation/SpeechRecognitionService.swift @@ -328,14 +328,12 @@ actor SpeechRecognitionService { try await reserve(locale: locale) + // progressiveTranscription is Apple's live-audio preset: volatile + // guesses plus fastResults, so the draft streams while speaking + // instead of appearing only at pauses. let transcriber = SpeechTranscriber( locale: locale, - transcriptionOptions: [], - // fastResults biases the transcriber towards responsiveness so - // the live draft streams while the user is still speaking, - // instead of appearing only at pauses. - reportingOptions: [.volatileResults, .fastResults], - attributeOptions: [] + preset: .progressiveTranscription ) // Only a missing model produces a request, so this is the one branch that diff --git a/Talkify/DropTranscription/HUD/DropHUDStyle.swift b/Talkify/DropTranscription/HUD/DropHUDStyle.swift index 49260b5..9a2fe65 100644 --- a/Talkify/DropTranscription/HUD/DropHUDStyle.swift +++ b/Talkify/DropTranscription/HUD/DropHUDStyle.swift @@ -7,11 +7,11 @@ struct DropHUDStyle { let metrics: HUDMetrics /// The accent for glyphs, the peek bar, and the notice. /// - /// The drop surfaces speak the voice visual's colour: Edge Glow lends its - /// palette — the same hue the status ghost takes during a glow session, so - /// one palette means one colour everywhere — and every other visual keeps - /// the Talkify blue. Settings chrome is unaffected either way; the palette - /// colours the HUD, never the app (CONTEXT.md). + /// The drop surfaces speak the voice visual's colour: Edge Glow and + /// Edge Glow + Draft lend the palette — the same hue the status ghost takes + /// during those sessions, so one palette means one colour everywhere — and + /// every other visual keeps the Talkify blue. Settings chrome is unaffected + /// either way; the palette colours the HUD, never the app (CONTEXT.md). let accent: Color /// The receptive edge gets the palette's whole gradient rather than one hue /// of it: it is the one element long enough to show a palette off. @@ -22,7 +22,7 @@ struct DropHUDStyle { init(settings: DictationSessionSettings) { metrics = settings.hudMetrics accent = Color(nsColor: settings.dropAccent) - accentStroke = settings.voiceVisual == .glow + accentStroke = settings.voiceVisual.usesEdgeGlow ? settings.glowPalette.stroke : AnyShapeStyle(SettingsTheme.accent) } diff --git a/Talkify/Settings/Sections/AppearanceSettingsView.swift b/Talkify/Settings/Sections/AppearanceSettingsView.swift index 54bbc47..8f7d24d 100644 --- a/Talkify/Settings/Sections/AppearanceSettingsView.swift +++ b/Talkify/Settings/Sections/AppearanceSettingsView.swift @@ -1,23 +1,40 @@ import SwiftUI /// The Appearance section: the live preview first, then the Voice visual -/// and Motion and layout groups (CONTEXT.md). Waveform and Glow rows are -/// conditional on the selected visual; hidden values stay persisted. +/// and Motion and layout groups (CONTEXT.md). Waveform, palette, and glow +/// center rows are conditional on the selected visual; hidden values stay +/// persisted. struct AppearanceSettingsView: View { @Bindable var settings: AppSettings @Environment(\.accessibilityReduceMotion) private var reduceMotion /// Which conditional rows the selected visual exposes; hidden values - /// stay persisted (CONTEXT.md). + /// stay persisted (CONTEXT.md). Edge Glow + Draft takes the glow palette + /// (beam, shaping caption, status ghost) but not a center or the concert + /// waveform styles. static func showsWaveformOptions(for visual: HUDVoiceVisualStyle) -> Bool { visual == .waveform } - static func showsGlowOptions(for visual: HUDVoiceVisualStyle) -> Bool { + static func showsGlowPalette(for visual: HUDVoiceVisualStyle) -> Bool { + visual.usesEdgeGlow + } + + static func showsGlowCenter(for visual: HUDVoiceVisualStyle) -> Bool { visual == .glow } + /// Edge Glow + Draft uses a recent-word line instead of the global long + /// draft behaviors. Reduce Motion restores the plain band, where the + /// existing pick still applies. + static func showsLongDraftBehavior( + for visual: HUDVoiceVisualStyle, + reduceMotion: Bool + ) -> Bool { + !(visual == .glowDraft && !reduceMotion) + } + var body: some View { VStack(alignment: .leading, spacing: 18) { SettingsPreviewCard(settings: settings) @@ -41,7 +58,7 @@ struct AppearanceSettingsView: View { ) } - if Self.showsGlowOptions(for: settings.voiceVisual) { + if Self.showsGlowPalette(for: settings.voiceVisual) { SettingsPickerRow( title: "Glow palette", description: "The colors used by the edge beam", @@ -49,7 +66,9 @@ struct AppearanceSettingsView: View { optionLabel: { $0.rawValue }, selection: $settings.glowPalette ) + } + if Self.showsGlowCenter(for: settings.voiceVisual) { SettingsPickerRow( title: "Glow center", description: "The visual inside the edge beam", @@ -90,13 +109,18 @@ struct AppearanceSettingsView: View { selection: $settings.revealStyle ) - SettingsPickerRow( - title: "Long draft behavior", - description: "How the HUD handles longer dictated text", - options: HUDLongDraftStyle.allCases, - optionLabel: { $0.rawValue }, - selection: $settings.longDraftStyle - ) + if Self.showsLongDraftBehavior( + for: settings.voiceVisual, + reduceMotion: reduceMotion + ) { + SettingsPickerRow( + title: "Long draft behavior", + description: "How the HUD handles longer dictated text", + options: HUDLongDraftStyle.allCases, + optionLabel: { $0.rawValue }, + selection: $settings.longDraftStyle + ) + } } } } diff --git a/TalkifyTests/AppSettingsTests.swift b/TalkifyTests/AppSettingsTests.swift index 5fbb013..52d636c 100644 --- a/TalkifyTests/AppSettingsTests.swift +++ b/TalkifyTests/AppSettingsTests.swift @@ -80,6 +80,8 @@ struct AppSettingsTests { #expect(defaults.object(forKey: "dictationSoundsEnabled") as? Bool == false) #expect(defaults.double(forKey: "dictationSoundVolume") == 0.2) #expect(defaults.string(forKey: "hudVoiceVisual") == "Edge Glow") + settings.voiceVisual = .glowDraft + #expect(defaults.string(forKey: "hudVoiceVisual") == "Edge Glow + Draft") #expect(defaults.string(forKey: "hudWaveformStyle") == "Silver") #expect(defaults.string(forKey: "hudRevealStyle") == "Drift") #expect(defaults.string(forKey: "hudLongDraftStyle") == "Tail Only") diff --git a/TalkifyTests/DirectDictationControllerTests.swift b/TalkifyTests/DirectDictationControllerTests.swift index b183568..5f6d56e 100644 --- a/TalkifyTests/DirectDictationControllerTests.swift +++ b/TalkifyTests/DirectDictationControllerTests.swift @@ -142,7 +142,7 @@ struct DirectDictationControllerTests { recorder.listeningTags.append(languageTag) }, showLatched: { recorder.events.append("showLatched") }, - showLiveText: { _ in recorder.events.append("showLiveText") }, + showLiveText: { _, _ in recorder.events.append("showLiveText") }, showFinalizing: { recorder.events.append("showFinalizing") }, showShaping: { name in recorder.events.append("showShaping") diff --git a/TalkifyTests/HUDMetricsTests.swift b/TalkifyTests/HUDMetricsTests.swift index 75cebe0..d28d8b0 100644 --- a/TalkifyTests/HUDMetricsTests.swift +++ b/TalkifyTests/HUDMetricsTests.swift @@ -7,6 +7,7 @@ struct HUDMetricsTests { #expect(HUDMetrics.standard.scale == 1) #expect(HUDMetrics.standard.contentWidth == 540) #expect(HUDMetrics.standard.waveBandHeight == 64) + #expect(HUDMetrics.standard.glowDraftStageHeight == 40) } @Test func everyDimensionScalesTogether() { @@ -18,6 +19,7 @@ struct HUDMetricsTests { #expect(half.maxTextBandHeight == full.maxTextBandHeight * 0.8) #expect(half.visualBandHeight == full.visualBandHeight * 0.8) #expect(half.waveBandHeight == full.waveBandHeight * 0.8) + #expect(half.glowDraftStageHeight == full.glowDraftStageHeight * 0.8) #expect(half.bottomCornerRadius == full.bottomCornerRadius * 0.8) } @@ -30,11 +32,12 @@ struct HUDMetricsTests { #expect(HUDMetrics(scale: 0).scale == HUDMetrics.minimumScale) } - /// Compact is the only visual built around the live draft, so it is the only - /// one that gives up the smallest sizes. Reduce Motion restores the draft for - /// every visual and takes them away from all three. - @Test func onlyCompactAndReduceMotionGiveUpTheSmallestSizes() { + /// Compact and Edge Glow + Draft are built around the live draft, so they + /// give up the smallest sizes. Reduce Motion restores the draft for every + /// visual and takes those sizes away from all of them. + @Test func draftVisualsAndReduceMotionGiveUpTheSmallestSizes() { #expect(HUDMetrics.minimumScale(for: .compact, reduceMotion: false) == 0.4) + #expect(HUDMetrics.minimumScale(for: .glowDraft, reduceMotion: false) == 0.4) #expect(HUDMetrics.minimumScale(for: .waveform, reduceMotion: false) == 0.2) #expect(HUDMetrics.minimumScale(for: .glow, reduceMotion: false) == 0.2) #expect(HUDMetrics.minimumScale(for: .waveform, reduceMotion: true) == 0.4) @@ -107,8 +110,32 @@ struct HUDShellMetricsTests { ).scale } + /// Listening, retracting, and shaping keep the recent draft; a status + /// message does not. Compact never has one. + @Test func theRecentDraftFollowsOccupancy() { + #expect(DictationHUDShellView.showsRecentDraft( + visual: .glowDraft, listening: true, dismissing: false, shaping: false, + reduceMotion: false)) + #expect(DictationHUDShellView.showsRecentDraft( + visual: .glowDraft, listening: false, dismissing: true, shaping: false, + reduceMotion: false)) + #expect(DictationHUDShellView.showsRecentDraft( + visual: .glowDraft, listening: false, dismissing: false, shaping: true, + reduceMotion: false)) + #expect(!DictationHUDShellView.showsRecentDraft( + visual: .glowDraft, listening: false, dismissing: false, shaping: false, + reduceMotion: false)) + #expect(!DictationHUDShellView.showsRecentDraft( + visual: .glowDraft, listening: true, dismissing: false, shaping: false, + reduceMotion: true)) + #expect(!DictationHUDShellView.showsRecentDraft( + visual: .compact, listening: true, dismissing: false, shaping: false, + reduceMotion: false)) + } + @Test func aDraftLayoutIsHeldAtTheReadableFloorAndOthersAreNot() { #expect(scale(picked: 0.2, visual: .compact) == HUDMetrics.minimumReadableScale) + #expect(scale(picked: 0.2, visual: .glowDraft) == HUDMetrics.minimumReadableScale) #expect(scale(picked: 0.2, visual: .waveform) == HUDMetrics.minimumScale) #expect(scale(picked: 0.7, visual: .compact) == 0.7) } diff --git a/TalkifyTests/HUDNotchGeometryTests.swift b/TalkifyTests/HUDNotchGeometryTests.swift index 9b14926..9df9470 100644 --- a/TalkifyTests/HUDNotchGeometryTests.swift +++ b/TalkifyTests/HUDNotchGeometryTests.swift @@ -142,6 +142,22 @@ struct HUDNotchGeometryTests { #expect(tallest <= window.height - HUDNotchGeometry.shadowPadding) } + /// Edge Glow + Draft has no dedicated visual band: the hanging stage + /// replaces the ordinary text band. Housing plus that stage plus shaping + /// still fits; a concert waveform on top of the draft would not. + @Test func glowDraftMaximumStackFitsTheFixedWindow() { + let window = HUDNotchGeometry.windowFrame(for: notched, clearsMenuBar: false) + let glowDraft = 32 + + HUDMetrics.standard.glowDraftStageHeight + + HUDMetrics.standard.shapingBandHeight + #expect(glowDraft <= window.height - HUDNotchGeometry.shadowPadding) + let concertPlusDraft = 32 + + HUDMetrics.standard.waveBandHeight + + HUDMetrics.standard.maxTextBandHeight + + HUDMetrics.standard.shapingBandHeight + #expect(concertPlusDraft > window.height - HUDNotchGeometry.shadowPadding) + } + /// Issue #83: a real notch already sits in its own housing, clear of /// wherever the system draws status items, so there is nothing for the /// shape to hide there — it keeps hugging the true top edge. diff --git a/TalkifyTests/HUDPlaceholderTests.swift b/TalkifyTests/HUDPlaceholderTests.swift index 9d7a5ed..1d0a869 100644 --- a/TalkifyTests/HUDPlaceholderTests.swift +++ b/TalkifyTests/HUDPlaceholderTests.swift @@ -3,10 +3,10 @@ import Testing @testable import Talkify -/// Compact is built around the live draft, so it opens with nothing. Three -/// separate paths write the placeholder — opening, latching, and coming back -/// from a model download — and fixing only the first left "Listening (latched)" -/// still appearing. +/// Compact and Edge Glow + Draft are built around the live draft, so they +/// open with nothing. Three separate paths write the placeholder — opening, +/// latching, and coming back from a model download — and fixing only the +/// first left "Listening (latched)" still appearing. @MainActor @Suite("HUD placeholder text") struct HUDPlaceholderTests { @@ -20,9 +20,20 @@ struct HUDPlaceholderTests { store.sessionSettings } - @Test func compactOpensWithNoText() { + private func recentDraft(_ stage: HUDStage, visual: HUDVoiceVisualStyle) -> Bool { + DictationHUDShellView.showsRecentDraft( + visual: visual, + listening: stage.dictationContent.showsVoiceVisual, + dismissing: stage.dictationContent.isDismissing, + shaping: stage.dictationContent.shapingName != nil, + reduceMotion: false + ) + } + + @Test(arguments: [HUDVoiceVisualStyle.compact, .glowDraft]) + func aDraftVisualOpensWithNoText(visual: HUDVoiceVisualStyle) { let store = AppSettings.previewStore() - store.voiceVisual = .compact + store.voiceVisual = visual let hud = DictationHUDController(stage: HUDStage(settings: store), settings: store) hud.showListening(on: CGDirectDisplayID?.none, isLatched: false, settings: session(store)) @@ -35,11 +46,12 @@ struct HUDPlaceholderTests { #expect(hud.textForTesting.isEmpty) } - /// A latched Compact session still says nothing, which is the case that was + /// A latched draft visual still says nothing, which is the case that was /// reported after the opening text was already handled. - @Test func compactLatchesWithNoText() { + @Test(arguments: [HUDVoiceVisualStyle.compact, .glowDraft]) + func aDraftVisualLatchesWithNoText(visual: HUDVoiceVisualStyle) { let store = AppSettings.previewStore() - store.voiceVisual = .compact + store.voiceVisual = visual let hud = DictationHUDController(stage: HUDStage(settings: store), settings: store) hud.showListening(on: CGDirectDisplayID?.none, isLatched: true, settings: session(store)) @@ -102,4 +114,51 @@ struct HUDPlaceholderTests { hud.showLiveText("hello there") #expect(hud.textForTesting == "hello there") } + + @Test func aVolatileGuessShowsBeforeItCommits() { + let store = AppSettings.previewStore() + store.voiceVisual = .glowDraft + let hud = DictationHUDController(stage: HUDStage(settings: store), settings: store) + + hud.showListening(on: CGDirectDisplayID?.none, isLatched: false, settings: session(store)) + hud.showLiveText("hello ", volatile: "ther") + #expect(hud.textForTesting == "hello ther") + } + + @Test func aStatusMessageDoesNotKeepThePreviousGuess() { + let store = AppSettings.previewStore() + store.voiceVisual = .glowDraft + let hud = DictationHUDController(stage: HUDStage(settings: store), settings: store) + + hud.showListening(on: CGDirectDisplayID?.none, isLatched: false, settings: session(store)) + hud.showLiveText("hello ", volatile: "ther") + hud.showMessage("Couldn't insert text") + #expect(hud.textForTesting == "Couldn't insert text") + } + + /// hide() pins the layout for the retract, then an insert failure claims + /// the shape for a message. That message is a new occupant: the recent + /// draft stays through shaping and the retract, and leaves with the + /// status line. + @Test func aStatusMessageDoesNotKeepTheRecentDraft() { + let store = AppSettings.previewStore() + store.voiceVisual = .glowDraft + let stage = HUDStage(settings: store) + let hud = DictationHUDController(stage: stage, settings: store) + + hud.showListening(on: CGDirectDisplayID?.none, isLatched: false, settings: session(store)) + hud.showLiveText("the words it is rewriting") + hud.showShaping(with: "Tighten grammar") + #expect(recentDraft(stage, visual: .glowDraft)) + + hud.hide() + #expect(stage.dictationContent.isDismissing) + #expect(recentDraft(stage, visual: .glowDraft)) + + hud.showMessage("Couldn't insert text") + #expect(!stage.dictationContent.isDismissing) + #expect(stage.dictationContent.shapingName == nil) + #expect(!recentDraft(stage, visual: .glowDraft)) + #expect(hud.textForTesting == "Couldn't insert text") + } } diff --git a/TalkifyTests/HUDRecentDraftWindowTests.swift b/TalkifyTests/HUDRecentDraftWindowTests.swift new file mode 100644 index 0000000..369937f --- /dev/null +++ b/TalkifyTests/HUDRecentDraftWindowTests.swift @@ -0,0 +1,195 @@ +import CoreGraphics +import Testing + +@testable import Talkify + +@Suite("Recent draft window") +struct HUDRecentDraftWindowTests { + @Test func fewerThanEightTokensStayWhole() { + let window = HUDRecentDraftWindow( + committed: "one two three", + volatile: " four" + ) + #expect(window.committed == "one two three") + #expect(window.volatile == " four") + #expect(window.displayString == "one two three four") + #expect(window.evictionOffset == 0) + #expect(window.tokenCount == 4) + } + + @Test func exactlyEightTokensStayWhole() { + let window = HUDRecentDraftWindow( + committed: "one two three four five six seven eight", + volatile: "" + ) + #expect(window.committed == "one two three four five six seven eight") + #expect(window.volatile.isEmpty) + #expect(window.evictionOffset == 0) + #expect(window.tokenCount == 8) + } + + @Test func theNinthTokenEvictsTheFirst() { + let window = HUDRecentDraftWindow( + committed: "one two three four five six seven eight nine", + volatile: "" + ) + #expect(window.committed == "two three four five six seven eight nine") + #expect(window.volatile.isEmpty) + #expect(window.evictionOffset > 0) + #expect(window.tokenCount == 9) + } + + @Test func aPartialVolatileTokenCountsAsOneWord() { + let window = HUDRecentDraftWindow( + committed: "one two three four five six seven ", + volatile: "eigh" + ) + #expect(window.committed == "one two three four five six seven ") + #expect(window.volatile == "eigh") + #expect(window.evictionOffset == 0) + #expect(window.tokenCount == 8) + } + + @Test func aVolatileTokenCanCrossTheEvictionBoundary() { + let window = HUDRecentDraftWindow( + committed: "one two three four five six seven eight ", + volatile: "nine" + ) + #expect(window.committed == "two three four five six seven eight ") + #expect(window.volatile == "nine") + #expect(window.evictionOffset > 0) + #expect(window.tokenCount == 9) + } + + @Test func punctuationAndSpacesStayInsideTheSuffix() { + let window = HUDRecentDraftWindow( + committed: "Hello, world. This is a longer line, yes it is.", + volatile: "" + ) + #expect(window.committed.hasPrefix("This")) + #expect(window.committed.contains("line,")) + #expect(!window.committed.contains("Hello")) + } + + @Test func aStringWithNoWordTokensIsUnchanged() { + let window = HUDRecentDraftWindow(committed: "— —", volatile: " !!!") + #expect(window.committed == "— —") + #expect(window.volatile == " !!!") + #expect(window.evictionOffset == 0) + #expect(window.tokenCount == 0) + } + + @Test func emptyInputIsEmpty() { + let window = HUDRecentDraftWindow(committed: "", volatile: "") + #expect(window.committed.isEmpty) + #expect(window.volatile.isEmpty) + #expect(window.evictionOffset == 0) + #expect(window.tokenCount == 0) + } + + @Test func aNewTokenAdvancesAndAnInTokenRewriteDoesNot() { + let two = HUDRecentDraftWindow(committed: "one two", volatile: "") + let three = HUDRecentDraftWindow(committed: "one two ", volatile: "thr") + let threeRewritten = HUDRecentDraftWindow(committed: "one two ", volatile: "three") + let four = HUDRecentDraftWindow(committed: "one two three four", volatile: "") + #expect(three.advances(from: two)) + #expect(!threeRewritten.advances(from: three)) + #expect(four.advances(from: threeRewritten)) + } + + @Test func theNinthTokenAdvancesByEviction() { + let eight = HUDRecentDraftWindow( + committed: "one two three four five six seven eight", + volatile: "" + ) + let nine = HUDRecentDraftWindow( + committed: "one two three four five six seven eight nine", + volatile: "" + ) + #expect(nine.advances(from: eight)) + } + + @Test func aBackwardRewriteDoesNotAdvance() { + let three = HUDRecentDraftWindow(committed: "one two three", volatile: "") + let two = HUDRecentDraftWindow(committed: "one two", volatile: "") + #expect(!two.advances(from: three)) + } + + @Test func visibleTokensKeepStableIDsAcrossAnEviction() { + let eight = HUDRecentDraftWindow( + committed: "one two three four five six seven eight", + volatile: "" + ) + let nine = HUDRecentDraftWindow( + committed: "one two three four five six seven eight nine", + volatile: "" + ) + #expect(eight.visibleTokens.map(\.id) == [0, 1, 2, 3, 4, 5, 6, 7]) + #expect(nine.visibleTokens.map(\.id) == [1, 2, 3, 4, 5, 6, 7, 8]) + #expect(eight.visibleTokens.map(\.text).joined() == eight.displayString) + #expect(nine.visibleTokens.map(\.text).joined() == nine.displayString) + } + + @Test func aFinalizationKeepsTheSameTokenID() { + let guessing = HUDRecentDraftWindow( + committed: "one two three four five six seven ", + volatile: "eigh" + ) + let finalized = HUDRecentDraftWindow( + committed: "one two three four five six seven eight", + volatile: "" + ) + #expect(guessing.tokenIDs == finalized.tokenIDs) + #expect(guessing.tokenIDs.count == 8) + } + + @Test func anInTokenRewriteKeepsTheSameTokenID() { + let partial = HUDRecentDraftWindow(committed: "one two ", volatile: "thr") + let longer = HUDRecentDraftWindow(committed: "one two ", volatile: "three") + #expect(partial.tokenIDs == longer.tokenIDs) + } + + /// Apple Speech can merge a volatile `can not` into a final `cannot`. + /// Token count drops and the window's ids reshuffle; that must snap, not + /// play the new-word slide. + @Test func aMergingFinalizationDoesNotAdvance() { + let guessing = HUDRecentDraftWindow( + committed: "one two three four five six seven ", + volatile: "can not" + ) + let finalized = HUDRecentDraftWindow( + committed: "one two three four five six seven cannot", + volatile: "" + ) + #expect(guessing.tokenIDs == [1, 2, 3, 4, 5, 6, 7, 8]) + #expect(finalized.tokenIDs == [0, 1, 2, 3, 4, 5, 6, 7]) + #expect(!finalized.advances(from: guessing)) + } + + @Test func aSplittingFinalizationAdvances() { + let guessing = HUDRecentDraftWindow( + committed: "one two three four five six seven ", + volatile: "cannot" + ) + let finalized = HUDRecentDraftWindow( + committed: "one two three four five six seven can not", + volatile: "" + ) + #expect(guessing.tokenIDs == [0, 1, 2, 3, 4, 5, 6, 7]) + #expect(finalized.tokenIDs == [1, 2, 3, 4, 5, 6, 7, 8]) + #expect(finalized.advances(from: guessing)) + } +} + +@Suite("Recent draft line overflow") +struct HUDRecentDraftLineTests { + @Test func aLineThatFitsIsCentered() { + let bounds = CGRect(x: 10, y: 0, width: 100, height: 40) + #expect(HUDRecentDraftLine.originX(contentWidth: 40, in: bounds) == 40) + } + + @Test func aLineThatOverflowsKeepsTheTrailingEdge() { + let bounds = CGRect(x: 10, y: 0, width: 100, height: 40) + #expect(HUDRecentDraftLine.originX(contentWidth: 140, in: bounds) == -30) + } +} diff --git a/TalkifyTests/HUDRenderTests.swift b/TalkifyTests/HUDRenderTests.swift index 5552e6d..74575e4 100644 --- a/TalkifyTests/HUDRenderTests.swift +++ b/TalkifyTests/HUDRenderTests.swift @@ -232,6 +232,51 @@ struct HUDRenderTests { _ = try render(card, named: "12-drop-preview-card") } + /// The PNG canvas is the fixed host, so this is a look-at fixture + /// rather than a size assertion. + @Test func glowDraftRendersTheRecentWordLine() throws { + let store = AppSettings.previewStore() + store.voiceVisual = .glowDraft + let content = DictationHUDContent() + content.isRevealed = true + content.showsVoiceVisual = true + content.isAudioAlive = true + content.text = "one two three four five six seven eight" + content.languageTag = "EN" + + _ = try render( + DictationHUDShellView( + screen: notchedScreen, + settings: store.sessionSettings, + content: content + ), + named: "14-glow-draft-text" + ) + } + + /// Ordinary eight-word drafts are wider than the island. This is a + /// look-at fixture for one overflow policy: the newest words stay + /// readable, the oldest clip, and no token ellipsizes on its own. + @Test func glowDraftRendersALongEightWordLine() throws { + let store = AppSettings.previewStore() + store.voiceVisual = .glowDraft + let content = DictationHUDContent() + content.isRevealed = true + content.showsVoiceVisual = true + content.isAudioAlive = true + content.text = "please schedule another meeting with the marketing team" + content.languageTag = "EN" + + _ = try render( + DictationHUDShellView( + screen: notchedScreen, + settings: store.sessionSettings, + content: content + ), + named: "16-glow-draft-long-line" + ) + } + /// The menu bar ghost filling from the bottom as a file job advances, in the /// accent the drop surfaces are wearing. @Test(arguments: [SettingsTheme.accentColor, HUDGlowPalette.sunset.statusAccent]) diff --git a/TalkifyTests/SettingsWindowTests.swift b/TalkifyTests/SettingsWindowTests.swift index b8448aa..7cdf290 100644 --- a/TalkifyTests/SettingsWindowTests.swift +++ b/TalkifyTests/SettingsWindowTests.swift @@ -55,9 +55,20 @@ struct SettingsWindowTests { #expect(AppearanceSettingsView.showsWaveformOptions(for: .waveform)) #expect(!AppearanceSettingsView.showsWaveformOptions(for: .glow)) #expect(!AppearanceSettingsView.showsWaveformOptions(for: .compact)) - #expect(AppearanceSettingsView.showsGlowOptions(for: .glow)) - #expect(!AppearanceSettingsView.showsGlowOptions(for: .waveform)) - #expect(!AppearanceSettingsView.showsGlowOptions(for: .compact)) + #expect(!AppearanceSettingsView.showsWaveformOptions(for: .glowDraft)) + #expect(AppearanceSettingsView.showsGlowPalette(for: .glow)) + #expect(AppearanceSettingsView.showsGlowPalette(for: .glowDraft)) + #expect(!AppearanceSettingsView.showsGlowPalette(for: .waveform)) + #expect(!AppearanceSettingsView.showsGlowPalette(for: .compact)) + #expect(AppearanceSettingsView.showsGlowCenter(for: .glow)) + #expect(!AppearanceSettingsView.showsGlowCenter(for: .glowDraft)) + #expect(!AppearanceSettingsView.showsGlowCenter(for: .waveform)) + #expect(!AppearanceSettingsView.showsLongDraftBehavior( + for: .glowDraft, reduceMotion: false)) + #expect(AppearanceSettingsView.showsLongDraftBehavior( + for: .glowDraft, reduceMotion: true)) + #expect(AppearanceSettingsView.showsLongDraftBehavior( + for: .compact, reduceMotion: false)) } @Test func releaseMetadataExcludesUnlicensedOptions() { diff --git a/TalkifyTests/SmoothLineShapeTests.swift b/TalkifyTests/SmoothLineShapeTests.swift new file mode 100644 index 0000000..72fcbc3 --- /dev/null +++ b/TalkifyTests/SmoothLineShapeTests.swift @@ -0,0 +1,38 @@ +import CoreGraphics +import SwiftUI +import Testing + +@testable import Talkify + +/// The concert rest lift is 0.75pt in a 64-point band. Applied unchanged +/// to the 12-point ribbon (and the 40% HUD-size floor) it is larger than +/// quiet speech, so the line never moves. +@Suite("Smooth line shape") +struct SmoothLineShapeTests { + @Test func quietSpeechMovesInTheRibbon() { + let samples: [Float] = [0, 0.2, 0.4, 0.25, 0] + let ribbon = SmoothLineShape( + samples: samples.map { $0.squareRoot() }, + scrollProgress: 0, + fill: 0.8 + ) + .path(in: CGRect(x: 0, y: 0, width: 200, height: 12)) + + let concertMapping = SmoothLineShape( + samples: samples, + scrollProgress: 0 + ) + .path(in: CGRect(x: 0, y: 0, width: 200, height: 12)) + + #expect(ribbon.boundingRect.height > 2) + #expect(ribbon.boundingRect.height > concertMapping.boundingRect.height) + } + + @Test func theConcertBandStillRestsNearTheMidline() { + let path = SmoothLineShape(samples: [0, 1, 0], scrollProgress: 0) + .path(in: CGRect(x: 0, y: 0, width: 100, height: 64)) + #expect(path.boundingRect.maxY > 30) + #expect(path.boundingRect.maxY < 33) + #expect(path.boundingRect.minY < 16) + } +} diff --git a/docs/design/FEATURE.md b/docs/design/FEATURE.md index ee9c5c4..6583fbd 100644 --- a/docs/design/FEATURE.md +++ b/docs/design/FEATURE.md @@ -48,12 +48,13 @@ the notch, nothing inside it. **Open** is the real target. Open shows a media type glyph, the file name, and nothing else. The edge carries a dashed accent stroke, so it reads as live and receptive. -The accent is not fixed. It follows the voice visual: with Edge Glow selected -the target wears that palette — the full gradient on its edge, where there is -length enough to show a palette off, and the palette's one representative hue -on the glyph, the peek bar and the closing line. Sunset makes an orange target; -Aurora a green one. Every other visual keeps the Talkify blue. It is the same -hue the status ghost takes during a glow session, so a palette means one colour +The accent is not fixed. It follows the voice visual: with Edge Glow or +Edge Glow + Draft selected the target wears that palette — the full gradient +on its edge, where there is length enough to show a palette off, and the +palette's one representative hue on the glyph, the peek bar and the closing +line. Sunset makes an orange target; Aurora a green one. Every other visual +keeps the Talkify blue. It is the same hue the status ghost takes during a +glow session, so a palette means one colour across the whole app — except Settings chrome, which stays blue on purpose. The peek bar breathes for the same reason: a pointer crossing the top of the screen catches movement, never a static tab. diff --git a/docs/design/README.md b/docs/design/README.md index 957d5db..277afca 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -16,7 +16,7 @@ bar, and a dark modal Settings window. | `design-system.md` | Color, type, spacing, radius, elevation, contrast | | `hud-anatomy.md` | The notch island: geometry, bands, states, hard rules | | `motion.md` | Every animation with its real spring and duration | -| `voice-visuals.md` | The three voice visuals and their palettes | +| `voice-visuals.md` | The four voice visuals and their palettes | | `settings-ui.md` | The Settings modal and its component set | | `FEATURE.md` | **Drop Transcription** — the feature being designed | diff --git a/docs/design/hud-anatomy.md b/docs/design/hud-anatomy.md index aa4a545..d612a7d 100644 --- a/docs/design/hud-anatomy.md +++ b/docs/design/hud-anatomy.md @@ -16,7 +16,7 @@ as the notch itself stretching downward. ``` screen top edge ───────┬─────────────────┬─────── - │▓▓▓ housing ▓▓▓ │ ← camera lives here, always empty + │▓▓▓ housing ▓▓▓ │ ← camera lives here; flanks can carry Edge Glow + Draft ╭──────┴─────────────────┴──────╮ │ │ ← visual band (voice visual) │ │ @@ -37,6 +37,7 @@ detached tabs. | Text band height | `36 * scale` min, `120 * scale` max | yes | | Visual band, Reduce Motion | `24 * scale` | yes | | Visual band, waveform / glow | `64 * scale` | yes | +| Edge Glow + Draft hanging stage | `40 * scale` | yes | | Bottom corner radius | `20 * scale` | yes | | Housing, measured notch | whatever the display reports | **no** | | Housing, simulated | `185 × 32` | **no** | @@ -53,25 +54,36 @@ bezel curve. ## The bands -**Housing band.** Always present, always empty. Its height is the notch's -height. Nothing is ever drawn here — content would sit behind the camera. +**Housing band.** Always present. Its height is the notch's height. The +camera sits in the middle of this strip, so Compact, Waveform, Edge Glow, +and status messages leave it empty. Edge Glow + Draft is the exception: its +recent-word line is an overlay centered on the housing-plus-stage +silhouette, because the flanks of that strip are still visible beside the +camera. The camera itself stays empty. **Visual band.** Holds the voice visual. Its height depends on which visual is selected: `64 * scale` for Waveform, `64 * scale` (empty, used as a stage for edge-hugging light) for Edge Glow, `24 * scale` for the Reduce Motion -level meter, and zero for Compact, whose indicator lives inside the text band -instead. - -**Text band.** Holds live draft text. Present for Compact, absent for -Waveform and Edge Glow while listening, and always present under Reduce -Motion. Draft text is 15pt medium white, centered, with three long-draft -behaviors the user picks between: single line with head truncation, wrap and -grow downward capped at four lines, or single line that shrinks to fit. - -**Language tag.** A small capsule pinned to the top leading corner below the -housing, shown only when a second dictation language is configured. It hangs -off the shell as an overlay rather than sitting in the layout, so it never -changes the window's size. +level meter, and zero for Compact and Edge Glow + Draft, whose indicator +and hanging stage live inside the text band instead. + +**Text band.** Holds live draft text. Present for Compact and Edge Glow + +Draft, absent for Waveform and Edge Glow while listening, and always present +under Reduce Motion. Draft text is 15pt medium white and centered, except +Compact (leading, beside the five-bar indicator) and Edge Glow + Draft (a +`24 * scale` recent-word line centered in the island, overlaying housing +plus a `40 * scale` hanging stage). Compact keeps the three long-draft +behaviors the user picks between: single line with head truncation, wrap +and grow downward capped at four lines, or single line that shrinks to +fit. Edge Glow + Draft does not: eight tokens on one line, and a line that +does not fit clips the oldest words instead of ellipsizing each one. + +**Language tag.** A small capsule shown only when a second dictation +language is configured. It hangs off the shell as an overlay rather than +sitting in the layout, so it never changes the window's size. Compact, +Waveform, Edge Glow, and status messages pin it to the top leading corner +below the housing. Edge Glow + Draft overlays it on the leading edge of the +island, vertically centered with the draft. ## Hard rules that keep being violated @@ -86,7 +98,11 @@ itself inside the same fixed window. overshoot lifts the shape off the screen edge and opens a visible gap above it. Scale anchored at `.top` cannot. -**Nothing is drawn in the housing band.** +**Nothing is drawn in the housing band**, except Edge Glow + Draft's +recent-word overlay, which is centered on the housing-plus-stage +silhouette so the line sits in the visible flanks rather than only under +the camera. Status messages, Compact, Waveform, and Edge Glow still leave +the housing empty. **Fillets require a real notch.** Zero otherwise. diff --git a/docs/design/voice-visuals.md b/docs/design/voice-visuals.md index 1cc6e9e..551657a 100644 --- a/docs/design/voice-visuals.md +++ b/docs/design/voice-visuals.md @@ -2,14 +2,14 @@ Source: `Talkify/Dictation/HUD/Visuals/`, `Talkify/CoreHUD/Shaders/`. -The animated thing inside the island that reacts to the user's voice. Three +The animated thing inside the island that reacts to the user's voice. Four of them, user-selectable, default Waveform. Rendering is Metal for the heavy ones (ADR-0002). This document exists mostly so a new surface knows what visual language it is joining, and what colors are already spoken for. -## The three +## The four **Waveform** — a shader waveform filling the `64 * scale` visual band. Nine sub-styles the user picks between: Article, Silver, Capsules, Chart Line @@ -27,10 +27,21 @@ silhouette has flanks for light to wrap around. Also replaces draft text. **Compact** — a five-bar indicator beside leading-aligned live draft text, after the iOS Dynamic Island caption look. Bars are 2.5pt wide capsules, -2.5pt apart, resting at 3pt and reaching about 14pt. This is the only visual -that shows the draft while listening, and the shape grows with the text. - -Under Reduce Motion all three are replaced by a quiet level meter in the slim +2.5pt apart, resting at 3pt and reaching about 14pt. The shape grows with +the text. + +**Edge Glow + Draft** — the last eight word tokens on one `24 * scale` +line centered in the island (housing plus a `40 * scale` stage), with +Edge Glow on the silhouette. No Chart Line ribbon, five-bar indicator, +particle cloud, or orb. The concert waveform styles stay on Waveform. +Glow palette colours the beam, the shaping caption, and the status ghost. +Putting the draft on top of the 64-point waveform was prototyped and +removed; this is the other direction: the words sit in the hanging stage +under the glow. New and departing tokens slide; a finalization does not +fade the line. A line that does not fit keeps the newest words readable +and clips the oldest. + +Under Reduce Motion all four are replaced by a quiet level meter in the slim `24 * scale` band, and the draft text always shows. ## Edge Glow palettes diff --git a/docs/settings-ui-spec.md b/docs/settings-ui-spec.md index 9a5b6b5..6dcfbc4 100644 --- a/docs/settings-ui-spec.md +++ b/docs/settings-ui-spec.md @@ -47,9 +47,10 @@ The content pane must update in place when a navigation item is selected. The se ### Appearance -- Voice visual: Waveform or Edge Glow. +- Voice visual: Waveform, Edge Glow, Edge Glow + Draft, or Compact. - Waveform style, shown only for Waveform. -- Glow palette and glow center, shown only for Edge Glow. +- Glow palette, shown for Edge Glow and Edge Glow + Draft. +- Glow center, shown only for Edge Glow. - Reveal style. - Long draft behavior. - Reduce Motion, using the system setting as the default.