From d2fa953056e0bc92cc1b473666dcacadd819c426 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Wed, 26 Aug 2026 09:53:41 +0200 Subject: [PATCH 1/3] Remove voice.controls handling --- src/SpeechServer/speechServerEngine.ts | 5 ++-- .../speechServerEngineProvider.ts | 14 ++++++++--- src/SpeechServer/speechServerVoiceMapping.ts | 8 ++++--- src/SpeechServer/types.ts | 3 +-- test/SpeechServer/speechServerEngine.test.ts | 23 +++++++++++++++---- .../speechServerEngineProvider.test.ts | 9 ++++---- test/SpeechServer/testUtils.ts | 1 - 7 files changed, 43 insertions(+), 20 deletions(-) diff --git a/src/SpeechServer/speechServerEngine.ts b/src/SpeechServer/speechServerEngine.ts index 7c38e7b..df6a51b 100644 --- a/src/SpeechServer/speechServerEngine.ts +++ b/src/SpeechServer/speechServerEngine.ts @@ -240,12 +240,13 @@ export class SpeechServerEngine implements ReadiumSpeechPlaybackEngine { return this.voices; } - const response = await this.fetchImpl(this.endpoints.voices); + const [response, serviceInfo] = await Promise.all([this.fetchImpl(this.endpoints.voices), this.getServiceInfo()]); if (!response.ok) { throw await toSpeechServerError(response); } const serverVoices: SpeechServerVoice[] = await response.json(); - this.voices = serverVoices.map(mapServerVoice); + const providerControls = new Map(serviceInfo.providers.map(p => [p.id, p.controls])); + this.voices = serverVoices.map(voice => mapServerVoice(voice, providerControls.get(voice.provider))); return this.voices; } diff --git a/src/SpeechServer/speechServerEngineProvider.ts b/src/SpeechServer/speechServerEngineProvider.ts index f591d86..f3a9c18 100644 --- a/src/SpeechServer/speechServerEngineProvider.ts +++ b/src/SpeechServer/speechServerEngineProvider.ts @@ -4,7 +4,7 @@ import { ReadiumSpeechVoice } from "../voices/types"; import { SpeechServerEngine, SpeechServerEngineOptions } from "./speechServerEngine"; import { mapServerVoice } from "./speechServerVoiceMapping"; import { toSpeechServerError } from "./errors"; -import { SpeechServerVoice } from "./types"; +import { SpeechServerServiceInfo, SpeechServerVoice } from "./types"; // Reuses SpeechServerEngineOptions wholesale (not a hand-picked subset) so every option the // engine accepts is also available through the provider, with nothing to keep in sync. @@ -28,12 +28,20 @@ export class SpeechServerEngineProvider implements ReadiumSpeechEngineProvider { return this.voices; } - const response = await this.fetchImpl(this.options.endpoints.voices); + const [response, serviceResponse] = await Promise.all([ + this.fetchImpl(this.options.endpoints.voices), + this.fetchImpl(this.options.endpoints.service) + ]); if (!response.ok) { throw await toSpeechServerError(response); } + if (!serviceResponse.ok) { + throw await toSpeechServerError(serviceResponse); + } const serverVoices: SpeechServerVoice[] = await response.json(); - this.voices = serverVoices.map(mapServerVoice); + const serviceInfo: SpeechServerServiceInfo = await serviceResponse.json(); + const providerControls = new Map(serviceInfo.providers.map(p => [p.id, p.controls])); + this.voices = serverVoices.map(voice => mapServerVoice(voice, providerControls.get(voice.provider))); return this.voices; } diff --git a/src/SpeechServer/speechServerVoiceMapping.ts b/src/SpeechServer/speechServerVoiceMapping.ts index 814f49e..231e5d6 100644 --- a/src/SpeechServer/speechServerVoiceMapping.ts +++ b/src/SpeechServer/speechServerVoiceMapping.ts @@ -1,7 +1,9 @@ -import { ReadiumSpeechVoice } from "../voices/types"; +import { ReadiumSpeechVoice, TServerVoiceControls } from "../voices/types"; import { SpeechServerVoice } from "./types"; -export function mapServerVoice(voice: SpeechServerVoice): ReadiumSpeechVoice { +// `controls` isn't sent per voice — it's a provider-wide default from `GET /service`, +// merged in here so each voice still reports what it actually honors. +export function mapServerVoice(voice: SpeechServerVoice, providerControls?: TServerVoiceControls): ReadiumSpeechVoice { return { source: "server", label: voice.name, @@ -13,6 +15,6 @@ export function mapServerVoice(voice: SpeechServerVoice): ReadiumSpeechVoice { quality: voice.quality, provider: voice.provider, identifier: voice.identifier, - controls: voice.controls + controls: providerControls }; } diff --git a/src/SpeechServer/types.ts b/src/SpeechServer/types.ts index 1c77536..a690b3c 100644 --- a/src/SpeechServer/types.ts +++ b/src/SpeechServer/types.ts @@ -9,7 +9,6 @@ export interface SpeechServerVoice { otherLanguages?: string[]; gender?: TGender | null; quality?: TQuality; - controls?: TServerVoiceControls; } export interface SpeechServerTimingMark { @@ -49,5 +48,5 @@ export interface SpeechServerSynthesizeBoundaryResponse { export interface SpeechServerServiceInfo { output: { formats: SpeechServerAudioFormat[]; default: SpeechServerAudioFormat }; limits: { maxTextLength: number; maxConcurrentSyntheses: number }; - providers: { id: string; installedLanguages: string[] }[]; + providers: { id: string; installedLanguages: string[]; quality?: TQuality; controls?: TServerVoiceControls }[]; } diff --git a/test/SpeechServer/speechServerEngine.test.ts b/test/SpeechServer/speechServerEngine.test.ts index 0edb39e..b64cb51 100644 --- a/test/SpeechServer/speechServerEngine.test.ts +++ b/test/SpeechServer/speechServerEngine.test.ts @@ -507,7 +507,8 @@ test.serial("rate is only faked locally when the voice's controls don't report s test.serial("setVoice(string) with an uncached identifier resolves controls.speed in the background, avoiding a doubled rate once resolved", async (t) => { const { fetchImpl } = createMockFetch({ - voices: () => [makeServerVoice({ controls: { speed: true } })], + voices: () => [makeServerVoice()], + service: () => ({ json: { ...defaultServiceInfo(), providers: [{ id: "pocket", installedLanguages: ["en"], controls: { speed: true } }] } }), synthesize: () => ({ json: { audio: wavBase64(), format: "wav", boundaries: null } }) }); const engine = new SpeechServerEngine({ endpoints: { voices: "http://localhost:8000/voices", synthesize: "http://localhost:8000/synthesize", service: "http://localhost:8000/service" }, fetch: fetchImpl }); @@ -529,16 +530,28 @@ test.serial("setVoice(string) with an uncached identifier resolves controls.spee test.serial("setVoice(string) called again before the background voice lookup resolves doesn't get overwritten by the stale lookup", async (t) => { const { fetchImpl } = createMockFetch({ - voices: () => [makeServerVoice({ controls: { speed: true } }), makeServerVoice({ name: "Estelle", identifier: "urn:readium:tts:pocket:estelle", controls: {} })] + voices: () => [ + makeServerVoice(), + makeServerVoice({ name: "Estelle", identifier: "urn:readium:tts:eleven:estelle", provider: "elevenlabs" }) + ], + service: () => ({ + json: { + ...defaultServiceInfo(), + providers: [ + { id: "pocket", installedLanguages: ["en"], controls: { speed: true } }, + { id: "elevenlabs", installedLanguages: ["en"], controls: {} } + ] + } + }) }); const engine = new SpeechServerEngine({ endpoints: { voices: "http://localhost:8000/voices", synthesize: "http://localhost:8000/synthesize", service: "http://localhost:8000/service" }, fetch: fetchImpl }); engine.setVoice("urn:readium:tts:pocket:alba"); - engine.setVoice("urn:readium:tts:pocket:estelle"); // supersedes the still-pending lookup for "alba" + engine.setVoice("urn:readium:tts:eleven:estelle"); // supersedes the still-pending lookup for "alba" await flush(); - t.is(engine.getCurrentVoice()?.identifier, "urn:readium:tts:pocket:estelle", "the later setVoice() call wins, not the earlier one's background resolution"); - t.deepEqual(engine.getCurrentVoice()?.controls, {}, "estelle's own resolved controls, not alba's"); + t.is(engine.getCurrentVoice()?.identifier, "urn:readium:tts:eleven:estelle", "the later setVoice() call wins, not the earlier one's background resolution"); + t.deepEqual(engine.getCurrentVoice()?.controls, {}, "estelle's own provider's controls, not alba's"); }); test.serial("setVolume applies to the shared gain node", async (t) => { diff --git a/test/SpeechServer/speechServerEngineProvider.test.ts b/test/SpeechServer/speechServerEngineProvider.test.ts index a90d1de..4424309 100644 --- a/test/SpeechServer/speechServerEngineProvider.test.ts +++ b/test/SpeechServer/speechServerEngineProvider.test.ts @@ -9,10 +9,10 @@ test("getVoices maps server voices into ReadiumSpeechVoice shape and caches", as makeServerVoice({ name: "Estelle", originalName: "estelle", - identifier: "urn:readium:tts:pocket:estelle", - controls: { speed: true } + identifier: "urn:readium:tts:pocket:estelle" }) - ] + ], + service: () => ({ json: { ...defaultServiceInfo(), providers: [{ id: "pocket", installedLanguages: ["en"], controls: { speed: true } }] } }) }); const provider = new SpeechServerEngineProvider({ endpoints: { voices: "http://localhost:8000/voices", synthesize: "http://localhost:8000/synthesize", service: "http://localhost:8000/service" }, fetch: fetchImpl }); @@ -21,7 +21,8 @@ test("getVoices maps server voices into ReadiumSpeechVoice shape and caches", as t.is(voices[0].source, "server"); t.is(voices[0].identifier, "urn:readium:tts:pocket:alba"); t.is(voices[0].provider, "pocket"); - t.deepEqual(voices[1].controls, { speed: true }); + t.deepEqual(voices[0].controls, { speed: true }, "controls merged from the pocket provider's service-level default"); + t.deepEqual(voices[1].controls, { speed: true }, "both voices share the same provider, so the same controls"); await provider.getVoices(); t.is(calls.filter(c => c.url.endsWith("/voices")).length, 1, "second call is served from cache, not refetched"); diff --git a/test/SpeechServer/testUtils.ts b/test/SpeechServer/testUtils.ts index c724d2b..0b4a38c 100644 --- a/test/SpeechServer/testUtils.ts +++ b/test/SpeechServer/testUtils.ts @@ -80,7 +80,6 @@ export function makeServerVoice(overrides: Record = {}) { otherLanguages: [], gender: "female", quality: "veryHigh", - controls: {}, ...overrides }; } From d93a0c508204e11f548d1676064d4ab508d3b162 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Wed, 26 Aug 2026 09:59:43 +0200 Subject: [PATCH 2/3] Prepare package --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a2750a..5c5e753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows [Semantic Versioning](https://semver.org/). +## [0.7.1] - 2026-08-26 + +### Fixed + +- `SpeechServerEngine`/`SpeechServerEngineProvider` no longer double-apply speed on voices whose provider already applies it server-side (e.g. ElevenLabs). [speech-server](https://github.com/readium/speech-server) stopped sending `controls` per voice on `GET /voices`, moving it to `GET /service`'s `providers[]` only — voice mapping now merges each voice's `controls` from there instead of a field the server no longer sends. + ## [0.7.0] - 2026-08-04 ### Added diff --git a/package.json b/package.json index d75facd..f583e81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@readium/speech", - "version": "0.7.0", + "version": "0.7.1", "description": "A TypeScript library for implementing read aloud features with Web technologies, following best practices for digital publishing.", "author": "Readium Foundation", "keywords": [ From 45832ff0601ef89e47c924203962d8709dc3732a Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Wed, 26 Aug 2026 10:20:48 +0200 Subject: [PATCH 3/3] Make provider in test consistent --- test/SpeechServer/speechServerEngine.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/SpeechServer/speechServerEngine.test.ts b/test/SpeechServer/speechServerEngine.test.ts index b64cb51..a73bd42 100644 --- a/test/SpeechServer/speechServerEngine.test.ts +++ b/test/SpeechServer/speechServerEngine.test.ts @@ -532,7 +532,7 @@ test.serial("setVoice(string) called again before the background voice lookup re const { fetchImpl } = createMockFetch({ voices: () => [ makeServerVoice(), - makeServerVoice({ name: "Estelle", identifier: "urn:readium:tts:eleven:estelle", provider: "elevenlabs" }) + makeServerVoice({ name: "Estelle", identifier: "urn:readium:tts:elevenlabs:estelle", provider: "elevenlabs" }) ], service: () => ({ json: { @@ -547,10 +547,10 @@ test.serial("setVoice(string) called again before the background voice lookup re const engine = new SpeechServerEngine({ endpoints: { voices: "http://localhost:8000/voices", synthesize: "http://localhost:8000/synthesize", service: "http://localhost:8000/service" }, fetch: fetchImpl }); engine.setVoice("urn:readium:tts:pocket:alba"); - engine.setVoice("urn:readium:tts:eleven:estelle"); // supersedes the still-pending lookup for "alba" + engine.setVoice("urn:readium:tts:elevenlabs:estelle"); // supersedes the still-pending lookup for "alba" await flush(); - t.is(engine.getCurrentVoice()?.identifier, "urn:readium:tts:eleven:estelle", "the later setVoice() call wins, not the earlier one's background resolution"); + t.is(engine.getCurrentVoice()?.identifier, "urn:readium:tts:elevenlabs:estelle", "the later setVoice() call wins, not the earlier one's background resolution"); t.deepEqual(engine.getCurrentVoice()?.controls, {}, "estelle's own provider's controls, not alba's"); });