Skip to content

Commit 87272cf

Browse files
authored
Merge pull request #40 from StevenACZ/feat/360-round2
v2.9.0 — 360 round 2: 4-bit local model, Liquid Glass overlay, VoiceOver, paste hardening
2 parents 27a9168 + 98f911a commit 87272cf

82 files changed

Lines changed: 1558 additions & 654 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010
ci-check:
1111
runs-on: macos-15
1212
steps:
13-
- uses: actions/checkout@v4
13+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
1414

1515
- name: Select Xcode
1616
run: sudo xcode-select -s /Applications/Xcode.app

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ addresses, and machine-specific workflow details.
2020
- `TranscriptionPipeline`: shared transcribe -> polish -> paste -> persist control flow for stop paths; the ViewModel implements `TranscriptionPipelineHost`.
2121
- Strict concurrency is `complete` on app and test targets. Keep new code warning-free instead of widening unsafe isolation.
2222
- 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.
23-
- `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).
23+
- `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).
2424
- 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.
2525
- 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.
2626
- 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.

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,39 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
## [Unreleased]
88

9+
## [2.9.0] - 2026-07-09
10+
11+
### Added
12+
13+
- **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.
14+
- **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.
15+
- **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.
16+
- **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.
17+
18+
### Changed
19+
20+
- **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.
21+
- **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.
22+
- **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.
23+
- The vendored MLX decode loop honors task cancellation between decode steps — groundwork for cancellable re-transcriptions of long recordings.
24+
25+
### Fixed
26+
27+
- **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.
28+
- **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.
29+
- **Cmd+, opens the real Settings window** — previously a phantom empty settings window could appear while an app window was focused.
30+
- **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.
31+
- 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.
32+
- "now" and the m/h/d suffixes in History timestamps were hardcoded English in the Spanish UI; they are localized.
33+
- 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.
34+
35+
### Security
36+
37+
- **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.
38+
- **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.
39+
- History storage (database and audio) is created user-only (`0o700`).
40+
- 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.
41+
942
## [2.8.0] - 2026-07-06
1043

1144
### Added

LocalPackages/MLXWhisper/Package.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import PackageDescription
77
// (2026-07-03), trimmed to the Whisper model only so the app does not compile
88
// the 18 other STT families, the codecs, or mlx-swift-lm. Local changes on
99
// top of upstream: initial-prompt (`<|startofprev|>`) support so the user
10-
// vocabulary reaches the decoder, and a downloader with real progress
11-
// reporting instead of prints.
10+
// vocabulary reaches the decoder, a downloader with real progress reporting
11+
// instead of prints, 4/8-bit quantized-checkpoint loading (`quantization` in
12+
// config.json), and a Task-cancellation hook between decode steps so long
13+
// transcriptions can be aborted.
1214
let package = Package(
1315
name: "MLXWhisper",
1416
platforms: [.macOS(.v14)],

LocalPackages/MLXWhisper/Sources/MLXWhisper/MLXWhisperRuntime.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ public enum MLXWhisperRuntime {
1818

1919
extension WhisperModel {
2020
/// Convenience entry point over raw 16 kHz mono samples, so callers do
21-
/// not need to import MLX to build an `MLXArray`.
21+
/// not need to import MLX to build an `MLXArray`. Cancellation-aware:
22+
/// throws `CancellationError` when the surrounding task is cancelled.
2223
public func generate(
2324
samples: [Float],
2425
generationParameters: STTGenerateParameters
25-
) -> STTOutput {
26-
generate(audio: MLXArray(samples), generationParameters: generationParameters)
26+
) throws -> STTOutput {
27+
try generateCancellable(audio: MLXArray(samples), generationParameters: generationParameters)
2728
}
2829
}

LocalPackages/MLXWhisper/Sources/MLXWhisper/WhisperConfig.swift

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
import Foundation
22

3+
/// `quantization` block written by mlx-community 4/8-bit checkpoints; absent
4+
/// on fp16/fp32 checkpoints.
5+
public struct WhisperQuantization: Codable, Sendable {
6+
public var groupSize: Int
7+
public var bits: Int
8+
9+
enum CodingKeys: String, CodingKey {
10+
case groupSize = "group_size"
11+
case bits
12+
}
13+
14+
public init(groupSize: Int, bits: Int) {
15+
self.groupSize = groupSize
16+
self.bits = bits
17+
}
18+
}
19+
320
public struct WhisperConfig: Codable, Sendable {
421
public var modelType: String
522
public var vocabSize: Int
@@ -23,6 +40,8 @@ public struct WhisperConfig: Codable, Sendable {
2340

2441
public var scaleEmbedding: Bool
2542

43+
public var quantization: WhisperQuantization?
44+
2645
enum CodingKeys: String, CodingKey {
2746
case modelType = "model_type"
2847
case vocabSize = "vocab_size"
@@ -41,6 +60,7 @@ public struct WhisperConfig: Codable, Sendable {
4160
case padTokenId = "pad_token_id"
4261
case decoderStartTokenId = "decoder_start_token_id"
4362
case scaleEmbedding = "scale_embedding"
63+
case quantization
4464

4565
// OpenAI / mlx-whisper layout (mlx-community/whisper-*).
4666
case nMels = "n_mels"
@@ -72,7 +92,8 @@ public struct WhisperConfig: Codable, Sendable {
7292
eosTokenId: Int = 50257,
7393
padTokenId: Int = 50257,
7494
decoderStartTokenId: Int = 50258,
75-
scaleEmbedding: Bool = false
95+
scaleEmbedding: Bool = false,
96+
quantization: WhisperQuantization? = nil
7697
) {
7798
self.modelType = modelType
7899
self.vocabSize = vocabSize
@@ -91,6 +112,7 @@ public struct WhisperConfig: Codable, Sendable {
91112
self.padTokenId = padTokenId
92113
self.decoderStartTokenId = decoderStartTokenId
93114
self.scaleEmbedding = scaleEmbedding
115+
self.quantization = quantization
94116
}
95117

96118
public init(from decoder: Swift.Decoder) throws {
@@ -137,6 +159,7 @@ public struct WhisperConfig: Codable, Sendable {
137159
padTokenId = try c.decodeIfPresent(Int.self, forKey: .padTokenId) ?? 50257
138160
decoderStartTokenId = try c.decodeIfPresent(Int.self, forKey: .decoderStartTokenId) ?? 50258
139161
scaleEmbedding = try c.decodeIfPresent(Bool.self, forKey: .scaleEmbedding) ?? false
162+
quantization = try c.decodeIfPresent(WhisperQuantization.self, forKey: .quantization)
140163
}
141164

142165
public func encode(to encoder: Swift.Encoder) throws {
@@ -158,6 +181,7 @@ public struct WhisperConfig: Codable, Sendable {
158181
try c.encode(padTokenId, forKey: .padTokenId)
159182
try c.encode(decoderStartTokenId, forKey: .decoderStartTokenId)
160183
try c.encode(scaleEmbedding, forKey: .scaleEmbedding)
184+
try c.encodeIfPresent(quantization, forKey: .quantization)
161185
}
162186
}
163187

0 commit comments

Comments
 (0)