Skip to content

Commit 9771355

Browse files
Phase 2b: engine registry for voice->engine routing
Replace Speech.pickEngine's hardcoded if/else ladder with a data-driven engineRegistry table (ordered {match, get} entries, browser-engine fallback). Behavior is identical: order preserved, GoogleTranslate Hebrew/Telugu opt-out and Premium.prepare / GoogleNative TimeoutTtsEngine side effects retained. getInfo().engine is intentionally left as-is (only hosted engines surface a name, which popup.js uses for the "initializing" label). Add test/engine-routing.test.js: extracts the registry + predicates and asserts 16 representative voices route to the same engine the old ladder produced -- routing was previously untestable (nested inside the Speech constructor). This decoupling is what lets a new engine become a one-line registry entry (unblocks PRs ken107#426 and ken107#480). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 276ff18 commit 9771355

2 files changed

Lines changed: 121 additions & 18 deletions

File tree

js/speech.js

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,25 @@
11

2+
// Ordered voice -> engine routing table. pickEngine() returns the first entry
3+
// whose match(voice) is true, falling back to the browser engine, so order is
4+
// significant. get(options) resolves the engine instance; a few entries have
5+
// side effects (Premium.prepare) or build a fresh wrapper per session
6+
// (GoogleNative's TimeoutTtsEngine).
7+
const engineRegistry = [
8+
{match: isPiperVoice, get: () => piperTtsEngine},
9+
{match: isSupertonicVoice, get: () => supertonicTtsEngine},
10+
{match: isNghiTtsVoice, get: () => nghiTtsEngine},
11+
{match: isAzure, get: () => azureTtsEngine},
12+
{match: isOpenai, get: () => openaiTtsEngine},
13+
{match: isUseMyPhone, get: () => phoneTtsEngine},
14+
{match: voice => isGoogleTranslate(voice) && !/\s(Hebrew|Telugu)$/.test(voice.voiceName), get: () => googleTranslateTtsEngine},
15+
{match: isAmazonPolly, get: () => amazonPollyTtsEngine},
16+
{match: isGoogleWavenet, get: () => googleWavenetTtsEngine},
17+
{match: isIbmWatson, get: () => ibmWatsonTtsEngine},
18+
{match: voice => isPremiumVoice(voice) || isReadAloudCloud(voice), get: options => {premiumTtsEngine.prepare(options); return premiumTtsEngine}},
19+
{match: isGoogleNative, get: () => new TimeoutTtsEngine(browserTtsEngine, 3*1000, 16*1000)},
20+
]
21+
22+
223
function Speech(texts, options) {
324
options.rate = (options.rate || 1) * (isGoogleNative(options.voice) ? 0.9 : 1);
425

@@ -25,24 +46,8 @@ function Speech(texts, options) {
2546
this.gotoEnd = () => cmd$.next({name: "gotoEnd"})
2647

2748
function pickEngine() {
28-
if (isPiperVoice(options.voice)) return piperTtsEngine;
29-
if (isSupertonicVoice(options.voice)) return supertonicTtsEngine;
30-
if (isNghiTtsVoice(options.voice)) return nghiTtsEngine;
31-
if (isAzure(options.voice)) return azureTtsEngine;
32-
if (isOpenai(options.voice)) return openaiTtsEngine;
33-
if (isUseMyPhone(options.voice)) return phoneTtsEngine;
34-
if (isGoogleTranslate(options.voice) && !/\s(Hebrew|Telugu)$/.test(options.voice.voiceName)) {
35-
return googleTranslateTtsEngine
36-
}
37-
if (isAmazonPolly(options.voice)) return amazonPollyTtsEngine;
38-
if (isGoogleWavenet(options.voice)) return googleWavenetTtsEngine;
39-
if (isIbmWatson(options.voice)) return ibmWatsonTtsEngine;
40-
if (isPremiumVoice(options.voice) || isReadAloudCloud(options.voice)) {
41-
premiumTtsEngine.prepare(options)
42-
return premiumTtsEngine;
43-
}
44-
if (isGoogleNative(options.voice)) return new TimeoutTtsEngine(browserTtsEngine, 3*1000, 16*1000);
45-
return browserTtsEngine;
49+
const entry = engineRegistry.find(e => e.match(options.voice))
50+
return entry ? entry.get(options) : browserTtsEngine
4651
}
4752

4853
function getChunks(text) {

test/engine-routing.test.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"use strict";
2+
/*
3+
* Regression baseline for voice -> engine routing (Speech.pickEngine, now driven
4+
* by engineRegistry in speech.js). This was previously untestable (a nested
5+
* if/else ladder inside the Speech constructor). We extract the registry and the
6+
* predicates it references, wire in sentinel engine stubs, and assert every
7+
* representative voice resolves to the same engine the original ladder did --
8+
* including order-sensitive cases and the GoogleTranslate Hebrew/Telugu opt-out.
9+
*/
10+
11+
const { test } = require("node:test");
12+
const assert = require("node:assert/strict");
13+
const { readSrc, extractDecl } = require("./harness/extract.js");
14+
15+
// Sentinel "engines" so we can assert which one routing picked.
16+
const stubs = {
17+
piperTtsEngine: "Piper",
18+
supertonicTtsEngine: "Supertonic",
19+
nghiTtsEngine: "NghiTTS",
20+
azureTtsEngine: "Azure",
21+
openaiTtsEngine: "OpenAI",
22+
phoneTtsEngine: "Phone",
23+
googleTranslateTtsEngine: "GoogleTranslate",
24+
amazonPollyTtsEngine: "AmazonPolly",
25+
googleWavenetTtsEngine: "GoogleWavenet",
26+
ibmWatsonTtsEngine: "IbmWatson",
27+
browserTtsEngine: "Browser",
28+
premiumTtsEngine: { prepare() {}, name: "Premium" },
29+
TimeoutTtsEngine: function (base) { this.wraps = base; this.kind = "Timeout"; },
30+
};
31+
32+
// Predicates the registry references (plus their transitive helpers).
33+
const PREDICATES = [
34+
"isPiperVoice", "isSupertonicVoice", "isNghiTtsVoice", "isAzure", "isOpenai",
35+
"isUseMyPhone", "isGoogleTranslate", "isAmazonPolly", "isGoogleWavenet",
36+
"isIbmWatson", "isPremiumVoice", "isReadAloudCloud", "isGoogleNative",
37+
"isAmazonCloud", "isMicrosoftCloud", "isRHVoice",
38+
];
39+
40+
const defaultsSrc = readSrc("js/defaults.js");
41+
const predSrc = PREDICATES.map((p) => extractDecl(defaultsSrc, p)).join("\n");
42+
const registrySrc = extractDecl(readSrc("js/speech.js"), "engineRegistry");
43+
44+
const keys = Object.keys(stubs);
45+
const engineRegistry = new Function(
46+
...keys,
47+
`${predSrc}\n${registrySrc}\nreturn engineRegistry;`
48+
)(...keys.map((k) => stubs[k]));
49+
50+
// Mirror of Speech.pickEngine's registry lookup.
51+
function pickEngine(voice) {
52+
const entry = engineRegistry.find((e) => e.match(voice));
53+
return entry ? entry.get({ voice }) : stubs.browserTtsEngine;
54+
}
55+
56+
const v = (voiceName, extra) => ({ voiceName, ...extra });
57+
58+
const routes = [
59+
["Piper en_US-amy", "Piper"],
60+
["Supertonic en-US", "Supertonic"],
61+
["NghiTTS vi-VN", "NghiTTS"],
62+
["Azure en-US - AriaNeural", "Azure"],
63+
["OpenAI alloy", "OpenAI"],
64+
["GoogleTranslate Spanish", "GoogleTranslate"],
65+
["AmazonPolly en-US (Joanna)", "AmazonPolly"],
66+
["GoogleWavenet en-US-Wavenet-A", "GoogleWavenet"],
67+
["IBM-Watson en-US (Allison)", "IbmWatson"],
68+
["Amazon Joanna", "Premium"], // premium cloud, not AmazonPolly
69+
["Microsoft David", "Premium"],
70+
["ReadAloud Generic Voice", "Premium"],
71+
];
72+
73+
for (const [name, expected] of routes) {
74+
test(`"${name}" routes to ${expected}`, () => {
75+
const picked = pickEngine(v(name));
76+
const got = picked === stubs.premiumTtsEngine ? "Premium" : picked;
77+
assert.equal(got, expected);
78+
});
79+
}
80+
81+
test("isUseMyPhone voice routes to Phone", () => {
82+
assert.equal(pickEngine({ voiceName: "x", isUseMyPhone: true }), "Phone");
83+
});
84+
85+
test("GoogleTranslate Hebrew/Telugu opt out of GoogleTranslate and fall back to Browser", () => {
86+
assert.equal(pickEngine(v("GoogleTranslate Hebrew")), "Browser");
87+
assert.equal(pickEngine(v("GoogleTranslate Telugu")), "Browser");
88+
});
89+
90+
test("Google native voice is wrapped in a TimeoutTtsEngine over the browser engine", () => {
91+
const picked = pickEngine(v("Google US English"));
92+
assert.equal(picked.kind, "Timeout");
93+
assert.equal(picked.wraps, "Browser");
94+
});
95+
96+
test("an unrecognized/native voice falls back to the browser engine", () => {
97+
assert.equal(pickEngine(v("Alex", { remote: false })), "Browser");
98+
});

0 commit comments

Comments
 (0)