Skip to content

feat(announcer): pluggable speech engine with LG/Samsung device detection - #48

Closed
chiefcll wants to merge 1 commit into
mainfrom
feat/announcer-speech-engine
Closed

feat(announcer): pluggable speech engine with LG/Samsung device detection#48
chiefcll wants to merge 1 commit into
mainfrom
feat/announcer-speech-engine

Conversation

@chiefcll

@chiefcll chiefcll commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

What

Makes the Announcer's text-to-speech backend replaceable, and adds detection that switches to a TV's own speech output.

Announcer.setSpeechEngine({ speak, cancel })

The Announcer was hard-wired to window.speechSynthesis, so platforms with their own TTS had no way in. (The docs pointed at Announcer._textToSpeech as the extension point — that property does not exist anywhere in the codebase, so the documented hook was dead.)

An engine is a per-phrase driver, not a replacement for the announcer:

interface SpeechEngine {
  speak: (phrase: string, options: { lang: string; voice?: string }) => void | Promise<void>;
  cancel: VoidFunction;
}

The Announcer keeps owning the series — string flattening, PAUSE-# delays, nested arrays/promises/functions, append, cancel, the 3-attempt network retry — so an integration is a few lines instead of a reimplementation of speakSeries. Returning a promise lets the series pace against real speech; returning void advances immediately. Rejections are classified by their error property, same codes as the Web Speech API (network retries, canceled/interrupted end quietly, anything else propagates).

Announcer.detectSpeechEngine()

Returns 'webos' | 'tizen' | 'default' and configures the matching output. Reads globals only — it never speaks.

The two platforms are not symmetric, and that shapes the API:

  • LG (webos) — installs an engine driving luna://com.webos.service.tts. Each phrase uses feedback: true + subscribe: true and resolves on msgStatus: 'done', so the promise settles when the TV actually finishes and PAUSE-# mid-series stays accurate. stopped/canceled map to the benign canceled code.
  • Samsung (tizen)Samsung provides no API for an app to speak a string. Voice Guide is the TTS and it only reads the DOM; Samsung's documented guidance is ARIA markup. So there is no engine to install — detection turns on the existing aria mode, which already writes to an aria-live="assertive" region.
  • default — restores speechSynthesis, leaves aria as configured.

Reviewer notes

Three judgment calls worth a look:

  1. webOS detection is a capability test on webOS.service.request, not a user-agent match — Luna is unreachable unless the app loaded webOSTV.js. On webOS without it we warn and fall back rather than installing an engine that can't work.
  2. A Luna request-level failure warns once and resolves instead of rejecting. This path is usually a missing com.webos.service.tts permission in appinfo.json, which fails for every phrase; rejecting would turn a one-line config typo into an unhandled rejection per phrase with no indication of the cause. Per-message msgStatus: 'error' still rejects.
  3. Samsung announcements are inaudible unless the viewer has Voice Guide on — a system setting an app cannot change. Detection warns when webapis.tvinfo reports it off; isTizenVoiceGuideEnabled() is exported for apps that want to react. Documented limits of that path: Announcer.voice is ignored, and completion isn't observable so a series resolves once labels are written.

Also fixed along the way: phrase instanceof SpeechSynthesisUtterance threw a ReferenceError on devices that ship a platform TTS and no Web Speech API — exactly the devices this feature targets — so it's now guarded. window.speechSynthesis is only touched by the default engine. Dropped the utterances array in speakSeries that was pushed to but never read.

Testing

npm test — 169 pass (18 new across tests/announcer-engine.spec.ts and tests/announcer-platform.spec.ts: engine routing, lang/voice pass-through, PAUSE ordering, async sequencing, cancel, reset; Luna call shape, appID, done/stopped/failure paths, cancel→stop, and each detection branch driven through a webOS.service.request stub). npm run tsc clean, npm run lint 0 errors.

API surface is additive — no existing behavior changes when no engine is set and detectSpeechEngine() isn't called.

Docs: docs/primitives/a11y.md gains "Custom Speech Engine" and "Device Detection (LG & Samsung)" sections, plus the corrected _textToSpeech reference.

🤖 Generated with Claude Code

The Announcer was hard-wired to window.speechSynthesis, so TV platforms
with their own TTS had no way in. The docs pointed at
`Announcer._textToSpeech` as the extension point, which does not exist.

Add `Announcer.setSpeechEngine({ speak, cancel })`, a per-phrase driver.
The Announcer keeps owning the series - flattening, PAUSE- delays, nested
arrays/promises/functions, append, cancel and the network retry - so a
platform integration is a few lines rather than a reimplementation.

Add `Announcer.detectSpeechEngine()` to pick a device's built-in output:

- LG (webos): installs an engine driving luna://com.webos.service.tts.
  Each phrase subscribes for feedback and resolves on msgStatus 'done',
  so PAUSE- timing mid-series stays accurate. Detection is a capability
  test on webOS.service.request rather than a user-agent match, since
  Luna is unreachable without webOSTV.js. A request-level failure
  (usually a missing com.webos.service.tts permission, which fails for
  every phrase) warns once and resolves instead of rejecting, so a
  config typo does not become an unhandled rejection per phrase.
- Samsung (tizen): Samsung exposes no API for an app to speak a string -
  Voice Guide is the TTS and it only reads the DOM. So detection turns
  on the existing `aria` mode instead, and warns when it can tell Voice
  Guide is switched off, since nothing is audible in that state.

Also guard `phrase instanceof SpeechSynthesisUtterance`, which throws a
ReferenceError on devices that ship a platform TTS and no Web Speech API
- exactly the devices this targets. window.speechSynthesis is now only
touched by the default engine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chiefcll chiefcll closed this Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant