[fix] a dictation borrows the microphone only if it is still running - #328
Merged
Conversation
`launch()` decided whether to open the microphone with `if capture == nil`. That is a pointer check, not a liveness check. `AudioCapture` can end up torn down while the coordinator still holds it — the rebuild ladder runs out of attempts, or a `.failed` status arrives at a moment nothing is in a position to act on it — and the reference outlives the engine. `capture = nil` only ever ran in `fail()` and `closeMicrophone()`, both of which need a status callback to be reached, so in practice the only thing that cleared it was killing the process. The next session then skipped `start()`, attached the relay to a microphone that emits nothing, and went to `.listening`. The user talked into a dead mic, and could not recover: the borrow happens in the background, and iOS will not let a backgrounded process start recording. The report was that dictation stops working entirely after a stop-and-swipe-back, until Parley is force-quit and reopened. So `AudioCapture` can now be asked. `isCapturing` is true only while the capture is held, the engine is running with a tap installed, and the system is not holding the hardware. It is a flag mirrored under a lock rather than a `queue.sync`, because every caller is on the main actor and the queue is where `setActive(true)` and the rebuilds run — the work most likely to be in flight exactly when someone asks whether this capture is sick. The mirror is kept in step from `didSet` on the three flags it reports on, so a mutation site added later cannot forget it. `launch()` now stops a capture that is not capturing and opens a fresh one. If that start is refused, the session does not sit in `.listening` collecting silence: it fails with the microphone window closed behind it, which is what flips the keyboard's pane back to honestly promising a trip through Parley, where the microphone can be opened from the foreground. The same liveness rule guards the window opened from Settings, which had the identical `capture == nil` test one function away. `endWindow()` guarded on `window.openedAt != nil`, which dropped the call that mattered most: a capture that dies when no window is open reaches it through `handle(capture:)` with nothing for `openedAt` to be, and the early return sent it away with the dead capture still held. The guard now asks whether there is anything left to end at all — the window *or* the microphone it was holding — which keeps "one window ends once" while letting the microphone be torn down on its own. Not verified by a build: this machine has Command Line Tools only, so the app target cannot be compiled and there is no XCTest. Both files parse, and ParleyKit still builds. The new string has no zh-Hant translation yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tring is bilingual Two follow-ups to the liveness fix. `releaseMicrophone` still opened its window on `capture != nil`, which is the same "holding it is not having it" mistake reached from a third side. An open window is a promise to the keyboard that the next tap will be served where the user already is, and the keyboard renders that promise; publishing one over a capture that is not capturing makes it a lie the user only discovers by tapping. The invariant is now uniform: a window is opened, borrowed from, and kept only while the capture behind it is actually running. The new error string shipped `en`-only. Every other entry in the catalog carries both, and a zh-Hant user would have seen English at the one moment they are being told what to do about a broken microphone.
✅ SonarQube Quality Gate passed — pathorsAI_parley0 open issues on this PR. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Dictation stops working entirely, and only force-quitting Parley brings it back.
DictationCoordinator.launch()decided whether to open the microphone withif capture == nil— a pointer check, not a liveness check. The microphonewindow deliberately keeps an
AudioCapturealive between dictations so the nextone can borrow it (iOS refuses to let a backgrounded process start recording,
so borrowing is the only way a keyboard tap avoids a trip through Parley).
But the engine can die behind that object's back — a rebuild that runs out of
attempts, a media-server reset that never recovers — and the only announcement
is a status push, which has paths that reach nobody in a position to act. What
is left is a non-nil capture that will never produce another sample. The next
session skipped
start(), attached the relay to a dead microphone, went to.listening, and heard nothing.capture = nilis only executed infail()and
closeMicrophone(), both of which need that same status push to be reached,so the only thing that cleared it was killing the process.
endWindow()made it worse: it opened withguard window.openedAt != nil, sothe one call that mattered — a capture dying with its window already closed,
arriving via
handle(capture:)— was turned away before it could tear the deadcapture down.
The fix
AudioCapturecan now be asked.isCapturingreportswantsCapture && live && !interrupted, mirrored into a lock-guardedBoolbydidSeton the three flags. Notqueue.sync: every caller is@MainActor, andqueueis wheresetActive(true)and the rebuilds run — precisely the work inflight when a capture is sick, which is precisely when anyone asks.
Three places stop confusing holding with having.
launch()(borrow),beginWindowFromForeground()(open a window), andreleaseMicrophone()(handthe microphone to a window) all now require
isCapturingrather than non-nil.The invariant is uniform: a window is opened, borrowed from, and kept only while
the capture behind it is actually running.
endWindow()guards on "is there anything left to end" — the window or themicrophone it was holding. "One window ends once" survives: the first call closes
both, the second finds neither.
Failure is loud. If the fresh
start()fails — realistically iOS refusing abackgrounded process the microphone, which is exactly where a dead window leaves
us — the session no longer sits in
.listeningcollecting silence.fail()takes the window down with it, so the keyboard's pane goes back to promising a
trip through Parley, where the microphone can be opened from the foreground. The
message keys off
applicationState, because telling a foreground user to "openParley" is nonsense.
Deliberately strict
isCapturingreadsfalsewhile a rebuild is in flight, so a tap landing inthat window tears down a capture that would have recovered and — in the
background — fails with the "open Parley" message instead of borrowing.
That trade is on purpose and it is the conservative direction: the cost of the
strict answer is one visible, actionable error during a rare 250 ms–4 s window;
the cost of a generous one is the bug this PR exists to fix. Given none of this
can be tested off-device, failing loudly beats listening to nothing.
Nothing here has been compiled, and CI will not compile it either.
swiftc -parsepasses on both changed files — that is a syntax check only.It does not resolve
import AVFoundation/UIKit, does no type checking, andwould not catch a wrong overload, an actor-isolation error, or a Swift 6
concurrency diagnostic.
swift build --package-path ios/ParleyKitpasses, but ParleyKit is untouched —it only proves nothing else broke.
.github/workflows/ci.ymlhaspaths-ignore: ios/**, so this PR gets noCI build at all.
@MainActor+ AVFoundation + UIKit, so none of it couldbe moved into ParleyKit where it would at least be buildable.
The first real build is the iOS release workflow. Someone with Xcode should
build this before it is tagged, and the fix itself needs a device to confirm:
open a microphone window, dictate, press stop, swipe back, wait for the engine
to die, and tap again — it should now either serve you or tell you to open
Parley, never sit listening to nothing.
🤖 Generated with Claude Code