Skip to content

Commit aa8b746

Browse files
author
DavidQ
committed
Filter text2speach-V2 voices by selected language and reorder controls - PR_26130_010-text2speach-v2-language-filtering
1 parent 20e0a23 commit aa8b746

9 files changed

Lines changed: 258 additions & 30 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# PR_26130_010-text2speach-v2-language-filtering
2+
3+
## Purpose
4+
5+
Make `text2speach-V2` match how browser SpeechSynthesis behaves: Language is selected first, and Voice only shows voices whose `SpeechSynthesisVoice.lang` matches that language.
6+
7+
## Scope
8+
9+
Changed only `text2speach-V2` language/voice UI, control filtering, schema/default ordering, and Workspace Manager V2 Playwright coverage.
10+
11+
No `start_of_day` files were changed.
12+
13+
`docs/dev/codex_commands.md` and `docs/dev/commit_comment.txt` were updated locally as required and remain ignored so they cannot be committed.
14+
15+
## Implementation Summary
16+
17+
- Moved Language above Voice in the Speech Options form.
18+
- Moved `language` before `voice` in the text2speach-V2 required-field/default/schema order.
19+
- Filtered Voice options to `speechSynthesis.getVoices()` entries whose `lang` matches the selected Language.
20+
- Added visible voice match details under Voice, including match count and voice names.
21+
- Auto-selects the first matching voice when a language change invalidates the previous selected voice.
22+
- Clears Voice, disables Speak, and logs a visible failure when the selected Language has no matching voices.
23+
- Preserved the existing queue payload shape and required queue item fields.
24+
25+
## Tool Completion Status
26+
27+
Failing behavior before: Language and Voice were independent, so selecting Language did not change audible voice behavior or the Voice dropdown contents.
28+
29+
Tool fixed: `text2speach-V2`.
30+
31+
Remaining failures after targeted validation: none found in `npm run test:workspace-v2`.
32+
33+
## Playwright Impact
34+
35+
Playwright impacted: Yes.
36+
37+
Coverage added/updated for:
38+
39+
- language-first control ordering
40+
- dynamic Voice filtering by selected Language
41+
- auto-selection of the first matching voice when the prior Voice becomes invalid
42+
- invalid voice reset behavior when a language has no matching voices
43+
- visible Voice match counts/details
44+
- delayed `voiceschanged` population respecting the selected Language filter
45+
- existing full TTS options, schema-valid default queue, speech actions, and Workspace Manager V2 launch behavior
46+
47+
Expected pass behavior: Language controls the Voice list, Voice never shows non-matching voices, selection adjustments are logged, and Speak is disabled when no matching voice exists.
48+
49+
Expected fail behavior: tests fail if Language is not first, non-matching voices appear, an invalid selected Voice remains active, Voice match details are missing, or delayed voice population ignores the language filter.
50+
51+
## Validation
52+
53+
Passed:
54+
55+
```text
56+
npm run test:workspace-v2
57+
```
58+
59+
Result:
60+
61+
```text
62+
26 passed
63+
```
64+
65+
Additional checks:
66+
67+
```text
68+
node --check src/engine/audio/TextToSpeechDefaults.js
69+
node --check tools/text2speach-V2/js/controls/SpeechOptionsControl.js
70+
node --check tools/text2speach-V2/js/TextToSpeechToolApp.js
71+
node --check tools/text2speach-V2/js/bootstrap.js
72+
node --check tests/playwright/tools/WorkspaceManagerV2.spec.mjs
73+
node -e "JSON.parse(require('node:fs').readFileSync('tools/schemas/tools/text2speach-V2.schema.json','utf8'));"
74+
git diff --check
75+
```
76+
77+
The workspace-v2 Playwright run also generated advisory V8 coverage reports:
78+
79+
- `docs/dev/reports/playwright_v8_coverage_report.txt`
80+
- `docs/dev/reports/coverage_changed_js_guardrail.txt`
81+
82+
## Full Samples Smoke Test
83+
84+
Skipped. The full samples smoke test is intentionally out of scope because this PR is limited to text2speach-V2 language/voice filtering behavior and targeted Workspace Manager V2 coverage, not broad sample runtime behavior.
85+
86+
## ZIP Artifact
87+
88+
Repo-structured delta ZIP:
89+
90+
```text
91+
tmp/PR_26130_010-text2speach-v2-language-filtering_delta.zip
92+
```
93+
94+
## Manual Validation Steps
95+
96+
1. Open `tools/text2speach-V2/index.html`.
97+
2. Confirm Language appears above Voice in Speech Options.
98+
3. Confirm the default `en-US` language shows only matching `en-US` voices and the visible Voice details line reports match count/name details.
99+
4. Change Language to `en-GB`; Voice should auto-select the first matching `en-GB` voice and the status log should report the adjustment.
100+
5. Change Language to a locale with no available voices; Voice should clear, Speak should disable, and the status log should explain that no matching voice exists.
101+
6. Change back to a language with matching voices and confirm Speak becomes available again.
102+
103+
Expected outcome: Voice options are always language-filtered, selection changes are visible/logged, and no non-matching or hidden fallback voice is used.
104+
105+
## Changed Files
106+
107+
- `src/engine/audio/TextToSpeechDefaults.js`
108+
- `tests/playwright/tools/WorkspaceManagerV2.spec.mjs`
109+
- `tools/schemas/tools/text2speach-V2.schema.json`
110+
- `tools/text2speach-V2/index.html`
111+
- `tools/text2speach-V2/js/TextToSpeechToolApp.js`
112+
- `tools/text2speach-V2/js/bootstrap.js`
113+
- `tools/text2speach-V2/js/controls/SpeechOptionsControl.js`
114+
- `tools/text2speach-V2/styles/text2speach-V2.css`
115+
- `docs/dev/reports/PR_26130_010-text2speach-v2-language-filtering.md`
116+
- `docs/dev/reports/codex_review.diff`
117+
- `docs/dev/reports/codex_changed_files.txt`
118+
- `docs/dev/reports/playwright_v8_coverage_report.txt`
119+
- `docs/dev/reports/coverage_changed_js_guardrail.txt`
120+
- `docs/dev/codex_commands.md`
121+
- `docs/dev/commit_comment.txt`

