Background
`useSingToScore.js` and `useSingToVerify.js` (both in `client/src/hooks/`) each independently implement the same re-entrancy-guard pattern for a pending `getUserMedia` permission prompt: a `startPendingRef` boolean plus a `requestGenerationRef` incrementing counter, with every async continuation (the `getUserMedia` catch, the post-await mount/generation check, the metronome-start catch) gated on `requestGeneration === requestGenerationRef.current`, and a `cancel()` callback that bumps the generation, tears down, and resets phase — invoked both directly and via a ref-mirrored unmount effect.
`useSingToScore`'s copy (added in #4236, "prevent pending sing-to-score mic leaks") was deliberately written to mirror `useSingToVerify.js` lines ~78-120 — the issue that shipped it explicitly specified duplicating the sibling's pattern rather than extracting it, to keep that PR scoped as a narrow bug fix.
That leaves ~40 lines of subtle, correctness-critical concurrency logic living in two places with nothing enforcing they stay in sync — a real drift risk if either hook's edge cases evolve independently.
Decision
Extract a shared hook, e.g. `useAsyncCaptureGuard` in `client/src/hooks/`, exposing something like `{ tryStart(), cancel(), isCurrent(gen) }` over the two refs, parameterized by the caller's `teardown`/idle-reset callback. Have both `useSingToScore.js` and `useSingToVerify.js` consume it instead of each rolling their own copy.
Note other hooks (`useShellSession`, `useMidiNotes`) also roll similar-but-distinct generation-guard logic inline — check during implementation whether either is close enough to the same shape to consume the new hook too, but don't force a fit if their semantics diverge meaningfully.
Acceptance criteria
- New hook lives in `client/src/hooks/`, re-exported from the barrel `index.js`, with a one-line row in `client/src/hooks/README.md` (per the module-organization maintenance rule in the root CLAUDE.md).
- `useSingToScore.js` and `useSingToVerify.js` both consume it; their existing test suites (`useSingToScore.test.js`, `useSingToVerify.test.js`) continue to pass unchanged in behavior — this is a refactor, not a behavior change.
- No new re-entrancy edge cases introduced; the "allows only one microphone request while permission is pending" / "stops a permission-pending stream when capture is cancelled" style tests should pass identically for both hooks.
Found during the `/simplify` pass on #4236 (PR shipping the `useSingToScore` mic-leak fix) — deliberately left out of that PR, which was scoped to mirroring the sibling pattern, not extracting it.
Background
`useSingToScore.js` and `useSingToVerify.js` (both in `client/src/hooks/`) each independently implement the same re-entrancy-guard pattern for a pending `getUserMedia` permission prompt: a `startPendingRef` boolean plus a `requestGenerationRef` incrementing counter, with every async continuation (the `getUserMedia` catch, the post-await mount/generation check, the metronome-start catch) gated on `requestGeneration === requestGenerationRef.current`, and a `cancel()` callback that bumps the generation, tears down, and resets phase — invoked both directly and via a ref-mirrored unmount effect.
`useSingToScore`'s copy (added in #4236, "prevent pending sing-to-score mic leaks") was deliberately written to mirror `useSingToVerify.js` lines ~78-120 — the issue that shipped it explicitly specified duplicating the sibling's pattern rather than extracting it, to keep that PR scoped as a narrow bug fix.
That leaves ~40 lines of subtle, correctness-critical concurrency logic living in two places with nothing enforcing they stay in sync — a real drift risk if either hook's edge cases evolve independently.
Decision
Extract a shared hook, e.g. `useAsyncCaptureGuard` in `client/src/hooks/`, exposing something like `{ tryStart(), cancel(), isCurrent(gen) }` over the two refs, parameterized by the caller's `teardown`/idle-reset callback. Have both `useSingToScore.js` and `useSingToVerify.js` consume it instead of each rolling their own copy.
Note other hooks (`useShellSession`, `useMidiNotes`) also roll similar-but-distinct generation-guard logic inline — check during implementation whether either is close enough to the same shape to consume the new hook too, but don't force a fit if their semantics diverge meaningfully.
Acceptance criteria
Found during the `/simplify` pass on #4236 (PR shipping the `useSingToScore` mic-leak fix) — deliberately left out of that PR, which was scoped to mirroring the sibling pattern, not extracting it.