Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
5 changes: 3 additions & 2 deletions src/SpeechServer/speechServerEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
14 changes: 11 additions & 3 deletions src/SpeechServer/speechServerEngineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}

Expand Down
8 changes: 5 additions & 3 deletions src/SpeechServer/speechServerVoiceMapping.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -13,6 +15,6 @@ export function mapServerVoice(voice: SpeechServerVoice): ReadiumSpeechVoice {
quality: voice.quality,
provider: voice.provider,
identifier: voice.identifier,
controls: voice.controls
controls: providerControls
};
}
3 changes: 1 addition & 2 deletions src/SpeechServer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export interface SpeechServerVoice {
otherLanguages?: string[];
gender?: TGender | null;
quality?: TQuality;
controls?: TServerVoiceControls;
}

export interface SpeechServerTimingMark {
Expand Down Expand Up @@ -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 }[];
}
23 changes: 18 additions & 5 deletions test/SpeechServer/speechServerEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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:elevenlabs: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:elevenlabs: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: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");
});

test.serial("setVolume applies to the shared gain node", async (t) => {
Expand Down
9 changes: 5 additions & 4 deletions test/SpeechServer/speechServerEngineProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand All @@ -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");
Expand Down
1 change: 0 additions & 1 deletion test/SpeechServer/testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ export function makeServerVoice(overrides: Record<string, any> = {}) {
otherLanguages: [],
gender: "female",
quality: "veryHigh",
controls: {},
...overrides
};
}
Expand Down