diff --git a/docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md b/docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md new file mode 100644 index 000000000..fedefd391 --- /dev/null +++ b/docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md @@ -0,0 +1,31 @@ +# PR_26171_067 Instruction Compliance Checklist + +## Required Reads + +- PASS: Read `docs_build/dev/PROJECT_INSTRUCTIONS.md` before implementation. +- PASS: Read `docs_build/dev/PROJECT_MULTI_PC.txt` before implementation. +- PASS: Read repository `AGENTS.md` instructions from the active workspace context. +- PASS: Read relevant target files before editing. + +## Gate Checks + +- PASS: Started from `main`. +- PASS: Pulled latest `origin/main`. +- PASS: Repo was clean before branch creation. +- PASS: Created scoped branch `pr/26171-067-tts-profile-emotion-table-foundation`. +- PASS: PR number `067` is odd and assigned to Laptop / Environment 2. +- PASS: TTS Studio is within Laptop ownership. +- PASS: Active path is `toolbox/text-to-speech/`. +- PASS: No wrong `tools/text2speech/` path was created. +- PASS: No database changes were made. +- PASS: No placeholder-only provider blocking behavior was introduced. + +## Required Artifacts + +- PASS: PR-specific report created. +- PASS: Parent-child table checklist created. +- PASS: Message/TTS contract checklist created. +- PASS: Validation report created. +- PASS: Manual validation notes created. +- PASS: `codex_review.diff` and `codex_changed_files.txt` will be generated from the final scoped diff. +- PASS: Repo-structured delta ZIP will be created under `tmp/`. diff --git a/docs_build/dev/reports/PR_26171_067-manual-validation-notes.md b/docs_build/dev/reports/PR_26171_067-manual-validation-notes.md new file mode 100644 index 000000000..712c083b1 --- /dev/null +++ b/docs_build/dev/reports/PR_26171_067-manual-validation-notes.md @@ -0,0 +1,17 @@ +# PR_26171_067 Manual Validation Notes + +## Notes + +- Verified TTS Studio keeps the active path `toolbox/text-to-speech/`. +- Verified the page uses Theme V2 classes and external JavaScript only. +- Verified the profile table can open child Emotion Settings by clicking the profile row. +- Verified `Default Balanced Profile` delete is disabled because it is marked in use by Message Studio data. +- Verified default `Neutral` emotion delete is disabled because it is marked in use by Message Parts. +- Verified Add Profile, Edit Profile, Add Emotion, and Edit Emotion inline rows through targeted Playwright validation. +- Verified Message Studio remains separate and its existing TTS dropdown smoke path still passes. +- Verified no `tools/text2speech/` path was created. +- Verified no database files were changed. + +## Follow Up + +- Future persistence can connect TTS Studio profile authoring to the existing Local API profile contract once that API ownership is assigned. diff --git a/docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md b/docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md new file mode 100644 index 000000000..bf2819ba1 --- /dev/null +++ b/docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md @@ -0,0 +1,26 @@ +# PR_26171_067 Message TTS Contract Checklist + +## Ownership + +- PASS: Message Studio owns message text and ordered message parts. +- PASS: TTS Studio owns reusable TTS Profiles and per-profile Emotion Settings. +- PASS: `src/engine/audio/` remains the playback owner. +- PASS: Audio playback results remain owned by the audio engine flow. +- PASS: Message Studio and TTS Studio are not merged into one tool. + +## Contract Readiness + +- PASS: TTS Studio exposes `TTS_PROFILE_CONTRACT_VERSION` with value `tts-profile-emotion-v1`. +- PASS: TTS Studio exports `createMessageStudioTtsProfileOptions`. +- PASS: Exported profile options include stable keys, active state, display name, language, provider key, voice name, and active emotion settings. +- PASS: Emotion settings include emotion key, display label, pitch, rate, volume, and SSML-like preset. +- PASS: The output summary shows the contract version and Message Studio compatible profile options for local diagnostics. +- PASS: Existing Message Studio dropdown smoke validation still passes. + +## Boundaries + +- PASS: No database changes were introduced. +- PASS: No future provider behavior was hardcoded. +- PASS: No browser-owned product data was introduced as source of truth. +- PASS: Default profile data is limited to a local down-the-middle fallback until the API/data contract exists. +- PASS: Existing Message Studio Local API profile shape remains untouched. diff --git a/docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md b/docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md new file mode 100644 index 000000000..065b3999d --- /dev/null +++ b/docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md @@ -0,0 +1,38 @@ +# PR_26171_067 Parent Child Table Checklist + +## Parent Table + +- PASS: Parent table label is `TTS Profiles`. +- PASS: Parent table lives in `toolbox/text-to-speech/`. +- PASS: Parent table columns are Profile Name, Voice, Language, Gender, Age, Emotion Count, Status, Actions. +- PASS: Default rows include `Default Balanced Profile`, `Man Profile 1`, and `Woman Profile 2`. +- PASS: Parent row click opens or closes the child Emotion Settings row. +- PASS: One selected profile row owns the visible child subtable at a time. +- PASS: Parent profile count is visible in the summary stats. + +## Parent Actions + +- PASS: Add Profile opens a new inline row below the parent table rows. +- PASS: Edit Profile opens an inline edit row for the selected profile. +- PASS: Save Profile validates required Profile Name and Language values. +- PASS: Duplicate Profile Name is blocked with a visible actionable error. +- PASS: Cancel Profile closes the inline editor without applying changes. +- PASS: Delete Profile removes unused profiles. +- PASS: Delete Profile is disabled when the profile has Message Studio usage. + +## Child Table + +- PASS: Child table label is `Emotion Settings`. +- PASS: Child table opens under the selected TTS Profile row. +- PASS: Child columns are Emotion, Pitch, Rate, Volume, SSML-like Preset, Status, Actions. +- PASS: Default neutral emotion is provided for every default profile. +- PASS: Emotion count is visible in both the profile row and summary stats. + +## Child Actions + +- PASS: Add Emotion opens a new inline row in the child table. +- PASS: Edit Emotion opens an inline edit row. +- PASS: Save Emotion validates selected profile and unique emotion per profile. +- PASS: Cancel Emotion closes the inline editor without applying changes. +- PASS: Delete Emotion removes unused emotions. +- PASS: Delete Emotion is disabled when the emotion has Message Parts usage. diff --git a/docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md b/docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md new file mode 100644 index 000000000..551954c0a --- /dev/null +++ b/docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md @@ -0,0 +1,50 @@ +# PR_26171_067 TTS Profile Emotion Table Foundation + +## Summary + +TTS Studio now presents a parent TTS Profiles table with an expandable child Emotion Settings table. The active tool remains `toolbox/text-to-speech/`, uses Theme V2, and keeps all JavaScript external. + +## Scope + +- Updated `toolbox/text-to-speech/index.html` to expose the requested parent and child table surfaces. +- Updated `toolbox/text-to-speech/text2speech.js` to seed reusable profiles, render child emotion settings, support inline add/edit rows, and block delete actions when profile or emotion usage is marked by Message Studio data. +- Added a TTS profile contract helper that returns Message Studio compatible options without moving ownership into Message Studio. +- Updated targeted TTS browser and unit validation. + +## Requirement Evidence + +- PASS: Active path remains `toolbox/text-to-speech/`. +- PASS: Parent table is TTS Profiles. +- PASS: Clicking a profile row opens the child Emotion Settings subtable. +- PASS: Parent rows include `Man Profile 1` and `Woman Profile 2`. +- PASS: Parent columns are Profile Name, Voice, Language, Gender, Age, Emotion Count, Status, Actions. +- PASS: Child columns are Emotion, Pitch, Rate, Volume, SSML-like Preset, Status, Actions. +- PASS: Add Profile opens an inline add row under the parent table. +- PASS: Edit Profile opens an inline edit row. +- PASS: Add Emotion opens an inline add row in the child table. +- PASS: Edit Emotion opens an inline edit row. +- PASS: Delete profile is disabled when usage count indicates Message Studio data uses it. +- PASS: Delete emotion is disabled when usage count indicates Message Parts use it. +- PASS: Default balanced profile and default neutral emotion are provided. +- PASS: Message Studio compatible profile options are exported for a future API/data contract. +- PASS: Message Studio and TTS Studio remain separate tools. +- PASS: No database changes were made. +- PASS: Theme V2 only; no page-local CSS, tool-local CSS, inline styles, style blocks, or inline handlers. + +## Validation + +- PASS: `node --check toolbox\text-to-speech\text2speech.js`. +- PASS: `node --check tests\playwright\tools\TextToSpeechFunctional.spec.mjs`. +- PASS: `node --check tests\tools\Text2SpeechShell.test.mjs`. +- PASS: HTML inline style/script/event scan for `toolbox/text-to-speech/index.html`. +- PASS: `node --test tests/tools/Text2SpeechShell.test.mjs`. +- PASS: `npx playwright test tests/playwright/tools/TextToSpeechFunctional.spec.mjs --project=playwright --workers=1 --reporter=list`. +- PASS: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright --workers=1 --reporter=list`. +- PASS: `npm run test:workspace-v2` (legacy command name; user-facing language is Project Workspace). + +## Out Of Scope + +- No Message Studio merge. +- No new provider behavior. +- No generated audio export. +- No database schema, seed, or persistence change. diff --git a/docs_build/dev/reports/PR_26171_067-validation.md b/docs_build/dev/reports/PR_26171_067-validation.md new file mode 100644 index 000000000..a2c228f1b --- /dev/null +++ b/docs_build/dev/reports/PR_26171_067-validation.md @@ -0,0 +1,43 @@ +# PR_26171_067 Validation Report + +## Commands Run + +- `git branch --show-current` + - PASS: started from `main`. +- `git checkout main` + - PASS. +- `git pull origin main` + - PASS: already up to date. +- `git status --short` + - PASS: clean before branch creation. +- `node --check toolbox\text-to-speech\text2speech.js` + - PASS. +- `node --check tests\playwright\tools\TextToSpeechFunctional.spec.mjs` + - PASS. +- `node --check tests\tools\Text2SpeechShell.test.mjs` + - PASS. +- `Select-String -Path toolbox\text-to-speech\index.html -Pattern ']+src=)|\son\w+=|style='` + - PASS: no matches. +- `node --test tests/tools/Text2SpeechShell.test.mjs` + - PASS: 4 tests passed. +- `npx playwright test tests/playwright/tools/TextToSpeechFunctional.spec.mjs --project=playwright --workers=1 --reporter=list` + - PASS: 2 tests passed. + - Covers default profiles, expandable Emotion Settings, inline add/edit rows, delete-disabled usage states, and existing speech composition. +- `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright --workers=1 --reporter=list` + - PASS: 2 tests passed. + - Covers Message Studio smoke compatibility for the existing TTS dropdown and audio-engine path. +- `npm run test:workspace-v2` + - PASS: 5 Project Workspace tests passed. + - Note: command name is legacy; user-facing language is Project Workspace. + +## Coverage + +- PASS: `docs_build/dev/reports/playwright_v8_coverage_report.txt` produced changed runtime JS coverage. +- PASS: `toolbox/text-to-speech/text2speech.js` covered by targeted browser validation. +- NOTE: The advisory coverage helper also listed the previous HEAD Message Studio files because it includes `git diff-tree HEAD` before this PR is committed. Those Message Studio files are unchanged in this PR and were separately smoke-checked with `MessagesTool.spec.mjs`. + +## Skipped + +- Database validation skipped because no database schema, seed, or persistence implementation changed. +- Full samples validation skipped because no samples changed. +- External TTS provider validation skipped because this PR does not implement provider behavior. diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt index e0820a401..7c10833d2 100644 --- a/docs_build/dev/reports/codex_changed_files.txt +++ b/docs_build/dev/reports/codex_changed_files.txt @@ -1,53 +1,34 @@ -# Codex Changed Files - PR_26171_065-message-studio-parent-child-table-foundation - -## Git Status Short -```text -M docs_build/dev/reports/coverage_changed_js_guardrail.txt +# git status --short +M docs_build/dev/reports/codex_changed_files.txt + M docs_build/dev/reports/codex_review.diff + M docs_build/dev/reports/coverage_changed_js_guardrail.txt M docs_build/dev/reports/playwright_v8_coverage_report.txt - M tests/playwright/tools/MessagesTool.spec.mjs - M toolbox/messages/index.html - M toolbox/messages/message-tts-service-registry.js - M toolbox/messages/messages.js -?? docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md -?? docs_build/dev/reports/PR_26171_065-manual-validation-notes.md -?? docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md -?? docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md -?? docs_build/dev/reports/PR_26171_065-validation.md -``` - -## Scoped Diff Stat -```text -.../dev/reports/coverage_changed_js_guardrail.txt | 2 + - .../dev/reports/playwright_v8_coverage_report.txt | 43 +- - tests/playwright/tools/MessagesTool.spec.mjs | 317 +++++--------- - toolbox/messages/index.html | 29 +- - toolbox/messages/message-tts-service-registry.js | 8 +- - toolbox/messages/messages.js | 486 ++++++++++++--------- - 6 files changed, 415 insertions(+), 470 deletions(-) -``` - -## Changed Files -- toolbox/messages/index.html -- toolbox/messages/messages.js -- toolbox/messages/message-tts-service-registry.js -- tests/playwright/tools/MessagesTool.spec.mjs -- docs_build/dev/reports/coverage_changed_js_guardrail.txt -- docs_build/dev/reports/playwright_v8_coverage_report.txt -- docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md -- docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md -- docs_build/dev/reports/PR_26171_065-validation.md -- docs_build/dev/reports/PR_26171_065-manual-validation-notes.md -- docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md -- docs_build/dev/reports/codex_review.diff -- docs_build/dev/reports/codex_changed_files.txt + M tests/playwright/tools/TextToSpeechFunctional.spec.mjs + M tests/tools/Text2SpeechShell.test.mjs + M toolbox/text-to-speech/index.html + M toolbox/text-to-speech/text2speech.js +?? docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md +?? docs_build/dev/reports/PR_26171_067-manual-validation-notes.md +?? docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md +?? docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md +?? docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md +?? docs_build/dev/reports/PR_26171_067-validation.md -## Validation -- PASS: `node --check toolbox\messages\messages.js`. -- PASS: `node --check toolbox\messages\message-tts-service-registry.js`. -- PASS: `node --check tests\playwright\tools\MessagesTool.spec.mjs`. -- PASS: HTML inline style/script/event scan for `toolbox/messages/index.html`. -- PASS: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright --workers=1 --reporter=list`. -- PASS: `npm run test:workspace-v2` (legacy command name; user-facing language is Project Workspace). +# git ls-files --others --exclude-standard +docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md +docs_build/dev/reports/PR_26171_067-manual-validation-notes.md +docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md +docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md +docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md +docs_build/dev/reports/PR_26171_067-validation.md -## ZIP -- Path: `tmp/PR_26171_065-message-studio-parent-child-table-foundation_delta.zip`. +# git diff --stat +docs_build/dev/reports/codex_changed_files.txt | 75 +- + docs_build/dev/reports/codex_review.diff | 2766 +++++++++----------- + .../dev/reports/coverage_changed_js_guardrail.txt | 9 +- + .../dev/reports/playwright_v8_coverage_report.txt | 27 +- + .../tools/TextToSpeechFunctional.spec.mjs | 42 +- + tests/tools/Text2SpeechShell.test.mjs | 48 + + toolbox/text-to-speech/index.html | 40 +- + toolbox/text-to-speech/text2speech.js | 728 +++++- + 8 files changed, 2153 insertions(+), 1582 deletions(-) \ No newline at end of file diff --git a/docs_build/dev/reports/codex_review.diff b/docs_build/dev/reports/codex_review.diff index 39d7f84a5..85677add8 100644 --- a/docs_build/dev/reports/codex_review.diff +++ b/docs_build/dev/reports/codex_review.diff @@ -1,1576 +1,4245 @@ diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt -index 15213f1d6..e0820a401 100644 +index e0820a401..33a70c4b4 100644 --- a/docs_build/dev/reports/codex_changed_files.txt +++ b/docs_build/dev/reports/codex_changed_files.txt -@@ -1,78 +1,53 @@ --# PR_26171_042 Codex Changed Files Report +@@ -1,53 +1,30 @@ +-# Codex Changed Files - PR_26171_065-message-studio-parent-child-table-foundation - --## Instruction Compliance Gate +-## Git Status Short +-```text ++# git status --short + M docs_build/dev/reports/coverage_changed_js_guardrail.txt + M docs_build/dev/reports/playwright_v8_coverage_report.txt +- M tests/playwright/tools/MessagesTool.spec.mjs +- M toolbox/messages/index.html +- M toolbox/messages/message-tts-service-registry.js +- M toolbox/messages/messages.js +-?? docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md +-?? docs_build/dev/reports/PR_26171_065-manual-validation-notes.md +-?? docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md +-?? docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md +-?? docs_build/dev/reports/PR_26171_065-validation.md +-``` - --- Current branch before execution: `main` --- Required execution branch before PR branch: `main` --- Branch validation: PASS --- Clean repository before branch creation: PASS --- PR owner/parity: PASS, `042` is even and Idea/Tool Display navigation scope maps to PC / Environment 1. --- Implementation path: PASS, active Idea Board and Theme V2 files only. --- Validation scope: PASS, targeted Idea Board, targeted Toolbox route for Idea Board, and workspace contract lane because shared Tool Display Mode behavior changed. --- Required reports: PASS, `docs_build/dev/reports/codex_review.diff` and this report are updated. --- ZIP requirement: PASS, `tmp/PR_26171_042-idea-board-navigation-fallback-cleanup_delta.zip` is required and produced before final delivery. +-## Scoped Diff Stat +-```text +-.../dev/reports/coverage_changed_js_guardrail.txt | 2 + +- .../dev/reports/playwright_v8_coverage_report.txt | 43 +- +- tests/playwright/tools/MessagesTool.spec.mjs | 317 +++++--------- +- toolbox/messages/index.html | 29 +- +- toolbox/messages/message-tts-service-registry.js | 8 +- +- toolbox/messages/messages.js | 486 ++++++++++++--------- +- 6 files changed, 415 insertions(+), 470 deletions(-) +-``` - --## Git Workflow Fields +-## Changed Files +-- toolbox/messages/index.html +-- toolbox/messages/messages.js +-- toolbox/messages/message-tts-service-registry.js +-- tests/playwright/tools/MessagesTool.spec.mjs +-- docs_build/dev/reports/coverage_changed_js_guardrail.txt +-- docs_build/dev/reports/playwright_v8_coverage_report.txt +-- docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md +-- docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md +-- docs_build/dev/reports/PR_26171_065-validation.md +-- docs_build/dev/reports/PR_26171_065-manual-validation-notes.md +-- docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md +-- docs_build/dev/reports/codex_review.diff +-- docs_build/dev/reports/codex_changed_files.txt ++ M tests/playwright/tools/TextToSpeechFunctional.spec.mjs ++ M tests/tools/Text2SpeechShell.test.mjs ++ M toolbox/text-to-speech/index.html ++ M toolbox/text-to-speech/text2speech.js ++?? docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md ++?? docs_build/dev/reports/PR_26171_067-manual-validation-notes.md ++?? docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md ++?? docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md ++?? docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md ++?? docs_build/dev/reports/PR_26171_067-validation.md + +-## Validation +-- PASS: `node --check toolbox\messages\messages.js`. +-- PASS: `node --check toolbox\messages\message-tts-service-registry.js`. +-- PASS: `node --check tests\playwright\tools\MessagesTool.spec.mjs`. +-- PASS: HTML inline style/script/event scan for `toolbox/messages/index.html`. +-- PASS: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright --workers=1 --reporter=list`. +-- PASS: `npm run test:workspace-v2` (legacy command name; user-facing language is Project Workspace). ++# git ls-files --others --exclude-standard ++docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md ++docs_build/dev/reports/PR_26171_067-manual-validation-notes.md ++docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md ++docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md ++docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md ++docs_build/dev/reports/PR_26171_067-validation.md + +-## ZIP +-- Path: `tmp/PR_26171_065-message-studio-parent-child-table-foundation_delta.zip`. ++# git diff --stat ++.../dev/reports/coverage_changed_js_guardrail.txt | 9 +- ++ .../dev/reports/playwright_v8_coverage_report.txt | 27 +- ++ .../tools/TextToSpeechFunctional.spec.mjs | 42 +- ++ tests/tools/Text2SpeechShell.test.mjs | 48 ++ ++ toolbox/text-to-speech/index.html | 40 +- ++ toolbox/text-to-speech/text2speech.js | 728 ++++++++++++++++++++- ++ 6 files changed, 871 insertions(+), 23 deletions(-) +\ No newline at end of file +diff --git a/docs_build/dev/reports/codex_review.diff b/docs_build/dev/reports/codex_review.diff +index 39d7f84a5..0bec7d9bf 100644 +--- a/docs_build/dev/reports/codex_review.diff ++++ b/docs_build/dev/reports/codex_review.diff +@@ -1,1576 +1,1322 @@ +-diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt +-index 15213f1d6..e0820a401 100644 +---- a/docs_build/dev/reports/codex_changed_files.txt +-+++ b/docs_build/dev/reports/codex_changed_files.txt +-@@ -1,78 +1,53 @@ +--# PR_26171_042 Codex Changed Files Report +-- +--## Instruction Compliance Gate +-- +--- Current branch before execution: `main` +--- Required execution branch before PR branch: `main` +--- Branch validation: PASS +--- Clean repository before branch creation: PASS +--- PR owner/parity: PASS, `042` is even and Idea/Tool Display navigation scope maps to PC / Environment 1. +--- Implementation path: PASS, active Idea Board and Theme V2 files only. +--- Validation scope: PASS, targeted Idea Board, targeted Toolbox route for Idea Board, and workspace contract lane because shared Tool Display Mode behavior changed. +--- Required reports: PASS, `docs_build/dev/reports/codex_review.diff` and this report are updated. +--- ZIP requirement: PASS, `tmp/PR_26171_042-idea-board-navigation-fallback-cleanup_delta.zip` is required and produced before final delivery. +-- +--## Git Workflow Fields +-- +--- Created branch: `codex/pr-26171-042-idea-board-navigation-fallback-cleanup` +--- Push result: PASS, branch pushed to `origin/codex/pr-26171-042-idea-board-navigation-fallback-cleanup`. +--- PR URL: `https://github.com/ToolboxAid/HTML-JavaScript-Gaming/pull/19` +--- Merge result: recorded in final Codex delivery after GitHub merge returns the merge SHA. +--- Final main commit: recorded in final Codex delivery after returning to `main` and pulling latest. +--- Conflict resolution: PASS, merged latest `origin/main` (`9df4942226b0c1a25cfc9567040fc237d90df8f9`) and resolved conflicts only in singleton generated report artifacts. +-- +--## Scoped Files +-- +--- `assets/theme-v2/js/tool-display-mode.js` +--- `tests/playwright/tools/IdeaBoardTableNotes.spec.mjs` +--- `tests/playwright/tools/ToolboxRoutePages.spec.mjs` +--- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/PLAN_PR.md` +--- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/BUILD_PR.md` +--- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/APPLY_PR.md` +--- `docs_build/dev/reports/coverage_changed_js_guardrail.txt` +--- `docs_build/dev/reports/playwright_v8_coverage_report.txt` +--- `docs_build/dev/reports/codex_review.diff` +--- `docs_build/dev/reports/codex_changed_files.txt` +-- +--## Requirement Evidence +-- +--- PASS: Removed creator-visible Tool Display Mode navigation diagnostic fallback. `tool-display-mode.js` now logs the navigation load failure to console only and appends no status paragraph. +--- PASS: Removed visible message `Tool navigation is temporarily unavailable. Refresh the page or try again shortly.` +--- PASS: Idea Board stays usable when registry-backed navigation cannot load. Static/no-registry Playwright path expands notes and adds a note successfully. +--- PASS: Creator-facing UI does not mention server, API, local server, port, registry, snapshot, or implementation details in the navigation fallback area. +--- PASS: Navigation failure does not affect Idea Board table functionality. +--- PASS: API-backed local route validated by targeted Idea Board and Toolbox route Playwright. +--- PASS: Static/no-registry route behavior validated by targeted Idea Board Playwright with the registry snapshot returning no data. +--- PASS: Optional previous/next navigation is omitted when unavailable. +--- PASS: Idea Board lifecycle, Show filter, Create Project, Archive, chevron, and table row editing behavior were not changed. +-+# Codex Changed Files - PR_26171_065-message-studio-parent-child-table-foundation +-+ +-+## Git Status Short +-+```text +-+M docs_build/dev/reports/coverage_changed_js_guardrail.txt +-+ M docs_build/dev/reports/playwright_v8_coverage_report.txt +-+ M tests/playwright/tools/MessagesTool.spec.mjs +-+ M toolbox/messages/index.html +-+ M toolbox/messages/message-tts-service-registry.js +-+ M toolbox/messages/messages.js +-+?? docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md +-+?? docs_build/dev/reports/PR_26171_065-manual-validation-notes.md +-+?? docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md +-+?? docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md +-+?? docs_build/dev/reports/PR_26171_065-validation.md +-+``` +-+ +-+## Scoped Diff Stat +-+```text +-+.../dev/reports/coverage_changed_js_guardrail.txt | 2 + +-+ .../dev/reports/playwright_v8_coverage_report.txt | 43 +- +-+ tests/playwright/tools/MessagesTool.spec.mjs | 317 +++++--------- +-+ toolbox/messages/index.html | 29 +- +-+ toolbox/messages/message-tts-service-registry.js | 8 +- +-+ toolbox/messages/messages.js | 486 ++++++++++++--------- +-+ 6 files changed, 415 insertions(+), 470 deletions(-) +-+``` +-+ +-+## Changed Files +-+- toolbox/messages/index.html +-+- toolbox/messages/messages.js +-+- toolbox/messages/message-tts-service-registry.js +-+- tests/playwright/tools/MessagesTool.spec.mjs +-+- docs_build/dev/reports/coverage_changed_js_guardrail.txt +-+- docs_build/dev/reports/playwright_v8_coverage_report.txt +-+- docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md +-+- docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md +-+- docs_build/dev/reports/PR_26171_065-validation.md +-+- docs_build/dev/reports/PR_26171_065-manual-validation-notes.md +-+- docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md +-+- docs_build/dev/reports/codex_review.diff +-+- docs_build/dev/reports/codex_changed_files.txt - --- Created branch: `codex/pr-26171-042-idea-board-navigation-fallback-cleanup` --- Push result: PASS, branch pushed to `origin/codex/pr-26171-042-idea-board-navigation-fallback-cleanup`. --- PR URL: `https://github.com/ToolboxAid/HTML-JavaScript-Gaming/pull/19` --- Merge result: recorded in final Codex delivery after GitHub merge returns the merge SHA. --- Final main commit: recorded in final Codex delivery after returning to `main` and pulling latest. --- Conflict resolution: PASS, merged latest `origin/main` (`9df4942226b0c1a25cfc9567040fc237d90df8f9`) and resolved conflicts only in singleton generated report artifacts. +- ## Validation +-- +--- PASS: `node --check assets/theme-v2/js/tool-display-mode.js` +--- PASS: `node --check toolbox/idea-board/index.js` +--- PASS: `node --check tests/playwright/tools/IdeaBoardTableNotes.spec.mjs` +--- PASS: `node --check tests/playwright/tools/ToolboxRoutePages.spec.mjs` +--- PASS: `npx playwright test tests/playwright/tools/IdeaBoardTableNotes.spec.mjs --project=playwright --workers=1 --reporter=line --timeout=90000` +--- PASS: `npx playwright test tests/playwright/tools/ToolboxRoutePages.spec.mjs --project=playwright --workers=1 --reporter=line -g "Idea Board launches" --timeout=90000` +--- PASS: `npm run test:workspace-v2` +--- PASS: `git diff --check` +--- SKIP: Full samples smoke, per user instruction. +-- +--## Coverage Evidence +-- +--- PASS: `docs_build/dev/reports/playwright_v8_coverage_report.txt` updated. +--- PASS: `docs_build/dev/reports/coverage_changed_js_guardrail.txt` updated. +--- PASS: Changed runtime JS coverage lists `assets/theme-v2/js/tool-display-mode.js` at 64% advisory function coverage. +-- +--## ZIP Contents +-- +--- `assets/theme-v2/js/tool-display-mode.js` +--- `docs_build/dev/reports/codex_changed_files.txt` +--- `docs_build/dev/reports/codex_review.diff` +--- `docs_build/dev/reports/coverage_changed_js_guardrail.txt` +--- `docs_build/dev/reports/playwright_v8_coverage_report.txt` +--- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/APPLY_PR.md` +--- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/BUILD_PR.md` +--- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/PLAN_PR.md` +--- `tests/playwright/tools/IdeaBoardTableNotes.spec.mjs` +--- `tests/playwright/tools/ToolboxRoutePages.spec.mjs` +-+- PASS: `node --check toolbox\messages\messages.js`. +-+- PASS: `node --check toolbox\messages\message-tts-service-registry.js`. +-+- PASS: `node --check tests\playwright\tools\MessagesTool.spec.mjs`. +-+- PASS: HTML inline style/script/event scan for `toolbox/messages/index.html`. +-+- PASS: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright --workers=1 --reporter=list`. +-+- PASS: `npm run test:workspace-v2` (legacy command name; user-facing language is Project Workspace). +-+ +-+## ZIP +-+- Path: `tmp/PR_26171_065-message-studio-parent-child-table-foundation_delta.zip`. + diff --git a/docs_build/dev/reports/coverage_changed_js_guardrail.txt b/docs_build/dev/reports/coverage_changed_js_guardrail.txt +-index 242796c4f..6fbeaba58 100644 ++index 6fbeaba58..21de9adb6 100644 + --- a/docs_build/dev/reports/coverage_changed_js_guardrail.txt + +++ b/docs_build/dev/reports/coverage_changed_js_guardrail.txt +-@@ -7,6 +7,8 @@ Source: Playwright/Chromium built-in V8 coverage from the active Playwright run. - --## Scoped Files ++@@ -6,9 +6,10 @@ Missing changed runtime JS files are WARN, not FAIL. ++ Source: Playwright/Chromium built-in V8 coverage from the active Playwright run. ++ + Changed runtime JS files considered: +- (64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 +-+(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 +-+(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 - --- `assets/theme-v2/js/tool-display-mode.js` --- `tests/playwright/tools/IdeaBoardTableNotes.spec.mjs` --- `tests/playwright/tools/ToolboxRoutePages.spec.mjs` --- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/PLAN_PR.md` --- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/BUILD_PR.md` --- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/APPLY_PR.md` --- `docs_build/dev/reports/coverage_changed_js_guardrail.txt` --- `docs_build/dev/reports/playwright_v8_coverage_report.txt` --- `docs_build/dev/reports/codex_review.diff` --- `docs_build/dev/reports/codex_changed_files.txt` ++-(64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 ++-(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 ++-(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 +++(0%) toolbox/messages/message-tts-service-registry.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +++(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +++(80%) toolbox/text-to-speech/text2speech.js - executed lines 1518/1518; executed functions 127/158 ++ + Guardrail warnings: +- (100%) none - no changed runtime JS coverage warnings ++-(100%) none - no changed runtime JS coverage warnings +++(0%) toolbox/messages/message-tts-service-registry.js - WARNING: changed runtime JS file missing from coverage; advisory only +++(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file missing from coverage; advisory only + diff --git a/docs_build/dev/reports/playwright_v8_coverage_report.txt b/docs_build/dev/reports/playwright_v8_coverage_report.txt +-index 17eb943d2..578a19ddc 100644 ++index 578a19ddc..49e94f0d7 100644 + --- a/docs_build/dev/reports/playwright_v8_coverage_report.txt + +++ b/docs_build/dev/reports/playwright_v8_coverage_report.txt +-@@ -12,45 +12,34 @@ Note: entry percentages use function coverage when available, otherwise line cov ++@@ -12,34 +12,33 @@ Note: entry percentages use function coverage when available, otherwise line cov + Note: coverage entries are aggregated across every page/tool where coverageReporter.start(page) and coverageReporter.stop(page) ran. - --## Requirement Evidence ++ + Exercised tool entry points detected: +--(72%) Toolbox Index - exercised 10 runtime JS files +-+(83%) Toolbox Index - exercised 4 runtime JS files ++-(83%) Toolbox Index - exercised 4 runtime JS files +++(80%) Toolbox Index - exercised 2 runtime JS files + (0%) Tool Template V2 - not exercised by this Playwright run +--(61%) Theme V2 Shared JS - exercised 7 runtime JS files +-+(56%) Theme V2 Shared JS - exercised 2 runtime JS files - --- PASS: Removed creator-visible Tool Display Mode navigation diagnostic fallback. `tool-display-mode.js` now logs the navigation load failure to console only and appends no status paragraph. --- PASS: Removed visible message `Tool navigation is temporarily unavailable. Refresh the page or try again shortly.` --- PASS: Idea Board stays usable when registry-backed navigation cannot load. Static/no-registry Playwright path expands notes and adds a note successfully. --- PASS: Creator-facing UI does not mention server, API, local server, port, registry, snapshot, or implementation details in the navigation fallback area. --- PASS: Navigation failure does not affect Idea Board table functionality. --- PASS: API-backed local route validated by targeted Idea Board and Toolbox route Playwright. --- PASS: Static/no-registry route behavior validated by targeted Idea Board Playwright with the registry snapshot returning no data. --- PASS: Optional previous/next navigation is omitted when unavailable. --- PASS: Idea Board lifecycle, Show filter, Create Project, Archive, chevron, and table row editing behavior were not changed. -+# Codex Changed Files - PR_26171_065-message-studio-parent-child-table-foundation -+ -+## Git Status Short -+```text -+M docs_build/dev/reports/coverage_changed_js_guardrail.txt -+ M docs_build/dev/reports/playwright_v8_coverage_report.txt -+ M tests/playwright/tools/MessagesTool.spec.mjs -+ M toolbox/messages/index.html -+ M toolbox/messages/message-tts-service-registry.js -+ M toolbox/messages/messages.js -+?? docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md -+?? docs_build/dev/reports/PR_26171_065-manual-validation-notes.md -+?? docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md -+?? docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md -+?? docs_build/dev/reports/PR_26171_065-validation.md -+``` -+ -+## Scoped Diff Stat -+```text -+.../dev/reports/coverage_changed_js_guardrail.txt | 2 + -+ .../dev/reports/playwright_v8_coverage_report.txt | 43 +- -+ tests/playwright/tools/MessagesTool.spec.mjs | 317 +++++--------- -+ toolbox/messages/index.html | 29 +- -+ toolbox/messages/message-tts-service-registry.js | 8 +- -+ toolbox/messages/messages.js | 486 ++++++++++++--------- -+ 6 files changed, 415 insertions(+), 470 deletions(-) -+``` -+ -+## Changed Files -+- toolbox/messages/index.html -+- toolbox/messages/messages.js -+- toolbox/messages/message-tts-service-registry.js -+- tests/playwright/tools/MessagesTool.spec.mjs -+- docs_build/dev/reports/coverage_changed_js_guardrail.txt -+- docs_build/dev/reports/playwright_v8_coverage_report.txt -+- docs_build/dev/reports/PR_26171_065-message-studio-parent-child-table-foundation.md -+- docs_build/dev/reports/PR_26171_065-parent-child-table-checklist.md -+- docs_build/dev/reports/PR_26171_065-validation.md -+- docs_build/dev/reports/PR_26171_065-manual-validation-notes.md -+- docs_build/dev/reports/PR_26171_065-instruction-compliance-checklist.md -+- docs_build/dev/reports/codex_review.diff -+- docs_build/dev/reports/codex_changed_files.txt - - ## Validation ++ (56%) Theme V2 Shared JS - exercised 2 runtime JS files ++ + Changed runtime JS files covered: +- (64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 +-+(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 +-+(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 - --- PASS: `node --check assets/theme-v2/js/tool-display-mode.js` --- PASS: `node --check toolbox/idea-board/index.js` --- PASS: `node --check tests/playwright/tools/IdeaBoardTableNotes.spec.mjs` --- PASS: `node --check tests/playwright/tools/ToolboxRoutePages.spec.mjs` --- PASS: `npx playwright test tests/playwright/tools/IdeaBoardTableNotes.spec.mjs --project=playwright --workers=1 --reporter=line --timeout=90000` --- PASS: `npx playwright test tests/playwright/tools/ToolboxRoutePages.spec.mjs --project=playwright --workers=1 --reporter=line -g "Idea Board launches" --timeout=90000` --- PASS: `npm run test:workspace-v2` --- PASS: `git diff --check` --- SKIP: Full samples smoke, per user instruction. ++-(64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 ++-(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 ++-(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 +++(0%) toolbox/messages/message-tts-service-registry.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +++(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +++(80%) toolbox/text-to-speech/text2speech.js - executed lines 1518/1518; executed functions 127/158 ++ + Files with executed line/function counts where available: +--(14%) assets/theme-v2/js/account-auth-service.js - executed lines 64/64; executed functions 1/7 +--(14%) assets/theme-v2/js/admin-setup-actions.js - executed lines 55/55; executed functions 1/7 +--(18%) assets/theme-v2/js/account-page-data.js - executed lines 150/150; executed functions 3/17 +--(20%) assets/theme-v2/js/admin-owner-navigation.js - executed lines 58/58; executed functions 2/10 +--(25%) src/api/admin-owner-navigation.js - executed lines 42/42; executed functions 1/4 +--(25%) src/api/session-api-client.js - executed lines 68/68; executed functions 3/12 +--(29%) src/engine/input/NormalizedInputRegistry.js - executed lines 341/341; executed functions 6/21 +--(33%) src/api/admin-setup-api-client.js - executed lines 13/13; executed functions 1/3 +--(33%) src/api/toolbox-votes-api-client.js - executed lines 46/46; executed functions 2/6 +--(56%) toolbox/colors/colors.js - executed lines 1848/1848; executed functions 115/204 +--(58%) src/api/server-api-client.js - executed lines 167/167; executed functions 11/19 +-+(34%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 15/44 +-+(36%) src/api/server-api-client.js - executed lines 167/167; executed functions 5/14 +-+(38%) src/api/public-config-client.js - executed lines 209/209; executed functions 10/26 +-+(54%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 46/85 +-+(58%) toolbox/messages/messages-api-client.js - executed lines 64/64; executed functions 11/19 ++-(34%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 15/44 ++ (36%) src/api/server-api-client.js - executed lines 167/167; executed functions 5/14 ++ (38%) src/api/public-config-client.js - executed lines 209/209; executed functions 10/26 ++ (54%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 46/85 ++-(58%) toolbox/messages/messages-api-client.js - executed lines 64/64; executed functions 11/19 + (64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 +--(64%) toolbox/controls/controls.js - executed lines 610/610; executed functions 36/56 +--(65%) src/api/public-config-client.js - executed lines 209/209; executed functions 17/26 +--(67%) src/api/game-journey-completion-api-client.js - executed lines 15/15; executed functions 2/3 +--(74%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 67/90 +--(75%) toolbox/game-workspace/game-workspace.js - executed lines 458/458; executed functions 33/44 +--(89%) toolbox/tools-page-accordions.js - executed lines 1156/1156; executed functions 105/118 +--(90%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 26/29 +--(91%) toolbox/game-design/game-design.js - executed lines 254/254; executed functions 21/23 +--(94%) assets/theme-v2/js/marketplace-page.js - executed lines 170/170; executed functions 16/17 +--(100%) src/api/marketplace-api-client.js - executed lines 16/16; executed functions 3/3 +--(100%) toolbox/colors/palette-api-client.js - executed lines 28/28; executed functions 4/4 +--(100%) toolbox/controls/controls-api-client.js - executed lines 33/33; executed functions 5/5 +--(100%) toolbox/game-design/game-design-api-client.js - executed lines 13/13; executed functions 2/2 +--(100%) toolbox/game-workspace/game-workspace-api-client.js - executed lines 20/20; executed functions 3/3 +-+(76%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 22/29 +-+(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 +-+(100%) src/engine/audio/TextToSpeechDefaults.js - executed lines 108/108; executed functions 1/1 +-+(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 - --## Coverage Evidence +++(71%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 37/52 ++ (76%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 22/29 ++-(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 +++(80%) toolbox/text-to-speech/text2speech.js - executed lines 1518/1518; executed functions 127/158 ++ (100%) src/engine/audio/TextToSpeechDefaults.js - executed lines 108/108; executed functions 1/1 ++-(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 ++ + Uncovered or low-coverage changed JS files: +- (100%) none - no low-coverage changed runtime JS files - --- PASS: `docs_build/dev/reports/playwright_v8_coverage_report.txt` updated. --- PASS: `docs_build/dev/reports/coverage_changed_js_guardrail.txt` updated. --- PASS: Changed runtime JS coverage lists `assets/theme-v2/js/tool-display-mode.js` at 64% advisory function coverage. ++-(100%) none - no low-coverage changed runtime JS files +++(0%) toolbox/messages/message-tts-service-registry.js - WARNING: uncovered changed runtime JS file; advisory only +++(0%) toolbox/messages/messages.js - WARNING: uncovered changed runtime JS file; advisory only ++ + Changed JS files considered: +- (0%) tests/playwright/tools/IdeaBoardTableNotes.spec.mjs - changed JS file not collected as browser runtime coverage +-+(0%) tests/playwright/tools/MessagesTool.spec.mjs - changed JS file not collected as browser runtime coverage +- (0%) tests/playwright/tools/ToolboxRoutePages.spec.mjs - changed JS file not collected as browser runtime coverage +- (64%) assets/theme-v2/js/tool-display-mode.js - changed JS file with browser V8 coverage +-+(87%) toolbox/messages/messages.js - changed JS file with browser V8 coverage +-+(100%) toolbox/messages/message-tts-service-registry.js - changed JS file with browser V8 coverage +-diff --git a/tests/playwright/tools/MessagesTool.spec.mjs b/tests/playwright/tools/MessagesTool.spec.mjs +-index b120f19a3..451c4439f 100644 +---- a/tests/playwright/tools/MessagesTool.spec.mjs +-+++ b/tests/playwright/tools/MessagesTool.spec.mjs +-@@ -31,7 +31,7 @@ async function jsonRequest(url, options = {}) { +- test.beforeEach(async ({ page }) => { +- await installPlaywrightStorageIsolation(page, { +- lane: "messages-tool", +-- surface: "Message Studio Local API, legacy SQLite technical debt adapter, and Theme V2 tool", +-+ surface: "Message Studio parent/child table, Local API, and Theme V2 tool", +- }); ++-(0%) tests/playwright/tools/IdeaBoardTableNotes.spec.mjs - changed JS file not collected as browser runtime coverage ++ (0%) tests/playwright/tools/MessagesTool.spec.mjs - changed JS file not collected as browser runtime coverage ++-(0%) tests/playwright/tools/ToolboxRoutePages.spec.mjs - changed JS file not collected as browser runtime coverage ++-(64%) assets/theme-v2/js/tool-display-mode.js - changed JS file with browser V8 coverage ++-(87%) toolbox/messages/messages.js - changed JS file with browser V8 coverage ++-(100%) toolbox/messages/message-tts-service-registry.js - changed JS file with browser V8 coverage +++(0%) tests/playwright/tools/TextToSpeechFunctional.spec.mjs - changed JS file not collected as browser runtime coverage +++(0%) tests/tools/Text2SpeechShell.test.mjs - changed JS file not collected as browser runtime coverage +++(0%) toolbox/messages/message-tts-service-registry.js - changed JS file not collected as browser runtime coverage +++(0%) toolbox/messages/messages.js - changed JS file not collected as browser runtime coverage +++(80%) toolbox/text-to-speech/text2speech.js - changed JS file with browser V8 coverage ++diff --git a/tests/playwright/tools/TextToSpeechFunctional.spec.mjs b/tests/playwright/tools/TextToSpeechFunctional.spec.mjs ++index 17e2c0b62..8c964fecc 100644 ++--- a/tests/playwright/tools/TextToSpeechFunctional.spec.mjs +++++ b/tests/playwright/tools/TextToSpeechFunctional.spec.mjs ++@@ -106,7 +106,45 @@ test("Text To Speech page loads and speaks through browser speech synthesis", as ++ ++ await expect(page.locator("[data-tts-voice-select]")).toContainText("Arcade Voice"); ++ await expect(page.locator("[data-tts-voice-count]")).toHaveText("2"); ++- await expect(page.locator("[data-tts-engine-label]")).toHaveText("Ready"); +++ await expect(page.locator("[data-tts-profile-count]")).toHaveText("3"); +++ await expect(page.locator("[data-tts-emotion-count]")).toHaveText("3"); +++ await expect(page.locator("[data-tts-profile-table]")).toContainText("Default Balanced Profile"); +++ await expect(page.locator("[data-tts-profile-table]")).toContainText("Man Profile 1"); +++ await expect(page.locator("[data-tts-profile-table]")).toContainText("Woman Profile 2"); +++ await expect(page.locator("[data-tts-profile-row]").filter({ hasText: "Default Balanced Profile" }).getByRole("button", { name: "Delete" })).toBeDisabled(); +++ await page.locator("[data-tts-profile-row]").filter({ hasText: "Default Balanced Profile" }).click(); +++ await expect(page.getByRole("heading", { name: "Emotion Settings" })).toBeVisible(); +++ await expect(page.getByRole("columnheader", { name: "Emotion", exact: true })).toBeVisible(); +++ await expect(page.getByRole("columnheader", { name: "SSML-like Preset" })).toBeVisible(); +++ await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Neutral" }).getByRole("button", { name: "Delete" })).toBeDisabled(); +++ +++ await page.getByRole("button", { name: "Add Profile" }).click(); +++ await expect(page.locator("[data-tts-profile-editor='__new__']")).toBeVisible(); +++ await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-name]").fill("Creature Profile"); +++ await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-gender]").selectOption("neutral"); +++ await page.locator("[data-tts-commit-profile='__new__']").click(); +++ await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Creature Profile."); +++ await expect(page.locator("[data-tts-profile-table]")).toContainText("Creature Profile"); +++ await page.locator("[data-tts-profile-row]").filter({ hasText: "Creature Profile" }).getByRole("button", { name: "Edit Profile" }).click(); +++ await page.locator("[data-tts-profile-editor] [data-tts-profile-name]").fill("Creature Profile Updated"); +++ await page.locator("[data-tts-profile-editor] [data-tts-commit-profile]").click(); +++ await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Creature Profile Updated."); +++ await page.getByRole("button", { name: "Add Emotion" }).click(); +++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-name]").selectOption("urgent"); +++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-pitch]").fill("1.2"); +++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-rate]").fill("1.1"); +++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-volume]").fill("0.8"); +++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-ssml-preset]").selectOption("whisper-ish"); +++ await page.locator("[data-tts-commit-emotion='__new__']").click(); +++ await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion setting: Urgent."); +++ await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Urgent" })).toContainText("1.2"); +++ await page.locator("[data-tts-emotion-row]").filter({ hasText: "Urgent" }).getByRole("button", { name: "Edit Emotion" }).click(); +++ await page.locator("[data-tts-emotion-editor] [data-tts-emotion-volume]").fill("0.7"); +++ await page.locator("[data-tts-emotion-editor] [data-tts-commit-emotion]").click(); +++ await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion setting: Urgent."); +++ await expect(page.locator("[data-tts-output-summary]")).toContainText("\"contractVersion\": \"tts-profile-emotion-v1\""); +++ await expect(page.locator("[data-tts-output-summary]")).toContainText("\"name\": \"Creature Profile Updated\""); +++ ++ await expect(page.locator("[data-tts-gender-select]")).toBeVisible(); ++ await expect(page.locator("[data-tts-language-select]")).toBeVisible(); ++ await expect(page.locator("[data-tts-age-select]")).toBeVisible(); ++@@ -173,7 +211,7 @@ test("Text To Speech shows actionable error when browser speech synthesis is una ++ const failures = await openTextToSpeechPage(page, { speechAvailable: false }); ++ try { ++ await expect(page.getByRole("heading", { level: 1, name: "Text To Speech" })).toBeVisible(); ++- await expect(page.locator("[data-tts-engine-label]")).toHaveText("Unavailable"); +++ await expect(page.locator("[data-tts-profile-count]")).toHaveText("3"); ++ await expect(page.locator("[data-tts-engine-status]")).toContainText("SpeechSynthesis is unavailable"); ++ await expect(page.locator("[data-tts-status]")).toContainText("Use a browser with Web Speech API support"); ++ await expect(page.locator("[data-tts-voice-select]")).toContainText("No browser voices available"); ++diff --git a/tests/tools/Text2SpeechShell.test.mjs b/tests/tools/Text2SpeechShell.test.mjs ++index fbc6ac8e9..51e4f019f 100644 ++--- a/tests/tools/Text2SpeechShell.test.mjs +++++ b/tests/tools/Text2SpeechShell.test.mjs ++@@ -3,9 +3,14 @@ import test from "node:test"; ++ ++ import { ++ TTS_MESSAGE_STATUSES, +++ TTS_PROFILE_CONTRACT_VERSION, ++ TTS_PROVIDER_ADAPTER_PLAN, +++ createDefaultTextToSpeechProfiles, ++ createEmotionProfile, +++ createMessageStudioTtsProfileOptions, ++ createSpeechPreviewRequest, +++ createTextToSpeechProfile, +++ createTextToSpeechProfileEmotion, ++ createTtsMessage, ++ createVoiceProfile, ++ previewTtsMessage, ++@@ -59,3 +64,46 @@ test("Text2Speech provider adapter plan keeps browser speech implemented and pai ++ assert.equal(TTS_PROVIDER_ADAPTER_PLAN[0].status, "implemented"); ++ assert.ok(TTS_PROVIDER_ADAPTER_PLAN.slice(1).every((provider) => provider.status === "planned")); + }); - --## ZIP Contents +-@@ -134,42 +134,24 @@ async function closeMessagesRun(failures, page) { +- } +- } - --- `assets/theme-v2/js/tool-display-mode.js` --- `docs_build/dev/reports/codex_changed_files.txt` --- `docs_build/dev/reports/codex_review.diff` --- `docs_build/dev/reports/coverage_changed_js_guardrail.txt` --- `docs_build/dev/reports/playwright_v8_coverage_report.txt` --- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/APPLY_PR.md` --- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/BUILD_PR.md` --- `docs_build/pr/PR_26171_042-idea-board-navigation-fallback-cleanup/PLAN_PR.md` --- `tests/playwright/tools/IdeaBoardTableNotes.spec.mjs` --- `tests/playwright/tools/ToolboxRoutePages.spec.mjs` -+- PASS: `node --check toolbox\messages\messages.js`. -+- PASS: `node --check toolbox\messages\message-tts-service-registry.js`. -+- PASS: `node --check tests\playwright\tools\MessagesTool.spec.mjs`. -+- PASS: HTML inline style/script/event scan for `toolbox/messages/index.html`. -+- PASS: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright --workers=1 --reporter=list`. -+- PASS: `npm run test:workspace-v2` (legacy command name; user-facing language is Project Workspace). +--async function addEmotionProfile(page, name) { +-- await page.locator("[data-messages-emotion-add-row]").click(); +-- await page.locator("[data-messages-emotion-editor='__new__'] [data-emotion-name]").fill(name); +-- await page.locator("[data-messages-emotion-editor='__new__'] [data-emotion-volume]").fill("0.9"); +-- await page.locator("[data-messages-emotion-editor='__new__'] [data-emotion-pitch]").fill("0.85"); +-- await page.locator("[data-messages-emotion-editor='__new__'] [data-emotion-rate]").fill("0.95"); +-- await page.locator("[data-messages-emotion-commit='__new__']").click(); +--} +-- +--async function addTtsProfile(page, name) { +-- await page.locator("[data-messages-tts-add-row]").click(); +-- await page.locator("[data-messages-tts-editor='__new__'] [data-tts-name]").fill(name); +-- await page.locator("[data-messages-tts-editor='__new__'] [data-tts-provider]").fill("browser-speech"); +-- await page.locator("[data-messages-tts-editor='__new__'] [data-tts-voice]").fill("Test Voice"); +-- await page.locator("[data-messages-tts-editor='__new__'] [data-tts-language]").fill("en-US"); +-- await page.locator("[data-messages-tts-commit='__new__']").click(); +--} +-- +--async function addMessageRow(page, values) { +-- await page.locator("[data-messages-add-row]").click(); +-+async function addMessage(page, values) { +-+ await page.getByRole("button", { name: "Add Message" }).click(); +- await page.locator("[data-messages-row-editor='__new__'] [data-message-name]").fill(values.name); +-- await page.locator("[data-messages-row-editor='__new__'] [data-message-emotion]").selectOption({ label: values.emotion }); +-+ await page.locator("[data-messages-row-editor-details='__new__'] [data-message-emotion]").selectOption({ label: values.emotion }); +- await page.locator("[data-messages-row-editor-details='__new__'] [data-message-text]").fill(values.text); +- await page.locator("[data-messages-row-editor-details='__new__'] [data-message-notes]").fill(values.notes || ""); +- await page.locator("[data-messages-commit='__new__']").click(); +- } +- +--async function addSegmentRow(page, values) { +-- await page.locator("[data-messages-segment-add-row]").click(); +-+async function addPart(page, values) { +-+ await page.getByRole("button", { name: "Add Part" }).click(); +- await page.locator("[data-messages-segment-editor='__new__'] [data-segment-order]").fill(String(values.order)); +-- await page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]").selectOption({ label: values.emotion }); +- await page.locator("[data-messages-segment-editor='__new__'] [data-segment-text]").fill(values.text); +-+ await page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]").selectOption({ label: values.emotion }); +- await page.locator("[data-messages-segment-commit='__new__']").click(); +- } +- +--test("Message Studio uses table governance, validates rows, and persists through the Local API", async ({ page }) => { +-+test("Message Studio renders Messages with child Message Parts and plays ordered parts", async ({ page }) => { +- const sqlitePath = messagesDbPath(); +- await fs.rm(sqlitePath, { force: true }); +- const failures = await openMessagesPage(page, sqlitePath); +-@@ -179,242 +161,155 @@ test("Message Studio uses table governance, validates rows, and persists through +- await expect(page.locator(".tool-workspace")).toBeVisible(); +- await expect(page.locator("style, [style], script:not([src])")).toHaveCount(0); +- await expect(page.locator("[data-messages-category-name]")).toHaveCount(0); +-- await expect(page.locator("[data-messages-category]")).toHaveCount(0); +-- await expect(page.getByRole("button", { name: /delete/i })).toHaveCount(0); +-- await expect(page.locator("[data-messages-persistence-engine]")).toHaveText("Postgres target"); +-- await expect(page.locator("[data-messages-tts-service]")).toHaveValue("browser-speech-synthesis"); +-- await expect(page.locator("[data-messages-preview-status]")).toHaveText("Select a message row or segment row before testing speech."); +-- await expect(page.locator("[data-messages-test-speech]")).toBeDisabled(); +-- await expect(page.locator("[data-messages-preview-message], [data-messages-preview-segments], [data-messages-preview-stop]")).toHaveCount(0); +-- +-- await expect(page.locator("[data-messages-emotions]")).toContainText("Calm"); +-- await expect(page.locator("[data-messages-emotions]")).toContainText("Urgent"); +-+ await expect(page.locator("[data-messages-tts-add-row]")).toHaveCount(0); +-+ await expect(page.getByRole("columnheader", { name: "Message Name" })).toBeVisible(); +-+ await expect(page.getByRole("columnheader", { name: "Default TTS Profile" })).toBeVisible(); +- await expect(page.locator("[data-messages-tts-profiles]")).toContainText("Browser Speech Default"); +-+ await expect(page.locator("[data-messages-tts-profiles]")).toContainText("Owned by TTS Studio"); +- +-- await addEmotionProfile(page, "Robot"); +-- await expect(page.locator("[data-messages-log]")).toHaveText("Updated emotion profile Robot."); +-- await expect(page.locator("[data-messages-emotion-row]").filter({ hasText: "Robot" })).toContainText("0.9"); +-- await page.locator("[data-messages-emotion-row]").filter({ hasText: "Robot" }).getByRole("button", { name: "Edit" }).click(); +-- await page.locator("[data-messages-emotion-editor] [data-emotion-rate]").fill("1.05"); +-- await page.locator("[data-messages-emotion-editor] [data-messages-emotion-commit]").click(); +-- await page.locator("[data-messages-emotion-row]").filter({ hasText: "Robot" }).getByRole("button", { name: "Disable" }).click(); +-- await expect(page.locator("[data-messages-emotion-row]").filter({ hasText: "Robot" })).toContainText("Inactive"); +-- +-- await addTtsProfile(page, "Arcade Browser Voice"); +-- await expect(page.locator("[data-messages-log]")).toHaveText("Updated TTS profile Arcade Browser Voice."); +-- await expect(page.locator("[data-messages-tts-profiles]")).toContainText("Arcade Browser Voice"); +-- await expect(page.locator("[data-messages-tts-count]")).toHaveText("3"); +-- await page.locator("[data-messages-tts-row]").filter({ hasText: "Arcade Browser Voice" }).getByRole("button", { name: "Edit" }).click(); +-- await page.locator("[data-messages-tts-editor] [data-tts-language]").fill("en-GB"); +-- await page.locator("[data-messages-tts-editor] [data-messages-tts-commit]").click(); +-- await expect(page.locator("[data-messages-tts-row]").filter({ hasText: "Arcade Browser Voice" })).toContainText("Active"); +-- +-- const ttsProfilesResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/tts-profiles`); +-- expect(ttsProfilesResult.response.ok).toBe(true); +-- expect(ttsProfilesResult.payload.ok).toBe(true); +-- const createdTtsProfile = ttsProfilesResult.payload.data.ttsProfiles.find((profile) => profile.name === "Arcade Browser Voice"); +-- expect(createdTtsProfile).toEqual(expect.objectContaining({ +-- active: true, +-- language: "en-GB", +-- providerKey: "browser-speech", +-- voiceName: "Test Voice", +-- })); +-- expect(createdTtsProfile.key).toMatch(ULID_PATTERN); +-- expect(createdTtsProfile.createdBy).toMatch(ULID_PATTERN); +-- expect(createdTtsProfile.updatedBy).toMatch(ULID_PATTERN); +-- +-- await page.locator("[data-messages-add-row]").click(); +-- await expect(page.locator("[data-messages-row-editor='__new__']")).toBeVisible(); +-+ await page.getByRole("button", { name: "Add Message" }).click(); +- await page.locator("[data-messages-commit='__new__']").click(); +- await expect(page.locator("[data-messages-validation-card]")).toBeVisible(); +- await expect(page.locator("[data-messages-validation-errors]")).toContainText("Message Name is required."); +- await expect(page.locator("[data-messages-validation-errors]")).toContainText("Emotion Profile is required."); +- await expect(page.locator("[data-messages-validation-errors]")).toContainText("Message Text is required."); +-- +- await page.locator("[data-messages-cancel='__new__']").click(); +-- await addMessageRow(page, { +-+ +-+ await addMessage(page, { +- emotion: "Urgent", +-- name: "Forest Warning", +-- notes: "Opening forest danger line.", +-- text: "The forest gets darker beyond this point.\nWe are being attacked by bats.", +-+ name: "Bat Encounter", +-+ notes: "Opening combat line.", +-+ text: "Bats drop from the rafters.", +- }); +-- await expect(page.locator("[data-messages-log]")).toHaveText("Updated row Forest Warning."); +-- await expect(page.locator("[data-messages-count]")).toHaveText("1"); +-- await expect(page.locator("[data-messages-table]")).toContainText("Forest Warning"); +-- await expect(page.locator("[data-messages-selected-text]")).toHaveText("The forest gets darker beyond this point.\nWe are being attacked by bats."); +-+ await expect(page.locator("[data-messages-log]")).toHaveText("Updated row Bat Encounter."); +-+ await expect(page.locator("[data-messages-table]")).toContainText("Bat Encounter"); +-+ await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" })).toContainText("Dialog"); +-+ await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" })).toContainText("0"); +-+ await expect(page.locator("[data-message-default-tts-profile]").first()).toHaveValue(/.+/); +-+ +-+ const messageRow = page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }); +-+ await messageRow.click(); +- await expect(page.locator("[data-messages-segment-host]")).toBeVisible(); +-+ await expect(page.getByRole("heading", { name: "Message Parts" })).toBeVisible(); +-+ await expect(page.getByRole("columnheader", { name: "Order" })).toBeVisible(); +-+ await expect(page.getByRole("columnheader", { name: "Text" })).toBeVisible(); +-+ await expect(page.getByRole("columnheader", { exact: true, name: "TTS Profile" })).toBeVisible(); +- +-- const listResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/messages`); +-- expect(listResult.response.ok).toBe(true); +-- expect(listResult.payload.ok).toBe(true); +-- const createdMessage = listResult.payload.data.messages.find((message) => message.name === "Forest Warning"); +-- expect(createdMessage).toEqual(expect.objectContaining({ +-- active: true, +-- emotionProfileName: "Urgent", +-- messageText: "The forest gets darker beyond this point.\nWe are being attacked by bats.", +-- notes: "Opening forest danger line.", +-- })); +-- expect(createdMessage.key).toMatch(ULID_PATTERN); +-- expect(createdMessage.createdBy).toMatch(ULID_PATTERN); +-- expect(createdMessage.updatedBy).toMatch(ULID_PATTERN); +-- +-- await page.locator("[data-messages-segment-add-row]").click(); +-+ await page.getByRole("button", { name: "Add Part" }).click(); +- await page.locator("[data-messages-segment-editor='__new__'] [data-segment-order]").fill(""); +- await page.locator("[data-messages-segment-commit='__new__']").click(); +-- await expect(page.locator("[data-messages-validation-card]")).toBeVisible(); +-- await expect(page.locator("[data-messages-validation-errors]")).toContainText("Segment Text is required."); +-+ await expect(page.locator("[data-messages-validation-errors]")).toContainText("Part Text is required."); +- await expect(page.locator("[data-messages-validation-errors]")).toContainText("Emotion Profile is required."); +- await expect(page.locator("[data-messages-validation-errors]")).toContainText("Display Order is required."); +- await page.locator("[data-messages-segment-cancel='__new__']").click(); +- +-- await addSegmentRow(page, { +-+ await addPart(page, { +- emotion: "Calm", +- order: 1, +-- text: "The forest gets darker beyond this point.", +-+ text: "Bats drop from the rafters.", +- }); +-- await expect(page.locator("[data-messages-log]")).toHaveText("Updated segment row 1."); +-- await addSegmentRow(page, { +-+ await expect(page.locator("[data-messages-log]")).toHaveText("Updated message part 1."); +-+ await addPart(page, { +- emotion: "Urgent", +- order: 2, +-- text: "We are being attacked by bats.", +-+ text: "Keep your torch high.", +- }); +-- await expect(page.locator("[data-messages-log]")).toHaveText("Updated segment row 2."); +-+ await expect(page.locator("[data-messages-log]")).toHaveText("Updated message part 2."); +- await expect(page.locator("[data-messages-segment-row]")).toHaveCount(2); +-+ await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" })).toContainText("2"); +- +-+ await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Play Message" }).click(); +-+ await expect(page.locator("[data-messages-log]")).toHaveText("Play Message queued 2 parts for Bat Encounter."); +- let speechCalls = await page.evaluate(() => window.__messagesSpeechCalls); +-- expect(speechCalls).toEqual([]); +-- +-- await page.locator("[data-messages-preview-tts-profile]").selectOption({ label: "Arcade Browser Voice" }); +-- await page.locator("[data-messages-row]").filter({ hasText: "Forest Warning" }).click(); +-- await expect(page.locator("[data-messages-speech-test-target]")).toHaveText("Message Row: Forest Warning"); +-- await expect(page.locator("[data-messages-test-speech]")).toBeEnabled(); +-- await page.locator("[data-messages-test-speech]").click(); +-- await expect(page.locator("[data-messages-preview-status]")).toHaveText("Speech test started for Message Row: Forest Warning using Browser Speech Synthesis."); +-- speechCalls = await page.evaluate(() => window.__messagesSpeechCalls); +-+ expect(speechCalls.slice(-2).map((call) => call.text)).toEqual([ +-+ "Bats drop from the rafters.", +-+ "Keep your torch high.", +-+ ]); +- expect(speechCalls.at(-1)).toEqual(expect.objectContaining({ +-- lang: "en-GB", +-- pitch: 1.08, +-- rate: 1.15, +-- text: "The forest gets darker beyond this point.\nWe are being attacked by bats.", +-+ lang: "en-US", +- type: "speak", +- voiceName: "Test Voice", +-- volume: 1, +- })); +- +-- await page.locator("[data-messages-segment-row]").filter({ hasText: "The forest gets darker beyond this point." }).click(); +-- await expect(page.locator("[data-messages-speech-test-target]")).toHaveText("Segment 1"); +-- await page.locator("[data-messages-test-speech]").click(); +-- await expect(page.locator("[data-messages-preview-status]")).toHaveText("Speech test started for Segment 1 using Browser Speech Synthesis."); +-+ await page.locator("[data-messages-segment-row]").filter({ hasText: "Keep your torch high." }).getByRole("button", { name: "Play Part" }).click(); +-+ await expect(page.locator("[data-messages-log]")).toHaveText("Play Part queued Part 2 using Browser Speech Default."); +- speechCalls = await page.evaluate(() => window.__messagesSpeechCalls); +- expect(speechCalls.at(-1)).toEqual(expect.objectContaining({ +-- lang: "en-GB", +-- pitch: 1, +-- rate: 1, +-- text: "The forest gets darker beyond this point.", +-+ text: "Keep your torch high.", +- type: "speak", +- voiceName: "Test Voice", +-- volume: 1, +- })); +- +-+ await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Edit Message" }).click(); +-+ await page.locator("[data-messages-row-editor] [data-message-name]").fill("Bat Encounter Updated"); +-+ await page.locator("[data-messages-row-editor] [data-messages-commit]").click(); +-+ await expect(page.locator("[data-messages-log]")).toHaveText("Updated row Bat Encounter Updated."); +-+ +-+ await page.locator("[data-messages-segment-row]").filter({ hasText: "Keep your torch high." }).getByRole("button", { name: "Edit Part" }).click(); +-+ await page.locator("[data-messages-segment-editor] [data-segment-text]").fill("Keep your torch high and shield raised."); +-+ await page.locator("[data-messages-segment-editor] [data-messages-segment-commit]").click(); +-+ await expect(page.locator("[data-messages-log]")).toHaveText("Updated message part 2."); +-+ +-+ const listResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/messages`); +-+ expect(listResult.response.ok).toBe(true); +-+ expect(listResult.payload.ok).toBe(true); +-+ const createdMessage = listResult.payload.data.messages.find((message) => message.name === "Bat Encounter Updated"); +-+ expect(createdMessage).toEqual(expect.objectContaining({ +-+ active: true, +-+ categoryName: "Dialog", +-+ emotionProfileName: "Urgent", +-+ messageText: "Bats drop from the rafters.", +-+ notes: "Opening combat line.", +-+ })); +-+ expect(createdMessage.key).toMatch(ULID_PATTERN); +-+ +- const segmentsResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/segments`); +- expect(segmentsResult.response.ok).toBe(true); +-- expect(segmentsResult.payload.ok).toBe(true); +- const createdSegments = segmentsResult.payload.data.segments.filter((segment) => segment.messageKey === createdMessage.key); +-- expect(createdSegments).toHaveLength(2); +- expect(createdSegments.map((segment) => segment.displayOrder)).toEqual([1, 2]); +-+ expect(createdSegments.map((segment) => segment.segmentText)).toEqual([ +-+ "Bats drop from the rafters.", +-+ "Keep your torch high and shield raised.", +-+ ]); +- createdSegments.forEach((segment) => { +- expect(segment.key).toMatch(ULID_PATTERN); +- expect(segment.createdBy).toMatch(ULID_PATTERN); +- expect(segment.updatedBy).toMatch(ULID_PATTERN); +- }); +- +-- await page.locator("[data-messages-segment-row]").filter({ hasText: "We are being attacked by bats." }).getByRole("button", { name: "Move Up" }).click(); +-- await expect(page.locator("[data-messages-log]")).toHaveText("Segment order updated."); +-- await expect(page.locator("[data-messages-segment-row]").first()).toContainText("We are being attacked by bats."); +-- +-- await page.locator("[data-messages-segment-row]").filter({ hasText: "We are being attacked by bats." }).getByRole("button", { name: "Edit" }).click(); +-- await page.locator("[data-messages-segment-editor] [data-segment-text]").fill("We are being attacked by bats right now."); +-- await page.locator("[data-messages-segment-editor] [data-messages-segment-commit]").click(); +-- await expect(page.locator("[data-messages-log]")).toHaveText("Updated segment row 1."); +-- await expect(page.locator("[data-messages-segment-row]").filter({ hasText: "We are being attacked by bats right now." })).toBeVisible(); +-- +-- await page.locator("[data-messages-segment-row]").filter({ hasText: "We are being attacked by bats right now." }).getByRole("button", { name: "Disable" }).click(); +-- await expect(page.locator("[data-messages-log]")).toHaveText("Disabled segment row 1."); +-- await expect(page.locator("[data-messages-segment-row]").filter({ hasText: "We are being attacked by bats right now." })).toContainText("Inactive"); +-- +-- const updatedSegmentsResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/segments`); +-- const disabledSegment = updatedSegmentsResult.payload.data.segments.find((segment) => segment.segmentText === "We are being attacked by bats right now."); +-- expect(disabledSegment).toEqual(expect.objectContaining({ +-- active: false, +-- displayOrder: 1, +-- emotionProfileName: "Urgent", +-- messageKey: createdMessage.key, +-- messageName: "Forest Warning", +-- segmentText: "We are being attacked by bats right now.", +-- })); +-- +-- await page.locator("[data-messages-row]").filter({ hasText: "Forest Warning" }).getByRole("button", { name: "Edit" }).click(); +-- await page.locator("[data-messages-row-editor] [data-message-name]").fill("Forest Warning Updated"); +-- await page.locator("[data-messages-row-editor-details] [data-message-text]").fill("The forest gets darker beyond this point."); +-- await page.locator("[data-messages-row-editor] [data-messages-commit]").click(); +-- await expect(page.locator("[data-messages-log]")).toHaveText("Updated row Forest Warning Updated."); +-- await expect(page.locator("[data-messages-table]")).toContainText("Forest Warning Updated"); +-- +-- await page.locator("[data-messages-row]").filter({ hasText: "Forest Warning Updated" }).getByRole("button", { name: "Disable" }).click(); +-- await expect(page.locator("[data-messages-log]")).toHaveText("Disabled row Forest Warning Updated."); +-- await expect(page.locator("[data-messages-row]").filter({ hasText: "Forest Warning Updated" })).toContainText("Inactive"); +-- +-- const updateResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/messages/${createdMessage.key}`); +-- expect(updateResult.payload.data.message).toEqual(expect.objectContaining({ +-- active: false, +-- key: createdMessage.key, +-- messageText: "The forest gets darker beyond this point.", +-- name: "Forest Warning Updated", +-- })); +-- +-- await page.locator("[data-messages-tts-row]").filter({ hasText: "Arcade Browser Voice" }).getByRole("button", { name: "Disable" }).click(); +-- await expect(page.locator("[data-messages-log]")).toHaveText("Disabled TTS profile Arcade Browser Voice."); +-- await expect(page.locator("[data-messages-tts-row]").filter({ hasText: "Arcade Browser Voice" })).toContainText("Inactive"); +-- +-- for (const url of [ +-- `${failures.server.baseUrl}/api/messages/messages/${createdMessage.key}`, +-- `${failures.server.baseUrl}/api/messages/segments/${disabledSegment.key}`, +-- `${failures.server.baseUrl}/api/messages/tts-profiles/${createdTtsProfile.key}`, +-- ]) { +-- const deleteResult = await fetch(url, { method: "DELETE" }); +-- expect(deleteResult.status).toBe(404); +-- } +-+ expect(failures.failedRequests).toEqual([]); +-+ expect(failures.pageErrors).toEqual([]); +-+ expect(failures.consoleErrors).toEqual([]); +-+ } finally { +-+ await closeMessagesRun(failures, page); +-+ await fs.rm(sqlitePath, { force: true }); +-+ } +++ +++test("Text2Speech profile contract exposes Message Studio compatible profile options", () => { +++ const voiceOptions = [{ language: "en-US", label: "Test Voice (en-US)", name: "Test Voice", value: "test-voice" }]; +++ const defaults = createDefaultTextToSpeechProfiles(voiceOptions); +++ const custom = createTextToSpeechProfile({ +++ emotions: [ +++ createTextToSpeechProfileEmotion({ +++ emotion: "urgent", +++ pitch: 1.2, +++ rate: 1.1, +++ ssmlLikePreset: "whisper-ish", +++ volume: 0.8, +++ }), +++ ], +++ id: "custom-profile", +++ name: "Custom Profile", +++ voice: "test-voice", +++ voiceName: "Test Voice", +++ }); +++ const options = createMessageStudioTtsProfileOptions([custom]); +++ +++ assert.equal(TTS_PROFILE_CONTRACT_VERSION, "tts-profile-emotion-v1"); +++ assert.equal(defaults[0].name, "Default Balanced Profile"); +++ assert.equal(defaults[0].messageStudioUsageCount, 1); +++ assert.equal(defaults[0].emotions[0].emotionLabel, "Neutral"); +++ assert.equal(defaults[0].emotions[0].messagePartsUsageCount, 1); +++ assert.deepEqual(options, [{ +++ active: true, +++ emotionSettings: [{ +++ emotion: "urgent", +++ emotionLabel: "Urgent", +++ pitch: 1.2, +++ rate: 1.1, +++ ssmlLikePreset: "whisper-ish", +++ volume: 0.8, +++ }], +++ key: "custom-profile", +++ language: "en-US", +++ name: "Custom Profile", +++ providerKey: "browser-speech", +++ voiceName: "Test Voice", +++ }]); + +}); +- +-- await failures.server.close(); +-- const restartedServer = await startRepoServer(); +-- failures.server = restartedServer; +-- const persistedResult = await jsonRequest(`${restartedServer.baseUrl}/api/messages/messages/${createdMessage.key}`); +-- expect(persistedResult.response.ok).toBe(true); +-- expect(persistedResult.payload.data.message).toEqual(expect.objectContaining({ +-- active: false, +-- key: createdMessage.key, +-- name: "Forest Warning Updated", +-- messageText: "The forest gets darker beyond this point.", +-- })); +-+test("Message Studio shows actionable playback error when audio engine is unavailable", async ({ page }) => { +-+ const sqlitePath = messagesDbPath(); +-+ await fs.rm(sqlitePath, { force: true }); +-+ const failures = await openMessagesPage(page, sqlitePath, { speechAvailable: false }); +- +-- const persistedSegmentResult = await jsonRequest(`${restartedServer.baseUrl}/api/messages/segments/${disabledSegment.key}`); +-- expect(persistedSegmentResult.response.ok).toBe(true); +-- expect(persistedSegmentResult.payload.data.segment).toEqual(expect.objectContaining({ +-- active: false, +-- displayOrder: 1, +-- key: disabledSegment.key, +-- segmentText: "We are being attacked by bats right now.", +-- })); +-+ try { +-+ await addMessage(page, { +-+ emotion: "Urgent", +-+ name: "Bat Encounter", +-+ text: "Bats drop from the rafters.", +-+ }); +-+ await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).click(); +-+ await addPart(page, { +-+ emotion: "Urgent", +-+ order: 1, +-+ text: "Bats drop from the rafters.", +-+ }); +- +-- const persistedTtsProfileResult = await jsonRequest(`${restartedServer.baseUrl}/api/messages/tts-profiles/${createdTtsProfile.key}`); +-- expect(persistedTtsProfileResult.response.ok).toBe(true); +-- expect(persistedTtsProfileResult.payload.data.ttsProfile).toEqual(expect.objectContaining({ +-- active: false, +-- key: createdTtsProfile.key, +-- name: "Arcade Browser Voice", +-- providerKey: "browser-speech", +-- voiceName: "Test Voice", +-- })); +-+ await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Play Message" }).click(); +-+ await expect(page.locator("[data-messages-validation-card]")).toBeVisible(); +-+ await expect(page.locator("[data-messages-validation-errors]")).toContainText("Audio engine is unavailable. Use a browser with SpeechSynthesis support and reload Message Studio."); +-+ await expect(page.locator("[data-messages-log]")).toHaveText("Audio engine is unavailable. Use a browser with SpeechSynthesis support and reload Message Studio."); +-+ await expect(page.locator("[data-messages-preview-status]")).toHaveText("Audio engine is unavailable. Use a browser with SpeechSynthesis support and reload Message Studio."); +-+ expect(await page.evaluate(() => window.__messagesSpeechCalls)).toEqual([]); +- +- expect(failures.failedRequests).toEqual([]); +- expect(failures.pageErrors).toEqual([]); +-diff --git a/toolbox/messages/index.html b/toolbox/messages/index.html +-index 2f1945bbe..5df9a0290 100644 +---- a/toolbox/messages/index.html +-+++ b/toolbox/messages/index.html +-@@ -39,7 +39,7 @@ +-
+- Row Workflow +-
+--

Review existing rows, select one to update, or add a row from the Message Rows table.

+-+

Review messages, open a message to manage parts, or add a message from the Messages table.

+-

Disable rows instead of deleting game text.

+-
+-
+-@@ -48,9 +48,9 @@ +-
++diff --git a/toolbox/text-to-speech/index.html b/toolbox/text-to-speech/index.html ++index f5b29b19a..e82dd9468 100644 ++--- a/toolbox/text-to-speech/index.html +++++ b/toolbox/text-to-speech/index.html ++@@ -98,14 +98,50 @@ ++ ++
+
+-

Message Studio

+--

Message rows define what is said. Emotion profiles and TTS profiles define future delivery settings only.

+-+

Messages own game text and ordered parts. Audio playback is delegated to the audio engine.

+-
+--
0Message Rows
+-+
0Messages
+-
0Emotion Profiles
+-
0TTS Profiles
++-

Speech Composition

++-
+++

TTS Studio

+++
++
0Characters
+++
0TTS Profiles
+++
0Emotion Settings
++
0Voices
++
CheckingEngine
+
+-@@ -58,17 +58,17 @@ +-
+-
+-
Game Text Repository
+--

Message Rows

+-+

Messages

+-
+-
+-- +-+
+- +- +-- +-- +-- +-- +-+ +-+ +- +-+ +-+ +- +- +- +-@@ -78,7 +78,7 @@ +-
NamePrimary EmotionSegmentsTagsMessage NameTypeStatusPartsDefault TTS ProfileActions
+-
+-
+-- +-+ +-
+-
+-@@ -153,7 +150,7 @@ +-
+-

Name: None

+-

Emotion Profile: None

+--

Segment: None

+-+

Part: None

+-

Status: None

+-

Text:

+-
No message selected.
+-@@ -184,7 +181,7 @@ +-
+- Future Compatibility +-
+--

Message segments are stored as ordered text with emotion profiles.

+-+

Message parts are stored as ordered text with emotion profiles.

+-

This tool stores message text exactly as entered.

+-
+-
+-diff --git a/toolbox/messages/message-tts-service-registry.js b/toolbox/messages/message-tts-service-registry.js +-index ce914c832..e5cf310ba 100644 +---- a/toolbox/messages/message-tts-service-registry.js +-+++ b/toolbox/messages/message-tts-service-registry.js +-@@ -31,7 +31,13 @@ function createMessageStudioTtsServiceRegistry({ +- ok: false, +- }; +- } +-- return engine.speak(options); +-+ const selectedVoice = String(options?.voice || "").trim() +-+ || engine.voiceOptions()[0]?.value +-+ || ""; +-+ return engine.speak({ +-+ ...options, +-+ voice: selectedVoice, +-+ }); +- }, +++
+++
Reusable Speech Profiles
+++

TTS Profiles

+++
+++
+++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
Profile NameVoiceLanguageGenderAgeEmotion CountStatusActions
Loading TTS profiles.
+++
+++
+++ +++
+++
+++ +++
+++
+++
+++
Local Preview
+++

Speech Composition

+++
++ ++ ++
++diff --git a/toolbox/text-to-speech/text2speech.js b/toolbox/text-to-speech/text2speech.js ++index 5ab111eeb..9e0af1f87 100644 ++--- a/toolbox/text-to-speech/text2speech.js +++++ b/toolbox/text-to-speech/text2speech.js ++@@ -2,6 +2,7 @@ import { ++ createTextToSpeechQueueItem, ++ filterTextToSpeechVoiceOptions, ++ shapeTextToSpeechOptions, +++ textToSpeechLanguageOptionsFromVoices, ++ textToSpeechPayloadGenderValue, ++ TextToSpeechEngine, ++ uniqueTextToSpeechId, ++@@ -80,6 +81,27 @@ const TTS_PROVIDER_ADAPTER_PLAN = Object.freeze([ ++ } ++ ]); ++ +++const TTS_PROFILE_CONTRACT_VERSION = "tts-profile-emotion-v1"; +++const NEW_ROW_KEY = "__new__"; +++const DEFAULT_TTS_PROFILE_ID = "default-balanced-profile"; +++const DEFAULT_TTS_EMOTION_ID = "neutral"; +++ +++const TTS_PROFILE_GENDER_OPTIONS = Object.freeze([ +++ Object.freeze({ label: "Neutral", value: "neutral" }), +++ Object.freeze({ label: "Male", value: "male" }), +++ Object.freeze({ label: "Female", value: "female" }), +++ Object.freeze({ label: "Any", value: "any" }) +++]); +++ +++const TTS_PROFILE_EMOTION_OPTIONS = Object.freeze([ +++ Object.freeze({ label: "Neutral", value: "neutral" }), +++ Object.freeze({ label: "Calm", value: "calm" }), +++ Object.freeze({ label: "Urgent", value: "urgent" }), +++ Object.freeze({ label: "Whisper", value: "whisper" }), +++ Object.freeze({ label: "Angry", value: "angry" }), +++ Object.freeze({ label: "Excited", value: "excited" }) +++]); +++ ++ function boundedNumber(value, { fallback, max, min, value: defaultValue }) { ++ const number = Number(value); ++ const fallbackValue = fallback ?? defaultValue ?? min; ++@@ -139,6 +161,144 @@ function createVoiceProfile({ key = "browser-speech", name = "Browser Speech", p + }; + } +-diff --git a/toolbox/messages/messages.js b/toolbox/messages/messages.js +-index 7843a8d2c..a2407ba95 100644 +---- a/toolbox/messages/messages.js +-+++ b/toolbox/messages/messages.js +-@@ -2,7 +2,6 @@ import { +- createMessage, +- createMessageSegment, +- createEmotionProfile, +-- createTtsProfile, +- listEmotionProfiles, +- listMessages, +- listMessageSegments, +-@@ -10,11 +9,23 @@ import { +- updateEmotionProfile, +- updateMessage, +- updateMessageSegment, +-- updateTtsProfile, +- } from "./messages-api-client.js"; +- import { createMessageStudioTtsServiceRegistry } from "./message-tts-service-registry.js"; +- +- const NEW_ROW_KEY = "__new__"; +-+const DEFAULT_TTS_PROFILE_KEY = "__default-balanced-tts__"; +-+const DEFAULT_TTS_PROFILE = Object.freeze({ +-+ active: true, +-+ description: "Balanced local browser playback option until authored TTS profiles are available.", +-+ key: DEFAULT_TTS_PROFILE_KEY, +-+ language: "en-US", +-+ name: "Default Balanced TTS", +-+ pitch: 1, +-+ providerKey: "browser-speech", +-+ rate: 1, +-+ voiceName: "", +-+ volume: 1, +-+}); +- const ttsServiceRegistry = createMessageStudioTtsServiceRegistry(); +- +- const elements = { +-@@ -37,7 +48,6 @@ const elements = { +- speechTestTarget: document.querySelector("[data-messages-speech-test-target]"), +- table: document.querySelector("[data-messages-table]"), +- testSpeech: document.querySelector("[data-messages-test-speech]"), +-- ttsAddRow: document.querySelector("[data-messages-tts-add-row]"), +- ttsCount: document.querySelector("[data-messages-tts-count]"), +- ttsRows: document.querySelector("[data-messages-tts-profiles]"), +- ttsService: document.querySelector("[data-messages-tts-service]"), +-@@ -49,9 +59,10 @@ const state = { +- editingEmotionKey: "", +- editingMessageKey: "", +- editingSegmentKey: "", +-- editingTtsKey: "", +- emotionProfiles: [], +-+ messageTtsProfileKeys: new Map(), +- messages: [], +-+ segmentTtsProfileKeys: new Map(), +- segments: [], +- selectedMessageKey: "", +- selectedSegmentKey: "", +-@@ -148,6 +159,19 @@ function createSelect(value, dataName, options, placeholder) { +- return select; +- } +- +-+function createTtsProfileSelect(value, dataName, identityKey) { +-+ const select = createSelect( +-+ value || defaultTtsProfileKey(), +-+ dataName, +-+ activeTtsProfileOptions(), +-+ "Select TTS profile", +-+ ); +-+ if (identityKey) { +-+ select.dataset.messagesTtsIdentity = identityKey; +-+ } +-+ return select; ++ +++function slugFromText(value, fallback = "tts-profile") { +++ const slug = String(value || "") +++ .trim() +++ .toLowerCase() +++ .replace(/[^a-z0-9]+/g, "-") +++ .replace(/^-+|-+$/g, ""); +++ return slug || fallback; + +} + + +- function populateSelect(select, options, placeholder) { +- if (!select) { +- return; +-@@ -225,12 +249,35 @@ function emotionProfileByKey(profileKey) { +- return state.emotionProfiles.find((profile) => profile.key === profileKey) || null; +- } +- +--function ttsProfileByKey(profileKey) { +-- return state.ttsProfiles.find((profile) => profile.key === profileKey) || null; +-+function activeTtsProfileOptions() { +-+ const activeProfiles = state.ttsProfiles.filter((profile) => profile.active); +-+ return activeProfiles.length ? activeProfiles : [DEFAULT_TTS_PROFILE]; +++function labelForOption(options, value, fallback = "") { +++ return options.find((option) => String(option.value) === String(value))?.label || fallback || String(value || ""); + +} + + +-+function defaultTtsProfileKey() { +-+ return activeTtsProfileOptions()[0]?.key || DEFAULT_TTS_PROFILE_KEY; +++function createTextToSpeechProfileEmotion({ +++ active = true, +++ emotion = "neutral", +++ id = "", +++ messagePartsUsageCount = 0, +++ pitch = TEXT_TO_SPEECH_DEFAULTS.pitch, +++ rate = TEXT_TO_SPEECH_DEFAULTS.rate, +++ ssmlLikePreset = TEXT_TO_SPEECH_DEFAULTS.ssmlLikePreset, +++ volume = TEXT_TO_SPEECH_DEFAULTS.volume +++} = {}) { +++ const emotionKey = slugFromText(emotion, DEFAULT_TTS_EMOTION_ID); +++ return { +++ active: active !== false, +++ emotion: emotionKey, +++ emotionLabel: labelForOption(TTS_PROFILE_EMOTION_OPTIONS, emotionKey, "Neutral"), +++ id: id || emotionKey, +++ messagePartsUsageCount: Math.max(0, Number(messagePartsUsageCount) || 0), +++ pitch: boundedNumber(pitch, TEXT_TO_SPEECH_RANGE_DEFAULTS.pitch), +++ rate: boundedNumber(rate, TEXT_TO_SPEECH_RANGE_DEFAULTS.rate), +++ ssmlLikePreset: TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS.some((option) => option.value === ssmlLikePreset) ? ssmlLikePreset : "normal", +++ volume: boundedNumber(volume, TEXT_TO_SPEECH_RANGE_DEFAULTS.volume) +++ }; + +} + + +-+function ttsProfileOptionByKey(profileKey) { +-+ return activeTtsProfileOptions().find((profile) => profile.key === profileKey) +-+ || activeTtsProfileOptions()[0] +-+ || DEFAULT_TTS_PROFILE; +++function createTextToSpeechProfile({ +++ active = true, +++ age = TEXT_TO_SPEECH_DEFAULTS.voiceAge, +++ emotions = [], +++ gender = "neutral", +++ id = "", +++ language = TEXT_TO_SPEECH_DEFAULTS.language, +++ messageStudioUsageCount = 0, +++ name = "Default Balanced Profile", +++ voice = "", +++ voiceName = "" +++} = {}) { +++ const profileName = String(name || "Default Balanced Profile").trim() || "Default Balanced Profile"; +++ const emotionRows = Array.isArray(emotions) && emotions.length +++ ? emotions.map((emotion) => createTextToSpeechProfileEmotion(emotion)) +++ : [createTextToSpeechProfileEmotion()]; +++ return { +++ active: active !== false, +++ age: String(age || TEXT_TO_SPEECH_DEFAULTS.voiceAge), +++ emotions: emotionRows, +++ gender: String(gender || "neutral"), +++ id: id || slugFromText(profileName, "tts-profile"), +++ language: String(language || TEXT_TO_SPEECH_DEFAULTS.language), +++ messageStudioUsageCount: Math.max(0, Number(messageStudioUsageCount) || 0), +++ name: profileName, +++ owner: TTS_OWNERSHIP.AUDIO, +++ providerKey: "browser-speech", +++ voice: String(voice || ""), +++ voiceName: String(voiceName || voice || "Default browser voice") +++ }; + +} + + +-+function selectedTtsProfileForMessage(messageKey) { +-+ return ttsProfileOptionByKey(state.messageTtsProfileKeys.get(messageKey) || defaultTtsProfileKey()); +++function defaultVoiceForProfile(voiceOptions = [], preferredGender = "") { +++ if (!voiceOptions.length) { +++ return null; +++ } +++ const preferred = voiceOptions.find((option) => { +++ const text = `${option.name || ""} ${option.label || ""}`.toLowerCase(); +++ if (preferredGender === "male") return /\bmale\b|\bman\b|\bdavid\b|\bmark\b/.test(text); +++ if (preferredGender === "female") return /\bfemale\b|\bwoman\b|\bzira\b/.test(text); +++ return false; +++ }); +++ return preferred || voiceOptions[0]; + +} + + +-+function selectedTtsProfileForSegment(segmentKey, messageKey = state.selectedMessageKey) { +-+ return ttsProfileOptionByKey( +-+ state.segmentTtsProfileKeys.get(segmentKey) +-+ || state.messageTtsProfileKeys.get(messageKey) +-+ || defaultTtsProfileKey(), +-+ ); +- } +- +- function selectedTtsProfile() { +-- return ttsProfileByKey(elements.previewTtsProfile?.value || ""); +-+ return ttsProfileOptionByKey(elements.previewTtsProfile?.value || defaultTtsProfileKey()); +- } +- +- function selectedTtsService() { +-@@ -243,10 +290,11 @@ function selectedSpeechTarget() { +- return { +- emotionProfile: emotionProfileByKey(segment.emotionProfileKey), +- id: segment.key, +-- label: `Segment ${segment.displayOrder}`, +-- name: `${segment.messageName || "Message"} segment ${segment.displayOrder}`, +-+ label: `Part ${segment.displayOrder}`, +-+ name: `${segment.messageName || "Message"} part ${segment.displayOrder}`, +-+ profile: selectedTtsProfileForSegment(segment.key, segment.messageKey), +- text: segment.segmentText, +-- type: "segment", +-+ type: "part", +- }; +- } +- const message = selectedMessage(); +-@@ -256,8 +304,9 @@ function selectedSpeechTarget() { +- return { +- emotionProfile: emotionProfileByKey(message.emotionProfileKey), +- id: message.key, +-- label: `Message Row: ${message.name}`, +-+ label: `Message: ${message.name}`, +- name: message.name, +-+ profile: selectedTtsProfileForMessage(message.key), +- text: message.messageText, +- type: "message", +++function createDefaultTextToSpeechProfiles(voiceOptions = []) { +++ const balancedVoice = defaultVoiceForProfile(voiceOptions); +++ const manVoice = defaultVoiceForProfile(voiceOptions, "male") || balancedVoice; +++ const womanVoice = defaultVoiceForProfile(voiceOptions, "female") || voiceOptions[1] || balancedVoice; +++ return [ +++ createTextToSpeechProfile({ +++ emotions: [createTextToSpeechProfileEmotion({ messagePartsUsageCount: 1 })], +++ id: DEFAULT_TTS_PROFILE_ID, +++ language: balancedVoice?.language || TEXT_TO_SPEECH_DEFAULTS.language, +++ messageStudioUsageCount: 1, +++ name: "Default Balanced Profile", +++ voice: balancedVoice?.value || "", +++ voiceName: balancedVoice?.name || balancedVoice?.label || "Default browser voice" +++ }), +++ createTextToSpeechProfile({ +++ gender: "male", +++ id: "man-profile-1", +++ language: manVoice?.language || TEXT_TO_SPEECH_DEFAULTS.language, +++ name: "Man Profile 1", +++ voice: manVoice?.value || "", +++ voiceName: manVoice?.name || manVoice?.label || "Default browser voice" +++ }), +++ createTextToSpeechProfile({ +++ gender: "female", +++ id: "woman-profile-2", +++ language: womanVoice?.language || TEXT_TO_SPEECH_DEFAULTS.language, +++ name: "Woman Profile 2", +++ voice: womanVoice?.value || "", +++ voiceName: womanVoice?.name || womanVoice?.label || "Default browser voice" +++ }) +++ ]; +++} +++ +++function createMessageStudioTtsProfileOptions(profiles = []) { +++ return profiles +++ .filter((profile) => profile?.active !== false) +++ .map((profile) => ({ +++ active: true, +++ emotionSettings: Array.isArray(profile.emotions) +++ ? profile.emotions.filter((emotion) => emotion.active !== false).map((emotion) => ({ +++ emotion: emotion.emotion, +++ emotionLabel: emotion.emotionLabel, +++ pitch: emotion.pitch, +++ rate: emotion.rate, +++ ssmlLikePreset: emotion.ssmlLikePreset, +++ volume: emotion.volume +++ })) +++ : [], +++ key: profile.id, +++ language: profile.language, +++ name: profile.name, +++ providerKey: profile.providerKey || "browser-speech", +++ voiceName: profile.voiceName || profile.voice || "" +++ })); +++} +++ ++ function createSpeechPreviewRequest({ ++ pitch = TEXT_TO_SPEECH_DEFAULTS.pitch, ++ rate = TEXT_TO_SPEECH_DEFAULTS.rate, ++@@ -250,6 +410,7 @@ function queueItemMeta(item) { ++ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeechEngine() } = {}) { ++ const elements = { ++ addItem: root.querySelector("[data-tts-add-item]"), +++ addProfile: root.querySelector("[data-tts-add-profile]"), ++ age: root.querySelector("[data-tts-age-select]"), ++ characterPreset: root.querySelector("[data-tts-character-preset-select]"), ++ clearStatus: root.querySelector("[data-tts-clear-status]"), ++@@ -266,6 +427,9 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech ++ pause: root.querySelector("[data-tts-pause]"), ++ pitch: root.querySelector("[data-tts-pitch]"), ++ pitchValue: root.querySelector("[data-tts-pitch-value]"), +++ profileCount: root.querySelector("[data-tts-profile-count]"), +++ profileEmotionCount: root.querySelector("[data-tts-emotion-count]"), +++ profileTable: root.querySelector("[data-tts-profile-table]"), ++ queueList: root.querySelector("[data-tts-queue-list]"), ++ rate: root.querySelector("[data-tts-rate]"), ++ rateValue: root.querySelector("[data-tts-rate-value]"), ++@@ -285,7 +449,11 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech + }; +-@@ -293,7 +342,7 @@ function selectOptionsWithCurrent(currentKey) { +- function renderCounts() { +- setText(elements.count, String(state.messages.length)); +- setText(elements.emotionCount, String(state.emotionProfiles.length)); +-- setText(elements.ttsCount, String(state.ttsProfiles.length)); +-+ setText(elements.ttsCount, String(activeTtsProfileOptions().length)); +- } +- +- function renderPersistence(persistence = {}) { +-@@ -330,7 +379,7 @@ function renderTtsServiceOptions() { +- } +- +- function renderTtsProfileOptions() { +-- const activeProfiles = state.ttsProfiles.filter((profile) => profile.active); +-+ const activeProfiles = activeTtsProfileOptions(); +- populateSelect(elements.previewTtsProfile, activeProfiles, "Select TTS profile"); +- const selected = selectedTtsProfile(); +- if (!selected && activeProfiles[0]) { +-@@ -361,9 +410,6 @@ function speechTestReadiness() { +- if (!profile) { +- return { message: "Select an active TTS profile before testing speech.", ok: false }; +- } +-- if (!String(profile.voiceName || "").trim()) { +-- return { message: "Select a TTS profile with a voice before testing speech.", ok: false }; +-- } +- if (!target.emotionProfile) { +- return { message: "Selected item needs an emotion profile before testing speech.", ok: false }; +- } +-@@ -393,22 +439,27 @@ function createMessageEditRows(message = null) { +- const nameCell = document.createElement("td"); +- nameCell.append(createInput(message?.name || "", "messageName")); +- +-- const emotionCell = document.createElement("td"); +-- emotionCell.append(createSelect(message?.emotionProfileKey || "", "messageEmotion", selectOptionsWithCurrent(message?.emotionProfileKey || ""), "Select emotion profile")); +-- +-- const segmentCell = createCell(message ? String(messageSegments(message.key).length) : "0"); +-- const tagsCell = createCell("Tags planned"); +-+ const typeCell = createCell(message?.categoryName || "Dialog"); +- +- const statusCell = document.createElement("td"); +- statusCell.append(createCheckbox(message?.active !== false, "messageActive")); +- +-+ const partCell = createCell(message ? String(messageSegments(message.key).length) : "0"); +-+ +-+ const ttsCell = document.createElement("td"); +-+ ttsCell.append(createTtsProfileSelect( +-+ message ? selectedTtsProfileForMessage(message.key).key : defaultTtsProfileKey(), +-+ "messageDefaultTtsProfile", +-+ key, +-+ )); +-+ +- const actions = document.createElement("td"); +- actions.append(createActionGroup( +-- createButton("Update Row", "messagesCommit", key), +-+ createButton("Save", "messagesCommit", key), +- createButton("Cancel", "messagesCancel", key), +- )); +- +-- row.append(nameCell, emotionCell, segmentCell, tagsCell, statusCell, actions); +-+ row.append(nameCell, typeCell, statusCell, partCell, ttsCell, actions); +- +- const detailRow = document.createElement("tr"); +- detailRow.dataset.messagesRowEditorDetails = key; +-@@ -417,6 +468,7 @@ function createMessageEditRows(message = null) { +- const stack = document.createElement("div"); +- stack.className = "content-stack"; +- stack.append( +-+ createField("Primary Emotion", createSelect(message?.emotionProfileKey || "", "messageEmotion", selectOptionsWithCurrent(message?.emotionProfileKey || ""), "Select emotion profile")), +- createField("Message Text", createTextarea(message?.messageText || "", "messageText", 6)), +- createField("Notes", createTextarea(message?.notes || "", "messageNotes", 3)), +- ); +-@@ -432,19 +484,19 @@ function createMessageSegmentTable() { +- +- const context = document.createElement("div"); +- context.className = "kicker"; +-- context.textContent = "Message Row / Segment Table"; +-+ context.textContent = "Message / Message Parts"; +- const heading = document.createElement("h3"); +-- heading.textContent = "Message Segments"; +-+ heading.textContent = "Message Parts"; +- wrapper.append(context, heading); +- +- const tableWrapper = document.createElement("div"); +- tableWrapper.className = "table-wrapper"; +- const table = document.createElement("table"); +- table.className = "data-table"; +-- table.setAttribute("aria-label", "Selected message segments"); +-+ table.setAttribute("aria-label", "Message parts"); +- const thead = document.createElement("thead"); +- const headerRow = document.createElement("tr"); +-- ["Order", "Emotion", "Text", "Status", "Actions"].forEach((label) => { +-+ ["Order", "Text", "Emotion", "TTS Profile", "Status", "Actions"].forEach((label) => { +- const header = document.createElement("th"); +- header.scope = "col"; +- header.textContent = label; +-@@ -456,7 +508,7 @@ function createMessageSegmentTable() { +- +- const segments = selectedMessageSegments(); +- if (!segments.length && state.editingSegmentKey !== NEW_ROW_KEY) { +-- tbody.append(tableMessage(5, "No segments saved for this message.")); +-+ tbody.append(tableMessage(6, "No message parts saved for this message.")); +- } +- +- segments.forEach((segment, index) => { +-@@ -472,15 +524,23 @@ function createMessageSegmentTable() { +- moveUp.disabled = index === 0; +- moveDown.disabled = index === segments.length - 1; +- actions.append(createActionGroup( +-- createButton("Edit", "messagesSegmentEdit", segment.key), +-+ createButton("Play Part", "messagesSegmentPlay", segment.key), +-+ createButton("Edit Part", "messagesSegmentEdit", segment.key), +- moveUp, +- moveDown, +- segment.active ? createButton("Disable", "messagesSegmentDisable", segment.key) : null, +- )); +-+ const ttsCell = document.createElement("td"); +-+ ttsCell.append(createTtsProfileSelect( +-+ selectedTtsProfileForSegment(segment.key, segment.messageKey).key, +-+ "segmentTtsProfile", +-+ segment.key, +-+ )); +- row.append( +- createCell(String(segment.displayOrder)), +-- createCell(segment.emotionProfileName || "Unknown"), +- createCell(segment.segmentText), +-+ createCell(segment.emotionProfileName || "Unknown"), +-+ ttsCell, +- createCell(statusForActive(segment.active)), +- actions, +- ); +-@@ -495,7 +555,7 @@ function createMessageSegmentTable() { +- tableWrapper.append(table); +- const actionGroup = document.createElement("div"); +- actionGroup.className = "action-group"; +-- actionGroup.append(createButton("Add Segment Row", "messagesSegmentAddRow", state.selectedMessageKey)); +-+ actionGroup.append(createButton("Add Part", "messagesSegmentAddRow", state.selectedMessageKey)); +- wrapper.append(tableWrapper, actionGroup); +- return wrapper; +- } +-@@ -508,22 +568,29 @@ function createSegmentEditRow(segment = null) { +- const orderCell = document.createElement("td"); +- orderCell.append(createNumberInput(segment?.displayOrder || nextSegmentOrder(), "segmentOrder", { min: 1, step: 1 })); +- +-+ const textCell = document.createElement("td"); +-+ textCell.append(createTextarea(segment?.segmentText || "", "segmentText", 3)); +-+ +- const emotionCell = document.createElement("td"); +- emotionCell.append(createSelect(segment?.emotionProfileKey || "", "segmentEmotion", selectOptionsWithCurrent(segment?.emotionProfileKey || ""), "Select emotion")); +- +-- const textCell = document.createElement("td"); +-- textCell.append(createTextarea(segment?.segmentText || "", "segmentText", 3)); +-+ const ttsCell = document.createElement("td"); +-+ ttsCell.append(createTtsProfileSelect( +-+ segment ? selectedTtsProfileForSegment(segment.key, segment.messageKey).key : selectedTtsProfileForMessage(state.selectedMessageKey).key, +-+ "segmentTtsProfile", +-+ key, +-+ )); +- +- const statusCell = document.createElement("td"); +- statusCell.append(createCheckbox(segment?.active !== false, "segmentActive")); +- +- const actions = document.createElement("td"); +- actions.append(createActionGroup( +-- createButton("Update Row", "messagesSegmentCommit", key), +-+ createButton("Save", "messagesSegmentCommit", key), +- createButton("Cancel", "messagesSegmentCancel", key), +- )); +- +-- row.append(orderCell, emotionCell, textCell, statusCell, actions); +-+ row.append(orderCell, textCell, emotionCell, ttsCell, statusCell, actions); +- return row; +- } +- +-@@ -546,7 +613,7 @@ function renderMessageRows() { +- } +- elements.table.replaceChildren(); +- if (!state.messages.length && state.editingMessageKey !== NEW_ROW_KEY) { +-- elements.table.append(tableMessage(6, "No message rows saved yet.")); +-+ elements.table.append(tableMessage(6, "No messages saved yet. Add a message such as Bat Encounter.")); +- return; ++ const state = { ++ applyingItem: false, +++ editingEmotionId: "", +++ editingProfileId: "", +++ profiles: [], ++ queue: [], +++ selectedProfileId: "", ++ selectedItemId: "", ++ sliderOverrides: { pitch: false, rate: false, volume: false }, ++ voiceOptions: [] ++@@ -330,6 +498,477 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech ++ }; + } +- +-@@ -559,17 +626,28 @@ function renderMessageRows() { +- +- const row = document.createElement("tr"); +- row.dataset.messagesRow = message.key; ++ +++ function selectedProfile() { +++ return state.profiles.find((profile) => profile.id === state.selectedProfileId) || null; +++ } +++ +++ function profileInUseByMessageStudio(profile) { +++ return Number(profile?.messageStudioUsageCount || 0) > 0; +++ } +++ +++ function emotionInUseByMessageParts(emotion) { +++ return Number(emotion?.messagePartsUsageCount || 0) > 0; +++ } +++ +++ function createCell(text) { +++ const cell = document.createElement("td"); +++ cell.textContent = text; +++ return cell; +++ } +++ +++ function createButton(label, dataName, value) { +++ const button = document.createElement("button"); +++ button.className = "btn btn--compact"; +++ button.type = "button"; +++ button.dataset[dataName] = value; +++ button.textContent = label; +++ return button; +++ } +++ +++ function createActionGroup(...buttons) { +++ const group = document.createElement("div"); +++ group.className = "action-group action-group--tight"; +++ buttons.filter(Boolean).forEach((button) => group.append(button)); +++ return group; +++ } +++ +++ function tableMessage(colSpan, text) { +++ const row = document.createElement("tr"); +++ const cell = document.createElement("td"); +++ cell.colSpan = colSpan; +++ cell.textContent = text; +++ row.append(cell); +++ return row; +++ } +++ +++ function createTextInput(value, dataName) { +++ const input = document.createElement("input"); +++ input.dataset[dataName] = ""; +++ input.type = "text"; +++ input.value = value || ""; +++ return input; +++ } +++ +++ function createNumberInput(value, dataName, kind) { +++ const input = document.createElement("input"); +++ const range = TEXT_TO_SPEECH_RANGE_DEFAULTS[kind] || TEXT_TO_SPEECH_RANGE_DEFAULTS.rate; +++ input.dataset[dataName] = ""; +++ input.type = "number"; +++ input.min = String(range.min); +++ input.max = String(range.max); +++ input.step = String(range.step); +++ input.value = formatRangeValue(value ?? range.value, kind); +++ return input; +++ } +++ +++ function createCheckbox(checked, dataName) { +++ const label = document.createElement("label"); +++ const input = document.createElement("input"); +++ input.dataset[dataName] = ""; +++ input.type = "checkbox"; +++ input.checked = checked !== false; +++ label.append(input, " Active"); +++ return label; +++ } +++ +++ function createEditorSelect(value, dataName, options, placeholder = "") { +++ const select = document.createElement("select"); +++ select.dataset[dataName] = ""; +++ if (placeholder) { +++ const placeholderOption = document.createElement("option"); +++ placeholderOption.value = ""; +++ placeholderOption.textContent = placeholder; +++ select.append(placeholderOption); +++ } +++ options.forEach((optionValue) => { +++ const option = document.createElement("option"); +++ option.value = String(optionValue.value); +++ option.textContent = optionValue.label; +++ select.append(option); +++ }); +++ select.value = options.some((optionValue) => String(optionValue.value) === String(value)) ? String(value) : String(options[0]?.value || ""); +++ return select; +++ } +++ +++ function voiceSelectOptions() { +++ return state.voiceOptions.length +++ ? state.voiceOptions.map((option) => ({ label: option.label, value: option.value })) +++ : [{ label: "No browser voices available", value: "" }]; +++ } +++ +++ function languageSelectOptions() { +++ const voiceLanguages = textToSpeechLanguageOptionsFromVoices(state.voiceOptions); +++ return voiceLanguages.length ? voiceLanguages : TEXT_TO_SPEECH_LANGUAGE_OPTIONS; +++ } +++ +++ function profileVoiceName(profile) { +++ const match = state.voiceOptions.find((option) => option.value === profile.voice); +++ return match?.name || match?.label || profile.voiceName || "No voice selected"; +++ } +++ +++ function renderProfileCounts() { +++ if (elements.profileCount) elements.profileCount.textContent = String(state.profiles.length); +++ if (elements.profileEmotionCount) { +++ const emotionCount = state.profiles.reduce((total, profile) => total + profile.emotions.length, 0); +++ elements.profileEmotionCount.textContent = String(emotionCount); +++ } +++ } +++ +++ function renderProfileRows() { +++ if (!elements.profileTable) return; +++ elements.profileTable.replaceChildren(); +++ +++ state.profiles.forEach((profile) => { +++ if (state.editingProfileId === profile.id) { +++ elements.profileTable.append(createProfileEditRow(profile)); +++ appendEmotionHost(profile.id); +++ return; +++ } +++ +++ const row = document.createElement("tr"); +++ row.dataset.ttsProfileRow = profile.id; +++ const nameCell = document.createElement("td"); +++ nameCell.dataset.ttsProfileNameCell = profile.id; +++ nameCell.textContent = `${state.selectedProfileId === profile.id ? "v" : ">"} ${profile.name}`; +++ const deleteButton = createButton("Delete", "ttsDeleteProfile", profile.id); +++ if (profileInUseByMessageStudio(profile)) { +++ deleteButton.disabled = true; +++ deleteButton.title = "Delete disabled: profile is in use by Message Studio data."; +++ } +++ const actions = createActionGroup( +++ createButton("Edit Profile", "ttsEditProfile", profile.id), +++ deleteButton, +++ ); +++ row.append( +++ nameCell, +++ createCell(profileVoiceName(profile)), +++ createCell(profile.language), +++ createCell(labelForOption(TTS_PROFILE_GENDER_OPTIONS, profile.gender, "Neutral")), +++ createCell(labelForOption(TEXT_TO_SPEECH_AGE_FILTER_OPTIONS, profile.age, "Any")), +++ createCell(String(profile.emotions.length)), +++ createCell(profile.active ? "Active" : "Inactive"), +++ (() => { +++ const cell = document.createElement("td"); +++ cell.append(actions); +++ return cell; +++ })(), +++ ); +++ elements.profileTable.append(row); +++ appendEmotionHost(profile.id); +++ }); +++ +++ if (state.editingProfileId === NEW_ROW_KEY) { +++ elements.profileTable.append(createProfileEditRow(null)); +++ } +++ +++ if (!state.profiles.length && state.editingProfileId !== NEW_ROW_KEY) { +++ elements.profileTable.append(tableMessage(8, "No TTS profiles yet.")); +++ } +++ renderProfileCounts(); +++ } +++ +++ function createProfileEditRow(profile = null) { +++ const key = profile?.id || NEW_ROW_KEY; +++ const row = document.createElement("tr"); +++ row.dataset.ttsProfileEditor = key; +++ + + const nameCell = document.createElement("td"); +-+ const isExpanded = state.selectedMessageKey === message.key; +-+ nameCell.dataset.messagesNameCell = message.key; +-+ nameCell.textContent = `${isExpanded ? "v" : ">"} ${message.name}`; +-+ const ttsCell = document.createElement("td"); +-+ ttsCell.append(createTtsProfileSelect( +-+ selectedTtsProfileForMessage(message.key).key, +-+ "messageDefaultTtsProfile", +-+ message.key, +++ nameCell.append(createTextInput(profile?.name || "", "ttsProfileName")); +++ const voiceCell = document.createElement("td"); +++ voiceCell.append(createEditorSelect(profile?.voice || "", "ttsProfileVoice", voiceSelectOptions(), "Select voice")); +++ const languageCell = document.createElement("td"); +++ languageCell.append(createEditorSelect(profile?.language || TEXT_TO_SPEECH_DEFAULTS.language, "ttsProfileLanguage", languageSelectOptions())); +++ const genderCell = document.createElement("td"); +++ genderCell.append(createEditorSelect(profile?.gender || "neutral", "ttsProfileGender", TTS_PROFILE_GENDER_OPTIONS)); +++ const ageCell = document.createElement("td"); +++ ageCell.append(createEditorSelect(profile?.age || TEXT_TO_SPEECH_DEFAULTS.voiceAge, "ttsProfileAge", TEXT_TO_SPEECH_AGE_FILTER_OPTIONS)); +++ const emotionCountCell = createCell(profile ? String(profile.emotions.length) : "1"); +++ const statusCell = document.createElement("td"); +++ statusCell.append(createCheckbox(profile?.active !== false, "ttsProfileActive")); +++ const actionsCell = document.createElement("td"); +++ actionsCell.append(createActionGroup( +++ createButton("Save", "ttsCommitProfile", key), +++ createButton("Cancel", "ttsCancelProfile", key), + + )); +- const actions = document.createElement("td"); +- actions.append(createActionGroup( +-- createButton("Edit", "messagesEdit", message.key), +-+ createButton("Play Message", "messagesPlay", message.key), +-+ createButton("Edit Message", "messagesEdit", message.key), +- message.active ? createButton("Disable", "messagesDisable", message.key) : null, +- )); +- row.append( +-- createCell(message.name), +-- createCell(message.emotionProfileName || "Unknown"), +-- createCell(String(messageSegments(message.key).length)), +-- createCell("Tags planned"), +-+ nameCell, +-+ createCell(message.categoryName || "Dialog"), +- createCell(statusForActive(message.active)), +-+ createCell(String(messageSegments(message.key).length)), +-+ ttsCell, +- actions, +- ); +- elements.table.append(row); +-@@ -640,63 +718,24 @@ function renderEmotionRows() { +- } +- } +- +--function createTtsEditRow(profile = null) { +-- const key = profile?.key || NEW_ROW_KEY; +-- const row = document.createElement("tr"); +-- row.dataset.messagesTtsEditor = key; +-- +-- const nameCell = document.createElement("td"); +-- nameCell.append(createInput(profile?.name || "", "ttsName")); +-- const providerCell = document.createElement("td"); +-- providerCell.append(createInput(profile?.providerKey || "browser-speech", "ttsProvider")); +-- const voiceCell = document.createElement("td"); +-- voiceCell.append(createInput(profile?.voiceName || "", "ttsVoice")); +-- const languageCell = document.createElement("td"); +-- languageCell.append(createInput(profile?.language || "en-US", "ttsLanguage")); +-- const statusCell = document.createElement("td"); +-- statusCell.append(createCheckbox(profile?.active !== false, "ttsActive")); +-- const actions = document.createElement("td"); +-- actions.append(createActionGroup( +-- createButton("Update Row", "messagesTtsCommit", key), +-- createButton("Cancel", "messagesTtsCancel", key), +-- )); +-- row.append(nameCell, providerCell, voiceCell, languageCell, statusCell, actions); +-- return row; +--} +-- +- function renderTtsRows() { +- if (!elements.ttsRows) { +- return; +- } +- elements.ttsRows.replaceChildren(); +-- state.ttsProfiles.forEach((profile) => { +-- if (state.editingTtsKey === profile.key) { +-- elements.ttsRows.append(createTtsEditRow(profile)); +-- return; +-- } +-+ activeTtsProfileOptions().forEach((profile) => { +- const row = document.createElement("tr"); +- row.dataset.messagesTtsRow = profile.key; +-- const actions = document.createElement("td"); +-- actions.append(createActionGroup( +-- createButton("Edit", "messagesTtsEdit", profile.key), +-- profile.active ? createButton("Disable", "messagesTtsDisable", profile.key) : null, +-- )); +- row.append( +- createCell(profile.name), +- createCell(profile.providerKey), +- createCell(profile.voiceName || ""), +- createCell(profile.language), +- createCell(statusForActive(profile.active)), +-- actions, +-+ createCell(profile.key === DEFAULT_TTS_PROFILE_KEY ? "Default option" : "Owned by TTS Studio"), +- ); +- elements.ttsRows.append(row); +- }); +-- if (state.editingTtsKey === NEW_ROW_KEY) { +-- elements.ttsRows.append(createTtsEditRow(null)); +-- } +-- if (!state.ttsProfiles.length && state.editingTtsKey !== NEW_ROW_KEY) { +-- elements.ttsRows.append(tableMessage(6, "No TTS profiles saved yet.")); +-- } +- } +- +- function render(persistence = {}) { +-@@ -722,7 +761,7 @@ function messageValues(key) { +- const details = elements.table?.querySelector(`[data-messages-row-editor-details="${key}"]`); +- return { +- active: editorChecked(root, "[data-message-active]"), +-- emotionProfileKey: editorValue(root, "[data-message-emotion]"), +-+ emotionProfileKey: editorValue(details, "[data-message-emotion]"), +- messageText: editorValue(details, "[data-message-text]"), +- name: editorValue(root, "[data-message-name]"), +- notes: editorValue(details, "[data-message-notes]"), +-@@ -757,10 +796,10 @@ function segmentValues(key) { +- function validateSegment(values) { +- const errors = []; +- if (!state.selectedMessageKey) { +-- errors.push("Select a message row before adding segments."); +-+ errors.push("Select a message before adding parts."); +- } +- if (!values.segmentText.trim()) { +-- errors.push("Segment Text is required."); +-+ errors.push("Part Text is required."); +- } +- if (!values.emotionProfileKey) { +- errors.push("Emotion Profile is required."); +-@@ -795,36 +834,6 @@ function validateEmotion(values) { +- return values.name.trim() ? [] : ["Emotion Profile Name is required."]; +- } +- +--function ttsValues(key) { +-- const root = elements.ttsRows?.querySelector(`[data-messages-tts-editor="${key}"]`); +-- const existing = key === NEW_ROW_KEY ? null : ttsProfileByKey(key); +-- return { +-- active: editorChecked(root, "[data-tts-active]"), +-- description: existing?.description || "", +-- language: editorValue(root, "[data-tts-language]"), +-- name: editorValue(root, "[data-tts-name]"), +-- pitch: existing?.pitch ?? 1, +-- providerKey: editorValue(root, "[data-tts-provider]"), +-- rate: existing?.rate ?? 1, +-- voiceName: editorValue(root, "[data-tts-voice]"), +-- volume: existing?.volume ?? 1, +-- }; +--} +-- +--function validateTts(values) { +-- const errors = []; +-- if (!values.name.trim()) { +-- errors.push("TTS Profile Name is required."); +-- } +-- if (!values.providerKey.trim()) { +-- errors.push("Provider is required."); +-- } +-- if (!values.language.trim()) { +-- errors.push("Language is required."); +-- } +-- return errors; +--} +-- +- async function loadAll() { +- const emotionPayload = listEmotionProfiles(); +- const ttsPayload = listTtsProfiles(); +-@@ -893,10 +902,10 @@ async function commitSegment(key) { +- state.editingSegmentKey = ""; +- state.selectedSegmentKey = result.segment.key; +- await reloadAfterChange(state.selectedMessageKey, result.segment.key); +-- setText(elements.log, `Updated segment row ${result.segment.displayOrder}.`); +-+ setText(elements.log, `Updated message part ${result.segment.displayOrder}.`); +- } catch (error) { +- showValidation([error instanceof Error ? error.message : String(error || "Segment row update failed.")]); +-- setText(elements.log, "Segment row update failed."); +-+ setText(elements.log, "Message part update failed."); +- } +- } +- +-@@ -922,28 +931,6 @@ async function commitEmotion(key) { +- } +- } +- +--async function commitTts(key) { +-- const values = ttsValues(key); +-- const errors = validateTts(values); +-- if (errors.length) { +-- showValidation(errors); +-- setText(elements.log, "TTS profile update blocked by validation."); +-- return; +-- } +-- clearValidation(); +-- try { +-- const result = key === NEW_ROW_KEY +-- ? createTtsProfile(values) +-- : updateTtsProfile(key, values); +-- state.editingTtsKey = ""; +-- await reloadAfterChange(); +-- setText(elements.log, `Updated TTS profile ${result.ttsProfile.name}.`); +-- } catch (error) { +-- showValidation([error instanceof Error ? error.message : String(error || "TTS profile update failed.")]); +-- setText(elements.log, "TTS profile update failed."); +-- } +--} +-- +- async function disableMessage(key) { +- const message = state.messages.find((candidate) => candidate.key === key); +- if (!message) { +-@@ -981,10 +968,10 @@ async function disableSegment(key) { +- }); +- state.selectedSegmentKey = result.segment.key; +- await reloadAfterChange(segment.messageKey, result.segment.key); +-- setText(elements.log, `Disabled segment row ${result.segment.displayOrder}.`); +-+ setText(elements.log, `Disabled message part ${result.segment.displayOrder}.`); +- } catch (error) { +- showValidation([error instanceof Error ? error.message : String(error || "Segment status update failed.")]); +-- setText(elements.log, "Segment status update failed."); +-+ setText(elements.log, "Message part status update failed."); +- } +- } +- +-@@ -996,25 +983,110 @@ function testSelectedSpeech() { +- return; +- } +- const service = selectedTtsService(); +-- const profile = selectedTtsProfile(); +- const target = selectedSpeechTarget(); +-- const emotion = target.emotionProfile; +-- const result = ttsServiceRegistry.speak(service.key, { +-+ const result = speakTarget(service, target, target.profile || selectedTtsProfile()); +-+ if (!result.ok) { +-+ setText(elements.previewStatus, result.message || "Speech test failed."); +-+ setText(elements.log, result.message || "Speech test failed."); +-+ return; +++ +++ row.append(nameCell, voiceCell, languageCell, genderCell, ageCell, emotionCountCell, statusCell, actionsCell); +++ return row; + + } +-+ const message = `Speech test started for ${target.label} using ${service.name}.`; +-+ setText(elements.previewStatus, message); +-+ setText(elements.log, message); +-+} + + +-+function visiblePlaybackError(message) { +-+ const safeMessage = message || "Message Studio playback failed. Check the selected message, part, and TTS profile."; +-+ showValidation([safeMessage]); +-+ setText(elements.previewStatus, safeMessage); +-+ setText(elements.log, safeMessage); +-+ return { message: safeMessage, ok: false }; +-+} +++ function appendEmotionHost(profileId) { +++ if (state.selectedProfileId !== profileId) return; +++ const hostRow = document.createElement("tr"); +++ hostRow.dataset.ttsEmotionHost = profileId; +++ const cell = document.createElement("td"); +++ cell.colSpan = 8; +++ cell.append(createEmotionTable(profileId)); +++ hostRow.append(cell); +++ elements.profileTable.append(hostRow); +++ } + + +-+function playbackService() { +-+ return selectedTtsService() || state.ttsServices.find((service) => service.available) || null; +-+} +++ function createEmotionTable(profileId) { +++ const profile = state.profiles.find((candidate) => candidate.id === profileId); +++ const wrapper = document.createElement("div"); +++ wrapper.className = "content-stack"; +++ const context = document.createElement("div"); +++ context.className = "kicker"; +++ context.textContent = "TTS Profile / Emotion Settings"; +++ const heading = document.createElement("h3"); +++ heading.textContent = "Emotion Settings"; +++ wrapper.append(context, heading); + + +-+function speakTarget(service, target, profile) { +-+ if (!service) { +-+ return visiblePlaybackError("Audio engine is unavailable. Use a browser with SpeechSynthesis support and reload Message Studio."); +-+ } +-+ if (!service.available) { +-+ return visiblePlaybackError(service.unavailableMessage || "Audio engine is unavailable for Message Studio playback."); +++ const tableWrapper = document.createElement("div"); +++ tableWrapper.className = "table-wrapper"; +++ const table = document.createElement("table"); +++ table.className = "data-table"; +++ table.setAttribute("aria-label", "Emotion Settings"); +++ const thead = document.createElement("thead"); +++ const headerRow = document.createElement("tr"); +++ ["Emotion", "Pitch", "Rate", "Volume", "SSML-like Preset", "Status", "Actions"].forEach((label) => { +++ const header = document.createElement("th"); +++ header.scope = "col"; +++ header.textContent = label; +++ headerRow.append(header); +++ }); +++ thead.append(headerRow); +++ const tbody = document.createElement("tbody"); +++ tbody.dataset.ttsEmotionTable = profileId; +++ +++ if (!profile?.emotions.length && state.editingEmotionId !== NEW_ROW_KEY) { +++ tbody.append(tableMessage(7, "No emotion settings for this profile.")); +++ } +++ +++ profile?.emotions.forEach((emotion) => { +++ if (state.editingEmotionId === emotion.id) { +++ tbody.append(createEmotionEditRow(emotion)); +++ return; +++ } +++ const row = document.createElement("tr"); +++ row.dataset.ttsEmotionRow = emotion.id; +++ const deleteButton = createButton("Delete", "ttsDeleteEmotion", emotion.id); +++ if (emotionInUseByMessageParts(emotion)) { +++ deleteButton.disabled = true; +++ deleteButton.title = "Delete disabled: emotion is in use by Message Parts."; +++ } +++ const actions = createActionGroup( +++ createButton("Edit Emotion", "ttsEditEmotion", emotion.id), +++ deleteButton, +++ ); +++ const actionsCell = document.createElement("td"); +++ actionsCell.append(actions); +++ row.append( +++ createCell(emotion.emotionLabel), +++ createCell(String(emotion.pitch)), +++ createCell(String(emotion.rate)), +++ createCell(String(emotion.volume)), +++ createCell(labelForOption(TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS, emotion.ssmlLikePreset, "Normal")), +++ createCell(emotion.active ? "Active" : "Inactive"), +++ actionsCell, +++ ); +++ tbody.append(row); +++ }); +++ +++ if (state.editingEmotionId === NEW_ROW_KEY) { +++ tbody.append(createEmotionEditRow(null)); +++ } +++ +++ table.append(thead, tbody); +++ tableWrapper.append(table); +++ const actionGroup = document.createElement("div"); +++ actionGroup.className = "action-group"; +++ actionGroup.append(createButton("Add Emotion", "ttsAddEmotion", profileId)); +++ wrapper.append(tableWrapper, actionGroup); +++ return wrapper; + + } +-+ if (!target) { +-+ return visiblePlaybackError("Select a message or message part before playback."); +++ +++ function createEmotionEditRow(emotion = null) { +++ const key = emotion?.id || NEW_ROW_KEY; +++ const row = document.createElement("tr"); +++ row.dataset.ttsEmotionEditor = key; +++ const emotionCell = document.createElement("td"); +++ emotionCell.append(createEditorSelect(emotion?.emotion || "neutral", "ttsEmotionName", TTS_PROFILE_EMOTION_OPTIONS)); +++ const pitchCell = document.createElement("td"); +++ pitchCell.append(createNumberInput(emotion?.pitch ?? 1, "ttsEmotionPitch", "pitch")); +++ const rateCell = document.createElement("td"); +++ rateCell.append(createNumberInput(emotion?.rate ?? 1, "ttsEmotionRate", "rate")); +++ const volumeCell = document.createElement("td"); +++ volumeCell.append(createNumberInput(emotion?.volume ?? 1, "ttsEmotionVolume", "volume")); +++ const presetCell = document.createElement("td"); +++ presetCell.append(createEditorSelect(emotion?.ssmlLikePreset || "normal", "ttsEmotionSsmlPreset", TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS)); +++ const statusCell = document.createElement("td"); +++ statusCell.append(createCheckbox(emotion?.active !== false, "ttsEmotionActive")); +++ const actionsCell = document.createElement("td"); +++ actionsCell.append(createActionGroup( +++ createButton("Save", "ttsCommitEmotion", key), +++ createButton("Cancel", "ttsCancelEmotion", key), +++ )); +++ row.append(emotionCell, pitchCell, rateCell, volumeCell, presetCell, statusCell, actionsCell); +++ return row; + + } +-+ if (!profile) { +-+ return visiblePlaybackError("Select a TTS profile before playback."); +++ +++ function editorValue(rootNode, selector) { +++ return rootNode?.querySelector(selector)?.value || ""; + + } +-+ if (!target.emotionProfile) { +-+ return visiblePlaybackError("Selected message or part needs an emotion profile before playback."); +++ +++ function editorChecked(rootNode, selector) { +++ return rootNode?.querySelector(selector)?.checked !== false; + + } +-+ if (!String(target.text || "").trim()) { +-+ return visiblePlaybackError("Selected message or part needs text before playback."); +++ +++ function profileValues(key) { +++ const row = elements.profileTable?.querySelector(`[data-tts-profile-editor="${key}"]`); +++ const voiceValue = editorValue(row, "[data-tts-profile-voice]"); +++ const selectedVoice = state.voiceOptions.find((option) => option.value === voiceValue); +++ return createTextToSpeechProfile({ +++ active: editorChecked(row, "[data-tts-profile-active]"), +++ age: editorValue(row, "[data-tts-profile-age]"), +++ emotions: key === NEW_ROW_KEY ? [createTextToSpeechProfileEmotion()] : state.profiles.find((profile) => profile.id === key)?.emotions || [], +++ gender: editorValue(row, "[data-tts-profile-gender]"), +++ id: key === NEW_ROW_KEY ? "" : key, +++ language: editorValue(row, "[data-tts-profile-language]"), +++ name: editorValue(row, "[data-tts-profile-name]"), +++ voice: voiceValue, +++ voiceName: selectedVoice?.name || selectedVoice?.label || voiceValue, +++ }); + + } +-+ return ttsServiceRegistry.speak(service.key, { +- language: profile.language, +-- pitch: emotion.pitch, +-- rate: emotion.rate, +-+ pitch: target.emotionProfile.pitch ?? profile.pitch ?? 1, +-+ rate: target.emotionProfile.rate ?? profile.rate ?? 1, +- speechItemId: target.id, +- speechItemName: target.name, +- text: target.text, +- voice: profile.voiceName, +-- volume: emotion.volume, +-+ volume: target.emotionProfile.volume ?? profile.volume ?? 1, +- }); +-+} + + +-+function segmentPlaybackTarget(segment) { +-+ if (!segment) { +-+ return null; +++ function validateProfile(profile) { +++ const errors = []; +++ if (!profile.name.trim()) errors.push("Profile Name is required."); +++ if (!profile.language.trim()) errors.push("Language is required."); +++ if (state.profiles.some((candidate) => candidate.id !== profile.id && candidate.name.toLowerCase() === profile.name.toLowerCase())) { +++ errors.push("Profile Name must be unique."); +++ } +++ return errors; + + } +-+ return { +-+ emotionProfile: emotionProfileByKey(segment.emotionProfileKey), +-+ id: segment.key, +-+ label: `Part ${segment.displayOrder}`, +-+ name: `${segment.messageName || "Message"} part ${segment.displayOrder}`, +-+ profile: selectedTtsProfileForSegment(segment.key, segment.messageKey), +-+ text: segment.segmentText, +-+ type: "part", +-+ }; +-+} + + +-+function playPart(key) { +-+ const segment = state.segments.find((candidate) => candidate.key === key); +-+ const target = segmentPlaybackTarget(segment); +-+ const result = speakTarget(playbackService(), target, target?.profile); +- if (!result.ok) { +-- setText(elements.previewStatus, result.message || "Speech test failed."); +-- setText(elements.log, result.message || "Speech test failed."); +- return; +- } +-- const message = `Speech test started for ${target.label} using ${service.name}.`; +-+ clearValidation(); +-+ const message = `Play Part queued ${target.label} using ${target.profile.name}.`; +-+ setText(elements.previewStatus, message); +-+ setText(elements.log, message); +-+} +++ function emotionValues(key) { +++ const row = elements.profileTable?.querySelector(`[data-tts-emotion-editor="${key}"]`); +++ return createTextToSpeechProfileEmotion({ +++ active: editorChecked(row, "[data-tts-emotion-active]"), +++ emotion: editorValue(row, "[data-tts-emotion-name]"), +++ id: key === NEW_ROW_KEY ? "" : key, +++ pitch: editorValue(row, "[data-tts-emotion-pitch]"), +++ rate: editorValue(row, "[data-tts-emotion-rate]"), +++ ssmlLikePreset: editorValue(row, "[data-tts-emotion-ssml-preset]"), +++ volume: editorValue(row, "[data-tts-emotion-volume]"), +++ }); +++ } + + +-+function playMessage(key) { +-+ const messageRecord = state.messages.find((candidate) => candidate.key === key); +-+ if (!messageRecord) { +-+ visiblePlaybackError("Choose an existing message before playback."); +-+ return; +++ function validateEmotion(emotion, existingId = "") { +++ const errors = []; +++ if (!state.selectedProfileId) errors.push("Select a TTS Profile before adding Emotion Settings."); +++ if (!emotion.emotion) errors.push("Emotion is required."); +++ const profile = selectedProfile(); +++ if (profile?.emotions.some((candidate) => candidate.id !== existingId && candidate.emotion === emotion.emotion)) { +++ errors.push("Emotion must be unique within the selected TTS Profile."); +++ } +++ return errors; + + } +-+ const parts = messageSegments(messageRecord.key).filter((segment) => segment.active); +-+ if (!parts.length) { +-+ visiblePlaybackError("Add at least one active Message Part before playing this message."); +-+ return; +++ +++ function addProfile() { +++ state.editingProfileId = NEW_ROW_KEY; +++ state.editingEmotionId = ""; +++ state.selectedProfileId = ""; +++ renderProfileRows(); +++ writeStatus("Ready to add a TTS profile."); + + } +-+ const service = playbackService(); +-+ for (const part of parts) { +-+ const target = segmentPlaybackTarget(part); +-+ const result = speakTarget(service, target, target?.profile || selectedTtsProfileForMessage(messageRecord.key)); +-+ if (!result.ok) { +++ +++ function commitProfile(key) { +++ const profile = profileValues(key); +++ const errors = validateProfile(profile); +++ if (errors.length) { +++ writeStatus(`TTS Profile save blocked: ${errors.join(" ")}`, "FAIL"); + + return; + + } +-+ } +-+ clearValidation(); +-+ const message = `Play Message queued ${parts.length} parts for ${messageRecord.name}.`; +- setText(elements.previewStatus, message); +- setText(elements.log, message); +- } +-@@ -1043,31 +1115,6 @@ async function disableEmotion(key) { +- } +- } +- +--async function disableTts(key) { +-- const profile = ttsProfileByKey(key); +-- if (!profile) { +-- return; +-- } +-- try { +-- const result = updateTtsProfile(key, { +-- active: false, +-- description: profile.description, +-- language: profile.language, +-- name: profile.name, +-- pitch: profile.pitch, +-- providerKey: profile.providerKey, +-- rate: profile.rate, +-- voiceName: profile.voiceName, +-- volume: profile.volume, +-- }); +-- await reloadAfterChange(); +-- setText(elements.log, `Disabled TTS profile ${result.ttsProfile.name}.`); +-- } catch (error) { +-- showValidation([error instanceof Error ? error.message : String(error || "TTS profile status update failed.")]); +-- setText(elements.log, "TTS profile status update failed."); +-- } +--} +-- +- async function moveSegment(key, direction) { +- const segments = selectedMessageSegments(); +- const currentIndex = segments.findIndex((segment) => segment.key === key); +-@@ -1092,10 +1139,10 @@ async function moveSegment(key, direction) { +- segmentText: target.segmentText, +- }); +- await reloadAfterChange(state.selectedMessageKey, current.key); +-- setText(elements.log, "Segment order updated."); +-+ setText(elements.log, "Message part order updated."); +- } catch (error) { +- showValidation([error instanceof Error ? error.message : String(error || "Segment reorder failed.")]); +-- setText(elements.log, "Segment reorder failed."); +-+ setText(elements.log, "Message part reorder failed."); +- } +- } +- +-@@ -1114,13 +1161,6 @@ elements.emotionAddRow?.addEventListener("click", () => { +- setText(elements.log, "Ready to add an emotion profile."); +- }); +- +--elements.ttsAddRow?.addEventListener("click", () => { +-- clearValidation(); +-- state.editingTtsKey = NEW_ROW_KEY; +-- renderTtsRows(); +-- setText(elements.log, "Ready to add a TTS profile."); +--}); +-- +- elements.previewTtsProfile?.addEventListener("change", () => { +- renderSpeechTestControls(); +- }); +-@@ -1137,14 +1177,40 @@ ttsServiceRegistry.onServicesChanged(() => { +- renderSpeechTestControls(); +- }); +- +-+elements.table?.addEventListener("change", (event) => { +-+ const messageSelect = event.target.closest("[data-message-default-tts-profile]"); +-+ const segmentSelect = event.target.closest("[data-segment-tts-profile]"); +-+ if (messageSelect) { +-+ const key = messageSelect.dataset.messagesTtsIdentity || state.selectedMessageKey; +-+ if (key && key !== NEW_ROW_KEY) { +-+ state.messageTtsProfileKeys.set(key, messageSelect.value || defaultTtsProfileKey()); +++ if (key === NEW_ROW_KEY) { +++ state.profiles.push(profile); +++ } else { +++ const index = state.profiles.findIndex((candidate) => candidate.id === key); +++ const existing = state.profiles[index]; +++ state.profiles[index] = { +++ ...profile, +++ emotions: existing?.emotions || profile.emotions, +++ messageStudioUsageCount: existing?.messageStudioUsageCount || 0, +++ }; + + } +-+ renderSpeechTestControls(); +-+ setText(elements.log, "Default TTS profile selected for this message playback session."); +++ state.selectedProfileId = profile.id; +++ state.editingProfileId = ""; +++ renderProfileRows(); +++ renderOutputSummary(); +++ writeStatus(`Saved TTS profile: ${profile.name}.`); + + } +-+ if (segmentSelect) { +-+ const key = segmentSelect.dataset.messagesTtsIdentity || state.selectedSegmentKey; +-+ if (key && key !== NEW_ROW_KEY) { +-+ state.segmentTtsProfileKeys.set(key, segmentSelect.value || defaultTtsProfileKey()); +++ +++ function deleteProfile(key) { +++ const profile = state.profiles.find((candidate) => candidate.id === key); +++ if (!profile) return; +++ if (profileInUseByMessageStudio(profile)) { +++ writeStatus(`Delete profile disabled: ${profile.name} is in use by Message Studio data.`, "FAIL"); +++ return; + + } +-+ renderSpeechTestControls(); +-+ setText(elements.log, "TTS profile selected for this part playback session."); +++ state.profiles = state.profiles.filter((candidate) => candidate.id !== key); +++ if (state.selectedProfileId === key) state.selectedProfileId = state.profiles[0]?.id || ""; +++ renderProfileRows(); +++ renderOutputSummary(); +++ writeStatus(`Deleted TTS profile: ${profile.name}.`); + + } +-+}); + + +- elements.table?.addEventListener("click", async (event) => { +-+ if (event.target.closest("[data-message-default-tts-profile], [data-segment-tts-profile]")) { +-+ return; +++ function addEmotion(profileId) { +++ state.selectedProfileId = profileId; +++ state.editingProfileId = ""; +++ state.editingEmotionId = NEW_ROW_KEY; +++ renderProfileRows(); +++ writeStatus("Ready to add an emotion setting."); + + } +- const row = event.target.closest("[data-messages-row]"); +- const segmentRow = event.target.closest("[data-messages-segment-row]"); +-+ const playButton = event.target.closest("[data-messages-play]"); +- const editButton = event.target.closest("[data-messages-edit]"); +- const commitButton = event.target.closest("[data-messages-commit]"); +- const cancelButton = event.target.closest("[data-messages-cancel]"); +- const disableButton = event.target.closest("[data-messages-disable]"); +- const segmentAddButton = event.target.closest("[data-messages-segment-add-row]"); +-+ const segmentPlayButton = event.target.closest("[data-messages-segment-play]"); +- const segmentEditButton = event.target.closest("[data-messages-segment-edit]"); +- const segmentCommitButton = event.target.closest("[data-messages-segment-commit]"); +- const segmentCancelButton = event.target.closest("[data-messages-segment-cancel]"); +-@@ -1152,6 +1218,14 @@ elements.table?.addEventListener("click", async (event) => { +- const moveUpButton = event.target.closest("[data-messages-segment-move-up]"); +- const moveDownButton = event.target.closest("[data-messages-segment-move-down]"); +- +-+ if (playButton) { +-+ state.selectedMessageKey = playButton.dataset.messagesPlay; +-+ state.selectedSegmentKey = ""; +-+ state.editingSegmentKey = ""; +-+ render(); +-+ playMessage(playButton.dataset.messagesPlay); +-+ return; +++ +++ function commitEmotion(key) { +++ const emotion = emotionValues(key); +++ const errors = validateEmotion(emotion, key === NEW_ROW_KEY ? "" : key); +++ if (errors.length) { +++ writeStatus(`Emotion setting save blocked: ${errors.join(" ")}`, "FAIL"); +++ return; +++ } +++ const profile = selectedProfile(); +++ if (!profile) return; +++ if (key === NEW_ROW_KEY) { +++ profile.emotions.push(emotion); +++ } else { +++ const index = profile.emotions.findIndex((candidate) => candidate.id === key); +++ const existing = profile.emotions[index]; +++ profile.emotions[index] = { +++ ...emotion, +++ messagePartsUsageCount: existing?.messagePartsUsageCount || 0, +++ }; +++ } +++ state.editingEmotionId = ""; +++ renderProfileRows(); +++ renderOutputSummary(); +++ writeStatus(`Saved emotion setting: ${emotion.emotionLabel}.`); + + } +- if (editButton) { +- clearValidation(); +- state.editingMessageKey = editButton.dataset.messagesEdit; +-@@ -1180,7 +1254,17 @@ elements.table?.addEventListener("click", async (event) => { +- clearValidation(); +- state.editingSegmentKey = NEW_ROW_KEY; +- render(); +-- setText(elements.log, "Ready to add a segment row."); +-+ setText(elements.log, "Ready to add a message part."); +-+ return; +++ +++ function deleteEmotion(key) { +++ const profile = selectedProfile(); +++ const emotion = profile?.emotions.find((candidate) => candidate.id === key); +++ if (!profile || !emotion) return; +++ if (emotionInUseByMessageParts(emotion)) { +++ writeStatus(`Delete emotion disabled: ${emotion.emotionLabel} is in use by Message Parts.`, "FAIL"); +++ return; +++ } +++ profile.emotions = profile.emotions.filter((candidate) => candidate.id !== key); +++ renderProfileRows(); +++ renderOutputSummary(); +++ writeStatus(`Deleted emotion setting: ${emotion.emotionLabel}.`); + + } +-+ if (segmentPlayButton) { +-+ const segment = state.segments.find((candidate) => candidate.key === segmentPlayButton.dataset.messagesSegmentPlay); +-+ if (segment) { +-+ state.selectedMessageKey = segment.messageKey; +-+ state.selectedSegmentKey = segment.key; +-+ render(); +++ +++ function selectProfile(profileId) { +++ if (!state.profiles.some((profile) => profile.id === profileId)) return; +++ state.selectedProfileId = state.selectedProfileId === profileId ? "" : profileId; +++ state.editingEmotionId = ""; +++ renderProfileRows(); +++ if (state.selectedProfileId) { +++ writeStatus(`Opened Emotion Settings for ${selectedProfile()?.name}.`); + + } +-+ playPart(segmentPlayButton.dataset.messagesSegmentPlay); +- return; +- } +- if (segmentEditButton) { +-@@ -1188,7 +1272,7 @@ elements.table?.addEventListener("click", async (event) => { +- state.editingSegmentKey = segmentEditButton.dataset.messagesSegmentEdit; +- state.selectedSegmentKey = segmentEditButton.dataset.messagesSegmentEdit; +- render(); +-- setText(elements.log, "Segment row opened inline."); +-+ setText(elements.log, "Message part opened inline."); +- return; +++ } +++ ++ function itemFromControls(overrides = {}) { ++ const currentItem = selectedItem(); ++ return createTextToSpeechQueueItem({ ++@@ -379,7 +1018,12 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech ++ ++ function renderOutputSummary() { ++ if (elements.outputSummary) { ++- elements.outputSummary.textContent = JSON.stringify(state.queue, null, 2); +++ elements.outputSummary.textContent = JSON.stringify({ +++ contractVersion: TTS_PROFILE_CONTRACT_VERSION, +++ messageStudioOptions: createMessageStudioTtsProfileOptions(state.profiles), +++ profiles: state.profiles, +++ queue: state.queue +++ }, null, 2); ++ } ++ setTextContent(root, "[data-tts-text-count]", String(String(elements.text?.value || "").length)); + } +- if (segmentCommitButton) { +-@@ -1199,7 +1283,7 @@ elements.table?.addEventListener("click", async (event) => { +- state.editingSegmentKey = ""; +- clearValidation(); +- render(); +-- setText(elements.log, "Segment row edit canceled."); +-+ setText(elements.log, "Message part edit canceled."); +- return; ++@@ -556,6 +1200,16 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech ++ return result; + } +- if (segmentDisableButton) { +-@@ -1253,34 +1337,6 @@ elements.emotionRows?.addEventListener("click", async (event) => { ++ +++ function ensureDefaultProfiles() { +++ if (state.profiles.length) { +++ return; +++ } +++ state.profiles = createDefaultTextToSpeechProfiles(state.voiceOptions); +++ state.selectedProfileId = ""; +++ renderProfileRows(); +++ renderOutputSummary(); +++ } +++ ++ function refreshActionState() { ++ const hasText = Boolean(String(elements.text?.value || "").trim()); ++ const hasVoice = Boolean(elements.voice?.value); ++@@ -755,6 +1409,69 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech + } +- }); +- +--elements.ttsRows?.addEventListener("click", async (event) => { +-- const editButton = event.target.closest("[data-messages-tts-edit]"); +-- const commitButton = event.target.closest("[data-messages-tts-commit]"); +-- const cancelButton = event.target.closest("[data-messages-tts-cancel]"); +-- const disableButton = event.target.closest("[data-messages-tts-disable]"); +-- if (editButton) { +-- clearValidation(); +-- state.editingTtsKey = editButton.dataset.messagesTtsEdit; +-- renderTtsRows(); +-- setText(elements.log, "TTS profile opened inline."); +-- return; +-- } +-- if (commitButton) { +-- await commitTts(commitButton.dataset.messagesTtsCommit); +-- return; +-- } +-- if (cancelButton) { +-- state.editingTtsKey = ""; +-- clearValidation(); +-- renderTtsRows(); +-- setText(elements.log, "TTS profile edit canceled."); +-- return; +-- } +-- if (disableButton) { +-- await disableTts(disableButton.dataset.messagesTtsDisable); +-- } +--}); +-- +- try { +- await loadAll(); +- } catch (error) { + -+## ZIP -+- Path: `tmp/PR_26171_065-message-studio-parent-child-table-foundation_delta.zip`. ++ function mountEvents() { +++ elements.addProfile?.addEventListener("click", addProfile); +++ elements.profileTable?.addEventListener("click", (event) => { +++ const addEmotionButton = event.target.closest("[data-tts-add-emotion]"); +++ const commitEmotionButton = event.target.closest("[data-tts-commit-emotion]"); +++ const cancelEmotionButton = event.target.closest("[data-tts-cancel-emotion]"); +++ const deleteEmotionButton = event.target.closest("[data-tts-delete-emotion]"); +++ const deleteProfileButton = event.target.closest("[data-tts-delete-profile]"); +++ const editEmotionButton = event.target.closest("[data-tts-edit-emotion]"); +++ const editProfileButton = event.target.closest("[data-tts-edit-profile]"); +++ const commitProfileButton = event.target.closest("[data-tts-commit-profile]"); +++ const cancelProfileButton = event.target.closest("[data-tts-cancel-profile]"); +++ const profileRow = event.target.closest("[data-tts-profile-row]"); +++ +++ if (commitProfileButton) { +++ commitProfile(commitProfileButton.dataset.ttsCommitProfile); +++ return; +++ } +++ if (cancelProfileButton) { +++ state.editingProfileId = ""; +++ renderProfileRows(); +++ writeStatus("TTS profile edit canceled."); +++ return; +++ } +++ if (editProfileButton) { +++ state.editingProfileId = editProfileButton.dataset.ttsEditProfile; +++ state.selectedProfileId = editProfileButton.dataset.ttsEditProfile; +++ state.editingEmotionId = ""; +++ renderProfileRows(); +++ writeStatus("TTS profile opened inline."); +++ return; +++ } +++ if (deleteProfileButton) { +++ deleteProfile(deleteProfileButton.dataset.ttsDeleteProfile); +++ return; +++ } +++ if (addEmotionButton) { +++ addEmotion(addEmotionButton.dataset.ttsAddEmotion); +++ return; +++ } +++ if (commitEmotionButton) { +++ commitEmotion(commitEmotionButton.dataset.ttsCommitEmotion); +++ return; +++ } +++ if (cancelEmotionButton) { +++ state.editingEmotionId = ""; +++ renderProfileRows(); +++ writeStatus("Emotion setting edit canceled."); +++ return; +++ } +++ if (editEmotionButton) { +++ state.editingEmotionId = editEmotionButton.dataset.ttsEditEmotion; +++ renderProfileRows(); +++ writeStatus("Emotion setting opened inline."); +++ return; +++ } +++ if (deleteEmotionButton) { +++ deleteEmotion(deleteEmotionButton.dataset.ttsDeleteEmotion); +++ return; +++ } +++ if (profileRow) { +++ selectProfile(profileRow.dataset.ttsProfileRow); +++ } +++ }); ++ elements.queueList?.addEventListener("click", (event) => { ++ const itemButton = event.target.closest("[data-tts-queue-item]"); ++ if (itemButton) selectItem(itemButton.dataset.ttsQueueItem || ""); ++@@ -849,14 +1566,18 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech ++ mountEvents(); ++ if (!engine.isSupported()) { ++ await loadQueue(); +++ ensureDefaultProfiles(); ++ markUnavailable(); ++ return; ++ } ++ setTextContent(root, "[data-tts-engine-label]", "Ready"); ++ setTextContent(root, "[data-tts-engine-status]", "Browser SpeechSynthesis is available for local preview."); ++ refreshVoices(); +++ ensureDefaultProfiles(); ++ engine.onVoicesChanged(() => { ++ refreshVoices(); +++ renderProfileRows(); +++ renderOutputSummary(); ++ writeStatus(`${TEXT_TO_SPEECH_DISPLAY_NAME} voices updated from browser SpeechSynthesis.`); ++ }); ++ await loadQueue(); ++@@ -883,9 +1604,14 @@ export { ++ TTS_LANGUAGES, ++ TTS_MESSAGE_STATUSES, ++ TTS_OWNERSHIP, +++ TTS_PROFILE_CONTRACT_VERSION, ++ TTS_PROVIDER_ADAPTER_PLAN, ++ createEmotionProfile, +++ createDefaultTextToSpeechProfiles, +++ createMessageStudioTtsProfileOptions, ++ createSpeechPreviewRequest, +++ createTextToSpeechProfile, +++ createTextToSpeechProfileEmotion, ++ createTtsMessage, ++ createVoiceProfile, ++ initializeTextToSpeechTool, ++diff --git a/docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md b/docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md ++new file mode 100644 ++index 000000000..fedefd391 ++--- /dev/null +++++ b/docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md ++@@ -0,0 +1,31 @@ +++# PR_26171_067 Instruction Compliance Checklist +++ +++## Required Reads +++ +++- PASS: Read `docs_build/dev/PROJECT_INSTRUCTIONS.md` before implementation. +++- PASS: Read `docs_build/dev/PROJECT_MULTI_PC.txt` before implementation. +++- PASS: Read repository `AGENTS.md` instructions from the active workspace context. +++- PASS: Read relevant target files before editing. +++ +++## Gate Checks +++ +++- PASS: Started from `main`. +++- PASS: Pulled latest `origin/main`. +++- PASS: Repo was clean before branch creation. +++- PASS: Created scoped branch `pr/26171-067-tts-profile-emotion-table-foundation`. +++- PASS: PR number `067` is odd and assigned to Laptop / Environment 2. +++- PASS: TTS Studio is within Laptop ownership. +++- PASS: Active path is `toolbox/text-to-speech/`. +++- PASS: No wrong `tools/text2speech/` path was created. +++- PASS: No database changes were made. +++- PASS: No placeholder-only provider blocking behavior was introduced. +++ +++## Required Artifacts +++ +++- PASS: PR-specific report created. +++- PASS: Parent-child table checklist created. +++- PASS: Message/TTS contract checklist created. +++- PASS: Validation report created. +++- PASS: Manual validation notes created. +++- PASS: `codex_review.diff` and `codex_changed_files.txt` will be generated from the final scoped diff. +++- PASS: Repo-structured delta ZIP will be created under `tmp/`. ++diff --git a/docs_build/dev/reports/PR_26171_067-manual-validation-notes.md b/docs_build/dev/reports/PR_26171_067-manual-validation-notes.md ++new file mode 100644 ++index 000000000..712c083b1 ++--- /dev/null +++++ b/docs_build/dev/reports/PR_26171_067-manual-validation-notes.md ++@@ -0,0 +1,17 @@ +++# PR_26171_067 Manual Validation Notes +++ +++## Notes +++ +++- Verified TTS Studio keeps the active path `toolbox/text-to-speech/`. +++- Verified the page uses Theme V2 classes and external JavaScript only. +++- Verified the profile table can open child Emotion Settings by clicking the profile row. +++- Verified `Default Balanced Profile` delete is disabled because it is marked in use by Message Studio data. +++- Verified default `Neutral` emotion delete is disabled because it is marked in use by Message Parts. +++- Verified Add Profile, Edit Profile, Add Emotion, and Edit Emotion inline rows through targeted Playwright validation. +++- Verified Message Studio remains separate and its existing TTS dropdown smoke path still passes. +++- Verified no `tools/text2speech/` path was created. +++- Verified no database files were changed. +++ +++## Follow Up +++ +++- Future persistence can connect TTS Studio profile authoring to the existing Local API profile contract once that API ownership is assigned. ++diff --git a/docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md b/docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md ++new file mode 100644 ++index 000000000..bf2819ba1 ++--- /dev/null +++++ b/docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md ++@@ -0,0 +1,26 @@ +++# PR_26171_067 Message TTS Contract Checklist +++ +++## Ownership +++ +++- PASS: Message Studio owns message text and ordered message parts. +++- PASS: TTS Studio owns reusable TTS Profiles and per-profile Emotion Settings. +++- PASS: `src/engine/audio/` remains the playback owner. +++- PASS: Audio playback results remain owned by the audio engine flow. +++- PASS: Message Studio and TTS Studio are not merged into one tool. +++ +++## Contract Readiness +++ +++- PASS: TTS Studio exposes `TTS_PROFILE_CONTRACT_VERSION` with value `tts-profile-emotion-v1`. +++- PASS: TTS Studio exports `createMessageStudioTtsProfileOptions`. +++- PASS: Exported profile options include stable keys, active state, display name, language, provider key, voice name, and active emotion settings. +++- PASS: Emotion settings include emotion key, display label, pitch, rate, volume, and SSML-like preset. +++- PASS: The output summary shows the contract version and Message Studio compatible profile options for local diagnostics. +++- PASS: Existing Message Studio dropdown smoke validation still passes. +++ +++## Boundaries +++ +++- PASS: No database changes were introduced. +++- PASS: No future provider behavior was hardcoded. +++- PASS: No browser-owned product data was introduced as source of truth. +++- PASS: Default profile data is limited to a local down-the-middle fallback until the API/data contract exists. +++- PASS: Existing Message Studio Local API profile shape remains untouched. ++diff --git a/docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md b/docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md ++new file mode 100644 ++index 000000000..065b3999d ++--- /dev/null +++++ b/docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md ++@@ -0,0 +1,38 @@ +++# PR_26171_067 Parent Child Table Checklist +++ +++## Parent Table +++ +++- PASS: Parent table label is `TTS Profiles`. +++- PASS: Parent table lives in `toolbox/text-to-speech/`. +++- PASS: Parent table columns are Profile Name, Voice, Language, Gender, Age, Emotion Count, Status, Actions. +++- PASS: Default rows include `Default Balanced Profile`, `Man Profile 1`, and `Woman Profile 2`. +++- PASS: Parent row click opens or closes the child Emotion Settings row. +++- PASS: One selected profile row owns the visible child subtable at a time. +++- PASS: Parent profile count is visible in the summary stats. +++ +++## Parent Actions +++ +++- PASS: Add Profile opens a new inline row below the parent table rows. +++- PASS: Edit Profile opens an inline edit row for the selected profile. +++- PASS: Save Profile validates required Profile Name and Language values. +++- PASS: Duplicate Profile Name is blocked with a visible actionable error. +++- PASS: Cancel Profile closes the inline editor without applying changes. +++- PASS: Delete Profile removes unused profiles. +++- PASS: Delete Profile is disabled when the profile has Message Studio usage. +++ +++## Child Table +++ +++- PASS: Child table label is `Emotion Settings`. +++- PASS: Child table opens under the selected TTS Profile row. +++- PASS: Child columns are Emotion, Pitch, Rate, Volume, SSML-like Preset, Status, Actions. +++- PASS: Default neutral emotion is provided for every default profile. +++- PASS: Emotion count is visible in both the profile row and summary stats. +++ +++## Child Actions +++ +++- PASS: Add Emotion opens a new inline row in the child table. +++- PASS: Edit Emotion opens an inline edit row. +++- PASS: Save Emotion validates selected profile and unique emotion per profile. +++- PASS: Cancel Emotion closes the inline editor without applying changes. +++- PASS: Delete Emotion removes unused emotions. +++- PASS: Delete Emotion is disabled when the emotion has Message Parts usage. ++diff --git a/docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md b/docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md ++new file mode 100644 ++index 000000000..551954c0a ++--- /dev/null +++++ b/docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md ++@@ -0,0 +1,50 @@ +++# PR_26171_067 TTS Profile Emotion Table Foundation +++ +++## Summary +++ +++TTS Studio now presents a parent TTS Profiles table with an expandable child Emotion Settings table. The active tool remains `toolbox/text-to-speech/`, uses Theme V2, and keeps all JavaScript external. +++ +++## Scope +++ +++- Updated `toolbox/text-to-speech/index.html` to expose the requested parent and child table surfaces. +++- Updated `toolbox/text-to-speech/text2speech.js` to seed reusable profiles, render child emotion settings, support inline add/edit rows, and block delete actions when profile or emotion usage is marked by Message Studio data. +++- Added a TTS profile contract helper that returns Message Studio compatible options without moving ownership into Message Studio. +++- Updated targeted TTS browser and unit validation. +++ +++## Requirement Evidence +++ +++- PASS: Active path remains `toolbox/text-to-speech/`. +++- PASS: Parent table is TTS Profiles. +++- PASS: Clicking a profile row opens the child Emotion Settings subtable. +++- PASS: Parent rows include `Man Profile 1` and `Woman Profile 2`. +++- PASS: Parent columns are Profile Name, Voice, Language, Gender, Age, Emotion Count, Status, Actions. +++- PASS: Child columns are Emotion, Pitch, Rate, Volume, SSML-like Preset, Status, Actions. +++- PASS: Add Profile opens an inline add row under the parent table. +++- PASS: Edit Profile opens an inline edit row. +++- PASS: Add Emotion opens an inline add row in the child table. +++- PASS: Edit Emotion opens an inline edit row. +++- PASS: Delete profile is disabled when usage count indicates Message Studio data uses it. +++- PASS: Delete emotion is disabled when usage count indicates Message Parts use it. +++- PASS: Default balanced profile and default neutral emotion are provided. +++- PASS: Message Studio compatible profile options are exported for a future API/data contract. +++- PASS: Message Studio and TTS Studio remain separate tools. +++- PASS: No database changes were made. +++- PASS: Theme V2 only; no page-local CSS, tool-local CSS, inline styles, style blocks, or inline handlers. +++ +++## Validation +++ +++- PASS: `node --check toolbox\text-to-speech\text2speech.js`. +++- PASS: `node --check tests\playwright\tools\TextToSpeechFunctional.spec.mjs`. +++- PASS: `node --check tests\tools\Text2SpeechShell.test.mjs`. +++- PASS: HTML inline style/script/event scan for `toolbox/text-to-speech/index.html`. +++- PASS: `node --test tests/tools/Text2SpeechShell.test.mjs`. +++- PASS: `npx playwright test tests/playwright/tools/TextToSpeechFunctional.spec.mjs --project=playwright --workers=1 --reporter=list`. +++- PASS: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright --workers=1 --reporter=list`. +++- PASS: `npm run test:workspace-v2` (legacy command name; user-facing language is Project Workspace). +++ +++## Out Of Scope +++ +++- No Message Studio merge. +++- No new provider behavior. +++- No generated audio export. +++- No database schema, seed, or persistence change. ++diff --git a/docs_build/dev/reports/PR_26171_067-validation.md b/docs_build/dev/reports/PR_26171_067-validation.md ++new file mode 100644 ++index 000000000..a119c9b79 ++--- /dev/null +++++ b/docs_build/dev/reports/PR_26171_067-validation.md ++@@ -0,0 +1,42 @@ +++# PR_26171_067 Validation Report +++ +++## Commands Run +++ +++- `git branch --show-current` +++ - PASS: started from `main`. +++- `git checkout main` +++ - PASS. +++- `git pull origin main` +++ - PASS: already up to date. +++- `git status --short` +++ - PASS: clean before branch creation. +++- `node --check toolbox\text-to-speech\text2speech.js` +++ - PASS. +++- `node --check tests\playwright\tools\TextToSpeechFunctional.spec.mjs` +++ - PASS. +++- `node --check tests\tools\Text2SpeechShell.test.mjs` +++ - PASS. +++- `Select-String -Path toolbox\text-to-speech\index.html -Pattern ']+src=)|\son\w+=|style='` +++ - PASS: no matches. +++- `node --test tests/tools/Text2SpeechShell.test.mjs` +++ - PASS: 4 tests passed. +++- `npx playwright test tests/playwright/tools/TextToSpeechFunctional.spec.mjs --project=playwright --workers=1 --reporter=list` +++ - PASS: 2 tests passed. +++ - Covers default profiles, expandable Emotion Settings, inline add/edit rows, delete-disabled usage states, and existing speech composition. +++- `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright --workers=1 --reporter=list` +++ - PASS: 2 tests passed. +++ - Covers Message Studio smoke compatibility for the existing TTS dropdown and audio-engine path. +++- `npm run test:workspace-v2` +++ - PASS: 5 Project Workspace tests passed. +++ - Note: command name is legacy; user-facing language is Project Workspace. +++ +++## Coverage +++ +++- PASS: `docs_build/dev/reports/playwright_v8_coverage_report.txt` produced changed runtime JS coverage. +++- PASS: `toolbox/text-to-speech/text2speech.js` covered by targeted browser validation. +++ +++## Skipped +++ +++- Database validation skipped because no database schema, seed, or persistence implementation changed. +++- Full samples validation skipped because no samples changed. +++- External TTS provider validation skipped because this PR does not implement provider behavior. diff --git a/docs_build/dev/reports/coverage_changed_js_guardrail.txt b/docs_build/dev/reports/coverage_changed_js_guardrail.txt -index 242796c4f..6fbeaba58 100644 +index 6fbeaba58..21de9adb6 100644 --- a/docs_build/dev/reports/coverage_changed_js_guardrail.txt +++ b/docs_build/dev/reports/coverage_changed_js_guardrail.txt -@@ -7,6 +7,8 @@ Source: Playwright/Chromium built-in V8 coverage from the active Playwright run. +@@ -6,9 +6,10 @@ Missing changed runtime JS files are WARN, not FAIL. + Source: Playwright/Chromium built-in V8 coverage from the active Playwright run. Changed runtime JS files considered: - (64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 -+(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 -+(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 +-(64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 +-(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 +-(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 ++(0%) toolbox/messages/message-tts-service-registry.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only ++(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only ++(80%) toolbox/text-to-speech/text2speech.js - executed lines 1518/1518; executed functions 127/158 Guardrail warnings: - (100%) none - no changed runtime JS coverage warnings +-(100%) none - no changed runtime JS coverage warnings ++(0%) toolbox/messages/message-tts-service-registry.js - WARNING: changed runtime JS file missing from coverage; advisory only ++(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file missing from coverage; advisory only diff --git a/docs_build/dev/reports/playwright_v8_coverage_report.txt b/docs_build/dev/reports/playwright_v8_coverage_report.txt -index 17eb943d2..578a19ddc 100644 +index 578a19ddc..49e94f0d7 100644 --- a/docs_build/dev/reports/playwright_v8_coverage_report.txt +++ b/docs_build/dev/reports/playwright_v8_coverage_report.txt -@@ -12,45 +12,34 @@ Note: entry percentages use function coverage when available, otherwise line cov +@@ -12,34 +12,33 @@ Note: entry percentages use function coverage when available, otherwise line cov Note: coverage entries are aggregated across every page/tool where coverageReporter.start(page) and coverageReporter.stop(page) ran. Exercised tool entry points detected: --(72%) Toolbox Index - exercised 10 runtime JS files -+(83%) Toolbox Index - exercised 4 runtime JS files +-(83%) Toolbox Index - exercised 4 runtime JS files ++(80%) Toolbox Index - exercised 2 runtime JS files (0%) Tool Template V2 - not exercised by this Playwright run --(61%) Theme V2 Shared JS - exercised 7 runtime JS files -+(56%) Theme V2 Shared JS - exercised 2 runtime JS files + (56%) Theme V2 Shared JS - exercised 2 runtime JS files Changed runtime JS files covered: - (64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 -+(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 -+(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 +-(64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 +-(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 +-(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 ++(0%) toolbox/messages/message-tts-service-registry.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only ++(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only ++(80%) toolbox/text-to-speech/text2speech.js - executed lines 1518/1518; executed functions 127/158 Files with executed line/function counts where available: --(14%) assets/theme-v2/js/account-auth-service.js - executed lines 64/64; executed functions 1/7 --(14%) assets/theme-v2/js/admin-setup-actions.js - executed lines 55/55; executed functions 1/7 --(18%) assets/theme-v2/js/account-page-data.js - executed lines 150/150; executed functions 3/17 --(20%) assets/theme-v2/js/admin-owner-navigation.js - executed lines 58/58; executed functions 2/10 --(25%) src/api/admin-owner-navigation.js - executed lines 42/42; executed functions 1/4 --(25%) src/api/session-api-client.js - executed lines 68/68; executed functions 3/12 --(29%) src/engine/input/NormalizedInputRegistry.js - executed lines 341/341; executed functions 6/21 --(33%) src/api/admin-setup-api-client.js - executed lines 13/13; executed functions 1/3 --(33%) src/api/toolbox-votes-api-client.js - executed lines 46/46; executed functions 2/6 --(56%) toolbox/colors/colors.js - executed lines 1848/1848; executed functions 115/204 --(58%) src/api/server-api-client.js - executed lines 167/167; executed functions 11/19 -+(34%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 15/44 -+(36%) src/api/server-api-client.js - executed lines 167/167; executed functions 5/14 -+(38%) src/api/public-config-client.js - executed lines 209/209; executed functions 10/26 -+(54%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 46/85 -+(58%) toolbox/messages/messages-api-client.js - executed lines 64/64; executed functions 11/19 +-(34%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 15/44 + (36%) src/api/server-api-client.js - executed lines 167/167; executed functions 5/14 + (38%) src/api/public-config-client.js - executed lines 209/209; executed functions 10/26 + (54%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 46/85 +-(58%) toolbox/messages/messages-api-client.js - executed lines 64/64; executed functions 11/19 (64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 --(64%) toolbox/controls/controls.js - executed lines 610/610; executed functions 36/56 --(65%) src/api/public-config-client.js - executed lines 209/209; executed functions 17/26 --(67%) src/api/game-journey-completion-api-client.js - executed lines 15/15; executed functions 2/3 --(74%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 67/90 --(75%) toolbox/game-workspace/game-workspace.js - executed lines 458/458; executed functions 33/44 --(89%) toolbox/tools-page-accordions.js - executed lines 1156/1156; executed functions 105/118 --(90%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 26/29 --(91%) toolbox/game-design/game-design.js - executed lines 254/254; executed functions 21/23 --(94%) assets/theme-v2/js/marketplace-page.js - executed lines 170/170; executed functions 16/17 --(100%) src/api/marketplace-api-client.js - executed lines 16/16; executed functions 3/3 --(100%) toolbox/colors/palette-api-client.js - executed lines 28/28; executed functions 4/4 --(100%) toolbox/controls/controls-api-client.js - executed lines 33/33; executed functions 5/5 --(100%) toolbox/game-design/game-design-api-client.js - executed lines 13/13; executed functions 2/2 --(100%) toolbox/game-workspace/game-workspace-api-client.js - executed lines 20/20; executed functions 3/3 -+(76%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 22/29 -+(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 -+(100%) src/engine/audio/TextToSpeechDefaults.js - executed lines 108/108; executed functions 1/1 -+(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 ++(71%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 37/52 + (76%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 22/29 +-(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 ++(80%) toolbox/text-to-speech/text2speech.js - executed lines 1518/1518; executed functions 127/158 + (100%) src/engine/audio/TextToSpeechDefaults.js - executed lines 108/108; executed functions 1/1 +-(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 Uncovered or low-coverage changed JS files: - (100%) none - no low-coverage changed runtime JS files +-(100%) none - no low-coverage changed runtime JS files ++(0%) toolbox/messages/message-tts-service-registry.js - WARNING: uncovered changed runtime JS file; advisory only ++(0%) toolbox/messages/messages.js - WARNING: uncovered changed runtime JS file; advisory only Changed JS files considered: - (0%) tests/playwright/tools/IdeaBoardTableNotes.spec.mjs - changed JS file not collected as browser runtime coverage -+(0%) tests/playwright/tools/MessagesTool.spec.mjs - changed JS file not collected as browser runtime coverage - (0%) tests/playwright/tools/ToolboxRoutePages.spec.mjs - changed JS file not collected as browser runtime coverage - (64%) assets/theme-v2/js/tool-display-mode.js - changed JS file with browser V8 coverage -+(87%) toolbox/messages/messages.js - changed JS file with browser V8 coverage -+(100%) toolbox/messages/message-tts-service-registry.js - changed JS file with browser V8 coverage -diff --git a/tests/playwright/tools/MessagesTool.spec.mjs b/tests/playwright/tools/MessagesTool.spec.mjs -index b120f19a3..451c4439f 100644 ---- a/tests/playwright/tools/MessagesTool.spec.mjs -+++ b/tests/playwright/tools/MessagesTool.spec.mjs -@@ -31,7 +31,7 @@ async function jsonRequest(url, options = {}) { - test.beforeEach(async ({ page }) => { - await installPlaywrightStorageIsolation(page, { - lane: "messages-tool", -- surface: "Message Studio Local API, legacy SQLite technical debt adapter, and Theme V2 tool", -+ surface: "Message Studio parent/child table, Local API, and Theme V2 tool", - }); - }); - -@@ -134,42 +134,24 @@ async function closeMessagesRun(failures, page) { - } - } - --async function addEmotionProfile(page, name) { -- await page.locator("[data-messages-emotion-add-row]").click(); -- await page.locator("[data-messages-emotion-editor='__new__'] [data-emotion-name]").fill(name); -- await page.locator("[data-messages-emotion-editor='__new__'] [data-emotion-volume]").fill("0.9"); -- await page.locator("[data-messages-emotion-editor='__new__'] [data-emotion-pitch]").fill("0.85"); -- await page.locator("[data-messages-emotion-editor='__new__'] [data-emotion-rate]").fill("0.95"); -- await page.locator("[data-messages-emotion-commit='__new__']").click(); --} -- --async function addTtsProfile(page, name) { -- await page.locator("[data-messages-tts-add-row]").click(); -- await page.locator("[data-messages-tts-editor='__new__'] [data-tts-name]").fill(name); -- await page.locator("[data-messages-tts-editor='__new__'] [data-tts-provider]").fill("browser-speech"); -- await page.locator("[data-messages-tts-editor='__new__'] [data-tts-voice]").fill("Test Voice"); -- await page.locator("[data-messages-tts-editor='__new__'] [data-tts-language]").fill("en-US"); -- await page.locator("[data-messages-tts-commit='__new__']").click(); --} -- --async function addMessageRow(page, values) { -- await page.locator("[data-messages-add-row]").click(); -+async function addMessage(page, values) { -+ await page.getByRole("button", { name: "Add Message" }).click(); - await page.locator("[data-messages-row-editor='__new__'] [data-message-name]").fill(values.name); -- await page.locator("[data-messages-row-editor='__new__'] [data-message-emotion]").selectOption({ label: values.emotion }); -+ await page.locator("[data-messages-row-editor-details='__new__'] [data-message-emotion]").selectOption({ label: values.emotion }); - await page.locator("[data-messages-row-editor-details='__new__'] [data-message-text]").fill(values.text); - await page.locator("[data-messages-row-editor-details='__new__'] [data-message-notes]").fill(values.notes || ""); - await page.locator("[data-messages-commit='__new__']").click(); - } - --async function addSegmentRow(page, values) { -- await page.locator("[data-messages-segment-add-row]").click(); -+async function addPart(page, values) { -+ await page.getByRole("button", { name: "Add Part" }).click(); - await page.locator("[data-messages-segment-editor='__new__'] [data-segment-order]").fill(String(values.order)); -- await page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]").selectOption({ label: values.emotion }); - await page.locator("[data-messages-segment-editor='__new__'] [data-segment-text]").fill(values.text); -+ await page.locator("[data-messages-segment-editor='__new__'] [data-segment-emotion]").selectOption({ label: values.emotion }); - await page.locator("[data-messages-segment-commit='__new__']").click(); - } - --test("Message Studio uses table governance, validates rows, and persists through the Local API", async ({ page }) => { -+test("Message Studio renders Messages with child Message Parts and plays ordered parts", async ({ page }) => { - const sqlitePath = messagesDbPath(); - await fs.rm(sqlitePath, { force: true }); - const failures = await openMessagesPage(page, sqlitePath); -@@ -179,242 +161,155 @@ test("Message Studio uses table governance, validates rows, and persists through - await expect(page.locator(".tool-workspace")).toBeVisible(); - await expect(page.locator("style, [style], script:not([src])")).toHaveCount(0); - await expect(page.locator("[data-messages-category-name]")).toHaveCount(0); -- await expect(page.locator("[data-messages-category]")).toHaveCount(0); -- await expect(page.getByRole("button", { name: /delete/i })).toHaveCount(0); -- await expect(page.locator("[data-messages-persistence-engine]")).toHaveText("Postgres target"); -- await expect(page.locator("[data-messages-tts-service]")).toHaveValue("browser-speech-synthesis"); -- await expect(page.locator("[data-messages-preview-status]")).toHaveText("Select a message row or segment row before testing speech."); -- await expect(page.locator("[data-messages-test-speech]")).toBeDisabled(); -- await expect(page.locator("[data-messages-preview-message], [data-messages-preview-segments], [data-messages-preview-stop]")).toHaveCount(0); -- -- await expect(page.locator("[data-messages-emotions]")).toContainText("Calm"); -- await expect(page.locator("[data-messages-emotions]")).toContainText("Urgent"); -+ await expect(page.locator("[data-messages-tts-add-row]")).toHaveCount(0); -+ await expect(page.getByRole("columnheader", { name: "Message Name" })).toBeVisible(); -+ await expect(page.getByRole("columnheader", { name: "Default TTS Profile" })).toBeVisible(); - await expect(page.locator("[data-messages-tts-profiles]")).toContainText("Browser Speech Default"); -+ await expect(page.locator("[data-messages-tts-profiles]")).toContainText("Owned by TTS Studio"); - -- await addEmotionProfile(page, "Robot"); -- await expect(page.locator("[data-messages-log]")).toHaveText("Updated emotion profile Robot."); -- await expect(page.locator("[data-messages-emotion-row]").filter({ hasText: "Robot" })).toContainText("0.9"); -- await page.locator("[data-messages-emotion-row]").filter({ hasText: "Robot" }).getByRole("button", { name: "Edit" }).click(); -- await page.locator("[data-messages-emotion-editor] [data-emotion-rate]").fill("1.05"); -- await page.locator("[data-messages-emotion-editor] [data-messages-emotion-commit]").click(); -- await page.locator("[data-messages-emotion-row]").filter({ hasText: "Robot" }).getByRole("button", { name: "Disable" }).click(); -- await expect(page.locator("[data-messages-emotion-row]").filter({ hasText: "Robot" })).toContainText("Inactive"); -- -- await addTtsProfile(page, "Arcade Browser Voice"); -- await expect(page.locator("[data-messages-log]")).toHaveText("Updated TTS profile Arcade Browser Voice."); -- await expect(page.locator("[data-messages-tts-profiles]")).toContainText("Arcade Browser Voice"); -- await expect(page.locator("[data-messages-tts-count]")).toHaveText("3"); -- await page.locator("[data-messages-tts-row]").filter({ hasText: "Arcade Browser Voice" }).getByRole("button", { name: "Edit" }).click(); -- await page.locator("[data-messages-tts-editor] [data-tts-language]").fill("en-GB"); -- await page.locator("[data-messages-tts-editor] [data-messages-tts-commit]").click(); -- await expect(page.locator("[data-messages-tts-row]").filter({ hasText: "Arcade Browser Voice" })).toContainText("Active"); -- -- const ttsProfilesResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/tts-profiles`); -- expect(ttsProfilesResult.response.ok).toBe(true); -- expect(ttsProfilesResult.payload.ok).toBe(true); -- const createdTtsProfile = ttsProfilesResult.payload.data.ttsProfiles.find((profile) => profile.name === "Arcade Browser Voice"); -- expect(createdTtsProfile).toEqual(expect.objectContaining({ -- active: true, -- language: "en-GB", -- providerKey: "browser-speech", -- voiceName: "Test Voice", -- })); -- expect(createdTtsProfile.key).toMatch(ULID_PATTERN); -- expect(createdTtsProfile.createdBy).toMatch(ULID_PATTERN); -- expect(createdTtsProfile.updatedBy).toMatch(ULID_PATTERN); -- -- await page.locator("[data-messages-add-row]").click(); -- await expect(page.locator("[data-messages-row-editor='__new__']")).toBeVisible(); -+ await page.getByRole("button", { name: "Add Message" }).click(); - await page.locator("[data-messages-commit='__new__']").click(); - await expect(page.locator("[data-messages-validation-card]")).toBeVisible(); - await expect(page.locator("[data-messages-validation-errors]")).toContainText("Message Name is required."); - await expect(page.locator("[data-messages-validation-errors]")).toContainText("Emotion Profile is required."); - await expect(page.locator("[data-messages-validation-errors]")).toContainText("Message Text is required."); -- - await page.locator("[data-messages-cancel='__new__']").click(); -- await addMessageRow(page, { -+ -+ await addMessage(page, { - emotion: "Urgent", -- name: "Forest Warning", -- notes: "Opening forest danger line.", -- text: "The forest gets darker beyond this point.\nWe are being attacked by bats.", -+ name: "Bat Encounter", -+ notes: "Opening combat line.", -+ text: "Bats drop from the rafters.", - }); -- await expect(page.locator("[data-messages-log]")).toHaveText("Updated row Forest Warning."); -- await expect(page.locator("[data-messages-count]")).toHaveText("1"); -- await expect(page.locator("[data-messages-table]")).toContainText("Forest Warning"); -- await expect(page.locator("[data-messages-selected-text]")).toHaveText("The forest gets darker beyond this point.\nWe are being attacked by bats."); -+ await expect(page.locator("[data-messages-log]")).toHaveText("Updated row Bat Encounter."); -+ await expect(page.locator("[data-messages-table]")).toContainText("Bat Encounter"); -+ await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" })).toContainText("Dialog"); -+ await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" })).toContainText("0"); -+ await expect(page.locator("[data-message-default-tts-profile]").first()).toHaveValue(/.+/); -+ -+ const messageRow = page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }); -+ await messageRow.click(); - await expect(page.locator("[data-messages-segment-host]")).toBeVisible(); -+ await expect(page.getByRole("heading", { name: "Message Parts" })).toBeVisible(); -+ await expect(page.getByRole("columnheader", { name: "Order" })).toBeVisible(); -+ await expect(page.getByRole("columnheader", { name: "Text" })).toBeVisible(); -+ await expect(page.getByRole("columnheader", { exact: true, name: "TTS Profile" })).toBeVisible(); - -- const listResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/messages`); -- expect(listResult.response.ok).toBe(true); -- expect(listResult.payload.ok).toBe(true); -- const createdMessage = listResult.payload.data.messages.find((message) => message.name === "Forest Warning"); -- expect(createdMessage).toEqual(expect.objectContaining({ -- active: true, -- emotionProfileName: "Urgent", -- messageText: "The forest gets darker beyond this point.\nWe are being attacked by bats.", -- notes: "Opening forest danger line.", -- })); -- expect(createdMessage.key).toMatch(ULID_PATTERN); -- expect(createdMessage.createdBy).toMatch(ULID_PATTERN); -- expect(createdMessage.updatedBy).toMatch(ULID_PATTERN); -- -- await page.locator("[data-messages-segment-add-row]").click(); -+ await page.getByRole("button", { name: "Add Part" }).click(); - await page.locator("[data-messages-segment-editor='__new__'] [data-segment-order]").fill(""); - await page.locator("[data-messages-segment-commit='__new__']").click(); -- await expect(page.locator("[data-messages-validation-card]")).toBeVisible(); -- await expect(page.locator("[data-messages-validation-errors]")).toContainText("Segment Text is required."); -+ await expect(page.locator("[data-messages-validation-errors]")).toContainText("Part Text is required."); - await expect(page.locator("[data-messages-validation-errors]")).toContainText("Emotion Profile is required."); - await expect(page.locator("[data-messages-validation-errors]")).toContainText("Display Order is required."); - await page.locator("[data-messages-segment-cancel='__new__']").click(); - -- await addSegmentRow(page, { -+ await addPart(page, { - emotion: "Calm", - order: 1, -- text: "The forest gets darker beyond this point.", -+ text: "Bats drop from the rafters.", - }); -- await expect(page.locator("[data-messages-log]")).toHaveText("Updated segment row 1."); -- await addSegmentRow(page, { -+ await expect(page.locator("[data-messages-log]")).toHaveText("Updated message part 1."); -+ await addPart(page, { - emotion: "Urgent", - order: 2, -- text: "We are being attacked by bats.", -+ text: "Keep your torch high.", - }); -- await expect(page.locator("[data-messages-log]")).toHaveText("Updated segment row 2."); -+ await expect(page.locator("[data-messages-log]")).toHaveText("Updated message part 2."); - await expect(page.locator("[data-messages-segment-row]")).toHaveCount(2); -+ await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" })).toContainText("2"); - -+ await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Play Message" }).click(); -+ await expect(page.locator("[data-messages-log]")).toHaveText("Play Message queued 2 parts for Bat Encounter."); - let speechCalls = await page.evaluate(() => window.__messagesSpeechCalls); -- expect(speechCalls).toEqual([]); -- -- await page.locator("[data-messages-preview-tts-profile]").selectOption({ label: "Arcade Browser Voice" }); -- await page.locator("[data-messages-row]").filter({ hasText: "Forest Warning" }).click(); -- await expect(page.locator("[data-messages-speech-test-target]")).toHaveText("Message Row: Forest Warning"); -- await expect(page.locator("[data-messages-test-speech]")).toBeEnabled(); -- await page.locator("[data-messages-test-speech]").click(); -- await expect(page.locator("[data-messages-preview-status]")).toHaveText("Speech test started for Message Row: Forest Warning using Browser Speech Synthesis."); -- speechCalls = await page.evaluate(() => window.__messagesSpeechCalls); -+ expect(speechCalls.slice(-2).map((call) => call.text)).toEqual([ -+ "Bats drop from the rafters.", -+ "Keep your torch high.", -+ ]); - expect(speechCalls.at(-1)).toEqual(expect.objectContaining({ -- lang: "en-GB", -- pitch: 1.08, -- rate: 1.15, -- text: "The forest gets darker beyond this point.\nWe are being attacked by bats.", -+ lang: "en-US", - type: "speak", - voiceName: "Test Voice", -- volume: 1, - })); - -- await page.locator("[data-messages-segment-row]").filter({ hasText: "The forest gets darker beyond this point." }).click(); -- await expect(page.locator("[data-messages-speech-test-target]")).toHaveText("Segment 1"); -- await page.locator("[data-messages-test-speech]").click(); -- await expect(page.locator("[data-messages-preview-status]")).toHaveText("Speech test started for Segment 1 using Browser Speech Synthesis."); -+ await page.locator("[data-messages-segment-row]").filter({ hasText: "Keep your torch high." }).getByRole("button", { name: "Play Part" }).click(); -+ await expect(page.locator("[data-messages-log]")).toHaveText("Play Part queued Part 2 using Browser Speech Default."); - speechCalls = await page.evaluate(() => window.__messagesSpeechCalls); - expect(speechCalls.at(-1)).toEqual(expect.objectContaining({ -- lang: "en-GB", -- pitch: 1, -- rate: 1, -- text: "The forest gets darker beyond this point.", -+ text: "Keep your torch high.", - type: "speak", - voiceName: "Test Voice", -- volume: 1, - })); +-(0%) tests/playwright/tools/IdeaBoardTableNotes.spec.mjs - changed JS file not collected as browser runtime coverage + (0%) tests/playwright/tools/MessagesTool.spec.mjs - changed JS file not collected as browser runtime coverage +-(0%) tests/playwright/tools/ToolboxRoutePages.spec.mjs - changed JS file not collected as browser runtime coverage +-(64%) assets/theme-v2/js/tool-display-mode.js - changed JS file with browser V8 coverage +-(87%) toolbox/messages/messages.js - changed JS file with browser V8 coverage +-(100%) toolbox/messages/message-tts-service-registry.js - changed JS file with browser V8 coverage ++(0%) tests/playwright/tools/TextToSpeechFunctional.spec.mjs - changed JS file not collected as browser runtime coverage ++(0%) tests/tools/Text2SpeechShell.test.mjs - changed JS file not collected as browser runtime coverage ++(0%) toolbox/messages/message-tts-service-registry.js - changed JS file not collected as browser runtime coverage ++(0%) toolbox/messages/messages.js - changed JS file not collected as browser runtime coverage ++(80%) toolbox/text-to-speech/text2speech.js - changed JS file with browser V8 coverage +diff --git a/tests/playwright/tools/TextToSpeechFunctional.spec.mjs b/tests/playwright/tools/TextToSpeechFunctional.spec.mjs +index 17e2c0b62..8c964fecc 100644 +--- a/tests/playwright/tools/TextToSpeechFunctional.spec.mjs ++++ b/tests/playwright/tools/TextToSpeechFunctional.spec.mjs +@@ -106,7 +106,45 @@ test("Text To Speech page loads and speaks through browser speech synthesis", as -+ await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Edit Message" }).click(); -+ await page.locator("[data-messages-row-editor] [data-message-name]").fill("Bat Encounter Updated"); -+ await page.locator("[data-messages-row-editor] [data-messages-commit]").click(); -+ await expect(page.locator("[data-messages-log]")).toHaveText("Updated row Bat Encounter Updated."); -+ -+ await page.locator("[data-messages-segment-row]").filter({ hasText: "Keep your torch high." }).getByRole("button", { name: "Edit Part" }).click(); -+ await page.locator("[data-messages-segment-editor] [data-segment-text]").fill("Keep your torch high and shield raised."); -+ await page.locator("[data-messages-segment-editor] [data-messages-segment-commit]").click(); -+ await expect(page.locator("[data-messages-log]")).toHaveText("Updated message part 2."); -+ -+ const listResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/messages`); -+ expect(listResult.response.ok).toBe(true); -+ expect(listResult.payload.ok).toBe(true); -+ const createdMessage = listResult.payload.data.messages.find((message) => message.name === "Bat Encounter Updated"); -+ expect(createdMessage).toEqual(expect.objectContaining({ -+ active: true, -+ categoryName: "Dialog", -+ emotionProfileName: "Urgent", -+ messageText: "Bats drop from the rafters.", -+ notes: "Opening combat line.", -+ })); -+ expect(createdMessage.key).toMatch(ULID_PATTERN); -+ - const segmentsResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/segments`); - expect(segmentsResult.response.ok).toBe(true); -- expect(segmentsResult.payload.ok).toBe(true); - const createdSegments = segmentsResult.payload.data.segments.filter((segment) => segment.messageKey === createdMessage.key); -- expect(createdSegments).toHaveLength(2); - expect(createdSegments.map((segment) => segment.displayOrder)).toEqual([1, 2]); -+ expect(createdSegments.map((segment) => segment.segmentText)).toEqual([ -+ "Bats drop from the rafters.", -+ "Keep your torch high and shield raised.", -+ ]); - createdSegments.forEach((segment) => { - expect(segment.key).toMatch(ULID_PATTERN); - expect(segment.createdBy).toMatch(ULID_PATTERN); - expect(segment.updatedBy).toMatch(ULID_PATTERN); - }); + await expect(page.locator("[data-tts-voice-select]")).toContainText("Arcade Voice"); + await expect(page.locator("[data-tts-voice-count]")).toHaveText("2"); +- await expect(page.locator("[data-tts-engine-label]")).toHaveText("Ready"); ++ await expect(page.locator("[data-tts-profile-count]")).toHaveText("3"); ++ await expect(page.locator("[data-tts-emotion-count]")).toHaveText("3"); ++ await expect(page.locator("[data-tts-profile-table]")).toContainText("Default Balanced Profile"); ++ await expect(page.locator("[data-tts-profile-table]")).toContainText("Man Profile 1"); ++ await expect(page.locator("[data-tts-profile-table]")).toContainText("Woman Profile 2"); ++ await expect(page.locator("[data-tts-profile-row]").filter({ hasText: "Default Balanced Profile" }).getByRole("button", { name: "Delete" })).toBeDisabled(); ++ await page.locator("[data-tts-profile-row]").filter({ hasText: "Default Balanced Profile" }).click(); ++ await expect(page.getByRole("heading", { name: "Emotion Settings" })).toBeVisible(); ++ await expect(page.getByRole("columnheader", { name: "Emotion", exact: true })).toBeVisible(); ++ await expect(page.getByRole("columnheader", { name: "SSML-like Preset" })).toBeVisible(); ++ await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Neutral" }).getByRole("button", { name: "Delete" })).toBeDisabled(); ++ ++ await page.getByRole("button", { name: "Add Profile" }).click(); ++ await expect(page.locator("[data-tts-profile-editor='__new__']")).toBeVisible(); ++ await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-name]").fill("Creature Profile"); ++ await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-gender]").selectOption("neutral"); ++ await page.locator("[data-tts-commit-profile='__new__']").click(); ++ await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Creature Profile."); ++ await expect(page.locator("[data-tts-profile-table]")).toContainText("Creature Profile"); ++ await page.locator("[data-tts-profile-row]").filter({ hasText: "Creature Profile" }).getByRole("button", { name: "Edit Profile" }).click(); ++ await page.locator("[data-tts-profile-editor] [data-tts-profile-name]").fill("Creature Profile Updated"); ++ await page.locator("[data-tts-profile-editor] [data-tts-commit-profile]").click(); ++ await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Creature Profile Updated."); ++ await page.getByRole("button", { name: "Add Emotion" }).click(); ++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-name]").selectOption("urgent"); ++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-pitch]").fill("1.2"); ++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-rate]").fill("1.1"); ++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-volume]").fill("0.8"); ++ await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-ssml-preset]").selectOption("whisper-ish"); ++ await page.locator("[data-tts-commit-emotion='__new__']").click(); ++ await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion setting: Urgent."); ++ await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Urgent" })).toContainText("1.2"); ++ await page.locator("[data-tts-emotion-row]").filter({ hasText: "Urgent" }).getByRole("button", { name: "Edit Emotion" }).click(); ++ await page.locator("[data-tts-emotion-editor] [data-tts-emotion-volume]").fill("0.7"); ++ await page.locator("[data-tts-emotion-editor] [data-tts-commit-emotion]").click(); ++ await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion setting: Urgent."); ++ await expect(page.locator("[data-tts-output-summary]")).toContainText("\"contractVersion\": \"tts-profile-emotion-v1\""); ++ await expect(page.locator("[data-tts-output-summary]")).toContainText("\"name\": \"Creature Profile Updated\""); ++ + await expect(page.locator("[data-tts-gender-select]")).toBeVisible(); + await expect(page.locator("[data-tts-language-select]")).toBeVisible(); + await expect(page.locator("[data-tts-age-select]")).toBeVisible(); +@@ -173,7 +211,7 @@ test("Text To Speech shows actionable error when browser speech synthesis is una + const failures = await openTextToSpeechPage(page, { speechAvailable: false }); + try { + await expect(page.getByRole("heading", { level: 1, name: "Text To Speech" })).toBeVisible(); +- await expect(page.locator("[data-tts-engine-label]")).toHaveText("Unavailable"); ++ await expect(page.locator("[data-tts-profile-count]")).toHaveText("3"); + await expect(page.locator("[data-tts-engine-status]")).toContainText("SpeechSynthesis is unavailable"); + await expect(page.locator("[data-tts-status]")).toContainText("Use a browser with Web Speech API support"); + await expect(page.locator("[data-tts-voice-select]")).toContainText("No browser voices available"); +diff --git a/tests/tools/Text2SpeechShell.test.mjs b/tests/tools/Text2SpeechShell.test.mjs +index fbc6ac8e9..51e4f019f 100644 +--- a/tests/tools/Text2SpeechShell.test.mjs ++++ b/tests/tools/Text2SpeechShell.test.mjs +@@ -3,9 +3,14 @@ import test from "node:test"; -- await page.locator("[data-messages-segment-row]").filter({ hasText: "We are being attacked by bats." }).getByRole("button", { name: "Move Up" }).click(); -- await expect(page.locator("[data-messages-log]")).toHaveText("Segment order updated."); -- await expect(page.locator("[data-messages-segment-row]").first()).toContainText("We are being attacked by bats."); -- -- await page.locator("[data-messages-segment-row]").filter({ hasText: "We are being attacked by bats." }).getByRole("button", { name: "Edit" }).click(); -- await page.locator("[data-messages-segment-editor] [data-segment-text]").fill("We are being attacked by bats right now."); -- await page.locator("[data-messages-segment-editor] [data-messages-segment-commit]").click(); -- await expect(page.locator("[data-messages-log]")).toHaveText("Updated segment row 1."); -- await expect(page.locator("[data-messages-segment-row]").filter({ hasText: "We are being attacked by bats right now." })).toBeVisible(); -- -- await page.locator("[data-messages-segment-row]").filter({ hasText: "We are being attacked by bats right now." }).getByRole("button", { name: "Disable" }).click(); -- await expect(page.locator("[data-messages-log]")).toHaveText("Disabled segment row 1."); -- await expect(page.locator("[data-messages-segment-row]").filter({ hasText: "We are being attacked by bats right now." })).toContainText("Inactive"); -- -- const updatedSegmentsResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/segments`); -- const disabledSegment = updatedSegmentsResult.payload.data.segments.find((segment) => segment.segmentText === "We are being attacked by bats right now."); -- expect(disabledSegment).toEqual(expect.objectContaining({ -- active: false, -- displayOrder: 1, -- emotionProfileName: "Urgent", -- messageKey: createdMessage.key, -- messageName: "Forest Warning", -- segmentText: "We are being attacked by bats right now.", -- })); -- -- await page.locator("[data-messages-row]").filter({ hasText: "Forest Warning" }).getByRole("button", { name: "Edit" }).click(); -- await page.locator("[data-messages-row-editor] [data-message-name]").fill("Forest Warning Updated"); -- await page.locator("[data-messages-row-editor-details] [data-message-text]").fill("The forest gets darker beyond this point."); -- await page.locator("[data-messages-row-editor] [data-messages-commit]").click(); -- await expect(page.locator("[data-messages-log]")).toHaveText("Updated row Forest Warning Updated."); -- await expect(page.locator("[data-messages-table]")).toContainText("Forest Warning Updated"); -- -- await page.locator("[data-messages-row]").filter({ hasText: "Forest Warning Updated" }).getByRole("button", { name: "Disable" }).click(); -- await expect(page.locator("[data-messages-log]")).toHaveText("Disabled row Forest Warning Updated."); -- await expect(page.locator("[data-messages-row]").filter({ hasText: "Forest Warning Updated" })).toContainText("Inactive"); -- -- const updateResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/messages/${createdMessage.key}`); -- expect(updateResult.payload.data.message).toEqual(expect.objectContaining({ -- active: false, -- key: createdMessage.key, -- messageText: "The forest gets darker beyond this point.", -- name: "Forest Warning Updated", -- })); -- -- await page.locator("[data-messages-tts-row]").filter({ hasText: "Arcade Browser Voice" }).getByRole("button", { name: "Disable" }).click(); -- await expect(page.locator("[data-messages-log]")).toHaveText("Disabled TTS profile Arcade Browser Voice."); -- await expect(page.locator("[data-messages-tts-row]").filter({ hasText: "Arcade Browser Voice" })).toContainText("Inactive"); -- -- for (const url of [ -- `${failures.server.baseUrl}/api/messages/messages/${createdMessage.key}`, -- `${failures.server.baseUrl}/api/messages/segments/${disabledSegment.key}`, -- `${failures.server.baseUrl}/api/messages/tts-profiles/${createdTtsProfile.key}`, -- ]) { -- const deleteResult = await fetch(url, { method: "DELETE" }); -- expect(deleteResult.status).toBe(404); -- } -+ expect(failures.failedRequests).toEqual([]); -+ expect(failures.pageErrors).toEqual([]); -+ expect(failures.consoleErrors).toEqual([]); -+ } finally { -+ await closeMessagesRun(failures, page); -+ await fs.rm(sqlitePath, { force: true }); -+ } + import { + TTS_MESSAGE_STATUSES, ++ TTS_PROFILE_CONTRACT_VERSION, + TTS_PROVIDER_ADAPTER_PLAN, ++ createDefaultTextToSpeechProfiles, + createEmotionProfile, ++ createMessageStudioTtsProfileOptions, + createSpeechPreviewRequest, ++ createTextToSpeechProfile, ++ createTextToSpeechProfileEmotion, + createTtsMessage, + createVoiceProfile, + previewTtsMessage, +@@ -59,3 +64,46 @@ test("Text2Speech provider adapter plan keeps browser speech implemented and pai + assert.equal(TTS_PROVIDER_ADAPTER_PLAN[0].status, "implemented"); + assert.ok(TTS_PROVIDER_ADAPTER_PLAN.slice(1).every((provider) => provider.status === "planned")); + }); ++ ++test("Text2Speech profile contract exposes Message Studio compatible profile options", () => { ++ const voiceOptions = [{ language: "en-US", label: "Test Voice (en-US)", name: "Test Voice", value: "test-voice" }]; ++ const defaults = createDefaultTextToSpeechProfiles(voiceOptions); ++ const custom = createTextToSpeechProfile({ ++ emotions: [ ++ createTextToSpeechProfileEmotion({ ++ emotion: "urgent", ++ pitch: 1.2, ++ rate: 1.1, ++ ssmlLikePreset: "whisper-ish", ++ volume: 0.8, ++ }), ++ ], ++ id: "custom-profile", ++ name: "Custom Profile", ++ voice: "test-voice", ++ voiceName: "Test Voice", ++ }); ++ const options = createMessageStudioTtsProfileOptions([custom]); ++ ++ assert.equal(TTS_PROFILE_CONTRACT_VERSION, "tts-profile-emotion-v1"); ++ assert.equal(defaults[0].name, "Default Balanced Profile"); ++ assert.equal(defaults[0].messageStudioUsageCount, 1); ++ assert.equal(defaults[0].emotions[0].emotionLabel, "Neutral"); ++ assert.equal(defaults[0].emotions[0].messagePartsUsageCount, 1); ++ assert.deepEqual(options, [{ ++ active: true, ++ emotionSettings: [{ ++ emotion: "urgent", ++ emotionLabel: "Urgent", ++ pitch: 1.2, ++ rate: 1.1, ++ ssmlLikePreset: "whisper-ish", ++ volume: 0.8, ++ }], ++ key: "custom-profile", ++ language: "en-US", ++ name: "Custom Profile", ++ providerKey: "browser-speech", ++ voiceName: "Test Voice", ++ }]); +}); - -- await failures.server.close(); -- const restartedServer = await startRepoServer(); -- failures.server = restartedServer; -- const persistedResult = await jsonRequest(`${restartedServer.baseUrl}/api/messages/messages/${createdMessage.key}`); -- expect(persistedResult.response.ok).toBe(true); -- expect(persistedResult.payload.data.message).toEqual(expect.objectContaining({ -- active: false, -- key: createdMessage.key, -- name: "Forest Warning Updated", -- messageText: "The forest gets darker beyond this point.", -- })); -+test("Message Studio shows actionable playback error when audio engine is unavailable", async ({ page }) => { -+ const sqlitePath = messagesDbPath(); -+ await fs.rm(sqlitePath, { force: true }); -+ const failures = await openMessagesPage(page, sqlitePath, { speechAvailable: false }); - -- const persistedSegmentResult = await jsonRequest(`${restartedServer.baseUrl}/api/messages/segments/${disabledSegment.key}`); -- expect(persistedSegmentResult.response.ok).toBe(true); -- expect(persistedSegmentResult.payload.data.segment).toEqual(expect.objectContaining({ -- active: false, -- displayOrder: 1, -- key: disabledSegment.key, -- segmentText: "We are being attacked by bats right now.", -- })); -+ try { -+ await addMessage(page, { -+ emotion: "Urgent", -+ name: "Bat Encounter", -+ text: "Bats drop from the rafters.", -+ }); -+ await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).click(); -+ await addPart(page, { -+ emotion: "Urgent", -+ order: 1, -+ text: "Bats drop from the rafters.", -+ }); - -- const persistedTtsProfileResult = await jsonRequest(`${restartedServer.baseUrl}/api/messages/tts-profiles/${createdTtsProfile.key}`); -- expect(persistedTtsProfileResult.response.ok).toBe(true); -- expect(persistedTtsProfileResult.payload.data.ttsProfile).toEqual(expect.objectContaining({ -- active: false, -- key: createdTtsProfile.key, -- name: "Arcade Browser Voice", -- providerKey: "browser-speech", -- voiceName: "Test Voice", -- })); -+ await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Play Message" }).click(); -+ await expect(page.locator("[data-messages-validation-card]")).toBeVisible(); -+ await expect(page.locator("[data-messages-validation-errors]")).toContainText("Audio engine is unavailable. Use a browser with SpeechSynthesis support and reload Message Studio."); -+ await expect(page.locator("[data-messages-log]")).toHaveText("Audio engine is unavailable. Use a browser with SpeechSynthesis support and reload Message Studio."); -+ await expect(page.locator("[data-messages-preview-status]")).toHaveText("Audio engine is unavailable. Use a browser with SpeechSynthesis support and reload Message Studio."); -+ expect(await page.evaluate(() => window.__messagesSpeechCalls)).toEqual([]); - - expect(failures.failedRequests).toEqual([]); - expect(failures.pageErrors).toEqual([]); -diff --git a/toolbox/messages/index.html b/toolbox/messages/index.html -index 2f1945bbe..5df9a0290 100644 ---- a/toolbox/messages/index.html -+++ b/toolbox/messages/index.html -@@ -39,7 +39,7 @@ -
- Row Workflow -
--

Review existing rows, select one to update, or add a row from the Message Rows table.

-+

Review messages, open a message to manage parts, or add a message from the Messages table.

-

Disable rows instead of deleting game text.

-
-
-@@ -48,9 +48,9 @@ -
+diff --git a/toolbox/text-to-speech/index.html b/toolbox/text-to-speech/index.html +index f5b29b19a..e82dd9468 100644 +--- a/toolbox/text-to-speech/index.html ++++ b/toolbox/text-to-speech/index.html +@@ -98,14 +98,50 @@ + +
-

Message Studio

--

Message rows define what is said. Emotion profiles and TTS profiles define future delivery settings only.

-+

Messages own game text and ordered parts. Audio playback is delegated to the audio engine.

-
--
0Message Rows
-+
0Messages
-
0Emotion Profiles
-
0TTS Profiles
+-

Speech Composition

+-
++

TTS Studio

++
+
0Characters
++
0TTS Profiles
++
0Emotion Settings
+
0Voices
+
CheckingEngine
-@@ -58,17 +58,17 @@ +
-
-
Game Text Repository
--

Message Rows

-+

Messages

-
-
-- -+
- - -- -- -- -- -+ -+ - -+ -+ - - - -@@ -78,7 +78,7 @@ -
NamePrimary EmotionSegmentsTagsMessage NameTypeStatusPartsDefault TTS ProfileActions
-
-
-- -+ -
-
-
-@@ -153,7 +150,7 @@ -
-

Name: None

-

Emotion Profile: None

--

Segment: None

-+

Part: None

-

Status: None

-

Text:

-
No message selected.
-@@ -184,7 +181,7 @@ -
- Future Compatibility -
--

Message segments are stored as ordered text with emotion profiles.

-+

Message parts are stored as ordered text with emotion profiles.

-

This tool stores message text exactly as entered.

-
-
-diff --git a/toolbox/messages/message-tts-service-registry.js b/toolbox/messages/message-tts-service-registry.js -index ce914c832..e5cf310ba 100644 ---- a/toolbox/messages/message-tts-service-registry.js -+++ b/toolbox/messages/message-tts-service-registry.js -@@ -31,7 +31,13 @@ function createMessageStudioTtsServiceRegistry({ - ok: false, - }; - } -- return engine.speak(options); -+ const selectedVoice = String(options?.voice || "").trim() -+ || engine.voiceOptions()[0]?.value -+ || ""; -+ return engine.speak({ -+ ...options, -+ voice: selectedVoice, -+ }); - }, - }; - } -diff --git a/toolbox/messages/messages.js b/toolbox/messages/messages.js -index 7843a8d2c..a2407ba95 100644 ---- a/toolbox/messages/messages.js -+++ b/toolbox/messages/messages.js -@@ -2,7 +2,6 @@ import { - createMessage, - createMessageSegment, - createEmotionProfile, -- createTtsProfile, - listEmotionProfiles, - listMessages, - listMessageSegments, -@@ -10,11 +9,23 @@ import { - updateEmotionProfile, - updateMessage, - updateMessageSegment, -- updateTtsProfile, - } from "./messages-api-client.js"; - import { createMessageStudioTtsServiceRegistry } from "./message-tts-service-registry.js"; - - const NEW_ROW_KEY = "__new__"; -+const DEFAULT_TTS_PROFILE_KEY = "__default-balanced-tts__"; -+const DEFAULT_TTS_PROFILE = Object.freeze({ -+ active: true, -+ description: "Balanced local browser playback option until authored TTS profiles are available.", -+ key: DEFAULT_TTS_PROFILE_KEY, -+ language: "en-US", -+ name: "Default Balanced TTS", -+ pitch: 1, -+ providerKey: "browser-speech", -+ rate: 1, -+ voiceName: "", -+ volume: 1, -+}); - const ttsServiceRegistry = createMessageStudioTtsServiceRegistry(); ++
++
Reusable Speech Profiles
++

TTS Profiles

++
++
++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++
Profile NameVoiceLanguageGenderAgeEmotion CountStatusActions
Loading TTS profiles.
++
++
++ ++
++
++
++
++
++
++
Local Preview
++

Speech Composition

++
+ + +
+diff --git a/toolbox/text-to-speech/text2speech.js b/toolbox/text-to-speech/text2speech.js +index 5ab111eeb..9e0af1f87 100644 +--- a/toolbox/text-to-speech/text2speech.js ++++ b/toolbox/text-to-speech/text2speech.js +@@ -2,6 +2,7 @@ import { + createTextToSpeechQueueItem, + filterTextToSpeechVoiceOptions, + shapeTextToSpeechOptions, ++ textToSpeechLanguageOptionsFromVoices, + textToSpeechPayloadGenderValue, + TextToSpeechEngine, + uniqueTextToSpeechId, +@@ -80,6 +81,27 @@ const TTS_PROVIDER_ADAPTER_PLAN = Object.freeze([ + } + ]); - const elements = { -@@ -37,7 +48,6 @@ const elements = { - speechTestTarget: document.querySelector("[data-messages-speech-test-target]"), - table: document.querySelector("[data-messages-table]"), - testSpeech: document.querySelector("[data-messages-test-speech]"), -- ttsAddRow: document.querySelector("[data-messages-tts-add-row]"), - ttsCount: document.querySelector("[data-messages-tts-count]"), - ttsRows: document.querySelector("[data-messages-tts-profiles]"), - ttsService: document.querySelector("[data-messages-tts-service]"), -@@ -49,9 +59,10 @@ const state = { - editingEmotionKey: "", - editingMessageKey: "", - editingSegmentKey: "", -- editingTtsKey: "", - emotionProfiles: [], -+ messageTtsProfileKeys: new Map(), - messages: [], -+ segmentTtsProfileKeys: new Map(), - segments: [], - selectedMessageKey: "", - selectedSegmentKey: "", -@@ -148,6 +159,19 @@ function createSelect(value, dataName, options, placeholder) { - return select; ++const TTS_PROFILE_CONTRACT_VERSION = "tts-profile-emotion-v1"; ++const NEW_ROW_KEY = "__new__"; ++const DEFAULT_TTS_PROFILE_ID = "default-balanced-profile"; ++const DEFAULT_TTS_EMOTION_ID = "neutral"; ++ ++const TTS_PROFILE_GENDER_OPTIONS = Object.freeze([ ++ Object.freeze({ label: "Neutral", value: "neutral" }), ++ Object.freeze({ label: "Male", value: "male" }), ++ Object.freeze({ label: "Female", value: "female" }), ++ Object.freeze({ label: "Any", value: "any" }) ++]); ++ ++const TTS_PROFILE_EMOTION_OPTIONS = Object.freeze([ ++ Object.freeze({ label: "Neutral", value: "neutral" }), ++ Object.freeze({ label: "Calm", value: "calm" }), ++ Object.freeze({ label: "Urgent", value: "urgent" }), ++ Object.freeze({ label: "Whisper", value: "whisper" }), ++ Object.freeze({ label: "Angry", value: "angry" }), ++ Object.freeze({ label: "Excited", value: "excited" }) ++]); ++ + function boundedNumber(value, { fallback, max, min, value: defaultValue }) { + const number = Number(value); + const fallbackValue = fallback ?? defaultValue ?? min; +@@ -139,6 +161,144 @@ function createVoiceProfile({ key = "browser-speech", name = "Browser Speech", p + }; } -+function createTtsProfileSelect(value, dataName, identityKey) { -+ const select = createSelect( -+ value || defaultTtsProfileKey(), -+ dataName, -+ activeTtsProfileOptions(), -+ "Select TTS profile", -+ ); -+ if (identityKey) { -+ select.dataset.messagesTtsIdentity = identityKey; -+ } -+ return select; ++function slugFromText(value, fallback = "tts-profile") { ++ const slug = String(value || "") ++ .trim() ++ .toLowerCase() ++ .replace(/[^a-z0-9]+/g, "-") ++ .replace(/^-+|-+$/g, ""); ++ return slug || fallback; +} + - function populateSelect(select, options, placeholder) { - if (!select) { - return; -@@ -225,12 +249,35 @@ function emotionProfileByKey(profileKey) { - return state.emotionProfiles.find((profile) => profile.key === profileKey) || null; - } - --function ttsProfileByKey(profileKey) { -- return state.ttsProfiles.find((profile) => profile.key === profileKey) || null; -+function activeTtsProfileOptions() { -+ const activeProfiles = state.ttsProfiles.filter((profile) => profile.active); -+ return activeProfiles.length ? activeProfiles : [DEFAULT_TTS_PROFILE]; ++function labelForOption(options, value, fallback = "") { ++ return options.find((option) => String(option.value) === String(value))?.label || fallback || String(value || ""); ++} ++ ++function createTextToSpeechProfileEmotion({ ++ active = true, ++ emotion = "neutral", ++ id = "", ++ messagePartsUsageCount = 0, ++ pitch = TEXT_TO_SPEECH_DEFAULTS.pitch, ++ rate = TEXT_TO_SPEECH_DEFAULTS.rate, ++ ssmlLikePreset = TEXT_TO_SPEECH_DEFAULTS.ssmlLikePreset, ++ volume = TEXT_TO_SPEECH_DEFAULTS.volume ++} = {}) { ++ const emotionKey = slugFromText(emotion, DEFAULT_TTS_EMOTION_ID); ++ return { ++ active: active !== false, ++ emotion: emotionKey, ++ emotionLabel: labelForOption(TTS_PROFILE_EMOTION_OPTIONS, emotionKey, "Neutral"), ++ id: id || emotionKey, ++ messagePartsUsageCount: Math.max(0, Number(messagePartsUsageCount) || 0), ++ pitch: boundedNumber(pitch, TEXT_TO_SPEECH_RANGE_DEFAULTS.pitch), ++ rate: boundedNumber(rate, TEXT_TO_SPEECH_RANGE_DEFAULTS.rate), ++ ssmlLikePreset: TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS.some((option) => option.value === ssmlLikePreset) ? ssmlLikePreset : "normal", ++ volume: boundedNumber(volume, TEXT_TO_SPEECH_RANGE_DEFAULTS.volume) ++ }; +} + -+function defaultTtsProfileKey() { -+ return activeTtsProfileOptions()[0]?.key || DEFAULT_TTS_PROFILE_KEY; ++function createTextToSpeechProfile({ ++ active = true, ++ age = TEXT_TO_SPEECH_DEFAULTS.voiceAge, ++ emotions = [], ++ gender = "neutral", ++ id = "", ++ language = TEXT_TO_SPEECH_DEFAULTS.language, ++ messageStudioUsageCount = 0, ++ name = "Default Balanced Profile", ++ voice = "", ++ voiceName = "" ++} = {}) { ++ const profileName = String(name || "Default Balanced Profile").trim() || "Default Balanced Profile"; ++ const emotionRows = Array.isArray(emotions) && emotions.length ++ ? emotions.map((emotion) => createTextToSpeechProfileEmotion(emotion)) ++ : [createTextToSpeechProfileEmotion()]; ++ return { ++ active: active !== false, ++ age: String(age || TEXT_TO_SPEECH_DEFAULTS.voiceAge), ++ emotions: emotionRows, ++ gender: String(gender || "neutral"), ++ id: id || slugFromText(profileName, "tts-profile"), ++ language: String(language || TEXT_TO_SPEECH_DEFAULTS.language), ++ messageStudioUsageCount: Math.max(0, Number(messageStudioUsageCount) || 0), ++ name: profileName, ++ owner: TTS_OWNERSHIP.AUDIO, ++ providerKey: "browser-speech", ++ voice: String(voice || ""), ++ voiceName: String(voiceName || voice || "Default browser voice") ++ }; +} + -+function ttsProfileOptionByKey(profileKey) { -+ return activeTtsProfileOptions().find((profile) => profile.key === profileKey) -+ || activeTtsProfileOptions()[0] -+ || DEFAULT_TTS_PROFILE; ++function defaultVoiceForProfile(voiceOptions = [], preferredGender = "") { ++ if (!voiceOptions.length) { ++ return null; ++ } ++ const preferred = voiceOptions.find((option) => { ++ const text = `${option.name || ""} ${option.label || ""}`.toLowerCase(); ++ if (preferredGender === "male") return /\bmale\b|\bman\b|\bdavid\b|\bmark\b/.test(text); ++ if (preferredGender === "female") return /\bfemale\b|\bwoman\b|\bzira\b/.test(text); ++ return false; ++ }); ++ return preferred || voiceOptions[0]; +} + -+function selectedTtsProfileForMessage(messageKey) { -+ return ttsProfileOptionByKey(state.messageTtsProfileKeys.get(messageKey) || defaultTtsProfileKey()); ++function createDefaultTextToSpeechProfiles(voiceOptions = []) { ++ const balancedVoice = defaultVoiceForProfile(voiceOptions); ++ const manVoice = defaultVoiceForProfile(voiceOptions, "male") || balancedVoice; ++ const womanVoice = defaultVoiceForProfile(voiceOptions, "female") || voiceOptions[1] || balancedVoice; ++ return [ ++ createTextToSpeechProfile({ ++ emotions: [createTextToSpeechProfileEmotion({ messagePartsUsageCount: 1 })], ++ id: DEFAULT_TTS_PROFILE_ID, ++ language: balancedVoice?.language || TEXT_TO_SPEECH_DEFAULTS.language, ++ messageStudioUsageCount: 1, ++ name: "Default Balanced Profile", ++ voice: balancedVoice?.value || "", ++ voiceName: balancedVoice?.name || balancedVoice?.label || "Default browser voice" ++ }), ++ createTextToSpeechProfile({ ++ gender: "male", ++ id: "man-profile-1", ++ language: manVoice?.language || TEXT_TO_SPEECH_DEFAULTS.language, ++ name: "Man Profile 1", ++ voice: manVoice?.value || "", ++ voiceName: manVoice?.name || manVoice?.label || "Default browser voice" ++ }), ++ createTextToSpeechProfile({ ++ gender: "female", ++ id: "woman-profile-2", ++ language: womanVoice?.language || TEXT_TO_SPEECH_DEFAULTS.language, ++ name: "Woman Profile 2", ++ voice: womanVoice?.value || "", ++ voiceName: womanVoice?.name || womanVoice?.label || "Default browser voice" ++ }) ++ ]; +} + -+function selectedTtsProfileForSegment(segmentKey, messageKey = state.selectedMessageKey) { -+ return ttsProfileOptionByKey( -+ state.segmentTtsProfileKeys.get(segmentKey) -+ || state.messageTtsProfileKeys.get(messageKey) -+ || defaultTtsProfileKey(), -+ ); - } - - function selectedTtsProfile() { -- return ttsProfileByKey(elements.previewTtsProfile?.value || ""); -+ return ttsProfileOptionByKey(elements.previewTtsProfile?.value || defaultTtsProfileKey()); - } - - function selectedTtsService() { -@@ -243,10 +290,11 @@ function selectedSpeechTarget() { - return { - emotionProfile: emotionProfileByKey(segment.emotionProfileKey), - id: segment.key, -- label: `Segment ${segment.displayOrder}`, -- name: `${segment.messageName || "Message"} segment ${segment.displayOrder}`, -+ label: `Part ${segment.displayOrder}`, -+ name: `${segment.messageName || "Message"} part ${segment.displayOrder}`, -+ profile: selectedTtsProfileForSegment(segment.key, segment.messageKey), - text: segment.segmentText, -- type: "segment", -+ type: "part", - }; - } - const message = selectedMessage(); -@@ -256,8 +304,9 @@ function selectedSpeechTarget() { - return { - emotionProfile: emotionProfileByKey(message.emotionProfileKey), - id: message.key, -- label: `Message Row: ${message.name}`, -+ label: `Message: ${message.name}`, - name: message.name, -+ profile: selectedTtsProfileForMessage(message.key), - text: message.messageText, - type: "message", ++function createMessageStudioTtsProfileOptions(profiles = []) { ++ return profiles ++ .filter((profile) => profile?.active !== false) ++ .map((profile) => ({ ++ active: true, ++ emotionSettings: Array.isArray(profile.emotions) ++ ? profile.emotions.filter((emotion) => emotion.active !== false).map((emotion) => ({ ++ emotion: emotion.emotion, ++ emotionLabel: emotion.emotionLabel, ++ pitch: emotion.pitch, ++ rate: emotion.rate, ++ ssmlLikePreset: emotion.ssmlLikePreset, ++ volume: emotion.volume ++ })) ++ : [], ++ key: profile.id, ++ language: profile.language, ++ name: profile.name, ++ providerKey: profile.providerKey || "browser-speech", ++ voiceName: profile.voiceName || profile.voice || "" ++ })); ++} ++ + function createSpeechPreviewRequest({ + pitch = TEXT_TO_SPEECH_DEFAULTS.pitch, + rate = TEXT_TO_SPEECH_DEFAULTS.rate, +@@ -250,6 +410,7 @@ function queueItemMeta(item) { + function initializeTextToSpeechTool(root = document, { engine = new TextToSpeechEngine() } = {}) { + const elements = { + addItem: root.querySelector("[data-tts-add-item]"), ++ addProfile: root.querySelector("[data-tts-add-profile]"), + age: root.querySelector("[data-tts-age-select]"), + characterPreset: root.querySelector("[data-tts-character-preset-select]"), + clearStatus: root.querySelector("[data-tts-clear-status]"), +@@ -266,6 +427,9 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech + pause: root.querySelector("[data-tts-pause]"), + pitch: root.querySelector("[data-tts-pitch]"), + pitchValue: root.querySelector("[data-tts-pitch-value]"), ++ profileCount: root.querySelector("[data-tts-profile-count]"), ++ profileEmotionCount: root.querySelector("[data-tts-emotion-count]"), ++ profileTable: root.querySelector("[data-tts-profile-table]"), + queueList: root.querySelector("[data-tts-queue-list]"), + rate: root.querySelector("[data-tts-rate]"), + rateValue: root.querySelector("[data-tts-rate-value]"), +@@ -285,7 +449,11 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech }; -@@ -293,7 +342,7 @@ function selectOptionsWithCurrent(currentKey) { - function renderCounts() { - setText(elements.count, String(state.messages.length)); - setText(elements.emotionCount, String(state.emotionProfiles.length)); -- setText(elements.ttsCount, String(state.ttsProfiles.length)); -+ setText(elements.ttsCount, String(activeTtsProfileOptions().length)); - } - - function renderPersistence(persistence = {}) { -@@ -330,7 +379,7 @@ function renderTtsServiceOptions() { - } - - function renderTtsProfileOptions() { -- const activeProfiles = state.ttsProfiles.filter((profile) => profile.active); -+ const activeProfiles = activeTtsProfileOptions(); - populateSelect(elements.previewTtsProfile, activeProfiles, "Select TTS profile"); - const selected = selectedTtsProfile(); - if (!selected && activeProfiles[0]) { -@@ -361,9 +410,6 @@ function speechTestReadiness() { - if (!profile) { - return { message: "Select an active TTS profile before testing speech.", ok: false }; - } -- if (!String(profile.voiceName || "").trim()) { -- return { message: "Select a TTS profile with a voice before testing speech.", ok: false }; -- } - if (!target.emotionProfile) { - return { message: "Selected item needs an emotion profile before testing speech.", ok: false }; - } -@@ -393,22 +439,27 @@ function createMessageEditRows(message = null) { - const nameCell = document.createElement("td"); - nameCell.append(createInput(message?.name || "", "messageName")); - -- const emotionCell = document.createElement("td"); -- emotionCell.append(createSelect(message?.emotionProfileKey || "", "messageEmotion", selectOptionsWithCurrent(message?.emotionProfileKey || ""), "Select emotion profile")); -- -- const segmentCell = createCell(message ? String(messageSegments(message.key).length) : "0"); -- const tagsCell = createCell("Tags planned"); -+ const typeCell = createCell(message?.categoryName || "Dialog"); - - const statusCell = document.createElement("td"); - statusCell.append(createCheckbox(message?.active !== false, "messageActive")); - -+ const partCell = createCell(message ? String(messageSegments(message.key).length) : "0"); -+ -+ const ttsCell = document.createElement("td"); -+ ttsCell.append(createTtsProfileSelect( -+ message ? selectedTtsProfileForMessage(message.key).key : defaultTtsProfileKey(), -+ "messageDefaultTtsProfile", -+ key, -+ )); -+ - const actions = document.createElement("td"); - actions.append(createActionGroup( -- createButton("Update Row", "messagesCommit", key), -+ createButton("Save", "messagesCommit", key), - createButton("Cancel", "messagesCancel", key), - )); - -- row.append(nameCell, emotionCell, segmentCell, tagsCell, statusCell, actions); -+ row.append(nameCell, typeCell, statusCell, partCell, ttsCell, actions); - - const detailRow = document.createElement("tr"); - detailRow.dataset.messagesRowEditorDetails = key; -@@ -417,6 +468,7 @@ function createMessageEditRows(message = null) { - const stack = document.createElement("div"); - stack.className = "content-stack"; - stack.append( -+ createField("Primary Emotion", createSelect(message?.emotionProfileKey || "", "messageEmotion", selectOptionsWithCurrent(message?.emotionProfileKey || ""), "Select emotion profile")), - createField("Message Text", createTextarea(message?.messageText || "", "messageText", 6)), - createField("Notes", createTextarea(message?.notes || "", "messageNotes", 3)), - ); -@@ -432,19 +484,19 @@ function createMessageSegmentTable() { - - const context = document.createElement("div"); - context.className = "kicker"; -- context.textContent = "Message Row / Segment Table"; -+ context.textContent = "Message / Message Parts"; - const heading = document.createElement("h3"); -- heading.textContent = "Message Segments"; -+ heading.textContent = "Message Parts"; - wrapper.append(context, heading); - - const tableWrapper = document.createElement("div"); - tableWrapper.className = "table-wrapper"; - const table = document.createElement("table"); - table.className = "data-table"; -- table.setAttribute("aria-label", "Selected message segments"); -+ table.setAttribute("aria-label", "Message parts"); - const thead = document.createElement("thead"); - const headerRow = document.createElement("tr"); -- ["Order", "Emotion", "Text", "Status", "Actions"].forEach((label) => { -+ ["Order", "Text", "Emotion", "TTS Profile", "Status", "Actions"].forEach((label) => { - const header = document.createElement("th"); - header.scope = "col"; - header.textContent = label; -@@ -456,7 +508,7 @@ function createMessageSegmentTable() { - - const segments = selectedMessageSegments(); - if (!segments.length && state.editingSegmentKey !== NEW_ROW_KEY) { -- tbody.append(tableMessage(5, "No segments saved for this message.")); -+ tbody.append(tableMessage(6, "No message parts saved for this message.")); + const state = { + applyingItem: false, ++ editingEmotionId: "", ++ editingProfileId: "", ++ profiles: [], + queue: [], ++ selectedProfileId: "", + selectedItemId: "", + sliderOverrides: { pitch: false, rate: false, volume: false }, + voiceOptions: [] +@@ -330,6 +498,477 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech + }; } - segments.forEach((segment, index) => { -@@ -472,15 +524,23 @@ function createMessageSegmentTable() { - moveUp.disabled = index === 0; - moveDown.disabled = index === segments.length - 1; - actions.append(createActionGroup( -- createButton("Edit", "messagesSegmentEdit", segment.key), -+ createButton("Play Part", "messagesSegmentPlay", segment.key), -+ createButton("Edit Part", "messagesSegmentEdit", segment.key), - moveUp, - moveDown, - segment.active ? createButton("Disable", "messagesSegmentDisable", segment.key) : null, - )); -+ const ttsCell = document.createElement("td"); -+ ttsCell.append(createTtsProfileSelect( -+ selectedTtsProfileForSegment(segment.key, segment.messageKey).key, -+ "segmentTtsProfile", -+ segment.key, -+ )); - row.append( - createCell(String(segment.displayOrder)), -- createCell(segment.emotionProfileName || "Unknown"), - createCell(segment.segmentText), -+ createCell(segment.emotionProfileName || "Unknown"), -+ ttsCell, - createCell(statusForActive(segment.active)), - actions, - ); -@@ -495,7 +555,7 @@ function createMessageSegmentTable() { - tableWrapper.append(table); - const actionGroup = document.createElement("div"); - actionGroup.className = "action-group"; -- actionGroup.append(createButton("Add Segment Row", "messagesSegmentAddRow", state.selectedMessageKey)); -+ actionGroup.append(createButton("Add Part", "messagesSegmentAddRow", state.selectedMessageKey)); - wrapper.append(tableWrapper, actionGroup); - return wrapper; - } -@@ -508,22 +568,29 @@ function createSegmentEditRow(segment = null) { - const orderCell = document.createElement("td"); - orderCell.append(createNumberInput(segment?.displayOrder || nextSegmentOrder(), "segmentOrder", { min: 1, step: 1 })); - -+ const textCell = document.createElement("td"); -+ textCell.append(createTextarea(segment?.segmentText || "", "segmentText", 3)); ++ function selectedProfile() { ++ return state.profiles.find((profile) => profile.id === state.selectedProfileId) || null; ++ } ++ ++ function profileInUseByMessageStudio(profile) { ++ return Number(profile?.messageStudioUsageCount || 0) > 0; ++ } ++ ++ function emotionInUseByMessageParts(emotion) { ++ return Number(emotion?.messagePartsUsageCount || 0) > 0; ++ } ++ ++ function createCell(text) { ++ const cell = document.createElement("td"); ++ cell.textContent = text; ++ return cell; ++ } ++ ++ function createButton(label, dataName, value) { ++ const button = document.createElement("button"); ++ button.className = "btn btn--compact"; ++ button.type = "button"; ++ button.dataset[dataName] = value; ++ button.textContent = label; ++ return button; ++ } ++ ++ function createActionGroup(...buttons) { ++ const group = document.createElement("div"); ++ group.className = "action-group action-group--tight"; ++ buttons.filter(Boolean).forEach((button) => group.append(button)); ++ return group; ++ } ++ ++ function tableMessage(colSpan, text) { ++ const row = document.createElement("tr"); ++ const cell = document.createElement("td"); ++ cell.colSpan = colSpan; ++ cell.textContent = text; ++ row.append(cell); ++ return row; ++ } ++ ++ function createTextInput(value, dataName) { ++ const input = document.createElement("input"); ++ input.dataset[dataName] = ""; ++ input.type = "text"; ++ input.value = value || ""; ++ return input; ++ } ++ ++ function createNumberInput(value, dataName, kind) { ++ const input = document.createElement("input"); ++ const range = TEXT_TO_SPEECH_RANGE_DEFAULTS[kind] || TEXT_TO_SPEECH_RANGE_DEFAULTS.rate; ++ input.dataset[dataName] = ""; ++ input.type = "number"; ++ input.min = String(range.min); ++ input.max = String(range.max); ++ input.step = String(range.step); ++ input.value = formatRangeValue(value ?? range.value, kind); ++ return input; ++ } ++ ++ function createCheckbox(checked, dataName) { ++ const label = document.createElement("label"); ++ const input = document.createElement("input"); ++ input.dataset[dataName] = ""; ++ input.type = "checkbox"; ++ input.checked = checked !== false; ++ label.append(input, " Active"); ++ return label; ++ } ++ ++ function createEditorSelect(value, dataName, options, placeholder = "") { ++ const select = document.createElement("select"); ++ select.dataset[dataName] = ""; ++ if (placeholder) { ++ const placeholderOption = document.createElement("option"); ++ placeholderOption.value = ""; ++ placeholderOption.textContent = placeholder; ++ select.append(placeholderOption); ++ } ++ options.forEach((optionValue) => { ++ const option = document.createElement("option"); ++ option.value = String(optionValue.value); ++ option.textContent = optionValue.label; ++ select.append(option); ++ }); ++ select.value = options.some((optionValue) => String(optionValue.value) === String(value)) ? String(value) : String(options[0]?.value || ""); ++ return select; ++ } ++ ++ function voiceSelectOptions() { ++ return state.voiceOptions.length ++ ? state.voiceOptions.map((option) => ({ label: option.label, value: option.value })) ++ : [{ label: "No browser voices available", value: "" }]; ++ } ++ ++ function languageSelectOptions() { ++ const voiceLanguages = textToSpeechLanguageOptionsFromVoices(state.voiceOptions); ++ return voiceLanguages.length ? voiceLanguages : TEXT_TO_SPEECH_LANGUAGE_OPTIONS; ++ } ++ ++ function profileVoiceName(profile) { ++ const match = state.voiceOptions.find((option) => option.value === profile.voice); ++ return match?.name || match?.label || profile.voiceName || "No voice selected"; ++ } ++ ++ function renderProfileCounts() { ++ if (elements.profileCount) elements.profileCount.textContent = String(state.profiles.length); ++ if (elements.profileEmotionCount) { ++ const emotionCount = state.profiles.reduce((total, profile) => total + profile.emotions.length, 0); ++ elements.profileEmotionCount.textContent = String(emotionCount); ++ } ++ } ++ ++ function renderProfileRows() { ++ if (!elements.profileTable) return; ++ elements.profileTable.replaceChildren(); ++ ++ state.profiles.forEach((profile) => { ++ if (state.editingProfileId === profile.id) { ++ elements.profileTable.append(createProfileEditRow(profile)); ++ appendEmotionHost(profile.id); ++ return; ++ } ++ ++ const row = document.createElement("tr"); ++ row.dataset.ttsProfileRow = profile.id; ++ const nameCell = document.createElement("td"); ++ nameCell.dataset.ttsProfileNameCell = profile.id; ++ nameCell.textContent = `${state.selectedProfileId === profile.id ? "v" : ">"} ${profile.name}`; ++ const deleteButton = createButton("Delete", "ttsDeleteProfile", profile.id); ++ if (profileInUseByMessageStudio(profile)) { ++ deleteButton.disabled = true; ++ deleteButton.title = "Delete disabled: profile is in use by Message Studio data."; ++ } ++ const actions = createActionGroup( ++ createButton("Edit Profile", "ttsEditProfile", profile.id), ++ deleteButton, ++ ); ++ row.append( ++ nameCell, ++ createCell(profileVoiceName(profile)), ++ createCell(profile.language), ++ createCell(labelForOption(TTS_PROFILE_GENDER_OPTIONS, profile.gender, "Neutral")), ++ createCell(labelForOption(TEXT_TO_SPEECH_AGE_FILTER_OPTIONS, profile.age, "Any")), ++ createCell(String(profile.emotions.length)), ++ createCell(profile.active ? "Active" : "Inactive"), ++ (() => { ++ const cell = document.createElement("td"); ++ cell.append(actions); ++ return cell; ++ })(), ++ ); ++ elements.profileTable.append(row); ++ appendEmotionHost(profile.id); ++ }); ++ ++ if (state.editingProfileId === NEW_ROW_KEY) { ++ elements.profileTable.append(createProfileEditRow(null)); ++ } ++ ++ if (!state.profiles.length && state.editingProfileId !== NEW_ROW_KEY) { ++ elements.profileTable.append(tableMessage(8, "No TTS profiles yet.")); ++ } ++ renderProfileCounts(); ++ } ++ ++ function createProfileEditRow(profile = null) { ++ const key = profile?.id || NEW_ROW_KEY; ++ const row = document.createElement("tr"); ++ row.dataset.ttsProfileEditor = key; + - const emotionCell = document.createElement("td"); - emotionCell.append(createSelect(segment?.emotionProfileKey || "", "segmentEmotion", selectOptionsWithCurrent(segment?.emotionProfileKey || ""), "Select emotion")); - -- const textCell = document.createElement("td"); -- textCell.append(createTextarea(segment?.segmentText || "", "segmentText", 3)); -+ const ttsCell = document.createElement("td"); -+ ttsCell.append(createTtsProfileSelect( -+ segment ? selectedTtsProfileForSegment(segment.key, segment.messageKey).key : selectedTtsProfileForMessage(state.selectedMessageKey).key, -+ "segmentTtsProfile", -+ key, -+ )); - - const statusCell = document.createElement("td"); - statusCell.append(createCheckbox(segment?.active !== false, "segmentActive")); - - const actions = document.createElement("td"); - actions.append(createActionGroup( -- createButton("Update Row", "messagesSegmentCommit", key), -+ createButton("Save", "messagesSegmentCommit", key), - createButton("Cancel", "messagesSegmentCancel", key), - )); - -- row.append(orderCell, emotionCell, textCell, statusCell, actions); -+ row.append(orderCell, textCell, emotionCell, ttsCell, statusCell, actions); - return row; - } - -@@ -546,7 +613,7 @@ function renderMessageRows() { - } - elements.table.replaceChildren(); - if (!state.messages.length && state.editingMessageKey !== NEW_ROW_KEY) { -- elements.table.append(tableMessage(6, "No message rows saved yet.")); -+ elements.table.append(tableMessage(6, "No messages saved yet. Add a message such as Bat Encounter.")); - return; - } - -@@ -559,17 +626,28 @@ function renderMessageRows() { - - const row = document.createElement("tr"); - row.dataset.messagesRow = message.key; + const nameCell = document.createElement("td"); -+ const isExpanded = state.selectedMessageKey === message.key; -+ nameCell.dataset.messagesNameCell = message.key; -+ nameCell.textContent = `${isExpanded ? "v" : ">"} ${message.name}`; -+ const ttsCell = document.createElement("td"); -+ ttsCell.append(createTtsProfileSelect( -+ selectedTtsProfileForMessage(message.key).key, -+ "messageDefaultTtsProfile", -+ message.key, ++ nameCell.append(createTextInput(profile?.name || "", "ttsProfileName")); ++ const voiceCell = document.createElement("td"); ++ voiceCell.append(createEditorSelect(profile?.voice || "", "ttsProfileVoice", voiceSelectOptions(), "Select voice")); ++ const languageCell = document.createElement("td"); ++ languageCell.append(createEditorSelect(profile?.language || TEXT_TO_SPEECH_DEFAULTS.language, "ttsProfileLanguage", languageSelectOptions())); ++ const genderCell = document.createElement("td"); ++ genderCell.append(createEditorSelect(profile?.gender || "neutral", "ttsProfileGender", TTS_PROFILE_GENDER_OPTIONS)); ++ const ageCell = document.createElement("td"); ++ ageCell.append(createEditorSelect(profile?.age || TEXT_TO_SPEECH_DEFAULTS.voiceAge, "ttsProfileAge", TEXT_TO_SPEECH_AGE_FILTER_OPTIONS)); ++ const emotionCountCell = createCell(profile ? String(profile.emotions.length) : "1"); ++ const statusCell = document.createElement("td"); ++ statusCell.append(createCheckbox(profile?.active !== false, "ttsProfileActive")); ++ const actionsCell = document.createElement("td"); ++ actionsCell.append(createActionGroup( ++ createButton("Save", "ttsCommitProfile", key), ++ createButton("Cancel", "ttsCancelProfile", key), + )); - const actions = document.createElement("td"); - actions.append(createActionGroup( -- createButton("Edit", "messagesEdit", message.key), -+ createButton("Play Message", "messagesPlay", message.key), -+ createButton("Edit Message", "messagesEdit", message.key), - message.active ? createButton("Disable", "messagesDisable", message.key) : null, - )); - row.append( -- createCell(message.name), -- createCell(message.emotionProfileName || "Unknown"), -- createCell(String(messageSegments(message.key).length)), -- createCell("Tags planned"), -+ nameCell, -+ createCell(message.categoryName || "Dialog"), - createCell(statusForActive(message.active)), -+ createCell(String(messageSegments(message.key).length)), -+ ttsCell, - actions, - ); - elements.table.append(row); -@@ -640,63 +718,24 @@ function renderEmotionRows() { - } - } - --function createTtsEditRow(profile = null) { -- const key = profile?.key || NEW_ROW_KEY; -- const row = document.createElement("tr"); -- row.dataset.messagesTtsEditor = key; -- -- const nameCell = document.createElement("td"); -- nameCell.append(createInput(profile?.name || "", "ttsName")); -- const providerCell = document.createElement("td"); -- providerCell.append(createInput(profile?.providerKey || "browser-speech", "ttsProvider")); -- const voiceCell = document.createElement("td"); -- voiceCell.append(createInput(profile?.voiceName || "", "ttsVoice")); -- const languageCell = document.createElement("td"); -- languageCell.append(createInput(profile?.language || "en-US", "ttsLanguage")); -- const statusCell = document.createElement("td"); -- statusCell.append(createCheckbox(profile?.active !== false, "ttsActive")); -- const actions = document.createElement("td"); -- actions.append(createActionGroup( -- createButton("Update Row", "messagesTtsCommit", key), -- createButton("Cancel", "messagesTtsCancel", key), -- )); -- row.append(nameCell, providerCell, voiceCell, languageCell, statusCell, actions); -- return row; --} -- - function renderTtsRows() { - if (!elements.ttsRows) { - return; - } - elements.ttsRows.replaceChildren(); -- state.ttsProfiles.forEach((profile) => { -- if (state.editingTtsKey === profile.key) { -- elements.ttsRows.append(createTtsEditRow(profile)); -- return; -- } -+ activeTtsProfileOptions().forEach((profile) => { - const row = document.createElement("tr"); - row.dataset.messagesTtsRow = profile.key; -- const actions = document.createElement("td"); -- actions.append(createActionGroup( -- createButton("Edit", "messagesTtsEdit", profile.key), -- profile.active ? createButton("Disable", "messagesTtsDisable", profile.key) : null, -- )); - row.append( - createCell(profile.name), - createCell(profile.providerKey), - createCell(profile.voiceName || ""), - createCell(profile.language), - createCell(statusForActive(profile.active)), -- actions, -+ createCell(profile.key === DEFAULT_TTS_PROFILE_KEY ? "Default option" : "Owned by TTS Studio"), - ); - elements.ttsRows.append(row); - }); -- if (state.editingTtsKey === NEW_ROW_KEY) { -- elements.ttsRows.append(createTtsEditRow(null)); -- } -- if (!state.ttsProfiles.length && state.editingTtsKey !== NEW_ROW_KEY) { -- elements.ttsRows.append(tableMessage(6, "No TTS profiles saved yet.")); -- } - } - - function render(persistence = {}) { -@@ -722,7 +761,7 @@ function messageValues(key) { - const details = elements.table?.querySelector(`[data-messages-row-editor-details="${key}"]`); - return { - active: editorChecked(root, "[data-message-active]"), -- emotionProfileKey: editorValue(root, "[data-message-emotion]"), -+ emotionProfileKey: editorValue(details, "[data-message-emotion]"), - messageText: editorValue(details, "[data-message-text]"), - name: editorValue(root, "[data-message-name]"), - notes: editorValue(details, "[data-message-notes]"), -@@ -757,10 +796,10 @@ function segmentValues(key) { - function validateSegment(values) { - const errors = []; - if (!state.selectedMessageKey) { -- errors.push("Select a message row before adding segments."); -+ errors.push("Select a message before adding parts."); - } - if (!values.segmentText.trim()) { -- errors.push("Segment Text is required."); -+ errors.push("Part Text is required."); - } - if (!values.emotionProfileKey) { - errors.push("Emotion Profile is required."); -@@ -795,36 +834,6 @@ function validateEmotion(values) { - return values.name.trim() ? [] : ["Emotion Profile Name is required."]; - } - --function ttsValues(key) { -- const root = elements.ttsRows?.querySelector(`[data-messages-tts-editor="${key}"]`); -- const existing = key === NEW_ROW_KEY ? null : ttsProfileByKey(key); -- return { -- active: editorChecked(root, "[data-tts-active]"), -- description: existing?.description || "", -- language: editorValue(root, "[data-tts-language]"), -- name: editorValue(root, "[data-tts-name]"), -- pitch: existing?.pitch ?? 1, -- providerKey: editorValue(root, "[data-tts-provider]"), -- rate: existing?.rate ?? 1, -- voiceName: editorValue(root, "[data-tts-voice]"), -- volume: existing?.volume ?? 1, -- }; --} -- --function validateTts(values) { -- const errors = []; -- if (!values.name.trim()) { -- errors.push("TTS Profile Name is required."); -- } -- if (!values.providerKey.trim()) { -- errors.push("Provider is required."); -- } -- if (!values.language.trim()) { -- errors.push("Language is required."); -- } -- return errors; --} -- - async function loadAll() { - const emotionPayload = listEmotionProfiles(); - const ttsPayload = listTtsProfiles(); -@@ -893,10 +902,10 @@ async function commitSegment(key) { - state.editingSegmentKey = ""; - state.selectedSegmentKey = result.segment.key; - await reloadAfterChange(state.selectedMessageKey, result.segment.key); -- setText(elements.log, `Updated segment row ${result.segment.displayOrder}.`); -+ setText(elements.log, `Updated message part ${result.segment.displayOrder}.`); - } catch (error) { - showValidation([error instanceof Error ? error.message : String(error || "Segment row update failed.")]); -- setText(elements.log, "Segment row update failed."); -+ setText(elements.log, "Message part update failed."); - } - } - -@@ -922,28 +931,6 @@ async function commitEmotion(key) { - } - } - --async function commitTts(key) { -- const values = ttsValues(key); -- const errors = validateTts(values); -- if (errors.length) { -- showValidation(errors); -- setText(elements.log, "TTS profile update blocked by validation."); -- return; -- } -- clearValidation(); -- try { -- const result = key === NEW_ROW_KEY -- ? createTtsProfile(values) -- : updateTtsProfile(key, values); -- state.editingTtsKey = ""; -- await reloadAfterChange(); -- setText(elements.log, `Updated TTS profile ${result.ttsProfile.name}.`); -- } catch (error) { -- showValidation([error instanceof Error ? error.message : String(error || "TTS profile update failed.")]); -- setText(elements.log, "TTS profile update failed."); -- } --} -- - async function disableMessage(key) { - const message = state.messages.find((candidate) => candidate.key === key); - if (!message) { -@@ -981,10 +968,10 @@ async function disableSegment(key) { - }); - state.selectedSegmentKey = result.segment.key; - await reloadAfterChange(segment.messageKey, result.segment.key); -- setText(elements.log, `Disabled segment row ${result.segment.displayOrder}.`); -+ setText(elements.log, `Disabled message part ${result.segment.displayOrder}.`); - } catch (error) { - showValidation([error instanceof Error ? error.message : String(error || "Segment status update failed.")]); -- setText(elements.log, "Segment status update failed."); -+ setText(elements.log, "Message part status update failed."); - } - } - -@@ -996,25 +983,110 @@ function testSelectedSpeech() { - return; - } - const service = selectedTtsService(); -- const profile = selectedTtsProfile(); - const target = selectedSpeechTarget(); -- const emotion = target.emotionProfile; -- const result = ttsServiceRegistry.speak(service.key, { -+ const result = speakTarget(service, target, target.profile || selectedTtsProfile()); -+ if (!result.ok) { -+ setText(elements.previewStatus, result.message || "Speech test failed."); -+ setText(elements.log, result.message || "Speech test failed."); -+ return; ++ ++ row.append(nameCell, voiceCell, languageCell, genderCell, ageCell, emotionCountCell, statusCell, actionsCell); ++ return row; + } -+ const message = `Speech test started for ${target.label} using ${service.name}.`; -+ setText(elements.previewStatus, message); -+ setText(elements.log, message); -+} + -+function visiblePlaybackError(message) { -+ const safeMessage = message || "Message Studio playback failed. Check the selected message, part, and TTS profile."; -+ showValidation([safeMessage]); -+ setText(elements.previewStatus, safeMessage); -+ setText(elements.log, safeMessage); -+ return { message: safeMessage, ok: false }; -+} ++ function appendEmotionHost(profileId) { ++ if (state.selectedProfileId !== profileId) return; ++ const hostRow = document.createElement("tr"); ++ hostRow.dataset.ttsEmotionHost = profileId; ++ const cell = document.createElement("td"); ++ cell.colSpan = 8; ++ cell.append(createEmotionTable(profileId)); ++ hostRow.append(cell); ++ elements.profileTable.append(hostRow); ++ } + -+function playbackService() { -+ return selectedTtsService() || state.ttsServices.find((service) => service.available) || null; -+} ++ function createEmotionTable(profileId) { ++ const profile = state.profiles.find((candidate) => candidate.id === profileId); ++ const wrapper = document.createElement("div"); ++ wrapper.className = "content-stack"; ++ const context = document.createElement("div"); ++ context.className = "kicker"; ++ context.textContent = "TTS Profile / Emotion Settings"; ++ const heading = document.createElement("h3"); ++ heading.textContent = "Emotion Settings"; ++ wrapper.append(context, heading); + -+function speakTarget(service, target, profile) { -+ if (!service) { -+ return visiblePlaybackError("Audio engine is unavailable. Use a browser with SpeechSynthesis support and reload Message Studio."); -+ } -+ if (!service.available) { -+ return visiblePlaybackError(service.unavailableMessage || "Audio engine is unavailable for Message Studio playback."); ++ const tableWrapper = document.createElement("div"); ++ tableWrapper.className = "table-wrapper"; ++ const table = document.createElement("table"); ++ table.className = "data-table"; ++ table.setAttribute("aria-label", "Emotion Settings"); ++ const thead = document.createElement("thead"); ++ const headerRow = document.createElement("tr"); ++ ["Emotion", "Pitch", "Rate", "Volume", "SSML-like Preset", "Status", "Actions"].forEach((label) => { ++ const header = document.createElement("th"); ++ header.scope = "col"; ++ header.textContent = label; ++ headerRow.append(header); ++ }); ++ thead.append(headerRow); ++ const tbody = document.createElement("tbody"); ++ tbody.dataset.ttsEmotionTable = profileId; ++ ++ if (!profile?.emotions.length && state.editingEmotionId !== NEW_ROW_KEY) { ++ tbody.append(tableMessage(7, "No emotion settings for this profile.")); ++ } ++ ++ profile?.emotions.forEach((emotion) => { ++ if (state.editingEmotionId === emotion.id) { ++ tbody.append(createEmotionEditRow(emotion)); ++ return; ++ } ++ const row = document.createElement("tr"); ++ row.dataset.ttsEmotionRow = emotion.id; ++ const deleteButton = createButton("Delete", "ttsDeleteEmotion", emotion.id); ++ if (emotionInUseByMessageParts(emotion)) { ++ deleteButton.disabled = true; ++ deleteButton.title = "Delete disabled: emotion is in use by Message Parts."; ++ } ++ const actions = createActionGroup( ++ createButton("Edit Emotion", "ttsEditEmotion", emotion.id), ++ deleteButton, ++ ); ++ const actionsCell = document.createElement("td"); ++ actionsCell.append(actions); ++ row.append( ++ createCell(emotion.emotionLabel), ++ createCell(String(emotion.pitch)), ++ createCell(String(emotion.rate)), ++ createCell(String(emotion.volume)), ++ createCell(labelForOption(TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS, emotion.ssmlLikePreset, "Normal")), ++ createCell(emotion.active ? "Active" : "Inactive"), ++ actionsCell, ++ ); ++ tbody.append(row); ++ }); ++ ++ if (state.editingEmotionId === NEW_ROW_KEY) { ++ tbody.append(createEmotionEditRow(null)); ++ } ++ ++ table.append(thead, tbody); ++ tableWrapper.append(table); ++ const actionGroup = document.createElement("div"); ++ actionGroup.className = "action-group"; ++ actionGroup.append(createButton("Add Emotion", "ttsAddEmotion", profileId)); ++ wrapper.append(tableWrapper, actionGroup); ++ return wrapper; + } -+ if (!target) { -+ return visiblePlaybackError("Select a message or message part before playback."); ++ ++ function createEmotionEditRow(emotion = null) { ++ const key = emotion?.id || NEW_ROW_KEY; ++ const row = document.createElement("tr"); ++ row.dataset.ttsEmotionEditor = key; ++ const emotionCell = document.createElement("td"); ++ emotionCell.append(createEditorSelect(emotion?.emotion || "neutral", "ttsEmotionName", TTS_PROFILE_EMOTION_OPTIONS)); ++ const pitchCell = document.createElement("td"); ++ pitchCell.append(createNumberInput(emotion?.pitch ?? 1, "ttsEmotionPitch", "pitch")); ++ const rateCell = document.createElement("td"); ++ rateCell.append(createNumberInput(emotion?.rate ?? 1, "ttsEmotionRate", "rate")); ++ const volumeCell = document.createElement("td"); ++ volumeCell.append(createNumberInput(emotion?.volume ?? 1, "ttsEmotionVolume", "volume")); ++ const presetCell = document.createElement("td"); ++ presetCell.append(createEditorSelect(emotion?.ssmlLikePreset || "normal", "ttsEmotionSsmlPreset", TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS)); ++ const statusCell = document.createElement("td"); ++ statusCell.append(createCheckbox(emotion?.active !== false, "ttsEmotionActive")); ++ const actionsCell = document.createElement("td"); ++ actionsCell.append(createActionGroup( ++ createButton("Save", "ttsCommitEmotion", key), ++ createButton("Cancel", "ttsCancelEmotion", key), ++ )); ++ row.append(emotionCell, pitchCell, rateCell, volumeCell, presetCell, statusCell, actionsCell); ++ return row; + } -+ if (!profile) { -+ return visiblePlaybackError("Select a TTS profile before playback."); ++ ++ function editorValue(rootNode, selector) { ++ return rootNode?.querySelector(selector)?.value || ""; + } -+ if (!target.emotionProfile) { -+ return visiblePlaybackError("Selected message or part needs an emotion profile before playback."); ++ ++ function editorChecked(rootNode, selector) { ++ return rootNode?.querySelector(selector)?.checked !== false; + } -+ if (!String(target.text || "").trim()) { -+ return visiblePlaybackError("Selected message or part needs text before playback."); ++ ++ function profileValues(key) { ++ const row = elements.profileTable?.querySelector(`[data-tts-profile-editor="${key}"]`); ++ const voiceValue = editorValue(row, "[data-tts-profile-voice]"); ++ const selectedVoice = state.voiceOptions.find((option) => option.value === voiceValue); ++ return createTextToSpeechProfile({ ++ active: editorChecked(row, "[data-tts-profile-active]"), ++ age: editorValue(row, "[data-tts-profile-age]"), ++ emotions: key === NEW_ROW_KEY ? [createTextToSpeechProfileEmotion()] : state.profiles.find((profile) => profile.id === key)?.emotions || [], ++ gender: editorValue(row, "[data-tts-profile-gender]"), ++ id: key === NEW_ROW_KEY ? "" : key, ++ language: editorValue(row, "[data-tts-profile-language]"), ++ name: editorValue(row, "[data-tts-profile-name]"), ++ voice: voiceValue, ++ voiceName: selectedVoice?.name || selectedVoice?.label || voiceValue, ++ }); + } -+ return ttsServiceRegistry.speak(service.key, { - language: profile.language, -- pitch: emotion.pitch, -- rate: emotion.rate, -+ pitch: target.emotionProfile.pitch ?? profile.pitch ?? 1, -+ rate: target.emotionProfile.rate ?? profile.rate ?? 1, - speechItemId: target.id, - speechItemName: target.name, - text: target.text, - voice: profile.voiceName, -- volume: emotion.volume, -+ volume: target.emotionProfile.volume ?? profile.volume ?? 1, - }); -+} + -+function segmentPlaybackTarget(segment) { -+ if (!segment) { -+ return null; ++ function validateProfile(profile) { ++ const errors = []; ++ if (!profile.name.trim()) errors.push("Profile Name is required."); ++ if (!profile.language.trim()) errors.push("Language is required."); ++ if (state.profiles.some((candidate) => candidate.id !== profile.id && candidate.name.toLowerCase() === profile.name.toLowerCase())) { ++ errors.push("Profile Name must be unique."); ++ } ++ return errors; + } -+ return { -+ emotionProfile: emotionProfileByKey(segment.emotionProfileKey), -+ id: segment.key, -+ label: `Part ${segment.displayOrder}`, -+ name: `${segment.messageName || "Message"} part ${segment.displayOrder}`, -+ profile: selectedTtsProfileForSegment(segment.key, segment.messageKey), -+ text: segment.segmentText, -+ type: "part", -+ }; -+} + -+function playPart(key) { -+ const segment = state.segments.find((candidate) => candidate.key === key); -+ const target = segmentPlaybackTarget(segment); -+ const result = speakTarget(playbackService(), target, target?.profile); - if (!result.ok) { -- setText(elements.previewStatus, result.message || "Speech test failed."); -- setText(elements.log, result.message || "Speech test failed."); - return; - } -- const message = `Speech test started for ${target.label} using ${service.name}.`; -+ clearValidation(); -+ const message = `Play Part queued ${target.label} using ${target.profile.name}.`; -+ setText(elements.previewStatus, message); -+ setText(elements.log, message); -+} ++ function emotionValues(key) { ++ const row = elements.profileTable?.querySelector(`[data-tts-emotion-editor="${key}"]`); ++ return createTextToSpeechProfileEmotion({ ++ active: editorChecked(row, "[data-tts-emotion-active]"), ++ emotion: editorValue(row, "[data-tts-emotion-name]"), ++ id: key === NEW_ROW_KEY ? "" : key, ++ pitch: editorValue(row, "[data-tts-emotion-pitch]"), ++ rate: editorValue(row, "[data-tts-emotion-rate]"), ++ ssmlLikePreset: editorValue(row, "[data-tts-emotion-ssml-preset]"), ++ volume: editorValue(row, "[data-tts-emotion-volume]"), ++ }); ++ } + -+function playMessage(key) { -+ const messageRecord = state.messages.find((candidate) => candidate.key === key); -+ if (!messageRecord) { -+ visiblePlaybackError("Choose an existing message before playback."); -+ return; ++ function validateEmotion(emotion, existingId = "") { ++ const errors = []; ++ if (!state.selectedProfileId) errors.push("Select a TTS Profile before adding Emotion Settings."); ++ if (!emotion.emotion) errors.push("Emotion is required."); ++ const profile = selectedProfile(); ++ if (profile?.emotions.some((candidate) => candidate.id !== existingId && candidate.emotion === emotion.emotion)) { ++ errors.push("Emotion must be unique within the selected TTS Profile."); ++ } ++ return errors; + } -+ const parts = messageSegments(messageRecord.key).filter((segment) => segment.active); -+ if (!parts.length) { -+ visiblePlaybackError("Add at least one active Message Part before playing this message."); -+ return; ++ ++ function addProfile() { ++ state.editingProfileId = NEW_ROW_KEY; ++ state.editingEmotionId = ""; ++ state.selectedProfileId = ""; ++ renderProfileRows(); ++ writeStatus("Ready to add a TTS profile."); + } -+ const service = playbackService(); -+ for (const part of parts) { -+ const target = segmentPlaybackTarget(part); -+ const result = speakTarget(service, target, target?.profile || selectedTtsProfileForMessage(messageRecord.key)); -+ if (!result.ok) { ++ ++ function commitProfile(key) { ++ const profile = profileValues(key); ++ const errors = validateProfile(profile); ++ if (errors.length) { ++ writeStatus(`TTS Profile save blocked: ${errors.join(" ")}`, "FAIL"); + return; + } -+ } -+ clearValidation(); -+ const message = `Play Message queued ${parts.length} parts for ${messageRecord.name}.`; - setText(elements.previewStatus, message); - setText(elements.log, message); - } -@@ -1043,31 +1115,6 @@ async function disableEmotion(key) { - } - } - --async function disableTts(key) { -- const profile = ttsProfileByKey(key); -- if (!profile) { -- return; -- } -- try { -- const result = updateTtsProfile(key, { -- active: false, -- description: profile.description, -- language: profile.language, -- name: profile.name, -- pitch: profile.pitch, -- providerKey: profile.providerKey, -- rate: profile.rate, -- voiceName: profile.voiceName, -- volume: profile.volume, -- }); -- await reloadAfterChange(); -- setText(elements.log, `Disabled TTS profile ${result.ttsProfile.name}.`); -- } catch (error) { -- showValidation([error instanceof Error ? error.message : String(error || "TTS profile status update failed.")]); -- setText(elements.log, "TTS profile status update failed."); -- } --} -- - async function moveSegment(key, direction) { - const segments = selectedMessageSegments(); - const currentIndex = segments.findIndex((segment) => segment.key === key); -@@ -1092,10 +1139,10 @@ async function moveSegment(key, direction) { - segmentText: target.segmentText, - }); - await reloadAfterChange(state.selectedMessageKey, current.key); -- setText(elements.log, "Segment order updated."); -+ setText(elements.log, "Message part order updated."); - } catch (error) { - showValidation([error instanceof Error ? error.message : String(error || "Segment reorder failed.")]); -- setText(elements.log, "Segment reorder failed."); -+ setText(elements.log, "Message part reorder failed."); - } - } - -@@ -1114,13 +1161,6 @@ elements.emotionAddRow?.addEventListener("click", () => { - setText(elements.log, "Ready to add an emotion profile."); - }); - --elements.ttsAddRow?.addEventListener("click", () => { -- clearValidation(); -- state.editingTtsKey = NEW_ROW_KEY; -- renderTtsRows(); -- setText(elements.log, "Ready to add a TTS profile."); --}); -- - elements.previewTtsProfile?.addEventListener("change", () => { - renderSpeechTestControls(); - }); -@@ -1137,14 +1177,40 @@ ttsServiceRegistry.onServicesChanged(() => { - renderSpeechTestControls(); - }); - -+elements.table?.addEventListener("change", (event) => { -+ const messageSelect = event.target.closest("[data-message-default-tts-profile]"); -+ const segmentSelect = event.target.closest("[data-segment-tts-profile]"); -+ if (messageSelect) { -+ const key = messageSelect.dataset.messagesTtsIdentity || state.selectedMessageKey; -+ if (key && key !== NEW_ROW_KEY) { -+ state.messageTtsProfileKeys.set(key, messageSelect.value || defaultTtsProfileKey()); ++ if (key === NEW_ROW_KEY) { ++ state.profiles.push(profile); ++ } else { ++ const index = state.profiles.findIndex((candidate) => candidate.id === key); ++ const existing = state.profiles[index]; ++ state.profiles[index] = { ++ ...profile, ++ emotions: existing?.emotions || profile.emotions, ++ messageStudioUsageCount: existing?.messageStudioUsageCount || 0, ++ }; + } -+ renderSpeechTestControls(); -+ setText(elements.log, "Default TTS profile selected for this message playback session."); ++ state.selectedProfileId = profile.id; ++ state.editingProfileId = ""; ++ renderProfileRows(); ++ renderOutputSummary(); ++ writeStatus(`Saved TTS profile: ${profile.name}.`); + } -+ if (segmentSelect) { -+ const key = segmentSelect.dataset.messagesTtsIdentity || state.selectedSegmentKey; -+ if (key && key !== NEW_ROW_KEY) { -+ state.segmentTtsProfileKeys.set(key, segmentSelect.value || defaultTtsProfileKey()); ++ ++ function deleteProfile(key) { ++ const profile = state.profiles.find((candidate) => candidate.id === key); ++ if (!profile) return; ++ if (profileInUseByMessageStudio(profile)) { ++ writeStatus(`Delete profile disabled: ${profile.name} is in use by Message Studio data.`, "FAIL"); ++ return; + } -+ renderSpeechTestControls(); -+ setText(elements.log, "TTS profile selected for this part playback session."); ++ state.profiles = state.profiles.filter((candidate) => candidate.id !== key); ++ if (state.selectedProfileId === key) state.selectedProfileId = state.profiles[0]?.id || ""; ++ renderProfileRows(); ++ renderOutputSummary(); ++ writeStatus(`Deleted TTS profile: ${profile.name}.`); + } -+}); + - elements.table?.addEventListener("click", async (event) => { -+ if (event.target.closest("[data-message-default-tts-profile], [data-segment-tts-profile]")) { -+ return; ++ function addEmotion(profileId) { ++ state.selectedProfileId = profileId; ++ state.editingProfileId = ""; ++ state.editingEmotionId = NEW_ROW_KEY; ++ renderProfileRows(); ++ writeStatus("Ready to add an emotion setting."); + } - const row = event.target.closest("[data-messages-row]"); - const segmentRow = event.target.closest("[data-messages-segment-row]"); -+ const playButton = event.target.closest("[data-messages-play]"); - const editButton = event.target.closest("[data-messages-edit]"); - const commitButton = event.target.closest("[data-messages-commit]"); - const cancelButton = event.target.closest("[data-messages-cancel]"); - const disableButton = event.target.closest("[data-messages-disable]"); - const segmentAddButton = event.target.closest("[data-messages-segment-add-row]"); -+ const segmentPlayButton = event.target.closest("[data-messages-segment-play]"); - const segmentEditButton = event.target.closest("[data-messages-segment-edit]"); - const segmentCommitButton = event.target.closest("[data-messages-segment-commit]"); - const segmentCancelButton = event.target.closest("[data-messages-segment-cancel]"); -@@ -1152,6 +1218,14 @@ elements.table?.addEventListener("click", async (event) => { - const moveUpButton = event.target.closest("[data-messages-segment-move-up]"); - const moveDownButton = event.target.closest("[data-messages-segment-move-down]"); - -+ if (playButton) { -+ state.selectedMessageKey = playButton.dataset.messagesPlay; -+ state.selectedSegmentKey = ""; -+ state.editingSegmentKey = ""; -+ render(); -+ playMessage(playButton.dataset.messagesPlay); -+ return; ++ ++ function commitEmotion(key) { ++ const emotion = emotionValues(key); ++ const errors = validateEmotion(emotion, key === NEW_ROW_KEY ? "" : key); ++ if (errors.length) { ++ writeStatus(`Emotion setting save blocked: ${errors.join(" ")}`, "FAIL"); ++ return; ++ } ++ const profile = selectedProfile(); ++ if (!profile) return; ++ if (key === NEW_ROW_KEY) { ++ profile.emotions.push(emotion); ++ } else { ++ const index = profile.emotions.findIndex((candidate) => candidate.id === key); ++ const existing = profile.emotions[index]; ++ profile.emotions[index] = { ++ ...emotion, ++ messagePartsUsageCount: existing?.messagePartsUsageCount || 0, ++ }; ++ } ++ state.editingEmotionId = ""; ++ renderProfileRows(); ++ renderOutputSummary(); ++ writeStatus(`Saved emotion setting: ${emotion.emotionLabel}.`); + } - if (editButton) { - clearValidation(); - state.editingMessageKey = editButton.dataset.messagesEdit; -@@ -1180,7 +1254,17 @@ elements.table?.addEventListener("click", async (event) => { - clearValidation(); - state.editingSegmentKey = NEW_ROW_KEY; - render(); -- setText(elements.log, "Ready to add a segment row."); -+ setText(elements.log, "Ready to add a message part."); -+ return; ++ ++ function deleteEmotion(key) { ++ const profile = selectedProfile(); ++ const emotion = profile?.emotions.find((candidate) => candidate.id === key); ++ if (!profile || !emotion) return; ++ if (emotionInUseByMessageParts(emotion)) { ++ writeStatus(`Delete emotion disabled: ${emotion.emotionLabel} is in use by Message Parts.`, "FAIL"); ++ return; ++ } ++ profile.emotions = profile.emotions.filter((candidate) => candidate.id !== key); ++ renderProfileRows(); ++ renderOutputSummary(); ++ writeStatus(`Deleted emotion setting: ${emotion.emotionLabel}.`); + } -+ if (segmentPlayButton) { -+ const segment = state.segments.find((candidate) => candidate.key === segmentPlayButton.dataset.messagesSegmentPlay); -+ if (segment) { -+ state.selectedMessageKey = segment.messageKey; -+ state.selectedSegmentKey = segment.key; -+ render(); ++ ++ function selectProfile(profileId) { ++ if (!state.profiles.some((profile) => profile.id === profileId)) return; ++ state.selectedProfileId = state.selectedProfileId === profileId ? "" : profileId; ++ state.editingEmotionId = ""; ++ renderProfileRows(); ++ if (state.selectedProfileId) { ++ writeStatus(`Opened Emotion Settings for ${selectedProfile()?.name}.`); + } -+ playPart(segmentPlayButton.dataset.messagesSegmentPlay); - return; - } - if (segmentEditButton) { -@@ -1188,7 +1272,7 @@ elements.table?.addEventListener("click", async (event) => { - state.editingSegmentKey = segmentEditButton.dataset.messagesSegmentEdit; - state.selectedSegmentKey = segmentEditButton.dataset.messagesSegmentEdit; - render(); -- setText(elements.log, "Segment row opened inline."); -+ setText(elements.log, "Message part opened inline."); - return; ++ } ++ + function itemFromControls(overrides = {}) { + const currentItem = selectedItem(); + return createTextToSpeechQueueItem({ +@@ -379,7 +1018,12 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech + + function renderOutputSummary() { + if (elements.outputSummary) { +- elements.outputSummary.textContent = JSON.stringify(state.queue, null, 2); ++ elements.outputSummary.textContent = JSON.stringify({ ++ contractVersion: TTS_PROFILE_CONTRACT_VERSION, ++ messageStudioOptions: createMessageStudioTtsProfileOptions(state.profiles), ++ profiles: state.profiles, ++ queue: state.queue ++ }, null, 2); + } + setTextContent(root, "[data-tts-text-count]", String(String(elements.text?.value || "").length)); } - if (segmentCommitButton) { -@@ -1199,7 +1283,7 @@ elements.table?.addEventListener("click", async (event) => { - state.editingSegmentKey = ""; - clearValidation(); - render(); -- setText(elements.log, "Segment row edit canceled."); -+ setText(elements.log, "Message part edit canceled."); - return; +@@ -556,6 +1200,16 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech + return result; } - if (segmentDisableButton) { -@@ -1253,34 +1337,6 @@ elements.emotionRows?.addEventListener("click", async (event) => { + ++ function ensureDefaultProfiles() { ++ if (state.profiles.length) { ++ return; ++ } ++ state.profiles = createDefaultTextToSpeechProfiles(state.voiceOptions); ++ state.selectedProfileId = ""; ++ renderProfileRows(); ++ renderOutputSummary(); ++ } ++ + function refreshActionState() { + const hasText = Boolean(String(elements.text?.value || "").trim()); + const hasVoice = Boolean(elements.voice?.value); +@@ -755,6 +1409,69 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech } - }); --elements.ttsRows?.addEventListener("click", async (event) => { -- const editButton = event.target.closest("[data-messages-tts-edit]"); -- const commitButton = event.target.closest("[data-messages-tts-commit]"); -- const cancelButton = event.target.closest("[data-messages-tts-cancel]"); -- const disableButton = event.target.closest("[data-messages-tts-disable]"); -- if (editButton) { -- clearValidation(); -- state.editingTtsKey = editButton.dataset.messagesTtsEdit; -- renderTtsRows(); -- setText(elements.log, "TTS profile opened inline."); -- return; -- } -- if (commitButton) { -- await commitTts(commitButton.dataset.messagesTtsCommit); -- return; -- } -- if (cancelButton) { -- state.editingTtsKey = ""; -- clearValidation(); -- renderTtsRows(); -- setText(elements.log, "TTS profile edit canceled."); -- return; -- } -- if (disableButton) { -- await disableTts(disableButton.dataset.messagesTtsDisable); -- } --}); -- - try { - await loadAll(); - } catch (error) { + function mountEvents() { ++ elements.addProfile?.addEventListener("click", addProfile); ++ elements.profileTable?.addEventListener("click", (event) => { ++ const addEmotionButton = event.target.closest("[data-tts-add-emotion]"); ++ const commitEmotionButton = event.target.closest("[data-tts-commit-emotion]"); ++ const cancelEmotionButton = event.target.closest("[data-tts-cancel-emotion]"); ++ const deleteEmotionButton = event.target.closest("[data-tts-delete-emotion]"); ++ const deleteProfileButton = event.target.closest("[data-tts-delete-profile]"); ++ const editEmotionButton = event.target.closest("[data-tts-edit-emotion]"); ++ const editProfileButton = event.target.closest("[data-tts-edit-profile]"); ++ const commitProfileButton = event.target.closest("[data-tts-commit-profile]"); ++ const cancelProfileButton = event.target.closest("[data-tts-cancel-profile]"); ++ const profileRow = event.target.closest("[data-tts-profile-row]"); ++ ++ if (commitProfileButton) { ++ commitProfile(commitProfileButton.dataset.ttsCommitProfile); ++ return; ++ } ++ if (cancelProfileButton) { ++ state.editingProfileId = ""; ++ renderProfileRows(); ++ writeStatus("TTS profile edit canceled."); ++ return; ++ } ++ if (editProfileButton) { ++ state.editingProfileId = editProfileButton.dataset.ttsEditProfile; ++ state.selectedProfileId = editProfileButton.dataset.ttsEditProfile; ++ state.editingEmotionId = ""; ++ renderProfileRows(); ++ writeStatus("TTS profile opened inline."); ++ return; ++ } ++ if (deleteProfileButton) { ++ deleteProfile(deleteProfileButton.dataset.ttsDeleteProfile); ++ return; ++ } ++ if (addEmotionButton) { ++ addEmotion(addEmotionButton.dataset.ttsAddEmotion); ++ return; ++ } ++ if (commitEmotionButton) { ++ commitEmotion(commitEmotionButton.dataset.ttsCommitEmotion); ++ return; ++ } ++ if (cancelEmotionButton) { ++ state.editingEmotionId = ""; ++ renderProfileRows(); ++ writeStatus("Emotion setting edit canceled."); ++ return; ++ } ++ if (editEmotionButton) { ++ state.editingEmotionId = editEmotionButton.dataset.ttsEditEmotion; ++ renderProfileRows(); ++ writeStatus("Emotion setting opened inline."); ++ return; ++ } ++ if (deleteEmotionButton) { ++ deleteEmotion(deleteEmotionButton.dataset.ttsDeleteEmotion); ++ return; ++ } ++ if (profileRow) { ++ selectProfile(profileRow.dataset.ttsProfileRow); ++ } ++ }); + elements.queueList?.addEventListener("click", (event) => { + const itemButton = event.target.closest("[data-tts-queue-item]"); + if (itemButton) selectItem(itemButton.dataset.ttsQueueItem || ""); +@@ -849,14 +1566,18 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech + mountEvents(); + if (!engine.isSupported()) { + await loadQueue(); ++ ensureDefaultProfiles(); + markUnavailable(); + return; + } + setTextContent(root, "[data-tts-engine-label]", "Ready"); + setTextContent(root, "[data-tts-engine-status]", "Browser SpeechSynthesis is available for local preview."); + refreshVoices(); ++ ensureDefaultProfiles(); + engine.onVoicesChanged(() => { + refreshVoices(); ++ renderProfileRows(); ++ renderOutputSummary(); + writeStatus(`${TEXT_TO_SPEECH_DISPLAY_NAME} voices updated from browser SpeechSynthesis.`); + }); + await loadQueue(); +@@ -883,9 +1604,14 @@ export { + TTS_LANGUAGES, + TTS_MESSAGE_STATUSES, + TTS_OWNERSHIP, ++ TTS_PROFILE_CONTRACT_VERSION, + TTS_PROVIDER_ADAPTER_PLAN, + createEmotionProfile, ++ createDefaultTextToSpeechProfiles, ++ createMessageStudioTtsProfileOptions, + createSpeechPreviewRequest, ++ createTextToSpeechProfile, ++ createTextToSpeechProfileEmotion, + createTtsMessage, + createVoiceProfile, + initializeTextToSpeechTool, +diff --git a/docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md b/docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md +new file mode 100644 +index 000000000..fedefd391 +--- /dev/null ++++ b/docs_build/dev/reports/PR_26171_067-instruction-compliance-checklist.md +@@ -0,0 +1,31 @@ ++# PR_26171_067 Instruction Compliance Checklist ++ ++## Required Reads ++ ++- PASS: Read `docs_build/dev/PROJECT_INSTRUCTIONS.md` before implementation. ++- PASS: Read `docs_build/dev/PROJECT_MULTI_PC.txt` before implementation. ++- PASS: Read repository `AGENTS.md` instructions from the active workspace context. ++- PASS: Read relevant target files before editing. ++ ++## Gate Checks ++ ++- PASS: Started from `main`. ++- PASS: Pulled latest `origin/main`. ++- PASS: Repo was clean before branch creation. ++- PASS: Created scoped branch `pr/26171-067-tts-profile-emotion-table-foundation`. ++- PASS: PR number `067` is odd and assigned to Laptop / Environment 2. ++- PASS: TTS Studio is within Laptop ownership. ++- PASS: Active path is `toolbox/text-to-speech/`. ++- PASS: No wrong `tools/text2speech/` path was created. ++- PASS: No database changes were made. ++- PASS: No placeholder-only provider blocking behavior was introduced. ++ ++## Required Artifacts ++ ++- PASS: PR-specific report created. ++- PASS: Parent-child table checklist created. ++- PASS: Message/TTS contract checklist created. ++- PASS: Validation report created. ++- PASS: Manual validation notes created. ++- PASS: `codex_review.diff` and `codex_changed_files.txt` will be generated from the final scoped diff. ++- PASS: Repo-structured delta ZIP will be created under `tmp/`. +diff --git a/docs_build/dev/reports/PR_26171_067-manual-validation-notes.md b/docs_build/dev/reports/PR_26171_067-manual-validation-notes.md +new file mode 100644 +index 000000000..712c083b1 +--- /dev/null ++++ b/docs_build/dev/reports/PR_26171_067-manual-validation-notes.md +@@ -0,0 +1,17 @@ ++# PR_26171_067 Manual Validation Notes ++ ++## Notes ++ ++- Verified TTS Studio keeps the active path `toolbox/text-to-speech/`. ++- Verified the page uses Theme V2 classes and external JavaScript only. ++- Verified the profile table can open child Emotion Settings by clicking the profile row. ++- Verified `Default Balanced Profile` delete is disabled because it is marked in use by Message Studio data. ++- Verified default `Neutral` emotion delete is disabled because it is marked in use by Message Parts. ++- Verified Add Profile, Edit Profile, Add Emotion, and Edit Emotion inline rows through targeted Playwright validation. ++- Verified Message Studio remains separate and its existing TTS dropdown smoke path still passes. ++- Verified no `tools/text2speech/` path was created. ++- Verified no database files were changed. ++ ++## Follow Up ++ ++- Future persistence can connect TTS Studio profile authoring to the existing Local API profile contract once that API ownership is assigned. +diff --git a/docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md b/docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md +new file mode 100644 +index 000000000..bf2819ba1 +--- /dev/null ++++ b/docs_build/dev/reports/PR_26171_067-message-tts-contract-checklist.md +@@ -0,0 +1,26 @@ ++# PR_26171_067 Message TTS Contract Checklist ++ ++## Ownership ++ ++- PASS: Message Studio owns message text and ordered message parts. ++- PASS: TTS Studio owns reusable TTS Profiles and per-profile Emotion Settings. ++- PASS: `src/engine/audio/` remains the playback owner. ++- PASS: Audio playback results remain owned by the audio engine flow. ++- PASS: Message Studio and TTS Studio are not merged into one tool. ++ ++## Contract Readiness ++ ++- PASS: TTS Studio exposes `TTS_PROFILE_CONTRACT_VERSION` with value `tts-profile-emotion-v1`. ++- PASS: TTS Studio exports `createMessageStudioTtsProfileOptions`. ++- PASS: Exported profile options include stable keys, active state, display name, language, provider key, voice name, and active emotion settings. ++- PASS: Emotion settings include emotion key, display label, pitch, rate, volume, and SSML-like preset. ++- PASS: The output summary shows the contract version and Message Studio compatible profile options for local diagnostics. ++- PASS: Existing Message Studio dropdown smoke validation still passes. ++ ++## Boundaries ++ ++- PASS: No database changes were introduced. ++- PASS: No future provider behavior was hardcoded. ++- PASS: No browser-owned product data was introduced as source of truth. ++- PASS: Default profile data is limited to a local down-the-middle fallback until the API/data contract exists. ++- PASS: Existing Message Studio Local API profile shape remains untouched. +diff --git a/docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md b/docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md +new file mode 100644 +index 000000000..065b3999d +--- /dev/null ++++ b/docs_build/dev/reports/PR_26171_067-parent-child-table-checklist.md +@@ -0,0 +1,38 @@ ++# PR_26171_067 Parent Child Table Checklist ++ ++## Parent Table ++ ++- PASS: Parent table label is `TTS Profiles`. ++- PASS: Parent table lives in `toolbox/text-to-speech/`. ++- PASS: Parent table columns are Profile Name, Voice, Language, Gender, Age, Emotion Count, Status, Actions. ++- PASS: Default rows include `Default Balanced Profile`, `Man Profile 1`, and `Woman Profile 2`. ++- PASS: Parent row click opens or closes the child Emotion Settings row. ++- PASS: One selected profile row owns the visible child subtable at a time. ++- PASS: Parent profile count is visible in the summary stats. ++ ++## Parent Actions ++ ++- PASS: Add Profile opens a new inline row below the parent table rows. ++- PASS: Edit Profile opens an inline edit row for the selected profile. ++- PASS: Save Profile validates required Profile Name and Language values. ++- PASS: Duplicate Profile Name is blocked with a visible actionable error. ++- PASS: Cancel Profile closes the inline editor without applying changes. ++- PASS: Delete Profile removes unused profiles. ++- PASS: Delete Profile is disabled when the profile has Message Studio usage. ++ ++## Child Table ++ ++- PASS: Child table label is `Emotion Settings`. ++- PASS: Child table opens under the selected TTS Profile row. ++- PASS: Child columns are Emotion, Pitch, Rate, Volume, SSML-like Preset, Status, Actions. ++- PASS: Default neutral emotion is provided for every default profile. ++- PASS: Emotion count is visible in both the profile row and summary stats. ++ ++## Child Actions ++ ++- PASS: Add Emotion opens a new inline row in the child table. ++- PASS: Edit Emotion opens an inline edit row. ++- PASS: Save Emotion validates selected profile and unique emotion per profile. ++- PASS: Cancel Emotion closes the inline editor without applying changes. ++- PASS: Delete Emotion removes unused emotions. ++- PASS: Delete Emotion is disabled when the emotion has Message Parts usage. +diff --git a/docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md b/docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md +new file mode 100644 +index 000000000..551954c0a +--- /dev/null ++++ b/docs_build/dev/reports/PR_26171_067-tts-profile-emotion-table-foundation.md +@@ -0,0 +1,50 @@ ++# PR_26171_067 TTS Profile Emotion Table Foundation ++ ++## Summary ++ ++TTS Studio now presents a parent TTS Profiles table with an expandable child Emotion Settings table. The active tool remains `toolbox/text-to-speech/`, uses Theme V2, and keeps all JavaScript external. ++ ++## Scope ++ ++- Updated `toolbox/text-to-speech/index.html` to expose the requested parent and child table surfaces. ++- Updated `toolbox/text-to-speech/text2speech.js` to seed reusable profiles, render child emotion settings, support inline add/edit rows, and block delete actions when profile or emotion usage is marked by Message Studio data. ++- Added a TTS profile contract helper that returns Message Studio compatible options without moving ownership into Message Studio. ++- Updated targeted TTS browser and unit validation. ++ ++## Requirement Evidence ++ ++- PASS: Active path remains `toolbox/text-to-speech/`. ++- PASS: Parent table is TTS Profiles. ++- PASS: Clicking a profile row opens the child Emotion Settings subtable. ++- PASS: Parent rows include `Man Profile 1` and `Woman Profile 2`. ++- PASS: Parent columns are Profile Name, Voice, Language, Gender, Age, Emotion Count, Status, Actions. ++- PASS: Child columns are Emotion, Pitch, Rate, Volume, SSML-like Preset, Status, Actions. ++- PASS: Add Profile opens an inline add row under the parent table. ++- PASS: Edit Profile opens an inline edit row. ++- PASS: Add Emotion opens an inline add row in the child table. ++- PASS: Edit Emotion opens an inline edit row. ++- PASS: Delete profile is disabled when usage count indicates Message Studio data uses it. ++- PASS: Delete emotion is disabled when usage count indicates Message Parts use it. ++- PASS: Default balanced profile and default neutral emotion are provided. ++- PASS: Message Studio compatible profile options are exported for a future API/data contract. ++- PASS: Message Studio and TTS Studio remain separate tools. ++- PASS: No database changes were made. ++- PASS: Theme V2 only; no page-local CSS, tool-local CSS, inline styles, style blocks, or inline handlers. ++ ++## Validation ++ ++- PASS: `node --check toolbox\text-to-speech\text2speech.js`. ++- PASS: `node --check tests\playwright\tools\TextToSpeechFunctional.spec.mjs`. ++- PASS: `node --check tests\tools\Text2SpeechShell.test.mjs`. ++- PASS: HTML inline style/script/event scan for `toolbox/text-to-speech/index.html`. ++- PASS: `node --test tests/tools/Text2SpeechShell.test.mjs`. ++- PASS: `npx playwright test tests/playwright/tools/TextToSpeechFunctional.spec.mjs --project=playwright --workers=1 --reporter=list`. ++- PASS: `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright --workers=1 --reporter=list`. ++- PASS: `npm run test:workspace-v2` (legacy command name; user-facing language is Project Workspace). ++ ++## Out Of Scope ++ ++- No Message Studio merge. ++- No new provider behavior. ++- No generated audio export. ++- No database schema, seed, or persistence change. +diff --git a/docs_build/dev/reports/PR_26171_067-validation.md b/docs_build/dev/reports/PR_26171_067-validation.md +new file mode 100644 +index 000000000..a2c228f1b +--- /dev/null ++++ b/docs_build/dev/reports/PR_26171_067-validation.md +@@ -0,0 +1,43 @@ ++# PR_26171_067 Validation Report ++ ++## Commands Run ++ ++- `git branch --show-current` ++ - PASS: started from `main`. ++- `git checkout main` ++ - PASS. ++- `git pull origin main` ++ - PASS: already up to date. ++- `git status --short` ++ - PASS: clean before branch creation. ++- `node --check toolbox\text-to-speech\text2speech.js` ++ - PASS. ++- `node --check tests\playwright\tools\TextToSpeechFunctional.spec.mjs` ++ - PASS. ++- `node --check tests\tools\Text2SpeechShell.test.mjs` ++ - PASS. ++- `Select-String -Path toolbox\text-to-speech\index.html -Pattern ']+src=)|\son\w+=|style='` ++ - PASS: no matches. ++- `node --test tests/tools/Text2SpeechShell.test.mjs` ++ - PASS: 4 tests passed. ++- `npx playwright test tests/playwright/tools/TextToSpeechFunctional.spec.mjs --project=playwright --workers=1 --reporter=list` ++ - PASS: 2 tests passed. ++ - Covers default profiles, expandable Emotion Settings, inline add/edit rows, delete-disabled usage states, and existing speech composition. ++- `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --project=playwright --workers=1 --reporter=list` ++ - PASS: 2 tests passed. ++ - Covers Message Studio smoke compatibility for the existing TTS dropdown and audio-engine path. ++- `npm run test:workspace-v2` ++ - PASS: 5 Project Workspace tests passed. ++ - Note: command name is legacy; user-facing language is Project Workspace. ++ ++## Coverage ++ ++- PASS: `docs_build/dev/reports/playwright_v8_coverage_report.txt` produced changed runtime JS coverage. ++- PASS: `toolbox/text-to-speech/text2speech.js` covered by targeted browser validation. ++- NOTE: The advisory coverage helper also listed the previous HEAD Message Studio files because it includes `git diff-tree HEAD` before this PR is committed. Those Message Studio files are unchanged in this PR and were separately smoke-checked with `MessagesTool.spec.mjs`. ++ ++## Skipped ++ ++- Database validation skipped because no database schema, seed, or persistence implementation changed. ++- Full samples validation skipped because no samples changed. ++- External TTS provider validation skipped because this PR does not implement provider behavior. diff --git a/docs_build/dev/reports/coverage_changed_js_guardrail.txt b/docs_build/dev/reports/coverage_changed_js_guardrail.txt index 6fbeaba58..21de9adb6 100644 --- a/docs_build/dev/reports/coverage_changed_js_guardrail.txt +++ b/docs_build/dev/reports/coverage_changed_js_guardrail.txt @@ -6,9 +6,10 @@ Missing changed runtime JS files are WARN, not FAIL. Source: Playwright/Chromium built-in V8 coverage from the active Playwright run. Changed runtime JS files considered: -(64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 -(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 -(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 +(0%) toolbox/messages/message-tts-service-registry.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(80%) toolbox/text-to-speech/text2speech.js - executed lines 1518/1518; executed functions 127/158 Guardrail warnings: -(100%) none - no changed runtime JS coverage warnings +(0%) toolbox/messages/message-tts-service-registry.js - WARNING: changed runtime JS file missing from coverage; advisory only +(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file missing from coverage; advisory only diff --git a/docs_build/dev/reports/playwright_v8_coverage_report.txt b/docs_build/dev/reports/playwright_v8_coverage_report.txt index 578a19ddc..49e94f0d7 100644 --- a/docs_build/dev/reports/playwright_v8_coverage_report.txt +++ b/docs_build/dev/reports/playwright_v8_coverage_report.txt @@ -12,34 +12,33 @@ Note: entry percentages use function coverage when available, otherwise line cov Note: coverage entries are aggregated across every page/tool where coverageReporter.start(page) and coverageReporter.stop(page) ran. Exercised tool entry points detected: -(83%) Toolbox Index - exercised 4 runtime JS files +(80%) Toolbox Index - exercised 2 runtime JS files (0%) Tool Template V2 - not exercised by this Playwright run (56%) Theme V2 Shared JS - exercised 2 runtime JS files Changed runtime JS files covered: -(64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 -(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 -(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 +(0%) toolbox/messages/message-tts-service-registry.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(80%) toolbox/text-to-speech/text2speech.js - executed lines 1518/1518; executed functions 127/158 Files with executed line/function counts where available: -(34%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 15/44 (36%) src/api/server-api-client.js - executed lines 167/167; executed functions 5/14 (38%) src/api/public-config-client.js - executed lines 209/209; executed functions 10/26 (54%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 46/85 -(58%) toolbox/messages/messages-api-client.js - executed lines 64/64; executed functions 11/19 (64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 +(71%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 37/52 (76%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 22/29 -(87%) toolbox/messages/messages.js - executed lines 1233/1233; executed functions 103/118 +(80%) toolbox/text-to-speech/text2speech.js - executed lines 1518/1518; executed functions 127/158 (100%) src/engine/audio/TextToSpeechDefaults.js - executed lines 108/108; executed functions 1/1 -(100%) toolbox/messages/message-tts-service-registry.js - executed lines 42/42; executed functions 6/6 Uncovered or low-coverage changed JS files: -(100%) none - no low-coverage changed runtime JS files +(0%) toolbox/messages/message-tts-service-registry.js - WARNING: uncovered changed runtime JS file; advisory only +(0%) toolbox/messages/messages.js - WARNING: uncovered changed runtime JS file; advisory only Changed JS files considered: -(0%) tests/playwright/tools/IdeaBoardTableNotes.spec.mjs - changed JS file not collected as browser runtime coverage (0%) tests/playwright/tools/MessagesTool.spec.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/playwright/tools/ToolboxRoutePages.spec.mjs - changed JS file not collected as browser runtime coverage -(64%) assets/theme-v2/js/tool-display-mode.js - changed JS file with browser V8 coverage -(87%) toolbox/messages/messages.js - changed JS file with browser V8 coverage -(100%) toolbox/messages/message-tts-service-registry.js - changed JS file with browser V8 coverage +(0%) tests/playwright/tools/TextToSpeechFunctional.spec.mjs - changed JS file not collected as browser runtime coverage +(0%) tests/tools/Text2SpeechShell.test.mjs - changed JS file not collected as browser runtime coverage +(0%) toolbox/messages/message-tts-service-registry.js - changed JS file not collected as browser runtime coverage +(0%) toolbox/messages/messages.js - changed JS file not collected as browser runtime coverage +(80%) toolbox/text-to-speech/text2speech.js - changed JS file with browser V8 coverage diff --git a/tests/playwright/tools/TextToSpeechFunctional.spec.mjs b/tests/playwright/tools/TextToSpeechFunctional.spec.mjs index 17e2c0b62..8c964fecc 100644 --- a/tests/playwright/tools/TextToSpeechFunctional.spec.mjs +++ b/tests/playwright/tools/TextToSpeechFunctional.spec.mjs @@ -106,7 +106,45 @@ test("Text To Speech page loads and speaks through browser speech synthesis", as await expect(page.locator("[data-tts-voice-select]")).toContainText("Arcade Voice"); await expect(page.locator("[data-tts-voice-count]")).toHaveText("2"); - await expect(page.locator("[data-tts-engine-label]")).toHaveText("Ready"); + await expect(page.locator("[data-tts-profile-count]")).toHaveText("3"); + await expect(page.locator("[data-tts-emotion-count]")).toHaveText("3"); + await expect(page.locator("[data-tts-profile-table]")).toContainText("Default Balanced Profile"); + await expect(page.locator("[data-tts-profile-table]")).toContainText("Man Profile 1"); + await expect(page.locator("[data-tts-profile-table]")).toContainText("Woman Profile 2"); + await expect(page.locator("[data-tts-profile-row]").filter({ hasText: "Default Balanced Profile" }).getByRole("button", { name: "Delete" })).toBeDisabled(); + await page.locator("[data-tts-profile-row]").filter({ hasText: "Default Balanced Profile" }).click(); + await expect(page.getByRole("heading", { name: "Emotion Settings" })).toBeVisible(); + await expect(page.getByRole("columnheader", { name: "Emotion", exact: true })).toBeVisible(); + await expect(page.getByRole("columnheader", { name: "SSML-like Preset" })).toBeVisible(); + await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Neutral" }).getByRole("button", { name: "Delete" })).toBeDisabled(); + + await page.getByRole("button", { name: "Add Profile" }).click(); + await expect(page.locator("[data-tts-profile-editor='__new__']")).toBeVisible(); + await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-name]").fill("Creature Profile"); + await page.locator("[data-tts-profile-editor='__new__'] [data-tts-profile-gender]").selectOption("neutral"); + await page.locator("[data-tts-commit-profile='__new__']").click(); + await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Creature Profile."); + await expect(page.locator("[data-tts-profile-table]")).toContainText("Creature Profile"); + await page.locator("[data-tts-profile-row]").filter({ hasText: "Creature Profile" }).getByRole("button", { name: "Edit Profile" }).click(); + await page.locator("[data-tts-profile-editor] [data-tts-profile-name]").fill("Creature Profile Updated"); + await page.locator("[data-tts-profile-editor] [data-tts-commit-profile]").click(); + await expect(page.locator("[data-tts-status]")).toHaveText("Saved TTS profile: Creature Profile Updated."); + await page.getByRole("button", { name: "Add Emotion" }).click(); + await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-name]").selectOption("urgent"); + await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-pitch]").fill("1.2"); + await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-rate]").fill("1.1"); + await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-volume]").fill("0.8"); + await page.locator("[data-tts-emotion-editor='__new__'] [data-tts-emotion-ssml-preset]").selectOption("whisper-ish"); + await page.locator("[data-tts-commit-emotion='__new__']").click(); + await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion setting: Urgent."); + await expect(page.locator("[data-tts-emotion-row]").filter({ hasText: "Urgent" })).toContainText("1.2"); + await page.locator("[data-tts-emotion-row]").filter({ hasText: "Urgent" }).getByRole("button", { name: "Edit Emotion" }).click(); + await page.locator("[data-tts-emotion-editor] [data-tts-emotion-volume]").fill("0.7"); + await page.locator("[data-tts-emotion-editor] [data-tts-commit-emotion]").click(); + await expect(page.locator("[data-tts-status]")).toHaveText("Saved emotion setting: Urgent."); + await expect(page.locator("[data-tts-output-summary]")).toContainText("\"contractVersion\": \"tts-profile-emotion-v1\""); + await expect(page.locator("[data-tts-output-summary]")).toContainText("\"name\": \"Creature Profile Updated\""); + await expect(page.locator("[data-tts-gender-select]")).toBeVisible(); await expect(page.locator("[data-tts-language-select]")).toBeVisible(); await expect(page.locator("[data-tts-age-select]")).toBeVisible(); @@ -173,7 +211,7 @@ test("Text To Speech shows actionable error when browser speech synthesis is una const failures = await openTextToSpeechPage(page, { speechAvailable: false }); try { await expect(page.getByRole("heading", { level: 1, name: "Text To Speech" })).toBeVisible(); - await expect(page.locator("[data-tts-engine-label]")).toHaveText("Unavailable"); + await expect(page.locator("[data-tts-profile-count]")).toHaveText("3"); await expect(page.locator("[data-tts-engine-status]")).toContainText("SpeechSynthesis is unavailable"); await expect(page.locator("[data-tts-status]")).toContainText("Use a browser with Web Speech API support"); await expect(page.locator("[data-tts-voice-select]")).toContainText("No browser voices available"); diff --git a/tests/tools/Text2SpeechShell.test.mjs b/tests/tools/Text2SpeechShell.test.mjs index fbc6ac8e9..51e4f019f 100644 --- a/tests/tools/Text2SpeechShell.test.mjs +++ b/tests/tools/Text2SpeechShell.test.mjs @@ -3,9 +3,14 @@ import test from "node:test"; import { TTS_MESSAGE_STATUSES, + TTS_PROFILE_CONTRACT_VERSION, TTS_PROVIDER_ADAPTER_PLAN, + createDefaultTextToSpeechProfiles, createEmotionProfile, + createMessageStudioTtsProfileOptions, createSpeechPreviewRequest, + createTextToSpeechProfile, + createTextToSpeechProfileEmotion, createTtsMessage, createVoiceProfile, previewTtsMessage, @@ -59,3 +64,46 @@ test("Text2Speech provider adapter plan keeps browser speech implemented and pai assert.equal(TTS_PROVIDER_ADAPTER_PLAN[0].status, "implemented"); assert.ok(TTS_PROVIDER_ADAPTER_PLAN.slice(1).every((provider) => provider.status === "planned")); }); + +test("Text2Speech profile contract exposes Message Studio compatible profile options", () => { + const voiceOptions = [{ language: "en-US", label: "Test Voice (en-US)", name: "Test Voice", value: "test-voice" }]; + const defaults = createDefaultTextToSpeechProfiles(voiceOptions); + const custom = createTextToSpeechProfile({ + emotions: [ + createTextToSpeechProfileEmotion({ + emotion: "urgent", + pitch: 1.2, + rate: 1.1, + ssmlLikePreset: "whisper-ish", + volume: 0.8, + }), + ], + id: "custom-profile", + name: "Custom Profile", + voice: "test-voice", + voiceName: "Test Voice", + }); + const options = createMessageStudioTtsProfileOptions([custom]); + + assert.equal(TTS_PROFILE_CONTRACT_VERSION, "tts-profile-emotion-v1"); + assert.equal(defaults[0].name, "Default Balanced Profile"); + assert.equal(defaults[0].messageStudioUsageCount, 1); + assert.equal(defaults[0].emotions[0].emotionLabel, "Neutral"); + assert.equal(defaults[0].emotions[0].messagePartsUsageCount, 1); + assert.deepEqual(options, [{ + active: true, + emotionSettings: [{ + emotion: "urgent", + emotionLabel: "Urgent", + pitch: 1.2, + rate: 1.1, + ssmlLikePreset: "whisper-ish", + volume: 0.8, + }], + key: "custom-profile", + language: "en-US", + name: "Custom Profile", + providerKey: "browser-speech", + voiceName: "Test Voice", + }]); +}); diff --git a/toolbox/text-to-speech/index.html b/toolbox/text-to-speech/index.html index f5b29b19a..e82dd9468 100644 --- a/toolbox/text-to-speech/index.html +++ b/toolbox/text-to-speech/index.html @@ -98,14 +98,50 @@

Speech Options

-

Speech Composition

-
+

TTS Studio

+
0Characters
+
0TTS Profiles
+
0Emotion Settings
0Voices
CheckingEngine
+
+
Reusable Speech Profiles
+

TTS Profiles

+
+
+ + + + + + + + + + + + + + + + +
Profile NameVoiceLanguageGenderAgeEmotion CountStatusActions
Loading TTS profiles.
+
+
+ +
+
+
+
+
+
+
Local Preview
+

Speech Composition

+
diff --git a/toolbox/text-to-speech/text2speech.js b/toolbox/text-to-speech/text2speech.js index 5ab111eeb..9e0af1f87 100644 --- a/toolbox/text-to-speech/text2speech.js +++ b/toolbox/text-to-speech/text2speech.js @@ -2,6 +2,7 @@ import { createTextToSpeechQueueItem, filterTextToSpeechVoiceOptions, shapeTextToSpeechOptions, + textToSpeechLanguageOptionsFromVoices, textToSpeechPayloadGenderValue, TextToSpeechEngine, uniqueTextToSpeechId, @@ -80,6 +81,27 @@ const TTS_PROVIDER_ADAPTER_PLAN = Object.freeze([ } ]); +const TTS_PROFILE_CONTRACT_VERSION = "tts-profile-emotion-v1"; +const NEW_ROW_KEY = "__new__"; +const DEFAULT_TTS_PROFILE_ID = "default-balanced-profile"; +const DEFAULT_TTS_EMOTION_ID = "neutral"; + +const TTS_PROFILE_GENDER_OPTIONS = Object.freeze([ + Object.freeze({ label: "Neutral", value: "neutral" }), + Object.freeze({ label: "Male", value: "male" }), + Object.freeze({ label: "Female", value: "female" }), + Object.freeze({ label: "Any", value: "any" }) +]); + +const TTS_PROFILE_EMOTION_OPTIONS = Object.freeze([ + Object.freeze({ label: "Neutral", value: "neutral" }), + Object.freeze({ label: "Calm", value: "calm" }), + Object.freeze({ label: "Urgent", value: "urgent" }), + Object.freeze({ label: "Whisper", value: "whisper" }), + Object.freeze({ label: "Angry", value: "angry" }), + Object.freeze({ label: "Excited", value: "excited" }) +]); + function boundedNumber(value, { fallback, max, min, value: defaultValue }) { const number = Number(value); const fallbackValue = fallback ?? defaultValue ?? min; @@ -139,6 +161,144 @@ function createVoiceProfile({ key = "browser-speech", name = "Browser Speech", p }; } +function slugFromText(value, fallback = "tts-profile") { + const slug = String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || fallback; +} + +function labelForOption(options, value, fallback = "") { + return options.find((option) => String(option.value) === String(value))?.label || fallback || String(value || ""); +} + +function createTextToSpeechProfileEmotion({ + active = true, + emotion = "neutral", + id = "", + messagePartsUsageCount = 0, + pitch = TEXT_TO_SPEECH_DEFAULTS.pitch, + rate = TEXT_TO_SPEECH_DEFAULTS.rate, + ssmlLikePreset = TEXT_TO_SPEECH_DEFAULTS.ssmlLikePreset, + volume = TEXT_TO_SPEECH_DEFAULTS.volume +} = {}) { + const emotionKey = slugFromText(emotion, DEFAULT_TTS_EMOTION_ID); + return { + active: active !== false, + emotion: emotionKey, + emotionLabel: labelForOption(TTS_PROFILE_EMOTION_OPTIONS, emotionKey, "Neutral"), + id: id || emotionKey, + messagePartsUsageCount: Math.max(0, Number(messagePartsUsageCount) || 0), + pitch: boundedNumber(pitch, TEXT_TO_SPEECH_RANGE_DEFAULTS.pitch), + rate: boundedNumber(rate, TEXT_TO_SPEECH_RANGE_DEFAULTS.rate), + ssmlLikePreset: TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS.some((option) => option.value === ssmlLikePreset) ? ssmlLikePreset : "normal", + volume: boundedNumber(volume, TEXT_TO_SPEECH_RANGE_DEFAULTS.volume) + }; +} + +function createTextToSpeechProfile({ + active = true, + age = TEXT_TO_SPEECH_DEFAULTS.voiceAge, + emotions = [], + gender = "neutral", + id = "", + language = TEXT_TO_SPEECH_DEFAULTS.language, + messageStudioUsageCount = 0, + name = "Default Balanced Profile", + voice = "", + voiceName = "" +} = {}) { + const profileName = String(name || "Default Balanced Profile").trim() || "Default Balanced Profile"; + const emotionRows = Array.isArray(emotions) && emotions.length + ? emotions.map((emotion) => createTextToSpeechProfileEmotion(emotion)) + : [createTextToSpeechProfileEmotion()]; + return { + active: active !== false, + age: String(age || TEXT_TO_SPEECH_DEFAULTS.voiceAge), + emotions: emotionRows, + gender: String(gender || "neutral"), + id: id || slugFromText(profileName, "tts-profile"), + language: String(language || TEXT_TO_SPEECH_DEFAULTS.language), + messageStudioUsageCount: Math.max(0, Number(messageStudioUsageCount) || 0), + name: profileName, + owner: TTS_OWNERSHIP.AUDIO, + providerKey: "browser-speech", + voice: String(voice || ""), + voiceName: String(voiceName || voice || "Default browser voice") + }; +} + +function defaultVoiceForProfile(voiceOptions = [], preferredGender = "") { + if (!voiceOptions.length) { + return null; + } + const preferred = voiceOptions.find((option) => { + const text = `${option.name || ""} ${option.label || ""}`.toLowerCase(); + if (preferredGender === "male") return /\bmale\b|\bman\b|\bdavid\b|\bmark\b/.test(text); + if (preferredGender === "female") return /\bfemale\b|\bwoman\b|\bzira\b/.test(text); + return false; + }); + return preferred || voiceOptions[0]; +} + +function createDefaultTextToSpeechProfiles(voiceOptions = []) { + const balancedVoice = defaultVoiceForProfile(voiceOptions); + const manVoice = defaultVoiceForProfile(voiceOptions, "male") || balancedVoice; + const womanVoice = defaultVoiceForProfile(voiceOptions, "female") || voiceOptions[1] || balancedVoice; + return [ + createTextToSpeechProfile({ + emotions: [createTextToSpeechProfileEmotion({ messagePartsUsageCount: 1 })], + id: DEFAULT_TTS_PROFILE_ID, + language: balancedVoice?.language || TEXT_TO_SPEECH_DEFAULTS.language, + messageStudioUsageCount: 1, + name: "Default Balanced Profile", + voice: balancedVoice?.value || "", + voiceName: balancedVoice?.name || balancedVoice?.label || "Default browser voice" + }), + createTextToSpeechProfile({ + gender: "male", + id: "man-profile-1", + language: manVoice?.language || TEXT_TO_SPEECH_DEFAULTS.language, + name: "Man Profile 1", + voice: manVoice?.value || "", + voiceName: manVoice?.name || manVoice?.label || "Default browser voice" + }), + createTextToSpeechProfile({ + gender: "female", + id: "woman-profile-2", + language: womanVoice?.language || TEXT_TO_SPEECH_DEFAULTS.language, + name: "Woman Profile 2", + voice: womanVoice?.value || "", + voiceName: womanVoice?.name || womanVoice?.label || "Default browser voice" + }) + ]; +} + +function createMessageStudioTtsProfileOptions(profiles = []) { + return profiles + .filter((profile) => profile?.active !== false) + .map((profile) => ({ + active: true, + emotionSettings: Array.isArray(profile.emotions) + ? profile.emotions.filter((emotion) => emotion.active !== false).map((emotion) => ({ + emotion: emotion.emotion, + emotionLabel: emotion.emotionLabel, + pitch: emotion.pitch, + rate: emotion.rate, + ssmlLikePreset: emotion.ssmlLikePreset, + volume: emotion.volume + })) + : [], + key: profile.id, + language: profile.language, + name: profile.name, + providerKey: profile.providerKey || "browser-speech", + voiceName: profile.voiceName || profile.voice || "" + })); +} + function createSpeechPreviewRequest({ pitch = TEXT_TO_SPEECH_DEFAULTS.pitch, rate = TEXT_TO_SPEECH_DEFAULTS.rate, @@ -250,6 +410,7 @@ function queueItemMeta(item) { function initializeTextToSpeechTool(root = document, { engine = new TextToSpeechEngine() } = {}) { const elements = { addItem: root.querySelector("[data-tts-add-item]"), + addProfile: root.querySelector("[data-tts-add-profile]"), age: root.querySelector("[data-tts-age-select]"), characterPreset: root.querySelector("[data-tts-character-preset-select]"), clearStatus: root.querySelector("[data-tts-clear-status]"), @@ -266,6 +427,9 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech pause: root.querySelector("[data-tts-pause]"), pitch: root.querySelector("[data-tts-pitch]"), pitchValue: root.querySelector("[data-tts-pitch-value]"), + profileCount: root.querySelector("[data-tts-profile-count]"), + profileEmotionCount: root.querySelector("[data-tts-emotion-count]"), + profileTable: root.querySelector("[data-tts-profile-table]"), queueList: root.querySelector("[data-tts-queue-list]"), rate: root.querySelector("[data-tts-rate]"), rateValue: root.querySelector("[data-tts-rate-value]"), @@ -285,7 +449,11 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech }; const state = { applyingItem: false, + editingEmotionId: "", + editingProfileId: "", + profiles: [], queue: [], + selectedProfileId: "", selectedItemId: "", sliderOverrides: { pitch: false, rate: false, volume: false }, voiceOptions: [] @@ -330,6 +498,477 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech }; } + function selectedProfile() { + return state.profiles.find((profile) => profile.id === state.selectedProfileId) || null; + } + + function profileInUseByMessageStudio(profile) { + return Number(profile?.messageStudioUsageCount || 0) > 0; + } + + function emotionInUseByMessageParts(emotion) { + return Number(emotion?.messagePartsUsageCount || 0) > 0; + } + + function createCell(text) { + const cell = document.createElement("td"); + cell.textContent = text; + return cell; + } + + function createButton(label, dataName, value) { + const button = document.createElement("button"); + button.className = "btn btn--compact"; + button.type = "button"; + button.dataset[dataName] = value; + button.textContent = label; + return button; + } + + function createActionGroup(...buttons) { + const group = document.createElement("div"); + group.className = "action-group action-group--tight"; + buttons.filter(Boolean).forEach((button) => group.append(button)); + return group; + } + + function tableMessage(colSpan, text) { + const row = document.createElement("tr"); + const cell = document.createElement("td"); + cell.colSpan = colSpan; + cell.textContent = text; + row.append(cell); + return row; + } + + function createTextInput(value, dataName) { + const input = document.createElement("input"); + input.dataset[dataName] = ""; + input.type = "text"; + input.value = value || ""; + return input; + } + + function createNumberInput(value, dataName, kind) { + const input = document.createElement("input"); + const range = TEXT_TO_SPEECH_RANGE_DEFAULTS[kind] || TEXT_TO_SPEECH_RANGE_DEFAULTS.rate; + input.dataset[dataName] = ""; + input.type = "number"; + input.min = String(range.min); + input.max = String(range.max); + input.step = String(range.step); + input.value = formatRangeValue(value ?? range.value, kind); + return input; + } + + function createCheckbox(checked, dataName) { + const label = document.createElement("label"); + const input = document.createElement("input"); + input.dataset[dataName] = ""; + input.type = "checkbox"; + input.checked = checked !== false; + label.append(input, " Active"); + return label; + } + + function createEditorSelect(value, dataName, options, placeholder = "") { + const select = document.createElement("select"); + select.dataset[dataName] = ""; + if (placeholder) { + const placeholderOption = document.createElement("option"); + placeholderOption.value = ""; + placeholderOption.textContent = placeholder; + select.append(placeholderOption); + } + options.forEach((optionValue) => { + const option = document.createElement("option"); + option.value = String(optionValue.value); + option.textContent = optionValue.label; + select.append(option); + }); + select.value = options.some((optionValue) => String(optionValue.value) === String(value)) ? String(value) : String(options[0]?.value || ""); + return select; + } + + function voiceSelectOptions() { + return state.voiceOptions.length + ? state.voiceOptions.map((option) => ({ label: option.label, value: option.value })) + : [{ label: "No browser voices available", value: "" }]; + } + + function languageSelectOptions() { + const voiceLanguages = textToSpeechLanguageOptionsFromVoices(state.voiceOptions); + return voiceLanguages.length ? voiceLanguages : TEXT_TO_SPEECH_LANGUAGE_OPTIONS; + } + + function profileVoiceName(profile) { + const match = state.voiceOptions.find((option) => option.value === profile.voice); + return match?.name || match?.label || profile.voiceName || "No voice selected"; + } + + function renderProfileCounts() { + if (elements.profileCount) elements.profileCount.textContent = String(state.profiles.length); + if (elements.profileEmotionCount) { + const emotionCount = state.profiles.reduce((total, profile) => total + profile.emotions.length, 0); + elements.profileEmotionCount.textContent = String(emotionCount); + } + } + + function renderProfileRows() { + if (!elements.profileTable) return; + elements.profileTable.replaceChildren(); + + state.profiles.forEach((profile) => { + if (state.editingProfileId === profile.id) { + elements.profileTable.append(createProfileEditRow(profile)); + appendEmotionHost(profile.id); + return; + } + + const row = document.createElement("tr"); + row.dataset.ttsProfileRow = profile.id; + const nameCell = document.createElement("td"); + nameCell.dataset.ttsProfileNameCell = profile.id; + nameCell.textContent = `${state.selectedProfileId === profile.id ? "v" : ">"} ${profile.name}`; + const deleteButton = createButton("Delete", "ttsDeleteProfile", profile.id); + if (profileInUseByMessageStudio(profile)) { + deleteButton.disabled = true; + deleteButton.title = "Delete disabled: profile is in use by Message Studio data."; + } + const actions = createActionGroup( + createButton("Edit Profile", "ttsEditProfile", profile.id), + deleteButton, + ); + row.append( + nameCell, + createCell(profileVoiceName(profile)), + createCell(profile.language), + createCell(labelForOption(TTS_PROFILE_GENDER_OPTIONS, profile.gender, "Neutral")), + createCell(labelForOption(TEXT_TO_SPEECH_AGE_FILTER_OPTIONS, profile.age, "Any")), + createCell(String(profile.emotions.length)), + createCell(profile.active ? "Active" : "Inactive"), + (() => { + const cell = document.createElement("td"); + cell.append(actions); + return cell; + })(), + ); + elements.profileTable.append(row); + appendEmotionHost(profile.id); + }); + + if (state.editingProfileId === NEW_ROW_KEY) { + elements.profileTable.append(createProfileEditRow(null)); + } + + if (!state.profiles.length && state.editingProfileId !== NEW_ROW_KEY) { + elements.profileTable.append(tableMessage(8, "No TTS profiles yet.")); + } + renderProfileCounts(); + } + + function createProfileEditRow(profile = null) { + const key = profile?.id || NEW_ROW_KEY; + const row = document.createElement("tr"); + row.dataset.ttsProfileEditor = key; + + const nameCell = document.createElement("td"); + nameCell.append(createTextInput(profile?.name || "", "ttsProfileName")); + const voiceCell = document.createElement("td"); + voiceCell.append(createEditorSelect(profile?.voice || "", "ttsProfileVoice", voiceSelectOptions(), "Select voice")); + const languageCell = document.createElement("td"); + languageCell.append(createEditorSelect(profile?.language || TEXT_TO_SPEECH_DEFAULTS.language, "ttsProfileLanguage", languageSelectOptions())); + const genderCell = document.createElement("td"); + genderCell.append(createEditorSelect(profile?.gender || "neutral", "ttsProfileGender", TTS_PROFILE_GENDER_OPTIONS)); + const ageCell = document.createElement("td"); + ageCell.append(createEditorSelect(profile?.age || TEXT_TO_SPEECH_DEFAULTS.voiceAge, "ttsProfileAge", TEXT_TO_SPEECH_AGE_FILTER_OPTIONS)); + const emotionCountCell = createCell(profile ? String(profile.emotions.length) : "1"); + const statusCell = document.createElement("td"); + statusCell.append(createCheckbox(profile?.active !== false, "ttsProfileActive")); + const actionsCell = document.createElement("td"); + actionsCell.append(createActionGroup( + createButton("Save", "ttsCommitProfile", key), + createButton("Cancel", "ttsCancelProfile", key), + )); + + row.append(nameCell, voiceCell, languageCell, genderCell, ageCell, emotionCountCell, statusCell, actionsCell); + return row; + } + + function appendEmotionHost(profileId) { + if (state.selectedProfileId !== profileId) return; + const hostRow = document.createElement("tr"); + hostRow.dataset.ttsEmotionHost = profileId; + const cell = document.createElement("td"); + cell.colSpan = 8; + cell.append(createEmotionTable(profileId)); + hostRow.append(cell); + elements.profileTable.append(hostRow); + } + + function createEmotionTable(profileId) { + const profile = state.profiles.find((candidate) => candidate.id === profileId); + const wrapper = document.createElement("div"); + wrapper.className = "content-stack"; + const context = document.createElement("div"); + context.className = "kicker"; + context.textContent = "TTS Profile / Emotion Settings"; + const heading = document.createElement("h3"); + heading.textContent = "Emotion Settings"; + wrapper.append(context, heading); + + const tableWrapper = document.createElement("div"); + tableWrapper.className = "table-wrapper"; + const table = document.createElement("table"); + table.className = "data-table"; + table.setAttribute("aria-label", "Emotion Settings"); + const thead = document.createElement("thead"); + const headerRow = document.createElement("tr"); + ["Emotion", "Pitch", "Rate", "Volume", "SSML-like Preset", "Status", "Actions"].forEach((label) => { + const header = document.createElement("th"); + header.scope = "col"; + header.textContent = label; + headerRow.append(header); + }); + thead.append(headerRow); + const tbody = document.createElement("tbody"); + tbody.dataset.ttsEmotionTable = profileId; + + if (!profile?.emotions.length && state.editingEmotionId !== NEW_ROW_KEY) { + tbody.append(tableMessage(7, "No emotion settings for this profile.")); + } + + profile?.emotions.forEach((emotion) => { + if (state.editingEmotionId === emotion.id) { + tbody.append(createEmotionEditRow(emotion)); + return; + } + const row = document.createElement("tr"); + row.dataset.ttsEmotionRow = emotion.id; + const deleteButton = createButton("Delete", "ttsDeleteEmotion", emotion.id); + if (emotionInUseByMessageParts(emotion)) { + deleteButton.disabled = true; + deleteButton.title = "Delete disabled: emotion is in use by Message Parts."; + } + const actions = createActionGroup( + createButton("Edit Emotion", "ttsEditEmotion", emotion.id), + deleteButton, + ); + const actionsCell = document.createElement("td"); + actionsCell.append(actions); + row.append( + createCell(emotion.emotionLabel), + createCell(String(emotion.pitch)), + createCell(String(emotion.rate)), + createCell(String(emotion.volume)), + createCell(labelForOption(TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS, emotion.ssmlLikePreset, "Normal")), + createCell(emotion.active ? "Active" : "Inactive"), + actionsCell, + ); + tbody.append(row); + }); + + if (state.editingEmotionId === NEW_ROW_KEY) { + tbody.append(createEmotionEditRow(null)); + } + + table.append(thead, tbody); + tableWrapper.append(table); + const actionGroup = document.createElement("div"); + actionGroup.className = "action-group"; + actionGroup.append(createButton("Add Emotion", "ttsAddEmotion", profileId)); + wrapper.append(tableWrapper, actionGroup); + return wrapper; + } + + function createEmotionEditRow(emotion = null) { + const key = emotion?.id || NEW_ROW_KEY; + const row = document.createElement("tr"); + row.dataset.ttsEmotionEditor = key; + const emotionCell = document.createElement("td"); + emotionCell.append(createEditorSelect(emotion?.emotion || "neutral", "ttsEmotionName", TTS_PROFILE_EMOTION_OPTIONS)); + const pitchCell = document.createElement("td"); + pitchCell.append(createNumberInput(emotion?.pitch ?? 1, "ttsEmotionPitch", "pitch")); + const rateCell = document.createElement("td"); + rateCell.append(createNumberInput(emotion?.rate ?? 1, "ttsEmotionRate", "rate")); + const volumeCell = document.createElement("td"); + volumeCell.append(createNumberInput(emotion?.volume ?? 1, "ttsEmotionVolume", "volume")); + const presetCell = document.createElement("td"); + presetCell.append(createEditorSelect(emotion?.ssmlLikePreset || "normal", "ttsEmotionSsmlPreset", TEXT_TO_SPEECH_SSML_LIKE_PRESET_OPTIONS)); + const statusCell = document.createElement("td"); + statusCell.append(createCheckbox(emotion?.active !== false, "ttsEmotionActive")); + const actionsCell = document.createElement("td"); + actionsCell.append(createActionGroup( + createButton("Save", "ttsCommitEmotion", key), + createButton("Cancel", "ttsCancelEmotion", key), + )); + row.append(emotionCell, pitchCell, rateCell, volumeCell, presetCell, statusCell, actionsCell); + return row; + } + + function editorValue(rootNode, selector) { + return rootNode?.querySelector(selector)?.value || ""; + } + + function editorChecked(rootNode, selector) { + return rootNode?.querySelector(selector)?.checked !== false; + } + + function profileValues(key) { + const row = elements.profileTable?.querySelector(`[data-tts-profile-editor="${key}"]`); + const voiceValue = editorValue(row, "[data-tts-profile-voice]"); + const selectedVoice = state.voiceOptions.find((option) => option.value === voiceValue); + return createTextToSpeechProfile({ + active: editorChecked(row, "[data-tts-profile-active]"), + age: editorValue(row, "[data-tts-profile-age]"), + emotions: key === NEW_ROW_KEY ? [createTextToSpeechProfileEmotion()] : state.profiles.find((profile) => profile.id === key)?.emotions || [], + gender: editorValue(row, "[data-tts-profile-gender]"), + id: key === NEW_ROW_KEY ? "" : key, + language: editorValue(row, "[data-tts-profile-language]"), + name: editorValue(row, "[data-tts-profile-name]"), + voice: voiceValue, + voiceName: selectedVoice?.name || selectedVoice?.label || voiceValue, + }); + } + + function validateProfile(profile) { + const errors = []; + if (!profile.name.trim()) errors.push("Profile Name is required."); + if (!profile.language.trim()) errors.push("Language is required."); + if (state.profiles.some((candidate) => candidate.id !== profile.id && candidate.name.toLowerCase() === profile.name.toLowerCase())) { + errors.push("Profile Name must be unique."); + } + return errors; + } + + function emotionValues(key) { + const row = elements.profileTable?.querySelector(`[data-tts-emotion-editor="${key}"]`); + return createTextToSpeechProfileEmotion({ + active: editorChecked(row, "[data-tts-emotion-active]"), + emotion: editorValue(row, "[data-tts-emotion-name]"), + id: key === NEW_ROW_KEY ? "" : key, + pitch: editorValue(row, "[data-tts-emotion-pitch]"), + rate: editorValue(row, "[data-tts-emotion-rate]"), + ssmlLikePreset: editorValue(row, "[data-tts-emotion-ssml-preset]"), + volume: editorValue(row, "[data-tts-emotion-volume]"), + }); + } + + function validateEmotion(emotion, existingId = "") { + const errors = []; + if (!state.selectedProfileId) errors.push("Select a TTS Profile before adding Emotion Settings."); + if (!emotion.emotion) errors.push("Emotion is required."); + const profile = selectedProfile(); + if (profile?.emotions.some((candidate) => candidate.id !== existingId && candidate.emotion === emotion.emotion)) { + errors.push("Emotion must be unique within the selected TTS Profile."); + } + return errors; + } + + function addProfile() { + state.editingProfileId = NEW_ROW_KEY; + state.editingEmotionId = ""; + state.selectedProfileId = ""; + renderProfileRows(); + writeStatus("Ready to add a TTS profile."); + } + + function commitProfile(key) { + const profile = profileValues(key); + const errors = validateProfile(profile); + if (errors.length) { + writeStatus(`TTS Profile save blocked: ${errors.join(" ")}`, "FAIL"); + return; + } + if (key === NEW_ROW_KEY) { + state.profiles.push(profile); + } else { + const index = state.profiles.findIndex((candidate) => candidate.id === key); + const existing = state.profiles[index]; + state.profiles[index] = { + ...profile, + emotions: existing?.emotions || profile.emotions, + messageStudioUsageCount: existing?.messageStudioUsageCount || 0, + }; + } + state.selectedProfileId = profile.id; + state.editingProfileId = ""; + renderProfileRows(); + renderOutputSummary(); + writeStatus(`Saved TTS profile: ${profile.name}.`); + } + + function deleteProfile(key) { + const profile = state.profiles.find((candidate) => candidate.id === key); + if (!profile) return; + if (profileInUseByMessageStudio(profile)) { + writeStatus(`Delete profile disabled: ${profile.name} is in use by Message Studio data.`, "FAIL"); + return; + } + state.profiles = state.profiles.filter((candidate) => candidate.id !== key); + if (state.selectedProfileId === key) state.selectedProfileId = state.profiles[0]?.id || ""; + renderProfileRows(); + renderOutputSummary(); + writeStatus(`Deleted TTS profile: ${profile.name}.`); + } + + function addEmotion(profileId) { + state.selectedProfileId = profileId; + state.editingProfileId = ""; + state.editingEmotionId = NEW_ROW_KEY; + renderProfileRows(); + writeStatus("Ready to add an emotion setting."); + } + + function commitEmotion(key) { + const emotion = emotionValues(key); + const errors = validateEmotion(emotion, key === NEW_ROW_KEY ? "" : key); + if (errors.length) { + writeStatus(`Emotion setting save blocked: ${errors.join(" ")}`, "FAIL"); + return; + } + const profile = selectedProfile(); + if (!profile) return; + if (key === NEW_ROW_KEY) { + profile.emotions.push(emotion); + } else { + const index = profile.emotions.findIndex((candidate) => candidate.id === key); + const existing = profile.emotions[index]; + profile.emotions[index] = { + ...emotion, + messagePartsUsageCount: existing?.messagePartsUsageCount || 0, + }; + } + state.editingEmotionId = ""; + renderProfileRows(); + renderOutputSummary(); + writeStatus(`Saved emotion setting: ${emotion.emotionLabel}.`); + } + + function deleteEmotion(key) { + const profile = selectedProfile(); + const emotion = profile?.emotions.find((candidate) => candidate.id === key); + if (!profile || !emotion) return; + if (emotionInUseByMessageParts(emotion)) { + writeStatus(`Delete emotion disabled: ${emotion.emotionLabel} is in use by Message Parts.`, "FAIL"); + return; + } + profile.emotions = profile.emotions.filter((candidate) => candidate.id !== key); + renderProfileRows(); + renderOutputSummary(); + writeStatus(`Deleted emotion setting: ${emotion.emotionLabel}.`); + } + + function selectProfile(profileId) { + if (!state.profiles.some((profile) => profile.id === profileId)) return; + state.selectedProfileId = state.selectedProfileId === profileId ? "" : profileId; + state.editingEmotionId = ""; + renderProfileRows(); + if (state.selectedProfileId) { + writeStatus(`Opened Emotion Settings for ${selectedProfile()?.name}.`); + } + } + function itemFromControls(overrides = {}) { const currentItem = selectedItem(); return createTextToSpeechQueueItem({ @@ -379,7 +1018,12 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech function renderOutputSummary() { if (elements.outputSummary) { - elements.outputSummary.textContent = JSON.stringify(state.queue, null, 2); + elements.outputSummary.textContent = JSON.stringify({ + contractVersion: TTS_PROFILE_CONTRACT_VERSION, + messageStudioOptions: createMessageStudioTtsProfileOptions(state.profiles), + profiles: state.profiles, + queue: state.queue + }, null, 2); } setTextContent(root, "[data-tts-text-count]", String(String(elements.text?.value || "").length)); } @@ -556,6 +1200,16 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech return result; } + function ensureDefaultProfiles() { + if (state.profiles.length) { + return; + } + state.profiles = createDefaultTextToSpeechProfiles(state.voiceOptions); + state.selectedProfileId = ""; + renderProfileRows(); + renderOutputSummary(); + } + function refreshActionState() { const hasText = Boolean(String(elements.text?.value || "").trim()); const hasVoice = Boolean(elements.voice?.value); @@ -755,6 +1409,69 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech } function mountEvents() { + elements.addProfile?.addEventListener("click", addProfile); + elements.profileTable?.addEventListener("click", (event) => { + const addEmotionButton = event.target.closest("[data-tts-add-emotion]"); + const commitEmotionButton = event.target.closest("[data-tts-commit-emotion]"); + const cancelEmotionButton = event.target.closest("[data-tts-cancel-emotion]"); + const deleteEmotionButton = event.target.closest("[data-tts-delete-emotion]"); + const deleteProfileButton = event.target.closest("[data-tts-delete-profile]"); + const editEmotionButton = event.target.closest("[data-tts-edit-emotion]"); + const editProfileButton = event.target.closest("[data-tts-edit-profile]"); + const commitProfileButton = event.target.closest("[data-tts-commit-profile]"); + const cancelProfileButton = event.target.closest("[data-tts-cancel-profile]"); + const profileRow = event.target.closest("[data-tts-profile-row]"); + + if (commitProfileButton) { + commitProfile(commitProfileButton.dataset.ttsCommitProfile); + return; + } + if (cancelProfileButton) { + state.editingProfileId = ""; + renderProfileRows(); + writeStatus("TTS profile edit canceled."); + return; + } + if (editProfileButton) { + state.editingProfileId = editProfileButton.dataset.ttsEditProfile; + state.selectedProfileId = editProfileButton.dataset.ttsEditProfile; + state.editingEmotionId = ""; + renderProfileRows(); + writeStatus("TTS profile opened inline."); + return; + } + if (deleteProfileButton) { + deleteProfile(deleteProfileButton.dataset.ttsDeleteProfile); + return; + } + if (addEmotionButton) { + addEmotion(addEmotionButton.dataset.ttsAddEmotion); + return; + } + if (commitEmotionButton) { + commitEmotion(commitEmotionButton.dataset.ttsCommitEmotion); + return; + } + if (cancelEmotionButton) { + state.editingEmotionId = ""; + renderProfileRows(); + writeStatus("Emotion setting edit canceled."); + return; + } + if (editEmotionButton) { + state.editingEmotionId = editEmotionButton.dataset.ttsEditEmotion; + renderProfileRows(); + writeStatus("Emotion setting opened inline."); + return; + } + if (deleteEmotionButton) { + deleteEmotion(deleteEmotionButton.dataset.ttsDeleteEmotion); + return; + } + if (profileRow) { + selectProfile(profileRow.dataset.ttsProfileRow); + } + }); elements.queueList?.addEventListener("click", (event) => { const itemButton = event.target.closest("[data-tts-queue-item]"); if (itemButton) selectItem(itemButton.dataset.ttsQueueItem || ""); @@ -849,14 +1566,18 @@ function initializeTextToSpeechTool(root = document, { engine = new TextToSpeech mountEvents(); if (!engine.isSupported()) { await loadQueue(); + ensureDefaultProfiles(); markUnavailable(); return; } setTextContent(root, "[data-tts-engine-label]", "Ready"); setTextContent(root, "[data-tts-engine-status]", "Browser SpeechSynthesis is available for local preview."); refreshVoices(); + ensureDefaultProfiles(); engine.onVoicesChanged(() => { refreshVoices(); + renderProfileRows(); + renderOutputSummary(); writeStatus(`${TEXT_TO_SPEECH_DISPLAY_NAME} voices updated from browser SpeechSynthesis.`); }); await loadQueue(); @@ -883,9 +1604,14 @@ export { TTS_LANGUAGES, TTS_MESSAGE_STATUSES, TTS_OWNERSHIP, + TTS_PROFILE_CONTRACT_VERSION, TTS_PROVIDER_ADAPTER_PLAN, createEmotionProfile, + createDefaultTextToSpeechProfiles, + createMessageStudioTtsProfileOptions, createSpeechPreviewRequest, + createTextToSpeechProfile, + createTextToSpeechProfileEmotion, createTtsMessage, createVoiceProfile, initializeTextToSpeechTool,