Skip to content

[fix] a dictation borrows the microphone only if it is still running - #328

Merged
Lanznx merged 2 commits into
mainfrom
fix/mic-capture-liveness
Sep 2, 2026
Merged

[fix] a dictation borrows the microphone only if it is still running#328
Lanznx merged 2 commits into
mainfrom
fix/mic-capture-liveness

Conversation

@YJack0000

Copy link
Copy Markdown
Contributor

The bug

Dictation stops working entirely, and only force-quitting Parley brings it back.

DictationCoordinator.launch() decided whether to open the microphone with
if capture == nil — a pointer check, not a liveness check. The microphone
window deliberately keeps an AudioCapture alive between dictations so the next
one 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 = nil is only executed in fail()
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 with guard window.openedAt != nil, so
the one call that mattered — a capture dying with its window already closed,
arriving via handle(capture:) — was turned away before it could tear the dead
capture down.

The fix

AudioCapture can now be asked. isCapturing reports
wantsCapture && live && !interrupted, mirrored into a lock-guarded Bool by
didSet on the three flags. Not queue.sync: every caller is @MainActor, and
queue is where setActive(true) and the rebuilds run — precisely the work in
flight when a capture is sick, which is precisely when anyone asks.

Three places stop confusing holding with having. launch() (borrow),
beginWindowFromForeground() (open a window), and releaseMicrophone() (hand
the microphone to a window) all now require isCapturing rather 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 the
microphone 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 a
backgrounded process the microphone, which is exactly where a dead window leaves
us — the session no longer sits in .listening collecting 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 "open
Parley" is nonsense.

Deliberately strict

isCapturing reads false while a rebuild is in flight, so a tap landing in
that 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.

⚠️ Verification — read this before merging

Nothing here has been compiled, and CI will not compile it either.

  • This machine has Command Line Tools only: no Xcode, no simulator, no XCTest.
  • swiftc -parse passes on both changed files — that is a syntax check only.
    It does not resolve import AVFoundation/UIKit, does no type checking, and
    would not catch a wrong overload, an actor-isolation error, or a Swift 6
    concurrency diagnostic.
  • swift build --package-path ios/ParleyKit passes, but ParleyKit is untouched —
    it only proves nothing else broke.
  • .github/workflows/ci.yml has paths-ignore: ios/**, so this PR gets no
    CI build at all.
  • All the new logic is @MainActor + AVFoundation + UIKit, so none of it could
    be 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

YJack0000 and others added 2 commits September 3, 2026 00:11
`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.
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

✅ SonarQube Quality Gate passed — pathorsAI_parley

0 open issues on this PR.

@Lanznx
Lanznx merged commit d18e7b0 into main Sep 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants