Skip to content

feat(blocks): make the Speak block work again, with an optional neural voice - #8013

Open
7se7en72025 wants to merge 1 commit into
sugarlabs:masterfrom
7se7en72025:feat/speak-block
Open

feat(blocks): make the Speak block work again, with an optional neural voice#8013
7se7en72025 wants to merge 1 commit into
sugarlabs:masterfrom
7se7en72025:feat/speak-block

Conversation

@7se7en72025

@7se7en72025 7se7en72025 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

PR Category

  • Bug Fix — Fixes a bug or incorrect behavior
  • Feature — Adds new functionality
  • Tests — Adds or updates test coverage

The problem

The Speak block has been dead for a while. When meSpeak was pulled out of the codebase:

  • Logo.processSpeak() was left as an empty method with a // meSpeak was removed from the codebase. comment
  • the block set this.hidden = true, so it stopped appearing in the Media palette
  • SpeakBlock.flow() still guarded on logo.meSpeak !== null, which nothing sets anymore

So a child cannot find the block at all, and if they do get one onto the canvas from an older project file, it silently does nothing.

What I went with, and why

The default is the Web Speech API. It is already in every browser we target, it adds nothing to the bundle, it starts talking immediately, and it works offline. For a tool that children open on school hardware and sometimes without a connection, that felt like the right thing to have on by default.

I also wired up Kokoro, an 82M-parameter neural voice that runs in the browser, because it sounds dramatically more human than anything the OS ships. It is opt-in and off by default, and I want to be upfront about why: it is heavy.

Here is what it actually costs on first use, measured rather than estimated:

Asset Size
Model weights, q8 build 92.4 MB
Voice embedding 0.5 MB
onnxruntime-web wasm 4.1 MB
kokoro-js 0.7 MB
Total, first phrase only 97.7 MB
Added to the shipped bundle 0 bytes

That is a lot to ask of a child on a slow connection, which is exactly why it is not the default. Turning it on is a kokoroSpeech entry set to on in localStorage for now.

The shipped bundle genuinely does not change. dist/ is byte-for-byte the same, the service-worker precache list is untouched, and no dependency is added to package.json. Music Blocks has no module bundler, so js/kokoro-speech.js reaches kokoro-js through a native dynamic import() of a pinned CDN URL, evaluated only when someone has switched the neural voice on and a phrase is actually spoken. Transformers.js keeps the weights in the Cache Storage API, so it is one download per browser rather than per run, and it works offline afterwards.

There are other options here, and I would welcome direction

I do not think the split above is the only reasonable answer, and I would rather agree on the direction with maintainers than assume. The realistic alternatives, with sizes:

  • Web Speech only. Zero cost, works everywhere, but the voice quality is whatever the operating system provides, and on some Linux setups that is still fairly robotic.
  • Kokoro on by default. Best quality, but every user pays 97.7 MB before hearing anything, and a first run that is offline stays silent. I did not think that was a fair default for this audience.
  • A smaller neural model. KittenTTS nano is 23.8 MB, roughly a quarter of Kokoro, and is ONNX and CPU friendly. Piper en_US-amy-low is 63.1 MB. Either would be a meaningful step up from the OS voices at a fraction of Kokoro's download. The engine sits behind a small interface, so swapping it is a contained change and I am happy to do it.
  • Self-hosting the weights instead of reaching Hugging Face and jsDelivr at runtime, which removes the third-party dependency at the cost of hosting the files.

Happy to go whichever way the team prefers.

What changed

  • Logo.processSpeak() validates the text and routes it to the active engine. Empty and whitespace-only text is ignored, and non-string values are coerced.
  • Web Speech path picks a voice matching the current locale, falls back to any voice for the same language, and otherwise lets the browser choose. Chrome returns an empty getVoices() on the first call, which is treated as "browser decides" rather than a failure.
  • New js/kokoro-speech.js for the neural path. It lazily imports kokoro-js, memoises the model load so ten Speak blocks trigger one download, queues phrases, plays the raw samples through a single AudioContext, and gives up quietly with a console warning if the weights cannot be fetched.
  • Synthesis is pipelined: the next phrase starts rendering as soon as the current one begins playing, rather than only after it finishes. Rendering takes a few seconds on WebAssembly, so without this two Speak blocks in a row left a noticeable gap between them.
  • Consecutive Speak blocks now queue instead of cutting each other off, on both engines. Added Logo._cancelSpeech(), called from runLogoCommands() and doStopTurtles(), so speech from an old run never talks over a new one and Stop actually stops the talking.
  • Registered activity/kokoro-speech in js/loader.js and added it to the activity/logo dependency list.
  • Unhid the block and updated its help string.
  • Simplified SpeakBlock.flow(): dropped the dead logo.meSpeak guard and flattened the nesting. Behaviour inside a note block is unchanged, it still hands off to the embedded-graphics scheduler so the speech lands on the note's timing.
  • Removed the commented-out lib/mespeak.js script tag from index.html and the stale meSpeak entries in the test mocks. That file has not existed for some time.

Tests

js/__tests__/logo.test.js covers both engines: the happy path, empty and whitespace input, non-string coercion, a missing API, exact-locale and same-language voice selection, the empty getVoices() case, queueing, cancellation, that the neural voice stays off unless explicitly switched on, and that it falls back cleanly if switched on without the module present.

js/__tests__/kokoro-speech.test.js covers the engine itself: the phrase queue, cancelling mid-phrase, one-time model loading, a custom voice, and the path where the weights cannot be fetched. The model is never downloaded in tests; the engine boundary is stubbed.

All suites pass locally. The one unrelated failure on this branch is RhythmBlocks.test.js (setupBlockDragController is not defined), which fails the same way on master.

Demo

Two videos attached, sound on. speak-block-demo.mp4 shows the default browser voice, since that is what ships enabled. speak-block-demo-kokoro.mp4 shows the same flow with the neural voice switched on, recorded against a warm cache so it reflects synthesis speed rather than the one-time download. In both, the Speak block is picked out of the Media palette, dropped under Start, given a phrase, and run; the second half adds a second Speak block and runs both, so you can hear the two phrases read in order rather than the first getting cut off.

The block has been dead since meSpeak was pulled out of the codebase.
Logo.processSpeak() was left as an empty method, the block set
hidden = true so it never showed up in the Media palette, and
SpeakBlock.flow() still guarded on logo.meSpeak, which nothing sets
anymore.

The default is now the Web Speech API. It is already in every browser
we target, adds nothing to the bundle, starts talking immediately, and
works offline, which matters for a tool children open on school
hardware and sometimes without a connection. It picks a voice matching
the current locale, falls back to any voice for the same language, and
otherwise lets the browser choose.

An optional Kokoro neural voice is also wired up, off by default,
switched on with a "kokoroSpeech" entry set to "on" in localStorage.
It sounds far more human than the OS voices, but the weights are about
97 MB on first use, which is too much to ask of every user by default.
None of it ships with the app: there is no bundler here, so
js/kokoro-speech.js reaches kokoro-js through a native dynamic import
of a pinned CDN URL, only once the neural voice is switched on and a
phrase is actually spoken. dist/, the service-worker precache and
package.json are all untouched. Transformers.js keeps the weights in
Cache Storage, so it is one download per browser rather than per run.
Synthesis for the next phrase starts while the current one is still
playing, so consecutive Speak blocks have as little of a gap as
possible between them.

Consecutive Speak blocks now queue instead of cutting each other off,
on both engines. Logo._cancelSpeech(), called from runLogoCommands()
and doStopTurtles(), clears whichever engine is talking, so speech
from an old run never bleeds into a new one and Stop actually stops
the talking.

Also unhides the block, simplifies SpeakBlock.flow() by dropping the
dead meSpeak guard, and removes the stale meSpeak references left in
index.html and the test mocks.
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.52632% with 22 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.24%. Comparing base (1361b31) to head (54c72cc).

Files with missing lines Patch % Lines
js/kokoro-speech.js 80.85% 18 Missing ⚠️
js/logo.js 92.30% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8013      +/-   ##
==========================================
+ Coverage   62.19%   62.24%   +0.05%     
==========================================
  Files         187      188       +1     
  Lines       58324    58469     +145     
==========================================
+ Hits        36273    36396     +123     
- Misses      22051    22073      +22     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@7se7en72025 7se7en72025 changed the title Feat/speak block work again, with an optional neural voice feat(blocks): make the Speak block work again, with an optional neural voice Aug 10, 2026
@github-actions github-actions Bot added bug fix Fixes a bug or incorrect behavior feature Adds new functionality tests Adds or updates test coverage size/XL Extra large: 500-999 lines changed area/javascript Changes to JS source files area/tests Changes to test files area/core Changes to core app entry files and removed bug fix Fixes a bug or incorrect behavior labels Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core Changes to core app entry files area/javascript Changes to JS source files area/tests Changes to test files feature Adds new functionality size/XL Extra large: 500-999 lines changed tests Adds or updates test coverage

Projects

Development

Successfully merging this pull request may close these issues.

1 participant