Skip to content

Commit 21dbcfb

Browse files
author
DavidQ
committed
Correct Text to Speech V2 array schema and add JSON tool nav actions - PR_26130_019-text-to-speech-v2-schema-and-tool-json-nav
1 parent 1ca6002 commit 21dbcfb

11 files changed

Lines changed: 409 additions & 139 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# PR_26130_019-text-to-speech-v2-schema-and-tool-json-nav
2+
3+
## Summary
4+
5+
- Converted the Text to Speech V2 schema contract to a root array of named speech items only.
6+
- Updated Text to Speech V2 defaults, load validation, workspace write-back, and standalone Import JSON / Copy JSON / Export JSON actions to use the root array payload.
7+
- Added template-style standalone/workspace nav behavior: standalone shows JSON actions; workspace launch shows Return to Workspace and preserves `hostContextId`.
8+
- Removed planned roadmap cards from `tools/index.html` for Browser Speech Backend, eSpeak NG WASM Backend, Queue-Based Speech Playback, Offline / Local Speech Support, and Raspberry Pi Speech Deployment.
9+
- Kept unrelated tool schemas unchanged; only `tools/schemas/tools/text2speach-V2.schema.json` was updated.
10+
11+
## Scope Notes
12+
13+
- Runtime-only Text to Speech V2 UI state such as selected item, status, and queued runtime speaker details remains in the UI summary only and is not copied/exported/saved as the schema payload.
14+
- Workspace Manager V2 runtime glue was updated only where required to keep Text to Speech V2 array payloads hydrated, refreshed, summarized, and saved.
15+
- Workspace Manager schema contracts were not changed.
16+
17+
## Validation
18+
19+
- `npm run test:workspace-v2` passed: 31 tests passed.
20+
- Full samples smoke test skipped: this PR is limited to Text to Speech V2 schema/tool JSON navigation and `tools/index.html` planned-section cleanup; it does not modify broad sample launch/runtime paths.
21+
22+
## Artifacts
23+
24+
- `docs/dev/reports/codex_review.diff`
25+
- `docs/dev/reports/codex_changed_files.txt`
26+
- `tmp/PR_26130_019-text-to-speech-v2-schema-and-tool-json-nav_delta.zip`

src/engine/audio/TextToSpeechDefaults.js

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
const TEXT_TO_SPEECH_TOOL_ID = "text2speach-V2";
22
const TEXT_TO_SPEECH_SCHEMA_ID = "tools/schemas/tools/text2speach-V2.schema.json";
3-
const TEXT_TO_SPEECH_PAYLOAD_SCHEMA = "html-js-gaming.text2speach-V2";
43
const TEXT_TO_SPEECH_DISPLAY_NAME = "Text to Speech V2";
54

65
const TEXT_TO_SPEECH_LANGUAGE_OPTIONS = Object.freeze([
@@ -151,13 +150,7 @@ const TEXT_TO_SPEECH_DEFAULT_QUEUE = Object.freeze([
151150
})
152151
]);
153152

154-
const TEXT_TO_SPEECH_DEFAULT_QUEUE_DATA = Object.freeze({
155-
$schema: TEXT_TO_SPEECH_SCHEMA_ID,
156-
schema: TEXT_TO_SPEECH_PAYLOAD_SCHEMA,
157-
version: 1,
158-
name: "Text to Speech V2 default queue",
159-
queue: TEXT_TO_SPEECH_DEFAULT_QUEUE
160-
});
153+
const TEXT_TO_SPEECH_DEFAULT_QUEUE_DATA = TEXT_TO_SPEECH_DEFAULT_QUEUE;
161154

