Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,61 @@ All notable changes to bestASR are documented here. The format follows

### Added

- **Apple Speech backend (#121)**: `apple-speech` — the OS-native backend
(Speech.framework's `SpeechAnalyzer` / `SpeechTranscriber`, macOS 26+). The
only backend in the pool with no third-party dependency and no HuggingFace
weight pin: the recognizer ships with the operating system, so **its version
IS the OS version**. That is a claim about *provenance*, not about network
traffic — a locale whose asset is absent is still downloaded from Apple on
first use, as `ja_JP` was here. Registered unconditionally; `isAvailable()` is a pure
macOS-26 gate, so older hosts report it as not installed rather than failing
to build. One grid row (`apple-speech · speechanalyzer/system`), priority 1,
**`verified: false`** — the API is live-probed but the row is not yet measured
on this project's corpora, and `estMemoryGB` 2.0 is an explicit **unmeasured
placeholder**.

Three behaviors were established by live probing, not from the headers, and
are load-bearing enough to record here:

- The `results` `AsyncSequence` must be consumed **before** audio is fed, or
results are lost.
- A missing locale asset surfaces as `SFSpeechErrorDomain Code=3 "Audio
format is not supported"` — a **misleading message**. Proved not to be a
format problem (three corpora byte-identical in format behaved differently;
the same ja file transcribed fine under `en_US`; installing the ja asset
fixed it immediately). The engine therefore checks `installedLocales` and
downloads on demand, and never diagnoses from the error text.
- The framework exposes **no determinism knob** (`Speech.swiftinterface` has
zero hits for temperature/greedy/beam/sampling/seed), so this backend
records `flag-not-consumed` for `decode_deterministic` (#118) rather than
claiming an enforcement it cannot perform.

Language is **required**: `SpeechTranscriber` selects its model by locale and
has no auto-detect mode, so an unresolved language throws instead of guessing
— a ja file decoded under `en_US` yields measured garbage, and a benchmark
harness must not admit a silent guess into its evidence base. `zh` maps to
`zh_TW` to match the project's Common Voice zh-TW corpora. Per-segment
confidence is **derived** (the minimum over Apple's per-run values), so a
confident run cannot mask a weak one inside the same cue.

**First measured numbers** (M5 Max, macOS 27, single corpus per language —
indicative, not a certification; the row stays `verified: false` until a full
sweep):

| corpus | metric | apple-speech | whisperkit large-v3-turbo |
|---|---|---|---|
| `cv-zhtw-2` (zh) | CER | **13.9 %** @ 63–65× realtime | 12.5 % @ 7.1× |
| `fleurs-ja-1` (ja) | CER | **10.9 %** @ 85× realtime | 10.6 % @ 6.9× |
| `librispeech-testclean-1` (en) | WER | **4.3 %** @ 58× realtime | not yet measured |
| `librispeech-devclean-1` (en) | WER | **4.1 %** @ 45× realtime | not yet measured |

So on this machine Apple lands within ~1.4 pp of Whisper large-v3-turbo on
zh and ~0.3 pp on ja, at roughly **9–12× the speed** and with no model to
download or hold resident. Reported `peak-GB` is 0.00 because recognition
runs in Apple's out-of-process daemon — the harness's in-process sampler
cannot see that footprint, so this backend's memory column is **not
comparable** with the in-process backends'.

- **Release sweep (#109)**: `scripts/release-sweep.sh` — the per-version
deterministic measurement snapshot. Runs the priority-1 runnable candidates
(default ceiling; `--all-grid` widens to the whole grid) over the **canonical
Expand Down Expand Up @@ -53,6 +108,53 @@ All notable changes to bestASR are documented here. The format follows

### Fixed

- **`--backend apple-speech` was silently substituted (#121)**: `Router`'s
backend-membership list omitted the new backend, so an explicit `--backend
apple-speech` was discarded and another backend's output was written under
the user's chosen name. The accompanying `unavailable` warning was both
**false** (`isAvailable()` returns true on macOS 26+) and invisible without
`--explain`. Measured before the fix: the command produced mlx-audio Whisper
output in **Simplified** Chinese; after it, genuine Apple Speech output in
**Traditional**. `ModelRegistry.isRunnableModel` gained the same membership so
`--model system` resolves.

- **`zh-Hans` selected the Traditional model (#121 verify)**: language subtags
were read **by position**, so the script subtag in `zh-Hans` was taken for a
region, matched no locale, and fell through to the `zh` preference — a user
who explicitly asked for **Simplified** silently received **Traditional**.
Subtags are now classified by **shape** (BCP-47: script is 4 alpha, region is
2 alpha or 3 digits), with `Hans → CN` / `Hant → TW`; an explicit region still
outranks a contradicting script (`zh-Hans-TW` → `zh_TW`).

- **Provenance recorded the requested language, not the one that ran (#121
verify)**: asking for `pt-BR` on a system shipping only `pt_PT` produced a
measurement labelled `pt-BR`. The resolved locale is now recorded, in
hyphenated form — Apple's underscored `zh_TW` would otherwise defeat
`LanguageResolver.baseSubtag` (which splits on `-` only) and silently disable
the D7 Traditional/Simplified fold for this backend's zh scoring.

- **Locale asset downloaded before the audio file was validated (#121
verify)**: `transcribe --language ja /nonexistent.wav` could fetch a
multi-hundred-MB asset and only then report the missing file. The file is now
opened first. Additionally, a nil installation request no longer falls through
to Apple's misleading `"Audio format is not supported"`: `installedLocales` is
re-checked after the attempt and a still-absent asset fails with that
explanation.

- **Recognized speech could vanish silently (#121 verify)**: a result with
unusable timing was dropped. Because the WER denominator is the reference word
count and does not move, a dropped correct word adds a deletion while a dropped
garbage word removes an insertion — the measured rate shifts in an unsignposted
direction. Non-empty text with unusable timing now **fails the run**. Only
truly-empty text is dropped; whitespace-only segments are kept, since
`Engine.transcribe` joins with no separator and discarding the separator would
glue neighbouring words.

- **Orphaned collector task (#121 verify)**: the unstructured `Task` reading
`transcriber.results` was neither awaited nor cancelled when the analyzer
threw, pinning the transcriber and its buffers for the life of the process —
which accumulates across a benchmark sweep. It is now cancelled on every exit.

- **The compare stage no longer renders baseline values unescaped (#117)**:
`scripts/lib/baseline-compare.py` interpolated `corpus` / `language` /
`metric` straight into the pass/fail lines it prints, and that stdout is the
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ recommendations**, **stable fallback**, and **clear explanations**.
## Requirements

- Apple Silicon Mac (arm64) — Intel Macs and Rosetta are not supported
- macOS 14 (Sonoma) or later
- macOS 14 (Sonoma) or later to **run**
- **Xcode 26 / the macOS 26 SDK to _build_** (since #121). The `apple-speech`
backend references `SpeechAnalyzer` / `SpeechTranscriber`, which exist only in
that SDK. `@available` keeps the binary running on macOS 14 — it does **not**
let an older toolchain compile the file, so building with Xcode 16 fails at
type-checking with "cannot find type 'SpeechAnalyzer' in scope". Run-time
behavior below macOS 26 is unaffected: `isAvailable()` reports the backend as
not installed and it is skipped with a note.
- Swift 6.3+ (the Xcode built-in toolchain works). **Known issue**: the
swiftly-managed swift-6.2.4-RELEASE toolchain crashes the compiler
(signal 6, SIL specialization in the swift-transformers dependency)
Expand Down
8 changes: 8 additions & 0 deletions Sources/BestASRKit/CommandCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ public struct CommandCore: Sendable {
var engines: [any Engine] = [
WhisperKitEngine(), WhisperCppEngine(), ParakeetEngine(),
ChineseFamilyEngine.paraformer(), ChineseFamilyEngine.sensevoice(),
// #121: registered unconditionally. The struct is constructible at
// the package's macOS 14 deployment target; its isAvailable() is
// the pure macOS-26 gate, so an older host simply reports it as
// not installed instead of failing to build or link.
AppleSpeechEngine(),
]
for entry in ExternalEngineRegistry().engines {
engines.append(ExternalProcessEngine(id: entry.id, command: entry.command))
Expand Down Expand Up @@ -642,6 +647,9 @@ public struct CommandCore: Sendable {
let liveFamilies = [
ModelGrid.backendFluidParakeet, ModelGrid.backendFluidParaformer,
ModelGrid.backendFluidSenseVoice,
// #121: the OS-native backend is bundled too — a catalog row the
// catalog never prints is undiscoverable.
ModelGrid.backendAppleSpeech,
]
for backend in liveFamilies {
for row in ModelGrid.rows(backend: backend, priorityCeiling: nil) {
Expand Down
Loading
Loading