@@ -1,269 +1,201 @@ 1 - //! The Speech + AVFoundation actions of the `mobile` namespace — 2 - //! `startListening` and `stopListening` — **researched in full and 3 - //! deliberately not served**. Both fall through to the working Swift shim. 1 + //! The `mobile` namespace's speech pair: `startListening` and `stopListening`. 4 2 //! 5 - //! This is the outcome rule 6 exists for, and it is the cheapest one in the 6 - //! migration: the shim answers both actions exactly as the app answers them 7 - //! today, and — unlike `scheduleNotification`, `registerBackgroundTask` and 8 - //! `sendToWatch` — **there is no promise to strand**, because neither action 9 - //! has a `callbackId` and neither ever replies. Falling through costs the page 10 - //! nothing at all. What is written below is the part a host can honestly 11 - //! finish and pin: the event vocabulary, the four error messages, and the two 12 - //! detail shapers. What is missing is named, not glossed. 3 + //! Neither action replies. Both are raw `postMessage` calls with no 4 + //! `callbackId` (`CraftApp.swift:1562-1568`), so nothing on the page is 5 + //! awaiting an answer and every outcome — success, refusal, transcript, end — 6 + //! travels as a `craftSpeech*` event. That is why both are declared 7 + //! `.reply = .none` below, and why no path in this file returns an error to 8 + //! `ios_dispatch`: an error there would call `sendErrorToJS` under a request 9 + //! id no page is holding, which is console noise standing in for the event a 10 + //! page is actually listening for. 13 11 //! 14 - //! ## The contract, read rather than assumed 12 + //! ## What this round changed 15 13 //! 16 - //! **Payload: none, on both actions .** The injected JS is the whole surface 17 - //! (`CraftApp.swift:1562-1568`): 14 + //! An earlier round researched both actions in full and deliberately left them 15 + //! with the Swift shim, listing four blockers and five preconditions. All five 16 + //! preconditions are met here, and the four blockers are answered: 18 17 //! 19 - //! ```js 20 - //! startListening: function() { 21 - //! window.webkit.messageHandlers.craft.postMessage({action: 'startListening'}); 22 - //! }, 23 - //! stopListening: function() { 24 - //! window.webkit.messageHandlers.craft.postMessage({action: 'stopListening'}); 25 - //! }, 26 - //! ``` 18 + //! 1. `ios_events.Event` can spell `craftSpeechStart` now — the arm was added 19 + //! alongside this module, and the vocabulary test covers it. 20 + //! 2. **The realtime tap.** `-[AVAudioNode installTapOnBus:…]` runs its block 21 + //! on the audio I/O thread, which no other module in this tree touches. 22 + //! Swift gets a free ARC retain for the duration of 23 + //! `recognitionRequest?.append(buffer)`; Zig gets nothing, so releasing 24 + //! the request while a tap call is in flight is a use-after-free on a 25 + //! realtime thread. The answer is below under "The audio thread", and it 26 + //! is an atomic handoff with a bounded drain — never a mutex, which on an 27 + //! audio thread is a priority inversion. 28 + //! 3. **The zero-format abort.** With no input route 29 + //! `-outputFormatForBus:0` yields a format whose `sampleRate` and 30 + //! `channelCount` are 0, and `installTapOnBus:` then raises an 31 + //! `NSException` the process cannot catch. Swift has the identical crash 32 + //! at `:2535-2538`. `formatCarriesInput` pre-checks and refuses. 33 + //! 4. **Testability.** The fixture drives both actions on a simulator and 34 + //! asserts the events, so the audio path is exercised rather than only the 35 + //! shapers. 27 36 //! 28 - //! No fields, no `callbackId`, no return value. `ios_dispatch.payloadOf` would 29 - //! hand a handler `"{}"`. There is no second page surface: the nested mobile 30 - //! contract block (`:2297-2427`) has no speech methods, and `craft-bridge.js` 31 - //! has no `mobile` namespace at all — its `window.craft.speech` is desktop TTS 32 - //! over `AVSpeechSynthesizer`, namespace `t = "speech"`, unrelated. So is 33 - //! `bridge_speech_recognition.zig`, which is the desktop `speechRecognition` 34 - //! namespace with actions `isAvailable`/`start`/`stop`; it must not be 35 - //! confused with this one. 37 + //! ## The event contract, which is the whole page-visible surface 36 38 //! 37 - //! **Reply: none, on any path.** The dispatcher (`CraftApp.swift:533-536`) is 38 - //! 39 - //! ```swift 40 - //! case "startListening": 41 - //! if config.enableSpeechRecognition { startSpeechRecognition() } 42 - //! case "stopListening": 43 - //! stopSpeechRecognition() 44 - //! ``` 45 - //! 46 - //! and neither `startSpeechRecognition` (`:2486-2494`), `beginRecording` 47 - //! (`:2496-2548`) nor `stopSpeechRecognition` (`:2550-2559`) touches 48 - //! `resolveCallback`, `rejectCallback` or `resolveCallbackJSON` on any line. 49 - //! `craft.d.ts:188` and `:193` both declare `(): void`, and 50 - //! `test-bridges.html:785-795` awaits nothing. If this module ever serves 51 - //! these, the declaration is `.reply = .none` — the same reading 52 - //! `bridge_mobile_haptics.zig` made for `haptic`, and for the same reason. 53 - //! 54 - //! A denial does not reply either; it **emits**. Nor does the flag-off case 55 - //! emit anything at all: `if config.enableSpeechRecognition { … }` has no 56 - //! `else`, and `stopListening` is ungated and always runs. 57 - //! 58 - //! **Everything is events.** Four names, and the detail of each: 59 - //! 60 - //! | event | detail | when | 39 + //! | event | when Swift sends it | when this module does | 61 40 //! |---|---|---| 62 - //! | `craftSpeechError` | `{"error":"Not authorized"}` | auth status is not `.authorized` (`:2489`) | 63 - //! | `craftSpeechError` | `{"error":"Audio session failed"}` | `setCategory`/`setActive` threw (`:2507`) | 64 - //! | `craftSpeechError` | `{"error":"Speech recognizer unavailable"}` | request nil, recognizer nil, or not `isAvailable` (`:2515`) | 65 - //! | `craftSpeechError` | `{"error":"Audio engine failed"}` | `audioEngine.start()` threw (`:2546`) | 66 - //! | `craftSpeechStart` | `{}` | after `audioEngine.start()` succeeded (`:2543`) | 67 - //! | `craftSpeechResult` | `{"transcript": String, "isFinal": Bool}` | every partial and every final result (`:2525-2528`) | 68 - //! | `craftSpeechEnd` | `{}` | every `stopSpeechRecognition()` (`:2557`) | 69 - //! 70 - //! The result detail is **exactly two keys**. No `confidence`, no 71 - //! `alternatives`, no `segments`. Three independent consumers agree — 72 - //! `test-bridges.html:384-387` reads `e.detail.transcript` and 73 - //! `e.detail.isFinal`, `packages/ios/README.md:167-176` documents the same 74 - //! pair, and Android emits the same four names with the same keys 75 - //! (`CraftBridge.kt.template:1344-1383`). Unlike motion, there is no 76 - //! doc/implementation mismatch here to report. 77 - //! 78 - //! `sendToWeb` (`:4835-4846`) builds 79 - //! `window.dispatchEvent(new CustomEvent('<name>', {detail: <json>}));` — the 80 - //! same form `ios_events.formatEvent` builds, so the detail bytes below are 81 - //! the bytes that reach `e.detail` on either path. 82 - //! 83 - //! ## Why nothing is served 84 - //! 85 - //! Four blockers. Only the last three are load-bearing; the first is one line 86 - //! of data that would unblock nothing on its own. 41 + //! | `craftSpeechStart` | the engine started | the engine started | 42 + //! | `craftSpeechResult` | every partial and final transcript | the same | 43 + //! | `craftSpeechError` | four named failures | the same four | 44 + //! | `craftSpeechEnd` | every `stopSpeechRecognition()` | every `stopListening`, and every internal stop | 87 45 //! 88 - //! **1. `craftSpeechStart` cannot be spelled.** `ios_events.Event` carries 89 - //! `.speech_result`, `.speech_error` and `.speech_end`, and no 90 - //! `.speech_start`. Serving `startListening` without it would drop the one 91 - //! event that drives the page's state — `test-bridges.html:380-383` paints 92 - //! "Listening..." from it and from nothing else — so a working session would 93 - //! look to the page exactly like a session that never started. Adding the 94 - //! enum member and its `switch` arm is data rather than mechanism, and the 95 - //! existing test `"every event name is one an iOS page actually subscribes 96 - //! to"` already accepts the shape (`startsWith "craft"`, no colon). It is not 97 - //! left undone because a second file is off limits — landing this module 98 - //! already edits `ios_dispatch.zig`, `build.zig` and 99 - //! `test/ios_conformance_test.zig`. It is left undone because adding it alone 100 - //! serves nothing: blocker 2 stops `startListening` by itself, and an `Event` 101 - //! member no code emits is vocabulary for an event that never fires. So it is 102 - //! precondition 1 below rather than a reason of its own — and a test trips 103 - //! when it lands. 46 + //! `craftSpeechEnd` fires on a `stopListening` that stops nothing. Swift's 47 + //! `stopSpeechRecognition()` has no early return — it is called from 48 + //! `case "stopListening"` unconditionally, with no gate and no guard — so a 49 + //! page that stops twice gets two ends, and one that never started still gets 50 + //! one. That is bug-compatibility on purpose: the event is what a page's UI 51 + //! binds its "listening" indicator to, and an end that does not arrive leaves 52 + //! the indicator stuck on. 104 53 //! 105 - //! **2. The realtime tap has no precedent in this tree.** 106 - //! `-[AVAudioNode installTapOnBus:bufferSize:format:block:]` runs its block on 107 - //! the audio I/O thread, which is not a queue any existing module touches. 108 - //! `bridge_mobile_motion.zig`'s handler is an `NSOperationQueue.mainQueue` 109 - //! callback and is **not** a precedent for it. Swift's 110 - //! `self?.recognitionRequest?.append(buffer)` gets a free ARC retain for the 111 - //! duration of the call; Zig has none, so `stopListening` releasing the 112 - //! request while a tap call is in flight is a use-after-free on a realtime 113 - //! thread. It is solvable — an atomic pointer, never a mutex (priority 114 - //! inversion), plus stop-engine, remove-tap, clear-pointer, never-release 115 - //! ordering — but solving it is building a new foundation, and this round was 116 - //! asked to apply proven ones and to say so instead. 54 + //! ## Deliberate divergences, each with its reason 117 55 //! 118 - //! **3. The guard the tap needs is asserted, not read.** With no active input 119 - //! route, `-outputFormatForBus:0` yields a format whose `sampleRate` and 120 - //! `channelCount` are 0, and `installTapOnBus:` then raises an 121 - //! `NSException` — which Zig cannot catch, so the process aborts. Swift has 122 - //! the identical crash at `:2535-2538`. The honest port pre-checks the format 123 - //! and refuses with `craftSpeechError`; but that behaviour comes from API 124 - //! knowledge, nothing in this repo documents it, and there is no simulator 125 - //! here to verify it. Shipping an unverified guard against an unreproducible 126 - //! abort is the one place rule 5 and rule 1 meet. 127 - //! 128 - //! **4. Nothing here can test the audio path.** Every host test that could be 129 - //! written for a served version would exercise selector interning and block 130 - //! flags, and nothing of the recognizer, the session, the engine or the tap. 131 - //! That is a served action whose entire risk surface is untested. 132 - //! 133 - //! The gate is *not* among the blockers, because it is solved: 134 - //! `config.enableSpeechRecognition` defaults to `false` 135 - //! (`CraftApp.swift:189`, `packages/ios/src/index.ts:117`) and reaches Zig 136 - //! through nothing, but `packages/ios/src/index.ts:183` writes 56 + //! **The plist guard replaces the nil-recognizer guard.** Swift builds its 57 + //! `SFSpeechRecognizer` only when `config.enableSpeechRecognition` is on 58 + //! (`:439-440`), and `beginRecording`'s `guard let speechRecognizer` then 59 + //! emits `"Speech recognizer unavailable"` when it is off. Zig cannot see the 60 + //! ivar, but `packages/ios/src/index.ts:183` writes 137 61 //! `NSSpeechRecognitionUsageDescription` from that flag **and nothing else, 138 62 //! with no `||`** — the exact-proxy shape `bridge_mobile_motion.zig` already 139 - //! reads. (`NSMicrophoneUsageDescription` at `:184` is 140 - //! `enableSpeechRecognition || enableAudioRecording` and is **not** an exact 141 - //! proxy; it must not be used as the gate.) Here the proxy is mandatory 142 - //! rather than an improvement: without those keys in Info.plist iOS 143 - //! *terminates the process* on the authorization request or on mic access. 63 + //! reads. So a missing key means the flag was off, and the message emitted is 64 + //! the one Swift would have emitted for the same configuration. 65 + //! 66 + //! The check has to run *before* `requestAuthorization:`, where Swift's runs 67 + //! after, because asking for speech authorization without the key does not 68 + //! fail — iOS terminates the process. Order diverges; the page-visible 69 + //! message does not. 144 70 //! 145 - //! ## Why falling through, and not `.unavailable` 71 + //! (`NSMicrophoneUsageDescription` at `index.ts:184` is 72 + //! `enableSpeechRecognition || enableAudioRecording` and is therefore **not** 73 + //! an exact proxy, so it is not the gate. It is implied by the speech key 74 + //! rather than checked: speech on always writes both.) 146 75 //! 147 - //! `.unavailable` makes `ios_dispatch` route the action into this module and 148 - //! refuse it. For an action with a pending promise that is a nameable failure 149 - //! and an improvement on silence. Here there is no pending promise on any 150 - //! page surface, so the refusal would surface only as a console log — while 151 - //! taking away a shim path that works completely. That is strictly worse, and 152 - //! it is why `A` below is empty rather than carrying two `.unavailable` rows. 76 + //! **A zero input format is refused as `"Audio engine failed"`.** There is no 77 + //! Swift string for it because Swift does not survive the case, and inventing 78 + //! a fifth message would put bytes on a page's screen that no other arm of the 79 + //! bridge can produce. `startAndReturnError:` failing for the same underlying 80 + //! reason — no usable input — is what Swift reports as `"Audio engine 81 + //! failed"`, so the existing message is the honest one. 153 82 //! 154 - //! Serving only `stopListening` is worse still, and is ruled out for the 155 - //! reason `bridge_mobile_motion.zig` gives: a session started through the shim 156 - //! would meet a Zig stop, which would stop Zig's own null engine and emit 157 - //! `craftSpeechEnd` while Swift's recognizer kept pushing `craftSpeechResult`. 158 - //! Fabricated success at the event layer. Either both or neither; neither. 83 + //! **A second `startListening` while running stops first.** Swift's 84 + //! `beginRecording` cancels the task and nothing else (`:2497-2500`), then 85 + //! installs a second tap on bus 0, which raises. Reproducing that is not 86 + //! required of a port; `bridge_mobile_motion.zig` made the same call. The 87 + //! internal stop emits **no** `craftSpeechEnd`, because Swift's restart emits 88 + //! none either — only a real stop does. 159 89 //! 160 - //! ## What is finished here 90 + //! **`stopListening` does not touch the engine when nothing is running.** 91 + //! Swift's stop reads `audioEngine.inputNode` unconditionally, and that 92 + //! property *creates* the input node on demand; on a device with no input 93 + //! route available it can raise rather than return. Since a stop that stopped 94 + //! nothing has nothing to remove, the engine is touched only when this module 95 + //! knows a tap is installed. The `craftSpeechEnd` still fires, so the page 96 + //! sees no difference. 161 97 //! 162 - //! The two shapers and the four messages — the half of the port that decides 163 - //! page-visible bytes and that a host can pin. This is the precedent 164 - //! `bridge_mobile_haptics.zig` set with `triggerHapticChecked`: the finished 165 - //! half of a blocked action lives next to the admission, tested, so that 166 - //! unblocking it is a small deliberate edit rather than a rewrite. 98 + //! **Trailing buffers are not dropped.** The handoff clears the published 99 + //! request only after `removeTapOnBus:` has returned, so every buffer the tap 100 + //! delivered before removal reaches the recognizer — the same audio Swift 101 + //! would have appended. 167 102 //! 168 - //! `shapeResultDetail` is worth more than it looks. A transcript is arbitrary 169 - //! spoken text, the detail is inlined into JavaScript source as a literal, and 170 - //! `ios_events.zig`'s own module comment records what happens when a value 171 - //! like that is concatenated with hand-rolled escaping: `bridge_iap.zig:362` 172 - //! "works until a value contains a quote". Swift is safe here only because 173 - //! `JSONSerialization` escapes for it. Any Zig port must, and this one does. 103 + //! **On a recognition error, no `craftSpeechError` is emitted.** Swift 104 + //! discards the `NSError` entirely (`:2530-2532` calls only 105 + //! `stopSpeechRecognition()`), so the page sees `craftSpeechEnd` and nothing 106 + //! else. Android *does* emit one (`CraftBridge.kt.template:1367`). The 107 + //! divergence is left unfixed here on purpose: there is no Swift string to 108 + //! copy, and inventing one prejudges a contract question that belongs to the 109 + //! JS surface, not to a port. `error_messages` below is still exactly four. 174 110 //! 175 - //! ## What a future round still owes 111 + //! **Both haptics fire ungated.** `:2544` and `:2558` call 112 + //! `triggerHaptic("light")` directly, bypassing the `config.enableHaptics` 113 + //! check the dispatcher applies to `case "haptic"` at `:537`. Swift ignores 114 + //! the flag on this path, so firing it here is faithful rather than a 115 + //! divergence — and it is why `bridge_mobile_haptics.triggerHapticChecked` 116 + //! became `pub` in the same change. 176 117 //! 177 - //! Five preconditions, all five, before either action moves into `A`: 118 + //! ## The audio thread 178 119 //! 179 - //! 1. `.speech_start => "craftSpeechStart"` in `ios_events.Event`. 180 - //! 2. Refuse unless Info.plist carries `NSSpeechRecognitionUsageDescription` 181 - //! (`bridge_mobile_motion.zig:infoPlistHas` is the shape). 182 - //! 3. Validate `-sampleRate` and `-channelCount` before `installTapOnBus:`, 183 - //! and emit `craftSpeechError` rather than installing on a zero format. 184 - //! 4. Never release the recognition request while a tap call may be in 185 - //! flight — atomically clear the pointer after `removeTapOnBus:`, and 186 - //! prefer leaking one request per session over racing the audio thread. 187 - //! 5. Hop to the main queue with `dispatch_async_f(&_dispatch_main_q, …)` 188 - //! before touching `AVAudioEngine` from the recognition task handler; it 189 - //! fires on an internal Speech queue. (`ios_events.emit` needs no hop — 190 - //! it hops itself.) 120 + //! One rule: **the tap block touches exactly two atomics and one selector, 121 + //! all of them resolved before the tap was installed.** No allocation, no 122 + //! `sel_registerName`, no logging, no lock. 191 123 //! 192 - //! And two decisions that are decisions, not facts, to be taken out loud: 124 + //! The handoff is a published pointer plus an in-flight counter: 193 125 //! 194 - //! - **The error-emission divergence.** On a recognition error Swift emits 195 - //! **no** `craftSpeechError` — `:2530-2532` only calls 196 - //! `stopSpeechRecognition()`, so the page gets `craftSpeechEnd` and the 197 - //! `NSError` is discarded entirely. Android *does* emit one 198 - //! (`CraftBridge.kt.template:1367`). Matching Swift makes a simulator 199 - //! failure look exactly like a stream that never fires; matching Android 200 - //! tells the truth but deviates from the emitter contract this migration 201 - //! preserves. `err_recognition_failed` below is deliberately absent: there 202 - //! is no Swift string to copy, so inventing one here would prejudge it. 203 - //! - **`beginRecording` never stops the engine or removes the old tap** 204 - //! (`:2497-2500` cancels only the task). A second `startListening` while 205 - //! running installs a second tap on bus 0, which raises on iOS. 206 - //! Reproducing that bug is not required; stopping first, as 207 - //! `bridge_mobile_motion.zig` chose to, is the defensible port. 126 + //! - `active_request` holds the `SFSpeechAudioBufferRecognitionRequest` as a 127 + //! `usize`. Zero means "nobody may append". 128 + //! - `tap_in_flight` is incremented by the tap before it loads the pointer 129 + //! and decremented after the append returns. 130 + //! - Teardown stops the engine, removes the tap, clears `active_request`, 131 + //! then spins until `tap_in_flight` reads zero before releasing. 208 132 //! 209 - //! Also worth carrying: both `:2544` and `:2558` fire `triggerHaptic("light")` 210 - //! directly, bypassing the `config.enableHaptics` gate the dispatcher applies 211 - //! to `case "haptic"` at `:537`. Swift itself ignores the flag on this path, 212 - //! so firing it from a served port would be faithful rather than a 213 - //! divergence — but `triggerHapticChecked` in `bridge_mobile_haptics.zig` is 214 - //! not `pub`, so reuse needs a visibility change there. 133 + //! Every one of those four operations is `.seq_cst`, and that is not 134 + //! decoration. The dangerous interleaving is Dekker's: the tap increments then 135 + //! loads the pointer while teardown clears the pointer then loads the counter. 136 + //! Acquire/release ordering permits both sides to see the other's *old* value, 137 + //! which is exactly "teardown believes no tap is running while a tap holds a 138 + //! pointer it is about to release". Sequential consistency is what rules that 139 + //! out, and one `xchg` per audio buffer — roughly 86 a second at 1024 frames 140 + //! and 44.1kHz — is not a cost worth reasoning around. 215 141 //! 216 - //! ## Nothing in this file touches Objective-C 142 + //! The spin is bounded (`drain_spins` × 100µs = 20ms, against a tap call 143 + //! measured in microseconds) and runs on the main thread only while a stop is 144 + //! in progress. If it ever fails to drain, the request is **leaked rather 145 + //! than released** and the fact is logged. That is the trade the earlier round 146 + //! asked for in as many words: prefer leaking one request per session over 147 + //! racing the audio thread. 217 148 //! 218 - //! No `objc_getClass`, no `sel_registerName`, no block, no delegate, no 219 - //! `ios_async` ticket — there is no reply to correlate and no framework call 220 - //! to make. So there is nothing to Darwin-gate: the module compiles and its 221 - //! tests run identically on Linux and macOS, which is the one upside of 222 - //! serving nothing. 149 + //! ## Queues, and the hop each one needs 150 + //! 151 + //! - `handleMessage` runs on the main thread — it is reached from 152 + //! `-userContentController:didReceiveScriptMessage:`. 153 + //! - `+[SFSpeechRecognizer requestAuthorization:]` answers on an arbitrary 154 + //! queue, so its block hops to the main queue before anything touches 155 + //! `AVAudioSession` or `AVAudioEngine`. `bridge_mobile_audiorec.zig` makes 156 + //! the identical hop for the identical reason. 157 + //! - The recognition result handler fires on an internal Speech queue. It 158 + //! may emit (`ios_events.emit` hops itself) but must hop before tearing 159 + //! the engine down, which is precondition 5. 160 + //! - The tap block runs on the audio I/O thread. See above. 223 161 224 162 const std = ; 163 + const builtin = ; 225 164 const capabilities = ; 226 165 const bridge_error = ; 227 166 const ios_events = ; 167 + const objc_runtime = ; 168 + const haptics = ; 169 + 170 + const objc = objc_runtime.objc; 171 + const BridgeError = bridge_error.BridgeError; 172 + const Id = ?*anyopaque; 173 + const is_darwin = builtin.target.os.tag.isDarwin(); 228 174 229 - /// **Empty on purpose.** No action name here means `ios_dispatch` never routes 230 - /// `startListening` or `stopListening` into this module, so both reach the 231 - /// Swift shim that serves them correctly today. See the module comment for the 232 - /// four blockers and the five preconditions. 233 - /// 234 - /// `declared_count` is not decoration. `test/ios_conformance_test.zig` scans 235 - /// this block line by line from `pub const A = struct {` to the first bare 236 - /// `};`, and `zig fmt` collapses an empty struct onto one line — after which 237 - /// the scan would never find its terminator and would collect every quoted 238 - /// string in this file as an action name. A non-string member keeps the block 239 - /// two-line-safe under `zig fmt --check`, which CI runs. 240 175 pub const A = struct { 241 - /// How many actions this module claims. Zero, deliberately. 242 - pub const declared_count: usize = 0 ; 176 + pub const start_listening = "startListening"; 177 + pub const stop_listening = "stopListening" ; 243 178 }; 244 179 245 - /// Empty, and specifically not two `.unavailable` rows. 180 + /// `.none` for both, and the distinction matters. 246 181 /// 247 - /// `.unavailable` is for an action that dispatches and refuses — honest when a 248 - /// promise would otherwise hang, wrong here. Neither of these actions has a 249 - /// promise on any page surface, so a refusal would show up only in the 250 - /// console while removing a shim path that works end to end. Declaring 251 - /// nothing is what lets the shim keep answering. 252 - pub const capability_actions = [_]capabilities.ActionDecl{}; 253 - 254 - /// The two spec actions this module researched and left with the shim. 182 + /// `.result` would tell an app to await a reply that never comes: neither 183 + /// action calls `sendResultToJS` on any path, because neither has a page-side 184 + /// promise to settle. Everything observable leaves through `ios_events`. 255 185 /// 256 - /// Kept as data so the tests can prove both fall through rather than asserting 257 - /// it in prose, and so a future round has the exact spellings in one place. 258 - /// These names deliberately live outside the `A` block: inside it, the 259 - /// conformance scan would read them as served and the ratchet would drop for 260 - /// actions nothing here answers. 261 - pub const unserved_actions = [_][]const u8{ "startListening", "stopListening" }; 186 + /// `.live` for both. A refusal here is a specific condition — speech not 187 + /// configured, authorization declined, no input route — and never the normal 188 + /// answer, which is what `.unavailable` would claim. 189 + pub const capability_actions = [_]capabilities.ActionDecl{ 190 + .{ .name = A.start_listening, .reply = .none }, 191 + .{ .name = A.stop_listening, .reply = .none }, 192 + }; 262 193 263 194 pub const SpeechBridge = struct { 264 - /// Held but never used: `ios_dispatch` constructs every mobile bridge with 265 - /// `init(allocator)` and the shape is the interface. Nothing here 266 - /// allocates, because nothing here answers. 195 + /// Held for the interface `ios_dispatch` builds every mobile bridge with . 196 + /// Unused: nothing in a dispatch allocates, and the two paths that do — 197 + /// shaping a transcript and shaping an error — run from callbacks where 198 + /// this bridge is long gone, so they take the C allocator instead. 267 199 allocator: std.mem.Allocator, 268 200 269 201 const Self = @This(); ⋯ 4 lines Show the 4 lines above this hunk @@ -274,42 +206,39 @@ pub const SpeechBridge = struct { 274 206 275 207 pub fn deinit(_: *Self) void {} 276 208 277 - /// Always `UnknownAction` — "not mine, ask the next" — which is what makes 278 - /// the fall-through work. 279 - /// 280 - /// The payload is deliberately never parsed. `ios_dispatch.route` treats 281 - /// any error other than `UnknownAction` as final and stops the chain, so a 282 - /// module that parsed first and answered `InvalidJSON` on a malformed 283 - /// payload would strand an action it does not even serve. That is the 284 - /// failure this signature is shaped to make impossible, and a test below 285 - /// pins it with payloads that no parser would accept. 209 + /// The payload is ignored, deliberately: Swift reads no field from either 210 + /// action, and the injected JS posts neither a body nor a `callbackId`. 211 + /// Parsing it would only create a way to fail that the spec has not got. 286 212 pub fn handleMessage(_: *Self, action: []const u8, _: []const u8) !void { 287 - _ = action ; 288 - return bridge_error.BridgeError.UnknownAction; 213 + if (std.mem.eql(u8, action , A.start_listening)) { 214 + startListening(); 215 + return; 216 + } 217 + if (std.mem.eql(u8, action, A.stop_listening)) { 218 + stopListening(); 219 + return; 220 + } 221 + return BridgeError.UnknownAction; 289 222 } 290 223 }; 291 224 292 225 // ============================================================================= 293 - // The event vocabulary , as far as it can be spelled today . 226 + // The event vocabulary. 294 227 // ============================================================================= 295 228 229 + /// `craftSpeechStart` — the engine is running and audio is reaching the 230 + /// recognizer. 231 + pub const event_start: ios_events.Event = .speech_start; 232 + 296 233 /// `craftSpeechResult` — the transcript stream. 297 234 pub const event_result: ios_events.Event = .speech_result; 298 235 299 236 /// `craftSpeechError` — every refusal, on every path. 300 237 pub const event_error: ios_events.Event = .speech_error; 301 238 302 - /// `craftSpeechEnd` — every `stopSpeechRecognition()` , including one that 303 - /// stops nothing. 239 + /// `craftSpeechEnd` — every stop , including one that stops nothing. 304 240 pub const event_end: ios_events.Event = .speech_end; 305 241 306 - /// `craftSpeechStart`, which `ios_events.Event` cannot currently spell. 307 - /// 308 - /// A plain string, not an `Event`, because that is the whole point: there is 309 - /// no member to name. Pinned here so the eventual enum arm is copied from the 310 - /// contract rather than retyped from memory. 311 - pub const event_start_name = "craftSpeechStart"; 312 - 313 242 /// The `data: [:]` of `craftSpeechStart` and `craftSpeechEnd` — an empty 314 243 /// object, not an empty string and not `null`. `ios_events.formatEvent` 315 244 /// inlines the detail as a literal, so `e.detail` must still be an object the ⋯ 23 lines Show the 23 lines above this hunk @@ -339,12 +268,13 @@ pub const err_audio_session = "Audio session failed"; 339 268 /// NO. A simulator commonly answers NO here. 340 269 pub const err_recognizer_unavailable = "Speech recognizer unavailable"; 341 270 342 - /// `:2546` — `-startAndReturnError:` returned NO. 271 + /// `:2546` — `-startAndReturnError:` returned NO. Also the zero-format 272 + /// refusal, which Swift does not survive to report; see the module comment. 343 273 pub const err_audio_engine = "Audio engine failed"; 344 274 345 - /// Every message the port may emit, in Swift's source order. A future round 346 - /// adding a fifth must add it to Swift's contract first, or document the 347 - /// divergence — see the module comment on the recognition-error case. 275 + /// Every message this module may emit, in Swift's source order. Still four: 276 + /// the recognition-error divergence is documented rather than papered over 277 + /// with a fifth string Swift has never sent. 348 278 pub const error_messages = [_][]const u8{ 349 279 err_not_authorized, 350 280 err_audio_session, ⋯ 1 line Show the 1 line above this hunk @@ -352,6 +282,25 @@ pub const error_messages = [_][]const u8{ 352 282 err_audio_engine, 353 283 }; 354 284 285 + /// Why a start failed, in the vocabulary the page reads. 286 + /// 287 + /// An error set rather than a returned string, so a path that forgets to 288 + /// report is a compile error rather than an empty message, and so 289 + /// `messageFor` is the single place the mapping lives. 290 + const StartFailure = error{ 291 + AudioSession, 292 + RecognizerUnavailable, 293 + AudioEngine, 294 + }; 295 + 296 + fn messageFor(err: StartFailure) []const u8 { 297 + return switch (err) { 298 + error.AudioSession => err_audio_session, 299 + error.RecognizerUnavailable => err_recognizer_unavailable, 300 + error.AudioEngine => err_audio_engine, 301 + }; 302 + } 303 + 355 304 // ============================================================================= 356 305 // The detail shapers. Pure, and the bytes are the contract. 357 306 // ============================================================================= ⋯ 60 lines Show the 60 lines above this hunk @@ -418,393 +367,1046 @@ pub fn shapeResultDetail( 418 367 return out.toOwnedSlice(allocator); 419 368 } 420 369 370 + /// Emit one `craftSpeechError`, or drop it and say so. 371 + /// 372 + /// Every failure path in this file ends here, so this is the one place that 373 + /// decides what a page sees when even the reporting fails. Dropping is the 374 + /// only option — there is no promise to reject and no second channel — and 375 + /// the log line is what a developer has left. 376 + fn emitError(message: []const u8) void { 377 + const allocator = std.heap.c_allocator; 378 + const detail = shapeErrorDetail(allocator, message) catch { 379 + std.log.warn("speech: could not shape '{s}' for craftSpeechError", .{message}); 380 + return; 381 + }; 382 + defer allocator.free(detail); 383 + ios_events.emit(event_error, detail); 384 + } 385 + 421 386 // ============================================================================= 422 - // Tests. 387 + // Native constants, each read from the SDK header rather than recalled. 388 + // ============================================================================= 389 + 390 + /// `SFSpeechRecognizerAuthorizationStatusAuthorized`. 391 + /// 392 + /// `SFSpeechRecognizer.h:25-41` numbers the enum implicitly from zero: 393 + /// NotDetermined 0, Denied 1, Restricted 2, Authorized 3. Only 3 proceeds — 394 + /// Swift's `guard status == .authorized` treats the other three identically, 395 + /// including NotDetermined, which is what a user dismissing the prompt leaves 396 + /// behind. 397 + const status_authorized: isize = 3; 398 + 399 + /// `AVAudioSessionCategoryOptionDuckOthers` — `AVAudioSessionTypes.h:466`, 400 + /// `0x2`. Swift passes `options: .duckOthers`. 401 + const category_option_duck_others: c_ulong = 0x2; 402 + 403 + /// `AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation` — 404 + /// `AVAudioSessionTypes.h:660`, `1`. Swift passes it on activation, where the 405 + /// header says it is only meaningful on deactivation; carried across anyway, 406 + /// because it is inert rather than wrong and matching the shim costs nothing. 407 + const set_active_notify_others: c_ulong = 1; 408 + 409 + /// The bus every call here uses. Swift hardcodes 0 in all four places. 410 + const bus_zero: c_ulong = 0; 411 + 412 + /// Swift's `bufferSize: 1024` (`:2536`). `AVAudioFrameCount` is `uint32_t` 413 + /// (`AVAudioTypes.h:22`). 414 + const tap_buffer_frames: u32 = 1024; 415 + 416 + /// The locale Swift builds its recognizer with (`:440`). 417 + const recognizer_locale = "en-US"; 418 + 419 + /// The Info.plist key that stands in for `config.enableSpeechRecognition`. 420 + const key_speech_usage = "NSSpeechRecognitionUsageDescription"; 421 + 422 + /// How many spins teardown will wait for the audio thread before giving up and 423 + /// leaking the request. 200 × 100µs is 20ms, against a tap call measured in 424 + /// microseconds — long enough that reaching the end means something is wrong, 425 + /// short enough that a page's stop still feels instant. 426 + const drain_spins: usize = 200; 427 + const drain_spin_micros: c_uint = 100; 428 + 429 + // ============================================================================= 430 + // State. 423 431 // 424 - // Two things are under test. First, that this module really does serve 425 - // nothing — declaring nothing is only half of it, since a module that 426 - // answered anything other than `UnknownAction` would break the fall-through 427 - // even with an empty table. Second, the finished half: the event names and the 428 - // two detail shapers, whose bytes are what a page reads. 432 + // Module-level, because `ios_dispatch` builds a fresh `SpeechBridge` per 433 + // dispatch and drops it again: anything held on the bridge would be released 434 + // the instant `startListening` returned, and the session with it. Swift's are 435 + // ivars on a coordinator that outlives every call, for the same reason. 429 436 // 430 - // Nothing here is Darwin-gated, because nothing in this file touches 431 - // Objective-C. 437 + // Everything here is main-thread state except the two atomics, which are the 438 + // only things the audio thread may touch. 432 439 // ============================================================================= 433 440 434 - const testing = std.testing; 441 + /// The `SFSpeechRecognizer`, built once and kept. Swift's is built at 442 + /// coordinator init when the flag is on. 443 + var recognizer: Id = null; 435 444 436 - test "this module declares no actions, so both fall through to the shim" { 437 - // Table -> dispatch, with the table empty. The shim serves both actions 438 - // correctly today and neither has a promise to strand, so an empty table 439 - // is the whole feature: `ios_dispatch.route` never enters this module and 440 - // `handOffToHost` answers. 441 - try testing.expectEqual(@as(usize, 0), capability_actions.len); 442 - try testing.expectEqual(@as(usize, 0), A.declared_count); 443 - } 444 - 445 - test "the two unserved actions are the spec's, spelled exactly" { 446 - // The spelling a future round will move into `A`. Pinned here so the move 447 - // is a copy rather than a retype — `startlistening` would route nowhere and 448 - // look exactly like this module still not serving it. 449 - try testing.expectEqual(@as(usize, 2), unserved_actions.len); 450 - try testing.expectEqualStrings("startListening", unserved_actions[0]); 451 - try testing.expectEqualStrings("stopListening", unserved_actions[1]); 452 - } 453 - 454 - test "neither unserved action is in the action table" { 455 - // The invariant that keeps the ratchet honest. An action listed in `A` 456 - // counts as migrated to `test/ios_conformance_test.zig` whether or not 457 - // anything answers it, so a name leaking from `unserved_actions` into the 458 - // table would lower the ratchet for work nobody did. 459 - for (unserved_actions) |name| { 460 - for (capability_actions) |decl| { 461 - try testing.expect(!std.mem.eql(u8, decl.name, name)); 462 - } 445 + /// The `AVAudioEngine`. Swift's is `private var audioEngine = AVAudioEngine()` 446 + /// (`:381`) — one instance for the process. 447 + var engine: Id = null; 448 + 449 + /// The current `SFSpeechRecognitionTask`, retained, cancelled by teardown. 450 + var task: Id = null; 451 + 452 + /// Whether a tap is installed on bus 0 of the engine's input node. The only 453 + /// thing that makes teardown touch the engine at all. 454 + var tap_installed: bool = false; 455 + 456 + /// Whether a start is between `requestAuthorization:` and the main-queue hop. 457 + /// A second `startListening` in that window is dropped rather than queued: it 458 + /// would produce a second authorization callback racing the first one's 459 + /// `beginRecording`, and the page cannot tell two starts apart anyway. 460 + var starting: bool = false; 461 + 462 + /// Whether a session is running, in the sense that `craftSpeechStart` was 463 + /// emitted and no end has been. 464 + var listening: bool = false; 465 + 466 + /// `@selector(appendAudioPCMBuffer:)`, interned on the main thread before the 467 + /// tap is installed. The audio thread must never call `sel_registerName`, 468 + /// which takes the runtime's lock. 469 + var sel_append: objc.SEL = null; 470 + 471 + /// The `SFSpeechAudioBufferRecognitionRequest` the tap may append to, as a 472 + /// `usize`. Zero means nobody may append. 473 + /// 474 + /// `.seq_cst` on every access; see the module comment on Dekker. 475 + var active_request: std.atomic.Value(usize) = .init(0); 476 + 477 + /// How many tap invocations are between their fence and their append. Teardown 478 + /// waits for this to reach zero before releasing what `active_request` held. 479 + var tap_in_flight: std.atomic.Value(u32) = .init(0); 480 + 481 + /// The authorization status, written on whatever queue Speech chose and read 482 + /// on the main queue after the hop. 483 + var auth_status: std.atomic.Value(isize) = .init(0); 484 + 485 + // ============================================================================= 486 + // startListening. 487 + // ============================================================================= 488 + 489 + /// `case "startListening"` (`CraftApp.swift:533-534`, `:2486-2548`). 490 + /// 491 + /// Returns nothing and reports nothing to the dispatcher: every outcome is an 492 + /// event. The plist guard runs before `requestAuthorization:` because asking 493 + /// without the key terminates the process rather than failing. 494 + fn startListening() void { 495 + if (!is_darwin) { 496 + emitError(err_recognizer_unavailable); 497 + return; 463 498 } 499 + 500 + if (starting) { 501 + std.log.info("startListening: an authorization request is already in flight", .{}); 502 + return; 503 + } 504 + 505 + if (!hasSpeechUsageDescription()) { 506 + std.log.warn( 507 + "startListening refused: Info.plist has no {s}, so this app was not built " ++ 508 + "with speech recognition enabled", 509 + .{key_speech_usage}, 510 + ); 511 + emitError(err_recognizer_unavailable); 512 + return; 513 + } 514 + 515 + const SFSpeechRecognizer = objc.objc_getClass("SFSpeechRecognizer") orelse { 516 + std.log.warn( 517 + "startListening: SFSpeechRecognizer is not in this process; " ++ 518 + "the app does not link Speech", 519 + .{}, 520 + ); 521 + emitError(err_recognizer_unavailable); 522 + return; 523 + }; 524 + const sel_request = objc.sel_registerName("requestAuthorization:") orelse { 525 + emitError(err_recognizer_unavailable); 526 + return; 527 + }; 528 + 529 + starting = true; 530 + 531 + const RequestFn = *const fn (Id, objc.SEL, *const AuthBlock) callconv(.c) void; 532 + const requestAuthorization: RequestFn = @ptrCast(&objc.objc_msgSend); 533 + requestAuthorization(SFSpeechRecognizer, sel_request, &auth_block); 464 534 } 465 535 466 - test "handleMessage refuses everything, including the actions it researched" { 467 - // Dispatch -> table, with both empty. `UnknownAction` is the only error 468 - // `ios_dispatch.route` reads as "not mine, ask the next"; any other error 469 - // is treated as final and would stop the chain before the shim. 470 - var bridge = SpeechBridge.init(testing.allocator); 471 - defer bridge.deinit(); 536 + /// Whether the main bundle's Info.plist carries the speech usage description. 537 + /// 538 + /// Answers `false` on any runtime failure rather than propagating one. Unlike 539 + /// `bridge_mobile_motion.zig`'s version, which separates "no NSBundle class" 540 + /// from "not configured" because it has an error to return, every caller here 541 + /// has only an event to emit — and the event would be the same either way, so 542 + /// the distinction would cost a code path and buy a page nothing. The log line 543 + /// above the call is where a broken process is named. 544 + fn hasSpeechUsageDescription() bool { 545 + if (!is_darwin) return false; 546 + 547 + const NSBundle = objc.objc_getClass("NSBundle") orelse return false; 548 + const sel_main = objc.sel_registerName("mainBundle") orelse return false; 549 + const bundle = objc.msgSendId(NSBundle, sel_main) orelse return false; 550 + 551 + const NSString = objc.objc_getClass("NSString") orelse return false; 552 + const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse return false; 553 + const ns_key = objc.msgSendId1( 554 + NSString, 555 + sel_string, 556 + @as([*:0]const u8, key_speech_usage), 557 + ) orelse return false; 558 + 559 + const sel_lookup = objc.sel_registerName("objectForInfoDictionaryKey:") orelse return false; 560 + return objc.msgSendId1(bundle, sel_lookup, ns_key) != null; 561 + } 562 + 563 + /// The main-queue half of the authorization answer. 564 + /// 565 + /// Swift hops here too (`:2493`) before `beginRecording`, because activating an 566 + /// audio session and starting an engine off the main thread is not supported. 567 + fn authorizedOnMain(_: ?*anyopaque) callconv(.c) void { 568 + starting = false; 569 + 570 + if (auth_status.load(.seq_cst) != status_authorized) { 571 + std.log.warn( 572 + "startListening: speech authorization was not granted (status {d})", 573 + .{auth_status.load(.seq_cst)}, 574 + ); 575 + emitError(err_not_authorized); 576 + return; 577 + } 472 578 473 - const E = bridge_error.BridgeError; 579 + beginRecording() catch |err| { 580 + std.log.warn("startListening: {s}", .{messageFor(err)}); 581 + emitError(messageFor(err)); 582 + return; 583 + }; 584 + } 474 585 475 - for (unserved_actions) |name| { 476 - try testing.expectError(E.UnknownAction, bridge.handleMessage(name, "{}")); 586 + /// Everything `beginRecording` does, in Swift's order (`:2496-2548`). 587 + /// 588 + /// Once `active_request` is published, ownership of the request belongs to 589 + /// `teardown`, so every failure after that point goes through `failStart` 590 + /// rather than releasing anything itself. That is why there is no `errdefer` 591 + /// here: an errdefer plus a teardown is a double release, and the object it 592 + /// would double-release is one an audio thread may be holding. 593 + fn beginRecording() StartFailure!void { 594 + // Swift cancels only the task and then installs a second tap, which 595 + // raises. Stop properly instead — and emit no end, because a restart is 596 + // not a stop. 597 + if (listening or tap_installed) teardown(false); 598 + 599 + const session = sharedAudioSession() orelse return error.AudioSession; 600 + configureSession(session) catch return error.AudioSession; 601 + 602 + const rec = ensureRecognizer() orelse return error.RecognizerUnavailable; 603 + if (!recognizerIsAvailable(rec)) { 604 + std.log.warn("startListening: the recognizer reports itself unavailable", .{}); 605 + return error.RecognizerUnavailable; 477 606 } 478 607 479 - // Casing, near misses, and the neighbouring namespaces' spellings — the 480 - // desktop `speechRecognition` actions in particular, which are a different 481 - // namespace and must never be answered from here. 482 - for ([_][]const u8{ 483 - "startlistening", 484 - "StartListening", 485 - "startListen", 486 - "listen", 487 - "start", 488 - "stop", 489 - "isAvailable", 490 - "speak", 491 - "startMotionUpdates", 492 - "getDeviceInfo", 493 - "", 494 - }) |name| { 495 - try testing.expectError(E.UnknownAction, bridge.handleMessage(name, "{}")); 608 + const eng = ensureEngine() orelse return error.AudioEngine; 609 + const input = inputNode(eng) orelse return error.AudioEngine; 610 + const format = outputFormat(input) orelse return error.AudioEngine; 611 + 612 + // Precondition 3. `installTapOnBus:` on a zero format raises an 613 + // NSException, and an uncaught ObjC exception is an uncatchable SIGABRT — 614 + // Swift aborts here, this returns. 615 + if (!formatCarriesInput(format)) { 616 + std.log.warn( 617 + "startListening: the input node reports no usable format, " ++ 618 + "so this process has no audio input route", 619 + .{}, 620 + ); 621 + return error.AudioEngine; 496 622 } 623 + 624 + // Interned before the tap exists, so the audio thread never needs the 625 + // runtime's selector lock. 626 + sel_append = objc.sel_registerName("appendAudioPCMBuffer:") orelse 627 + return error.RecognizerUnavailable; 628 + 629 + const request = newRecognitionRequest() orelse return error.RecognizerUnavailable; 630 + setShouldReportPartialResults(request, true); 631 + 632 + // From here on the request is teardown's to release. 633 + active_request.store(@intFromPtr(request), .seq_cst); 634 + 635 + const started_task = startRecognitionTask(rec, request) orelse 636 + return failStart(error.RecognizerUnavailable); 637 + // `recognitionTaskWithRequest:` returns an autoreleased task; without a 638 + // retain it dies at the end of this turn of the run loop and the stream 639 + // stops with no event to say so. 640 + task = objc.retain(started_task); 641 + 642 + installTap(input, format) catch return failStart(error.AudioEngine); 643 + tap_installed = true; 644 + 645 + prepareEngine(eng); 646 + if (!startEngine(eng)) return failStart(error.AudioEngine); 647 + 648 + listening = true; 649 + ios_events.emit(event_start, empty_detail); 650 + fireHaptic(); 497 651 } 498 652 499 - test "no payload can turn the fall-through into a final error" { 500 - // The subtle way a serve-nothing module breaks the thing it is protecting. 501 - // `ios_dispatch.route` stops the chain on any error but `UnknownAction`, so 502 - // a module that parsed its payload before routing would answer 503 - // `InvalidJSON` for a malformed one and strand an action it does not 504 - // serve — the page would get a rejection where the shim would have worked. 505 - var bridge = SpeechBridge.init(testing.allocator); 506 - defer bridge.deinit(); 653 + /// Tear down whatever was built, then report why the start failed. 654 + /// 655 + /// No `craftSpeechEnd`: a start that never emitted `craftSpeechStart` has no 656 + /// end to announce, and Swift's failure paths emit only the error. 657 + fn failStart(err: StartFailure) StartFailure { 658 + teardown(false); 659 + return err; 660 + } 661 + 662 + // ============================================================================= 663 + // stopListening, and the teardown both stops share. 664 + // ============================================================================= 665 + 666 + /// `case "stopListening"` (`CraftApp.swift:535-536`, `:2550-2559`). 667 + /// 668 + /// Ungated in the spec — there is no `if config.` on this case — so 669 + /// `ios_config.gateFor` has no entry for it either, and an app with speech 670 + /// disabled can still stop. Which matters: the page's own stop is how a stuck 671 + /// listening indicator gets cleared. 672 + fn stopListening() void { 673 + if (!is_darwin) { 674 + // Nothing to stop, and the page still gets its end. `emit` is a no-op 675 + // off Darwin, so this is documentation more than behaviour. 676 + ios_events.emit(event_end, empty_detail); 677 + return; 678 + } 679 + teardown(true); 680 + } 681 + 682 + /// The main-queue landing for a stop the recognition handler asked for. 683 + /// 684 + /// Precondition 5: the handler fires on an internal Speech queue, and 685 + /// `AVAudioEngine` must not be touched from there. 686 + fn stopOnMain(_: ?*anyopaque) callconv(.c) void { 687 + teardown(true); 688 + } 689 + 690 + /// Release everything, in the one order that is safe against the audio thread. 691 + /// 692 + /// 1. Stop the engine and remove the tap, so `AVAudioEngine` guarantees no 693 + /// new tap call begins. 694 + /// 2. Clear the published request, so a call that began before step 1 695 + /// returned finds nothing to append to. 696 + /// 3. Drain: wait for any in-flight tap call to leave. 697 + /// 4. `endAudio`, then release — or leak, if step 3 timed out. 698 + /// 5. Cancel and release the task. 699 + /// 700 + /// Steps 1 and 2 in that order rather than the reverse: clearing first would 701 + /// silently drop the last buffers the tap had already delivered, which is 702 + /// audio Swift would have transcribed. 703 + /// 704 + /// Idempotent. Every field is checked, so a stop that stops nothing does 705 + /// nothing but emit — which is exactly what `stopListening` needs. 706 + fn teardown(emit_end: bool) void { 707 + if (!is_darwin) return; 708 + 709 + if (tap_installed) { 710 + if (engine) |eng| { 711 + stopEngine(eng); 712 + if (inputNode(eng)) |input| removeTap(input); 713 + } 714 + tap_installed = false; 715 + } 507 716 508 - const E = bridge_error . BridgeError ; 509 - 510 - for ([_][]const u8{ 511 - "{not json", 512 - "", 513 - "null", 514 - "[1,2,3]", 515 - "{\"transcript\":\"hello\"}", 516 - "{\"callbackId\":\"cb_7\" } ", 517 - }) |payload| { 518 - for (unserved_actions) |name| { 519 - try testing.expectError(E.UnknownAction, bridge.handleMessage(name, payload)); 717 + const held = active_request . swap(0, .seq_cst) ; 718 + const drained = drainTap(); 719 + 720 + if (held != 0) { 721 + const request: Id = @ptrFromInt(held); 722 + endAudio(request); 723 + if (drained) { 724 + objc.release(request); 725 + } else { 726 + std.log.warn( 727 + "stopListening: the audio thread did not leave within {d}ms, " ++ 728 + "so one recognition request is leaked rather than released", 729 + .{(drain_spins * drain_spin_micros) / 1000}, 730 + ); 520 731 } 521 732 } 733 + 734 + if (task) |t| { 735 + cancelTask(t); 736 + objc.release(t); 737 + task = null; 738 + } 739 + 740 + listening = false; 741 + 742 + if (emit_end) { 743 + ios_events.emit(event_end, empty_detail); 744 + fireHaptic(); 745 + } 522 746 } 523 747 524 - test "the three spellable events are the ones the page subscribes to" { 525 - // `test-bridges.html:384`, `:388`, `:391`, and Swift's `sendToWeb` call 526 - // sites. camelCase, no colon — the iOS vocabulary, which is why 527 - // `ios_events.Event` exists separately from `capabilities.Channel`. 528 - try testing.expectEqualStrings("craftSpeechResult", event_result.eventName()); 529 - try testing.expectEqualStrings("craftSpeechError", event_error.eventName()); 530 - try testing.expectEqualStrings("craftSpeechEnd", event_end.eventName()); 748 + /// Wait for `tap_in_flight` to reach zero, bounded. 749 + /// 750 + /// Sleeping rather than spinning hot: the audio thread runs at a higher 751 + /// priority than the main thread, so burning the main thread's slice is the 752 + /// one thing that could actually delay the call being waited on. This Zig has 753 + /// no `std.Thread.sleep` (`startup_timing.zig:150` records the same gap), and 754 + /// a busy spin is the wrong shape here, so the sleep is libc's — which this 755 + /// file already depends on for `dlsym` and `dispatch_async_f`. 756 + fn drainTap() bool { 757 + var spins: usize = 0; 758 + while (spins < drain_spins) : (spins += 1) { 759 + if (tap_in_flight.load(.seq_cst) == 0) return true; 760 + _ = usleep(drain_spin_micros); 761 + } 762 + return tap_in_flight.load(.seq_cst) == 0; 763 + } 531 764 532 - // And no desktop channel can spell any of them, so emitting through 533 - // `capabilities.Channel` would fire events with no subscriber. 534 - for (std.enums.values(capabilities.Channel)) |channel| { 535 - try testing.expect(!std.mem.eql(u8, channel.eventName(), event_result.eventName())); 536 - try testing.expect(!std.mem.eql(u8, channel.eventName(), event_error.eventName())); 537 - try testing.expect(!std.mem.eql(u8, channel.eventName(), event_end.eventName())); 538 - try testing.expect(!std.mem.eql(u8, channel.eventName(), event_start_name)); 765 + /// `triggerHaptic(style: "light")`, as `:2544` and `:2558` fire it. 766 + /// 767 + /// Ungated on purpose — see the module comment. Errors are swallowed because 768 + /// the Taptic Engine is absent on the simulator and on iPad, and a haptic that 769 + /// could not fire is not a reason to withhold a speech event. 770 + fn fireHaptic() void { 771 + haptics.triggerHapticChecked(.impact_light) catch |err| { 772 + std.log.debug("speech: the light haptic did not fire: {}", .{err}); 773 + }; 774 + } 775 + 776 + // ============================================================================= 777 + // Blocks. 778 + // 779 + // All three are global blocks — `_NSConcreteGlobalBlock` with 780 + // `BLOCK_IS_GLOBAL`, so `Block_copy` is identity and none of them can outlive 781 + // its storage. None captures anything: the authorization block writes an 782 + // atomic and hops, the result block reads its arguments, and the tap block 783 + // reads two atomics. Capture would mean a heap block, a copy helper, and a 784 + // dispose helper, and the tap block would then be allocating on the audio 785 + // thread. 786 + // ============================================================================= 787 + 788 + const BlockDescriptor = extern struct { 789 + reserved: c_ulong = 0, 790 + size: c_ulong, 791 + }; 792 + 793 + /// 1 << 28 — a global block is never copied. 794 + const BLOCK_IS_GLOBAL: c_int = 1 << 28; 795 + 796 + extern var _NSConcreteGlobalBlock: anyopaque; 797 + 798 + /// `void (^)(SFSpeechRecognizerAuthorizationStatus status)`. 799 + const AuthBlock = extern struct { 800 + isa: ?*anyopaque, 801 + flags: c_int, 802 + reserved: c_int = 0, 803 + invoke: *const anyopaque, 804 + descriptor: *const BlockDescriptor, 805 + }; 806 + 807 + const auth_block_descriptor = BlockDescriptor{ .size = @sizeOf(AuthBlock) }; 808 + 809 + fn authInvoke(_: *const AuthBlock, status: isize) callconv(.c) void { 810 + // An arbitrary queue. Record and hop; touch nothing else. 811 + auth_status.store(status, .seq_cst); 812 + dispatch_async_f(&_dispatch_main_q, null, authorizedOnMain); 813 + } 814 + 815 + var auth_block: AuthBlock = if (is_darwin) .{ 816 + .isa = &_NSConcreteGlobalBlock, 817 + .flags = BLOCK_IS_GLOBAL, 818 + .invoke = @ptrCast(&authInvoke), 819 + .descriptor = &auth_block_descriptor, 820 + } else undefined; 821 + 822 + /// `void (^)(SFSpeechRecognitionResult *result, NSError *error)`. 823 + const ResultBlock = extern struct { 824 + isa: ?*anyopaque, 825 + flags: c_int, 826 + reserved: c_int = 0, 827 + invoke: *const anyopaque, 828 + descriptor: *const BlockDescriptor, 829 + }; 830 + 831 + const result_block_descriptor = BlockDescriptor{ .size = @sizeOf(ResultBlock) }; 832 + 833 + /// Swift's result handler (`:2522-2533`), including its discarded error. 834 + fn resultInvoke(_: *const ResultBlock, result: Id, err: Id) callconv(.c) void { 835 + var is_final = false; 836 + 837 + if (result) |r| { 838 + is_final = resultIsFinal(r); 839 + emitTranscript(r, is_final); 840 + } 841 + 842 + if (err != null or is_final) { 843 + // Precondition 5: this is an internal Speech queue, and the teardown 844 + // touches AVAudioEngine. 845 + dispatch_async_f(&_dispatch_main_q, null, stopOnMain); 539 846 } 540 - // Non-vacuity: the loop above proves nothing over an empty enum. 541 - try testing.expect(std.enums.values(capabilities.Channel).len >= 40); 542 - } 543 - 544 - test "craftSpeechStart is still unspellable, which is blocker one" { 545 - // A tripwire, not a preference. This module's first blocker is that 546 - // `ios_events.Event` has no `.speech_start`, and the moment somebody adds 547 - // it precondition 1 is met — which should be noticed here, in the file 548 - // that documents it, rather than in a later round rediscovering the 549 - // research. Blockers 2-4 still stand on their own, so this trip is a 550 - // prompt to re-read them, never a clearance to serve. 551 - if (@hasField(ios_events.Event, "speech_start")) { 552 - std.debug.print( 553 - "ios_events.Event now has .speech_start, so blocker 1 in " ++ 554 - "bridge_mobile_speech.zig is lifted.\n" ++ 555 - " Emit it through the enum, delete event_start_name, and work " ++ 556 - "through preconditions 2-5 before moving either action into `A`.\n", 847 + } 848 + 849 + var result_block: ResultBlock = if (is_darwin) .{ 850 + .isa = &_NSConcreteGlobalBlock, 851 + .flags = BLOCK_IS_GLOBAL, 852 + .invoke = @ptrCast(&resultInvoke), 853 + .descriptor = &result_block_descriptor, 854 + } else undefined; 855 + 856 + /// `AVAudioNodeTapBlock` — `void (^)(AVAudioPCMBuffer *buffer, AVAudioTime 857 + /// *when)` (`AVAudioNode.h:32`). 858 + const TapBlock = extern struct { 859 + isa: ?*anyopaque, 860 + flags: c_int, 861 + reserved: c_int = 0, 862 + invoke: *const anyopaque, 863 + descriptor: *const BlockDescriptor, 864 + }; 865 + 866 + const tap_block_descriptor = BlockDescriptor{ .size = @sizeOf(TapBlock) }; 867 + 868 + /// The audio I/O thread. Two atomics, one `objc_msgSend`, nothing else. 869 + /// 870 + /// No allocation, no `sel_registerName`, no logging, no lock: each of those 871 + /// takes a lock somewhere, and taking a lock here inverts priority against 872 + /// whatever holds it. `when` is unread, as it is in Swift. 873 + fn tapInvoke(_: *const TapBlock, buffer: Id, when: Id) callconv(.c) void { 874 + _ = when; 875 + 876 + _ = tap_in_flight.fetchAdd(1, .seq_cst); 877 + defer _ = tap_in_flight.fetchSub(1, .seq_cst); 878 + 879 + const held = active_request.load(.seq_cst); 880 + if (held == 0) return; 881 + 882 + const request: Id = @ptrFromInt(held); 883 + const AppendFn = *const fn (Id, objc.SEL, Id) callconv(.c) void; 884 + const append: AppendFn = @ptrCast(&objc.objc_msgSend); 885 + append(request, sel_append, buffer); 886 + } 887 + 888 + var tap_block: TapBlock = if (is_darwin) .{ 889 + .isa = &_NSConcreteGlobalBlock, 890 + .flags = BLOCK_IS_GLOBAL, 891 + .invoke = @ptrCast(&tapInvoke), 892 + .descriptor = &tap_block_descriptor, 893 + } else undefined; 894 + 895 + // ============================================================================= 896 + // The Objective-C calls, one thin wrapper each. 897 + // 898 + // Wrapped rather than inlined so `beginRecording` reads as the sequence Swift 899 + // reads as, and so every signature is written exactly once — the place this 900 + // migration has repeatedly found its mistakes. 901 + // ============================================================================= 902 + 903 + fn sharedAudioSession() Id { 904 + const AVAudioSession = objc.objc_getClass("AVAudioSession") orelse { 905 + std.log.warn( 906 + "startListening: AVAudioSession is not in this process; " ++ 907 + "the app does not link AVFoundation", 557 908 .{}, 558 909 ); 559 - return error.SpeechStartBlockerLifted ; 910 + return null ; 911 + }; 912 + const sel = objc.sel_registerName("sharedInstance") orelse return null; 913 + return objc.msgSendId(AVAudioSession, sel); 914 + } 915 + 916 + /// `setCategory(.record, mode: .measurement, options: .duckOthers)` then 917 + /// `setActive(true, options: .notifyOthersOnDeactivation)` — Swift `:2502-2505`. 918 + fn configureSession(session: Id) !void { 919 + const category = audioSessionConstant("AVAudioSessionCategoryRecord") orelse 920 + return error.NotFound; 921 + const mode = audioSessionConstant("AVAudioSessionModeMeasurement") orelse 922 + return error.NotFound; 923 + 924 + const sel_category = objc.sel_registerName("setCategory:mode:options:error:") orelse 925 + return error.SelectorNotFound; 926 + const CategoryFn = *const fn (Id, objc.SEL, Id, Id, c_ulong, ?*Id) callconv(.c) bool; 927 + const setCategory: CategoryFn = @ptrCast(&objc.objc_msgSend); 928 + var category_error: Id = null; 929 + if (!setCategory( 930 + session, 931 + sel_category, 932 + category, 933 + mode, 934 + category_option_duck_others, 935 + &category_error, 936 + )) { 937 + logNSError("startListening", category_error); 938 + return error.NativeCallFailed; 560 939 } 561 940 562 - // Until then the name exists only as a string, and it is the right one. 563 - try testing.expectEqualStrings("craftSpeechStart", event_start_name); 941 + const sel_active = objc.sel_registerName("setActive:withOptions:error:") orelse 942 + return error.SelectorNotFound; 943 + const ActiveFn = *const fn (Id, objc.SEL, bool, c_ulong, ?*Id) callconv(.c) bool; 944 + const setActive: ActiveFn = @ptrCast(&objc.objc_msgSend); 945 + var active_error: Id = null; 946 + if (!setActive(session, sel_active, true, set_active_notify_others, &active_error)) { 947 + logNSError("startListening", active_error); 948 + return error.NativeCallFailed; 949 + } 564 950 } 565 951 566 - test "start and end carry an empty object, not an empty string" { 567 - // `data: [:]` through `JSONSerialization` is `{}`. `ios_events.formatEvent` 568 - // inlines the detail as a literal, so `""` would produce 569 - // `{detail:}` — a syntax error that takes the whole event with it — and 570 - // `"null"` would make `e.detail` null where the page expects an object. 571 - try testing.expectEqualStrings("{}", empty_detail); 952 + /// `SFSpeechRecognizer(locale: Locale(identifier: "en-US"))`, built once. 953 + fn ensureRecognizer() Id { 954 + if (recognizer) |existing| return existing; 955 + 956 + const NSLocale = objc.objc_getClass("NSLocale") orelse return null; 957 + const sel_locale = objc.sel_registerName("localeWithLocaleIdentifier:") orelse return null; 958 + const NSString = objc.objc_getClass("NSString") orelse return null; 959 + const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse return null; 960 + const identifier = objc.msgSendId1( 961 + NSString, 962 + sel_string, 963 + @as([*:0]const u8, recognizer_locale), 964 + ) orelse return null; 965 + const locale = objc.msgSendId1(NSLocale, sel_locale, identifier) orelse return null; 966 + 967 + const SFSpeechRecognizer = objc.objc_getClass("SFSpeechRecognizer") orelse return null; 968 + const sel_alloc = objc.sel_registerName("alloc") orelse return null; 969 + const sel_init = objc.sel_registerName("initWithLocale:") orelse return null; 970 + const allocated = objc.msgSendId(SFSpeechRecognizer, sel_alloc) orelse return null; 971 + 972 + // `-initWithLocale:` is nullable: an unsupported locale answers nil, and 973 + // then so does this, and the caller emits "Speech recognizer unavailable" 974 + // exactly as Swift's `guard let` does. 975 + recognizer = objc.msgSendId1(allocated, sel_init, locale); 976 + return recognizer; 977 + } 572 978 573 - var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, empty_detail, .{}); 574 - defer parsed.deinit(); 575 - try testing.expectEqual(@as(usize, 0), parsed.value.object.count()); 979 + fn recognizerIsAvailable(rec: Id) bool { 980 + const sel = objc.sel_registerName("isAvailable") orelse return false; 981 + return objc.msgSendBool(rec, sel); 576 982 } 577 983 578 - // ----------------------------------------------------------------------------- 579 - // craftSpeechError 580 - // ----------------------------------------------------------------------------- 984 + fn ensureEngine() Id { 985 + if (engine) |existing| return existing; 986 + const AVAudioEngine = objc.objc_getClass("AVAudioEngine") orelse return null; 987 + engine = objc.allocInit(AVAudioEngine) catch null; 988 + return engine; 989 + } 581 990 582 - test "the four error messages are Swift's, character for character" { 583 - // These strings are shown to a human (`test-bridges.html:392-393`) and 584 - // documented in README.md. A paraphrase changes what a user reads without 585 - // changing anything else a test can see. 586 - try testing.expectEqualStrings("Not authorized", err_not_authorized); 587 - try testing.expectEqualStrings("Audio session failed", err_audio_session); 588 - try testing.expectEqualStrings("Speech recognizer unavailable", err_recognizer_unavailable); 589 - try testing.expectEqualStrings("Audio engine failed", err_audio_engine); 590 - try testing.expectEqual(@as(usize, 4), error_messages.len); 991 + fn inputNode(eng: Id) Id { 992 + const sel = objc.sel_registerName("inputNode") orelse return null; 993 + return objc.msgSendId(eng, sel); 591 994 } 592 995 593 - test "an error detail is the single key the page reads" { 594 - // `e.detail.error` (`test-bridges.html:392`) and nothing else. A `code` or 595 - // `message` key alongside it would be a surface no consumer knows about. 596 - const json = try shapeErrorDetail(testing.allocator, err_not_authorized); 597 - defer testing.allocator.free(json); 996 + fn outputFormat(node: Id) Id { 997 + const sel = objc.sel_registerName("outputFormatForBus:") orelse return null; 998 + const Fn = *const fn (Id, objc.SEL, c_ulong) callconv(.c) Id; 999 + const call: Fn = @ptrCast(&objc.objc_msgSend); 1000 + return call(node, sel, bus_zero); 1001 + } 598 1002 599 - try testing.expectEqualStrings("{\"error\":\"Not authorized\"}", json); 1003 + /// Whether this format describes a real input route. 1004 + /// 1005 + /// The header says it in as many words: "Check for the input node's input 1006 + /// format for non-zero sample rate and channel count to see if input is 1007 + /// enabled" (`AVAudioEngine.h:459-461`). Both must be non-zero — a format with 1008 + /// a rate and no channels is as unusable as one with neither. 1009 + fn formatCarriesInput(format: Id) bool { 1010 + const sel_rate = objc.sel_registerName("sampleRate") orelse return false; 1011 + const RateFn = *const fn (Id, objc.SEL) callconv(.c) f64; 1012 + const sampleRate: RateFn = @ptrCast(&objc.objc_msgSend); 1013 + 1014 + const sel_channels = objc.sel_registerName("channelCount") orelse return false; 1015 + const ChannelFn = *const fn (Id, objc.SEL) callconv(.c) u32; 1016 + const channelCount: ChannelFn = @ptrCast(&objc.objc_msgSend); 1017 + 1018 + return sampleRate(format, sel_rate) > 0 and channelCount(format, sel_channels) > 0; 1019 + } 600 1020 601 - var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); 602 - defer parsed.deinit(); 603 - try testing.expectEqual(@as(usize, 1), parsed.value.object.count()); 604 - try testing.expectEqualStrings("Not authorized", parsed.value.object.get("error").?.string); 1021 + fn newRecognitionRequest() Id { 1022 + const class = objc.objc_getClass("SFSpeechAudioBufferRecognitionRequest") orelse return null; 1023 + return objc.allocInit(class) catch null; 605 1024 } 606 1025 607 - test "every one of the four messages shapes and parses back" { 608 - for (error_messages) |message| { 609 - const json = try shapeErrorDetail(testing.allocator, message); 610 - defer testing.allocator.free(json); 1026 + fn setShouldReportPartialResults(request: Id, value: bool) void { 1027 + const sel = objc.sel_registerName("setShouldReportPartialResults:") orelse return; 1028 + const Fn = *const fn (Id, objc.SEL, bool) callconv(.c) void; 1029 + const call: Fn = @ptrCast(&objc.objc_msgSend); 1030 + call(request, sel, value); 1031 + } 611 1032 612 - var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); 613 - defer parsed.deinit(); 614 - try testing.expectEqual(@as(usize, 1), parsed.value.object.count()); 615 - try testing.expectEqualStrings(message, parsed.value.object.get("error").?.string); 616 - } 1033 + fn startRecognitionTask(rec: Id, request: Id) Id { 1034 + const sel = objc.sel_registerName("recognitionTaskWithRequest:resultHandler:") orelse 1035 + return null; 1036 + const Fn = *const fn (Id, objc.SEL, Id, *const ResultBlock) callconv(.c) Id; 1037 + const call: Fn = @ptrCast(&objc.objc_msgSend); 1038 + return call(rec, sel, request, &result_block); 617 1039 } 618 1040 619 - test "an error message carrying a quote does not end the object early" { 620 - // No current message does. The escaping is a property of the position, not 621 - // of today's inputs, and a future round quoting a framework error is 622 - // exactly how the unescaped case arrives. 623 - const json = try shapeErrorDetail(testing.allocator, "recognizer said \"no\"\\ever"); 624 - defer testing.allocator.free(json); 1041 + fn installTap(node: Id, format: Id) !void { 1042 + const sel = objc.sel_registerName("installTapOnBus:bufferSize:format:block:") orelse 1043 + return error.SelectorNotFound; 1044 + const Fn = *const fn (Id, objc.SEL, c_ulong, u32, Id, *const TapBlock) callconv(.c) void; 1045 + const call: Fn = @ptrCast(&objc.objc_msgSend); 1046 + call(node, sel, bus_zero, tap_buffer_frames, format, &tap_block); 1047 + } 625 1048 626 - try testing.expectEqualStrings( 627 - "{\"error\":\"recognizer said \\\"no\\\"\\\\ever\"}", 628 - json, 629 - ); 1049 + fn removeTap(node: Id) void { 1050 + const sel = objc.sel_registerName("removeTapOnBus:") orelse return; 1051 + const Fn = *const fn (Id, objc.SEL, c_ulong) callconv(.c) void; 1052 + const call: Fn = @ptrCast(&objc.objc_msgSend); 1053 + call(node, sel, bus_zero); 1054 + } 630 1055 631 - var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); 632 - defer parsed.deinit(); 633 - try testing.expectEqualStrings( 634 - "recognizer said \"no\"\\ever", 635 - parsed.value.object.get("error").?.string, 636 - ); 1056 + fn prepareEngine(eng: Id) void { 1057 + const sel = objc.sel_registerName("prepare") orelse return; 1058 + objc.msgSend(eng, sel); 637 1059 } 638 1060 639 - // ----------------------------------------------------------------------------- 640 - // craftSpeechResult 641 - // ----------------------------------------------------------------------------- 1061 + fn startEngine(eng: Id) bool { 1062 + const sel = objc.sel_registerName("startAndReturnError:") orelse return false; 1063 + const Fn = *const fn (Id, objc.SEL, ?*Id) callconv(.c) bool; 1064 + const call: Fn = @ptrCast(&objc.objc_msgSend); 1065 + var start_error: Id = null; 1066 + if (call(eng, sel, &start_error)) return true; 1067 + logNSError("startListening", start_error); 1068 + return false; 1069 + } 642 1070 643 - test "a result detail is exactly transcript and isFinal" { 644 - // `CraftApp.swift:2525-2528`, and the two keys `test-bridges.html:386` 645 - // reads. These are the bytes `e.detail` receives either way, because 646 - // `sendToWeb` and `ios_events.formatEvent` inline the detail identically. 647 - const partial = try shapeResultDetail(testing.allocator, "hello world", false); 648 - defer testing.allocator.free(partial); 649 - try testing.expectEqualStrings( 650 - "{\"transcript\":\"hello world\",\"isFinal\":false}", 651 - partial, 652 - ); 1071 + fn stopEngine(eng: Id) void { 1072 + const sel = objc.sel_registerName("stop") orelse return; 1073 + objc.msgSend(eng, sel); 1074 + } 653 1075 654 - const final = try shapeResultDetail(testing.allocator, "hello world", true); 655 - defer testing.allocator.free(final); 656 - try testing.expectEqualStrings( 657 - "{\"transcript\":\"hello world\",\"isFinal\":true}", 658 - final, 1076 + fn endAudio(request: Id) void { 1077 + const sel = objc.sel_registerName("endAudio") orelse return; 1078 + objc.msgSend(request, sel); 1079 + } 1080 + 1081 + fn cancelTask(t: Id) void { 1082 + const sel = objc.sel_registerName("cancel") orelse return; 1083 + objc.msgSend(t, sel); 1084 + } 1085 + 1086 + fn resultIsFinal(result: Id) bool { 1087 + const sel = objc.sel_registerName("isFinal") orelse return false; 1088 + return objc.msgSendBool(result, sel); 1089 + } 1090 + 1091 + /// `result.bestTranscription.formattedString` -> one `craftSpeechResult`. 1092 + /// 1093 + /// Runs on Speech's internal queue. `ios_events.emit` hops to the main queue 1094 + /// itself, so nothing here needs to. 1095 + fn emitTranscript(result: Id, is_final: bool) void { 1096 + const sel_best = objc.sel_registerName("bestTranscription") orelse return; 1097 + const transcription = objc.msgSendId(result, sel_best) orelse return; 1098 + 1099 + const sel_formatted = objc.sel_registerName("formattedString") orelse return; 1100 + const string = objc.msgSendId(transcription, sel_formatted) orelse return; 1101 + 1102 + const utf8 = objc.getNSStringUTF8(string) orelse return; 1103 + const text = std.mem.span(utf8); 1104 + 1105 + const allocator = std.heap.c_allocator; 1106 + const detail = shapeResultDetail(allocator, text, is_final) catch { 1107 + std.log.warn("speech: could not shape a transcript for craftSpeechResult", .{}); 1108 + return; 1109 + }; 1110 + defer allocator.free(detail); 1111 + 1112 + ios_events.emit(event_result, detail); 1113 + } 1114 + 1115 + /// Read an `AVAudioSession` string constant out of the process. 1116 + /// 1117 + /// `dlsym` rather than an `extern` declaration, for the reason 1118 + /// `bridge_mobile_imagepicker.zig` documents: a host test binary links Cocoa 1119 + /// and not AVFoundation, and an `extern` would make the whole test executable 1120 + /// fail to link rather than this one call fail to find its symbol. 1121 + fn audioSessionConstant(comptime symbol: [*:0]const u8) Id { 1122 + const found = dlsym(RTLD_DEFAULT, symbol) orelse { 1123 + std.log.warn("startListening: {s} is not in this process", .{symbol}); 1124 + return null; 1125 + }; 1126 + const cell: *const Id = @ptrCast(@alignCast(found)); 1127 + return cell.*; 1128 + } 1129 + 1130 + fn logNSError(context: []const u8, err: Id) void { 1131 + const object = err orelse { 1132 + std.log.warn("{s}: the native call failed and reported no error", .{context}); 1133 + return; 1134 + }; 1135 + const sel = objc.sel_registerName("localizedDescription") orelse return; 1136 + const description = objc.msgSendId(object, sel) orelse return; 1137 + const utf8 = objc.getNSStringUTF8(description) orelse return; 1138 + std.log.warn("{s}: {s}", .{ context, std.mem.span(utf8) }); 1139 + } 1140 + 1141 + const dispatch_function_t = *const fn (?*anyopaque) callconv(.c) void; 1142 + extern "c" fn dispatch_async_f(queue: *anyopaque, context: ?*anyopaque, work: dispatch_function_t) void; 1143 + extern var _dispatch_main_q: anyopaque; 1144 + 1145 + extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque; 1146 + 1147 + extern "c" fn usleep(microseconds: c_uint) c_int; 1148 + 1149 + /// `RTLD_DEFAULT` — search every image already loaded into the process. 1150 + const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2)))); 1151 + 1152 + // ============================================================================= 1153 + // Tests. 1154 + // 1155 + // Three groups. The pure shapers and the vocabulary, whose bytes are what a 1156 + // page reads. The routing, which is what makes `ios_dispatch` reach this file 1157 + // at all. And the state machine — the parts of teardown and the tap handoff 1158 + // that can be exercised without an audio device, which is more of them than it 1159 + // looks, because the handoff is deliberately made of atomics rather than of 1160 + // framework state. 1161 + // 1162 + // What no test here can reach: the recognizer, the session, the engine, the 1163 + // tap itself. That risk surface belongs to `packages/ios/fixtures/zig-slice`, 1164 + // which drives both actions on a simulator and asserts the events. 1165 + // ============================================================================= 1166 + 1167 + const testing = std.testing; 1168 + 1169 + test "the action names match the Swift case labels exactly" { 1170 + // A typo here routes nowhere and looks exactly like the shim still serving 1171 + // both actions, which is the state this module just left. 1172 + try testing.expectEqualStrings("startListening", A.start_listening); 1173 + try testing.expectEqualStrings("stopListening", A.stop_listening); 1174 + } 1175 + 1176 + test "both actions are declared, and neither promises a reply" { 1177 + try testing.expectEqual(@as(usize, 2), capability_actions.len); 1178 + for (capability_actions) |decl| { 1179 + // `.result` would tell an app to await something no path here sends. 1180 + try testing.expectEqual(capabilities.Reply.none, decl.reply); 1181 + try testing.expectEqual(capabilities.ActionStatus.live, decl.status); 1182 + } 1183 + } 1184 + 1185 + test "an unrelated action falls through rather than being refused" { 1186 + // `ios_dispatch.route` treats any error other than UnknownAction as final, 1187 + // so a module that answered something else here would stop the chain and 1188 + // strand an action another module serves. 1189 + var bridge = SpeechBridge.init(testing.allocator); 1190 + defer bridge.deinit(); 1191 + try testing.expectError( 1192 + BridgeError.UnknownAction, 1193 + bridge.handleMessage("getDeviceInfo", "{}"), 659 1194 ); 1195 + } 660 1196 661 - var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, final, .{}); 662 - defer parsed.deinit(); 663 - try testing.expectEqual(@as(usize, 2), parsed.value.object.count()); 664 - try testing.expectEqualStrings("hello world", parsed.value.object.get("transcript").?.string); 665 - try testing.expectEqual(true, parsed.value.object.get("isFinal").?.bool); 1197 + test "a malformed payload is not a reason to refuse either action" { 1198 + // Neither action reads a field, so neither parses one. A parser here would 1199 + // invent a failure the spec has not got — and it would surface as a 1200 + // dispatcher error under a request id no page holds. 1201 + var bridge = SpeechBridge.init(testing.allocator); 1202 + defer bridge.deinit(); 1203 + 1204 + for ([_][]const u8{ "", "{", "not json at all", "[1,2,3]" }) |payload| { 1205 + try bridge.handleMessage(A.stop_listening, payload); 1206 + } 1207 + 1208 + // And the stop left no state behind, which is what makes it idempotent. 1209 + try testing.expect(!listening); 1210 + try testing.expect(!tap_installed); 1211 + try testing.expectEqual(@as(usize, 0), active_request.load(.seq_cst)); 666 1212 } 667 1213 668 - test "the detail does not invent keys the recognizer could have supplied" { 669 - // `SFSpeechRecognitionResult` also carries `transcriptions`, 670 - // `bestTranscription.segments` and per-segment `confidence`, and Swift 671 - // reads none of them. Adding one would be a surface no consumer reads and 672 - // a divergence from the emitter contract this migration preserves. 673 - const json = try shapeResultDetail( testing. allocator, " x ", true ); 674 - defer testing.allocator.free(json); 1214 + test "the four error messages are Swift's, in Swift's order" { 1215 + try testing.expectEqual(@as(usize, 4), error_messages.len); 1216 + try testing.expectEqualStrings("Not authorized", error_messages[0]); 1217 + try testing.expectEqualStrings("Audio session failed", error_messages[1]); 1218 + try testing.expectEqualStrings("Speech recognizer unavailable", error_messages[2]); 1219 + try testing. expectEqualStrings( " Audio engine failed ", error_messages[3] ); 1220 + 1221 + // And every StartFailure maps into that set, so no path can emit a fifth 1222 + // string by accident. 1223 + inline for (@typeInfo(StartFailure).error_set.error_names.?) |name| { 1224 + const message = messageFor(@field(StartFailure, name)); 1225 + var found = false; 1226 + for (error_messages) |known| { 1227 + if (std.mem.eql(u8, known, message)) found = true; 1228 + } 1229 + try testing.expect(found); 1230 + } 1231 + } 675 1232 676 - try testing.expect(std.mem.indexOf(u8, json, "confidence") == null); 677 - try testing.expect(std.mem.indexOf(u8, json, "alternatives") == null); 678 - try testing.expect(std.mem.indexOf(u8, json, "segments") == null); 679 - try testing.expect(std.mem.indexOf(u8, json, "\"transcript\":") != null); 680 - try testing.expect(std.mem.indexOf(u8, json, "\"isFinal\":") != null); 1233 + test "a zero-format refusal reports the engine, not the session" { 1234 + // The one message this module emits that Swift never reaches. Picking 1235 + // `err_audio_session` would blame a session that succeeded; picking a 1236 + // fifth string would put bytes on a page no other arm can produce. 1237 + try testing.expectEqualStrings(err_audio_engine, messageFor(error.AudioEngine)); 1238 + try testing.expectEqualStrings(err_audio_session, messageFor(error.AudioSession)); 681 1239 } 682 1240 683 - test "isFinal is a JSON boolean, never a quoted string" { 684 - // `${e.detail.isFinal}` renders `"false"` and `false` identically, so a 685 - // quoted value would read as correct on the test page while being truthy 686 - // to any code that branches on it. 687 - const json = try shapeResultDetail( testing. allocator, "", false ); 688 - defer testing.allocator.free(json); 1241 + test "the four event names are the ones Swift's sendToWeb spells" { 1242 + try testing.expectEqualStrings("craftSpeechStart", event_start.eventName()); 1243 + try testing.expectEqualStrings("craftSpeechResult", event_result.eventName()); 1244 + try testing.expectEqualStrings("craftSpeechError", event_error.eventName()); 1245 + try testing. expectEqualStrings( " craftSpeechEnd ", event_end.eventName() ); 1246 + } 689 1247 690 - try testing.expect(std.mem.indexOf(u8, json, "\"isFinal\":false") != null); 691 - try testing.expect(std.mem.indexOf(u8, json, "\"false\"") == null); 1248 + test "the empty detail is an object a page can index into" { 1249 + // `ios_events.formatEvent` inlines this as a literal. An empty string 1250 + // would make `e.detail.anything` a TypeError rather than undefined. 1251 + try testing.expectEqualStrings("{}", empty_detail); 1252 + } 692 1253 693 - var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); 694 - defer parsed.deinit(); 695 - try testing.expectEqual(false, parsed.value.object.get("isFinal").?.bool); 1254 + test "an error detail carries the message under the key Swift uses" { 1255 + const detail = try shapeErrorDetail(testing.allocator, err_not_authorized); 1256 + defer testing.allocator.free(detail); 1257 + try testing.expectEqualStrings("{\"error\":\"Not authorized\"}", detail); 696 1258 } 697 1259 698 - test "an empty transcript still emits the key" { 699 - // A first partial result can legitimately be empty. Omitting the key would 700 - // leave `e.detail.transcript` undefined, which a page cannot tell from a 701 - // bridge that stopped working . 702 - const json = try shapeResultDetail (testing.allocator, "" , false ); 703 - defer testing.allocator.free( json ); 1260 + test "an error message with JavaScript metacharacters survives escaping" { 1261 + // No current message needs this. The escaping is a property of the 1262 + // position — a JSON string inlined into JavaScript source — so it is 1263 + // pinned against the message that has not been written yet . 1264 + const detail = try shapeErrorDetail (testing.allocator, " quote \ " and \\ and\nnewline" ); 1265 + defer testing.allocator.free( detail ); 1266 + try testing.expectEqualStrings( 1267 + "{\"error\":\"quote \\\" and \\\\ and\\nnewline\"}", 1268 + detail, 1269 + ); 1270 + } 704 1271 705 - try testing.expectEqualStrings("{\"transcript\":\"\",\"isFinal\":false}", json); 1272 + test "a transcript detail carries both keys, with isFinal as a real boolean" { 1273 + const partial = try shapeResultDetail(testing.allocator, "hello", false); 1274 + defer testing.allocator.free(partial); 1275 + try testing.expectEqualStrings( 1276 + "{\"transcript\":\"hello\",\"isFinal\":false}", 1277 + partial, 1278 + ); 706 1279 707 - var parsed = try std.json.parseFromSlice ( std.json.Value, testing.allocator, json , .{} ); 708 - defer parsed . deinit (); 709 - try testing. expectEqual(@as(usize, 2), parsed.value.object.count ( )); 710 - try testing.expectEqualStrings("", parsed.value.object.get("transcript").?.string); 1280 + const final = try shapeResultDetail (testing.allocator, "hello there" , true ); 1281 + defer testing . allocator.free ( final ); 1282 + try testing. expectEqualStrings ( 1283 + "{\"transcript\":\"hello there\",\"isFinal\":true}", 1284 + final, 1285 + ); 711 1286 } 712 1287 713 - test " a transcript with a quote does not truncate the event " { 714 - // The failure this shaper exists to prevent, and the one 715 - // `ios_events.zig` records against `bridge_iap.zig:362`: the detail is 716 - // inlined into JavaScript source, so an unescaped `"` does not corrupt one 717 - // field, it ends the object early and makes the whole `dispatchEvent` call 718 - // a syntax error. The page sees the stream stop, not a bad transcript. 719 - // 720 - // Dictation really does produce these: it transcribes spoken quotation 721 - // marks, and `formattedString` is not sanitised. 722 - const json = try shapeResultDetail(testing.allocator, "she said \"hello\"", true); 723 - defer testing.allocator.free(json); 1288 + test " an empty transcript still emits the key " { 1289 + // A first partial result can legitimately be empty, and an omitted key 1290 + // reads to a page exactly like a broken bridge. 1291 + const detail = try shapeResultDetail(testing.allocator, "", false); 1292 + defer testing.allocator.free(detail); 1293 + try testing.expectEqualStrings("{\"transcript\":\"\",\"isFinal\":false}", detail); 1294 + } 724 1295 1296 + test "a spoken quote cannot end the object early" { 1297 + // Arbitrary speech lands in a JavaScript source position. An unescaped 1298 + // quote would not corrupt one field: it would make the whole dispatchEvent 1299 + // call a syntax error, and the page would see the stream stop with no 1300 + // event to say why. 1301 + const detail = try shapeResultDetail(testing.allocator, "say \"stop\" now", true); 1302 + defer testing.allocator.free(detail); 725 1303 try testing.expectEqualStrings( 726 - "{\"transcript\":\" she said \\\" hello \\\"\",\"isFinal\":true}", 727 - json , 1304 + "{\"transcript\":\" say \\\" stop \\\" now \",\"isFinal\":true}", 1305 + detail , 728 1306 ); 729 1307 730 - var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); 1308 + // And the result is parseable, which is the property the escaping exists 1309 + // for rather than the exact bytes. 1310 + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, detail, .{}); 731 1311 defer parsed.deinit(); 732 1312 try testing.expectEqualStrings( 733 - " she said \" hello \"", 1313 + " say \" stop \" now ", 734 1314 parsed.value.object.get("transcript").?.string, 735 1315 ); 1316 + try testing.expectEqual(true, parsed.value.object.get("isFinal").?.bool); 736 1317 } 737 1318 738 - test "backslashes, newlines and control bytes survive as escapes" { 739 - // A literal newline inside a JavaScript string literal is a syntax error, 740 - // and a lone backslash would escape whatever followed it. Both reach the 741 - // page as a dead event rather than as bad text. 742 - const json = try shapeResultDetail(testing.allocator, "a\\b\nc\td\r\x01", false); 743 - defer testing.allocator.free(json); 1319 + test "the tap reads nothing while the request is unpublished" { 1320 + // The handoff, exercised without an audio device. Zero means "nobody may 1321 + // append", and it is the state both before a start and after a stop — 1322 + // which is what makes a tap call that outlives its session harmless 1323 + // rather than a use-after-free. 1324 + const saved = active_request.swap(0, .seq_cst); 1325 + defer active_request.store(saved, .seq_cst); 744 1326 745 - try testing. expectEqualStrings ( 746 - "{\"transcript\":\"a\\\\b\\nc\\td\\r\\u0001\",\"isFinal\":false}", 747 - json, 748 - ); 1327 + try testing. expectEqual ( @as(usize, 0), active_request.load(.seq_cst)); 749 1328 750 - // No raw control byte survived into the bytes that become JS source . 751 - for (json) |c| try testing.expect( c > = 0x20 ); 1329 + // The tap's own guard, spelled the way the tap spells it . 1330 + try testing.expect( active_request.load(.seq_cst) = = 0 ); 1331 + } 752 1332 753 - var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); 754 - defer parsed.deinit(); 755 - try testing.expectEqualStrings("a\\b\nc\td\r\x01", parsed.value.object.get("transcript").?.string); 1333 + test "the drain reports success only when the audio thread has left" { 1334 + // `drainTap` is what stands between a stop and a use-after-free, so its 1335 + // two answers are pinned rather than assumed. The busy case deliberately 1336 + // runs the full bounded wait: 20ms is the cost of proving it gives up 1337 + // instead of spinning forever. 1338 + tap_in_flight.store(0, .seq_cst); 1339 + try testing.expect(drainTap()); 1340 + 1341 + tap_in_flight.store(1, .seq_cst); 1342 + try testing.expect(!drainTap()); 1343 + 1344 + tap_in_flight.store(0, .seq_cst); 756 1345 } 757 1346 758 - test "an apostrophe passes through unescaped, and must" { 759 - // The opposite error, and an easy one to make while fixing the quote case. 760 - // `formatEvent` single-quotes the event *name* only; the detail is an 761 - // object literal, where `'` is an ordinary character inside a 762 - // double-quoted JSON string. Escaping it would put a literal backslash 763 - // into the transcript the page displays — and "don't" and "it's" are the 764 - // most common words dictation produces. 765 - const json = try shapeResultDetail(testing.allocator, "don't stop", true); 766 - defer testing.allocator.free(json); 1347 + test "the native constants are the SDK's, not remembered" { 1348 + // Every one of these was read out of a header rather than recalled, after 1349 + // a HealthKit option guessed at 1 << 2 turned out to be 1 << 4 and took 1350 + // the process down with an unrecoverable exception inside the framework. 1351 + // 1352 + // SFSpeechRecognizer.h:25-41 implicit 0..3, Authorized last 1353 + // AVAudioSessionTypes.h:466 DuckOthers = 0x2 1354 + // AVAudioSessionTypes.h:660 NotifyOthersOnDeactivation = 1 1355 + // AVAudioNode.h:110 bufferSize 1024 in the header's own example 1356 + try testing.expectEqual(@as(isize, 3), status_authorized); 1357 + try testing.expectEqual(@as(c_ulong, 2), category_option_duck_others); 1358 + try testing.expectEqual(@as(c_ulong, 1), set_active_notify_others); 1359 + try testing.expectEqual(@as(u32, 1024), tap_buffer_frames); 1360 + try testing.expectEqual(@as(c_ulong, 0), bus_zero); 1361 + 1362 + // DuckOthers is 0x2 and MixWithOthers is 0x1; picking the neighbour would 1363 + // silently stop ducking rather than fail. 1364 + try testing.expect(category_option_duck_others != 1); 1365 + } 767 1366 768 - try testing.expectEqualStrings("{\"transcript\":\"don't stop\",\"isFinal\":true}", json); 769 - try testing.expect(std.mem.indexOfScalar(u8, json, '\\') == null); 1367 + test "the plist key is the exact proxy, and the microphone key is not" { 1368 + // `index.ts:183` writes this key from `enableSpeechRecognition` alone. 1369 + // `:184` writes NSMicrophoneUsageDescription from 1370 + // `enableSpeechRecognition || enableAudioRecording`, so it is implied by 1371 + // speech being on but cannot stand in for it — an audio-recording app 1372 + // would carry it with speech off. 1373 + try testing.expectEqualStrings("NSSpeechRecognitionUsageDescription", key_speech_usage); 1374 + } 770 1375 771 - var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); 772 - defer parsed.deinit(); 773 - try testing.expectEqualStrings("don't stop", parsed.value.object.get("transcript").?.string); 1376 + test "a test runner has no speech usage description, so a start refuses" { 1377 + // The shape of an app built with enableSpeechRecognition off, which is 1378 + // what a test binary's bundle looks like. The refusal has to come from the 1379 + // plist guard, before requestAuthorization: is reached — asking without 1380 + // the key terminates the process rather than failing. 1381 + if (!is_darwin) return error.SkipZigTest; 1382 + 1383 + try testing.expect(!hasSpeechUsageDescription()); 1384 + 1385 + var bridge = SpeechBridge.init(testing.allocator); 1386 + defer bridge.deinit(); 1387 + try bridge.handleMessage(A.start_listening, "{}"); 1388 + 1389 + // Nothing was built and no authorization request is outstanding. 1390 + try testing.expect(!starting); 1391 + try testing.expect(!listening); 1392 + try testing.expect(recognizer == null); 1393 + try testing.expect(engine == null); 1394 + try testing.expectEqual(@as(usize, 0), active_request.load(.seq_cst)); 774 1395 } 775 1396 776 - test "multi-byte UTF-8 is carried through unchanged" { 777 - // `formattedString` is whatever locale the recognizer was built with, and 778 - // even `en-US` produces curly apostrophes and em dashes. Escaping per byte 779 - // rather than per code point would split a sequence and hand the page 780 - // invalid JSON. 781 - const spoken = "café — naïve 日本語"; 782 - const json = try shapeResultDetail(testing.allocator, spoken, true); 783 - defer testing.allocator.free(json); 1397 + test "stopping something that never started is safe, and still ends" { 1398 + // Swift's stopSpeechRecognition() has no early return and no guard: a page 1399 + // that stops twice gets two ends, and one that never started still gets 1400 + // one. Bug-compatible on purpose — the event is what a listening indicator 1401 + // binds to, and an end that does not arrive leaves it stuck on. 1402 + var bridge = SpeechBridge.init(testing.allocator); 1403 + defer bridge.deinit(); 1404 + 1405 + try bridge.handleMessage(A.stop_listening, "{}"); 1406 + try bridge.handleMessage(A.stop_listening, "{}"); 784 1407 785 - var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); 786 - defer parsed.deinit(); 787 - try testing. expectEqualStrings ( spoken, parsed.value.object.get("transcript").?.string ); 788 - try testing. expect ( std.unicode . utf8ValidateSlice ( json )); 789 - } 790 - 791 - test "a shaped detail is a complete JSON value, which is what emit requires" { 792 - // `ios_events.emit` documents its argument as a complete JSON value and 793 - // inlines it verbatim. A shaper returning a fragment, or leaving a 794 - // trailing comma, would produce a `dispatchEvent` call the page cannot 795 - // parse — an event that silently never arrives. 796 - const details = [_][]u8{ 797 - try shapeResultDetail(testing.allocator, "one", false), 798 - try shapeResultDetail(testing.allocator, "", true), 799 - try shapeErrorDetail(testing.allocator, err_audio_engine), 800 - }; 801 - defer for (details) |d| testing.allocator.free(d); 802 - 803 - for (details) |detail| { 804 - try testing.expect(std.mem.startsWith(u8, detail, "{")); 805 - try testing.expect(std.mem.endsWith(u8, detail, "}")); 806 - var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, detail, .{}); 807 - defer parsed.deinit(); 808 - try testing.expect(parsed.value == .object); 809 - } 1408 + try testing.expect(!listening); 1409 + try testing.expect(!tap_installed); 1410 + try testing. expect ( task == null ); 1411 + try testing. expectEqual ( @as(usize, 0), active_request . load ( .seq_cst )); 810 1412 }