[DRAFT/prototype] Native audio reading experience (#1580) - #1581
openlibrary-bot wants to merge 5 commits into
Conversation
Core, engine-agnostic pieces of the internetarchive#1580 audio-first prototype, with tests. - textSegments: splits a paragraph into the priority order the issue asks for (first 3 words as a 'landmark', rest of the sentence, then the rest of the paragraph split by sentence so synthesis units stay small). - ParagraphSource: OCR paragraph supply addressed by (leaf, chunk), with page caching, in-flight de-duplication, blank-page skipping, and a lookahead window. - SynthesisQueue: single-worker priority queue so background loading happens in order without overloading the CPU; caches results, evicts what leaves the buffer, and aborts in-flight work when a seek makes it no longer urgent. 56 jest tests.
AudioReaderPlayer ties the source, segmenter and synthesis queue together: 5-paragraph lookahead hydrated in issue-internetarchive#1580 priority order, a loading flag that is on exactly when the segment being waited on is not buffered, and next/prev seeks that stop audio immediately but coalesce a burst of presses into a single buffer rebuild. Generation counter guards against a stale playback loop or hydration overtaking a newer seek. 28 further jest tests (84 total in tests/jest/audioreader).
- IaAudioBook: item metadata + BookReader manifest + Open Library table of contents, exposing only what audio mode needs. Reuses PageChunk's text parser; requests no page images. - AudioReaderView: the minimal Lit view from the issue -- cover, TOC, transport -- with segment styling that makes progressive rendering visible (read / current / buffered / not yet synthesized) plus a debug buffer overlay. - WebSpeechEngine: adapts the read-aloud plugin's WebTTSSound, keeping its browser workarounds, to the player's engine interface. - simulateLatency: optional synthesis delay so the buffering, spinner and seek-cancellation behaviour is observable before PocketTTS exists. - Standalone webpack config + jqueryShim, so the bundle stays jQuery-free (250KB, boots no BookReader) while still importing the real tts modules. - Starts reading at the book proper, not the cover: OCR chunks that are stray marks are skipped, and the first TOC entry chooses the start leaf. demo at /BookReaderDemo/demo-audioreader.html; 112 jest tests, lint clean.
- PcmAudioOutput: Web Audio playback for engines that emit samples, which is what PocketTTS will need. Hand-rolled pause/resume (an AudioBufferSourceNode is single-use) plus sample/peak counters so 'audio happened' is measurable rather than assumed. - Watchdog completion: an AudioBufferSourceNode's 'ended' event is not guaranteed -- an interrupted or non-rendering context never fires it, which would strand the reader on one segment for the rest of the book. Fall back to wall-clock, counted separately so a recovery is never mistaken for playback. - Fix: resuming after a pause started a second playback loop, so both advanced on the same sound and a segment was skipped. Regression test added. - SyntheticPcmEngine (?engine=pcm): renders measurable tones, not speech. Needed because neither speechSynthesis nor Web Audio can witness a sound *finishing* under Playwright here -- the AudioContext clock does not advance without an audio device (0.005s per 2 real seconds, headless and headed alike). 14 Playwright tests green with screenshots; 113 jest tests; lint clean.
…ights Ports the reference Python runtime (pocket_tts_onnx.py) so PocketTTS can run client-side. Runtime-agnostic: handed a session factory, so the same code runs under onnxruntime-web in a worker and under Node for verification. - SentencePieceUnigram: the model ships a sentencepiece ModelProto and there is no JS tokenizer to borrow, so this parses the protobuf and implements Viterbi segmentation with byte fallback. Byte pieces are kept out of the lattice -- their 0.0 score outranks every real piece's log probability. Checked against sentencepiece 0.2.2 token-for-token on ASCII, accents, Japanese, emoji and tabs. - PocketTtsSynthesizer: text conditioner -> flow_lm_main per frame -> flow_lm_flow integration -> mimi decoder, threading all 18 flow and 56 mimi state tensors explicitly. EOS detection, token-derived frame budget, sentence/clause chunking to the 50-token limit, and abort support. - resample: windowed-sinc rational resampling, because the only ungated voice source is a 16kHz reference clip and the encoder wants 24kHz. Naive interpolation colours the timbre we are trying to clone. - scripts/verify-pocket-tts.mjs: runs the whole thing against the real int8 weights and writes a WAV. Verified end to end against the actual models: 5.28s of audio for an 18-word sentence of real Plato OCR (3.4 words/s), RTFx 1.7x on one wasm thread. Its acoustic profile matches the human reference clip closely -- spectral centroid 1560Hz vs 1620Hz, 98.9% vs 99.4% of energy below 4kHz, envelope dynamics 27x vs 21x -- which is what distinguishes speech from noise. I have not listened to it. Voices: the 8 named presets are unusable in a browser (gated repo, 401), so the voice is cloned from ungated reference audio via the mimi encoder. 91 further jest tests (216 total in tests/jest/audioreader).
|
Progress update — PocketTTS synthesis now works, and the branch has moved to this repo. Branch location. PocketTTS is producing speech. The reference runtime is Python, so the flow-matching loop is now ported to JS ( The session signatures corroborate the port independently: 20 = 2 real + 18 state tensors, 57 = 1 + 56, exactly the I cannot listen to the output, so I compared its acoustics against the human reference clip: spectral centroid 1560Hz vs 1620Hz, 98.9% vs 99.4% of energy below 4kHz, envelope dynamics 27x vs 21x, RMS 0.030 vs 0.029, and 18 words in 5.28s (3.4 words/sec). That is a speech profile rather than noise. Intelligibility by ear remains unverified. Two pieces of incidental work that may be of wider interest:
Tests: 216 jest + 14 Playwright, lint clean. The generation loop is covered with stand-in ONNX sessions so the suite needs none of the ~146MB of weights; real-weight verification is Still to do: Web Worker wrapper, Cache API model storage, |
|
Milestone: PocketTTS now runs client-side in the browser and produces real speech. This closes the loop on the part of #1580 that was genuinely uncertain. Real audio from the real model, in a real browserA Playwright test drives the demo with 28,800 samples at 24kHz is 1.2s of audio for the landmark segment of the first paragraph, drawn from this book's own OCR. The assertions are deliberately the ones silence would fail: non-zero peak, non-zero RMS, and RMS well below peak (a constant tone or DC offset would not satisfy that). A second test then confirms those samples reach the audio device with a non-zero peak and a The in-browser voice-clone result matches the Node verification exactly — 24kHz, 112 voice frames — which is a useful independent check that the worker path and the direct path agree. How it is put together
Test status216 jest + 17 Playwright, lint clean. New coverage in this milestone:
New screenshots: The PocketTTS browser tests skip rather than fail when the weights are not present locally, so a clean checkout still runs the rest of the suite. They point at a gitignored local copy instead of HuggingFace to keep the run fast; the fetch commands are documented in the spec file. Honest limits
Still a draft — not for review, not for merge. |
|
Milestone: the hybrid preview path is built, which was the last substantive piece the issue describes. What it doesThe issue floats this directly — "a hybrid of PocketTTS and WebSpeechSynthesis (much faster) to provide an instant fallback preview while the higher quality voices are loading" — and it is the answer to PocketTTS's one real weakness in a browser: at ~RTFx 1.7 on a single wasm thread, a paragraph the buffer has not reached yet costs seconds of silence. The rule falls out of when each decision gets made:
So reading straight through sounds like PocketTTS; a cold seek speaks immediately and recovers quality from the next segment. Nothing is thrown away — late PocketTTS audio stays in the queue's cache for a seek back. Verified in a real browser: Two bugs this shook outBoth were mine, both in the hybrid, both would have silently degraded the feature rather than crashing:
An honest note on how the takeover is testedThe preview half tests cleanly with WebSpeech: the utterance starts immediately, with a real voice, speaking the paragraph's landmark segment. The takeover half cannot be observed with WebSpeech as the preview voice, for the environment reason documented earlier in this PR: a WebSpeech sound never fires Rather than assert something weaker, the preview engine is now selectable ( Tests233 jest + 19 Playwright, lint clean. 17 new unit tests pin the hybrid's decisions: quality-wins-inside-grace (and that no preview is even synthesized then), fallback outside it, upgrade-before-play, upgrade counted once across replays, permanent fallback on failure, pause/resume reaching whichever engine is live, stop silencing both mid-handover, and rate changes applying to both so a handover does not change speed. New screenshots: Where this leaves the prototypeEverything issue #1580 asks for is now built and demonstrable: the minimal audio-first view, real OCR read aloud, progressive chunk rendering, the ≤5-paragraph buffer, the spinner, seek throttling, PocketTTS client-side in WASM, and the hybrid preview. Out of scope and untouched: the accessibility word-window mode (the issue calls it future work), mobile packaging, and cross-browser support. Still a draft — not for review, not for merge. |
Draft — prototype for discussion, not for merge. Opened by
openlibrary-boton behalf of @mekarpeles' exploration of #1580.Refs #1580.
What this is
A working local prototype of the audio-first reading mode: load a URL, get cover + table of contents + playback controls (no page-image view), press play, and a real archive.org book is read aloud client-side from its OCR text.
Demo:
/BookReaderDemo/demo-audioreader.html?ocaid=theworksofplato01platialaVerified against
theworksofplato01platiala— public, English, 524 leaves, 18 resolvable TOC entries from Open Library.What works
speechSynthesis, sourced fromBookReaderGetTextWrapper.php— the same endpoint the existing read-aloud plugin uses.Shape, and the questions it raises
src/audioreader/is a standalone bundle (250KB) with its own webpack entry. It never bootsBookReader.js, so no plugin registry, no page model, no jQuery, no bergamot. It reuses the read-aloud plugin'sPageChunkparser andWebTTSSound(keeping its hard-won Chrome/Firefox/Safari speech workarounds) via ajqueryShim, so the reuse costs nothing.That is deliberate but not a recommendation: whether audio mode should be a lean standalone bundle or a BookReader mode that disables plugins is exactly the productionization decision this prototype does not settle. Today plugins are wired at the webpack entry level, so "don't load translate" means a different bundle, not a runtime flag.
PocketTTS — synthesis works, browser integration pending
The reference runtime is Python, so this is a port: text conditioner →
flow_lm_mainper frame →flow_lm_flowintegration → mimi decoder, threading all 18 flow and 56 mimi state tensors explicitly.PocketTtsSynthesizeris runtime-agnostic, so the same code runs underonnxruntime-webin a Worker and under Node.Verified against the real int8 weights (
node scripts/verify-pocket-tts.mjs <modelDir>): 5.28s of audio for an 18-word sentence of real Plato OCR — 3.4 words/sec — at RTFx 1.7x on a single wasm thread. Since I cannot listen to it, I compared its acoustics to the human reference clip: spectral centroid 1560Hz vs 1620Hz, 98.9% vs 99.4% of energy below 4kHz, envelope dynamics 27× vs 21×, RMS 0.030 vs 0.029. That profile is speech, not noise. Intelligibility by ear is unverified.Two pieces of unexpected work:
ModelProtoand there is no JS sentencepiece.SentencePieceUnigramparses the protobuf and implements Viterbi with byte fallback, validated token-for-token againstsentencepiece0.2.2 (68 tests: ASCII, accents, Japanese, emoji, tabs, decimals, abbreviations). Worth knowing if anyone else touches this: the 256 byte pieces carry score0.0, higher than every real piece's log probability, so they must be kept out of the lattice or the tokenizer spells everything out byte by byte.kyutai/pocket-tts(fetchingalba.safetensorsreturns 401). The voice is therefore cloned from ungated reference audio through the mimi encoder. Worth deciding deliberately, since "run PocketTTS client-side" implies either cloning from a clip we own or an arrangement with kyutai.It runs in the browser. Synthesis is in a Web Worker (per-frame ONNX on the main thread would stall the transport controls), the ~146MB bundle is cached in the Cache API so it downloads once rather than per page load, and the UI shows the download rather than sitting silent.
build-ortcopies onnxruntime's wasm intoBookReader/ort/, following thebuild-bergamotprecedent.The hybrid preview —
?engine=hybridThe arrangement the issue suggests, and the answer to PocketTTS's one real weakness: at ~RTFx 1.7 on one wasm thread, a paragraph the buffer has not reached costs seconds of silence.
The rule falls out of when each decision is made. At synthesis time, PocketTTS gets a short grace period — once the lookahead is warm it wins easily, because synthesis started paragraphs ago, so no preview is produced at all. If the grace expires, hand back a WebSpeech preview and leave PocketTTS running. At play time, check again: if PocketTTS landed in between, play that instead. Reading straight through is high quality; a cold seek speaks immediately and recovers quality from the next segment. Late PocketTTS audio is still cached for a seek back.
Verified in a browser:
{quality: 1, preview: 1, upgraded: 1}— first segment previewed, then PocketTTS took over with its samples reaching the device.Tests
tests/jest/audioreader/covering segmentation, paragraph walking, queue ordering/abort/eviction, the player state machine, the tokenizer against real sentencepiece, the resampler, and the generation loop (with stand-in ONNX sessions, so the suite needs no model weights).tests/playwright/against the live archive.org endpoints, with screenshots. Playwright is new to this repo (the committed e2e suite is testcafe) and is additive.One environment caveat, stated plainly: Chromium under Playwright on this machine has no audio device, so its AudioContext clock does not advance (0.005s per 2 real seconds) and
speechSynthesisnever firesend. Audio starting is verifiable — utterancestartevents with a real voice, and for the PCM path a captured sample count and non-zero peak amplitude — but a sound finishing is not observable in-browser here. Continuous advancement driven by genuine sound completion is covered by the jest suite instead. This also motivated a real fix:PcmAudioOutputnow has a duration-based watchdog so a context that stops rendering (backgrounded mobile tab, device removed) cannot strand the reader on one segment, and it counts those completions separately so they are never mistaken for playback.Not done / out of scope
Everything #1580 asks for is now built: the minimal view, real OCR read aloud, progressive chunk rendering, the ≤5-paragraph buffer, the spinner, seek throttling, PocketTTS client-side in WASM, and the hybrid preview. Deliberately out of scope: the accessibility word-window mode (the issue calls it future work), mobile packaging, cross-browser testing, production polish.
SyntheticPcmEnginerenders measurable tones, not speech — it exists to verify the pipeline and to exercise the PCM path. Model weights are gitignored and fetched at runtime.