Skip to content

Control TTS playback from the OS media session - #43

Merged
arphen merged 3 commits into
mainfrom
feat/tts-os-media-controls
Aug 22, 2026
Merged

arphen merged 3 commits into
mainfrom
feat/tts-os-media-controls

Conversation

@tomazzinko

Copy link
Copy Markdown
Collaborator

Summary

  • Registers TTS audio with the OS-level media session (macOS Now Playing, hardware media keys, Bluetooth headset buttons) so play/pause and word-level seek (±10 words) work outside the app, with metadata (title/artist/artwork, app-icon fallback when a book has no cover) shown in the OS UI.
  • A raw AudioContext graph is invisible to the OS media session bridge in Chromium/Firefox, so a near-silent, file-backed decoy <audio> element is played alongside the real TTS output purely to register the tab as playing real media. It's armed (primed then paused) as soon as the Audio panel opens, so the OS Play button works immediately, without leaving the decoy audibly playing before the user actually starts playback.
  • Fixes a bug where resuming after a skip-while-paused would restart from the pre-skip position instead of the skipped-to one (ttsPlayer's internal position wasn't being told about the skip).

Test plan

  • npm run build (typecheck + full test suite via pre-commit hook) — 801 tests passing
  • Manually verified in a live browser (Playwright-driven) against the demo book: OS play/pause/seekforward/seekbackward handlers correctly control real TTS playback and reflect state back to navigator.mediaSession
  • Verified skip-then-resume lands on the correct sentence via debug logs, and plain pause/resume (no skip) still resumes efficiently from the buffered queue
  • Manual check on real macOS Control Center / hardware media keys (done by requester during development, not re-verified in this session)

Registers TTS audio with the OS-level media session (macOS Now
Playing, hardware media keys, Bluetooth headset buttons) so
play/pause and word-level seek work outside the app. Also wires up
metadata (title/artist/artwork) for the OS Now Playing display,
with an app-icon fallback when a book has no cover.

A raw AudioContext graph is invisible to the OS media session bridge
in Chromium/Firefox, so a near-silent, file-backed decoy <audio>
element is played alongside the real TTS output purely to register
the tab as playing real media. It's armed as soon as the Audio panel
opens (primed then paused, so the OS state honestly reflects that
nothing is playing yet) so the OS Play button works immediately.
skipWords() was syncing ttsStore.currentWordIndex to the new target
even while paused, which made the reader position and the TTS store
position match again. handleToggle's paused-resume path uses that
same equality to decide whether to reseek, so it concluded nothing
had moved and resumed playback from ttsPlayer's own stale internal
sentence index instead of the skipped-to one.

Leaving ttsStore.currentWordIndex untouched while paused lets that
mismatch check do its job again — startFromSentence() sets it
correctly once real playback actually restarts.
Low-amplitude noise was audible as static/hum on some devices,
iOS Safari in particular where it made TTS unusable, and a random
noise loop has no zero-crossing at the seam, causing an audible
click every loop. All-zero samples fix both: no perceptible output,
and a click-free loop.
@tomazzinko
tomazzinko marked this pull request as draft August 20, 2026 17:02
@tomazzinko
tomazzinko marked this pull request as ready for review August 20, 2026 17:08
@tomazzinko
tomazzinko requested a review from arphen August 20, 2026 17:08
@arphen

arphen commented Aug 22, 2026

Copy link
Copy Markdown
Owner

sick lol

@arphen
arphen merged commit 59bf2ad into main Aug 22, 2026
2 checks 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