src/engine/audio/TextToSpeechDefaults.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ const TEXT_TO_SPEECH_QUEUE_ITEM_REQUIRED_FIELDS = Object.freeze([
4747
"id",
4848
"name",
4949
"text",
50-
"voice",
5150
"language",
51+
"voice",
5252
"volume",
5353
"rate",
5454
"pitch",

tests/playwright/tools/WorkspaceManagerV2.spec.mjs

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -850,8 +850,8 @@ test.describe("Workspace Manager V2 bootstrap", () => {
850850
"id",
851851
"name",
852852
"text",
853-
"voice",
854853
"language",
854+
"voice",
855855
"volume",
856856
"rate",
857857
"pitch",
@@ -878,11 +878,13 @@ test.describe("Workspace Manager V2 bootstrap", () => {
878878
await expect(page.locator("#text2speach-V2ResumeButton")).toBeEnabled();
879879
await expect(page.locator("#text2speach-V2StopButton")).toBeEnabled();
880880
await expect(page.locator("#text2speach-V2StatusLog")).toHaveValue(/OK Loaded 3 schema-complete text2speach-V2 queue items\./);
881-
await expect(page.locator("#text2speach-V2StatusLog")).toHaveValue(/OK Loaded 3 SpeechSynthesis voices for text2speach-V2\./);
881+
await expect(page.locator("#text2speach-V2StatusLog")).toHaveValue(/OK Loaded 2 matching SpeechSynthesis voices for text2speach-V2 \(3 available; language=en-US\)\./);
882882
await expect(page.locator("#text2speach-V2StatusLog")).toHaveValue(/OK text2speach-V2 ready\. SpeechSynthesis is available\./);
883883
expect(await page.locator("#text2speach-V2QueueSelect option").evaluateAll((options) => options.map((option) => option.value))).toEqual(["narrator-welcome", "hero-ready", "alert-warning"]);
884-
expect(await page.locator("#text2speach-V2VoiceSelect option").evaluateAll((options) => options.map((option) => option.value))).toEqual(["mock-us", "mock-uk", "mock-alert"]);
884+
expect(await page.locator("#text2speach-V2SpeechOptionsContent label").evaluateAll((labels) => labels.map((label) => label.getAttribute("for")).slice(0, 2))).toEqual(["text2speach-V2LanguageSelect", "text2speach-V2VoiceSelect"]);
885885
expect(await page.locator("#text2speach-V2LanguageSelect option").evaluateAll((options) => options.map((option) => option.value))).toEqual(["en-US", "en-GB", "es-ES", "fr-FR", "ja-JP"]);
886+
expect(await page.locator("#text2speach-V2VoiceSelect option").evaluateAll((options) => options.map((option) => option.value))).toEqual(["mock-us", "mock-alert"]);
887+
await expect(page.locator("#text2speach-V2VoiceDetails")).toHaveText("2 voices match en-US: Mock US Voice, Mock Alert Voice.");
886888
expect(await page.locator("#text2speach-V2QueueModeSelect option").evaluateAll((options) => options.map((option) => option.value))).toEqual(["replace", "append"]);
887889
expect(await page.locator("#text2speach-V2RepeatCountSelect option").evaluateAll((options) => options.map((option) => option.value))).toEqual(["1", "2", "3", "loop"]);
888890
expect(await page.locator("#text2speach-V2CharacterPresetSelect option").evaluateAll((options) => options.map((option) => option.value))).toEqual(["narrator", "hero", "villain", "alert"]);
@@ -934,8 +936,20 @@ test.describe("Workspace Manager V2 bootstrap", () => {
934936
await expect(page.locator("#text2speach-V2SpeechText")).toHaveValue("Systems ready. The hero prompt is queued for an upbeat menu confirmation.");
935937
await page.locator("#text2speach-V2SpeechText").fill("Mission control confirms launch readiness.");
936938
await expect(page.locator("#text2speach-V2SpeakButton")).toBeEnabled();
937-
await page.locator("#text2speach-V2VoiceSelect").selectOption("mock-uk");
938939
await page.locator("#text2speach-V2LanguageSelect").selectOption("en-GB");
940+
await expect(page.locator("#text2speach-V2VoiceSelect option")).toHaveText(["Mock UK Voice (en-GB)"]);
941+
await expect(page.locator("#text2speach-V2VoiceSelect")).toHaveValue("mock-uk");
942+
await expect(page.locator("#text2speach-V2VoiceDetails")).toHaveText("1 voice matches en-GB: Mock UK Voice.");
943+
await expect(page.locator("#text2speach-V2StatusLog")).toHaveValue(/OK Voice selection adjusted for language en-GB: Mock UK Voice \(en-GB\)\./);
944+
await page.locator("#text2speach-V2LanguageSelect").selectOption("es-ES");
945+
await expect(page.locator("#text2speach-V2VoiceSelect option")).toHaveText(["No voices for es-ES"]);
946+
await expect(page.locator("#text2speach-V2VoiceSelect")).toHaveValue("");
947+
await expect(page.locator("#text2speach-V2SpeakButton")).toBeDisabled();
948+
await expect(page.locator("#text2speach-V2VoiceDetails")).toHaveText("0 voices match es-ES. 3 total voices loaded.");
949+
await expect(page.locator("#text2speach-V2StatusLog")).toHaveValue(/FAIL Voice selection cleared for language es-ES: no matching SpeechSynthesis voices\./);
950+
await page.locator("#text2speach-V2LanguageSelect").selectOption("en-GB");
951+
await expect(page.locator("#text2speach-V2VoiceSelect")).toHaveValue("mock-uk");
952+
await expect(page.locator("#text2speach-V2SpeakButton")).toBeEnabled();
939953
await page.locator("#text2speach-V2QueueModeSelect").selectOption("append");
940954
await page.locator("#text2speach-V2RepeatCountSelect").selectOption("3");
941955
await page.locator("#text2speach-V2CharacterPresetSelect").selectOption("villain");
@@ -992,11 +1006,12 @@ test.describe("Workspace Manager V2 bootstrap", () => {
9921006
await expect(page.locator("#text2speach-V2SpeakButton")).toBeDisabled();
9931007
await expect(page.locator("#text2speach-V2StatusLog")).toHaveValue(/FAIL text2speach-V2 voice dropdown has no SpeechSynthesis voices; waiting for voiceschanged\. Speak is disabled\./);
9941008
await page.evaluate(() => window["__text2speach-V2LoadVoices"]());
995-
await expect(page.locator("#text2speach-V2VoiceSelect option")).toHaveCount(3);
996-
expect(await page.locator("#text2speach-V2VoiceSelect option").evaluateAll((options) => options.map((option) => option.value))).toEqual(["mock-us", "mock-uk", "mock-alert"]);
1009+
await expect(page.locator("#text2speach-V2VoiceSelect option")).toHaveCount(2);
1010+
expect(await page.locator("#text2speach-V2VoiceSelect option").evaluateAll((options) => options.map((option) => option.value))).toEqual(["mock-us", "mock-alert"]);
9971011
await expect(page.locator("#text2speach-V2VoiceSelect")).toHaveValue("mock-us");
1012+
await expect(page.locator("#text2speach-V2VoiceDetails")).toHaveText("2 voices match en-US: Mock US Voice, Mock Alert Voice.");
9981013
await expect(page.locator("#text2speach-V2SpeakButton")).toBeEnabled();
999-
await expect(page.locator("#text2speach-V2StatusLog")).toHaveValue(/OK Updated 3 SpeechSynthesis voices for text2speach-V2\./);
1014+
await expect(page.locator("#text2speach-V2StatusLog")).toHaveValue(/OK Updated 2 matching SpeechSynthesis voices for text2speach-V2 \(3 available; language=en-US\)\./);
10001015
const summary = JSON.parse(await page.locator("#text2speach-V2SpeechSummary").textContent());
10011016
expect(summary.status).toBe("voices-updated");
10021017
expect(summary.voice).toBe("mock-us");
@@ -2538,7 +2553,7 @@ test.describe("Workspace Manager V2 bootstrap", () => {
25382553
await expect(page.locator("#text2speach-V2WorkspacePauseButton")).toBeEnabled();
25392554
await expect(page.locator("#text2speach-V2WorkspaceResumeButton")).toBeEnabled();
25402555
await expect(page.locator("#text2speach-V2WorkspaceStopButton")).toBeEnabled();
2541-
await expect(page.locator("#text2speach-V2VoiceSelect option")).toHaveCount(3);
2556+
await expect(page.locator("#text2speach-V2VoiceSelect option")).toHaveCount(2);
25422557
await page.locator("#text2speach-V2WorkspaceSpeakButton").click();
25432558
await expect(page.locator("#text2speach-V2StatusLog")).toHaveValue(/OK Speak queued: en-US; voice=Mock US Voice; rate=1; pitch=1; volume=1; repeats=1\./);
25442559
const spoken = await page.evaluate(() => window["__text2speach-V2Spoken"]);

tools/schemas/tools/text2speach-V2.schema.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@
3737
"id",
3838
"name",
3939
"text",
40-
"voice",
4140
"language",
41+
"voice",
4242
"volume",
4343
"rate",
4444
"pitch",
@@ -63,13 +63,13 @@
6363
"type": "string",
6464
"minLength": 1
6565
},
66-
"voice": {
67-
"type": "string"
68-
},
6966
"language": {
7067
"type": "string",
7168
"enum": ["en-US", "en-GB", "es-ES", "fr-FR", "ja-JP"]
7269
},
70+
"voice": {
71+
"type": "string"
72+
},
7373
"volume": {
7474
"type": "number",
7575
"minimum": 0,

tools/text2speach-V2/index.html

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,15 @@ <h2 class="tools-platform-frame__eyebrow">First-Class Tools Surface V2</h2>
8585
<span class="accordion-v2__icon" aria-hidden="true">+</span>
8686
</button>
8787
<div id="text2speach-V2SpeechOptionsContent" class="accordion-v2__content text2speach-V2__options">
88-
<label class="text2speach-V2__field" for="text2speach-V2VoiceSelect">
89-
<span>Voice</span>
90-
<select id="text2speach-V2VoiceSelect"></select>
91-
</label>
9288
<label class="text2speach-V2__field" for="text2speach-V2LanguageSelect">
9389
<span>Language</span>
9490
<select id="text2speach-V2LanguageSelect"></select>
9591
</label>
92+
<label class="text2speach-V2__field" for="text2speach-V2VoiceSelect">
93+
<span>Voice</span>
94+
<select id="text2speach-V2VoiceSelect"></select>
95+
</label>
96+
<div id="text2speach-V2VoiceDetails" class="text2speach-V2__details" aria-live="polite">No voice data loaded.</div>
9697
<label class="text2speach-V2__field" for="text2speach-V2VolumeSlider">
9798
<span>Volume <output id="text2speach-V2VolumeOutput" for="text2speach-V2VolumeSlider"></output></span>
9899
<input id="text2speach-V2VolumeSlider" type="range">

tools/text2speach-V2/js/TextToSpeechToolApp.js

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ export class TextToSpeechToolApp {
7979
onInput: () => this.refreshOutputSummary("text-updated")
8080
});
8181
this.speechOptions.mount({
82-
onChange: () => {
82+
onChange: ({ controlId } = {}) => {
83+
if (controlId === "language") {
84+
this.refreshVoices("language-changed");
85+
}
8386
this.refreshOutputSummary("settings-updated");
8487
if (this.speechOptions.value().autoSpeak) {
8588
this.speak();
@@ -140,11 +143,23 @@ export class TextToSpeechToolApp {
140143

141144
refreshVoices(source = "initial") {
142145
const result = this.speechOptions.populateVoices(this.engine.voiceOptions(), this.speechOptions.value().voice);
143-
if (result.voiceCount > 0) {
144-
const action = source === "voiceschanged" ? "Updated" : "Loaded";
145-
this.statusLog.ok(`${action} ${result.voiceCount} SpeechSynthesis voices for text2speach-V2.`);
146+
if (result.matchingVoiceCount > 0) {
147+
const action = source === "voiceschanged"
148+
? "Updated"
149+
: source === "initial" ? "Loaded" : "Filtered";
150+
this.statusLog.ok(`${action} ${result.matchingVoiceCount} matching SpeechSynthesis voices for text2speach-V2 (${result.voiceCount} available; language=${result.language}).`);
146151
} else {
147-
this.statusLog.fail("text2speach-V2 voice dropdown has no SpeechSynthesis voices; waiting for voiceschanged. Speak is disabled.");
152+
const message = result.voiceCount === 0
153+
? "text2speach-V2 voice dropdown has no SpeechSynthesis voices; waiting for voiceschanged. Speak is disabled."
154+
: `text2speach-V2 voice dropdown has no SpeechSynthesis voices matching ${result.language}; voice selection cleared. Speak is disabled.`;
155+
this.statusLog.fail(message);
156+
}
157+
if (source === "language-changed" && result.selectionAdjusted) {
158+
if (result.selectedVoice) {
159+
this.statusLog.ok(`Voice selection adjusted for language ${result.language}: ${result.selectedVoiceLabel}.`);
160+
} else {
161+
this.statusLog.fail(`Voice selection cleared for language ${result.language}: no matching SpeechSynthesis voices.`);
162+
}
148163
}
149164
this.refreshActionState();
150165
return result;
@@ -157,6 +172,9 @@ export class TextToSpeechToolApp {
157172
}
158173
this.textInput.setText(item.text);
159174
this.speechOptions.setValue(item);
175+
if (status !== "queue-loaded") {
176+
this.refreshVoices(status);
177+
}
160178
this.refreshOutputSummary(status);
161179
}
162180

tools/text2speach-V2/js/bootstrap.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ window.addEventListener("DOMContentLoaded", () => {
6161
rateSlider: requireElement("#text2speach-V2RateSlider"),
6262
repeatCountSelect: requireElement("#text2speach-V2RepeatCountSelect"),
6363
ssmlLikePresetSelect: requireElement("#text2speach-V2SsmlLikePresetSelect"),
64+
voiceDetails: requireElement("#text2speach-V2VoiceDetails"),
6465
voiceSelect: requireElement("#text2speach-V2VoiceSelect"),
6566
volumeOutput: requireElement("#text2speach-V2VolumeOutput"),
6667
volumeSlider: requireElement("#text2speach-V2VolumeSlider")

0 commit comments

Comments
 (0)