8000 feat(voice): read answers and podcasts aloud with Kokoro on this machine by adeelahmad · Pull Request #538 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content 8000
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2db7aee
feat(voice): read answers and podcasts aloud with Kokoro on this machine
claude Oct 17, 2026
11ba8e0
Merge branch 'main' into claude/project-thread-hky2fo
claude Oct 18, 2026
51ac75c
test(settings): carry ai.auto_title in the Kokoro settings fixture
claude Oct 18, 2026
4028212
Merge branch 'main' into claude/project-thread-hky2fo
claude Oct 18, 2026
d31bdea
docs(threat-model): count the Kokoro row in the JSON companion
claude Oct 18, 2026
f8493ff
fix(voice): offer Kokoro in full precision and 8-bit only
claude Oct 18, 2026
4339b23
Merge branch 'main' into claude/project-thread-hky2fo
claude Oct 18, 2026
3ded53f
test(settings): carry the research section in the Kokoro settings fix…
claude Oct 18, 2026
9cb5bae
Merge branch 'main' into claude/project-thread-hky2fo
claude Oct 18, 2026
74a3f97
Merge branch 'main' into claude/project-thread-hky2fo
claude Oct 18, 2026
a02d70a
test(settings): carry ai.compact_at in the Kokoro settings fixture
claude Oct 18, 2026
14853f0
Merge origin/main into claude/project-thread-hky2fo
claude Oct 18, 2026
deee4ba
docs(readme): list answers about Lens itself as built, now that #400 …
claude Oct 18, 2026
3ad5fcd
Merge origin/main into claude/project-thread-hky2fo
claude Oct 18, 2026
b54e8df
Merge origin/main into claude/project-thread-hky2fo
claude Oct 18, 2026
e07f386
Merge origin/main into claude/project-thread-hky2fo, with the Kokoro …
claude Oct 18, 2026
4f47f78
docs(changelog): name the Kokoro fragment after its pull request
claude Oct 18, 2026
b957ecc
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 18, 2026
141406c
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 18, 2026
e638a9d
docs(threat-model): count the Kokoro entries against main's newest model
claude Oct 18, 2026
f501f2b
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 18, 2026
ffcbfbf
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 18, 2026
a6d1aaa
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 18, 2026
7e91636
docs(readme): take Kokoro out of In progress, now that it's built
claude Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/kokoro.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: Kokoro

# Kokoro reading aloud on this machine (docs/speech-providers.md#kokoro) on x64 and arm64 (a Raspberry Pi, Docker on a
# Mac): in the backend image, Lens fetches the kokoro extra at uv.lock's versions and the pinned model and voices files
# the way a worker does, and each of the three models reads a sentence aloud in a few languages. kokoro-onnx's wheels
# (espeak-ng's library is in one) and its release files are what could move, so this runs every week too.
on:
pull_request:
paths:
- fastapi_backend/Dockerfile
- fastapi_backend/pyproject.toml
- fastapi_backend/app/domain/kokoro.py
- fastapi_backend/app/domain/components.py
- .github/workflows/kokoro.yml
push:
branches: [main]
paths:
- fastapi_backend/Dockerfile
- fastapi_backend/pyproject.toml
- fastapi_backend/app/domain/kokoro.py
- fastapi_backend/app/domain/components.py
- .github/workflows/kokoro.yml
schedule:
- cron: "17 6 * * 1"
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

jobs:
kokoro:
name: Kokoro in the backend image (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, runner: ubuntu-latest }
- { arch: arm64, runner: ubuntu-24.04-arm }
steps:
- uses: actions/checkout@v7

- name: Build the backend image
run: docker build --target lean -t lens-backend:ci fastapi_backend

- name: Lens fetches Kokoro and reads aloud with each model
run: |
docker run --rm -i lens-backend:ci python - <<'PY'
import time

from app.domain import components, kokoro, machine

