diff --git a/CLAUDE.md b/CLAUDE.md
index 2a1f745..a706def 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -41,8 +41,8 @@ gotchas, and exact knobs.
uses **mlx-vlm** (default `mlx-community/Qwen2.5-VL-7B-Instruct-4bit`).
`/model` can switch per-read; any downloaded MLX chat model works.
- **TTS**: **CSM-1B** (`senstella/csm-1b-mlx`, Sesame Conversational Speech Model)
- via **`csm-mlx`** on Metal, bf16, 24 kHz native. 2 built-in reading voices +
- clone-condition voices + optional LoRA fine-tuning. English-best.
+ via **`csm-mlx`** on Metal, bf16, 24 kHz native. 2 built-in
+ reading voices + clone-condition voices + optional LoRA fine-tuning. English-best.
- **Server**: FastAPI + WebSocket (`/ws`) for live reads, plus a small REST
surface (config / models / read-library). Two clients: the terminal CLI (the
sole `/ws` consumer) and the web dashboard (REST + static, served at `/`).
@@ -79,9 +79,9 @@ readback/
│ # --full forces a full sync (with --delete to clean orphans on Pi).
│ # SSH keep-alive flags prevent drop on large transfers over Wi-Fi.
├── README.md # user-facing (GitHub landing; stays at root)
-├── tests/ # 38 pytest cases — PURE LOGIC only (no MLX/CSM/GPU): chunking,
+├── tests/ # 44 pytest cases — PURE LOGIC only (no MLX/CSM/GPU): chunking,
│ # silence-tidy, fade-out, extract scrub, library + cache, think-stripper,
-│ # tones, map-reduce batching. See docs/TESTS.md for the full catalogue.
+│ # tones, map-reduce batching, summary trim. See docs/TESTS.md for the catalogue.
│ # Config in pyproject.toml; runnable on Linux (requirements-pi.txt subset).
├── .github/workflows/
│ ├── ci.yml # runs the pytest suite on push + PR, Python 3.10 + 3.12 on Ubuntu;
@@ -233,20 +233,40 @@ readback/
special-casing; long scans are map-reduced by `summarize_article`, not truncated).
Progress fires `reading page N / M` via the existing `phase` WS channel.
- **Tones** (`tones.py`): a `Tone` bundles a summary framing prompt + a CSM
- delivery `temperature`. `classify_source(src)` → `"book"` (image / folder / glob)
+ *base* delivery `temperature` (per-chunk `_expressive_temperature` in
+ `speak.py` nudges it around this base — see Chunk + synth below).
+ `classify_source(src)` → `"book"` (image / folder / glob)
or `"article"` (URL); `tone_for(kind)` → `BOOK` (measured, **0.6**, opens by
naming the chapter/topic) or `ARTICLE` (livelier explainer, **0.8**). Auto,
server-side, invisible to the CLI — **no `/tone` override or config yet** (room
- for a 3rd tone). Both `summary_system` prompts carry a **hard ~250-word
+ for a 3rd tone). Both `summary_system` prompts carry a **hard 250-word
(10–15 sentence) length ceiling** so the spoken summary stays a briefing, not a
- retelling (paired with `oneshot`'s `max_tokens` bound). ⚠ Tone shifts
- *delivery temperature*, NOT the voice — the user's
+ retelling (paired with `oneshot`'s `max_tokens` bound). ⚠ The ceiling alone let
+ the model pad short sources with generic, ungrounded wrap-up sentences to
+ approach 250 words regardless of how little the source actually said —
+ `_summarize_once` (`summarize.py`) now spells out the source's **word count**
+ in the user prompt, and both prompts explicitly frame 250 as a ceiling to
+ reserve for long sources, targeting roughly half the source's word count
+ otherwise; forbids wrap-up sentences not grounded in a specific source fact,
+ tells the model to skip the source's own housekeeping (acknowledgements,
+ thanks, calls to action — faithful but zero-information for a listener),
+ and asks for natural spoken rhythm (varied sentence length, emphasis on a
+ genuinely notable point) instead of a flat, even-register list of facts.
+ The shared length policy lives ONCE, in `_LENGTH_RULES` (+ `_PLAIN_PROSE_RULE`),
+ `.format`-ed into both prompts — edit it there, not in the prompt bodies. The
+ ceiling itself is `SUMMARY_WORD_CEILING` (250), exported because the prompt is
+ only advisory: `summarize_article` **hard-enforces it post-hoc** with a
+ sentence-boundary trim (`_trim_to_word_ceiling` — the model measured 313 words
+ from a 3,446-word source on prompt alone).
+ Tone shifts *delivery temperature*, NOT the voice — the user's
`/voice` is untouched. Book sources also take their **title from the first ~3 OCR
lines** (`_book_title_from_text`), which the BOOK prompt then leads with.
- **Summarize** (`summarize.py`): short body (≤ `reader.summary_max_chars`, default
- 16000) → one `oneshot` with the spoken-explanation prompt (`_summarize_once`).
- The framing prompt is the tone's `system` (passed by the server; defaults to the
- article tone).
+ **60000** — Qwen3.5-9B's 262K-token context comfortably single-passes articles
+ up to this size; the old 16000 default forced most long-form articles through
+ map-reduce for no reason) → one `oneshot` with the spoken-explanation prompt
+ (`_summarize_once`). The framing prompt is the tone's `system` (passed by the
+ server; defaults to the article tone).
Longer input (book scans) → **map-reduce** (`_map_reduce`): `_batches` packs the
text into ≤`max_chars` batches (paragraph → sentence → hard-cut), each condensed
with `_MAP_SYSTEM`, the digests joined and reduced via `_summarize_once`;
@@ -254,16 +274,52 @@ readback/
old hard truncation that silently dropped everything past ~10-12 pages. Optional
`progress(done, total)` fires per batch in the map phase (server → `summarizing
section N / M`). Returns the article text unchanged if the LLM produced nothing.
+ ⚠ The **original source's word count rides through map-reduce** as
+ `source_words` — the reduce step's `body` is the compressed digests, and
+ anchoring the prompt's length target to *their* count mis-calibrates exactly
+ the long inputs map-reduce exists for. Non-empty summaries are then clipped by
+ `_trim_to_word_ceiling` (sentence-boundary cut at `SUMMARY_WORD_CEILING`,
+ always keeps ≥1 sentence) — the code-level backstop for the prompt's HARD
+ LIMIT, which also caps synthesis time (every overshoot word is paid for again
+ in TTS).
- **Chunk + synth** (`speak.py`):
- - `chunk_text` — paragraph-respecting, sentence-aware merge up to `_MAX_CHARS`
- (400; was 280 — fewer chunks = fewer CSM prefills = faster reads; see
- speed-vs-prosody comment in `speak.py`); over-long single sentences split on
- commas; sub-`_MIN_CHARS` (8) fragments stitched onto neighbors.
+ - `chunk_text` — paragraph-respecting, sentence-aware merge; **each chunk's cap
+ is randomized** (`_next_chunk_cap`) between `_MIN_CHUNK_CHARS` (**280**) and
+ `_MAX_CHARS` (**400**) instead of always hitting the same fixed cap — a
+ uniform cap gives every chunk the same length, which reads with a
+ mechanical, same-every-time breath cadence; randomizing it per chunk (same
+ input text produces a different chunk count/boundaries run to run — verified)
+ varies pacing AND gives `_expressive_temperature` below more chances to land
+ a short cap on a single sentence instead of merging it with a
+ differently-toned neighbor. ⚠ **Chunk count tracks the band's mean** — the
+ earlier [120, 200] band measured **2.5-3x** the chunks (= prefills = time) of
+ fixed 400, which is why the band sits at the fast end; randomization itself
+ adds ~zero chunks vs a fixed cap of the same size (see the guide in
+ `config.yaml`). The over-long-sentence safety net (comma split, then a
+ space-level `_hard_split` for comma-free runs) measures against the fixed
+ `_MAX_CHARS`, not the random per-chunk cap. Sub-`_MIN_CHARS` (8) fragments
+ are stitched onto a neighbor — mid-paragraph they're carried into the next
+ piece (⚠ never silently dropped; a low random cap made drops reachable —
+ "Wow!" was lost in ~21% of runs pre-fix, `test_short_fragment_is_never_dropped`
+ guards it).
- `_tidy_silence` — ⚠ **this is what removes the halting feel.** CSM, conditioned
on the casual/disfluent Sesame prompt, emits long mid-utterance pauses;
`_tidy_silence` trims leading/trailing silence (−40 dB threshold) and caps any
internal silent run to `max_pause_ms` (300). Model-agnostic post-processing.
- - `synthesize_article` — synth each chunk, tidy, fade-out tail, join with
+ - `_expressive_temperature` — ⚠ **the only expression-varies-with-content knob
+ CSM exposes.** CSM has no direct emotion/prosody API; sampling temperature
+ (more variation in pitch/pacing at higher values) is the one delivery lever
+ it has, so this nudges the tone's *base* temperature per chunk from its
+ punctuation: `!` → +0.08 (emphatic), `?` → +0.04 (questioning), ≥3 commas and
+ no `!`/`?` → −0.03 (dense/measured), clamped to `[0.55, 0.95]` (below ~0.55 a
+ short clone reference destabilizes). Granularity is **per chunk, not per
+ sentence** — at the [280, 400] band a chunk usually spans a few sentences and
+ gets whichever punctuation rule matches first; finer granularity means a
+ lower band and 2.5-3x the synthesis time (see the speed/quality guide in
+ `config.yaml`).
+ - `synthesize_article` — synth each chunk (at its `_expressive_temperature`,
+ when `base_temperature` is passed — the server always passes the reading
+ tone's temperature), tidy, fade-out tail, join with
`reader.gap_sec` (0.18 s) gaps; `progress`/`should_stop` hooks; a chunk that
throws is skipped, not fatal. **Degenerate-chunk guard:** if `_tidy_silence`
returns empty (all silence), synthesis is retried once before dropping the chunk.
@@ -279,9 +335,10 @@ readback/
- **Engine: `csm-mlx`** (`senstella/csm-1b-mlx`, `ckpt.safetensors`). float32 @
24 kHz, cast to **bf16** by default (`cfg.precision`: `bf16`/`fp16`/`fp32`).
- bf16 is ~6% faster than fp32 with no audible quality loss; fp32 is max
- fidelity (revert to it if clone voices sound off). `_make_sampler` caches
- per `(temperature, top_k)` to avoid recreation per chunk.
+ bf16 is ~6% faster than fp32 with no audible quality loss at normal
+ listening; switch to `fp32` for max fidelity if bf16 ever sounds off on a
+ clone voice. `_make_sampler` caches per
+ `(temperature, top_k)` to avoid recreation per chunk.
- **MLX single-thread:** `ThreadPoolExecutor(max_workers=1)` owns load + all
synth. `_impl` methods run ON that thread and must never re-submit. Never call
`mx` ops or the engine's `_impl` from another thread.
@@ -408,7 +465,8 @@ readback/
summary recommendation, switches the summary LLM per-read), `/vision` (same
flow filtered to **vision** models — switches the image/book OCR model
per-read; `handlePickModel(kind)` + `ModelList kind` serve both, no
- recommendation marker for vision), `/library` (alias `/lib` —
+ recommendation marker for vision), `/speed [x]` (playback rate 0.5–2,
+ persisted; no server involvement — see Playback speed below), `/library` (alias `/lib` —
`GET /api/library?sort=newest&limit=20&offset=N`, arrow-key nav, `space` to
preview inline (plays audio without leaving the library; shows `♫` + elapsed;
space again stops), Enter to open the full player, `d` twice to delete),
@@ -425,6 +483,14 @@ readback/
routed `/model ` to the read pipeline. Absolute paths (`/Users/…`),
globs (`*`/`?`), and tilde paths (`~/…`) have a non-command first token and
route to the server as local sources.
+- **Playback speed** (`player.ts` `setRate`, `/speed` + `+`/`-` in the player,
+ 0.5–2× step 0.1): `afplay -r RATE -q 1` (high-quality **pitch-preserving**
+ rate scaling — CSM has no speed control, so pace is playback-side). ⚠ `elapsed`
+ tracks **audio position**, advancing at `rate ×` wall time — seek slices and
+ the synced transcript depend on this; don't revert the timer to plain wall
+ clock. A mid-play rate change restarts afplay at the current position via the
+ seek-slice mechanism (debounced like seek). Rate shows next to the progress
+ bar when ≠ 1×; persists in prefs (`speed`).
- **Playback = `afplay`** (macOS-only): pause **SIGKILLs** the afplay process
and records the elapsed position; resume restarts afplay from that position via
the same WAV-slice mechanism seek uses (`restartAt`). ⚠ The old
@@ -461,7 +527,7 @@ readback/
runtime `DEV` check the bundler can't eliminate. The binary is named
`readback-cli` (not `readback`) so the server-lookup fallback
`Bun.which("readback")` can't spawn the CLI itself.
-- **Prefs** (voice/mode/model/visionModel) persist to `~/.readback/cli.json`. Theme = the
+- **Prefs** (voice/mode/model/visionModel/speed) persist to `~/.readback/cli.json`. Theme = the
Ghost palette (#f0f0f0 primary, #808080 dim, #ff5d5d errors/cancel —
inherited from the deleted web UI)
plus CLI-only fit colors (#5dd17a green / #e6c35a yellow, `/model` list only)
@@ -530,7 +596,8 @@ readback/
- **Summary mode has no first-token streaming.** Summary is a single `oneshot`
call — the whole summary is produced, then synthesized. (Full mode skips the
LLM entirely.)
-- **Speed has no effect.** `tts.csm.speed` is inert — CSM has no speed control.
+- **`tts.csm.speed` has no effect.** It's inert — CSM has no speed control.
+ Pace is playback-side: the CLI's `/speed` (afplay `-r`, pitch-preserving).
- **Clone sounds garbled.** Almost always a `ref_text` that doesn't match the
clip, or a too-short reference at low temperature. Fix the transcript / use a
5–8 s clip / raise temperature toward 0.6–0.8.
@@ -561,13 +628,19 @@ work: `Synthesizer(Config.load().tts).synthesize("…")` from a Python REPL.
## Version
-Current: **v4.1.0** — audio quality + performance. Read cache skips the entire
-pipeline on re-reads (keyed by url/mode/voice/llm_model). Degenerate-chunk
-guard retries all-silence synthesis once. Light crossfade (100 ms fade-out) at
-chunk joins. New `llm_model` column in the reads table (auto-migrated).
-
-Previously: v4.0.0 — full MLX LLM stack (Ollama removed); v3.0.0–v3.7.0 (see
-memory `version-history` for full changelog).
+Current: **v4.2.0** — summary quality + delivery + CLI playback speed. Summary
+mode no longer pads short articles with invented filler (word-count anchor +
+code-enforced 250-word ceiling trim); map-reduce's length anchor now tracks
+the original source, not the compressed digests; CSM delivery temperature
+nudges per chunk from punctuation (`_expressive_temperature`); chunk
+boundaries randomize within a fast [280, 400]-char band instead of a fixed
+cap; `summary_max_chars` raised 16K→60K to skip needless map-reduce; CLI
+gained a playback speed controller (`/speed` + `+`/`-` in the player,
+pitch-preserving, persisted).
+
+Previously: v4.1.0 — audio quality + performance (read cache, degenerate-chunk
+guard, chunk-join crossfade, `llm_model` column); v4.0.0 — full MLX LLM stack
+(Ollama removed); v3.0.0–v3.7.0 (see memory `version-history` for full changelog).
Set in `pyproject.toml`, `src/readback/__init__.py`,
`src/cli/package.json`, and `src/dashboard/package.json` — bump all four when
releasing. The standalone CLI binary needs `src/cli/install.sh` re-run to pick
diff --git a/README.md b/README.md
index a63718a..057cb3b 100644
--- a/README.md
+++ b/README.md
@@ -192,7 +192,7 @@ Edit `config.yaml` (or pass `--config path`). The defaults work out of the box.
| `reader.default_mode` | `full` (verbatim) or `summary` (LLM) | `full` |
| `reader.output_dir` | Where generated WAVs are written/served (a `readback-audio-db/` folder beside the repo) | `../readback-audio-db/audio` |
| `reader.gap_sec` | Silence inserted between synthesized chunks | `0.18` |
-| `reader.summary_max_chars` | Per-pass chunk size for Summary mode — longer inputs (book scans) are map-reduced across batches of this size, not truncated | `16000` |
+| `reader.summary_max_chars` | Per-pass chunk size for Summary mode — longer inputs (book scans) are map-reduced across batches of this size, not truncated | `60000` |
| `reader.library_db` | SQLite library of past reads (powers the dashboard) | `../readback-audio-db/library.db` |
Audio + library DB default to a **`readback-audio-db/`** folder beside the repo. Point `output_dir` / `library_db` anywhere (absolute or `~` both work).
diff --git a/config.pi.example.yaml b/config.pi.example.yaml
index f539370..23bddfd 100644
--- a/config.pi.example.yaml
+++ b/config.pi.example.yaml
@@ -23,4 +23,4 @@ reader:
library_db: "../readback-audio-db/library.db"
default_mode: "full"
gap_sec: 0.18
- summary_max_chars: 16000
+ summary_max_chars: 60000
diff --git a/config.yaml b/config.yaml
index 58cb8d9..a0897d6 100644
--- a/config.yaml
+++ b/config.yaml
@@ -23,7 +23,7 @@ tts:
speaker: "codeword"
# Precision — speed vs fidelity:
# bf16 — ~6% faster than fp32, native on Apple Silicon, no audible quality
- # loss at normal listening. Recommended default.
+ # loss at normal listening. Current default.
# fp32 — max fidelity (RTF ~0.72); use if you hear artifacts on a clone voice.
# fp16 — slightly faster than bf16, narrower dynamic range — not recommended.
precision: "bf16"
@@ -48,16 +48,24 @@ tts:
# ── Synthesis speed vs quality guide ──
#
# Three knobs control the speed/quality tradeoff. Each chunk pays a fixed
- # CSM reference-prefill cost (~1–2 s), so fewer chunks = faster reads.
+ # CSM reference-prefill cost (~1–2 s), so fewer chunks = faster reads — and
+ # chunk count tracks the cap band's MEAN, so a low band is expensive: a
+ # [120, 200] band measured 2.5-3x the chunks (and prefills) of 400 for the
+ # same text. Smaller chunks do give the per-chunk expressive-temperature
+ # nudge (speak.py's _expressive_temperature) finer granularity, but the
+ # time cost dominates for Full-mode reads.
#
- # Preset precision _MAX_CHARS (speak.py) Notes
+ # Preset precision chunk cap (speak.py) Notes
# ───────── ───────── ──────────────────── ─────
- # Fast bf16 400 current default; ~30% fewer chunks
+ # Current bf16 randomized [280, 400] fast (near the fixed-400 chunk
+ # count) + varied breath cadence
# Balanced bf16 280 more natural sentence breaks
- # Max quality fp32 200 best intonation, ~2× slower
+ # Max prosody bf16 randomized [120, 200] finest expression granularity,
+ # 2.5-3x the chunks — slow
+ # Max fidelity fp32 200 only if bf16 sounds off on a
+ # clone voice, ~2× vs Current
#
- # To revert to max quality: set precision: "fp32" above, and change
- # _MAX_CHARS to 200 in src/readback/pipeline/speak.py.
+ # The band is [_MIN_CHUNK_CHARS, _MAX_CHARS] in src/readback/pipeline/speak.py.
reader:
# Generated WAVs are written here and served for playback + download. Kept
@@ -67,4 +75,7 @@ reader:
output_dir: "../readback-audio-db/audio" # sibling of the repo (resolved from here)
default_mode: "full" # "full" (verbatim) or "summary" (LLM)
gap_sec: 0.18 # silence between (trimmed) chunks
- summary_max_chars: 16000 # cap article text fed to the LLM in summary mode
+ summary_max_chars: 60000 # cap article text fed to the LLM in one pass before
+ # map-reducing (Qwen3.5-9B has a 262K-token context —
+ # 60K chars/~15K tokens leaves it single-passing the
+ # vast majority of articles instead of map-reducing)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 0c273c5..d3dac78 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -1,4 +1,4 @@
-# Architecture — readback (v4.1.0)
+# Architecture — readback (v4.2.0)
How the pieces fit together and why. System-level companion to
[CLAUDE.md](../CLAUDE.md) (implementation notes, gotchas, exact knobs) and
@@ -61,12 +61,16 @@ responsive while a read job runs because all heavy work is pushed off it:
collapses whitespace so the voice doesn't read markup aloud. Returns an
`Article{title, text, url}`.
2. **Summarize** (`summarize.py`, Summary mode only) — `summarize_article` calls
- `LLMClient.oneshot(system, user)` with a spoken-explanation system prompt;
- long articles are truncated to `reader.summary_max_chars`. Full mode skips
- this and reads `article.text` verbatim.
+ `LLMClient.oneshot(system, user)` with a spoken-explanation system prompt when
+ the article fits in one pass (≤ `reader.summary_max_chars`); longer input
+ (book scans) is map-reduced across batches of that size instead of truncated.
+ The result is clipped to a 250-word ceiling at a sentence boundary
+ (`_trim_to_word_ceiling`) — the prompt's limit alone is advisory. Full mode
+ skips this and reads `article.text` verbatim.
3. **Chunk + synthesize** (`speak.py`) — `chunk_text` splits into TTS-sized,
- paragraph-respecting chunks (~400 chars, sentence-aware, over-long sentences
- split on commas). `synthesize_article` synthesizes each chunk fully,
+ paragraph-respecting chunks (sentence-aware, each chunk's cap randomized in
+ [280, 400] chars for varied pacing; over-long sentences split on commas, then
+ spaces). `synthesize_article` synthesizes each chunk fully,
**silence-tidies** it (`_tidy_silence`: trim leading/trailing silence and cap
internal pauses to ~300 ms), **fades out** the tail (100 ms linear fade via
`_fade_out_tail`), retries all-silence chunks once, and joins with `reader.gap_sec`
diff --git a/docs/JOURNEY.md b/docs/JOURNEY.md
index 2936a6c..e7e3ad3 100644
--- a/docs/JOURNEY.md
+++ b/docs/JOURNEY.md
@@ -228,7 +228,7 @@ handle the plumbing.
---
-## Stack snapshot (v4.1.0)
+## Stack snapshot (v4.2.0)
| Layer | Tech |
|---|---|
diff --git a/docs/PLAN.md b/docs/PLAN.md
index ad4f826..eb7e359 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -6,6 +6,303 @@ tracking. Each entry carries a date and a status (`proposed` / `in progress` /
---
+## 2026-07-02 — CLI playback speed controller (/speed + player +/- keys)
+
+**Status: done** — branch `fix/summary-padding-short-articles`. User found the
+reading pace a bit slow. Measured the two most recent reads: the *speech* is
+170–187 wpm (normal-to-brisk) — the slowness is pauses (10–11% of runtime) and,
+fundamentally, taste. CSM has no speed control, so pace is a playback concern:
+added a speed controller to the CLI player on `afplay -r RATE -q 1`
+(high-quality pitch-preserving rate scaling). `+`/`-` in the player steps
+0.1× (0.5–2×, live — restarts afplay at the current position via the seek-slice
+mechanism); `/speed ` sets it from the input screen; the rate shows next to
+the progress bar when ≠1× and persists to `~/.readback/cli.json` (`speed`).
+⚠ `player.ts`'s `elapsed` now advances at `rate ×` wall time (audio position,
+not wall clock) so seek slices and the synced transcript stay aligned.
+
+**Verified** via tmux-driven CLI (drive-cli): `/speed` show + set + persist;
+`+`/`-` mid-play (afplay respawns with `-r 1.3`/`-r 1.1`, indicator updates);
+pause/resume/seek/transcript all correct at non-1× rates; clean quit, no
+orphaned afplay. `tsc --noEmit` clean; binary rebuilt.
+
+---
+
+## 2026-07-02 — PR #21 review fixes: ceiling trim, word-count threading, fast chunk band, fragment drop
+
+**Status: done** — branch `fix/summary-padding-short-articles`. A multi-angle
+review of PR #21 (time named the most important factor) surfaced four issues;
+all fixed on the branch:
+
+1. **Chunk band [120, 200] → [280, 400]** (`speak.py`). Measured on a
+ ~2,100-word text: the low band produced ~100 chunks vs 40 at the old fixed
+ 400 (2.5-3x the CSM prefills — +60-120s on a Full-mode read at 1-2s/prefill);
+ the PR's own 104.9s→82.7s synth "win" came from the summary shrinking, not
+ the chunking. Verified randomization itself is free (fixed 200 vs randomized
+ [120, 200] chunk identically — boundaries dominate), so the cadence variation
+ and per-chunk expression are kept; only the band moved. Post-fix: 52-58
+ chunks on the same text.
+2. **250-word ceiling enforced in code** (`_trim_to_word_ceiling`,
+ `summarize.py`). The prompt's HARD LIMIT alone still measured 313 words from
+ a 3,446-word source; the trim clips at a sentence boundary at
+ `SUMMARY_WORD_CEILING` (exported from `tones.py`, single source of truth),
+ always keeping ≥1 sentence. Closes the ROADMAP overshoot item — and every
+ trimmed word is synthesis time saved.
+3. **Map-reduce length anchor fixed** (`source_words` threading,
+ `summarize.py`). The reduce step passed the joined digests as `body`, so the
+ new word-count anchor measured the compressed digests instead of the source —
+ mis-calibrated on exactly the long inputs map-reduce exists for. The original
+ `article.word_count` now rides through `_map_reduce` (and its recursion) into
+ every `_summarize_once`.
+4. **Fragment drop fixed** (`chunk_text`, `speak.py`). CONFIRMED by the review:
+ a sub-`_MIN_CHARS` buf ("Wow!") followed by a sentence overflowing a low
+ random cap was silently discarded — lost in ~21% of runs (415/2000) on the
+ [120, 200] band. Now carried into the next piece (or emitted as its own tiny
+ chunk if that would exceed `_MAX_CHARS`). Also added `_hard_split`: a
+ comma-free run > `_MAX_CHARS` (which risked the 20s `max_audio_length_ms`
+ mid-sentence cutoff) now space-splits under the cap.
+
+Cleanups from the same review: the duplicated length-policy prose in the two
+tone prompts hoisted into `_LENGTH_RULES`/`_PLAIN_PROSE_RULE` (`.format`-ed per
+tone, one source of truth); `_summarize_once` uses the threaded source count
+instead of re-deriving `Article.word_count`; stale "~400 chars" line in
+ARCHITECTURE.md refreshed.
+
+**Verified.** 44/44 pytest (6 new: 2 chunking regressions + 4 ceiling-trim).
+Empirical: "Wow!" retained in 2000/2000 runs (was ~79%); ~2,100-word text
+chunks 52-58 (was ~100); tone prompts render with no leftover `{src}`/`{out}`
+placeholders. Docs synced: CLAUDE.md, config.yaml guide, ARCHITECTURE.md,
+ROADMAP.md, TESTS.md.
+
+---
+
+## 2026-07-02 — Merge `optimize/summary-map-reduce-threshold`, verify on a real slow read
+
+**Status: done** — branch `fix/summary-padding-short-articles`. User reported a
+real CLI read ("Chasing a Phantom Jump", 3,446 words / 20,701 chars) took 140.4s
+and asked whether that was worth it. Investigation found `fix/summary-padding-
+short-articles` had branched off `main` *before* the `summary_max_chars`
+16000→60000 fix (see that entry below) landed, so it was still on the old
+16,000-char threshold — this 20,701-char article just barely exceeded it and
+needlessly map-reduced. Worse, the map-reduce path's final reduce step anchors
+its word-count target off the *combined digest* length (not the original
+article), so it also blew through the 250-word hard ceiling (403 words).
+
+**Fix.** Merged `optimize/summary-map-reduce-threshold` into this branch
+(one conflict, in this file, both branches adding entries at the top — resolved
+by keeping both, correctly ordered).
+
+**Verified** — full pipeline, same URL, before vs after the merge:
+
+| | before (16K threshold, map-reduce) | after (60K threshold, single-pass) |
+|---|---|---|
+| summarize | 34.6s | 13.4s |
+| summary length | 403 words (over the 250 ceiling) | 313 words (better, still slightly over) |
+| synthesize | ~104.9s (est. from the original 140.4s total) | 82.7s |
+| **total** | **140.4s** | **97.0s** (31% faster) |
+
+Noted, not chased further: 313 words is closer to the 250-word ceiling than
+403 but still exceeds it — the word-count-anchor fix from the padding entry
+helps but isn't fully reliable on longer single-pass inputs; a candidate for a
+future follow-up. Full `pytest` suite: 38/38 pass after the merge.
+
+---
+
+## 2026-07-02 — Revert precision to bf16 (keep the 200-char / dynamic chunking)
+
+**Status: done** — branch `fix/summary-padding-short-articles`. Follow-up to the
+Max-quality preset entry below: user asked to stick with `bf16` after all.
+Reverted `config.yaml`'s `tts.csm.precision` back to `bf16` — per the engine's
+own docs, bf16 has no audible quality loss at normal listening (its only
+downside is a nonexistent one here, since the docs already say fp32 is for
+fixing audible artifacts on a clone voice, not a baseline upgrade). The
+`_MAX_CHARS: 200` + randomized chunk-boundary changes from the two entries below
+are unaffected — those are what actually drove the voice-quality/expression
+improvements the user asked for; the precision knob was the one piece of that
+change to walk back. Updated the speed/quality guide comments in `config.yaml`
+and `CLAUDE.md` to describe the current combination (bf16 + 200/randomized) as
+its own row rather than reusing the "Max quality" fp32 label. Full `pytest`
+suite: 38/38 pass.
+
+---
+
+## 2026-07-02 — Randomize chunk boundaries instead of a fixed cap
+
+**Status: done** — branch `fix/summary-padding-short-articles`. Follow-up to the
+Max-quality preset switch above: user asked to make chunk sizing dynamic and
+keep varying it, rather than every chunk hitting the same fixed 200-char cap.
+A uniform cap means every chunk is close to the same length, which reads with a
+mechanical, same-every-time breath cadence — real speech doesn't chunk that
+evenly.
+
+**Fix.** `chunk_text` (`speak.py`) now draws each chunk's actual cap fresh via
+`_next_chunk_cap()`, a `random.randint` between a new `_MIN_CHUNK_CHARS` (120)
+and the existing `_MAX_CHARS` (200) — redrawn every time a new chunk starts, so
+consecutive chunks vary in length instead of all targeting the same number. The
+over-long-sentence comma-split safety net still checks against the fixed
+`_MAX_CHARS`, not the random per-chunk cap, since that's a hard ceiling, not a
+pacing choice. This also compounds with the per-chunk `_expressive_temperature`
+feature: a shorter random cap is more likely to isolate a single sentence into
+its own chunk (and its own temperature) instead of merging it with a
+differently-toned neighbor.
+
+**Verified.** Ran `chunk_text` 3x on the same fixed input text: got 3 chunks
+(lengths 152/82/164) on runs 1 and 3, 4 chunks (75/76/82/164) on run 2 —
+confirmed genuinely different boundaries across runs, not just different
+content. Existing chunking tests (`test_chunk_text.py`) don't assert exact
+chunk boundaries, only bounds (`len(c) <= _MAX_CHARS`) and paragraph-splitting
+behavior, both of which hold regardless of which cap was drawn; ran them 20x in
+a loop to rule out flakiness from the new randomness — stable every time. Full
+`pytest` suite: 38/38 pass.
+
+---
+
+## 2026-07-02 — Switch to the Max-quality synthesis preset (fp32 + 200-char chunks)
+
+**Status: done** — branch `fix/summary-padding-short-articles`. After reviewing
+the per-chunk expression change, user asked to pick "the best version" for voice
+quality and summary accuracy, explicitly accepting a slighter delay in exchange.
+The already-documented "Max quality" preset in `config.yaml`'s speed/quality
+guide (`tts.csm.precision: fp32` + `_MAX_CHARS: 200` in `speak.py`) was exactly
+this tradeoff, previously left off by default in favor of "Fast" (`bf16` / 400).
+Switched both: `config.yaml` → `precision: "fp32"`, `speak.py` → `_MAX_CHARS = 200`.
+
+Smaller chunks are a direct win for the expressiveness feature from the prior
+entry too — `_expressive_temperature` nudges the *whole* chunk from whichever
+punctuation rule matches, so more, shorter chunks means the nudge tracks the
+actual sentence that earned it instead of averaging over a bigger span.
+
+**Verified** — same dramatic test paragraph as the expression-feature entry
+(measured intro → exclamation → question → dense factual close), regenerated
+under the new preset:
+
+| | 400 chars / bf16 (before) | 200 chars / fp32 (after) |
+|---|---|---|
+| chunks | 2 | 5 |
+| temperatures | 0.88, 0.77 | 0.77, 0.88, 0.84, 0.80, 0.80 |
+
+The calm opening sentence, which previously got dragged into the same chunk (and
+temperature) as the exclamation that followed it, now gets its own chunk and its
+own measured 0.77 — the finer resolution the "known limitation" note in the
+prior entry called out. Synth wall time for this short sample went from ~2 chunks
+worth to 26.6s for 39.4s of audio (5 chunks) — the expected "up to ~2×" cost of
+the Max-quality preset, accepted per the user's explicit trade-off call. Played
+both samples back with `afplay` for a live A/B. Full `pytest` suite: 38/38 pass.
+
+---
+
+## 2026-07-02 — Content-driven expression: per-chunk delivery temperature
+
+**Status: done** — branch `fix/summary-padding-short-articles` (extends the tone
+prompt work from the padding fix above). User asked for a more natural, human
+delivery where expression shifts with the content instead of one flat register
+for the whole read. CSM has no direct emotion/prosody control API — the only
+delivery lever it exposes is sampling temperature (`tts.csm.temperature`), which
+was previously set **once per read** from the tone (`ARTICLE` 0.8 / `BOOK` 0.6)
+and held fixed for every chunk.
+
+**Fix.** `_expressive_temperature(chunk, base)` in `speak.py` nudges the tone's
+base temperature per chunk from its punctuation — a real, trained-on prosody
+signal: `!` → +0.08 (emphatic), `?` → +0.04 (questioning), 3+ commas with
+neither → −0.03 (dense/measured), clamped to `[0.55, 0.95]` (below ~0.55 a short
+clone reference destabilizes, per existing voice-cloning notes). `synthesize_article`
+takes a new `base_temperature` param and calls `synth.set_temperature(...)`
+per chunk instead of once up front; `server.py` passes `tone.temperature` through
+instead of setting it before the loop. Also nudged both tone `summary_system`
+prompts (`tones.py`) to write with natural spoken rhythm — varied sentence
+length, real emphasis on a genuinely notable point — rather than a flat,
+even-register list of facts, since Summary mode's generated text is the other
+lever available for varying expression (Full mode reads the source verbatim,
+so only its own natural punctuation drives this).
+
+**Verified.** Real `Synthesizer` (no stub): a mixed-punctuation paragraph split
+into 2 chunks got temperatures 0.88 (a chunk with `!`/`?`, lively section) and
+0.77 (a comma-dense explanatory chunk, no `!`/`?`) from a base of 0.8 — confirmed
+real variation across chunks. Full `pytest` suite: 38/38 pass (the existing
+`_FakeSynth` test stub never receives `base_temperature`, so `set_temperature`
+is never called on it — no behavior change for those tests).
+
+**Known limitation.** Granularity is **per chunk (~400 chars), not per
+sentence** — `chunk_text`'s merge-up-to-400-chars behavior (tuned for synthesis
+speed) can fold a lively and a measured sentence into one chunk, which then gets
+whichever punctuation rule matches first. Finer per-sentence expression is
+possible by lowering `_MAX_CHARS` (already a documented, config-driven
+speed/quality tradeoff in `config.yaml`) at the cost of more, slower chunks —
+left as a follow-up rather than silently reintroducing the synthesis slowdown
+the July 2026 tuning pass removed.
+
+---
+
+## 2026-07-02 — Stop Summary mode padding short articles with invented filler
+
+**Status: done** — branch `fix/summary-padding-short-articles`. Reviewing a real
+library read (Android developer blog, 189-word source) surfaced a content-quality
+bug: the spoken summary came out **243 words — longer than the source** — and
+contained generic, ungrounded wrap-up sentences ("these changes represent a
+significant shift toward industry-wide safety standards", "protect users from
+unverified or malicious applications") that weren't in the article at all. The
+`ARTICLE`/`BOOK` `summary_system` prompts (`pipeline/tones.py`) said "don't pad"
+but only gave the model an upper bound (250 words) with no sense of what "short"
+meant for a given source, so it drifted toward the ceiling regardless of input
+length.
+
+**Fix.** `_summarize_once` (`summarize.py`) now spells out the source's word
+count in the user prompt (`"Article (189 words): ..."`) as a concrete anchor.
+Both tone prompts were rewritten to target roughly half the source's word count,
+explicitly frame 250 words as a ceiling reserved for genuinely long sources, and
+forbid wrap-up/editorializing sentences not grounded in a specific fact from the
+source.
+
+**Verified** — same 189-word Android article, direct `summarize_article` calls
+against the live LLM:
+
+| | before | after |
+|---|---|---|
+| summary length | 243 words (padded, longer than source) | 188 words, zero invented claims |
+
+Checked the fix doesn't regress long-form coverage: the 5,240-word "Haiku"
+Wikipedia article still produces a 280-word summary (near the ceiling, as
+intended for content-rich sources) rather than being clipped short. Full
+`pytest` suite: 38/38 pass.
+
+---
+
+## 2026-07-02 — Raise `summary_max_chars` 16000 → 60000 (stop unnecessary map-reduce)
+
+**Status: done** — branch `optimize/summary-map-reduce-threshold`. Summary mode's
+single-pass/map-reduce threshold (`reader.summary_max_chars`) was 16,000 chars
+(~4K tokens) — ~60x more conservative than the configured summary LLM
+(`mlx-community/Qwen3.5-9B-4bit`, 262,144-token context, confirmed via its HF
+`config.json`). Most long-form web articles were paying for 3-4 sequential
+`oneshot` calls (map digests generated then discarded once merged) when one call
+would do. Raised the default to 60,000 chars (~15K tokens, still <2% of the
+model's context) in `config.yaml`, `config.pi.example.yaml`,
+`ReaderConfig.summary_max_chars`, and `summarize_article`'s default param.
+Map-reduce itself is unchanged — it still exists for genuinely huge inputs
+(book scans) — and since the same value sizes each map batch, anything that
+still map-reduces now does so in fewer, larger batches too.
+
+**Verified — live before/after read** (Wikipedia "Haiku" article, 33,143 chars /
+5,240 words, Summary mode, cache cleared between runs so both timings are cold):
+
+| | before (map-reduce, 3 sections) | after (single-pass) |
+|---|---|---|
+| summarize | 51.3s | 13.4s |
+| synthesize | 50.0s | 57.6s |
+| **total conversion** | **102.2s** | **72.0s** (~30% faster) |
+
+Summary quality/length unaffected (227-word spoken summary either way — the tone
+prompt + `max_tokens` ceiling governs that, untouched by this change). Also
+confirmed map-reduce still triggers correctly above the new threshold: a
+225,900-char synthetic document packed into 4 batches and produced a coherent
+207-word summary in 39.8s. Full `pytest` suite: 38/38 pass.
+
+**Merged into `fix/summary-padding-short-articles` on 2026-07-02**, after this
+threshold gap was caught reviewing a real read that unnecessarily map-reduced
+(20,701 chars, just over the old 16,000 cutoff) — see the merge note in that
+branch's most recent entry above for the concrete before/after.
+
+---
+
## 2026-06-24 — Faster synthesis + CLI generation timer + venv auto-detect
**Status: done** — branch `optimisation`. Synthesis speed tuning: default
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 94da269..a167f46 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -11,6 +11,32 @@ intentionally lower priority.
## Recently shipped
+- **CLI playback speed** — `/speed ` + live `+`/`-` in the player (0.5–2×,
+ 0.1 steps, persisted). Pitch-preserving via `afplay -r -q 1`; CSM has no
+ native speed control, so pace lives at playback. Works on library replays;
+ transcript sync and seek stay aligned at any rate.
+- **Content-driven expression + summary content fixes** — Summary mode no
+ longer pads short articles with invented, ungrounded filler (word-count
+ anchor in `_summarize_once`); CSM delivery temperature now nudges per chunk
+ from punctuation instead of staying flat for the whole read
+ (`_expressive_temperature`); chunk boundaries are randomized within a range
+ instead of a fixed cap, for a less mechanical breath cadence. See
+ `docs/PLAN.md` 2026-07-02 entries for verification detail.
+- **Review fixes: 250-word ceiling enforced in code, chunk band moved to the
+ fast end** — the PR review measured the [120, 200] chunk band at 2.5-3x the
+ chunks (= CSM prefills = time) of the old fixed 400 cap, so the band is now
+ [280, 400] (randomized cadence kept — randomization itself adds ~zero chunks);
+ the summary ceiling is hard-enforced by a sentence-boundary trim
+ (`_trim_to_word_ceiling`) instead of prompt-only; the map-reduce reduce step
+ now anchors its length target to the original source's word count, not the
+ digests'; a mid-paragraph fragment-drop bug in `chunk_text` (short exclamations
+ lost ~21% of the time under low random caps) is fixed and regression-tested.
+- **Summary mode: raised the map-reduce threshold 16K → 60K chars** — the
+ configured summary LLM (Qwen3.5-9B) has a 262K-token context, so the old
+ 16,000-char cutoff forced most long-form articles through 3-4 sequential LLM
+ calls when one pass would do. Verified live: a 33K-char article's conversion
+ time dropped from 102.2s → 72.0s (~30% faster); summarize alone dropped
+ 51.3s → 13.4s. Map-reduce is unchanged for genuinely huge inputs (book scans).
- **Summary/audio speedup — disabled LLM chain-of-thought** 🏁 _key milestone_.
Qwen3.5 defaulted to thinking and spent its whole token budget on an untagged
"Thinking Process:" monologue — slow, truncated before the real answer, and
@@ -49,6 +75,7 @@ intentionally lower priority.
- [x] Degenerate-chunk guard — an all-silence chunk triggers one retry before being dropped
- [x] LoRA fine-tune for higher fidelity — full pipeline ready (transcribe → convert → train → load adapter); add clips to `src/finetune/data/` to train. See [`src/finetune/README.md`](../src/finetune/README.md)
- [ ] More reading voices — A/B and expose the built-in read-speech references beyond the two defaults + `codeword`; eventually clone a new voice from the CLI instead of editing `config.yaml`
+- [x] Summary length still overshoots the 250-word hard ceiling on longer single-pass articles — resolved by `_trim_to_word_ceiling` (`summarize.py`): a post-hoc sentence-boundary trim at `SUMMARY_WORD_CEILING`, so the prompt's HARD LIMIT is now backed by code. The trim also caps synthesis time (overshoot words were paid for again in TTS).
## ⚡ CLI — tuning & performance — priority
diff --git a/docs/TESTS.md b/docs/TESTS.md
index f37a4a1..8b071c3 100644
--- a/docs/TESTS.md
+++ b/docs/TESTS.md
@@ -1,6 +1,6 @@
# Test Coverage
-38 tests across 8 files. Pure logic only — no MLX, no CSM, no GPU. Runs on
+44 tests across 9 files. Pure logic only — no MLX, no CSM, no GPU. Runs on
Linux (CI) and macOS (local). Config in `pyproject.toml`
(`[tool.pytest.ini_options]`).
@@ -14,7 +14,7 @@ pytest -k "cache" # run by keyword
## Pipeline — `speak.py`
-### Chunking (`test_chunk_text.py` — 4 tests)
+### Chunking (`test_chunk_text.py` — 6 tests)
| Test | What it guards |
|------|----------------|
@@ -22,6 +22,8 @@ pytest -k "cache" # run by keyword
| `sentences_merge_up_to_max` | Sentences pack into ≤ `_MAX_CHARS` chunks |
| `paragraph_boundary_forces_a_split` | `\n` between paragraphs = new chunk |
| `overlong_sentence_splits_on_commas` | Sentences > `_MAX_CHARS` split on `,` |
+| `short_fragment_is_never_dropped` | Sub-`_MIN_CHARS` fragment ("Wow!") survives any random cap draw |
+| `comma_free_overlong_sentence_is_hard_split` | Comma-free run > `_MAX_CHARS` splits on spaces, never over-cap |
### Synthesis (`test_speak.py` — 3 tests)
@@ -66,6 +68,15 @@ pytest -k "cache" # run by keyword
| `oversize_paragraph_falls_back_to_sentences` | Paragraph > cap splits on sentence boundaries |
| `giant_single_sentence_is_hard_cut` | No sentence boundary → hard character cut |
+### Summary ceiling trim (`test_summary_trim.py` — 4 tests)
+
+| Test | What it guards |
+|------|----------------|
+| `short_summary_is_untouched` | Under-ceiling text passes through unchanged |
+| `overshoot_is_trimmed_at_a_sentence_boundary` | Over-ceiling text clipped ≤ `SUMMARY_WORD_CEILING`, on a sentence boundary |
+| `single_giant_sentence_is_kept` | Trim always keeps ≥ 1 sentence |
+| `custom_ceiling` | Explicit `ceiling` param respected |
+
---
## LLM — `client.py`
diff --git a/pyproject.toml b/pyproject.toml
index 6f7cf50..540e15e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "readback"
-version = "4.1.0"
+version = "4.2.0"
description = "Local offline article & book reader: URL or image → text → audio, via CSM-1B TTS + MLX on Apple Silicon"
requires-python = ">=3.10,<3.13"
authors = [{ name = "mks" }]
diff --git a/src/cli/README.md b/src/cli/README.md
index a2bc303..912eb15 100644
--- a/src/cli/README.md
+++ b/src/cli/README.md
@@ -70,6 +70,7 @@ esc cancels a running read.
| `/vision [name]` | List downloaded MLX vision models / set the image-OCR model (persisted) |
| `/mode [full\|summary]` | Show / set the read mode (persisted) |
| `/library` (or `/lib`) | Browse past reads — arrow keys, Enter to replay, `d` to delete |
+| `/speed [x]` | Show / set playback speed, 0.5–2 (persisted; also `+`/`-` in the player) |
| `/help` | List commands |
| `/quit` | Exit (or press `q` when the input field is empty) |
@@ -84,7 +85,7 @@ OCR model (no recommendation marker; OCR has no single "best").
-Prefs (voice/mode/model/visionModel) persist to `~/.readback/cli.json`.
+Prefs (voice/mode/model/visionModel/speed) persist to `~/.readback/cli.json`.
### Library
@@ -111,6 +112,7 @@ same machine, download into `~/.readback/cli-cache/` otherwise.
|---|---|
| `space` | Pause / resume (replays when finished) |
| `←` / `→` | Seek back / forward 5 s |
+| `+` / `-` | Playback speed ±0.1× (0.5–2×, pitch preserved, persisted) |
| `t` | Toggle transcript (Summary mode only) |
| `q` / `esc` | Back to the URL input |
@@ -124,6 +126,11 @@ same machine, download into `~/.readback/cli-cache/` otherwise.
- Pause/resume kills and restarts `afplay` at the saved position (via WAV
slicing), so there's a brief (~50 ms) silence on resume. This replaced the
old SIGSTOP/SIGCONT approach which caused audible buffer bleed.
+- **Speed** rides on `afplay -r` with high-quality pitch-preserving rate
+ scaling (`-q 1`) — CSM itself has no speed control, so pace is a playback
+ concern. The current rate shows next to the progress bar when it isn't 1×,
+ applies to library replays too, and changing it mid-play restarts at the
+ current position (same slice trick as seek).
## Caveats
diff --git a/src/cli/package.json b/src/cli/package.json
index 5056060..583353f 100644
--- a/src/cli/package.json
+++ b/src/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "readback-cli",
- "version": "4.1.0",
+ "version": "4.2.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/src/cli/src/app.tsx b/src/cli/src/app.tsx
index bbdc160..fcc61b1 100644
--- a/src/cli/src/app.tsx
+++ b/src/cli/src/app.tsx
@@ -6,7 +6,7 @@ import { existsSync, mkdirSync } from "node:fs";
import type { ServerHandle } from "./server";
import { ReadbackSocket, type DoneMsg, type ServerMsg } from "./ws";
import * as player from "./player";
-import type { PlayerSnapshot } from "./player";
+import { MIN_RATE, MAX_RATE, type PlayerSnapshot } from "./player";
import { savePrefs, type Prefs } from "./prefs";
import { DIM, RED } from "./theme";
import { Header } from "./components/Header";
@@ -92,7 +92,7 @@ function reducer(state: State, action: Action): State {
result: action.result,
wavPath: action.wavPath,
showTranscript: false,
- player: { state: "playing", elapsed: 0 },
+ player: { ...state.player, state: "playing", elapsed: 0 },
};
case "error":
return { ...state, screen: "input", error: action.message, notice: null, modelList: null };
@@ -163,7 +163,7 @@ function reducer(state: State, action: Action): State {
// Recognized slash commands — keep in sync with handleCommand's switch. Used to
// tell a command (`/model …`) from a local source path (`/Users/…`) at submit.
const KNOWN_COMMANDS = new Set([
- "help", "quit", "exit", "mode", "voice", "model", "vision", "library", "lib",
+ "help", "quit", "exit", "mode", "voice", "model", "vision", "library", "lib", "speed",
]);
const SPIN_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -221,7 +221,7 @@ export function App({ handle, prefs, onQuit }: Props) {
progress: null,
result: null,
wavPath: "",
- player: { state: "stopped" as const, elapsed: 0 },
+ player: { state: "stopped" as const, elapsed: 0, rate: prefs.speed ?? 1 },
showTranscript: false,
voice: prefs.voice && voiceIds.includes(prefs.voice) ? prefs.voice : cfg.voice,
mode: prefs.mode ?? cfg.default_mode,
@@ -270,6 +270,7 @@ export function App({ handle, prefs, onQuit }: Props) {
sockRef.current = sock;
sock.connect().catch(() => dispatch({ type: "error", message: "could not connect to /ws" }));
player.onPlayerChange((snap) => dispatch({ type: "player", snap }));
+ if (prefs.speed) player.setRate(prefs.speed);
return () => {
player.onPlayerChange(null);
sock.close();
@@ -291,7 +292,14 @@ export function App({ handle, prefs, onQuit }: Props) {
mode: "full" | "summary",
model: string,
visionModel: string = stateRef.current.visionModel,
- ) => savePrefs({ voice, mode, model, visionModel });
+ ) => savePrefs({ voice, mode, model, visionModel, speed: player.getRate() });
+
+ // Player speed controller (+/- in the player, /speed on the input screen).
+ const applySpeed = (r: number) => {
+ player.setRate(r);
+ const s = stateRef.current;
+ savePrefs({ voice: s.voice, mode: s.mode, model: s.model, visionModel: s.visionModel, speed: player.getRate() });
+ };
const fetchModels = async (): Promise => {
const res = await fetch(handle.base + "/api/models");
@@ -414,6 +422,20 @@ export function App({ handle, prefs, onQuit }: Props) {
case "lib":
openLibrary();
break;
+ case "speed": {
+ if (!arg) {
+ dispatch({ type: "notice", text: `speed is ${player.getRate()}× — /speed 0.5–2 (also +/- in the player)` });
+ break;
+ }
+ const r = Number(arg.replace(/x$/i, ""));
+ if (!Number.isFinite(r) || r < MIN_RATE || r > MAX_RATE) {
+ dispatch({ type: "error", message: `speed must be ${MIN_RATE}–${MAX_RATE} (e.g. /speed 1.2)` });
+ break;
+ }
+ applySpeed(r);
+ dispatch({ type: "notice", text: `speed → ${player.getRate()}×` });
+ break;
+ }
default:
dispatch({ type: "error", message: `unknown command /${cmd} — /help` });
}
@@ -510,6 +532,7 @@ export function App({ handle, prefs, onQuit }: Props) {
}}
onToggleTranscript={() => dispatch({ type: "toggleTranscript" })}
onSeek={(delta) => player.seek(delta)}
+ onSpeed={(delta) => applySpeed(player.getRate() + delta)}
onBack={() => {
player.stop();
dispatch({ type: "back" });
diff --git a/src/cli/src/components/HelpView.tsx b/src/cli/src/components/HelpView.tsx
index 0d98ec9..e548944 100644
--- a/src/cli/src/components/HelpView.tsx
+++ b/src/cli/src/components/HelpView.tsx
@@ -13,12 +13,14 @@ const CMDS: Array<[cmd: string, desc: string]> = [
["/mode full", "read the whole article"],
["/mode summary", "spoken summary (local LLM)"],
["/lib", "browse past reads"],
+ ["/speed ", "playback speed 0.5–2 (persists)"],
["/quit", "exit"],
];
const KEYS: Array<[key: string, desc: string]> = [
["space", "pause / resume"],
["← →", "seek ±5s"],
+ ["+ -", "speed ±0.1×"],
["t", "toggle transcript"],
["q", "back"],
];
diff --git a/src/cli/src/components/PlayerView.tsx b/src/cli/src/components/PlayerView.tsx
index 9c8a022..6c5383c 100644
--- a/src/cli/src/components/PlayerView.tsx
+++ b/src/cli/src/components/PlayerView.tsx
@@ -6,6 +6,7 @@ import type { PlayerSnapshot } from "../player";
import { BLUE, DIM, FG } from "../theme";
const SEEK_STEP_SEC = 5;
+const SPEED_STEP = 0.1;
const TRANSCRIPT_MAX_LINES = 12;
function fmt(sec: number): string {
@@ -105,6 +106,7 @@ interface Props {
onTogglePause: () => void;
onToggleTranscript: () => void;
onSeek: (deltaSec: number) => void;
+ onSpeed: (delta: number) => void;
onBack: () => void;
}
@@ -116,6 +118,7 @@ export function PlayerView({
onTogglePause,
onToggleTranscript,
onSeek,
+ onSpeed,
onBack,
}: Props) {
const { stdout } = useStdout();
@@ -127,6 +130,8 @@ export function PlayerView({
if (input === " ") onTogglePause();
else if (key.leftArrow) onSeek(-SEEK_STEP_SEC);
else if (key.rightArrow) onSeek(SEEK_STEP_SEC);
+ else if (input === "+" || input === "=") onSpeed(SPEED_STEP);
+ else if (input === "-" || input === "_") onSpeed(-SPEED_STEP);
else if (input === "t" && result.text) onToggleTranscript();
else if (input === "q" || key.escape) onBack();
});
@@ -158,6 +163,7 @@ export function PlayerView({
{"─".repeat(Math.max(0, barWidth - filled))}
{fmt(total)}
+ {player.rate !== 1 && {player.rate}×}
{result.text && showTranscript && (
@@ -171,6 +177,8 @@ export function PlayerView({
space {player.state === "finished" ? "replay" : "pause/resume"}
{" · "}
←/→ ±{SEEK_STEP_SEC}s
+ {" · "}
+ +/- speed
{result.text && (
<>
{" · "}
diff --git a/src/cli/src/player.ts b/src/cli/src/player.ts
index 589e718..539bfdd 100644
--- a/src/cli/src/player.ts
+++ b/src/cli/src/player.ts
@@ -1,10 +1,17 @@
-// afplay wrapper — module singleton outside React. Pause/resume via
-// SIGSTOP/SIGCONT (afplay has no transport control); elapsed time is
-// wall-clock tracked here because afplay reports nothing.
+// afplay wrapper — module singleton outside React. Pause kills afplay and
+// resume restarts from the recorded position (afplay has no transport
+// control); elapsed time is wall-clock tracked here because afplay reports
+// nothing.
//
// Seeking: afplay can't start at an offset, so seek() slices the PCM data of
// the local WAV at the target byte offset into a temp file and relaunches
// afplay on that. Rapid seeks are debounced; the UI clock jumps immediately.
+//
+// Speed: afplay -r RATE with -q 1 (high-quality, pitch-preserving rate
+// scaling). `elapsed` tracks AUDIO position, not wall time — at 1.2× it
+// advances 1.2 audio-seconds per wall-second, so seek slices and the synced
+// transcript stay aligned. Rate changes mid-play restart at the current
+// position via the same slice mechanism seek uses.
import { openSync, readSync, closeSync, createReadStream, createWriteStream } from "node:fs";
import { tmpdir } from "node:os";
@@ -14,9 +21,13 @@ export type PlayerState = "playing" | "paused" | "stopped" | "finished";
export interface PlayerSnapshot {
state: PlayerState;
- elapsed: number; // seconds
+ elapsed: number; // audio seconds (advances at `rate` × wall time)
+ rate: number; // playback speed, 1 = natural
}
+export const MIN_RATE = 0.5;
+export const MAX_RATE = 2.0;
+
type Listener = (snap: PlayerSnapshot) => void;
const SEEK_FILE = join(tmpdir(), `readback-seek-${process.pid}.wav`);
@@ -25,6 +36,7 @@ const SEEK_DEBOUNCE_MS = 180;
let proc: ReturnType | null = null;
let state: PlayerState = "stopped";
let elapsed = 0;
+let rate = 1;
let timer: ReturnType | null = null;
let listener: Listener | null = null;
let generation = 0; // invalidates exit handlers from superseded plays
@@ -35,7 +47,7 @@ let seeking = false; // freezes the clock while a seek restart is in flight
let seekTimer: ReturnType | null = null;
function emit(): void {
- listener?.({ state, elapsed });
+ listener?.({ state, elapsed, rate });
}
function clearTimer(): void {
@@ -47,7 +59,7 @@ function startTimer(): void {
clearTimer();
timer = setInterval(() => {
if (state === "playing" && !seeking) {
- elapsed = Math.min(elapsed + 0.25, currentDur);
+ elapsed = Math.min(elapsed + 0.25 * rate, currentDur);
emit();
}
}, 250);
@@ -61,7 +73,8 @@ function killProc(): void {
}
function spawnAfplay(path: string, gen: number): void {
- proc = Bun.spawn(["afplay", path], { stdout: "ignore", stderr: "ignore" });
+ const args = rate !== 1 ? ["-r", String(rate), "-q", "1"] : [];
+ proc = Bun.spawn(["afplay", ...args, path], { stdout: "ignore", stderr: "ignore" });
proc.exited.then(() => {
if (gen !== generation) return; // superseded by a newer play()/seek()/stop()
clearTimer();
@@ -78,6 +91,29 @@ export function onPlayerChange(fn: Listener | null): void {
listener = fn;
}
+export function getRate(): number {
+ return rate;
+}
+
+/** Set playback speed (clamped, rounded to 0.05). Takes effect immediately on
+ * a playing read by restarting afplay at the current position. */
+export function setRate(r: number): void {
+ const next = Math.round(Math.min(Math.max(r, MIN_RATE), MAX_RATE) * 20) / 20;
+ if (next === rate) return;
+ rate = next;
+ if (state === "playing") {
+ seeking = true;
+ emit();
+ if (seekTimer) clearTimeout(seekTimer);
+ seekTimer = setTimeout(() => {
+ seekTimer = null;
+ void restartAt(elapsed);
+ }, SEEK_DEBOUNCE_MS);
+ } else {
+ emit(); // paused/stopped — new rate applies on next spawn
+ }
+}
+
export function play(wavPath: string, durationSec: number): void {
stop();
currentWav = wavPath;
diff --git a/src/cli/src/prefs.ts b/src/cli/src/prefs.ts
index 489cd8a..3e736f0 100644
--- a/src/cli/src/prefs.ts
+++ b/src/cli/src/prefs.ts
@@ -7,6 +7,7 @@ export interface Prefs {
mode: "full" | "summary" | null;
model: string | null;
visionModel: string | null;
+ speed: number | null; // playback rate, 0.5–2 (afplay -r)
}
const PREFS_PATH = join(homedir(), ".readback", "cli.json");
@@ -20,12 +21,16 @@ export function loadPrefs(): Prefs {
mode: raw.mode === "full" || raw.mode === "summary" ? raw.mode : null,
model: typeof raw.model === "string" ? raw.model : null,
visionModel: typeof raw.visionModel === "string" ? raw.visionModel : null,
+ speed:
+ typeof raw.speed === "number" && raw.speed >= 0.5 && raw.speed <= 2
+ ? raw.speed
+ : null,
};
}
} catch {
// corrupt prefs file — fall through to defaults
}
- return { voice: null, mode: null, model: null, visionModel: null };
+ return { voice: null, mode: null, model: null, visionModel: null, speed: null };
}
export function savePrefs(prefs: Prefs): void {
diff --git a/src/dashboard/package.json b/src/dashboard/package.json
index f4272cd..0d72035 100644
--- a/src/dashboard/package.json
+++ b/src/dashboard/package.json
@@ -1,6 +1,6 @@
{
"name": "readback-dashboard",
- "version": "4.1.0",
+ "version": "4.2.0",
"private": true,
"type": "module",
"description": "readback library dashboard — search, sort & replay past reads (Vue 3 + Vite)",
diff --git a/src/readback/__init__.py b/src/readback/__init__.py
index 7039708..0fd7811 100644
--- a/src/readback/__init__.py
+++ b/src/readback/__init__.py
@@ -1 +1 @@
-__version__ = "4.1.0"
+__version__ = "4.2.0"
diff --git a/src/readback/config.py b/src/readback/config.py
index 2751742..e0eb927 100644
--- a/src/readback/config.py
+++ b/src/readback/config.py
@@ -93,8 +93,10 @@ class ReaderConfig(BaseModel):
output_dir: Path = Path("../readback-audio-db/audio")
default_mode: Literal["full", "summary"] = "full"
gap_sec: float = 0.18 # silence between (trimmed) chunks
- # Cap article text fed to the LLM in summary mode (keeps it within context).
- summary_max_chars: int = 16000
+ # Cap article text for a single LLM pass before map-reducing. Qwen3.5-9B has
+ # a 262K-token context, so 60K chars (~15K tokens) single-passes the vast
+ # majority of articles instead of map-reducing.
+ summary_max_chars: int = 60000
# SQLite library of past reads (powers the web dashboard). One file, stdlib
# sqlite3. Default sits in the sibling `readback-audio-db/` folder next to the
# repo; relative paths resolve against config.yaml's dir.
diff --git a/src/readback/pipeline/speak.py b/src/readback/pipeline/speak.py
index 5d66503..83505ea 100644
--- a/src/readback/pipeline/speak.py
+++ b/src/readback/pipeline/speak.py
@@ -6,6 +6,7 @@
from __future__ import annotations
import logging
+import random
import re
from typing import Callable, Optional
@@ -15,41 +16,97 @@
_SENTENCE_RE = re.compile(r"(?<=[.!?])\s+")
# Cap chars per TTS call — fewer, larger chunks = fewer CSM reference-prefills =
-# faster total synthesis. 400 stays well under CSM's 2048-token budget (the
-# _max_ms_for safety bound caps runaway generation per chunk).
+# faster total synthesis, but coarser prosody AND coarser expressive-temperature
+# granularity (_expressive_temperature nudges the WHOLE chunk from whichever
+# punctuation rule matches first, so a big chunk can bury a measured sentence
+# inside a livelier neighbor's chunk). 400 stays well under CSM's 2048-token
+# budget either way (the _max_ms_for safety bound caps runaway generation per
+# chunk).
#
# Speed vs prosody tradeoff:
# 400 — fast (fewer prefills, ~30% fewer chunks than 280)
# 280 — balanced (more natural sentence-boundary breaks)
-# 200 — max prosody (shortest chunks, best intonation, slowest)
+# 200 — max prosody + finest expressive-temperature granularity, ~2.5-3x the
+# chunk count (and prefills) of 400 — measured, too slow for Full mode
_MAX_CHARS = 400
+# Each chunk's actual cap is drawn fresh from [_MIN_CHUNK_CHARS, _MAX_CHARS]
+# instead of always hitting _MAX_CHARS — a fixed cap produces a mechanical,
+# same-length-every-time breath cadence; real speech doesn't chunk that
+# uniformly. This also gives _expressive_temperature more varied windows: a
+# short random cap is more likely to land on a single sentence (its own
+# temperature) rather than merging it with a differently-toned neighbor.
+# The band sits at the fast end ([280, 400], not [120, 200]) because chunk
+# count — and therefore prefill cost — tracks the band's mean: [120, 200]
+# measured 2.5-3x the chunks of a fixed 400 for the same text.
+_MIN_CHUNK_CHARS = 280
_MIN_CHARS = 8
+def _next_chunk_cap() -> int:
+ return random.randint(_MIN_CHUNK_CHARS, _MAX_CHARS)
+
+
+def _hard_split(piece: str) -> list[str]:
+ """Last-resort split for a comma-less run longer than _MAX_CHARS: break on
+ spaces into ≤_MAX_CHARS runs. An over-cap chunk risks hitting the engine's
+ max_audio_length_ms bound and getting cut off mid-sentence in the audio."""
+ if len(piece) <= _MAX_CHARS:
+ return [piece]
+ out: list[str] = []
+ cur = ""
+ for word in piece.split(" "):
+ if cur and len(cur) + 1 + len(word) > _MAX_CHARS:
+ out.append(cur)
+ cur = word
+ else:
+ cur = f"{cur} {word}".strip()
+ if cur:
+ out.append(cur)
+ return out
+
+
def chunk_text(text: str) -> list[str]:
- """Split article text into TTS-sized chunks: sentence-aware, merged up to
- ~_MAX_CHARS, paragraph boundaries respected (so prosody resets per para)."""
+ """Split article text into TTS-sized chunks: sentence-aware, merged up to a
+ per-chunk cap randomized within [_MIN_CHUNK_CHARS, _MAX_CHARS] (never above
+ _MAX_CHARS), paragraph boundaries respected (so prosody resets per para)."""
chunks: list[str] = []
for para in text.split("\n"):
para = para.strip()
if not para:
continue
buf = ""
+ cap = _next_chunk_cap()
for sent in _SENTENCE_RE.split(para):
sent = sent.strip()
if not sent:
continue
- # Hard-split an over-long single sentence on commas as a fallback.
+ # Hard-split an over-long single sentence on commas as a fallback,
+ # then on spaces if a comma-free run still exceeds the cap. Always
+ # measured against _MAX_CHARS (not the current random cap) — this
+ # is a hard safety split, not the pacing variation.
pieces = [sent]
if len(sent) > _MAX_CHARS:
- pieces = [p.strip() for p in re.split(r",\s+", sent) if p.strip()]
+ pieces = [q for p in re.split(r",\s+", sent) if p.strip()
+ for q in _hard_split(p.strip())]
for piece in pieces:
- if len(buf) + len(piece) + 1 <= _MAX_CHARS:
+ if len(buf) + len(piece) + 1 <= cap:
buf = (buf + " " + piece).strip()
+ elif len(buf) >= _MIN_CHARS:
+ chunks.append(buf)
+ buf = piece
+ cap = _next_chunk_cap()
+ elif len(buf) + len(piece) + 1 <= _MAX_CHARS:
+ # A sub-_MIN_CHARS buf ("Wow!") can't stand alone — carry it
+ # into this piece instead of dropping it (a low random cap
+ # made silent mid-paragraph drops reachable otherwise).
+ buf = (buf + " " + piece).strip()
+ cap = _next_chunk_cap()
else:
- if len(buf) >= _MIN_CHARS:
- chunks.append(buf)
+ # Piece is near the hard cap: emit the fragment as its own
+ # tiny chunk rather than drop it or exceed _MAX_CHARS.
+ chunks.append(buf)
buf = piece
+ cap = _next_chunk_cap()
if len(buf) >= _MIN_CHARS:
chunks.append(buf)
elif buf and chunks:
@@ -116,18 +173,41 @@ def _fade_out_tail(audio: np.ndarray, sr: int, fade_ms: int = 100) -> np.ndarray
return audio
+def _expressive_temperature(chunk: str, base: float) -> float:
+ """Nudge the delivery temperature for one chunk based on its punctuation, so
+ expression shifts with the content instead of staying flat for the whole
+ read. CSM exposes no direct emotion/prosody control — sampling temperature
+ (more variation in pitch/pacing at higher values) is the one delivery knob
+ it does have, and punctuation is a real signal the model was trained on, so
+ small, content-driven nudges here are the practical lever available.
+ Clamped to stay within CSM's stable range (below ~0.55 a short clone
+ reference can destabilize; above ~0.95 delivery gets erratic)."""
+ temp = base
+ if "!" in chunk:
+ temp += 0.08 # emphatic / lively line
+ elif "?" in chunk:
+ temp += 0.04 # questioning / curious line
+ elif chunk.count(",") >= 3:
+ temp -= 0.03 # dense, measured explanatory line
+ return max(0.55, min(0.95, temp))
+
+
def synthesize_article(
synth,
text: str,
*,
gap_sec: float = 0.18,
+ base_temperature: Optional[float] = None,
progress: Optional[Callable[[int, int], None]] = None,
should_stop: Optional[Callable[[], bool]] = None,
) -> np.ndarray:
"""Synthesize `text` chunk-by-chunk into one float32 buffer (engine sample
rate). Each chunk is silence-trimmed, then joined with a short uniform gap so
pacing is natural (no stacked CSM trailing-silence). `progress(done, total)`
- fires after each chunk; `should_stop()` aborts early (e.g. client gone)."""
+ fires after each chunk; `should_stop()` aborts early (e.g. client gone).
+ `base_temperature`, when given, is the reading tone's delivery temperature —
+ each chunk's actual temperature is nudged around it per `_expressive_temperature`
+ so delivery varies with content rather than staying uniform for the whole read."""
sr = synth.sample_rate
gap = np.zeros(int(gap_sec * sr), dtype=np.float32)
chunks = chunk_text(text)
@@ -138,6 +218,8 @@ def synthesize_article(
if should_stop is not None and should_stop():
log.info("synth aborted at chunk %d/%d", i + 1, len(chunks))
break
+ if base_temperature is not None:
+ synth.set_temperature(_expressive_temperature(chunk, base_temperature))
try:
audio = _tidy_silence(synth.synthesize(chunk), sr)
if audio.size == 0:
diff --git a/src/readback/pipeline/summarize.py b/src/readback/pipeline/summarize.py
index 8149ea6..0bee880 100644
--- a/src/readback/pipeline/summarize.py
+++ b/src/readback/pipeline/summarize.py
@@ -14,7 +14,7 @@
import re
from readback.pipeline.extract import Article
-from readback.pipeline.tones import ARTICLE as _ARTICLE_TONE
+from readback.pipeline.tones import ARTICLE as _ARTICLE_TONE, SUMMARY_WORD_CEILING
log = logging.getLogger("readback.pipeline")
@@ -74,18 +74,49 @@ def _batches(text: str, max_chars: int) -> list[str]:
return batches
-def _summarize_once(llm, title: str, body: str, system: str, truncated: bool = False) -> str:
- """Single spoken-explanation pass over `body` with the given `system` framing."""
+def _summarize_once(llm, title: str, body: str, system: str, truncated: bool = False,
+ source_words: int | None = None) -> str:
+ """Single spoken-explanation pass over `body` with the given `system` framing.
+
+ The word count is spelled out to the model as a concrete length anchor — an
+ abstract "don't pad" instruction wasn't enough on its own to stop the model
+ padding short articles out toward the 250-word ceiling (it has no sense of
+ "short" without a number to calibrate against). `source_words` overrides the
+ count when `body` isn't the original source: the map-reduce reduce step
+ passes condensed digests, and anchoring to *their* length would mis-calibrate
+ the target on exactly the long inputs map-reduce exists for."""
+ word_count = source_words if source_words is not None else len(body.split())
user = (
f"Title: {title}\n\n"
- f"Article{' (truncated)' if truncated else ''}:\n{body}\n\n"
+ f"Article{' (truncated)' if truncated else ''} ({word_count} words):\n{body}\n\n"
"Give the spoken explanation now."
)
return llm.oneshot(system, user).strip()
+def _trim_to_word_ceiling(text: str, ceiling: int = SUMMARY_WORD_CEILING) -> str:
+ """Hard-enforce the tone prompts' word ceiling at a sentence boundary. The
+ prompt's HARD LIMIT is advisory — the model still overshoots on long sources
+ (measured 313 words from a 3,446-word article) — and every word past the
+ ceiling is paid for again in synthesis time. Always keeps at least one
+ sentence."""
+ kept: list[str] = []
+ words = 0
+ for sent in _SENT_SPLIT.split(text.strip()):
+ n = len(sent.split())
+ if kept and words + n > ceiling:
+ break
+ kept.append(sent)
+ words += n
+ trimmed = " ".join(kept)
+ if len(trimmed) < len(text.strip()):
+ log.info("summary trimmed to the %d-word ceiling: %d -> %d words",
+ ceiling, len(text.split()), words)
+ return trimmed
+
+
def summarize_article(
- llm, article: Article, max_chars: int = 16000, progress=None, system: str | None = None,
+ llm, article: Article, max_chars: int = 60000, progress=None, system: str | None = None,
) -> str:
"""Produce a spoken-style summary/explanation of `article`.
@@ -100,26 +131,28 @@ def summarize_article(
if len(body) <= max_chars:
if progress:
progress(0, 1)
- summary = _summarize_once(llm, article.title, body, system)
+ summary = _summarize_once(llm, article.title, body, system,
+ source_words=article.word_count)
if progress:
progress(1, 1)
log.info("summary: %d words from %d-word article (single pass)",
len(summary.split()), article.word_count)
- return summary or article.text
+ return _trim_to_word_ceiling(summary) if summary else article.text
- summary = _map_reduce(llm, article.title, body, max_chars, system, progress=progress)
+ summary = _map_reduce(llm, article.title, body, max_chars, system, progress=progress,
+ source_words=article.word_count)
log.info("summary: %d words from %d-word article (map-reduce)",
len(summary.split()), article.word_count)
- return summary or article.text
+ return _trim_to_word_ceiling(summary) if summary else article.text
def _map_reduce(llm, title: str, body: str, max_chars: int, system: str,
- depth: int = 0, progress=None) -> str:
+ depth: int = 0, progress=None, source_words: int | None = None) -> str:
batches = _batches(body, max_chars)
if len(batches) == 1:
if progress:
progress(0, 1)
- out = _summarize_once(llm, title, batches[0], system)
+ out = _summarize_once(llm, title, batches[0], system, source_words=source_words)
if progress:
progress(1, 1)
return out
@@ -142,6 +175,10 @@ def _map_reduce(llm, title: str, body: str, max_chars: int, system: str,
# The combined digests may still overflow context (many batches) — recurse,
# depth-limited so a pathological input can't loop forever. The final reduce
# uses the tone's `system` framing; intermediate maps stay tone-agnostic.
+ # `source_words` (the ORIGINAL source's count, not the digests') rides along
+ # so the length anchor stays calibrated to what the listener actually asked
+ # to have summarized.
if len(combined) > max_chars and depth < _MAX_REDUCE_DEPTH:
- return _map_reduce(llm, title, combined, max_chars, system, depth + 1)
- return _summarize_once(llm, title, combined, system)
+ return _map_reduce(llm, title, combined, max_chars, system, depth + 1,
+ source_words=source_words)
+ return _summarize_once(llm, title, combined, system, source_words=source_words)
diff --git a/src/readback/pipeline/tones.py b/src/readback/pipeline/tones.py
index ee0c040..aa46a48 100644
--- a/src/readback/pipeline/tones.py
+++ b/src/readback/pipeline/tones.py
@@ -8,12 +8,44 @@
another `Tone` and extending `tone_for`.
Delivery varies by temperature only — the user's chosen voice (`/voice`) is left
-untouched.
+untouched. A tone's `temperature` is the *base* delivery setting; `speak.py`'s
+`_expressive_temperature` nudges it per chunk (punctuation-driven) so expression
+shifts with the content instead of staying flat for the whole read.
"""
from __future__ import annotations
from dataclasses import dataclass
+# The spoken-summary length ceiling, in words. Baked into every tone prompt AND
+# hard-enforced post-hoc by summarize_article's sentence-boundary trim — the
+# prompt alone is advisory (the model overshoots on long sources), and every
+# word past the ceiling is paid for again in synthesis time.
+SUMMARY_WORD_CEILING = 250
+
+# Length policy shared by every tone — one source of truth so the rules can't
+# drift between prompts. {src} names what's being summarized, {out} what the
+# model produces (e.g. article/explanation, passage/narration).
+_LENGTH_RULES = (
+ "The {src}'s word count is given to you — use it: your {out} should usually "
+ "land at roughly half that many words, and should almost never exceed it. "
+ f"{SUMMARY_WORD_CEILING} words is a CEILING, not a target — reserve it for a "
+ "{src} that is itself long; a {src} of a few hundred words should get "
+ "well under 150. Never add generic wrap-up or editorializing "
+ "sentences that aren't grounded in a specific fact from the {src} (e.g. broad "
+ "claims about 'a significant shift' or 'a smoother transition') just to fill "
+ "space — every sentence must carry real information from the {src}, and it's "
+ "fine to stop early once you've covered the key points. OMIT the {src}'s own "
+ "housekeeping entirely — acknowledgements, thanks to the community, calls to "
+ "action ('try it out', 'share your feedback'), and subscription or contact "
+ "invites must not appear in your {out} in any form, even paraphrased; end on "
+ "a content point, never on an invitation or thanks. HARD LIMIT: never "
+ f"exceed {SUMMARY_WORD_CEILING} words (roughly 10 to 15 sentences). "
+)
+_PLAIN_PROSE_RULE = (
+ "Use plain flowing sentences only — no markdown, no headings, no bullet "
+ "points, no special characters."
+)
+
# Article / blog: turn written prose into a clear spoken explanation. (This is the
# prompt that lived in summarize.py as _SUMMARY_SYSTEM before tones existed.)
_ARTICLE_SYSTEM = (
@@ -21,11 +53,14 @@
"concise briefing of the gist, NOT a retelling. Lead with what the article is "
"about, then its main points in a logical order, defining any term the first "
"time it comes up. Be faithful to the source; don't invent facts and don't pad. "
- "HARD LIMIT: keep the entire explanation under about 250 words (roughly 10 to "
- "15 sentences) — stop as soon as you've covered the key points; never approach "
- "the length of the original article. Since this is read aloud, use plain "
- "flowing sentences only — no markdown, no headings, no bullet points, no "
- "special characters."
+ + _LENGTH_RULES.format(src="article", out="explanation") +
+ "Since this is read aloud, write it the way a "
+ "person would actually explain it out loud, not a flat list of facts: vary "
+ "sentence length and rhythm, use a short punchy sentence for a striking point "
+ "and a longer one to connect ideas, and let a genuinely surprising or notable "
+ "fact carry natural emphasis (a real exclamation or question where it truly "
+ "fits) rather than reading every sentence in the same even register. "
+ + _PLAIN_PROSE_RULE
)
# Book passage: narrate a scanned chapter/section. Open by naming the chapter or
@@ -36,11 +71,13 @@
"chapter or topic this passage covers (the title tells you), then walk through "
"its content in a clear, measured, reading-aloud narration. Explain the ideas "
"and how they develop in order, defining terms as they appear. Be faithful to "
- "the text; don't invent anything beyond what the passage says. HARD LIMIT: keep "
- "the narration concise — under about 250 words (roughly 10 to 15 sentences); "
- "cover the main ideas, not every detail. Since this is read aloud, use plain "
- "flowing sentences only — no markdown, no headings, no bullet points, no "
- "special characters."
+ "the text; don't invent anything beyond what the passage says, and don't pad. "
+ + _LENGTH_RULES.format(src="passage", out="narration") +
+ "Since this is read aloud, narrate it the way a person reads a book out loud, "
+ "not a flat list of facts: vary sentence length and rhythm with the material, "
+ "and let a genuinely pivotal or striking moment carry natural emphasis rather "
+ "than reading every line in the same even register. "
+ + _PLAIN_PROSE_RULE
)
diff --git a/src/readback/server/server.py b/src/readback/server/server.py
index 7521ccc..70eada0 100644
--- a/src/readback/server/server.py
+++ b/src/readback/server/server.py
@@ -244,10 +244,11 @@ def _summary_progress(done: int, total: int):
text = article.text
timings["summarize"] = time.monotonic() - t0
- # 3) Synthesize (offline, with progress). Apply the tone's delivery temperature
- # (the user's chosen voice is untouched — tone shifts pacing, not the voice).
+ # 3) Synthesize (offline, with progress). The tone's temperature is the base
+ # delivery setting — synthesize_article nudges it per chunk so expression
+ # shifts with content instead of staying flat for the whole read (the user's
+ # chosen voice is untouched either way — tone shifts pacing, not the voice).
await send({"type": "phase", "value": "synthesizing"})
- synth.set_temperature(tone.temperature)
if voice and voice != synth.current_voice:
try:
synth.swap_voice(voice)
@@ -266,8 +267,8 @@ def progress(done: int, total: int):
t0 = time.monotonic()
audio = await asyncio.to_thread(
synthesize_article, synth, text,
- gap_sec=cfg.reader.gap_sec, progress=progress,
- should_stop=lambda: not state["alive"],
+ gap_sec=cfg.reader.gap_sec, base_temperature=tone.temperature,
+ progress=progress, should_stop=lambda: not state["alive"],
)
timings["synthesize"] = time.monotonic() - t0
if not state["alive"]:
diff --git a/tests/test_chunk_text.py b/tests/test_chunk_text.py
index f9dc451..1e01d79 100644
--- a/tests/test_chunk_text.py
+++ b/tests/test_chunk_text.py
@@ -29,3 +29,21 @@ def test_overlong_sentence_splits_on_commas():
assert len(sentence) > _MAX_CHARS
chunks = chunk_text(sentence)
assert len(chunks) > 1
+
+
+def test_short_fragment_is_never_dropped():
+ # A sub-_MIN_CHARS sentence ("Wow!") followed by a sentence that may not fit
+ # the drawn random cap must be carried forward, not silently discarded.
+ sentence = "This deliberately padded sentence keeps going for long enough " \
+ "that it can overflow whichever random cap the chunker drew here."
+ for _ in range(100):
+ chunks = chunk_text("Wow! " + sentence)
+ assert "Wow!" in " ".join(chunks)
+
+
+def test_comma_free_overlong_sentence_is_hard_split():
+ sentence = "word" * 3 + " ".join(["antidisestablishmentarianism"] * 20) + "."
+ assert len(sentence) > _MAX_CHARS and "," not in sentence
+ chunks = chunk_text(sentence)
+ assert all(len(c) <= _MAX_CHARS for c in chunks)
+ assert len(chunks) > 1
diff --git a/tests/test_summary_trim.py b/tests/test_summary_trim.py
new file mode 100644
index 0000000..acea639
--- /dev/null
+++ b/tests/test_summary_trim.py
@@ -0,0 +1,29 @@
+"""_trim_to_word_ceiling — post-hoc enforcement of the spoken-summary ceiling."""
+from readback.pipeline.summarize import _trim_to_word_ceiling
+from readback.pipeline.tones import SUMMARY_WORD_CEILING
+
+
+def test_short_summary_is_untouched():
+ text = "First sentence here. Second sentence follows!"
+ assert _trim_to_word_ceiling(text) == text
+
+
+def test_overshoot_is_trimmed_at_a_sentence_boundary():
+ sent = "This sentence carries exactly ten words of real content padding."
+ text = " ".join([sent] * 40) # 400 words, well over the ceiling
+ trimmed = _trim_to_word_ceiling(text)
+ n = len(trimmed.split())
+ assert n <= SUMMARY_WORD_CEILING
+ assert trimmed.endswith("padding.") # cut on a sentence boundary, not mid-sentence
+ assert n == (SUMMARY_WORD_CEILING // 10) * 10
+
+
+def test_single_giant_sentence_is_kept():
+ text = "word " * 300
+ text = text.strip() + "."
+ assert _trim_to_word_ceiling(text) == text
+
+
+def test_custom_ceiling():
+ text = "One two three. Four five six. Seven eight nine."
+ assert _trim_to_word_ceiling(text, ceiling=6) == "One two three. Four five six."