feat: Apple Speech (SpeechAnalyzer) backend (#121) - #135
Conversation
The OS-native ASR backend: Speech.framework's SpeechAnalyzer / SpeechTranscriber, macOS 26+. No dependency, no weight download, no supply-chain pin — the model ships with the OS, so its version is the OS version. Registered unconditionally in CommandCore.live(); the struct carries no @available so it is constructible at the package's macOS 14 deployment target, and every macOS-26-only call sits behind an @available helper. isAvailable() is a pure version gate and never touches the asset inventory, which keeps list-backends cheap. Three probed behaviors drive the design: - transcriber.results is an AsyncSequence that must be consumed before audio is fed, or results are lost. - A missing locale asset reports "Audio format is not supported" — a misleading message. The engine checks installedLocales and downloads on demand instead of diagnosing from the error text. - The framework has no determinism knob, so decode_deterministic records flag-not-consumed rather than claiming enforcement (#118). Language is required rather than guessed: SpeechTranscriber has no auto-detect mode, and a ja file decoded under en_US yields garbage that would enter the benchmark evidence base silently. zh maps to zh_TW to match the project's Common Voice zh-TW corpora. Per-segment confidence is derived as the minimum over Apple's per-run values, so a confident run cannot mask a weak one inside the same cue. Also fixes a defect this backend exposed: Router's backend-membership list omitted apple-speech, so an explicit --backend was discarded and another backend's output was written under the user's chosen name, behind a false "unavailable" warning that --explain alone revealed. Refs #121
Six defects found by the verification legs, four of them capable of producing plausible-but-wrong output rather than an error. Locale resolution read subtags BY POSITION, so the script subtag in zh-Hans was taken for a region, matched nothing, and fell through to the zh preference: a user who asked for Simplified silently received the Traditional model (confirmed by probe). Subtags are now classified by shape, with Hans/Hant mapped to CN/TW and an explicit region still outranking a contradicting script. Provenance recorded the REQUESTED language rather than the one that ran, so a pt-BR request served by pt_PT was labelled pt-BR. The resolved locale is now recorded — hyphenated, because Apple's underscored zh_TW defeats LanguageResolver.baseSubtag (which splits on "-" only) and would have silently disabled the D7 zh script fold for this backend. Recognized text with unusable timing was dropped. The WER denominator is the reference word count and does not move, so a dropped correct word adds a deletion while a dropped garbage word removes an insertion — the measured rate moves in an unsignposted direction. That now fails the run. Whitespace-only segments are kept, since the seam joins with no separator. The locale asset was installed before the audio file was validated, so a typo in the path could cost a multi-hundred-MB download. A nil installation request also fell through to the misleading "Audio format is not supported" this preflight exists to prevent; installedLocales is now re-checked afterwards. The unstructured collector Task was neither awaited nor cancelled when the analyzer threw, pinning the transcriber across a benchmark sweep. aggregateConfidence now treats NaN/inf/out-of-range as unknown rather than passing them through as numbers. Also corrects a claim that exceeded its evidence: "no weight download" appeared in three places while this project itself had to download the ja_JP asset. The accurate claim is about provenance (Apple rather than a model hub), not about network traffic. Refs #121
#121) The package still RUNS on macOS 14, but since #121 it no longer BUILDS on an older toolchain: @available gates the runtime, not the type checker, so Xcode 16 fails on SpeechAnalyzer / SpeechTranscriber. CI is green only because the workflow selects the newest Xcode on the runner (Swift 6.2.4 = Xcode 26); nothing declared the requirement. Refs #121
Post-fix verification (real binary, not the test suite)The
|
| corpus | metric | result |
|---|---|---|
cv-zhtw-3 (zh) |
CER | 19.7 % @ 70× realtime |
fleurs-ja-2 (ja) |
CER | 10.9 % @ 103× realtime |
librispeech-testclean-2 (en) |
WER | 3.7 % @ 67× realtime |
Metric selection still resolves correctly (CER for zh/ja, WER for en) after the change that records the resolved locale rather than the requested tag — the specific regression risk there was that Apple's underscored zh_TW would defeat LanguageResolver.baseSubtag.
Note cv-zhtw-3 at 19.7 % is well above cv-zhtw-2's 13.9 %: per-corpus variance is large at this sample size, which is why the row stays verified: false until a proper sweep.
CI
Green (2m50s). Worth recording why, since it was in question: the workflow runs xcode-select -s "$NEWEST" and the macos-15 runner carries Xcode 26 (log shows Swift 6.2.4). The build floor genuinely rose to the macOS 26 SDK — @available gates the runtime, not the type checker — so that requirement is now stated in README Requirements rather than left implicit.
Requirements audit against #121The issue deferred five decisions to diagnose. Status of each:
One thing the issue asked for that is not visible yetThe issue expects Absent, not Checked for interaction: #130 and #135 both touch Verification legs actually runTwo, not the three planned. The cross-model (Codex, gpt-5.6-sol xhigh) leg found six defects — four of the plausible-but-wrong class — all fixed and covered. My own independent pass found the Router substitution and the Codex was also wrong twice and was not taken at face value: its case-sensitivity concerns ( |
Git auto-merged cleanly but silently mis-filed the CHANGELOG: it produced a duplicate '### Fixed' heading and moved the #109 (release sweep) and #111 (provenance fields) entries — both Added items — underneath it. Restructured so Unreleased has one Added (#121, #109, #111), one Fixed (#121 x6, #117, #116/#115, #112) and one Changed (#107). Verified against the post-merge main: 445 tests / 88 suites pass, which covers the semantic interaction CI could not see (#130 changed decodeDeterministic's type after this branch's CI last ran). Refs #121
Adds
apple-speech— the OS-native backend (Speech.framework'sSpeechAnalyzer/SpeechTranscriber, macOS 26+). It is 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 claim is about provenance, not network traffic — a locale whose asset is absent is still downloaded from Apple on first use, as
ja_JPwas here. An earlier draft of this PR said "no weight download" in three places; that was a claim exceeding its evidence and has been corrected.Refs #121
Measured results
The point of the issue was comparison, so here are numbers rather than a wiring claim. M5 Max, macOS 27, one corpus per language — indicative, not a certification; the grid row stays
verified: falseuntil a full sweep.cv-zhtw-2(zh)fleurs-ja-1(ja)librispeech-testclean-1(en)librispeech-devclean-1(en)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, with nothing to download or hold resident.
peak-GBreads 0.00 because recognition runs in Apple's out-of-process daemon and the in-process sampler cannot see it — that column is not comparable for this backend (filed as #138).Live probe evidence
en_*×9,zh_CN,zh_TW)Speech.swiftinterfacehas zero hits for temperature/greedy/beam/sampling/seed → recordsflag-not-consumed(#118)The misleading error, isolated
A missing locale asset surfaces as
SFSpeechErrorDomain Code=3 "Audio format is not supported". It is not a format problem. Two-step proof: three corpora byte-identical in format (1 ch / 16 kHz / Int16) behaved differently, and the samejafile transcribed fine underen_US. Installing the ja asset fixed it immediately. The engine checksinstalledLocalesand never diagnoses availability from the error text — following that message leads to a resampling bug that does not exist.Defects found during verification
Verification ran three independent lenses plus a cross-model (Codex) leg. Six defects surfaced, four capable of producing plausible-but-wrong output rather than an error. All are fixed in the second commit.
zh-Hansselected the Traditional model. Subtags were read by position, so the script subtag was taken for a region, matched nothing, and fell through to thezhpreference — a user asking for Simplified silently got Traditional. Confirmed by probe, then fixed by classifying subtags by shape (BCP-47: script = 4 alpha, region = 2 alpha / 3 digits).pt-BRserved bypt_PTwas labelledpt-BR. Now records the resolved locale — hyphenated, becauseLanguageResolver.baseSubtagsplits on-only, so Apple'szh_TWwould have silently disabled the D7 Traditional/Simplified fold for this backend."Audio format is not supported"the preflight exists to prevent.installedLocalesis now re-checked afterwards.Plus, separately:
--backend apple-speechwas silently substituted.Router's membership list omitted the backend, so the override was discarded and another backend's output was written under the user's chosen name — behind anunavailablewarning that was both false (isAvailable()returns true) and invisible without--explain. Measured, same command, before → after:RED evidence for the added test: the router threw
no ASR backend is available: whisperkit, …, apple-speech— listingapple-speechin the sentence denying it exists.Design decisions worth review
SpeechTranscriberhas no auto-detect mode; a ja file decoded underen_USyields measured garbage, and a benchmark harness must not admit a silent guess into its evidence base.nil.verified: false,estMemoryGBan explicit unmeasured placeholder. The numbers above are ad-hoc runs, not abestasr benchmarksweep over the canonical corpora, and Apple publishes no model footprint.languagesis the 25 probed base subtags, not"multi"— recommend/transcribe 的 --language auto 未偵測音訊語言,非英文內容被推薦英文專用後端 #105 is the standing lesson. (mulin the list is ISO 639-2 "multiple languages", Apple's realmul_INsubtag, not this field's sentinel.)Tests
421 tests / 87 suites green (branch baseline 389 → +32). The suite is hermetic — no real transcription, no download — so it certifies wiring, not recognition quality; that evidence is the tables above.
Non-vacuousness was checked by mutation: deleting the script-resolution branch failed exactly the script test (2 assertions) and nothing else.
Known limitations
verified: falseuntil a full sweep.collector.valuehas no upper bound.--explain) is pre-existing and is what made defect 7 above silent.peak-GBis not comparable for out-of-process backends.