cfg = {"data_dir": "/tmp/lens", "voice": {"tts_provider": "kokoro"}}
m = machine.probe(cfg["data_dir"])
c = components.BY_ID["kokoro"]
assert c.needed(cfg, m) and not c.present(cfg), "nothing should be here before the fetch"
lines = (
("af_heart", "Hello from Lens. Kokoro reads this on this machine, and nothing is sent anywhere."),
("bm_george", "Good morning. The shipment leaves on Friday."),
("ef_dora", "Buenos días. El envío sale el viernes."),
("jf_alpha", "おはようございます。"),
)
for size in kokoro.MODELS:
cfg["voice"]["kokoro_model"] = size
c.fetch(cfg, m, print) # the extra the first time, then the model and the voices
assert c.present(cfg), f"{size} isn't ready after the fetch"
for voice, text in lines:
t = time.monotonic()
pcm, rate = kokoro.read(cfg, text, voice)
seconds = len(pcm) / 2 / rate
print(f"{size} {voice}: {seconds:.2f} s of audio in {time.monotonic() - t:.2f} s")
assert rate == 24000 and seconds > 0.5, f"{size} {voice} read {seconds:.2f} s"
kokoro._loaded.clear()
assert len(kokoro.voices()) == 54
PY
16 changes: 16 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ Refine later: references in sources' credentials, MCP servers' environments and
read from the vault the way a typed one is checked; a custom CA for a self-hosted vault; an environment variable for
the master password; more providers (1Password, HashiCorp Vault, Infisical).

## 2026-10-17 · Kokoro reading aloud on this machine

Adeel: Kokoro v1.0's model and voices for spoken answers and podcasts, so Lens reads aloud fully on its own machine.
`kokoro.py` runs it through kokoro-onnx on ONNX Runtime, in-process (no service to start, unlike StyleTTS2), as
`voice.tts_provider` `kokoro`; components.py fetches the `kokoro` extra and the pinned model and voices files once it's
picked, and podcast jobs wait for them. The voice list is fixed (54 voices), so the picker works before the fetch.
espeak-ng and phonemizer are GPL-3.0, so the extra is opt-in only (AGENTS.md 13). Measured here on a 4-core x64
container, one 5-second sentence: fp32 2.8 s, fp16 3.0 s, int8 4.6 s, so fp32 is the default; on GitHub's arm64
runner (kokoro.yml) fp32 read the same sentence in 3.2 s. kokoro-onnx's fp16 export isn't offered: on arm64 ONNX
Runtime's CPU provider returned an empty waveform for it (kokoro-onnx 0.6.1's pause insertion then fails on a zero-size
array), and on x64 it bought nothing over fp32; int8 is the small-disk option.

Refine later: misaki's G2P instead of espeak-ng for English (better names and numbers, and no GPL in the path);
speaking speed (`speed` 0.5 to 2); voice blends (`af_heart:60,am_michael:40`); stream long answers sentence by
sentence; pick the hosts' voices in the podcast dialog; Japanese and Mandarin through misaki's own G2P.

## 2026-10-17 · Read any chat message aloud