162155
const TEXT_TO_SPEECH_DEFAULTS = Object.freeze({
163156
...TEXT_TO_SPEECH_DEFAULT_OPTIONS,
@@ -177,7 +170,6 @@ export {
177170
TEXT_TO_SPEECH_DISPLAY_NAME,
178171
TEXT_TO_SPEECH_GENDER_FILTER_OPTIONS,
179172
TEXT_TO_SPEECH_LANGUAGE_OPTIONS,
180-
TEXT_TO_SPEECH_PAYLOAD_SCHEMA,
181173
TEXT_TO_SPEECH_QUEUE_ITEM_REQUIRED_FIELDS,
182174
TEXT_TO_SPEECH_QUEUE_MODE_OPTIONS,
183175
TEXT_TO_SPEECH_RANGE_DEFAULTS,

tests/playwright/tools/WorkspaceManagerV2.spec.mjs

Lines changed: 179 additions & 43 deletions
Large diffs are not rendered by default.

tools/index.html

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,6 @@ <h3>Audio / SFX Playground</h3>
105105
</div>
106106
</div>
107107

108-
<div class="card">
109-
<h3>Browser Speech Backend (speechSynthesis)</h3>
110-
<p>Keep the browser-native speech backend available for quick previews, prompts, and narration workflows.</p>
111-
<div class="meta">
112-
<span class="pill planned">Planned</span>
113-
</div>
114-
</div>
115-
116108
<div class="card">
117109
<h3>Piper WASM Backend</h3>
118110
<p>Add a local Piper-powered speech backend for higher-quality offline voice generation in supported browsers.</p>
@@ -121,14 +113,6 @@ <h3>Piper WASM Backend</h3>
121113
</div>
122114
</div>
123115

124-
<div class="card">
125-
<h3>eSpeak NG WASM Backend</h3>
126-
<p>Add a lightweight eSpeak NG backend option for compact offline speech coverage across languages.</p>
127-
<div class="meta">
128-
<span class="pill planned">Planned</span>
129-
</div>
130-
</div>
131-
132116
<div class="card">
133117
<h3>Optional SSML Processing Layer</h3>
134118
<p>Plan an optional SSML-style processing layer for pauses, emphasis, pronunciation, and delivery hints.</p>
@@ -137,14 +121,6 @@ <h3>Optional SSML Processing Layer</h3>
137121
</div>
138122
</div>
139123

140-
<div class="card">
141-
<h3>Queue-Based Speech Playback</h3>
142-
<p>Expand queue playback planning for ordered lines, repeats, delays, replacement, and append behavior.</p>
143-
<div class="meta">
144-
<span class="pill planned">Planned</span>
145-
</div>
146-
</div>
147-
148124
<div class="card">
149125
<h3>Character Voice Presets</h3>
150126
<p>Plan character-oriented voice presets for reusable narrator, hero, villain, alert, and game role settings.</p>
@@ -153,22 +129,6 @@ <h3>Character Voice Presets</h3>
153129
</div>
154130
</div>
155131

156-
<div class="card">
157-
<h3>Offline / Local Speech Support</h3>
158-
<p>Support local speech generation paths that do not require network services during gameplay or authoring.</p>
159-
<div class="meta">
160-
<span class="pill planned">Planned</span>
161-
</div>
162-
</div>
163-
164-
<div class="card">
165-
<h3>Raspberry Pi Speech Deployment</h3>
166-
<p>Document and validate speech deployment paths for Raspberry Pi arcade cabinets and kiosk-style builds.</p>
167-
<div class="meta">
168-
<span class="pill planned">Planned</span>
169-
</div>
170-
</div>
171-
172132
<div class="card">
173133
<h3>Game Character Voice / Event Integration</h3>
174134
<p>Connect character voice presets to game events, prompts, menus, hazards, and in-game feedback triggers.</p>

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

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,10 @@
22
"$schema": "https://json-schema.org/draft/2020-12/schema",
33
"$id": "tools/schemas/tools/text2speach-V2.schema.json",
44
"title": "Text to Speech V2 Payload",
5-
"type": "object",
6-
"required": ["$schema", "schema", "version", "name", "queue"],
7-
"additionalProperties": false,
8-
"properties": {
9-
"$schema": {
10-
"type": "string",
11-
"const": "tools/schemas/tools/text2speach-V2.schema.json"
12-
},
13-
"schema": {
14-
"type": "string",
15-
"const": "html-js-gaming.text2speach-V2"
16-
},
17-
"version": {
18-
"type": "integer",
19-
"minimum": 1
20-
},
21-
"name": {
22-
"type": "string",
23-
"minLength": 1
24-
},
25-
"queue": {
26-
"type": "array",
27-
"minItems": 1,
28-
"items": {
29-
"$ref": "#/$defs/speechQueueItem"
30-
}
31-
}
5+
"type": "array",
6+
"minItems": 1,
7+
"items": {
8+
"$ref": "#/$defs/speechQueueItem"
329
},
3310
"$defs": {
3411
"speechQueueItem": {
@@ -108,5 +85,5 @@
10885
}
10986
}
11087
},
111-
"description": "Payload-only Text to Speech V2 schema for workspace-embeddable speech queue data."
88+
"description": "Payload-only Text to Speech V2 schema. The persisted payload is a root array of named speech items; runtime UI state is not part of this contract."
11289
}

tools/text2speach-V2/index.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ <h2 class="tools-platform-frame__eyebrow">Browser speech synthesis</h2>
3636
</div>
3737
</details>
3838

39+
<nav class="text2speach-V2__menu text2speach-V2__tool__menu" aria-label="Text to Speech JSON actions" data-launch-mode-nav="tool">
40+
<div class="text2speach-V2__menu-actions">
41+
<button id="text2speach-V2ImportJsonButton" type="button">Import JSON</button>
42+
<input id="text2speach-V2ImportJsonInput" type="file" accept="application/json,.json" hidden>
43+
<button id="text2speach-V2CopyJsonButton" type="button">Copy JSON</button>
44+
<button id="text2speach-V2ExportJsonButton" type="button">Export JSON</button>
45+
</div>
46+
</nav>
47+
3948
<nav class="text2speach-V2__menu text2speach-V2__workspace-menu" aria-label="Workspace actions" data-launch-mode-nav="workspace" hidden>
4049
<div class="text2speach-V2__menu-actions">
4150
<button id="returnToWorkspaceButton" type="button">Return to Workspace</button>

tools/text2speach-V2/js/TextToSpeechToolApp.js

Lines changed: 130 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,13 @@ export class TextToSpeechToolApp {
207207
voiceAgePresetDefaults: TEXT_TO_SPEECH_VOICE_AGE_PRESET_DEFAULTS
208208
});
209209
this.actionNav.mount({
210+
onCopyJson: () => {
211+
void this.copyJson();
212+
},
213+
onExportJson: () => this.exportJson(),
214+
onImportJson: (file) => {
215+
void this.importJson(file);
216+
},
210217
onPause: () => this.pause(),
211218
onResume: () => this.resume(),
212219
onReturnToWorkspace: (url) => {
@@ -330,18 +337,18 @@ export class TextToSpeechToolApp {
330337
this.actionNav.setSpeakEnabled(false);
331338
return;
332339
}
333-
const validation = validateQueue(queueDataResult.payload.queue);
340+
const validation = validateQueue(queueDataResult.payload);
334341
if (!validation.ok) {
335342
this.statusLog.fail(validation.message);
336343
this.actionNav.setSpeakEnabled(false);
337344
return;
338345
}
339-
this.queueControl.populate(queueDataResult.payload.queue);
340-
this.applyQueueItem(this.queueControl.selectedItem() || queueDataResult.payload.queue[0], "queue-loaded");
346+
this.queueControl.populate(queueDataResult.payload);
347+
this.applyQueueItem(this.queueControl.selectedItem() || queueDataResult.payload[0], "queue-loaded");
341348
this.statusLog.ok(`Loaded ${TEXT_TO_SPEECH_DISPLAY_NAME} payload source: ${queueDataResult.sourcePath}.`);
342-
this.statusLog.ok(`${TEXT_TO_SPEECH_DISPLAY_NAME} schema validation result: ${TEXT_TO_SPEECH_SCHEMA_ID} valid; queue=${queueDataResult.payload.queue.length}.`);
349+
this.statusLog.ok(`${TEXT_TO_SPEECH_DISPLAY_NAME} schema validation result: ${TEXT_TO_SPEECH_SCHEMA_ID} valid; queue=${queueDataResult.payload.length}.`);
343350
this.statusLog.ok(`${TEXT_TO_SPEECH_DISPLAY_NAME} dirty state: ${queueDataResult.dirtyState}.`);
344-
this.statusLog.ok(`Loaded ${queueDataResult.payload.queue.length} schema-complete ${TEXT_TO_SPEECH_DISPLAY_NAME} queue items.`);
351+
this.statusLog.ok(`Loaded ${queueDataResult.payload.length} schema-complete ${TEXT_TO_SPEECH_DISPLAY_NAME} queue items.`);
345352
}
346353

347354
queueData() {
@@ -366,7 +373,10 @@ export class TextToSpeechToolApp {
366373
}
367374
try {
368375
const toolState = JSON.parse(rawToolState);
369-
if (!isPlainObject(toolState?.data)) {
376+
if (!isPlainObject(toolState)) {
377+
return { ok: false, message: `${WORKSPACE_TOOL_STATE_KEY} must contain the normalized workspace toolState object before render.` };
378+
}
379+
if (!Object.prototype.hasOwnProperty.call(toolState, "data")) {
370380
return { ok: false, message: `${WORKSPACE_TOOL_STATE_KEY}.data must contain the ${TEXT_TO_SPEECH_DISPLAY_NAME} payload before render.` };
371381
}
372382
return {
@@ -455,10 +465,7 @@ export class TextToSpeechToolApp {
455465
this.statusLog.fail(`Cannot mark ${TEXT_TO_SPEECH_DISPLAY_NAME} dirty: ${WORKSPACE_TOOL_STATE_KEY} is not an object.`);
456466
return;
457467
}
458-
const nextData = {
459-
...(isPlainObject(toolState.data) ? toolState.data : {}),
460-
queue: this.queueControl.selectedQueue()
461-
};
468+
const nextData = this.queueControl.selectedQueue();
462469
if (this.payloadSchema) {
463470
const validation = this.validatePayload(nextData, toolState.workspace?.gameManifestPath || WORKSPACE_TOOL_STATE_KEY);
464471
if (!validation.ok) {
@@ -476,13 +483,125 @@ export class TextToSpeechToolApp {
476483
changedKeys
477484
}
478485
}));
479-
this.statusLog.ok(`${TEXT_TO_SPEECH_DISPLAY_NAME} dirty state: true; reason=${reason}; changedKeys=${changedKeys.join(", ")}; queue=${nextData.queue.length}.`);
486+
this.statusLog.ok(`${TEXT_TO_SPEECH_DISPLAY_NAME} dirty state: true; reason=${reason}; changedKeys=${changedKeys.join(", ")}; queue=${nextData.length}.`);
480487
this.statusLog.ok(`${TEXT_TO_SPEECH_DISPLAY_NAME} manifest write-back target: ${toolState.workspace?.gameManifestPath || "(missing manifest path)"}.`);
481488
} catch (error) {
482489
this.statusLog.fail(`Cannot mark ${TEXT_TO_SPEECH_DISPLAY_NAME} dirty: ${error.message}`);
483490
}
484491
}
485492

493+
async ensurePayloadSchemaForAction(actionLabel) {
494+
const schemaResult = await this.loadPayloadSchema();
495+
if (!schemaResult.ok) {
496+
this.statusLog.fail(`${actionLabel} blocked: ${schemaResult.message}`);
497+
return false;
498+
}
499+
return true;
500+
}
501+
502+
validateCurrentPayloadForAction(actionLabel) {
503+
const payload = this.queueControl.selectedQueue();
504+
const payloadValidation = this.validatePayload(payload, `${TEXT_TO_SPEECH_DISPLAY_NAME} current UI payload`);
505+
if (!payloadValidation.ok) {
506+
this.statusLog.fail(`${actionLabel} blocked: ${payloadValidation.message}`);
507+
return { ok: false };
508+
}
509+
const queueValidation = validateQueue(payload);
510+
if (!queueValidation.ok) {
511+
this.statusLog.fail(`${actionLabel} blocked: ${queueValidation.message}`);
512+
return { ok: false };
513+
}
514+
return { ok: true, payload };
515+
}
516+
517+
async importJson(file) {
518+
if (this.isWorkspaceLaunch()) {
519+
this.statusLog.fail(`${TEXT_TO_SPEECH_DISPLAY_NAME} Import JSON is only available during standalone launch.`);
520+
return;
521+
}
522+
if (!file) {
523+
this.statusLog.fail(`${TEXT_TO_SPEECH_DISPLAY_NAME} Import JSON blocked: choose a JSON file first.`);
524+
return;
525+
}
526+
if (!(await this.ensurePayloadSchemaForAction("Import JSON"))) {
527+
return;
528+
}
529+
530+
let payload;
531+
try {
532+
payload = JSON.parse(await file.text());
533+
} catch (error) {
534+
this.statusLog.fail(`${TEXT_TO_SPEECH_DISPLAY_NAME} Import JSON failed: ${file.name || "selected file"} is invalid JSON: ${error.message}`);
535+
return;
536+
}
537+
const sourcePath = file.name || "selected JSON file";
538+
const payloadValidation = this.validatePayload(payload, sourcePath);
539+
if (!payloadValidation.ok) {
540+
this.statusLog.fail(`Import JSON blocked: ${payloadValidation.message}`);
541+
return;
542+
}
543+
const queueValidation = validateQueue(payload);
544+
if (!queueValidation.ok) {
545+
this.statusLog.fail(`Import JSON blocked: ${queueValidation.message}`);
546+
return;
547+
}
548+
this.queueControl.populate(payload);
549+
this.applyQueueItem(this.queueControl.selectedItem() || payload[0], "json-imported");
550+
this.refreshVoices("json-imported");
551+
this.refreshOutputSummary("json-imported");
552+
this.statusLog.ok(`Imported ${payload.length} ${TEXT_TO_SPEECH_DISPLAY_NAME} item${payload.length === 1 ? "" : "s"} from ${sourcePath}; schema validation result: ${TEXT_TO_SPEECH_SCHEMA_ID} valid.`);
553+
}
554+
555+
async copyJson() {
556+
if (this.isWorkspaceLaunch()) {
557+
this.statusLog.fail(`${TEXT_TO_SPEECH_DISPLAY_NAME} Copy JSON is only available during standalone launch.`);
558+
return;
559+
}
560+
if (!(await this.ensurePayloadSchemaForAction("Copy JSON"))) {
561+
return;
562+
}
563+
const validation = this.validateCurrentPayloadForAction("Copy JSON");
564+
if (!validation.ok) {
565+
return;
566+
}
567+
if (!this.window.navigator?.clipboard || typeof this.window.navigator.clipboard.writeText !== "function") {
568+
this.statusLog.fail("Copy JSON failed: Clipboard API is unavailable.");
569+
return;
570+
}
571+
const json = JSON.stringify(validation.payload, null, 2);
572+
try {
573+
await this.window.navigator.clipboard.writeText(json);
574+
this.statusLog.ok(`Copied ${TEXT_TO_SPEECH_DISPLAY_NAME} JSON root array to clipboard (${validation.payload.length} item${validation.payload.length === 1 ? "" : "s"}).`);
575+
} catch (error) {
576+
this.statusLog.fail(`Copy JSON failed: ${error.message}`);
577+
}
578+
}
579+
580+
async exportJson() {
581+
if (this.isWorkspaceLaunch()) {
582+
this.statusLog.fail(`${TEXT_TO_SPEECH_DISPLAY_NAME} Export JSON is only available during standalone launch.`);
583+
return;
584+
}
585+
if (!(await this.ensurePayloadSchemaForAction("Export JSON"))) {
586+
return;
587+
}
588+
const validation = this.validateCurrentPayloadForAction("Export JSON");
589+
if (!validation.ok) {
590+
return;
591+
}
592+
const json = JSON.stringify(validation.payload, null, 2);
593+
const blob = new Blob([json], { type: "application/json" });
594+
const url = URL.createObjectURL(blob);
595+
const link = this.window.document.createElement("a");
596+
link.href = url;
597+
link.download = "text-to-speech-v2.json";
598+
this.window.document.body.append(link);
599+
link.click();
600+
link.remove();
601+
URL.revokeObjectURL(url);
602+
this.statusLog.ok(`Exported ${TEXT_TO_SPEECH_DISPLAY_NAME} JSON root array (${validation.payload.length} item${validation.payload.length === 1 ? "" : "s"}).`);
603+
}
604+
486605
addSpeechItem() {
487606
const requestedName = this.queueControl.itemName();
488607
if (!requestedName) {

tools/text2speach-V2/js/bootstrap.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ window.addEventListener("DOMContentLoaded", () => {
2525
resumeButtons: [requireElement("#text2speach-V2ResumeButton")],
2626
speakButtons: [requireElement("#text2speach-V2SpeakButton")],
2727
stopButtons: [requireElement("#text2speach-V2StopButton")],
28+
toolCopyJsonButton: requireElement("#text2speach-V2CopyJsonButton"),
29+
toolExportJsonButton: requireElement("#text2speach-V2ExportJsonButton"),
30+
toolImportJsonButton: requireElement("#text2speach-V2ImportJsonButton"),
31+
toolImportJsonInput: requireElement("#text2speach-V2ImportJsonInput"),
32+
toolNav: requireElement('[data-launch-mode-nav="tool"]'),
2833
workspaceNav: requireElement('[data-launch-mode-nav="workspace"]')
2934
}),
3035
engine: new TextToSpeechEngine(),

0 commit comments

Comments
 (0)