diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 844c989..c6efe8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: ci-check: runs-on: macos-15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode.app diff --git a/AGENTS.md b/AGENTS.md index f0fe9ea..dd8fdaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ addresses, and machine-specific workflow details. - `TranscriptionPipeline`: shared transcribe -> polish -> paste -> persist control flow for stop paths; the ViewModel implements `TranscriptionPipelineHost`. - Strict concurrency is `complete` on app and test targets. Keep new code warning-free instead of widening unsafe isolation. - Engines: MLX Whisper local (default; vendored `LocalPackages/MLXWhisper`), Deepgram Nova-3 batch, Deepgram Flux Live, ElevenLabs Scribe batch/realtime, and Local AI Server batch STT through OpenAI-style endpoints. WhisperKit was removed deliberately (2026-07-06; MLX runs the same weights ~6x faster) — do not reintroduce it, and keep `EnginePortfolioMigration` mapping stored `whisper` selections to `mlx_whisper` and purging the CoreML caches. -- `LocalPackages/MLXWhisper` is vendored from mlx-audio-swift (MIT, pinned commit in its Package.swift header) trimmed to the Whisper model. Local additions: initial-prompt (`<|startofprev|>`) vocabulary support, real auto language detection, and a downloader with progress; sync upstream fixes manually and keep the pin comment current. Its sources are exempt from repo swift-format lint. Building anything that links mlx-swift needs the Xcode Metal Toolchain component (`xcodebuild -downloadComponent MetalToolchain`) and `-skipPackagePluginValidation` on headless xcodebuild (already in the Makefile); plain `swift build` produces a binary without Metal shaders that dies at MLX init — bench with the package's `mlxwhisper-cli` built via xcodebuild instead (XCTest-host numbers are not representative for the GPU path). +- `LocalPackages/MLXWhisper` is vendored from mlx-audio-swift (MIT, pinned commit in its Package.swift header) trimmed to the Whisper model. Local additions: initial-prompt (`<|startofprev|>`) vocabulary support, real auto language detection, a downloader with progress, quantized-checkpoint loading (4-bit), a Task-cancellation hook in the decode loop, and HF snapshots pinned to commit shas (bump revisions in `MLXWhisperModel.revision` + `WhisperModelDownloader.tokenizerRepo`); sync upstream fixes manually and keep the pin comment current. Its sources are exempt from repo swift-format lint. Building anything that links mlx-swift needs the Xcode Metal Toolchain component (`xcodebuild -downloadComponent MetalToolchain`) and `-skipPackagePluginValidation` on headless xcodebuild (already in the Makefile); plain `swift build` produces a binary without Metal shaders that dies at MLX init — bench with the package's `mlxwhisper-cli` built via xcodebuild instead (XCTest-host numbers are not representative for the GPU path). - Audio capture: one class, `AudioCaptureEngine`, serves every engine. `.batch` records a WAV at `AudioUploadQuality`, except whisper-family targets (MLX Whisper, Local AI Server) on the STT-oriented qualities (ultra-fast, medium), which capture 16 kHz directly — whisper decodes at 16 kHz and a higher-rate capture only adds a second resample. `.streaming` keeps fixed 16 kHz mono int16 for WebSocket compatibility and emits PCM chunks (batch is streaming with a nil chunk handler). Do not reintroduce per-path capture classes. - Streaming engines (Flux, ElevenLabs realtime) are driven through `StreamingDictationSession` plus one shared start/stop/pause/abort/binding path in the ViewModel (`StreamingEngineContext`). Do not add per-engine copies of that flow. - Observation: `MLXWhisperTranscriber` and the vocabulary/AI-memory/prompt-context managers are `@Observable` — views read them directly; do not reintroduce `@Published` mirrors in the ViewModel. High-frequency tickers (recording duration) stay OFF ObservableObject state: publish through a subject and subscribe locally in the one view that renders them. diff --git a/CHANGELOG.md b/CHANGELOG.md index e509907..3754c7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,39 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [2.9.0] - 2026-07-09 + +### Added + +- **Large V3 Turbo (4-bit) local model** — a new quantized tier of the recommended turbo model: 464 MB download instead of 1.5 GB and roughly a third of the RAM while transcribing (~0.5 GB vs ~1.6 GB) at comparable quality. The vendored MLX engine now reads quantized checkpoints natively (packed 4-bit weights, quantized tied embeddings), so more tiers can follow. +- **Liquid Glass overlay on macOS 26** — the recording pill and the dock chip render in real glass, and they visually fuse when the droplet detaches from or absorbs into the chip. Older systems keep the exact material look they have today. +- **VoiceOver support across the app** — every icon-only button now has a spoken label (overlay controls, History actions, model rows, vocabulary tools), the overlay announces dictation phases (recording started, transcribing, text pasted, failed), the audio player's scrubber is adjustable with VoiceOver and arrow keys (±5 s), and hover-only affordances like vocabulary chip deletion also reveal on keyboard focus. +- **Export notice** — the History save panels for audio and text exports now remind that the file will contain dictated content before it leaves the app. + +### Changed + +- **Retrying and re-transcribing now run through the same pipeline as live dictations** — retry results update the original History row (no duplicate rows), stale results from an abandoned retry can no longer overwrite a newer dictation, and the audio behind a failed dictation is never cleaned up while it can still be retried. +- **Consistent motion and color language** — animations across Settings, History, and Welcome now use the shared motion tokens (including new transition and shake timings), Welcome steps and pill content swap with a blur-replace transition (Reduce Motion keeps plain crossfades), and green-as-text uses an adaptive darker shade that passes WCAG contrast on light backgrounds. The app also ships a real AccentColor, so selection tint matches the brand in every window. +- **Cloud batch engines pre-warm their connection** when the recording starts, shaving the DNS/TLS handshake (~100–400 ms) off the stop-to-paste wait on cold starts. +- The vendored MLX decode loop honors task cancellation between decode steps — groundwork for cancellable re-transcriptions of long recordings. + +### Fixed + +- **Two latent crash windows in audio setup** — the onboarding microphone probe and the capture-setup cleanup path called AVFAudio APIs outside the engine guard; a device route change at the wrong moment could kill the app with the same uncatchable exception class that caused historic crashes. All engine calls now go through the guard. +- **Settings window no longer clips its content** — the window was created at 800×560 while the content laid out at 860×620 (diverged in v2.2.0); both now share one constant, and the History window minimum matches its real layout. +- **Cmd+, opens the real Settings window** — previously a phantom empty settings window could appear while an app window was focused. +- **History survives database corruption** — a corrupt history database is detected on open (integrity check), sidelined with a timestamped name, and recreated fresh; History keeps working instead of silently doing nothing until reinstall. +- A race between unloading and loading local models could leave a freshly loaded model unloaded (or two resident at once); model loads are now generation-checked. +- "now" and the m/h/d suffixes in History timestamps were hardcoded English in the Spanish UI; they are localized. +- White text on the amber processing color in the menu bar failed contrast badly (≈1.6:1); processing states now use dark text on amber. + +### Security + +- **Pasted text is sanitized before it reaches the clipboard** — control characters, ANSI escape sequences, bidirectional-override characters, and zero-width characters are stripped from polish output and raw fallbacks alike, so a misbehaving or hostile polish endpoint cannot smuggle terminal escapes or spoofed text into what Cmd+V types. +- **Clipboard writes are marked concealed** (`org.nspasteboard.ConcealedType`), so clipboard managers and Universal Clipboard treat dictated text as sensitive instead of syncing and indexing it. +- History storage (database and audio) is created user-only (`0o700`). +- Supply chain hardening: Hugging Face model downloads are pinned to exact commit revisions, GitHub Actions are pinned by commit SHA, an unused Downloads entitlement was removed, and SECURITY.md documents supported versions and the reporting channel. + ## [2.8.0] - 2026-07-06 ### Added diff --git a/LocalPackages/MLXWhisper/Package.swift b/LocalPackages/MLXWhisper/Package.swift index 98d79f4..f361d5e 100644 --- a/LocalPackages/MLXWhisper/Package.swift +++ b/LocalPackages/MLXWhisper/Package.swift @@ -7,8 +7,10 @@ import PackageDescription // (2026-07-03), trimmed to the Whisper model only so the app does not compile // the 18 other STT families, the codecs, or mlx-swift-lm. Local changes on // top of upstream: initial-prompt (`<|startofprev|>`) support so the user -// vocabulary reaches the decoder, and a downloader with real progress -// reporting instead of prints. +// vocabulary reaches the decoder, a downloader with real progress reporting +// instead of prints, 4/8-bit quantized-checkpoint loading (`quantization` in +// config.json), and a Task-cancellation hook between decode steps so long +// transcriptions can be aborted. let package = Package( name: "MLXWhisper", platforms: [.macOS(.v14)], diff --git a/LocalPackages/MLXWhisper/Sources/MLXWhisper/MLXWhisperRuntime.swift b/LocalPackages/MLXWhisper/Sources/MLXWhisper/MLXWhisperRuntime.swift index 3ab7d2a..b69db1d 100644 --- a/LocalPackages/MLXWhisper/Sources/MLXWhisper/MLXWhisperRuntime.swift +++ b/LocalPackages/MLXWhisper/Sources/MLXWhisper/MLXWhisperRuntime.swift @@ -18,11 +18,12 @@ public enum MLXWhisperRuntime { extension WhisperModel { /// Convenience entry point over raw 16 kHz mono samples, so callers do - /// not need to import MLX to build an `MLXArray`. + /// not need to import MLX to build an `MLXArray`. Cancellation-aware: + /// throws `CancellationError` when the surrounding task is cancelled. public func generate( samples: [Float], generationParameters: STTGenerateParameters - ) -> STTOutput { - generate(audio: MLXArray(samples), generationParameters: generationParameters) + ) throws -> STTOutput { + try generateCancellable(audio: MLXArray(samples), generationParameters: generationParameters) } } diff --git a/LocalPackages/MLXWhisper/Sources/MLXWhisper/WhisperConfig.swift b/LocalPackages/MLXWhisper/Sources/MLXWhisper/WhisperConfig.swift index 889ec86..61e35bf 100644 --- a/LocalPackages/MLXWhisper/Sources/MLXWhisper/WhisperConfig.swift +++ b/LocalPackages/MLXWhisper/Sources/MLXWhisper/WhisperConfig.swift @@ -1,5 +1,22 @@ import Foundation +/// `quantization` block written by mlx-community 4/8-bit checkpoints; absent +/// on fp16/fp32 checkpoints. +public struct WhisperQuantization: Codable, Sendable { + public var groupSize: Int + public var bits: Int + + enum CodingKeys: String, CodingKey { + case groupSize = "group_size" + case bits + } + + public init(groupSize: Int, bits: Int) { + self.groupSize = groupSize + self.bits = bits + } +} + public struct WhisperConfig: Codable, Sendable { public var modelType: String public var vocabSize: Int @@ -23,6 +40,8 @@ public struct WhisperConfig: Codable, Sendable { public var scaleEmbedding: Bool + public var quantization: WhisperQuantization? + enum CodingKeys: String, CodingKey { case modelType = "model_type" case vocabSize = "vocab_size" @@ -41,6 +60,7 @@ public struct WhisperConfig: Codable, Sendable { case padTokenId = "pad_token_id" case decoderStartTokenId = "decoder_start_token_id" case scaleEmbedding = "scale_embedding" + case quantization // OpenAI / mlx-whisper layout (mlx-community/whisper-*). case nMels = "n_mels" @@ -72,7 +92,8 @@ public struct WhisperConfig: Codable, Sendable { eosTokenId: Int = 50257, padTokenId: Int = 50257, decoderStartTokenId: Int = 50258, - scaleEmbedding: Bool = false + scaleEmbedding: Bool = false, + quantization: WhisperQuantization? = nil ) { self.modelType = modelType self.vocabSize = vocabSize @@ -91,6 +112,7 @@ public struct WhisperConfig: Codable, Sendable { self.padTokenId = padTokenId self.decoderStartTokenId = decoderStartTokenId self.scaleEmbedding = scaleEmbedding + self.quantization = quantization } public init(from decoder: Swift.Decoder) throws { @@ -137,6 +159,7 @@ public struct WhisperConfig: Codable, Sendable { padTokenId = try c.decodeIfPresent(Int.self, forKey: .padTokenId) ?? 50257 decoderStartTokenId = try c.decodeIfPresent(Int.self, forKey: .decoderStartTokenId) ?? 50258 scaleEmbedding = try c.decodeIfPresent(Bool.self, forKey: .scaleEmbedding) ?? false + quantization = try c.decodeIfPresent(WhisperQuantization.self, forKey: .quantization) } public func encode(to encoder: Swift.Encoder) throws { @@ -158,6 +181,7 @@ public struct WhisperConfig: Codable, Sendable { try c.encode(padTokenId, forKey: .padTokenId) try c.encode(decoderStartTokenId, forKey: .decoderStartTokenId) try c.encode(scaleEmbedding, forKey: .scaleEmbedding) + try c.encodeIfPresent(quantization, forKey: .quantization) } } diff --git a/LocalPackages/MLXWhisper/Sources/MLXWhisper/WhisperModel.swift b/LocalPackages/MLXWhisper/Sources/MLXWhisper/WhisperModel.swift index 862416e..10229d8 100644 --- a/LocalPackages/MLXWhisper/Sources/MLXWhisper/WhisperModel.swift +++ b/LocalPackages/MLXWhisper/Sources/MLXWhisper/WhisperModel.swift @@ -6,8 +6,10 @@ // commit 580e952. Local changes: initial-prompt (`<|startofprev|>`) // support wired through the decode loop, `fromPretrained`/ModelUtils // replaced by the app-driven `WhisperModelDownloader` (progress reporting -// instead of prints), and the STTGenerationModel protocol dropped — -// Whisper is the only vendored model. +// instead of prints), the STTGenerationModel protocol dropped (Whisper is +// the only vendored model), quantized-checkpoint loading (`quantization` +// in config.json), and Task-cancellation checks between decode steps +// (`generateCancellable`). // import Foundation @@ -44,10 +46,28 @@ public final class WhisperModel: Module { ) } + /// Completion-only entry point (mlxwhisper-cli); the app path is the + /// throwing `generateCancellable`, which honors Task cancellation. public func generate( audio: MLXArray, generationParameters: STTGenerateParameters ) -> STTOutput { + do { + return try generateCancellable(audio: audio, generationParameters: generationParameters) + } catch { + // Only Task cancellation escapes the decode loop; outside a + // cancelled task this path is unreachable. + return STTOutput(text: "") + } + } + + /// Same as `generate(audio:generationParameters:)` but checks Task + /// cancellation between 30s windows and decode steps, throwing + /// `CancellationError` so callers can tell a cancel from a failure. + public func generateCancellable( + audio: MLXArray, + generationParameters: STTGenerateParameters + ) throws -> STTOutput { let startTime = Date() let mono = audio.ndim > 1 ? audio.mean(axis: -1) : audio let chunks = chunkAudioFor30sWindows(mono) @@ -61,13 +81,14 @@ public final class WhisperModel: Module { var detectedLanguageToken: Int? = nil for (index, chunk) in chunks.enumerated() { + try Task.checkCancellation() if generationParameters.verbose { let endSeconds = chunk.offsetSeconds + Float(chunk.audio.dim(0)) / Float(WhisperAudioConfig.sampleRate) print( "[Whisper] chunk \(index + 1)/\(chunks.count) \(String(format: "%.1f", chunk.offsetSeconds))s..\(String(format: "%.1f", endSeconds))s" ) } - let (text, promptTokens, generationTokens, lang, langToken) = transcribeChunk( + let (text, promptTokens, generationTokens, lang, langToken) = try transcribeChunk( audio: chunk.audio, generationParameters: generationParameters, initialPromptTokens: initialPromptTokens, @@ -124,40 +145,46 @@ public final class WhisperModel: Module { var detectedLanguage: String? = nil var detectedLanguageToken: Int? = nil - for (index, chunk) in chunks.enumerated() { - if generationParameters.verbose { - let endSeconds = chunk.offsetSeconds + Float(chunk.audio.dim(0)) / Float(WhisperAudioConfig.sampleRate) - print( - "[Whisper] chunk \(index + 1)/\(chunks.count) \(String(format: "%.1f", chunk.offsetSeconds))s..\(String(format: "%.1f", endSeconds))s" - ) - } + do { + for (index, chunk) in chunks.enumerated() { + try Task.checkCancellation() + if generationParameters.verbose { + let endSeconds = chunk.offsetSeconds + Float(chunk.audio.dim(0)) / Float(WhisperAudioConfig.sampleRate) + print( + "[Whisper] chunk \(index + 1)/\(chunks.count) \(String(format: "%.1f", chunk.offsetSeconds))s..\(String(format: "%.1f", endSeconds))s" + ) + } - let (text, promptTokens, generationTokens, lang, langToken) = transcribeChunk( - audio: chunk.audio, - generationParameters: generationParameters, - initialPromptTokens: initialPromptTokens, - languageTokenId: detectedLanguageToken, - onTokenDelta: { delta in - if !delta.isEmpty { - continuation.yield(.token(delta)) + let (text, promptTokens, generationTokens, lang, langToken) = try transcribeChunk( + audio: chunk.audio, + generationParameters: generationParameters, + initialPromptTokens: initialPromptTokens, + languageTokenId: detectedLanguageToken, + onTokenDelta: { delta in + if !delta.isEmpty { + continuation.yield(.token(delta)) + } } + ) + totalPromptTokens += promptTokens + totalGenerationTokens += generationTokens + if detectedLanguage == nil { detectedLanguage = lang } + if detectedLanguageToken == nil { detectedLanguageToken = langToken } + + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + allText.append(trimmed) + let endSeconds = Double(chunk.offsetSeconds) + Double(chunk.audio.dim(0)) / Double(WhisperAudioConfig.sampleRate) + allSegments.append([ + "text": trimmed, + "start": Double(chunk.offsetSeconds), + "end": endSeconds, + ]) } - ) - totalPromptTokens += promptTokens - totalGenerationTokens += generationTokens - if detectedLanguage == nil { detectedLanguage = lang } - if detectedLanguageToken == nil { detectedLanguageToken = langToken } - - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - allText.append(trimmed) - let endSeconds = Double(chunk.offsetSeconds) + Double(chunk.audio.dim(0)) / Double(WhisperAudioConfig.sampleRate) - allSegments.append([ - "text": trimmed, - "start": Double(chunk.offsetSeconds), - "end": endSeconds, - ]) } + } catch { + continuation.finish(throwing: error) + return } let elapsed = Date().timeIntervalSince(startTime) @@ -215,6 +242,12 @@ public final class WhisperModel: Module { return tokenizer.encodePrompt(prompt, maxTargetPositions: config.maxTargetPositions) } + /// Tied-embedding projection via `asLinear`, which stays correct when the + /// embedding is quantized (packed weights cannot be matmul'd directly). + private func projectToVocab(_ hidden: MLXArray) -> MLXArray { + model.decoder.embedTokens.asLinear(hidden) + } + /// Single decoder step from `<|startoftranscript|>` restricted to the /// language tokens — the same detection pass mlx_whisper runs when no /// language is forced (upstream skipped the language slot entirely in @@ -229,7 +262,7 @@ public final class WhisperModel: Module { encoderHidden: encoderHidden, caches: &caches ) - let logits = model.decoder.projectToVocab(hidden[0, -1]) + let logits = projectToVocab(hidden[0, -1]) let logits1D = logits.ndim > 1 ? logits.squeezed() : logits eval(logits1D) @@ -250,7 +283,7 @@ public final class WhisperModel: Module { initialPromptTokens: [Int] = [], languageTokenId: Int? = nil, onTokenDelta: ((String) -> Void)? = nil - ) -> (text: String, promptTokens: Int, generationTokens: Int, language: String?, languageTokenId: Int?) { + ) throws -> (text: String, promptTokens: Int, generationTokens: Int, language: String?, languageTokenId: Int?) { guard let tokenizer else { fatalError("WhisperTokenizer not loaded — call fromDirectory before generate.") } @@ -282,7 +315,7 @@ public final class WhisperModel: Module { encoderHidden: encoderHidden, caches: &caches ) - var logits = model.decoder.projectToVocab(hidden[0, -1]) + var logits = projectToVocab(hidden[0, -1]) eval(logits) var generated: [Int] = [] @@ -301,6 +334,9 @@ public final class WhisperModel: Module { ) for step in 0.. URL { - let tokenizerRepo: String - switch vocabSize { - case 51866: tokenizerRepo = "openai/whisper-large-v3" - case 51865: tokenizerRepo = "openai/whisper-medium" - case 51864: tokenizerRepo = "openai/whisper-medium.en" - default: tokenizerRepo = "openai/whisper-large-v3" - } + let (tokenizerRepo, tokenizerRevision) = WhisperModelDownloader.tokenizerRepo( + forVocabSize: vocabSize + ) let hfToken = ProcessInfo.processInfo.environment["HF_TOKEN"] @@ -687,7 +731,7 @@ public final class WhisperModel: Module { of: repoID, kind: .model, to: targetDir, - revision: "main", + revision: tokenizerRevision, matching: needed, progressHandler: { _ in } ) diff --git a/LocalPackages/MLXWhisper/Sources/MLXWhisper/WhisperModelDownloader.swift b/LocalPackages/MLXWhisper/Sources/MLXWhisper/WhisperModelDownloader.swift index 6b14b5e..e34638f 100644 --- a/LocalPackages/MLXWhisper/Sources/MLXWhisper/WhisperModelDownloader.swift +++ b/LocalPackages/MLXWhisper/Sources/MLXWhisper/WhisperModelDownloader.swift @@ -63,11 +63,14 @@ public enum WhisperModelDownloader { try? FileManager.default.removeItem(at: modelDirectory(repo: repo, root: root)) } - /// Download (or resume/complete) a model snapshot. `progress` receives - /// 0...1 fractions on the main actor. Returns the model directory ready - /// for `WhisperModel.fromDirectory`. + /// Download (or resume/complete) a model snapshot. `revision` should be + /// a pinned commit sha so a moved or compromised branch can never change + /// what lands on disk. `progress` receives 0...1 fractions on the main + /// actor. Returns the model directory ready for + /// `WhisperModel.fromDirectory`. public static func download( repo: String, + revision: String = "main", root: URL, progress: (@MainActor @Sendable (Double) -> Void)? = nil ) async throws -> URL { @@ -85,7 +88,7 @@ public enum WhisperModelDownloader { of: repoID, kind: .model, to: dir, - revision: "main", + revision: revision, matching: ["*.safetensors", "*.json", "*.txt", "merges.txt", "vocab.json"], progressHandler: { snapshotProgress in // Reserve the last 2% for the tokenizer-asset prefetch below. @@ -103,6 +106,16 @@ public enum WhisperModelDownloader { return dir } + /// Tokenizer-asset repo for a Whisper vocab size, pinned to a concrete + /// revision (commit shas resolved 2026-07-09) like the model snapshots. + static func tokenizerRepo(forVocabSize vocabSize: Int) -> (repo: String, revision: String) { + switch vocabSize { + case 51865: return ("openai/whisper-medium", "abdf7c39ab9d0397620ccaea8974cc764cd0953e") + case 51864: return ("openai/whisper-medium.en", "2e98eb6279edf5095af0c8dedb36bdec0acd172b") + default: return ("openai/whisper-large-v3", "06f233fe06e710322aca913c1bc4249a0d71fce1") + } + } + // MARK: - Internals private static func hasNonEmptySafetensors(in dir: URL) -> Bool { @@ -130,17 +143,11 @@ public enum WhisperModelDownloader { var vocabSize = 51866 if let data = try? Data(contentsOf: dir.appendingPathComponent("config.json")), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let size = json["vocab_size"] as? Int + let size = (json["vocab_size"] as? Int) ?? (json["n_vocab"] as? Int) { vocabSize = size } - let tokenizerRepo: String - switch vocabSize { - case 51866: tokenizerRepo = "openai/whisper-large-v3" - case 51865: tokenizerRepo = "openai/whisper-medium" - case 51864: tokenizerRepo = "openai/whisper-medium.en" - default: tokenizerRepo = "openai/whisper-large-v3" - } + let (tokenizerRepo, tokenizerRevision) = Self.tokenizerRepo(forVocabSize: vocabSize) guard let repoID = Repo.ID(rawValue: tokenizerRepo) else { throw WhisperModelDownloadError.invalidRepo(tokenizerRepo) } @@ -149,7 +156,7 @@ public enum WhisperModelDownloader { of: repoID, kind: .model, to: dir, - revision: "main", + revision: tokenizerRevision, matching: [ "tokenizer.json", "tokenizer_config.json", diff --git a/SECURITY.md b/SECURITY.md index 723a46b..8d3ac23 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,11 @@ # Security +## Supported Versions + +Only the latest release receives security fixes. There is no auto-update +channel: update by downloading the newest DMG from GitHub Releases and +verifying its published SHA-256. + ## Secret Handling Do not commit: @@ -16,7 +22,11 @@ The app stores user-provided cloud credentials and optional local-server bearer ## Reporting -For security-sensitive issues, do not post secrets or private recordings in public issues. Open a minimal report that describes the affected area and share sensitive details only through a private maintainer-approved channel. +For security-sensitive issues, do not post secrets or private recordings in public issues. Report privately via GitHub Security Advisories ("Report a vulnerability" on the repository's Security tab). If that channel is unavailable, open a minimal public issue that describes only the affected area and wait for a private follow-up. + +## Data Handling Model + +SapoWhisper is bring-your-own-key: cloud STT and AI-polish requests are sent directly from the app to the provider the user configured, authenticated with the user's own API key (sent in request headers, never in URLs). There is no intermediary backend and no telemetry. Dictation history (transcripts and WAV audio) is stored locally under the user's Library and is protected by FileVault, not by additional app-level encryption; history exports contain raw transcripts. ## Public Repo Boundary diff --git a/SapoWhisper.xcodeproj/project.pbxproj b/SapoWhisper.xcodeproj/project.pbxproj index c58576a..4fe1f5f 100644 --- a/SapoWhisper.xcodeproj/project.pbxproj +++ b/SapoWhisper.xcodeproj/project.pbxproj @@ -216,10 +216,10 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 2.8.0; + MARKETING_VERSION = 2.9.0; PRODUCT_BUNDLE_IDENTIFIER = oli.SapoWhisperTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -236,10 +236,10 @@ buildSettings = { ARCHS = arm64; BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 2.8.0; + MARKETING_VERSION = 2.9.0; PRODUCT_BUNDLE_IDENTIFIER = oli.SapoWhisperTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -385,7 +385,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = SapoWhisper/SapoWhisper.entitlements; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEAD_CODE_STRIPPING = YES; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -400,7 +400,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 2.8.0; + MARKETING_VERSION = 2.9.0; PRODUCT_BUNDLE_IDENTIFIER = oli.SapoWhisper; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -424,7 +424,7 @@ CODE_SIGN_ENTITLEMENTS = SapoWhisper/SapoWhisper.entitlements; CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEAD_CODE_STRIPPING = YES; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -439,7 +439,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 2.8.0; + MARKETING_VERSION = 2.9.0; PRODUCT_BUNDLE_IDENTIFIER = oli.SapoWhisper; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/SapoWhisper/App/MenuBarHosts.swift b/SapoWhisper/App/MenuBarHosts.swift index 437234b..f3275cf 100644 --- a/SapoWhisper/App/MenuBarHosts.swift +++ b/SapoWhisper/App/MenuBarHosts.swift @@ -53,6 +53,7 @@ struct HistoryWindowHost: View { var body: some View { HistoryView(viewModel: viewModel) .environment(\.locale, localizationManager.locale) + .tint(Constants.Colors.sapoGreen) .id(localizationManager.language) } } diff --git a/SapoWhisper/App/MenuBarStatusController.swift b/SapoWhisper/App/MenuBarStatusController.swift index 92c49ac..66f57a2 100644 --- a/SapoWhisper/App/MenuBarStatusController.swift +++ b/SapoWhisper/App/MenuBarStatusController.swift @@ -30,6 +30,7 @@ final class MenuBarStatusController: NSObject, NSPopoverDelegate { private var settingsOpenCount = 0 private var historyOpenCount = 0 private var historyFocusObserver: NSObjectProtocol? + private var settingsOpenObserver: NSObjectProtocol? init(viewModel: SapoWhisperViewModel) { self.viewModel = viewModel @@ -49,6 +50,15 @@ final class MenuBarStatusController: NSObject, NSPopoverDelegate { self?.openHistoryWindow() } } + // Cmd+, replaces the phantom Settings scene: the app-menu command + // requests the controller-managed window through this notification. + settingsOpenObserver = NotificationCenter.default.addObserver( + forName: SettingsOpenRequest.notification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.openSettingsWindow() + } + } } func closePopover() { @@ -73,6 +83,8 @@ final class MenuBarStatusController: NSObject, NSPopoverDelegate { button.target = self button.action = #selector(togglePopover) button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + button.toolTip = Constants.appName + button.setAccessibilityLabel(Constants.appName) } private func setupPopover() { @@ -301,7 +313,7 @@ final class MenuBarStatusController: NSObject, NSPopoverDelegate { let controller = settingsWindowController ?? makeWindowController( - size: NSSize(width: 800, height: 560), + size: Constants.Windows.settingsSize, resizable: false, rootView: SettingsWindowHost(viewModel: viewModel) ) @@ -409,7 +421,9 @@ final class MenuBarStatusController: NSObject, NSPopoverDelegate { window.setContentSize(size) if resizable { - window.contentMinSize = NSSize(width: 700, height: 420) + // Must not undercut the SwiftUI root's minWidth/minHeight or the + // window can shrink past its content and clip it. + window.contentMinSize = Constants.Windows.historyMinSize } return NSWindowController(window: window) diff --git a/SapoWhisper/App/SapoWhisperApp.swift b/SapoWhisper/App/SapoWhisperApp.swift index 066a941..dba9570 100644 --- a/SapoWhisper/App/SapoWhisperApp.swift +++ b/SapoWhisper/App/SapoWhisperApp.swift @@ -6,6 +6,14 @@ import SwiftUI +/// Cmd+, arrives at the SwiftUI command layer, but the real settings window +/// is AppKit-managed by MenuBarStatusController — the replaced app-settings +/// command requests it through this notification (same cross-scene pattern +/// as HistoryFocusRequest). +enum SettingsOpenRequest { + static let notification = Notification.Name("SapoWhisperSettingsOpenRequest") +} + @main struct SapoWhisperApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @@ -16,8 +24,22 @@ struct SapoWhisperApp: App { } var body: some Scene { + // Placeholder scene only (an App must declare one). Replacing the + // app-settings command keeps Cmd+, and the Settings menu item away + // from this empty window and routes them to the real AppKit-managed + // settings window instead. Settings { EmptyView() } + .commands { + CommandGroup(replacing: .appSettings) { + Button("menu.settings".localized) { + NotificationCenter.default.post( + name: SettingsOpenRequest.notification, object: nil + ) + } + .keyboardShortcut(",", modifiers: .command) + } + } } } diff --git a/SapoWhisper/Assets.xcassets/AccentColor.colorset/Contents.json b/SapoWhisper/Assets.xcassets/AccentColor.colorset/Contents.json index eb87897..f609826 100644 --- a/SapoWhisper/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/SapoWhisper/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,6 +1,33 @@ { "colors" : [ { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x50", + "green" : "0xAF", + "red" : "0x4C" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x6A", + "green" : "0xBB", + "red" : "0x66" + } + }, "idiom" : "universal" } ], diff --git a/SapoWhisper/Core/AudioCaptureEngine+Device.swift b/SapoWhisper/Core/AudioCaptureEngine+Device.swift index 1b0a23b..1be59ee 100644 --- a/SapoWhisper/Core/AudioCaptureEngine+Device.swift +++ b/SapoWhisper/Core/AudioCaptureEngine+Device.swift @@ -159,9 +159,13 @@ nonisolated extension AudioCaptureEngine { "\(self.mode.logLabel, privacy: .public) capture interrupted event=\(event.rawValue, privacy: .public) attempt=\(attempt, privacy: .public)" ) - oldEngine.inputNode.removeTap(onBus: 0) - oldEngine.stop() - oldEngine.reset() + // Teardown races the same route churn that triggered recovery; a + // guarded exception here just means the engine is already dead. + try? AudioEngineGuard.run("recovery-teardown") { + oldEngine.inputNode.removeTap(onBus: 0) + oldEngine.stop() + oldEngine.reset() + } audioEngine = nil guard attempt <= 2 else { diff --git a/SapoWhisper/Core/AudioCaptureEngine.swift b/SapoWhisper/Core/AudioCaptureEngine.swift index 03b01c1..577c925 100644 --- a/SapoWhisper/Core/AudioCaptureEngine.swift +++ b/SapoWhisper/Core/AudioCaptureEngine.swift @@ -352,6 +352,9 @@ nonisolated final class AudioCaptureEngine: @unchecked Sendable { // Back on caller context (MainActor) — flip published state only; the // engine/file/url references were assigned on the setup queue (A4). + // Debug-only tripwire: the VM sinks consume these without receive(on:), + // so a non-main caller would corrupt appState silently. + MainActor.assertIsolated() isRecording = true isPaused = false accumulatedDuration = 0 @@ -404,9 +407,13 @@ nonisolated final class AudioCaptureEngine: @unchecked Sendable { /// Must run on `audioSetupQueue`. private func finalizeCaptureOnQueue() -> URL? { deviceSentinel.end() - audioEngine?.inputNode.removeTap(onBus: 0) - audioEngine?.stop() - audioEngine?.reset() + if let engine = audioEngine { + try? AudioEngineGuard.run("finalize-capture-teardown") { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + engine.reset() + } + } _ = flushRemainingConvertedAudio() // A1: drain pending async writes before releasing the file so the WAV @@ -427,6 +434,7 @@ nonisolated final class AudioCaptureEngine: @unchecked Sendable { } private func completeStop(url: URL?, stopStart: CFAbsoluteTime, logSummary: Bool) -> RecordingCaptureDiagnostics { + MainActor.assertIsolated() isRecording = false isPaused = false diff --git a/SapoWhisper/Core/AudioLevelMonitor.swift b/SapoWhisper/Core/AudioLevelMonitor.swift index 7db8836..3fe1d58 100644 --- a/SapoWhisper/Core/AudioLevelMonitor.swift +++ b/SapoWhisper/Core/AudioLevelMonitor.swift @@ -204,11 +204,13 @@ class AudioLevelMonitor: ObservableObject, @unchecked Sendable { /// Limpia recursos sin cambiar el estado de monitoreo private nonisolated func cleanupEngineOnQueue() { if let engine = audioEngine { - if engine.isRunning { - engine.inputNode.removeTap(onBus: 0) - engine.stop() + try? AudioEngineGuard.run("monitor-cleanup-teardown") { + if engine.isRunning { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + } + engine.reset() } - engine.reset() } audioEngine = nil sampleTapFormat = nil @@ -336,7 +338,12 @@ class AudioLevelMonitor: ObservableObject, @unchecked Sendable { } guard let audioEngine = monitorSnapshot.engine, monitorSnapshot.isRunning else { return } - let tapFormat = monitorSnapshot.tapFormat ?? audioEngine.inputNode.outputFormat(forBus: 0) + guard + let tapFormat = monitorSnapshot.tapFormat + ?? (try? AudioEngineGuard.run("sample-tap-format") { + audioEngine.inputNode.outputFormat(forBus: 0) + }) + else { return } let rawURL = TemporaryAudioStorage.makeWAVURL(prefix: "mic_test_raw") do { diff --git a/SapoWhisper/Core/DeepgramBatchTranscriber.swift b/SapoWhisper/Core/DeepgramBatchTranscriber.swift index c250c4a..1718175 100644 --- a/SapoWhisper/Core/DeepgramBatchTranscriber.swift +++ b/SapoWhisper/Core/DeepgramBatchTranscriber.swift @@ -110,6 +110,19 @@ class DeepgramBatchTranscriber: ObservableObject { return trimmed } + // MARK: - Connection Warm-Up + + /// Opens DNS+TLS to the API host while the user is still dictating, so a + /// cold connection pool never sits inside stop→paste. No auth, response + /// ignored — only the handshake matters (URLSession reuses it). + func warmUpConnection() async { + guard let url = URL(string: "https://api.deepgram.com") else { return } + var request = URLRequest(url: url) + request.httpMethod = "HEAD" + request.timeoutInterval = 3 + _ = try? await URLSession.shared.data(for: request) + } + // MARK: - Audio Compression /// Convert WAV float32 to int16 WAV for faster upload (~2x smaller) diff --git a/SapoWhisper/Core/ElevenLabsScribeRealtimeTranscriber.swift b/SapoWhisper/Core/ElevenLabsScribeRealtimeTranscriber.swift index c093a6e..22beb6a 100644 --- a/SapoWhisper/Core/ElevenLabsScribeRealtimeTranscriber.swift +++ b/SapoWhisper/Core/ElevenLabsScribeRealtimeTranscriber.swift @@ -652,72 +652,8 @@ final class ElevenLabsScribeRealtimeTranscriber: ObservableObject { return transcript.isEmpty ? pendingPartial : "\(transcript) \(pendingPartial)" } - func transcribe(audioURL: URL, language: String) async throws -> String { - guard let apiKey = KeychainStore.string(for: .elevenLabsAPIKey), - !apiKey.isEmpty - else { - throw TranscriptionFailure(kind: .notConfigured, engine: Self.engineName) - } - - resetSessionState() - requestedLanguage = language - isStopping = true - let keytermPayload = VocabularyManager.shared.recognitionKeytermPayload( - maxCount: Self.maxRealtimeKeyterms, - maxLength: Self.maxRealtimeKeytermLength, - includeReplacementValues: true - ) - let keyterms = keytermPayload.terms - let task = Self.makeWebSocketTask(apiKey: apiKey, language: language, keyterms: keyterms) - webSocketTask = task - audioSender.start(task: task) - task.resume() - SapoLog.recording.info( - "ElevenLabs realtime file replay opened keyterms=\(keyterms.count, privacy: .public) keytermsDropped=\(keytermPayload.droppedCount, privacy: .public)" - ) - - receiveTask = Task { [weak self] in - await self?.receiveMessages() - } - - let startedAt = CFAbsoluteTimeGetCurrent() - defer { cleanupWebSocket() } - let pcmData = try Self.extractPCM16MonoData(from: audioURL) - try await sendReplayAudio(pcmData) - let committedCountBeforeFinalCommit = transcriptAccumulator.committedCount - let stats = await audioSender.finishAndCommit(timeout: 4.0) - - // Preserve any already-committed segments on a failed final-commit - // send; only fail outright when nothing was captured. - if stats.failedMessages > 0 && transcriptAccumulator.transcript.isEmpty { - throw TranscriptionFailure( - kind: .network, - engine: Self.engineName, - technicalDetail: "ElevenLabs realtime file replay failedMessages=\(stats.failedMessages)" - ) - } - - let transcript = try await waitForFinalTranscript( - timeout: 6.0, - committedCountBeforeFinalCommit: committedCountBeforeFinalCommit - ) - let salvagedTranscript = salvagingPendingPartial( - transcript, - committedCountBeforeFinalCommit: committedCountBeforeFinalCommit - ) - let cleanedTranscript = VocabularyManager.shared - .applyingRecognitionCorrections(to: salvagedTranscript) - .trimmingCharacters(in: .whitespacesAndNewlines) - let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000) - SapoLog.recording.info( - "ElevenLabs realtime file replay finished elapsed=\(elapsedMs, privacy: .public)ms audioBytes=\(pcmData.count, privacy: .public) chars=\(cleanedTranscript.count, privacy: .public)" - ) - - guard !cleanedTranscript.isEmpty else { - throw TranscriptionFailure(kind: .emptyTranscription, engine: Self.engineName) - } - return cleanedTranscript - } + // File transcription (retry, history, resume-merge) always routes to the + // batch endpoint — the old WebSocket replay path was dead code and is gone. func cancel() { capture.discardRecording() @@ -893,17 +829,6 @@ final class ElevenLabsScribeRealtimeTranscriber: ObservableObject { return false } - private func sendReplayAudio(_ pcmData: Data) async throws { - let chunkBytes = 6_400 - var offset = 0 - while offset < pcmData.count { - let end = min(offset + chunkBytes, pcmData.count) - audioSender.enqueue(pcmData.subdata(in: offset.. Int { + if model != nil { + // Direct load-over-load: drop the resident weights and their + // buffer pool before the new ones allocate, or peak RAM briefly + // doubles and the old pool stays cached. + unload() + } model = try await WhisperModel.fromDirectory(directory) loadGeneration += 1 return loadGeneration @@ -52,7 +58,10 @@ actor MLXWhisperEngine { language: language, initialPrompt: initialPrompt ) - return model.generate(samples: samples, generationParameters: parameters) + // Throws CancellationError when the caller's task is cancelled — the + // decode loop checks between windows and steps, so a retranscribe of + // a long WAV stops early instead of running to completion. + return try model.generate(samples: samples, generationParameters: parameters) } } @@ -143,6 +152,9 @@ class MLXWhisperTranscriber { private var currentModel: MLXWhisperModel? private var loadingModel: MLXWhisperModel? private var loadTask: Task? + /// Generation of the currently resident model; a late fire-and-forget + /// unload targets this generation so it can never clobber a newer load. + private var currentLoadGeneration = 0 @ObservationIgnored private var downloadTasks: [MLXWhisperModel: Task] = [:] private var idleUnloadTimer: Timer? @@ -240,6 +252,7 @@ class MLXWhisperTranscriber { loadingProgress = 1.0 currentModel = model currentModelName = model.displayName + currentLoadGeneration = generation isModelLoaded = true noteActivityForIdleUnload() SapoLog.recording.info( @@ -317,6 +330,7 @@ class MLXWhisperTranscriber { do { let directory = try await WhisperModelDownloader.download( repo: model.rawValue, + revision: model.revision, root: Self.modelsRootDirectory, progress: { fraction in guard let self else { return } @@ -371,8 +385,12 @@ class MLXWhisperTranscriber { loadingState = .idle currentModel = nil currentModelName = nil + // Generation-scoped: if a newer load lands on the actor before this + // task, the stale unload is a no-op instead of dropping fresh weights + // (Task.cancel alone never frees what a load already allocated). + let generation = currentLoadGeneration Task { - await engine.unload() + await engine.unload(ifGeneration: generation) } SapoLog.recording.info("MLX model unloaded") } @@ -454,6 +472,11 @@ class MLXWhisperTranscriber { "MLX transcription complete chars=\(transcription.count, privacy: .public) seconds=\(String(format: "%.2f", output.totalTime), privacy: .public) promptTokens=\(output.promptTokens, privacy: .public)" ) return transcription + } catch is CancellationError { + // Cancel is not a failure: no errorMessage, callers see the + // CancellationError itself. + SapoLog.recording.info("MLX transcription cancelled") + throw CancellationError() } catch let error as MLXWhisperError { errorMessage = error.localizedDescription throw error diff --git a/SapoWhisper/Core/Managers/AudioInputPreflightManager.swift b/SapoWhisper/Core/Managers/AudioInputPreflightManager.swift index 51c87ce..4405967 100644 --- a/SapoWhisper/Core/Managers/AudioInputPreflightManager.swift +++ b/SapoWhisper/Core/Managers/AudioInputPreflightManager.swift @@ -153,13 +153,17 @@ final class AudioInputPreflightManager { ) { _, _ in } inputNode.volume = 0 try AudioEngineGuard.prepareAndStart(engine, operation: "preflight-engine-start") - engine.stop() - inputNode.removeTap(onBus: 0) - engine.reset() + try? AudioEngineGuard.run("preflight-teardown") { + engine.stop() + inputNode.removeTap(onBus: 0) + engine.reset() + } consecutiveWarmupFailures = 0 } catch { - engine.stop() - engine.reset() + try? AudioEngineGuard.run("preflight-failure-teardown") { + engine.stop() + engine.reset() + } SapoLog.audioRoute.warning( "Audio input preflight warm-up failed error=\(error.localizedDescription, privacy: .public)" ) diff --git a/SapoWhisper/Core/Managers/HistoryAudioStorage.swift b/SapoWhisper/Core/Managers/HistoryAudioStorage.swift index 3b6de1f..5961ead 100644 --- a/SapoWhisper/Core/Managers/HistoryAudioStorage.swift +++ b/SapoWhisper/Core/Managers/HistoryAudioStorage.swift @@ -24,6 +24,12 @@ nonisolated final class HistoryAudioStorage: Sendable { init(appDirectory: URL) { audioDir = appDirectory.appendingPathComponent("audio") try? FileManager.default.createDirectory(at: audioDir, withIntermediateDirectories: true) + // Voice recordings + transcripts DB live here: owner-only access, so + // other same-user processes can't casually read them. + for dir in [appDirectory, audioDir] { + try? FileManager.default.setAttributes( + [.posixPermissions: 0o700], ofItemAtPath: dir.path) + } } func saveAudioFile(from sourceURL: URL) -> String? { diff --git a/SapoWhisper/Core/Managers/HotkeyManager.swift b/SapoWhisper/Core/Managers/HotkeyManager.swift index 1c7af55..4c97d15 100644 --- a/SapoWhisper/Core/Managers/HotkeyManager.swift +++ b/SapoWhisper/Core/Managers/HotkeyManager.swift @@ -228,10 +228,15 @@ class HotkeyManager: ObservableObject { &hotkeyID ) let manager = Unmanaged.fromOpaque(userData).takeUnretainedValue() - if hotkeyID.id == HotkeyManager.cancelHotkeyID { - manager.handleCancelKeyPressed() - } else { - manager.handleHotkeyPressed(source: "key-combination") + // Carbon delivers on the main run loop (GetApplicationEventTarget); + // make the C→MainActor hop explicit so a Swift 6 language-mode + // flip gets a check instead of silent UB. + MainActor.assumeIsolated { + if hotkeyID.id == HotkeyManager.cancelHotkeyID { + manager.handleCancelKeyPressed() + } else { + manager.handleHotkeyPressed(source: "key-combination") + } } return noErr }, @@ -367,17 +372,21 @@ class HotkeyManager: ObservableObject { } let manager = Unmanaged.fromOpaque(userData).takeUnretainedValue() - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - manager.enableEventTap() + // The tap runs on CFRunLoopGetMain; make the C→MainActor + // hop explicit (same rationale as the Carbon handler). + return MainActor.assumeIsolated { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + manager.enableEventTap() + return Unmanaged.passUnretained(event) + } + + guard type == .flagsChanged else { + return Unmanaged.passUnretained(event) + } + + manager.handleFlagsChanged(event) return Unmanaged.passUnretained(event) } - - guard type == .flagsChanged else { - return Unmanaged.passUnretained(event) - } - - manager.handleFlagsChanged(event) - return Unmanaged.passUnretained(event) }, userInfo: Unmanaged.passUnretained(self).toOpaque() ) diff --git a/SapoWhisper/Core/Managers/OverlayWindowManager.swift b/SapoWhisper/Core/Managers/OverlayWindowManager.swift index 7e451d2..1d4018f 100644 --- a/SapoWhisper/Core/Managers/OverlayWindowManager.swift +++ b/SapoWhisper/Core/Managers/OverlayWindowManager.swift @@ -367,7 +367,8 @@ class OverlayWindowManager: ObservableObject { } updateDisplayedSecond(for: newState) - let leavingDock = state.stateCategory == "docked" + let previousCategory = state.stateCategory + let leavingDock = previousCategory == "docked" // A fresh presentation (leaving the dock, or appearing from hidden) // opens on the screen the user is working on — the mouse screen. The @@ -405,6 +406,7 @@ class OverlayWindowManager: ObservableObject { resumeOffer = nil } SapoLog.overlay.info("Overlay state changed to \(newState.stateCategory, privacy: .public)") + announceStateTransition(from: previousCategory, to: newState) if case .recording = newState { beginMeterSession() @@ -416,6 +418,38 @@ class OverlayWindowManager: ObservableObject { } } + /// Speaks meaningful phase transitions to VoiceOver. The overlay is a + /// non-activating transparent panel VoiceOver never focuses, so the + /// announcement must be posted for the application element — posting it + /// on the panel itself is silently dropped. Medium priority reads politely + /// without interrupting; only category CHANGES speak (duration ticks and + /// same-state refreshes bypass or guard out). + private func announceStateTransition(from previousCategory: String, to newState: RecordingOverlayState) { + guard newState.stateCategory != previousCategory else { return } + + let key: String? + switch newState { + case .recording: key = "overlay.a11y.recording_started" + case .transcribing: key = "overlay.a11y.transcribing" + case .copied: key = "overlay.a11y.pasted" + case .error: key = "overlay.a11y.error" + default: key = nil + } + guard let key else { return } + + NSAccessibility.post( + element: NSApplication.shared, + notification: .announcementRequested, + userInfo: [ + .announcement: key.localized, + .priority: NSAccessibilityPriorityLevel.medium.rawValue, + ] + ) + SapoLog.overlay.info( + "Overlay VO announcement for \(newState.stateCategory, privacy: .public)" + ) + } + /// Actualiza el nivel de audio (para el ecualizador) func updateAudioLevel(_ level: Float) { if meterSessionStartedAt != nil { diff --git a/SapoWhisper/Core/Managers/PasteManager.swift b/SapoWhisper/Core/Managers/PasteManager.swift index 7a1fca6..50ec32f 100644 --- a/SapoWhisper/Core/Managers/PasteManager.swift +++ b/SapoWhisper/Core/Managers/PasteManager.swift @@ -32,6 +32,10 @@ class PasteManager { let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(text, forType: .string) + // nspasteboard.org convention: dictated speech is sensitive by + // default, so mark it concealed — clipboard managers skip it and it + // stays out of their synced histories. + pasteboard.setString("", forType: NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType")) SapoLog.menuBar.info("Clipboard updated chars=\(text.count, privacy: .public)") } diff --git a/SapoWhisper/Core/Managers/TranscriptionHistoryManager.swift b/SapoWhisper/Core/Managers/TranscriptionHistoryManager.swift index 06e6156..db5c196 100644 --- a/SapoWhisper/Core/Managers/TranscriptionHistoryManager.swift +++ b/SapoWhisper/Core/Managers/TranscriptionHistoryManager.swift @@ -49,14 +49,62 @@ nonisolated class TranscriptionHistoryManager: @unchecked Sendable { // FULLMUTEX: history persistence runs off the paste path (background // task) while the UI reads from the main thread on this connection. let openFlags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX - if sqlite3_open_v2(databasePath, &db, openFlags, nil) == SQLITE_OK { + if sqlite3_open_v2(databasePath, &db, openFlags, nil) == SQLITE_OK, integrityCheckPasses() { configureDatabase() createTable() migrateSchema() createIndexes() SapoLog.recording.info("History DB opened") } else { - SapoLog.recording.error("Failed to open history DB") + // A corrupt or unopenable file otherwise no-ops every later call + // and History dies silently forever. + recoverCorruptDatabase(at: databasePath, openFlags: openFlags) + } + } + + /// Cheap `PRAGMA integrity_check(1)` at open time; anything but "ok" + /// routes through corruption recovery. + private func integrityCheckPasses() -> Bool { + var stmt: OpaquePointer? + defer { sqlite3_finalize(stmt) } + guard sqlite3_prepare_v2(db, "PRAGMA integrity_check(1)", -1, &stmt, nil) == SQLITE_OK, + sqlite3_step(stmt) == SQLITE_ROW, + let cString = sqlite3_column_text(stmt, 0) + else { return false } + return String(cString: cString).lowercased() == "ok" + } + + /// Sidelines the corrupt file (plus WAL/SHM) with a timestamp and + /// recreates a fresh schema — dictation keeps persisting and the evidence + /// stays on disk for inspection. + private func recoverCorruptDatabase(at databasePath: String, openFlags: Int32) { + sqlite3_close(db) + db = nil + guard databasePath != ":memory:" else { + SapoLog.recording.error("Failed to open in-memory history DB") + return + } + + let stamp = Int(Date().timeIntervalSince1970) + let fileManager = FileManager.default + for suffix in ["", "-wal", "-shm"] { + let source = databasePath + suffix + guard fileManager.fileExists(atPath: source) else { continue } + try? fileManager.moveItem( + atPath: source, toPath: databasePath + ".corrupt-\(stamp)" + suffix) + } + SapoLog.recording.error("History DB corrupt or unopenable; sidelined stamp=\(stamp, privacy: .public)") + + if sqlite3_open_v2(databasePath, &db, openFlags, nil) == SQLITE_OK { + configureDatabase() + createTable() + migrateSchema() + createIndexes() + SapoLog.recording.info("History DB recreated after corruption") + } else { + sqlite3_close(db) + db = nil + SapoLog.recording.error("Failed to recreate history DB after corruption") } } diff --git a/SapoWhisper/Core/Permissions/MicrophonePermission.swift b/SapoWhisper/Core/Permissions/MicrophonePermission.swift index 831e650..ec6021a 100644 --- a/SapoWhisper/Core/Permissions/MicrophonePermission.swift +++ b/SapoWhisper/Core/Permissions/MicrophonePermission.swift @@ -129,25 +129,31 @@ nonisolated enum MicrophonePermission { private static func probeAudioInput() -> Bool { let engine = AVAudioEngine() - let inputNode = engine.inputNode - let format = inputNode.outputFormat(forBus: 0) + do { + // AudioEngineGuard: AVFAudio asserts with uncatchable NSExceptions + // mid route transition; a guarded throw means "cannot start input". + let inputNode = try AudioEngineGuard.inputNode(of: engine, operation: "probe-input-node") + let format = inputNode.outputFormat(forBus: 0) - guard format.sampleRate > 0, format.channelCount > 0 else { - return false - } + guard format.sampleRate > 0, format.channelCount > 0 else { + return false + } - inputNode.installTap(onBus: 0, bufferSize: 64, format: format) { _, _ in } - defer { - inputNode.removeTap(onBus: 0) - if engine.isRunning { - engine.stop() + try AudioEngineGuard.installTap( + on: inputNode, bufferSize: 64, format: format, operation: "probe-install-tap" + ) { _, _ in } + defer { + // Best-effort teardown; the probe engine is discarded either way. + try? AudioEngineGuard.run("probe-cleanup") { + inputNode.removeTap(onBus: 0) + if engine.isRunning { + engine.stop() + } + engine.reset() + } } - engine.reset() - } - do { - engine.prepare() - try engine.start() + try AudioEngineGuard.prepareAndStart(engine, operation: "probe-engine-start") return engine.isRunning } catch { return false diff --git a/SapoWhisper/Core/PostProcessing/PolishOutputSanitizer.swift b/SapoWhisper/Core/PostProcessing/PolishOutputSanitizer.swift index 4301c44..83e25cc 100644 --- a/SapoWhisper/Core/PostProcessing/PolishOutputSanitizer.swift +++ b/SapoWhisper/Core/PostProcessing/PolishOutputSanitizer.swift @@ -11,7 +11,8 @@ import Foundation enum PolishOutputSanitizer { static func clean(_ output: String, rawText: String) -> String { - var text = output.trimmingCharacters(in: .whitespacesAndNewlines) + let safeOutput = strippingUnsafeControlCharacters(from: output) + var text = safeOutput.trimmingCharacters(in: .whitespacesAndNewlines) // An unterminated means the whole output is reasoning that // never reached an answer; there is nothing usable to fall back to, // so return empty and let the pipeline keep the raw transcript. @@ -24,7 +25,35 @@ enum PolishOutputSanitizer { text = stripTranscriptDelimiters(text) text = stripWrappingQuotes(text, rawText: rawText) let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines) - return cleaned.isEmpty ? output.trimmingCharacters(in: .whitespacesAndNewlines) : cleaned + return cleaned.isEmpty ? safeOutput.trimmingCharacters(in: .whitespacesAndNewlines) : cleaned + } + + /// The polish guards are retry-only and the last output still ships after + /// the retry budget, so this strip is the single boundary between provider + /// output and the user's clipboard/paste: control bytes (ANSI/OSC escape + /// sequences), bidi overrides, and invisible characters never survive. + /// ZWJ/ZWNJ stay (emoji and legitimate scripts); `\n` and `\t` stay. + private static func strippingUnsafeControlCharacters(from text: String) -> String { + let normalized = text.replacingOccurrences(of: "\r\n", with: "\n") + var scalars = String.UnicodeScalarView() + scalars.reserveCapacity(normalized.unicodeScalars.count) + for scalar in normalized.unicodeScalars { + switch scalar.value { + case 0x09, 0x0A: + scalars.append(scalar) + case 0x0D: + scalars.append("\n") + case 0x00...0x1F, 0x7F, 0x80...0x9F: + break // C0 (ESC and friends), DEL, C1 + case 0x202A...0x202E, 0x2066...0x2069: + break // bidi embeddings/overrides/isolates + case 0x200B, 0x2060, 0xFEFF: + break // zero-width space, word joiner, BOM + default: + scalars.append(scalar) + } + } + return String(scalars) } /// Reasoning-tuned local models can leak a leading block diff --git a/SapoWhisper/Core/PostProcessing/TranscriptPolishPromptBuilder.swift b/SapoWhisper/Core/PostProcessing/TranscriptPolishPromptBuilder.swift index d83a9db..94a7b76 100644 --- a/SapoWhisper/Core/PostProcessing/TranscriptPolishPromptBuilder.swift +++ b/SapoWhisper/Core/PostProcessing/TranscriptPolishPromptBuilder.swift @@ -91,8 +91,7 @@ enum TranscriptPolishPromptBuilder { /// Compact mode: extract every requirement and rewrite the dictation as /// the shortest faithful text. One whole-transcript call (no chunking — /// merging repeated ideas needs the global view). Prompt is the c3 variant - /// benched against real history on 6 cloud + 3 local models, 2026-07-05 - /// (see brain/lessons/, IABrain benchmarks/sapowhisper-polish). + /// benched against real history on 6 cloud + 3 local models, 2026-07-05. static func makeCompactMessages( rawText: String, personalContext: String, diff --git a/SapoWhisper/Core/SapoWhisperViewModel.swift b/SapoWhisper/Core/SapoWhisperViewModel.swift index 44e5e1e..f2983ef 100644 --- a/SapoWhisper/Core/SapoWhisperViewModel.swift +++ b/SapoWhisper/Core/SapoWhisperViewModel.swift @@ -389,7 +389,7 @@ class SapoWhisperViewModel: ObservableObject { audioRecorder.isRecordingPublisher .sink { [weak self] isRecording in if isRecording { - self?.appState = .recording + self?.transition(to: .recording, reason: "capture-started") } } .store(in: &cancellables) @@ -412,7 +412,7 @@ class SapoWhisperViewModel: ObservableObject { mlxWhisperTranscriber.onTranscribingChanged = { [weak self] isTranscribing in guard let self, !self.isReprocessingHistory else { return } if isTranscribing { - self.appState = .processing + self.transition(to: .processing, reason: "mlx-transcribing") } } mlxWhisperTranscriber.onLoadingChanged = { [weak self] isLoading in @@ -429,7 +429,7 @@ class SapoWhisperViewModel: ObservableObject { // "no model" state so it never clobbers .recording/.processing/ // .polishing (mirrors the guard in loadMLXWhisperModel()). if case .noModel = self.appState { - self.appState = .idle + self.transition(to: .idle, reason: "model-loaded") } } // A standalone Settings download that finishes for the ACTIVE @@ -449,7 +449,7 @@ class SapoWhisperViewModel: ObservableObject { .sink { [weak self] isTranscribing in guard let self, !self.isReprocessingHistory else { return } if isTranscribing { - self.appState = .processing + self.transition(to: .processing, reason: "elevenlabs-transcribing") } } .store(in: &cancellables) @@ -526,7 +526,7 @@ class SapoWhisperViewModel: ObservableObject { session.isStreamingPublisher .sink { [weak self] isStreaming in if isStreaming { - self?.appState = .recording + self?.transition(to: .recording, reason: "streaming-started") } } .store(in: &cancellables) @@ -634,7 +634,19 @@ class SapoWhisperViewModel: ObservableObject { // MARK: - Initial State private func checkInitialState() { - appState = isEngineReady(currentEngine) ? .idle : .noModel + transition(to: isEngineReady(currentEngine) ? .idle : .noModel, reason: "check-initial-state") + } + + /// Single choke point for appState writes: applies the change and logs + /// any edge outside `AppState.canTransition` — observability first; no + /// rejection until the table has survived live QA. + private func transition(to newState: AppState, reason: String) { + if !appState.canTransition(to: newState) { + SapoLog.lifecycle.warning( + "appState transition outside table \(self.appState.diagnosticName, privacy: .public) -> \(newState.diagnosticName, privacy: .public) reason=\(reason, privacy: .public)" + ) + } + appState = newState } // MARK: - MLX Whisper Methods @@ -647,7 +659,7 @@ class SapoWhisperViewModel: ObservableObject { // R4: an on-demand reload can finish mid-recording — only leave // the "no model" state, never clobber an active session state. if case .noModel = appState { - appState = .idle + transition(to: .idle, reason: "mlx-load-recovered") } } catch is CancellationError { return @@ -657,7 +669,7 @@ class SapoWhisperViewModel: ObservableObject { // Mid-recording reload failures surface at stop time through the // normal transcription failure path; do not clobber the session. guard activeRecordingSessionID == nil else { return } - appState = .error(ErrorState(message: errorMsg)) + transition(to: .error(ErrorState(message: errorMsg)), reason: "mlx-load-failed") // Show the error briefly, then return to noModel for retry — but // only while THIS error is still showing; a newer, different @@ -882,7 +894,7 @@ class SapoWhisperViewModel: ObservableObject { guard isReady || canReloadOnDemand else { activeRecordingSessionID = nil - appState = .noModel + transition(to: .noModel, reason: "start-engine-not-ready") SapoLog.recording.warning("Recording blocked because engine is not ready") return } @@ -912,6 +924,18 @@ class SapoWhisperViewModel: ObservableObject { // Guardar la app activa para volver a ella despues de pegar PasteManager.savePreviousApp() + // Cloud batch stops pay DNS+TLS inside stop→paste on a cold pool; + // open the connection now, while the user is still dictating (the + // streaming engines already amortize their handshake at start). + switch engine { + case .deepgram where currentDeepgramMode == .nova3: + Task { await deepgramTranscriber.warmUpConnection() } + case .elevenLabsScribe where currentElevenLabsMode == .scribeV2Batch: + Task { await elevenLabsTranscriber.warmUpConnection() } + default: + break + } + // Primary-mic sync: a pinned explicit mic becomes the system default // input NOW. Opening a non-default device pays full route setup on // every take (on AirPods, the whole Bluetooth handshake); keeping app @@ -924,7 +948,7 @@ class SapoWhisperViewModel: ObservableObject { // Mostrar overlay PRIMERO para feedback visual inmediato sessionPeakAudioLevel = 0 sessionLevelTrackingStartedAt = triggerTime - appState = .recording + transition(to: .recording, reason: "start-optimistic") overlayManager.updateState(.recording(duration: 0)) // Until the first real buffer lands, the pill says "connecting " @@ -978,7 +1002,7 @@ class SapoWhisperViewModel: ObservableObject { snapshotPrefix: "recording", playSound: playSound, triggerTime: triggerTime, - prepare: { if isStartPending { audioRecorder.cancelPendingSetup() } }, + prepare: { audioRecorder.cancelPendingSetup() }, start: { try await self.startRecorderWithRecovery(microphone: mic) } ) } @@ -1047,6 +1071,9 @@ class SapoWhisperViewModel: ObservableObject { SapoLog.recording.warning( "Ignoring stale transcription completion session=\(sessionID, privacy: .public)" ) + // A retry transcribes the failed row's HISTORY audio — going stale + // must never delete a file the History still references. + guard audioURL != lastFailedAudioURL else { return } audioRecorder.deleteRecording(at: audioURL) } @@ -1078,17 +1105,16 @@ class SapoWhisperViewModel: ObservableObject { force: true ) - appState = .processing + transition(to: .processing, reason: "stop-requested") overlayManager.updateState(.transcribing) Task { + // Inherits MainActor from the enclosing @MainActor class. try? await Task.sleep(nanoseconds: UInt64(tailPadding * 1_000_000_000)) - await MainActor.run { - let elapsed = Int((CFAbsoluteTimeGetCurrent() - stopRequestTime) * 1000) - logger.info("\(logLabel, privacy: .public) stop tail elapsed=\(elapsed, privacy: .public)ms") - perf.markTailDone() - stop(perf) - } + let elapsed = Int((CFAbsoluteTimeGetCurrent() - stopRequestTime) * 1000) + logger.info("\(logLabel, privacy: .public) stop tail elapsed=\(elapsed, privacy: .public)ms") + perf.markTailDone() + stop(perf) } } @@ -1294,52 +1320,47 @@ class SapoWhisperViewModel: ObservableObject { return } guard !isRetryInFlight else { return } + guard activeTranscriptionSessionID == nil else { return } isRetryInFlight = true - appState = .processing + transition(to: .processing, reason: "retry-start") overlayManager.updateState(.transcribing) let engine = currentEngine let language = selectedLanguage - let duration = lastFailedHistoryId.flatMap { historyId in - historyManager.duration(for: historyId) - } + let historyId = lastFailedHistoryId + let duration = historyId.flatMap { historyManager.duration(for: $0) } + let sessionID = nextRecordingSessionID() + activeTranscriptionSessionID = sessionID + + // The retry rides the shared pipeline: same staleness gates and + // delivery as a live stop, with the failed row refreshed in place + // instead of inserting a new one. + let request = TranscriptionPipeline.Request( + sessionID: sessionID, + engine: engine, + engineName: historyEngineName(for: engine), + source: "retry", + failureLanguage: language, + snapshotPrefix: "retry-transcription", + logger: SapoLog.recording, + perf: nil, + historyTarget: historyId.map { .updateExisting(historyId: $0) } ?? .insertNew + ) Task { defer { isRetryInFlight = false } - do { - let transcription = try await transcribeAudio(at: audioURL, using: engine, language: language) - let aiResult = await postProcessTranscript( - transcription, - source: "retry", - duration: duration + await transcriptionPipeline.run(request) { + let transcript = try await self.transcribeAudio( + at: audioURL, using: engine, language: language) + return TranscriptionPipeline.EngineOutput( + transcript: transcript, + audioURL: audioURL, + duration: duration, + language: language ) - - deliverTranscription(aiResult, perf: nil) - - // Update history entry in place; the retry may run on a - // different engine than the failed attempt. - if let historyId = lastFailedHistoryId { - historyManager.updateRetranscription( - id: historyId, - engine: historyEngineName(for: engine), - finalText: aiResult.finalText, - rawText: aiResult.rawText, - aiStatus: aiResult.status, - aiModel: aiResult.model, - aiMode: aiResult.mode, - aiError: aiResult.error - ) - lastCompletedHistoryId = historyId - } - lastFailedAudioURL = nil - lastFailedHistoryId = nil - - } catch { - let failure = TranscriptionFailure.from(error, engine: engine.displayName) - presentTranscriptionFailure(failure) - SapoLog.recording.error( - "Retry transcription failed \(failure.logSummary, privacy: .public)") + } captureResultOnFailure: { + (audioURL, duration ?? 0) } } } @@ -1531,7 +1552,9 @@ class SapoWhisperViewModel: ObservableObject { return } self.activeRecordingSessionID = nil - self.appState = .error(ErrorState(message: error.localizedDescription)) + self.transition( + to: .error(ErrorState(message: error.localizedDescription)), + reason: "capture-start-failed") self.overlayManager.showError(message: error.localizedDescription) AutoDuckingManager.shared.restore() if playSound && !self.isRecoverableInputStartError(error) { @@ -1708,7 +1731,7 @@ class SapoWhisperViewModel: ObservableObject { if errorState.isNoSpeech { checkInitialState() } else { - appState = .error(errorState) + transition(to: .error(errorState), reason: "transcription-failure") } overlayManager.showError(errorState) if playSoundEnabled && !errorState.isNoSpeech { @@ -1782,7 +1805,7 @@ class SapoWhisperViewModel: ObservableObject { // History re-runs reuse this helper but must not drive the live // dictation UI: suppress the busy state + overlay, keep diagnostics. if !isReprocessingHistory { - appState = .polishing + transition(to: .polishing, reason: "polish-start") let usesLocalPolishBudget = PolishProviderConfiguration.configuredEndpointUsesLocalTimeoutBudget() // Same per-chunk sum the processor enforces — a chunked // transcript's countdown must not hit 0 mid-polish. @@ -1844,7 +1867,7 @@ class SapoWhisperViewModel: ObservableObject { let duration = lastDictationDuration let historyId = lastCompletedHistoryId let generation = dictationGeneration - appState = .polishing + transition(to: .polishing, reason: "repolish-start") let usesLocalPolishBudget = PolishProviderConfiguration.configuredEndpointUsesLocalTimeoutBudget() overlayManager.updateState( .polishing( @@ -1868,7 +1891,7 @@ class SapoWhisperViewModel: ObservableObject { lastTranscription = result.finalText PasteManager.copyToClipboard(result.finalText) - appState = .idle + transition(to: .idle, reason: "repolish-done") overlayManager.showCompleted(text: result.finalText) if playSoundEnabled { SoundManager.shared.play(.success) @@ -2169,11 +2192,12 @@ extension SapoWhisperViewModel: TranscriptionPipelineHost { /// with the error sound instead of silently passing as polished. func deliverTranscription(_ aiResult: TranscriptAIResult, perf: DictationPerfTimeline?) { let finalText = aiResult.finalText + let outcome = Self.copiedOutcome(for: aiResult) dictationGeneration &+= 1 lastCompletedHistoryId = nil lastTranscription = finalText PasteManager.copyToClipboard(finalText) - overlayManager.showCopied(text: finalText, outcome: Self.copiedOutcome(for: aiResult)) + overlayManager.showCopied(text: finalText, outcome: outcome) if autoPasteEnabled { PasteManager.simulatePaste { perf?.markPasteDone() } @@ -2181,9 +2205,9 @@ extension SapoWhisperViewModel: TranscriptionPipelineHost { perf?.markPasteDone(skipped: true) } - appState = .idle + transition(to: .idle, reason: "delivered") if playSoundEnabled { - SoundManager.shared.play(Self.copiedOutcome(for: aiResult) == .aiSkipped ? .error : .success) + SoundManager.shared.play(outcome == .aiSkipped ? .error : .success) } } @@ -2207,17 +2231,35 @@ extension SapoWhisperViewModel: TranscriptionPipelineHost { language: String, duration: TimeInterval, aiResult: TranscriptAIResult, - perf: DictationPerfTimeline? + perf: DictationPerfTimeline?, + target: HistoryPersistenceTarget ) { - scheduleCompletedHistoryPersistence( - from: audioURL, - engine: engine, - engineName: engineName, - language: language, - duration: duration, - aiResult: aiResult, - perf: perf - ) + switch target { + case .insertNew: + scheduleCompletedHistoryPersistence( + from: audioURL, + engine: engine, + engineName: engineName, + language: language, + duration: duration, + aiResult: aiResult, + perf: perf + ) + case .updateExisting(let historyId): + // Retry path: refresh the failed row in place (the retry may have + // run on a different engine than the failed attempt). + historyManager.updateRetranscription( + id: historyId, + engine: engineName, + finalText: aiResult.finalText, + rawText: aiResult.rawText, + aiStatus: aiResult.status, + aiModel: aiResult.model, + aiMode: aiResult.mode, + aiError: aiResult.error + ) + lastCompletedHistoryId = historyId + } lastFailedAudioURL = nil lastFailedHistoryId = nil } @@ -2228,8 +2270,15 @@ extension SapoWhisperViewModel: TranscriptionPipelineHost { engineName: String, language: String, duration: TimeInterval, - failure: TranscriptionFailure + failure: TranscriptionFailure, + target: HistoryPersistenceTarget ) { + if case .updateExisting = target { + // A failed retry keeps the original failed row (and the retry + // state) untouched so the user can retry again. + SapoLog.recording.info("Retry failed; keeping original failed history row") + return + } let persistedEntry = persistHistoryEntry( from: audioURL, engine: engine, diff --git a/SapoWhisper/Core/TemporaryAudioStorage.swift b/SapoWhisper/Core/TemporaryAudioStorage.swift index 7707a66..7e572fe 100644 --- a/SapoWhisper/Core/TemporaryAudioStorage.swift +++ b/SapoWhisper/Core/TemporaryAudioStorage.swift @@ -51,6 +51,8 @@ nonisolated enum TemporaryAudioStorage { static func makeWAVURL(prefix: String) -> URL { let dir = directory try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + // Raw voice audio: owner-only, same policy as the history store. + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: dir.path) return dir.appendingPathComponent("\(prefix)_\(UUID().uuidString).wav") } diff --git a/SapoWhisper/Core/TranscriptionPipeline.swift b/SapoWhisper/Core/TranscriptionPipeline.swift index 23cad00..604ca3e 100644 --- a/SapoWhisper/Core/TranscriptionPipeline.swift +++ b/SapoWhisper/Core/TranscriptionPipeline.swift @@ -29,7 +29,8 @@ protocol TranscriptionPipelineHost: AnyObject { language: String, duration: TimeInterval, aiResult: TranscriptAIResult, - perf: DictationPerfTimeline? + perf: DictationPerfTimeline?, + target: HistoryPersistenceTarget ) func persistFailedDictation( audioURL: URL, @@ -37,16 +38,26 @@ protocol TranscriptionPipelineHost: AnyObject { engineName: String, language: String, duration: TimeInterval, - failure: TranscriptionFailure + failure: TranscriptionFailure, + target: HistoryPersistenceTarget ) func logTranscriptionSnapshot(reason: String, extra: String) } +/// Where the pipeline's result lands in History: live dictations insert a +/// fresh row; a retry refreshes the failed row it came from (and a failed +/// retry keeps that row untouched so it stays retryable). +enum HistoryPersistenceTarget: Equatable { + case insertNew + case updateExisting(historyId: Int64) +} + /// C1: the transcribe→polish→paste→persist flow shared by the three stop -/// paths (batch recorder, Deepgram Flux, ElevenLabs realtime). The pipeline -/// owns control flow only: stage order, the session-staleness gate before -/// every stage (C2), and the local-silence rule that skips failed rows. +/// paths (batch recorder, Deepgram Flux, ElevenLabs realtime) plus retry. +/// The pipeline owns control flow only: stage order, the session-staleness +/// gate before every stage (C2), and the local-silence rule that skips +/// failed rows. @MainActor final class TranscriptionPipeline { struct Request { @@ -63,12 +74,15 @@ final class TranscriptionPipeline { let snapshotPrefix: String let logger: Logger let perf: DictationPerfTimeline? + var historyTarget: HistoryPersistenceTarget = .insertNew } struct EngineOutput { let transcript: String let audioURL: URL - let duration: TimeInterval + /// nil when the source duration is unknown (retry of a row without + /// one) — the polish minimum-duration gate treats unknown as allowed. + let duration: TimeInterval? /// Language persisted with the completed row (Flux reports the /// detected language; the other engines keep the selected one). let language: String @@ -115,9 +129,10 @@ final class TranscriptionPipeline { engine: request.engine, engineName: request.engineName, language: output.language, - duration: output.duration, + duration: output.duration ?? 0, aiResult: aiResult, - perf: request.perf + perf: request.perf, + target: request.historyTarget ) } catch { let captureResult = captureResultOnFailure() @@ -141,7 +156,8 @@ final class TranscriptionPipeline { engineName: request.engineName, language: request.failureLanguage, duration: captureResult.duration, - failure: failure + failure: failure, + target: request.historyTarget ) } request.logger.error( diff --git a/SapoWhisper/Models/AppState.swift b/SapoWhisper/Models/AppState.swift index 1da173d..278ebb7 100644 --- a/SapoWhisper/Models/AppState.swift +++ b/SapoWhisper/Models/AppState.swift @@ -115,4 +115,27 @@ enum AppState: Equatable { return false } } + + /// Legal edges of the dictation state machine, derived from the real + /// v2.8.0 flows. Self-transitions are always legal (publishers re-emit), + /// and every state may fail (→ .error) or reset (→ .idle). The ViewModel + /// logs — but does not yet reject — writes outside this table. + func canTransition(to next: AppState) -> Bool { + if self == next { return true } + switch (self, next) { + case (_, .idle), (_, .error): + return true + case (.idle, .recording), (.error, .recording), (.noModel, .recording): + return true + case (.recording, .processing), (.idle, .processing), + (.error, .processing), (.noModel, .processing): + return true + case (.processing, .polishing), (.idle, .polishing): + return true + case (.idle, .noModel), (.error, .noModel), (.processing, .noModel): + return true + default: + return false + } + } } diff --git a/SapoWhisper/Models/MLXWhisperModel.swift b/SapoWhisper/Models/MLXWhisperModel.swift index c1adc1b..e146eb9 100644 --- a/SapoWhisper/Models/MLXWhisperModel.swift +++ b/SapoWhisper/Models/MLXWhisperModel.swift @@ -5,32 +5,48 @@ import SwiftUI -/// Curated MLX Whisper tiers (mlx-community fp16 checkpoints — the vendored -/// loader reads plain safetensors only, so quantized 4/8-bit variants are -/// deliberately not offered). Raw value = Hugging Face repo ID. +/// Curated MLX Whisper tiers (mlx-community checkpoints; the vendored loader +/// reads fp16 and 4/8-bit quantized safetensors). Raw value = Hugging Face +/// repo ID. nonisolated enum MLXWhisperModel: String, CaseIterable, Identifiable { case base = "mlx-community/whisper-base-fp16" case small = "mlx-community/whisper-small-fp16" + case largeV3TurboQ4 = "mlx-community/whisper-large-v3-turbo-4bit" case largeV3Turbo = "mlx-community/whisper-large-v3-turbo" case largeV3 = "mlx-community/whisper-large-v3-fp16" var id: String { rawValue } + /// Pinned Hugging Face revision (commit shas resolved 2026-07-09) so a + /// moved or compromised `main` can never change what the app downloads. + var revision: String { + switch self { + case .base: return "3623ea5c2566e1320ba8eef401ec6c9cf58c6a2f" + case .small: return "fa19eb05939653a9334d81bec7e053db81970170" + case .largeV3TurboQ4: return "0f058d38170d183f9fdee07908f5b515d91793a8" + case .largeV3Turbo: return "a4aaeec0636e6fef84abdcbe3544cb2bf7e9f6fb" + case .largeV3: return "5467ef1f82cf0e110f521092acb434d9c82b5d5a" + } + } + var displayName: String { switch self { case .base: return "Base" case .small: return "Small" + case .largeV3TurboQ4: return "Large V3 Turbo (4-bit)" case .largeV3Turbo: return "Large V3 Turbo" case .largeV3: return "Large V3" } } /// Download size (weights + config + tokenizer assets), measured on the - /// Hugging Face repos 2026-07-05. Shown until the real on-disk size exists. + /// Hugging Face repos 2026-07-05 (4-bit tier 2026-07-09). Shown until the + /// real on-disk size exists. var fileSize: String { switch self { case .base: return "141 MB" case .small: return "463 MB" + case .largeV3TurboQ4: return "464 MB" case .largeV3Turbo: return "1.54 GB" case .largeV3: return "2.95 GB" } @@ -40,6 +56,7 @@ nonisolated enum MLXWhisperModel: String, CaseIterable, Identifiable { switch self { case .base: return Int64(141 * 1024 * 1024) case .small: return Int64(463 * 1024 * 1024) + case .largeV3TurboQ4: return Int64(464 * 1024 * 1024) case .largeV3Turbo: return Int64(1.54 * 1024 * 1024 * 1024) case .largeV3: return Int64(2.95 * 1024 * 1024 * 1024) } @@ -50,6 +67,7 @@ nonisolated enum MLXWhisperModel: String, CaseIterable, Identifiable { switch self { case .base: return 3 case .small: return 4 + case .largeV3TurboQ4: return 5 case .largeV3Turbo: return 5 case .largeV3: return 5 } @@ -65,6 +83,7 @@ extension MLXWhisperModel { switch self { case .base: return "model.speed.very_fast".localized case .small: return "model.speed.fast".localized + case .largeV3TurboQ4: return "model.speed.fast".localized case .largeV3Turbo: return "model.speed.fast".localized case .largeV3: return "model.speed.moderate".localized } diff --git a/SapoWhisper/Resources/en.lproj/Localizable.strings b/SapoWhisper/Resources/en.lproj/Localizable.strings index 2585dc6..110b5a0 100644 --- a/SapoWhisper/Resources/en.lproj/Localizable.strings +++ b/SapoWhisper/Resources/en.lproj/Localizable.strings @@ -21,6 +21,7 @@ /* History */ "history.title" = "History"; "history.search" = "Search transcriptions..."; +"history.relative_now" = "now"; "history.empty" = "No history"; "history.empty_sub" = "Transcriptions will appear here"; "history.copy" = "Copy"; @@ -88,6 +89,13 @@ "history.ai_polish_notice_title" = "AI polish not applied"; "history.ai_polish_rejected_notice" = "The polish was discarded because it drifted from your original words (fidelity protection). Your transcript was kept as-is."; "history.ai_polish_unavailable_notice" = "Couldn't apply AI polish. Check that the provider is configured in the Prompts tab."; +"history.export_sensitive_notice" = "This file will contain dictated content from your history."; +"history.audio_scrubber" = "Playback position"; +"history.audio_position" = "%@ of %@"; +"history.toggle_sidebar" = "Show or hide the sidebar"; +"history.more_actions" = "More actions"; +"history.filter_by_engine" = "Filter by engine"; +"common.clear_search" = "Clear search"; "common.cancel" = "Cancel"; "common.ok" = "OK"; @@ -614,3 +622,11 @@ "overlay.cancelled_saved" = "Cancelled — audio saved to History"; "settings.welcome_tour_open" = "Open"; "overlay.dock_last" = "Last transcription"; + +/* Overlay accessibility (VoiceOver) */ +"overlay.a11y.pause" = "Pause recording"; +"overlay.a11y.resume" = "Resume recording"; +"overlay.a11y.recording_started" = "Recording started"; +"overlay.a11y.transcribing" = "Transcribing"; +"overlay.a11y.pasted" = "Text pasted"; +"overlay.a11y.error" = "Dictation failed"; diff --git a/SapoWhisper/Resources/es.lproj/Localizable.strings b/SapoWhisper/Resources/es.lproj/Localizable.strings index b2e9d71..9ee006c 100644 --- a/SapoWhisper/Resources/es.lproj/Localizable.strings +++ b/SapoWhisper/Resources/es.lproj/Localizable.strings @@ -21,6 +21,7 @@ /* History */ "history.title" = "Historial"; "history.search" = "Buscar transcripciones..."; +"history.relative_now" = "ahora"; "history.empty" = "Sin historial"; "history.empty_sub" = "Las transcripciones aparecerán aquí"; "history.copy" = "Copiar"; @@ -88,6 +89,13 @@ "history.ai_polish_notice_title" = "Mejora con IA no aplicada"; "history.ai_polish_rejected_notice" = "La mejora se descartó porque se alejó de tu texto original (protección de fidelidad). Se mantiene tu transcripción tal cual."; "history.ai_polish_unavailable_notice" = "No se pudo aplicar la mejora con IA. Revisa que el proveedor esté configurado en la pestaña Prompts."; +"history.export_sensitive_notice" = "Este archivo contendrá contenido dictado de tu historial."; +"history.audio_scrubber" = "Posición de reproducción"; +"history.audio_position" = "%@ de %@"; +"history.toggle_sidebar" = "Mostrar u ocultar la barra lateral"; +"history.more_actions" = "Más acciones"; +"history.filter_by_engine" = "Filtrar por motor"; +"common.clear_search" = "Borrar búsqueda"; "common.cancel" = "Cancelar"; "common.ok" = "OK"; @@ -614,3 +622,11 @@ "overlay.cancelled_saved" = "Cancelado — audio guardado en Historial"; "settings.welcome_tour_open" = "Abrir"; "overlay.dock_last" = "Última transcripción"; + +/* Overlay accessibility (VoiceOver) */ +"overlay.a11y.pause" = "Pausar la grabación"; +"overlay.a11y.resume" = "Reanudar la grabación"; +"overlay.a11y.recording_started" = "Grabación iniciada"; +"overlay.a11y.transcribing" = "Transcribiendo"; +"overlay.a11y.pasted" = "Texto pegado"; +"overlay.a11y.error" = "El dictado falló"; diff --git a/SapoWhisper/SapoWhisper.entitlements b/SapoWhisper/SapoWhisper.entitlements index 892fd87..b251c20 100644 --- a/SapoWhisper/SapoWhisper.entitlements +++ b/SapoWhisper/SapoWhisper.entitlements @@ -2,23 +2,19 @@ - + com.apple.security.device.audio-input - - + + com.apple.security.files.user-selected.read-write - - - com.apple.security.files.downloads.read-write - - - + + com.apple.security.network.client - - + + com.apple.security.automation.apple-events diff --git a/SapoWhisper/Utilities/Constants.swift b/SapoWhisper/Utilities/Constants.swift index 57b0cab..04f9554 100644 --- a/SapoWhisper/Utilities/Constants.swift +++ b/SapoWhisper/Utilities/Constants.swift @@ -34,10 +34,20 @@ nonisolated enum Constants { static let error = Color(red: 1.0, green: 0.596, blue: 0.0) // #FF9800 static let disabled = Color(red: 0.620, green: 0.620, blue: 0.620) // #9E9E9E - // UI - static let background = Color(red: 0.118, green: 0.118, blue: 0.118) // #1E1E1E - static let surface = Color(red: 0.176, green: 0.176, blue: 0.176) // #2D2D2D - static let cardBackground = Color(red: 0.15, green: 0.15, blue: 0.15) + /// Foreground for text/icons over the amber `processing` fill — white + /// on #FFC107 is ~1.6:1; this dark tone keeps ~7.7:1 in both modes. + static let onProcessing = Color(red: 0.25, green: 0.18, blue: 0.02) + + /// Brand green for TEXT and small glyphs on window/material surfaces. + /// `sapoGreen` (#4CAF50) is a fill color; as text on light surfaces it + /// falls to ~2.8:1, so this variant darkens in light mode and + /// brightens in dark mode. + static let sapoGreenText = Color( + nsColor: NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + ? NSColor(red: 0.400, green: 0.733, blue: 0.416, alpha: 1) // #66BB6A + : NSColor(red: 0.180, green: 0.490, blue: 0.196, alpha: 1) // #2E7D32 + }) } // MARK: - Animaciones @@ -65,6 +75,19 @@ nonisolated enum Constants { /// Continuous progress fills (audio players) bridging 0.1 s timer ticks. static let progress: SwiftUI.Animation = .linear(duration: 0.1) + /// Hover highlights and other pointer-reactive micro-changes. + static let hover: SwiftUI.Animation = .easeOut(duration: 0.15) + + /// Snappy selection/confirmation spring for chips, pickers, toggles. + static let springSnap: SwiftUI.Animation = .spring(duration: 0.25, bounce: 0.35) + + /// Medium content swaps: detail-pane changes, section switches, + /// copied-state toggles. + static let transition: SwiftUI.Animation = .smooth(duration: 0.3) + + /// Invalid-input shake driver (hotkey recorder, keycap rows). + static let shake: SwiftUI.Animation = .spring(duration: 0.4) + /// System Reduce Motion for non-View call sites (managers); views /// should read `@Environment(\.accessibilityReduceMotion)` instead. @MainActor static var reduceMotion: Bool { @@ -72,6 +95,16 @@ nonisolated enum Constants { } } + // MARK: - Window Sizes + + /// Single source of truth for manually sized hosting windows: the window + /// controller and the SwiftUI root frame must agree (they drifted apart + /// in v2.2.0 and the Settings content clipped for six releases). + enum Windows { + static let settingsSize = NSSize(width: 860, height: 620) + static let historyMinSize = NSSize(width: 840, height: 520) + } + // MARK: - UI Sizes enum Sizes { @@ -190,4 +223,6 @@ extension Color { static let compactMode = Constants.Colors.compactMode static let sapoError = Constants.Colors.error static let disabled = Constants.Colors.disabled + static let sapoGreenText = Constants.Colors.sapoGreenText + static let onProcessing = Constants.Colors.onProcessing } diff --git a/SapoWhisper/Views/Components/KeycapView.swift b/SapoWhisper/Views/Components/KeycapView.swift new file mode 100644 index 0000000..1b98245 --- /dev/null +++ b/SapoWhisper/Views/Components/KeycapView.swift @@ -0,0 +1,27 @@ +// +// KeycapView.swift +// SapoWhisper +// + +import SwiftUI + +/// Physical-looking keycap shared by the welcome tour and hotkey settings. +struct KeycapView: View { + let label: String + var width: CGFloat = 52 + + var body: some View { + Text(label) + .font(.system(size: 17, weight: .semibold, design: .rounded)) + .frame(width: width, height: 46) + .background( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor)) + .shadow(color: .black.opacity(0.28), radius: 0, y: 3) + ) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1) + ) + } +} diff --git a/SapoWhisper/Views/Components/PermissionGrantCelebration.swift b/SapoWhisper/Views/Components/PermissionGrantCelebration.swift index 6e29a76..d851564 100644 --- a/SapoWhisper/Views/Components/PermissionGrantCelebration.swift +++ b/SapoWhisper/Views/Components/PermissionGrantCelebration.swift @@ -11,12 +11,14 @@ import SwiftUI struct PermissionGrantCelebration: ViewModifier { let trigger: Int var cornerRadius: CGFloat = 12 + @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .phaseAnimator([false, true], trigger: trigger) { view, flashing in view - .scaleEffect(flashing ? 1.02 : 1.0) + // Reduce Motion keeps the color flash, drops the scale pop. + .scaleEffect(flashing && !reduceMotion ? 1.02 : 1.0) .overlay( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .strokeBorder(Color.green.opacity(flashing ? 0.85 : 0), lineWidth: 2.5) diff --git a/SapoWhisper/Views/Components/ShakeEffect.swift b/SapoWhisper/Views/Components/ShakeEffect.swift new file mode 100644 index 0000000..7c04719 --- /dev/null +++ b/SapoWhisper/Views/Components/ShakeEffect.swift @@ -0,0 +1,22 @@ +// +// ShakeEffect.swift +// SapoWhisper +// + +import SwiftUI + +/// Horizontal shake used for failed key validation. +struct ShakeEffect: GeometryEffect { + var trigger: Int + var animatableData: CGFloat + + init(trigger: Int) { + self.trigger = trigger + self.animatableData = CGFloat(trigger) + } + + func effectValue(size: CGSize) -> ProjectionTransform { + let translation = 7 * sin(animatableData * .pi * 4) + return ProjectionTransform(CGAffineTransform(translationX: translation, y: 0)) + } +} diff --git a/SapoWhisper/Views/History/Components/AudioPlayerView.swift b/SapoWhisper/Views/History/Components/AudioPlayerView.swift index 94ae165..0b22db6 100644 --- a/SapoWhisper/Views/History/Components/AudioPlayerView.swift +++ b/SapoWhisper/Views/History/Components/AudioPlayerView.swift @@ -126,6 +126,9 @@ struct AudioPlayerView: View { @ObservedObject private var controller = HistoryAudioPlayerController.shared + /// Seconds moved per VoiceOver adjustment or arrow-key press. + private static let seekStep: TimeInterval = 5 + var body: some View { HStack(spacing: 14) { Button(action: { controller.togglePlayback(path: audioPath) }) { @@ -138,6 +141,7 @@ struct AudioPlayerView: View { } .buttonStyle(.plain) .help((controller.isPlaying ? "history.pause" : "history.play").localized) + .accessibilityLabel((controller.isPlaying ? "history.pause" : "history.play").localized) VStack(spacing: 5) { // Progress bar with click + drag scrubbing @@ -168,6 +172,28 @@ struct AudioPlayerView: View { ) } .frame(height: 14) + .focusable() + .onMoveCommand { direction in + switch direction { + case .left: seek(by: -Self.seekStep) + case .right: seek(by: Self.seekStep) + default: break + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("history.audio_scrubber".localized) + .accessibilityValue( + "history.audio_position".localized( + formatTime(controller.currentTime), formatTime(controller.duration) + ) + ) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: seek(by: Self.seekStep) + case .decrement: seek(by: -Self.seekStep) + @unknown default: break + } + } HStack { Text(formatTime(controller.currentTime)) @@ -192,6 +218,11 @@ struct AudioPlayerView: View { .onDisappear { controller.stopIfLoaded(path: audioPath) } } + private func seek(by seconds: TimeInterval) { + guard controller.duration > 0 else { return } + controller.seek(path: audioPath, to: (controller.currentTime + seconds) / controller.duration) + } + private func formatTime(_ time: TimeInterval) -> String { let minutes = Int(time) / 60 let seconds = Int(time) % 60 diff --git a/SapoWhisper/Views/History/Components/HistoryEngineColor.swift b/SapoWhisper/Views/History/Components/HistoryEngineColor.swift new file mode 100644 index 0000000..59ae87c --- /dev/null +++ b/SapoWhisper/Views/History/Components/HistoryEngineColor.swift @@ -0,0 +1,24 @@ +// +// HistoryEngineColor.swift +// SapoWhisper +// + +import SwiftUI + +/// Shared engine-name → accent color map for the history sidebar rows and the +/// detail header chip. Matched on substrings because stored engine strings +/// carry mode/model suffixes. +enum HistoryEngineColor { + static func color(for engine: String) -> Color { + switch engine.lowercased() { + case let e where e.contains("local ai"): return .indigo + case let e where e.contains("elevenlabs"): return .teal + case let e where e.contains("deepgram"): return .blue + case let e where e.contains("gemini"): return .cyan + case let e where e.contains("google"): return .orange + case let e where e.contains("whisper"): return .purple + case let e where e.contains("apple"): return .green + default: return .secondary + } + } +} diff --git a/SapoWhisper/Views/History/Components/HistoryRowView.swift b/SapoWhisper/Views/History/Components/HistoryRowView.swift index fd49d2c..838120b 100644 --- a/SapoWhisper/Views/History/Components/HistoryRowView.swift +++ b/SapoWhisper/Views/History/Components/HistoryRowView.swift @@ -46,7 +46,7 @@ struct HistoryRowView: View { if entry.audioFileExists { Image(systemName: "waveform") - .foregroundStyle(Color.sapoGreen) + .foregroundStyle(Color.sapoGreenText) } if entry.isFavorite { @@ -74,7 +74,7 @@ struct HistoryRowView: View { let hours = minutes / 60 let days = hours / 24 - if minutes < 1 { return "now" } + if minutes < 1 { return "history.relative_now".localized } if minutes < 60 { return "\(minutes)m" } if hours < 24 { return "\(hours)h" } if days < 7 { return "\(days)d" } @@ -90,7 +90,7 @@ private struct EngineIndicator: View { var body: some View { HStack(spacing: 4) { Circle() - .fill(color) + .fill(HistoryEngineColor.color(for: engine)) .frame(width: 6, height: 6) Text(shortName) @@ -100,19 +100,6 @@ private struct EngineIndicator: View { } } - private var color: Color { - switch engine.lowercased() { - case let e where e.contains("local ai"): return .indigo - case let e where e.contains("elevenlabs"): return .teal - case let e where e.contains("deepgram"): return .blue - case let e where e.contains("gemini"): return .cyan - case let e where e.contains("google"): return .orange - case let e where e.contains("whisper"): return .purple - case let e where e.contains("apple"): return .green - default: return .secondary - } - } - /// Family name only — the full engine/mode string lives in the detail /// panel; repeating it on every row drowned the transcript preview. private var shortName: String { diff --git a/SapoWhisper/Views/History/HistoryDetailView.swift b/SapoWhisper/Views/History/HistoryDetailView.swift index 00cefcd..7d7ac2e 100644 --- a/SapoWhisper/Views/History/HistoryDetailView.swift +++ b/SapoWhisper/Views/History/HistoryDetailView.swift @@ -89,19 +89,28 @@ struct HistoryDetailView: View { } HStack(spacing: 8) { - HeaderChip(text: entry.engine, dotColor: engineColor) + HeaderChip(text: entry.engine, dotColor: HistoryEngineColor.color(for: entry.engine)) HeaderChip(text: entry.language.uppercased()) } } } - /// "Today at 3:42 PM" — relative day names read faster than raw dates. - private var formattedTimestamp: String { + /// Cached: building a DateFormatter per body evaluation is expensive. + private static let timestampFormatter: DateFormatter = { let formatter = DateFormatter() - formatter.locale = locale formatter.dateStyle = .medium formatter.timeStyle = .short formatter.doesRelativeDateFormatting = true + return formatter + }() + + /// "Today at 3:42 PM" — relative day names read faster than raw dates. + private var formattedTimestamp: String { + let formatter = Self.timestampFormatter + // Environment locale can change at runtime (app language switch). + if formatter.locale != locale { + formatter.locale = locale + } return formatter.string(from: entry.timestamp) } @@ -213,14 +222,14 @@ struct HistoryDetailView: View { private func handleCopy() { onCopy() - withAnimation(.smooth(duration: 0.3)) { + withAnimation(Constants.Animation.transition) { showCopied = true } copiedResetTask?.cancel() copiedResetTask = Task { @MainActor in try? await Task.sleep(for: .seconds(1.5)) guard !Task.isCancelled else { return } - withAnimation(.smooth(duration: 0.3)) { + withAnimation(Constants.Animation.transition) { showCopied = false } } @@ -245,7 +254,7 @@ struct HistoryDetailView: View { StatCell( value: (entry.audioFileExists ? "history.audio_saved" : "history.audio_none").localized, label: "history.audio".localized, - valueColor: entry.audioFileExists ? Color.sapoGreen : .secondary + valueColor: entry.audioFileExists ? Color.sapoGreenText : .secondary ) } .padding(.vertical, 14) @@ -302,7 +311,7 @@ struct HistoryDetailView: View { private var aiStatusColor: Color { switch entry.transcriptAIStatus { case .applied: - return .sapoGreen + return .sapoGreenText case .failed, .rejectedFidelity: return .orange case .skippedShort, .skippedDuration, .none: @@ -453,19 +462,6 @@ struct HistoryDetailView: View { .strokeBorder(.secondary.opacity(0.2), lineWidth: 1) ) } - - private var engineColor: Color { - switch entry.engine.lowercased() { - case let e where e.contains("local ai"): return .indigo - case let e where e.contains("elevenlabs"): return .teal - case let e where e.contains("deepgram"): return .blue - case let e where e.contains("gemini"): return .cyan - case let e where e.contains("google"): return .orange - case let e where e.contains("whisper"): return .purple - case let e where e.contains("apple"): return .green - default: return .secondary - } - } } // MARK: - Subcomponents @@ -485,7 +481,7 @@ private struct PolishVersionRow: View { HStack(spacing: 8) { Text("v\(number)") .font(.caption.weight(.semibold).monospacedDigit()) - .foregroundStyle(isCurrent ? Color.sapoGreen : Color.secondary) + .foregroundStyle(isCurrent ? Color.sapoGreenText : Color.secondary) if let model = version.model, !model.isEmpty { Text(model) @@ -504,7 +500,7 @@ private struct PolishVersionRow: View { .padding(.horizontal, 7) .padding(.vertical, 2) .background(Color.sapoGreen.opacity(0.14), in: Capsule()) - .foregroundStyle(Color.sapoGreen) + .foregroundStyle(Color.sapoGreenText) } Spacer(minLength: 8) @@ -524,6 +520,7 @@ private struct PolishVersionRow: View { .buttonStyle(.plain) .foregroundStyle(.secondary) .help("history.copy".localized) + .accessibilityLabel("history.copy".localized) } Text(version.text) @@ -535,12 +532,19 @@ private struct PolishVersionRow: View { } } - private var formattedTime: String { + private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() - formatter.locale = locale formatter.dateStyle = .short formatter.timeStyle = .short formatter.doesRelativeDateFormatting = true + return formatter + }() + + private var formattedTime: String { + let formatter = Self.timeFormatter + if formatter.locale != locale { + formatter.locale = locale + } return formatter.string(from: version.createdAt) } } @@ -611,6 +615,7 @@ private struct IconActionButton: View { } .buttonStyle(.bordered) .help(help) + .accessibilityLabel(help) } } diff --git a/SapoWhisper/Views/History/HistorySidebarView.swift b/SapoWhisper/Views/History/HistorySidebarView.swift index 3a0ee05..6e5f854 100644 --- a/SapoWhisper/Views/History/HistorySidebarView.swift +++ b/SapoWhisper/Views/History/HistorySidebarView.swift @@ -59,6 +59,7 @@ struct HistorySidebarView: View { .foregroundStyle(.tertiary) } .buttonStyle(.plain) + .accessibilityLabel("common.clear_search".localized) } } .padding(.horizontal, 9) @@ -129,8 +130,10 @@ struct HistorySidebarView: View { .menuStyle(.borderlessButton) .menuIndicator(.hidden) .fixedSize() - .animation(.smooth(duration: 0.2), value: engineFilter) + .animation(Constants.Animation.reveal, value: engineFilter) .help(engineFilter.displayName) + .accessibilityLabel("history.filter_by_engine".localized) + .accessibilityValue(engineFilter.displayName) } private var activeFilterTint: Color { diff --git a/SapoWhisper/Views/History/HistoryView.swift b/SapoWhisper/Views/History/HistoryView.swift index 855a2a8..f6be97e 100644 --- a/SapoWhisper/Views/History/HistoryView.swift +++ b/SapoWhisper/Views/History/HistoryView.swift @@ -203,6 +203,8 @@ struct HistoryView: View { } label: { Image(systemName: "sidebar.leading") } + .help("history.toggle_sidebar".localized) + .accessibilityLabel("history.toggle_sidebar".localized) } ToolbarItem(placement: .primaryAction) { @@ -232,6 +234,8 @@ struct HistoryView: View { Image(systemName: "ellipsis.circle") } .menuIndicator(.hidden) + .help("history.more_actions".localized) + .accessibilityLabel("history.more_actions".localized) } } @@ -355,6 +359,7 @@ struct HistoryView: View { let sourceURL = URL(fileURLWithPath: audioPath) let panel = NSSavePanel() panel.canCreateDirectories = true + panel.message = "history.export_sensitive_notice".localized panel.nameFieldStringValue = exportFileName(for: entry, pathExtension: sourceURL.pathExtension) if let type = UTType(filenameExtension: sourceURL.pathExtension) { @@ -389,6 +394,7 @@ struct HistoryView: View { let panel = NSSavePanel() panel.canCreateDirectories = true + panel.message = "history.export_sensitive_notice".localized panel.nameFieldStringValue = HistoryExporter.suggestedFileName(for: format) if let type = UTType(filenameExtension: format.fileExtension) { panel.allowedContentTypes = [type] diff --git a/SapoWhisper/Views/MenuBar/Components/MenuBarWindowActions.swift b/SapoWhisper/Views/MenuBar/Components/MenuBarWindowActions.swift index cc04ea6..5b24314 100644 --- a/SapoWhisper/Views/MenuBar/Components/MenuBarWindowActions.swift +++ b/SapoWhisper/Views/MenuBar/Components/MenuBarWindowActions.swift @@ -10,24 +10,12 @@ import SwiftUI extension MenuBarView { func openHistoryWindow() { closeMenuBar() - if let openHistoryAction { - openHistoryAction() - return - } - - openWindow(id: "history") - NSApplication.shared.activate(ignoringOtherApps: true) + openHistoryAction?() } func openSettingsWindow() { closeMenuBar() - if let openSettingsAction { - openSettingsAction() - return - } - - openWindow(id: "settings") - NSApplication.shared.activate(ignoringOtherApps: true) + openSettingsAction?() } func openPermissionsWindow() { diff --git a/SapoWhisper/Views/MenuBarIcon.swift b/SapoWhisper/Views/MenuBarIcon.swift index 5f0512f..9b5a6ee 100644 --- a/SapoWhisper/Views/MenuBarIcon.swift +++ b/SapoWhisper/Views/MenuBarIcon.swift @@ -37,8 +37,11 @@ enum MenuBarIconImageProvider { "MenuBarIcon failed to load image=\(imageName, privacy: .public), using fallback" ) let config = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular) - return NSImage(systemSymbolName: fallbackIconName(for: appState), accessibilityDescription: nil)? - .withSymbolConfiguration(config) ?? NSImage() + return NSImage( + systemSymbolName: fallbackIconName(for: appState), + accessibilityDescription: Constants.appName + )? + .withSymbolConfiguration(config) ?? NSImage() } private static func menuBarImageName( diff --git a/SapoWhisper/Views/MenuBarView.swift b/SapoWhisper/Views/MenuBarView.swift index 1829bbe..eab4a0a 100644 --- a/SapoWhisper/Views/MenuBarView.swift +++ b/SapoWhisper/Views/MenuBarView.swift @@ -11,7 +11,6 @@ import SwiftUI struct MenuBarView: View { @ObservedObject var viewModel: SapoWhisperViewModel @ObservedObject private var reachability = NetworkReachability.shared - @Environment(\.openWindow) var openWindow var missingPermissions: [AppPermission] = [] var openSettingsAction: (() -> Void)? var openHistoryAction: (() -> Void)? @@ -172,7 +171,10 @@ struct MenuBarView: View { .frame(maxWidth: .infinity) .frame(height: Constants.Sizes.buttonHeight) .background(buttonBackground) - .foregroundColor(.white) + // White on the amber processing fill is ~1.6:1 — unreadable. + .foregroundColor( + viewModel.appState == .processing ? Constants.Colors.onProcessing : .white + ) .cornerRadius(Constants.Sizes.cornerRadius) .scaleEffect(isHoveringRecord ? 1.02 : 1.0) .shadow(color: buttonColor.opacity(0.3), radius: isHoveringRecord ? 8 : 4, y: 2) diff --git a/SapoWhisper/Views/Onboarding/WelcomeView.swift b/SapoWhisper/Views/Onboarding/WelcomeView.swift index 18ee078..920bf20 100644 --- a/SapoWhisper/Views/Onboarding/WelcomeView.swift +++ b/SapoWhisper/Views/Onboarding/WelcomeView.swift @@ -27,6 +27,7 @@ struct WelcomeView: View { UIPreviewMode.welcomeStep .flatMap(WelcomeStep.init(rawValue:)) ?? .welcome @Namespace private var engineSelection + @Environment(\.accessibilityReduceMotion) private var reduceMotion static let windowSize = CGSize(width: 660, height: 600) @@ -37,12 +38,8 @@ struct WelcomeView: View { ZStack { stepContent .id(step) - .transition( - .asymmetric( - insertion: .move(edge: .trailing).combined(with: .opacity), - removal: .move(edge: .leading).combined(with: .opacity) - ) - ) + // Reduce Motion swaps steps with a plain crossfade. + .transition(reduceMotion ? AnyTransition.opacity : AnyTransition(.blurReplace)) } .frame(maxWidth: .infinity, maxHeight: .infinity) .animation(.smooth(duration: 0.35), value: step) @@ -68,7 +65,7 @@ struct WelcomeView: View { Capsule() .fill(item.rawValue <= step.rawValue ? Color.sapoGreen : Color.secondary.opacity(0.2)) .frame(width: item == step ? 26 : 14, height: 5) - .animation(.smooth(duration: 0.3), value: step) + .animation(Constants.Animation.transition, value: step) } Spacer() @@ -288,26 +285,6 @@ private struct HotkeyKeycapsDemo: View { } } -struct KeycapView: View { - let label: String - var width: CGFloat = 52 - - var body: some View { - Text(label) - .font(.system(size: 17, weight: .semibold, design: .rounded)) - .frame(width: width, height: 46) - .background( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(Color(NSColor.controlBackgroundColor)) - .shadow(color: .black.opacity(0.28), radius: 0, y: 3) - ) - .overlay( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1) - ) - } -} - // MARK: - Step 2: Permissions private struct WelcomePermissionsStep: View { @@ -345,7 +322,7 @@ private struct WelcomePermissionsStep: View { private func refreshGranted() { let updated = Set(AppPermission.required.filter { $0.isGranted() }) guard updated != granted else { return } - withAnimation(.smooth(duration: 0.3)) { + withAnimation(Constants.Animation.transition) { granted = updated } } @@ -480,7 +457,7 @@ private struct WelcomeEngineStep: View { } private func select(_ engine: TranscriptionEngine) { - withAnimation(.smooth(duration: 0.3)) { + withAnimation(Constants.Animation.transition) { selectedCard = engine } if viewModel.isEngineReady(engine) { @@ -609,7 +586,7 @@ private struct WelcomeWhisperCard: View { if model.isRecommended { Image(systemName: "star.fill") .font(.system(size: 8)) - .foregroundStyle(Color.sapoGreen) + .foregroundStyle(Color.sapoGreenText) } } Text(model.fileSize) @@ -628,7 +605,7 @@ private struct WelcomeWhisperCard: View { } } .modifier(WelcomeEngineCardChrome(isSelected: isSelected, selectionNamespace: selectionNamespace)) - .animation(.smooth(duration: 0.25), value: isLoading) + .animation(Constants.Animation.reveal, value: isLoading) } private func startDownload(_ model: MLXWhisperModel) { @@ -722,7 +699,7 @@ private struct WelcomeLocalAIServerCard: View { private func save() { guard canSave else { - withAnimation(.spring(duration: 0.4)) { + withAnimation(Constants.Animation.shake) { shakeTrigger += 1 } return @@ -811,14 +788,14 @@ private struct WelcomeCloudEngineCard: View { try await EngineKeyValidator.validate(engine: engine, key: key) KeychainStore.setString(key, for: keychainKey) viewModel.setEngine(engine) - withAnimation(.smooth(duration: 0.3)) { + withAnimation(Constants.Animation.transition) { validation = .valid } } catch { - withAnimation(.smooth(duration: 0.3)) { + withAnimation(Constants.Animation.transition) { validation = .invalid(error.localizedDescription) } - withAnimation(.spring(duration: 0.4)) { + withAnimation(Constants.Animation.shake) { shakeTrigger += 1 } } @@ -920,7 +897,7 @@ private struct WelcomeAIPolishStep: View { case .success(let identifier): Label("ai.provider.test_success".localized(identifier), systemImage: "checkmark.circle.fill") .font(.caption) - .foregroundStyle(Color.sapoGreen) + .foregroundStyle(Color.sapoGreenText) .symbolEffect(.bounce, value: testState) .lineLimit(1) .truncationMode(.middle) @@ -979,7 +956,7 @@ private struct WelcomeAIPolishStep: View { PolishProviderConfiguration.setStoredBaseURLInput(newValue, for: endpoint) testState = .idle } - .animation(.smooth(duration: 0.25), value: endpoint) + .animation(Constants.Animation.reveal, value: endpoint) } private var shouldShowAPIKeyRow: Bool { @@ -1200,27 +1177,11 @@ private struct ProgressRing: View { .trim(from: 0, to: max(0.02, min(progress, 1))) .stroke(Color.sapoGreen, style: StrokeStyle(lineWidth: 3.5, lineCap: .round)) .rotationEffect(.degrees(-90)) - .animation(.smooth(duration: 0.3), value: progress) + .animation(Constants.Animation.transition, value: progress) } } } -/// Horizontal shake used for failed key validation. -struct ShakeEffect: GeometryEffect { - var trigger: Int - var animatableData: CGFloat - - init(trigger: Int) { - self.trigger = trigger - self.animatableData = CGFloat(trigger) - } - - func effectValue(size: CGSize) -> ProjectionTransform { - let translation = 7 * sin(animatableData * .pi * 4) - return ProjectionTransform(CGAffineTransform(translationX: translation, y: 0)) - } -} - #Preview("Welcome") { WelcomeView(viewModel: SapoWhisperViewModel(), onFinish: {}, onDismiss: {}) } diff --git a/SapoWhisper/Views/RecordingOverlay/Components/FloatingSapoIcon.swift b/SapoWhisper/Views/RecordingOverlay/Components/FloatingSapoIcon.swift index fb671f2..d0dc470 100644 --- a/SapoWhisper/Views/RecordingOverlay/Components/FloatingSapoIcon.swift +++ b/SapoWhisper/Views/RecordingOverlay/Components/FloatingSapoIcon.swift @@ -18,8 +18,12 @@ enum SapoIconState { switch self { case .recording: return "DockIconRecording" - case .paused, .completed, .error: + case .paused, .completed: return "DockIconLoading" + case .error: + // The error pill already carries the red state; the mascot goes + // back to its resting face instead of a misleading "loading". + return "DockIconIdle" case .transcribing, .polishing: return "DockIconTranscribing" } diff --git a/SapoWhisper/Views/RecordingOverlay/Components/OverlayIconButton.swift b/SapoWhisper/Views/RecordingOverlay/Components/OverlayIconButton.swift new file mode 100644 index 0000000..bdc9bdf --- /dev/null +++ b/SapoWhisper/Views/RecordingOverlay/Components/OverlayIconButton.swift @@ -0,0 +1,36 @@ +// +// OverlayIconButton.swift +// SapoWhisper +// + +import SwiftUI + +/// Icon-only circular button used across the overlay pills. `label` names the +/// control for VoiceOver (the glyph alone is not accessible); `help` adds the +/// hover tooltip where the original button had one. +struct OverlayIconButton: View { + let systemName: String + let label: String + var help: String? = nil + var diameter: CGFloat = 22 + var iconSize: CGFloat = 10 + let action: () -> Void + + var body: some View { + let button = Button(action: action) { + Image(systemName: systemName) + .font(.system(size: iconSize, weight: .semibold)) + .foregroundColor(.primary) + .frame(width: diameter, height: diameter) + .background(Circle().fill(Color.primary.opacity(0.1))) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + + if let help { + button.help(help) + } else { + button + } + } +} diff --git a/SapoWhisper/Views/RecordingOverlay/Components/OverlayPillChrome.swift b/SapoWhisper/Views/RecordingOverlay/Components/OverlayPillChrome.swift new file mode 100644 index 0000000..814480c --- /dev/null +++ b/SapoWhisper/Views/RecordingOverlay/Components/OverlayPillChrome.swift @@ -0,0 +1,74 @@ +// +// OverlayPillChrome.swift +// SapoWhisper +// + +import SwiftUI + +/// Shared chrome geometry for the overlay droplet pill: the background shape, +/// the content padding, and the glow flash overlay must stay in lockstep or +/// the glow outline drifts off the pill edge. +nonisolated enum OverlayPillChrome { + static let cornerRadius: CGFloat = 26 + static let horizontalPadding: CGFloat = 20 + static let verticalPadding: CGFloat = 12 + static let chipCornerRadius: CGFloat = 6 + + /// Continuous rounded rect instead of a capsule: multi-line states + /// (chips, expanded transcript) made the capsule's semicircular ends + /// huge, reading as wasted width. + static var pillShape: RoundedRectangle { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + } + + static var chipShape: RoundedRectangle { + RoundedRectangle(cornerRadius: chipCornerRadius, style: .continuous) + } +} + +/// Stable glass identities inside the overlay's shared namespace so the +/// droplet pill and the dock chip visually fuse on detach/absorb (macOS 26). +enum OverlayGlassID { + static let droplet = "droplet" + static let chip = "chip" +} + +extension View { + /// Droplet pill chrome: Liquid Glass on macOS 26, the original material + /// card everywhere else. + @ViewBuilder + func overlayPillChrome(glassNamespace: Namespace.ID) -> some View { + if #available(macOS 26, *) { + self + .glassEffect(.regular, in: OverlayPillChrome.pillShape) + .glassEffectID(OverlayGlassID.droplet, in: glassNamespace) + } else { + self.background( + OverlayPillChrome.pillShape + .fill(.ultraThinMaterial) + .shadow(color: .black.opacity(0.25), radius: 10, y: 3) + ) + } + } + + /// Dock chip chrome, same split. `glassNamespace` is optional so previews + /// can render the chip without a namespace owner (no morph, glass only). + @ViewBuilder + func overlayChipChrome(glassNamespace: Namespace.ID?) -> some View { + if #available(macOS 26, *) { + if let glassNamespace { + self + .glassEffect(.regular, in: OverlayPillChrome.chipShape) + .glassEffectID(OverlayGlassID.chip, in: glassNamespace) + } else { + self.glassEffect(.regular, in: OverlayPillChrome.chipShape) + } + } else { + self.background( + OverlayPillChrome.chipShape + .fill(.ultraThinMaterial) + .shadow(color: .black.opacity(0.25), radius: 4, y: 3) + ) + } + } +} diff --git a/SapoWhisper/Views/RecordingOverlay/Components/RecordingOverlayPills.swift b/SapoWhisper/Views/RecordingOverlay/Components/RecordingOverlayPills.swift index 8890b16..a705780 100644 --- a/SapoWhisper/Views/RecordingOverlay/Components/RecordingOverlayPills.swift +++ b/SapoWhisper/Views/RecordingOverlay/Components/RecordingOverlayPills.swift @@ -72,14 +72,13 @@ struct RecordingPillView: View { OverlayTranslationChip(onTranslationToggled: onTranslationToggled) } - Button(action: onPause) { - Image(systemName: "pause.fill") - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.primary) - .frame(width: 26, height: 26) - .background(Circle().fill(Color.primary.opacity(0.1))) - } - .buttonStyle(.plain) + OverlayIconButton( + systemName: "pause.fill", + label: "overlay.a11y.pause".localized, + diameter: 26, + iconSize: 11, + action: onPause + ) OverlayTimer(duration: duration) } @@ -146,14 +145,13 @@ struct PausedPillView: View { Spacer(minLength: 20) - Button(action: onResume) { - Image(systemName: "play.fill") - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.primary) - .frame(width: 26, height: 26) - .background(Circle().fill(Color.primary.opacity(0.1))) - } - .buttonStyle(.plain) + OverlayIconButton( + systemName: "play.fill", + label: "overlay.a11y.resume".localized, + diameter: 26, + iconSize: 11, + action: onResume + ) OverlayTimer(duration: duration) } @@ -238,7 +236,13 @@ struct CopiedPillView: View { @State private var iconScale: CGFloat = 0 @State private var glowFlash = 0 + /// Text/glyph tint: the contrast-safe green variant, not the fill green. private var accent: Color { + outcome == .aiSkipped ? .sapoError : .sapoGreenText + } + + /// The decorative outline flash keeps the brand fill green. + private var glowColor: Color { outcome == .aiSkipped ? .sapoError : .sapoGreen } @@ -271,15 +275,10 @@ struct CopiedPillView: View { .background(Capsule().fill(Color.compactMode.opacity(0.16))) } } - .keyframeAnimator(initialValue: 0.0, trigger: glowFlash) { content, glow in - content.overlay(glowStroke(color: accent, intensity: glow)) + .keyframeAnimator(initialValue: 0.0, trigger: glowFlash) { [glowColor] content, glow in + content.overlay(glowStroke(color: glowColor, intensity: glow)) } keyframes: { _ in - KeyframeTrack { - LinearKeyframe(0.0, duration: 0.15) - CubicKeyframe(1.0, duration: 0.3) - LinearKeyframe(1.0, duration: 0.75) - CubicKeyframe(0.0, duration: 0.8) - } + PillGlowFlashKeyframes() } .onAppear { withAnimation(.spring(response: 0.35, dampingFraction: 0.5).delay(0.1)) { @@ -365,12 +364,12 @@ struct CompletedPillView: View { HStack(spacing: 8) { Image(systemName: "doc.on.clipboard.fill") .font(.system(size: 16)) - .foregroundColor(.sapoGreen) + .foregroundColor(.sapoGreenText) .scaleEffect(iconScale) Text((showRecopied ? "overlay.copied_again" : "overlay.copied").localized) .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.sapoGreen) + .foregroundColor(.sapoGreenText) Spacer(minLength: 16) @@ -381,43 +380,30 @@ struct CompletedPillView: View { } if onOpenHistory != nil { - Button { - onOpenHistory?() - } label: { - Image(systemName: "clock.arrow.circlepath") - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(.primary) - .frame(width: 22, height: 22) - .background(Circle().fill(Color.primary.opacity(0.1))) - } - .buttonStyle(.plain) - .help("overlay.open_history".localized) + OverlayIconButton( + systemName: "clock.arrow.circlepath", + label: "overlay.open_history".localized, + help: "overlay.open_history".localized, + action: { onOpenHistory?() } + ) } - Button { - PasteManager.copyToClipboard(text) - showRecopied = true - } label: { - Image(systemName: "doc.on.doc") - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(.primary) - .frame(width: 22, height: 22) - .background(Circle().fill(Color.primary.opacity(0.1))) - } - .buttonStyle(.plain) - .help("overlay.copy".localized) - - Button { - onClose?() - } label: { - Image(systemName: "xmark") - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(.primary) - .frame(width: 22, height: 22) - .background(Circle().fill(Color.primary.opacity(0.1))) - } - .buttonStyle(.plain) - .help("overlay.close".localized) + OverlayIconButton( + systemName: "doc.on.doc", + label: "overlay.copy".localized, + help: "overlay.copy".localized, + action: { + PasteManager.copyToClipboard(text) + showRecopied = true + } + ) + + OverlayIconButton( + systemName: "xmark", + label: "overlay.close".localized, + help: "overlay.close".localized, + action: { onClose?() } + ) } if !text.isEmpty { @@ -443,19 +429,10 @@ struct CompletedPillView: View { } .frame(maxWidth: Self.contentWidth) - // One-shot success outline: short delay, ~0.3 s flash in, hold, - // ~0.8 s fade out. Keyframes replace the old pair of delayed - // withAnimation calls, which competed over one flag and could leave - // a stale glow when the pill changed under them. - .keyframeAnimator(initialValue: 0.0, trigger: glowFlash) { content, glow in - content.overlay(glowStroke(color: .sapoGreen, intensity: glow)) + .keyframeAnimator(initialValue: 0.0, trigger: glowFlash) { [accent = Color.sapoGreen] content, glow in + content.overlay(glowStroke(color: accent, intensity: glow)) } keyframes: { _ in - KeyframeTrack { - LinearKeyframe(0.0, duration: 0.15) - CubicKeyframe(1.0, duration: 0.3) - LinearKeyframe(1.0, duration: 0.75) - CubicKeyframe(0.0, duration: 0.8) - } + PillGlowFlashKeyframes() } .onAppear { withAnimation(.spring(response: 0.35, dampingFraction: 0.5).delay(0.1)) { @@ -473,6 +450,8 @@ struct CompletedPillView: View { struct DockedChipView: View { /// True while a droplet pill floats detached above the chip. var isExpanded: Bool = false + /// Overlay glass namespace; nil (previews) renders glass without an ID. + var glassNamespace: Namespace.ID? = nil var onTap: () -> Void @State private var isHovering = false @@ -480,40 +459,39 @@ struct DockedChipView: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { - Capsule() - .fill(Color.sapoGreen.opacity(isExpanded ? 0.9 : (isHovering ? 0.95 : 0.65))) - .frame(width: 24, height: 4) - .frame(width: 34, height: 8) - // Same ~46×12 footprint the chip had when it shared the pill's - // background, now self-contained. - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(.ultraThinMaterial) - .shadow(color: .black.opacity(0.25), radius: 4, y: 3) - ) - // Squash-and-stretch splash as the droplet detaches from or falls - // back into the chip — sells the "drop separating" read on both - // directions. Phase-driven so rapid open/close toggles can never - // strand the chip stretched. - .phaseAnimator([1.0, 1.75], trigger: splashTrigger) { content, stretch in - content.scaleEffect(x: 1, y: stretch) - } animation: { stretch in - stretch > 1 ? Constants.Animation.microBounce : .spring(duration: 0.3, bounce: 0.45) - } - .contentShape(Rectangle()) - .onHover { hovering in - withAnimation(.easeOut(duration: 0.15)) { - isHovering = hovering + Button(action: onTap) { + Capsule() + .fill(Color.sapoGreen.opacity(isExpanded ? 0.9 : (isHovering ? 0.95 : 0.65))) + .frame(width: 24, height: 4) + .frame(width: 34, height: 8) + // Same ~46×12 footprint the chip had when it shared the pill's + // background, now self-contained. + .padding(.horizontal, 6) + .padding(.vertical, 2) + .overlayChipChrome(glassNamespace: glassNamespace) + // Squash-and-stretch splash as the droplet detaches from or falls + // back into the chip — sells the "drop separating" read on both + // directions. Phase-driven so rapid open/close toggles can never + // strand the chip stretched. + .phaseAnimator([1.0, 1.75], trigger: splashTrigger) { content, stretch in + content.scaleEffect(x: 1, y: stretch) + } animation: { stretch in + stretch > 1 ? Constants.Animation.microBounce : .spring(duration: 0.3, bounce: 0.45) } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.15)) { + isHovering = hovering } - .onTapGesture(perform: onTap) - .onChange(of: isExpanded) { _, _ in - guard !reduceMotion else { return } - splashTrigger += 1 - } - .help("overlay.dock_last".localized) + } + .onChange(of: isExpanded) { _, _ in + guard !reduceMotion else { return } + splashTrigger += 1 + } + .accessibilityLabel("overlay.dock_last".localized) + .help("overlay.dock_last".localized) } } @@ -573,15 +551,10 @@ struct ErrorPillView: View { } } // Same one-shot outline flash as the completed pill, in error amber. - .keyframeAnimator(initialValue: 0.0, trigger: glowFlash) { content, glow in - content.overlay(glowStroke(color: .sapoError, intensity: glow)) + .keyframeAnimator(initialValue: 0.0, trigger: glowFlash) { [accent = Color.sapoError] content, glow in + content.overlay(glowStroke(color: accent, intensity: glow)) } keyframes: { _ in - KeyframeTrack { - LinearKeyframe(0.0, duration: 0.15) - CubicKeyframe(1.0, duration: 0.3) - LinearKeyframe(1.0, duration: 0.75) - CubicKeyframe(0.0, duration: 0.8) - } + PillGlowFlashKeyframes() } .onAppear { glowFlash += 1 @@ -603,8 +576,9 @@ struct DeviceChangePillView: View { private var accentColor: Color { switch announcement.phase { + // Glyph tint over material, so the contrast-safe green variant. case .connecting: return .aiPolish - case .ready: return .sapoGreen + case .ready: return .sapoGreenText case .fallback: return .sapoError } } @@ -698,9 +672,28 @@ struct PillDivider: View { } /// `intensity` is the 0...1 keyframe value; full flash keeps the old 0.4 peak. -private func glowStroke(color: Color, intensity: Double) -> some View { - RoundedRectangle(cornerRadius: 26, style: .continuous) +/// Negative padding pushes the stroke back out over the pill chrome that the +/// hosting view applies around this content. +nonisolated private func glowStroke(color: Color, intensity: Double) -> some View { + OverlayPillChrome.pillShape .strokeBorder(color.opacity(0.4 * intensity), lineWidth: 1.5) - .padding(.horizontal, -20) - .padding(.vertical, -12) + .padding(.horizontal, -OverlayPillChrome.horizontalPadding) + .padding(.vertical, -OverlayPillChrome.verticalPadding) +} + +/// One-shot pill outline flash timeline: short delay, ~0.3 s flash in, hold, +/// ~0.8 s fade out. One shared timeline replaces the old pair of delayed +/// withAnimation calls, which competed over one flag and could leave a stale +/// glow when the pill changed under them. Call sites keep `keyframeAnimator` +/// inline: hoisting it into a generic View extension makes the @Sendable +/// content closure capture `Self.Type`, which strict concurrency rejects. +private struct PillGlowFlashKeyframes: Keyframes { + var body: some Keyframes { + KeyframeTrack { + LinearKeyframe(0.0, duration: 0.15) + CubicKeyframe(1.0, duration: 0.3) + LinearKeyframe(1.0, duration: 0.75) + CubicKeyframe(0.0, duration: 0.8) + } + } } diff --git a/SapoWhisper/Views/RecordingOverlay/RecordingOverlayView.swift b/SapoWhisper/Views/RecordingOverlay/RecordingOverlayView.swift index 1ce0807..a7164d2 100644 --- a/SapoWhisper/Views/RecordingOverlay/RecordingOverlayView.swift +++ b/SapoWhisper/Views/RecordingOverlay/RecordingOverlayView.swift @@ -29,6 +29,9 @@ struct RecordingOverlayView: View { @State private var pillBounceTrigger = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion + /// Shared glass identity space (macOS 26): the droplet pill and dock chip + /// carry stable IDs here so their Liquid Glass shapes fuse on detach/absorb. + @Namespace private var glassNamespace private var stateCategory: String { manager.state.stateCategory } private var isActive: Bool { stateCategory != "hidden" && stateCategory != "docked" } @@ -46,6 +49,59 @@ struct RecordingOverlayView: View { } var body: some View { + contentStack + .fixedSize() + // Publish where the real content sits inside the mostly-transparent + // surface, so the outside-click collapse can compare against the + // visible pill instead of the whole fixed window rect. + .background( + GeometryReader { proxy in + Color.clear.preference( + key: OverlayContentFramePreferenceKey.self, + value: proxy.frame(in: .global) + ) + } + ) + // Slim transparent inset on the chip side so its shadow still renders + // while the chip visually hugs the screen edge. + .padding(chipOnTop ? .top : .bottom, 4) + // The hosting window is a fixed transparent surface that NEVER + // resizes: window resizes during SwiftUI transaction animations made + // NSHostingView animate the window frame from inside the display + // cycle (updateAnimatedWindowSize), which throws and crashes the app. + // The content simply lays out against the configured edge; empty + // surface pixels are alpha-transparent, so clicks there fall through + // to whatever is behind the window. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: surfaceAlignment) + .onPreferenceChange(OverlayContentFramePreferenceKey.self) { frame in + Task { @MainActor in + OverlayWindowManager.shared.setActiveContentFrame(frame) + } + } + .onChange(of: stateCategory) { oldValue, _ in + // Micro-bounce only on active-to-active swaps; dock transitions + // are carried entirely by the droplet detach/absorb. + guard isActive, oldValue != "hidden", oldValue != "docked" else { return } + guard !reduceMotion else { return } + pillBounceTrigger += 1 + } + } + + /// On macOS 26 the pill and chip render inside one glass container so + /// their Liquid Glass shapes blend while the droplet detaches/absorbs; + /// older systems lay out the same stack with the material chrome. + @ViewBuilder + private var contentStack: some View { + if #available(macOS 26, *) { + GlassEffectContainer(spacing: 24) { + pillAndChipStack + } + } else { + pillAndChipStack + } + } + + private var pillAndChipStack: some View { VStack(spacing: 0) { if chipOnTop { chip @@ -62,45 +118,14 @@ struct RecordingOverlayView: View { chip } } - .fixedSize() - // Publish where the real content sits inside the mostly-transparent - // surface, so the outside-click collapse can compare against the - // visible pill instead of the whole fixed window rect. - .background( - GeometryReader { proxy in - Color.clear.preference( - key: OverlayContentFramePreferenceKey.self, - value: proxy.frame(in: .global) - ) - } - ) - // Slim transparent inset on the chip side so its shadow still renders - // while the chip visually hugs the screen edge. - .padding(chipOnTop ? .top : .bottom, 4) - // The hosting window is a fixed transparent surface that NEVER - // resizes: window resizes during SwiftUI transaction animations made - // NSHostingView animate the window frame from inside the display - // cycle (updateAnimatedWindowSize), which throws and crashes the app. - // The content simply lays out against the configured edge; empty - // surface pixels are alpha-transparent, so clicks there fall through - // to whatever is behind the window. - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: surfaceAlignment) - .onPreferenceChange(OverlayContentFramePreferenceKey.self) { frame in - Task { @MainActor in - OverlayWindowManager.shared.setActiveContentFrame(frame) - } - } - .onChange(of: stateCategory) { oldValue, _ in - // Micro-bounce only on active-to-active swaps; dock transitions - // are carried entirely by the droplet detach/absorb. - guard isActive, oldValue != "hidden", oldValue != "docked" else { return } - guard !reduceMotion else { return } - pillBounceTrigger += 1 - } } private var chip: some View { - DockedChipView(isExpanded: isActive, onTap: { manager.dockChipTapped() }) + DockedChipView( + isExpanded: isActive, + glassNamespace: glassNamespace, + onTap: { manager.dockChipTapped() } + ) } /// The droplet grows out of the chip's edge and collapses back into it: @@ -111,30 +136,34 @@ struct RecordingOverlayView: View { .scale(scale: 0.04, anchor: chipOnTop ? .top : .bottom) } + /// Sequential content hand-off on active-to-active swaps (old leaves + /// fast, new enters right after). Blur-replace adds the material feel; + /// Reduce Motion keeps the plain crossfade. + private var contentSwapTransition: AnyTransition { + if reduceMotion { + return .asymmetric( + insertion: .opacity.animation(.easeIn(duration: 0.16).delay(0.1)), + removal: .opacity.animation(.easeOut(duration: 0.1)) + ) + } + return .asymmetric( + insertion: AnyTransition(.blurReplace).animation(.easeIn(duration: 0.16).delay(0.1)), + removal: AnyTransition(.blurReplace).animation(.easeOut(duration: 0.1)) + ) + } + private var activePill: some View { // The ZStack hosts the outgoing and incoming pill contents during an // active-to-active swap so the pill morphs once while the texts hand - // off sequentially (old fades out fast, new fades in right after). + // off sequentially. ZStack { contentForState .id(stateCategory) - .transition( - .asymmetric( - insertion: .opacity.animation(.easeIn(duration: 0.16).delay(0.1)), - removal: .opacity.animation(.easeOut(duration: 0.1)) - ) - ) + .transition(contentSwapTransition) } - .padding(.horizontal, 20) - .padding(.vertical, 12) - .background( - // Continuous rounded rect instead of a capsule: multi-line states - // (chips, expanded transcript) made the capsule's semicircular - // ends huge, reading as wasted width. - RoundedRectangle(cornerRadius: 26, style: .continuous) - .fill(.ultraThinMaterial) - .shadow(color: .black.opacity(0.25), radius: 10, y: 3) - ) + .padding(.horizontal, OverlayPillChrome.horizontalPadding) + .padding(.vertical, OverlayPillChrome.verticalPadding) + .overlayPillChrome(glassNamespace: glassNamespace) // Micro-bounce on state swaps — subtle scale pop for tactile // feedback. Phase-driven so a swap mid-bounce can never leave the // pill stuck scaled up (the old detached asyncAfter could). diff --git a/SapoWhisper/Views/Settings/Components/AIPolishComponents.swift b/SapoWhisper/Views/Settings/Components/AIPolishComponents.swift index fe2b7a9..518ba73 100644 --- a/SapoWhisper/Views/Settings/Components/AIPolishComponents.swift +++ b/SapoWhisper/Views/Settings/Components/AIPolishComponents.swift @@ -14,7 +14,7 @@ struct AIPolishFidelityBadge: View { Text("ai.polish.fidelity_max".localized) .font(.caption2.weight(.semibold)) } - .foregroundStyle(Color.sapoGreen) + .foregroundStyle(Color.sapoGreenText) .padding(.horizontal, 7) .padding(.vertical, 4) .background(Color.sapoGreen.opacity(0.10), in: Capsule()) diff --git a/SapoWhisper/Views/Settings/Components/AIPolishProviderSection.swift b/SapoWhisper/Views/Settings/Components/AIPolishProviderSection.swift index 2d23370..68ec31f 100644 --- a/SapoWhisper/Views/Settings/Components/AIPolishProviderSection.swift +++ b/SapoWhisper/Views/Settings/Components/AIPolishProviderSection.swift @@ -37,7 +37,7 @@ struct AIPolishProviderSection: View { .transition(.opacity) } } - .animation(.smooth(duration: 0.25), value: isExpanded) + .animation(Constants.Animation.reveal, value: isExpanded) } // MARK: - Collapsed summary @@ -77,7 +77,7 @@ struct AIPolishProviderSection: View { Button("ai.provider.summary_edit".localized) { onWillExpand() - withAnimation(.smooth(duration: 0.25)) { + withAnimation(Constants.Animation.reveal) { isExpanded = true } } @@ -107,13 +107,13 @@ struct AIPolishProviderSection: View { if isProviderUsable { Button("ai.provider.summary_done".localized) { - withAnimation(.smooth(duration: 0.25)) { + withAnimation(Constants.Animation.reveal) { isExpanded = false } } .buttonStyle(.plain) .font(.caption) - .foregroundStyle(Color.sapoGreen) + .foregroundStyle(Color.sapoGreenText) } } @@ -179,7 +179,7 @@ struct AIPolishProviderSection: View { testRow } - .animation(.smooth(duration: 0.25), value: endpoint) + .animation(Constants.Animation.reveal, value: endpoint) } private var shouldShowAPIKeyRow: Bool { @@ -219,7 +219,7 @@ struct AIPolishProviderSection: View { private var optionalAPIKeyButton: some View { Button { onWillEditAPIKey() - withAnimation(.smooth(duration: 0.2)) { + withAnimation(Constants.Animation.reveal) { showsOptionalAPIKey = true } } label: { @@ -252,7 +252,7 @@ struct AIPolishProviderSection: View { case .success(let modelIdentifier): Label("ai.provider.test_success".localized(modelIdentifier), systemImage: "checkmark.circle.fill") .font(.caption) - .foregroundStyle(Color.sapoGreen) + .foregroundStyle(Color.sapoGreenText) .symbolEffect(.bounce, value: testState) .lineLimit(1) .truncationMode(.middle) @@ -285,7 +285,7 @@ struct AIPolishProviderSection: View { // configuration away after a beat so the result stays legible. try? await Task.sleep(for: .seconds(1.2)) if case .success = testState { - withAnimation(.smooth(duration: 0.25)) { + withAnimation(Constants.Animation.reveal) { isExpanded = false } } diff --git a/SapoWhisper/Views/Settings/Components/AIPolishSettingsCard.swift b/SapoWhisper/Views/Settings/Components/AIPolishSettingsCard.swift index ffd0683..1190af6 100644 --- a/SapoWhisper/Views/Settings/Components/AIPolishSettingsCard.swift +++ b/SapoWhisper/Views/Settings/Components/AIPolishSettingsCard.swift @@ -121,7 +121,7 @@ struct AIPolishSettingsCard: View { .disabled(!aiPolishEnabled) .opacity(aiPolishEnabled ? 1 : 0.62) } - .animation(.smooth(duration: 0.2), value: aiPolishEnabled) + .animation(Constants.Animation.reveal, value: aiPolishEnabled) } .onAppear { // Start collapsed when the provider already works; expand (and @@ -270,7 +270,7 @@ struct AIPolishSettingsCard: View { .transition(.opacity.combined(with: .move(edge: .top))) } } - .animation(.smooth(duration: 0.22), value: currentMode) + .animation(Constants.Animation.reveal, value: currentMode) } // MARK: - Minimum duration @@ -340,7 +340,7 @@ struct AIPolishSettingsCard: View { .transition(.opacity.combined(with: .move(edge: .top))) } } - .animation(.smooth(duration: 0.22), value: currentOutputLanguage.requiresTranslation) + .animation(Constants.Animation.reveal, value: currentOutputLanguage.requiresTranslation) } // MARK: - Reasoning effort diff --git a/SapoWhisper/Views/Settings/Components/AudioSamplePlayerView.swift b/SapoWhisper/Views/Settings/Components/AudioSamplePlayerView.swift index ba1cefa..eac93b1 100644 --- a/SapoWhisper/Views/Settings/Components/AudioSamplePlayerView.swift +++ b/SapoWhisper/Views/Settings/Components/AudioSamplePlayerView.swift @@ -30,6 +30,7 @@ struct AudioSamplePlayerView: View { .foregroundStyle(isPlaying ? Color.sapoGreen : .secondary) } .buttonStyle(.plain) + .accessibilityLabel((isPlaying ? "history.pause" : "history.play").localized) Text(label) .font(.caption2) diff --git a/SapoWhisper/Views/Settings/Components/ClickableDisclosureStyle.swift b/SapoWhisper/Views/Settings/Components/ClickableDisclosureStyle.swift index c8db540..448a87a 100644 --- a/SapoWhisper/Views/Settings/Components/ClickableDisclosureStyle.swift +++ b/SapoWhisper/Views/Settings/Components/ClickableDisclosureStyle.swift @@ -11,7 +11,7 @@ struct ClickableDisclosureStyle: DisclosureGroupStyle { func makeBody(configuration: Configuration) -> some View { VStack(alignment: .leading, spacing: 0) { Button { - withAnimation(.smooth(duration: 0.25)) { + withAnimation(Constants.Animation.reveal) { configuration.isExpanded.toggle() } } label: { diff --git a/SapoWhisper/Views/Settings/Components/DeepgramSettingsCard.swift b/SapoWhisper/Views/Settings/Components/DeepgramSettingsCard.swift index 5361040..5032ba0 100644 --- a/SapoWhisper/Views/Settings/Components/DeepgramSettingsCard.swift +++ b/SapoWhisper/Views/Settings/Components/DeepgramSettingsCard.swift @@ -79,7 +79,7 @@ struct DeepgramSettingsCard: View { subtitle: mode.description, isSelected: currentMode == mode ) { - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(Constants.Animation.reveal) { selectedMode = mode.rawValue viewModel.setDeepgramMode(mode) } diff --git a/SapoWhisper/Views/Settings/Components/ElevenLabsSettingsCard.swift b/SapoWhisper/Views/Settings/Components/ElevenLabsSettingsCard.swift index aa5857c..e26aebc 100644 --- a/SapoWhisper/Views/Settings/Components/ElevenLabsSettingsCard.swift +++ b/SapoWhisper/Views/Settings/Components/ElevenLabsSettingsCard.swift @@ -86,7 +86,7 @@ struct ElevenLabsSettingsCard: View { subtitle: mode.description, isSelected: currentMode == mode ) { - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(Constants.Animation.reveal) { selectedMode = mode.rawValue viewModel.setElevenLabsMode(mode) } diff --git a/SapoWhisper/Views/Settings/Components/EngineButton.swift b/SapoWhisper/Views/Settings/Components/EngineButton.swift index 6fa964a..5bcf9f8 100644 --- a/SapoWhisper/Views/Settings/Components/EngineButton.swift +++ b/SapoWhisper/Views/Settings/Components/EngineButton.swift @@ -121,7 +121,7 @@ struct EngineOptionRow: View { .padding(.leading, 52) Button { - withAnimation(.easeInOut(duration: 0.18)) { + withAnimation(Constants.Animation.reveal) { isExpanded.toggle() } } label: { diff --git a/SapoWhisper/Views/Settings/Components/LanguageButton.swift b/SapoWhisper/Views/Settings/Components/LanguageButton.swift index 7faeac4..08e35bc 100644 --- a/SapoWhisper/Views/Settings/Components/LanguageButton.swift +++ b/SapoWhisper/Views/Settings/Components/LanguageButton.swift @@ -29,7 +29,7 @@ struct LanguageButton: View { .padding(.horizontal, 12) .padding(.vertical, 8) .background(isSelected ? Color.sapoGreen.opacity(0.15) : Color.secondary.opacity(0.06)) - .foregroundStyle(isSelected ? Color.sapoGreen : .primary) + .foregroundStyle(isSelected ? Color.sapoGreenText : .primary) .clipShape(Capsule()) .overlay( Capsule() diff --git a/SapoWhisper/Views/Settings/Components/LocalAIServerSettingsCard.swift b/SapoWhisper/Views/Settings/Components/LocalAIServerSettingsCard.swift index b0b0584..c41f6b8 100644 --- a/SapoWhisper/Views/Settings/Components/LocalAIServerSettingsCard.swift +++ b/SapoWhisper/Views/Settings/Components/LocalAIServerSettingsCard.swift @@ -147,7 +147,7 @@ struct LocalAIServerSettingsCard: View { systemImage: modelAvailable ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" ) .font(.caption) - .foregroundStyle(modelAvailable ? Color.sapoGreen : Color.orange) + .foregroundStyle(modelAvailable ? Color.sapoGreenText : Color.orange) .lineLimit(2) case .failure(let message): Label(message, systemImage: "xmark.circle.fill") diff --git a/SapoWhisper/Views/Settings/Components/LocalModels/LocalModelRow.swift b/SapoWhisper/Views/Settings/Components/LocalModels/LocalModelRow.swift index 2b71106..dcf8740 100644 --- a/SapoWhisper/Views/Settings/Components/LocalModels/LocalModelRow.swift +++ b/SapoWhisper/Views/Settings/Components/LocalModels/LocalModelRow.swift @@ -121,7 +121,7 @@ struct LocalModelRow: View { if isDownloaded, let size = downloadedSize { Text(size.byteCountLabel) .font(.caption) - .foregroundColor(.sapoGreen) + .foregroundColor(.sapoGreenText) .contentTransition(.numericText()) } else { Text(model.fileSize) @@ -203,6 +203,7 @@ struct LocalModelRow: View { } .buttonStyle(.plain) .help(helpKey.localized) + .accessibilityLabel(helpKey.localized) .transition(.scale.combined(with: .opacity)) } diff --git a/SapoWhisper/Views/Settings/Components/PolishPreviewSection.swift b/SapoWhisper/Views/Settings/Components/PolishPreviewSection.swift index c12ec56..dbd0366 100644 --- a/SapoWhisper/Views/Settings/Components/PolishPreviewSection.swift +++ b/SapoWhisper/Views/Settings/Components/PolishPreviewSection.swift @@ -96,7 +96,7 @@ struct PolishPreviewSection: View { EmptyView() } } - .animation(.smooth(duration: 0.25), value: state) + .animation(Constants.Animation.reveal, value: state) } private func polishedTitle(for mode: PolishMode) -> String { diff --git a/SapoWhisper/Views/Settings/Components/PromptContextSettingsCard.swift b/SapoWhisper/Views/Settings/Components/PromptContextSettingsCard.swift index 2d4030b..96982bf 100644 --- a/SapoWhisper/Views/Settings/Components/PromptContextSettingsCard.swift +++ b/SapoWhisper/Views/Settings/Components/PromptContextSettingsCard.swift @@ -50,7 +50,7 @@ struct PromptContextSettingsCard: View { if showsSavedConfirmation { Label("prompts.prompt_saved".localized, systemImage: "checkmark.circle.fill") .font(.caption) - .foregroundStyle(Color.sapoGreen) + .foregroundStyle(Color.sapoGreenText) .transition(.opacity.combined(with: .scale(scale: 0.9))) } @@ -67,8 +67,8 @@ struct PromptContextSettingsCard: View { .tint(Constants.Colors.sapoGreen) .disabled(!hasUnsavedChanges) } - .animation(.smooth(duration: 0.2), value: hasUnsavedChanges) - .animation(.smooth(duration: 0.2), value: showsSavedConfirmation) + .animation(Constants.Animation.reveal, value: hasUnsavedChanges) + .animation(Constants.Animation.reveal, value: showsSavedConfirmation) } } @@ -80,7 +80,7 @@ struct PromptContextSettingsCard: View { savedConfirmationTask = Task { @MainActor in try? await Task.sleep(for: .seconds(2.2)) guard !Task.isCancelled else { return } - withAnimation(.smooth(duration: 0.3)) { + withAnimation(Constants.Animation.transition) { showsSavedConfirmation = false } } diff --git a/SapoWhisper/Views/Settings/Components/SettingsPermissionsSection.swift b/SapoWhisper/Views/Settings/Components/SettingsPermissionsSection.swift index d5b95e4..ba9e9df 100644 --- a/SapoWhisper/Views/Settings/Components/SettingsPermissionsSection.swift +++ b/SapoWhisper/Views/Settings/Components/SettingsPermissionsSection.swift @@ -40,7 +40,7 @@ struct SettingsPermissionsSection: View { @ViewBuilder private var expandableSummary: some View { Button { - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(Constants.Animation.reveal) { isExpanded.toggle() } } label: { @@ -116,7 +116,7 @@ struct SettingsPermissionsSection: View { .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) .rotationEffect(.degrees(isExpanded ? 90 : 0)) - .animation(.easeInOut(duration: 0.2), value: isExpanded) + .animation(Constants.Animation.reveal, value: isExpanded) } .padding(.vertical, 8) .padding(.horizontal, 10) diff --git a/SapoWhisper/Views/Settings/Components/SettingsTextEditor.swift b/SapoWhisper/Views/Settings/Components/SettingsTextEditor.swift index b1bc773..83becbf 100644 --- a/SapoWhisper/Views/Settings/Components/SettingsTextEditor.swift +++ b/SapoWhisper/Views/Settings/Components/SettingsTextEditor.swift @@ -31,6 +31,6 @@ struct SettingsTextEditor: View { RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(isFocused ? Color.sapoGreen.opacity(0.45) : Color.secondary.opacity(0.18)) ) - .animation(.easeInOut(duration: 0.15), value: isFocused) + .animation(Constants.Animation.hover, value: isFocused) } } diff --git a/SapoWhisper/Views/Settings/Components/VocabularyChips.swift b/SapoWhisper/Views/Settings/Components/VocabularyChips.swift index 3c0299b..95ccf5a 100644 --- a/SapoWhisper/Views/Settings/Components/VocabularyChips.swift +++ b/SapoWhisper/Views/Settings/Components/VocabularyChips.swift @@ -32,7 +32,7 @@ struct VocabularyTermRow: View { .padding(.vertical, 6) .background(chipBackground(isHovering: isHovering)) .onHover { isHovering = $0 } - .animation(.easeOut(duration: 0.12), value: isHovering) + .animation(Constants.Animation.hover, value: isHovering) } } @@ -73,7 +73,7 @@ struct VocabularyReplacementRow: View { .padding(.vertical, 6) .background(chipBackground(isHovering: isHovering)) .onHover { isHovering = $0 } - .animation(.easeOut(duration: 0.12), value: isHovering) + .animation(Constants.Animation.hover, value: isHovering) } } @@ -93,7 +93,7 @@ private struct VocabularyAIBadge: View { var body: some View { Text("vocab.learning.saved_badge".localized) .font(.caption2.weight(.semibold)) - .foregroundStyle(Color.sapoGreen) + .foregroundStyle(Color.sapoGreenText) .padding(.horizontal, 6) .padding(.vertical, 2) .background( @@ -104,11 +104,14 @@ private struct VocabularyAIBadge: View { } } -/// Delete button that stays in the layout (no reflow) but only shows on hover. +/// Delete button that stays in the layout (no reflow) but only shows on hover +/// or while keyboard focus sits on it (Full Keyboard Access / VoiceOver). private struct VocabularyDeleteButton: View { let isVisible: Bool let action: () -> Void + @FocusState private var isFocused: Bool + var body: some View { Button(action: action) { Image(systemName: "xmark.circle.fill") @@ -116,7 +119,8 @@ private struct VocabularyDeleteButton: View { .font(.caption) } .buttonStyle(.plain) - .opacity(isVisible ? 1 : 0) + .focused($isFocused) + .opacity(isVisible || isFocused ? 1 : 0) .accessibilityLabel("config.delete_term_accessibility".localized) } } diff --git a/SapoWhisper/Views/Settings/Components/VocabularyMetricsHeader.swift b/SapoWhisper/Views/Settings/Components/VocabularyMetricsHeader.swift index 51abdf5..861e18a 100644 --- a/SapoWhisper/Views/Settings/Components/VocabularyMetricsHeader.swift +++ b/SapoWhisper/Views/Settings/Components/VocabularyMetricsHeader.swift @@ -98,7 +98,7 @@ struct VocabularyMetricsHeader: View { } } .fixedSize(horizontal: false, vertical: true) - .animation(.smooth(duration: 0.3), value: metrics) + .animation(Constants.Animation.transition, value: metrics) } private var paceValue: String { @@ -148,7 +148,7 @@ struct VocabularyMetricsHeader: View { .truncationMode(.tail) Text("×\(usage.count)") .font(.system(size: 11, weight: .semibold).monospacedDigit()) - .foregroundStyle(Color.sapoGreen) + .foregroundStyle(Color.sapoGreenText) } .padding(.horizontal, 7) .padding(.vertical, 4) diff --git a/SapoWhisper/Views/Settings/Components/VocabularySettingsCard.swift b/SapoWhisper/Views/Settings/Components/VocabularySettingsCard.swift index af55ef2..1680589 100644 --- a/SapoWhisper/Views/Settings/Components/VocabularySettingsCard.swift +++ b/SapoWhisper/Views/Settings/Components/VocabularySettingsCard.swift @@ -165,6 +165,7 @@ struct VocabularySettingsCard: View { .menuIndicator(.hidden) .frame(width: 28) .help("settings.vocabulary.transfer_help".localized) + .accessibilityLabel("settings.vocabulary.transfer_help".localized) } private var searchField: some View { @@ -181,6 +182,7 @@ struct VocabularySettingsCard: View { .foregroundColor(.secondary) } .buttonStyle(.plain) + .accessibilityLabel("common.clear_search".localized) } } .padding(.horizontal, 10) @@ -252,7 +254,7 @@ struct VocabularySettingsCard: View { .font(.caption) .disabled(newKeyterm.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - .animation(.easeOut(duration: 0.15), value: newKeyterm.isEmpty) + .animation(Constants.Animation.hover, value: newKeyterm.isEmpty) } /// Live limit feedback while typing, before the term is saved. Over-limit @@ -406,13 +408,13 @@ struct VocabularySettingsCard: View { HStack(spacing: 6) { Label("vocab.learning.title".localized, systemImage: "sparkles") .font(.caption.weight(.semibold)) - .foregroundStyle(Color.sapoGreen) + .foregroundStyle(Color.sapoGreenText) Spacer(minLength: 0) Text("vocab.learning.badge".localized) .font(.caption2.weight(.semibold)) - .foregroundStyle(Color.sapoGreen) + .foregroundStyle(Color.sapoGreenText) .padding(.horizontal, 7) .padding(.vertical, 3) .background( @@ -589,6 +591,7 @@ private struct LearnedCorrectionSuggestionRow: View { } .buttonStyle(.plain) .help("vocab.learning.accept".localized) + .accessibilityLabel("vocab.learning.accept".localized) Button(action: onEdit) { Image(systemName: "pencil.circle") @@ -596,6 +599,7 @@ private struct LearnedCorrectionSuggestionRow: View { } .buttonStyle(.plain) .help("vocab.learning.edit".localized) + .accessibilityLabel("vocab.learning.edit".localized) Button(action: onReject) { Image(systemName: "xmark.circle") @@ -603,6 +607,7 @@ private struct LearnedCorrectionSuggestionRow: View { } .buttonStyle(.plain) .help("vocab.learning.ignore".localized) + .accessibilityLabel("vocab.learning.ignore".localized) } .padding(.horizontal, 10) .padding(.vertical, 6) diff --git a/SapoWhisper/Views/Settings/SettingsView.swift b/SapoWhisper/Views/Settings/SettingsView.swift index 66a3a87..4416568 100644 --- a/SapoWhisper/Views/Settings/SettingsView.swift +++ b/SapoWhisper/Views/Settings/SettingsView.swift @@ -35,7 +35,10 @@ struct SettingsView: View { // Contenido del tab seleccionado selectedTabContent } - .frame(width: 860, height: 620) + .frame( + width: Constants.Windows.settingsSize.width, + height: Constants.Windows.settingsSize.height + ) .background(Color(NSColor.windowBackgroundColor)) .toolbarBackground(Color(NSColor.windowBackgroundColor), for: .windowToolbar) .toolbarBackground(.visible, for: .windowToolbar) diff --git a/SapoWhisper/Views/Settings/Tabs/EngineSettingsTab.swift b/SapoWhisper/Views/Settings/Tabs/EngineSettingsTab.swift index 7949e89..2defa45 100644 --- a/SapoWhisper/Views/Settings/Tabs/EngineSettingsTab.swift +++ b/SapoWhisper/Views/Settings/Tabs/EngineSettingsTab.swift @@ -50,7 +50,7 @@ struct EngineSettingsTab: View { hasDetails: engine.hasInlineSettings, isExpanded: $isSelectedEngineSettingsExpanded ) { - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(Constants.Animation.reveal) { if currentEngine == engine, engine.hasInlineSettings { isSelectedEngineSettingsExpanded.toggle() } else { diff --git a/SapoWhisper/Views/Settings/Tabs/GeneralSettingsTab.swift b/SapoWhisper/Views/Settings/Tabs/GeneralSettingsTab.swift index 0ee926b..bcc2bca 100644 --- a/SapoWhisper/Views/Settings/Tabs/GeneralSettingsTab.swift +++ b/SapoWhisper/Views/Settings/Tabs/GeneralSettingsTab.swift @@ -295,7 +295,7 @@ struct GeneralSettingsTab: View { Spacer() Text("\(Int(soundVolume * 100))%") .font(.caption.weight(.semibold)) - .foregroundStyle(Constants.Colors.sapoGreen) + .foregroundStyle(Constants.Colors.sapoGreenText) .monospacedDigit() } @@ -311,7 +311,7 @@ struct GeneralSettingsTab: View { .font(.caption) } .buttonStyle(.borderless) - .foregroundStyle(Constants.Colors.sapoGreen) + .foregroundStyle(Constants.Colors.sapoGreenText) .transition(.opacity.combined(with: .move(edge: .top))) } @@ -345,7 +345,7 @@ struct GeneralSettingsTab: View { Spacer() Text("\(Int(autoDuckingAmount * 100))%") .font(.caption.weight(.semibold)) - .foregroundStyle(Constants.Colors.sapoGreen) + .foregroundStyle(Constants.Colors.sapoGreenText) .monospacedDigit() } @@ -392,7 +392,7 @@ struct GeneralSettingsTab: View { fromByteCount: historyAudioUsageBytes, countStyle: .file) ) .font(.caption.weight(.semibold)) - .foregroundStyle(Constants.Colors.sapoGreen) + .foregroundStyle(Constants.Colors.sapoGreenText) .monospacedDigit() } diff --git a/SapoWhisper/Views/Settings/Tabs/HotkeySettingsTab.swift b/SapoWhisper/Views/Settings/Tabs/HotkeySettingsTab.swift index 3094d3b..0e01999 100644 --- a/SapoWhisper/Views/Settings/Tabs/HotkeySettingsTab.swift +++ b/SapoWhisper/Views/Settings/Tabs/HotkeySettingsTab.swift @@ -81,7 +81,7 @@ struct HotkeySettingsTab: View { doubleTapSection } } - .animation(.smooth(duration: 0.25), value: triggerKind) + .animation(Constants.Animation.reveal, value: triggerKind) } } @@ -210,7 +210,7 @@ struct HotkeySettingsTab: View { : "settings.hotkey_double_tap_try".localized ) .font(.caption) - .foregroundStyle(doubleTapFeedbackPhase >= 2 ? Color.sapoGreen : .secondary) + .foregroundStyle(doubleTapFeedbackPhase >= 2 ? Color.sapoGreenText : .secondary) } .onReceive(NotificationCenter.default.publisher(for: HotkeyManager.doubleTapFirstTapNotification)) { _ in registerDoubleTapFeedback(phase: 1) @@ -235,7 +235,7 @@ struct HotkeySettingsTab: View { doubleTapFeedbackResetTask = Task { @MainActor in try? await Task.sleep(nanoseconds: 1_600_000_000) guard !Task.isCancelled else { return } - withAnimation(.smooth(duration: 0.3)) { + withAnimation(Constants.Animation.transition) { doubleTapFeedbackPhase = 0 } } @@ -269,7 +269,7 @@ struct HotkeySettingsTab: View { keycapPressBounce += 1 if systemConflictName(keyCode: keyCode, modifiers: modifiers) != nil { - withAnimation(.spring(duration: 0.4)) { + withAnimation(Constants.Animation.shake) { conflictShake += 1 } } @@ -311,7 +311,7 @@ private struct DoubleTapModifierOption: View { Text(displayName) .font(.caption2.weight(isSelected ? .semibold : .regular)) - .foregroundStyle(isSelected ? Color.sapoGreen : .secondary) + .foregroundStyle(isSelected ? Color.sapoGreenText : .secondary) } .padding(.horizontal, 8) .padding(.vertical, 10) @@ -334,7 +334,7 @@ private struct DoubleTapModifierOption: View { } .buttonStyle(.plain) .scaleEffect(isHovered && !isSelected ? 1.04 : 1.0) - .animation(.smooth(duration: 0.18), value: isHovered) + .animation(Constants.Animation.hover, value: isHovered) .onHover { isHovered = $0 } } @@ -399,7 +399,7 @@ private struct AccessibilityPermissionFooter: View { } .buttonStyle(.plain) .font(.caption) - .foregroundStyle(Color.sapoGreen) + .foregroundStyle(Color.sapoGreenText) } .padding(.horizontal, 14) .padding(.vertical, 10) @@ -434,7 +434,7 @@ private struct AccessibilityPermissionFooter: View { private func refresh() { let granted = PermissionService.shared.isGranted(.accessibility) guard granted != isGranted else { return } - withAnimation(.smooth(duration: 0.3)) { + withAnimation(Constants.Animation.transition) { isGranted = granted } } diff --git a/SapoWhisperTests/AppStateTransitionTests.swift b/SapoWhisperTests/AppStateTransitionTests.swift new file mode 100644 index 0000000..a6f58fe --- /dev/null +++ b/SapoWhisperTests/AppStateTransitionTests.swift @@ -0,0 +1,57 @@ +// +// AppStateTransitionTests.swift +// SapoWhisperTests +// + +import XCTest + +@testable import SapoWhisper + +/// Pins the dictation state machine's legality table: every edge the real +/// flows take must be legal, and the clobber patterns the ViewModel's guards +/// were written against must not be. +final class AppStateTransitionTests: XCTestCase { + + private let error = AppState.error(ErrorState(message: "boom")) + + func testRealFlowEdgesAreLegal() { + // Live dictation: start → stop → polish → deliver. + XCTAssertTrue(AppState.idle.canTransition(to: .recording)) + XCTAssertTrue(AppState.recording.canTransition(to: .processing)) + XCTAssertTrue(AppState.processing.canTransition(to: .polishing)) + XCTAssertTrue(AppState.polishing.canTransition(to: .idle)) + + // Failure surfaces from any stage; retry restarts from error or idle. + XCTAssertTrue(AppState.recording.canTransition(to: error)) + XCTAssertTrue(AppState.processing.canTransition(to: error)) + XCTAssertTrue(AppState.polishing.canTransition(to: error)) + XCTAssertTrue(error.canTransition(to: .processing)) + XCTAssertTrue(AppState.idle.canTransition(to: .processing)) + + // Repolish runs from the completed pill; no-speech resets processing. + XCTAssertTrue(AppState.idle.canTransition(to: .polishing)) + XCTAssertTrue(AppState.processing.canTransition(to: .idle)) + + // Cloud engines record without a local model; model install recovers. + XCTAssertTrue(AppState.noModel.canTransition(to: .recording)) + XCTAssertTrue(AppState.noModel.canTransition(to: .idle)) + XCTAssertTrue(AppState.idle.canTransition(to: .noModel)) + } + + func testPublishersMayReEmitCurrentState() { + XCTAssertTrue(AppState.recording.canTransition(to: .recording)) + XCTAssertTrue(AppState.processing.canTransition(to: .processing)) + XCTAssertTrue(error.canTransition(to: AppState.error(ErrorState(message: "other")))) + } + + func testClobberEdgesAreIllegal() { + // The "never clobber an active session" comments, as a table. + XCTAssertFalse(AppState.processing.canTransition(to: .recording)) + XCTAssertFalse(AppState.polishing.canTransition(to: .recording)) + XCTAssertFalse(AppState.polishing.canTransition(to: .processing)) + XCTAssertFalse(AppState.recording.canTransition(to: .noModel)) + XCTAssertFalse(AppState.polishing.canTransition(to: .noModel)) + XCTAssertFalse(AppState.recording.canTransition(to: .polishing)) + XCTAssertFalse(AppState.noModel.canTransition(to: .polishing)) + } +} diff --git a/SapoWhisperTests/PolishOutputSanitizerTests.swift b/SapoWhisperTests/PolishOutputSanitizerTests.swift new file mode 100644 index 0000000..ad0902c --- /dev/null +++ b/SapoWhisperTests/PolishOutputSanitizerTests.swift @@ -0,0 +1,68 @@ +// +// PolishOutputSanitizerTests.swift +// SapoWhisperTests +// + +import XCTest + +@testable import SapoWhisper + +/// The sanitizer is the single boundary between provider output and the +/// user's clipboard/paste (guards are retry-only and the last output ships +/// after the budget): control bytes, bidi overrides, and invisible +/// characters must never survive — and legitimate text must be untouched. +final class PolishOutputSanitizerTests: XCTestCase { + + private func clean(_ output: String, raw: String = "texto dictado") -> String { + PolishOutputSanitizer.clean(output, rawText: raw) + } + + func testStripsANSIEscapeBytes() { + XCTAssertEqual( + clean("hola \u{1B}[31mmundo\u{1B}[0m"), + "hola [31mmundo[0m", + "ESC must be dropped so CSI sequences cannot rewrite a terminal line" + ) + } + + func testStripsOSCSequenceBytes() { + XCTAssertEqual( + clean("nota\u{1B}]52;c;cGF5bG9hZA==\u{07}final"), + "nota]52;c;cGF5bG9hZA==final", + "ESC and BEL must be dropped so OSC 52 cannot reach the clipboard" + ) + } + + func testStripsBidiOverrides() { + XCTAssertEqual(clean("factura \u{202E}021\u{202C} lista"), "factura 021 lista") + XCTAssertEqual(clean("total \u{2066}42\u{2069}"), "total 42") + } + + func testStripsInvisibleCharacters() { + XCTAssertEqual(clean("cla\u{200B}ve \u{2060}dos\u{FEFF}"), "clave dos") + } + + func testNormalizesCarriageReturns() { + XCTAssertEqual(clean("línea uno\r\nlínea dos\rlínea tres"), "línea uno\nlínea dos\nlínea tres") + } + + func testKeepsSpanishEmojiTabsAndNewlines() { + let text = "¿Qué señal? café\tlisto\nfamilia 👨‍👩‍👧 ¡ya!" + XCTAssertEqual(clean(text), text) + } + + func testStripApplsToRawFallbackToo() { + // A cleaned-to-empty output falls back to the original — that + // fallback must be the sanitized original, not the raw bytes. + let onlyWrapped = "\"\u{1B}[2Jtexto dictado\"" + XCTAssertFalse(clean(onlyWrapped).unicodeScalars.contains { $0.value == 0x1B }) + } + + func testComposesWithWrapperStripping() { + XCTAssertEqual( + clean("```\nhola\u{200B} mundo\n```"), + "hola mundo", + "control stripping must not break fence/preamble cleanup" + ) + } +} diff --git a/SapoWhisperTests/TranscriptionPipelineTests.swift b/SapoWhisperTests/TranscriptionPipelineTests.swift index 5a9dd62..1acda83 100644 --- a/SapoWhisperTests/TranscriptionPipelineTests.swift +++ b/SapoWhisperTests/TranscriptionPipelineTests.swift @@ -21,8 +21,16 @@ final class TranscriptionPipelineTests: XCTestCase { var polishedTranscripts: [(rawText: String, source: String, duration: TimeInterval?)] = [] var deliveredTexts: [String] = [] var presentedFailures: [TranscriptionFailure] = [] - var completedPersists: [(audioURL: URL, engineName: String, language: String, finalText: String)] = [] - var failedPersists: [(audioURL: URL, engineName: String, language: String, failureCode: String)] = [] + var completedPersists: + [( + audioURL: URL, engineName: String, language: String, finalText: String, + target: HistoryPersistenceTarget + )] = [] + var failedPersists: + [( + audioURL: URL, engineName: String, language: String, failureCode: String, + target: HistoryPersistenceTarget + )] = [] var staleCleanups: [(audioURL: URL, sessionID: UInt64)] = [] var clearedSessionCount = 0 var snapshotReasons: [String] = [] @@ -73,9 +81,10 @@ final class TranscriptionPipelineTests: XCTestCase { language: String, duration: TimeInterval, aiResult: TranscriptAIResult, - perf: DictationPerfTimeline? + perf: DictationPerfTimeline?, + target: HistoryPersistenceTarget ) { - completedPersists.append((audioURL, engineName, language, aiResult.finalText)) + completedPersists.append((audioURL, engineName, language, aiResult.finalText, target)) } func persistFailedDictation( @@ -84,9 +93,10 @@ final class TranscriptionPipelineTests: XCTestCase { engineName: String, language: String, duration: TimeInterval, - failure: TranscriptionFailure + failure: TranscriptionFailure, + target: HistoryPersistenceTarget ) { - failedPersists.append((audioURL, engineName, language, failure.diagnosticCode)) + failedPersists.append((audioURL, engineName, language, failure.diagnosticCode, target)) } func logTranscriptionSnapshot(reason: String, extra: String) { @@ -143,6 +153,7 @@ final class TranscriptionPipelineTests: XCTestCase { XCTAssertEqual(host.completedPersists.first?.language, "es") XCTAssertEqual(host.completedPersists.first?.engineName, "Deepgram Flux") XCTAssertEqual(host.completedPersists.first?.audioURL, audioURL) + XCTAssertEqual(host.completedPersists.first?.target, .insertNew) XCTAssertTrue(host.presentedFailures.isEmpty) XCTAssertTrue(host.failedPersists.isEmpty) @@ -294,4 +305,72 @@ final class TranscriptionPipelineTests: XCTestCase { XCTAssertTrue(host.presentedFailures.isEmpty) XCTAssertTrue(host.failedPersists.isEmpty) } + + // MARK: - History persistence target (retry path) + + private func makeRetryRequest(historyId: Int64 = 42) -> TranscriptionPipeline.Request { + TranscriptionPipeline.Request( + sessionID: 7, + engine: .deepgram, + engineName: "Deepgram Nova-3", + source: "retry", + failureLanguage: "auto", + snapshotPrefix: "retry-transcription", + logger: SapoLog.recording, + perf: nil, + historyTarget: .updateExisting(historyId: historyId) + ) + } + + func testRetryTargetReachesCompletedPersist() async { + let host = HostSpy() + let pipeline = TranscriptionPipeline(host: host) + + await pipeline.run(makeRetryRequest(historyId: 42)) { + self.makeOutput() + } captureResultOnFailure: { + nil + } + + XCTAssertEqual(host.completedPersists.count, 1) + XCTAssertEqual(host.completedPersists.first?.target, .updateExisting(historyId: 42)) + XCTAssertEqual(host.deliveredTexts.count, 1) + } + + func testRetryTargetReachesFailedPersist() async { + let host = HostSpy() + let pipeline = TranscriptionPipeline(host: host) + + await pipeline.run(makeRetryRequest(historyId: 42)) { + throw TranscriptionFailure(kind: .network, engine: "Deepgram") + } captureResultOnFailure: { + (self.audioURL, 3.0) + } + + XCTAssertEqual(host.failedPersists.count, 1) + XCTAssertEqual(host.failedPersists.first?.target, .updateExisting(historyId: 42)) + XCTAssertEqual(host.presentedFailures.count, 1) + } + + func testNilEngineDurationStillReachesPolishAsNil() async { + let host = HostSpy() + let pipeline = TranscriptionPipeline(host: host) + + await pipeline.run(makeRequest()) { + TranscriptionPipeline.EngineOutput( + transcript: "texto", + audioURL: self.audioURL, + duration: nil, + language: "es" + ) + } captureResultOnFailure: { + nil + } + + XCTAssertEqual(host.polishedTranscripts.count, 1) + XCTAssertNil( + host.polishedTranscripts.first?.duration, + "unknown duration must reach the polish gate as nil (allowed), never as 0 (blocked)" + ) + } }