Adeel: play any message in the chat through the TTS model. A speaker button on every question and answer reads it by
Expand Down
11 changes: 4 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ listed under [In progress](#in-progress), and what comes next under [Planned](#p
question makes a new version beside the old one, and nothing is lost. Each answer keeps a record of what produced it
(the model, its tools, how long it took and what it cost), opened from its header. Copy an answer as Markdown or plain
text, a question or a code block, and export a conversation as Markdown, PDF or JSON, its citations as footnotes that
link back to the moment in Lens. It reads answers aloud with a speech provider, macOS `say` or StyleTTS2 on this
machine (opt-in), any message in one click. Long conversations are summarised before they fill the model's context,
link back to the moment in Lens. It reads answers aloud with a speech provider, macOS `say`, or Kokoro (54
voices, fetched once picked) or StyleTTS2 on this machine (opt-in), any message in one click. Long conversations are summarised before they fill the model's context,
and `/clear` and `/compact` start afresh or summarise now. Conversations title themselves after their first answer,
unless you name them. Each namespace can have its own assistant with its own name, instructions and memory, answering
in Telegram and Slack (built in, with voice notes and files), in Discord, Matrix, WhatsApp and other chat rooms
Expand Down Expand Up @@ -185,7 +185,7 @@ listed under [In progress](#in-progress), and what comes next under [Planned](#p
[cleaning up transcripts](docs/processing.md#cleaning-up-transcripts), [dates](docs/processing.md#dates);
[checks](docs/requirements.md#understand-it))
- **Learn from it.** Make a two-host podcast episode about what you pick, every claim cited and checked against its
source, read aloud in two voices. Turn a voice session or a meeting into a technical spec, in a note linked to it,
source, read aloud in two voices, by Kokoro on this machine or a speech provider. Turn a voice session or a meeting into a technical spec, in a note linked to it,
that keeps every decision, every no and every constraint in the speaker's own words, linked to when they said them,
flags what was left to decide later and marks any quote the transcript doesn't hold. Keep notes beside every asset,
entity and topic, written by you or the assistant, in a tree where notes are renamed, moved, copied and pinned (pins
Expand Down Expand Up @@ -306,10 +306,7 @@ still loading. ([Components](docs/components.md),

## In progress

These are open pull requests, not yet part of Lens:

- **Voice:** answers and podcasts read aloud with Kokoro on this machine
([#538](https://github.com/adeelahmad/lense/pull/538)).
Open pull requests, not yet part of Lens, are listed here; none right now.

## Planned

Expand Down
7 changes: 7 additions & 0 deletions changelog.d/538.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- **Kokoro reads aloud on this machine.** Pick **Kokoro** as the voice in Settings → AI assistant → Voice, and spoken
answers and podcast hosts are read by Kokoro v1.0 on the server's own CPU, in any of its 54 voices across nine
languages, with nothing sent anywhere and nothing billed. It's fetched once picked (the `kokoro` extra with
kokoro-onnx and ONNX Runtime, the model in `voice.kokoro_model`, full or 8-bit, and the voices: about 450 MB,
checked against their SHA-256); podcast episodes wait for it, and the browser reads answers until it's here. A
podcast's second host gets a voice in the first host's language. Agents pick it through the settings API and list the
voices with `GET /api/v1/voice/voices?provider=kokoro` ([Kokoro](docs/speech-providers.md#kokoro)).
4 changes: 2 additions & 2 deletions docs/assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,8 @@ Voice goes through one hook, `useVoice()` in `nextjs-frontend/lib/voice.ts` (`li
Edge and Safari; Chrome sends the audio to its own speech service).
- **Speaking.** Answers are read by a speech model when one is set (`voice.tts_model`: any OpenAI-compatible
`/audio/speech`, such as Kokoro-FastAPI, LocalAI or OpenAI's `tts-1`, on `voice.tts_base_url` or the LLM provider),
or by ElevenLabs, Deepgram, Google Gemini or macOS `say` ([Speech providers](speech-providers.md#voices)); else by
the browser.
or by ElevenLabs, Deepgram, Google Gemini, macOS `say`, or Kokoro on this machine
([Speech providers](speech-providers.md#kokoro)); else by the browser.

#### Read a message aloud

Expand Down
5 changes: 3 additions & 2 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ never fetch a speech or voice model.
| Moondream | `llm.describe_engine` is `moondream`, and this machine can run the chosen one | Moondream 3 Preview (BSL 1.1, about 19 GB, an NVIDIA GPU or a Mac with 32 GB) or Moondream 2 (Apache-2.0, about 3.7 GB), `llm.moondream_model`, at a pinned revision, with its tokenizer, PyTorch (the CPU build without an NVIDIA GPU) and transformers, to [describe pictures](configuration.md#descriptions) on this machine |
| Real-ESRGAN | [enhancing images](enhance.md) is on and `enhance.image_engine` is `realesrgan` (off by default) | Real-ESRGAN general x4v3 as an ONNX model (BSD-3-Clause, 5 MB), and ONNX Runtime |
| Prompt-injection classifier | `ai.injection_check` is `classifier` | ProtectAI's deberta-v3-base-prompt-injection-v2 (Apache-2.0, about 740 MB), the tokenizers library and ONNX Runtime ([third-party content](assistant.md#third-party-content-and-prompt-injection)) |
| Kokoro | `voice.tts_provider` is `kokoro`, as soon as it's saved (podcast jobs wait for it) | Kokoro v1.0 (Apache-2.0): the model in `voice.kokoro_model` (326, 164 or 114 MB) and its 54 voices (28 MB), and the `kokoro` extra: kokoro-onnx (MIT), ONNX Runtime, and espeak-ng with phonemizer (GPL-3.0, only in this extra), about 95 MB, to [read aloud](speech-providers.md#kokoro) on this machine |
| Punctuation model | `analysis.cleanup` is on and `analysis.cleanup_punctuation` isn't `off` ([Cleaning up transcripts](processing.md#cleaning-up-transcripts)) | kredor/punctuate-all at a pinned revision (MIT, about 1.1 GB), with Transformers and PyTorch's CPU build |
| spaCy | `analysis.entities` is `spacy` ([Entities](processing.md#entities-and-the-graph-explorer)) | spaCy (MIT) and the model in `analysis.spacy_model`: `en_core_web_sm`, `de_core_news_sm` or `xx_ent_wiki_sm` (MIT, 11 to 15 MB each), about 60 MB in all |
| WebRTC audio processing | a job first reaches a pipeline's `clean_audio` step ([Cleaning up audio](processing.md#cleaning-up-audio)) | pywebrtc-audio (Apache-2.0, about 0.5 MB, numpy only): WebRTC's noise suppression, gain control, echo cancellation and voice detection |
Expand All @@ -37,8 +38,8 @@ never fetch a speech or voice model.
- Python packages go into `data_dir/python`, at the versions in `uv.lock`. Packages the image already has win.
- Models go into `data_dir/models`: the Hugging Face, ModelScope and PyTorch caches point there (unless `HF_HOME`,
`MODELSCOPE_CACHE` or `TORCH_HOME` say otherwise). They outlast container rebuilds and upgrades.
- Model files fetched by URL (YOLOX, YuNet, SFace, the NSFW model, Florence-2, spaCy's models, Real-ESRGAN) are checked against their
SHA-256.
- Model files fetched by URL (YOLOX, YuNet, SFace, the NSFW model, Florence-2, Kokoro, spaCy's models, Real-ESRGAN) are
checked against their SHA-256.
- A job step that needs something still being fetched waits for it instead of falling back or failing. If a fetch fails,
the step runs as before (for transcription, falling back to another engine), and the fetch is tried again an hour
later, when the settings change, or on **Check again now**.
Expand Down
4 changes: 2 additions & 2 deletions docs/podcasts.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ One job on the episode's asset, one step per stage, visible in Activity and on t
| planning | The model outlines the episode: key ideas, learner questions, 2 to 4 connections between excerpts and a recap, each tied to excerpt numbers. |
| writing | The model writes the script as lines of `{speaker, kind, text, citations}`. Citations to excerpts it wasn't given are dropped. |
| fact-checking | Every claim and recap line is checked against the excerpts it cites. A partly supported line takes the checker's correction; an unsupported one is rewritten once and checked again, or dropped, as is a claim with no citation. Every change is recorded. |
| recording the voices | Each line is read aloud in its host's voice by the [text-to-speech provider](speech-providers.md) (Settings › Voice: an OpenAI-compatible server such as a local Kokoro, ElevenLabs or Deepgram), the clips are joined with short pauses, and the loudness is evened out (-16 LUFS) into one MP3. Clips are kept, so making an episode again only reads changed lines. Without text to speech this stage is skipped and the episode stays a script. |
| recording the voices | Each line is read aloud in its host's voice by the [text-to-speech provider](speech-providers.md) (Settings › Voice: Kokoro on this machine, an OpenAI-compatible server, ElevenLabs or Deepgram), the clips are joined with short pauses, and the loudness is evened out (-16 LUFS) into one MP3. Clips are kept, so making an episode again only reads changed lines. Without text to speech this stage is skipped and the episode stays a script. |
| publishing | The lines become the episode's transcript, timed where each line is in the audio (or at `podcasts.words_per_minute` without audio), and the audio becomes the episode's media. |

Then the usual analyze, embed and summarize steps run. Workers run podcast steps when their `workers.steps` list has
Expand Down Expand Up @@ -81,7 +81,7 @@ change what every later episode is asked; each episode records which versions ma
| `podcasts.max_minutes` | 30 | The longest episode someone can ask for |
| `podcasts.words_per_minute` | 150 | Speaking pace, for the script's length and its timing |
| `podcasts.model` | none | A model for the podcast's LLM calls (none: `llm.model`) |
| `podcasts.voice_a`, `podcasts.voice_b` | none | The hosts' voices (none: `voice.tts_voice` for A, and onyx, ElevenLabs' Sarah or Deepgram's aura-2-orion-en for B) |
| `podcasts.voice_a`, `podcasts.voice_b` | none | The hosts' voices (none: `voice.tts_voice` for A, and onyx, ElevenLabs' Sarah, Deepgram's aura-2-orion-en or, with Kokoro, a voice in A's language such as am_michael for B) |

## Privacy and cost

Expand Down
1 change: 1 addition & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ request, to another check that proves the same thing; loosening a requirement or
| Answers, questions and code blocks copied from the web app, an answer as Markdown or plain text | [chat-copy-export.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/chat-copy-export.test.tsx) `gives Markdown with citations as footnotes linking back to Lens, or plain text`, `copies a question`, `copies a code block and says Copied` |
| An answer is finished, saved and shown even when the person leaves before it's done; only Stop ends it early | [test_chats.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_chats.py) `test_an_answer_is_saved_when_the_client_leaves`, `test_stopping_an_answer`; [chat-fresh-answer.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/chat-fresh-answer.test.tsx) `shows the answer even when the conversation's first read left before it was saved` |
| Answers read aloud by a speech provider, macOS `say` or StyleTTS2 (opt-in) | [test_voice.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_voice.py) `test_answers_are_read_aloud_by_a_speech_model`; [test_speech.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_speech.py) `test_macos_say_reads_aloud_on_a_mac`, `test_styletts2_reads_aloud_from_its_own_service`; [styletts2.yml](https://github.com/adeelahmad/lense/blob/main/.github/workflows/styletts2.yml) `Read a sentence aloud` |
| Answers and podcasts read aloud by Kokoro v1.0 on this machine, in 54 voices, fetched once it's picked and checked against its SHA-256 (opt-in) | [test_kokoro.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_kokoro.py) `test_kokoro_reads_answers_aloud_on_this_machine`, `test_the_model_size_is_a_setting`, `test_kokoro_is_fetched_once_it_is_picked`, `test_every_voice_is_listed_before_it_is_fetched`, `test_a_podcast_is_read_by_kokoro`; [settings-kokoro.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/settings-kokoro.test.tsx) `reads on this machine, with the model size to pick`; [kokoro.yml](https://github.com/adeelahmad/lense/blob/main/.github/workflows/kokoro.yml) `Lens fetches Kokoro and reads aloud with each model` |
| Any message read aloud in one click, one at a time, its audio kept so a replay costs nothing | [chat-read-aloud.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/chat-read-aloud.test.tsx) `plays an answer by the speech model in one click, stops on the next, and keeps its audio`, `reads one message at a time: starting another stops the first`, `reads a long answer piece by piece, asking for the next while one plays` |
| Conversations title themselves after their first answer, and a name you give is kept | [test_chat_titles.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_chat_titles.py) `test_a_first_answer_titles_the_conversation`, `test_a_title_you_give_is_kept`, `test_agents_rename_and_retitle_over_mcp` |
| Long conversations summarised before they fill the model's context, and `/clear` and `/compact` | [test_compaction.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_compaction.py) `test_older_messages_are_folded_into_a_summary`, `test_the_context_window_is_the_model_servers`, `test_a_long_conversation_is_folded_in_runs_that_fit_the_summary_model`, `test_a_conversation_that_doesnt_fit_is_folded_in_and_asked_again`, `test_what_tools_returned_is_cut_to_fit_the_model`, `test_clear_starts_afresh_and_compact_folds_everything_now`, `test_commands_in_the_app`; [chat-summary-mark.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/chat-summary-mark.test.tsx) `says how many messages the summary stands for, and shows it on request` |
Expand Down
Loading
Loading
0