Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# PR_26171_BETA_081 Manual Validation Notes

## Review
- Confirmed /api/messages/tts-profiles returns server-owned emotionSettings for playback.
- Confirmed Play Message queues each active Message Part through the TextToSpeechEngine-backed registry.
- Confirmed Play Part uses the selected part TTS Profile and matching Emotion Setting values.
- Confirmed Stop continues through the TextToSpeechEngine-backed registry stop path.
- Confirmed missing Emotion Settings produce a visible validation error instead of falling back silently.

## Manual Browser Coverage
- Covered by targeted Playwright validation for Message Studio playback, TTS profile emotion settings, Play Message, Play Part, and Stop.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# PR_26171_BETA_081 Validation Report

## Commands
- PASS: node --check toolbox/messages/messages.js
- PASS: node --check src/dev-runtime/messages/messages-sqlite-service.mjs
- PASS: npx playwright test tests/playwright/tools/MessagesTool.spec.mjs
- PASS: npx playwright test tests/playwright/tools/TextToSpeechFunctional.spec.mjs --reporter=list
- PASS: node --test tests/tools/Text2SpeechShell.test.mjs
- PASS: npm run test:workspace-v2
- PASS: git diff --check

## Targeted Results
- Message Studio Playwright tests: 2 passed.
- Text To Speech compatibility Playwright tests: 3 passed.
- Text2Speech Node contract tests: 4 passed.
- Project Workspace legacy validation: 5 passed.

## Notes
- A parallel Playwright run caused an HTML reporter file-copy collision after tests passed; the TTS compatibility lane was rerun with the list reporter and passed cleanly.
- npm run test:workspace-v2 is a legacy command name; user-facing language remains Project Workspace.
- Standard generated validation-report churn was restored before staging this PR.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# PR_26171_BETA_081-message-playback-through-tts-engine

## Team Ownership
- TEAM: BETA
- Ownership source: docs_build/dev/PROJECT_MULTI_PC.txt
- Scope confirmed: Audio, Messages, Text To Speech, and TTS are owned by Team BETA.

## Summary
- Added server-owned Emotion Settings to Messages TTS profile API responses without changing database schema.
- Updated Message Studio playback readiness to require a matching Emotion Setting on the selected TTS Profile.
- Wired Play Part and Play Message playback values through the existing TextToSpeechEngine registry path using selected TTS Profile language/voice and Emotion Setting pitch/rate/volume/preset.
- Kept Stop routed through the existing TextToSpeechEngine stop path.

## Scope Guard
- No database schema changes.
- No engine core changes.
- No silent playback fallback when a selected TTS Profile lacks the selected Emotion Setting.
- Theme V2 and external JS only.
7 changes: 4 additions & 3 deletions docs_build/dev/reports/codex_changed_files.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
docs_build/dev/reports/codex_changed_files.txt
docs_build/dev/reports/codex_review.diff
docs_build/dev/reports/PR_26171_BETA_079-message-studio-parent-child-table-completion-manual-validation-notes.md
docs_build/dev/reports/PR_26171_BETA_079-message-studio-parent-child-table-completion-validation-report.md
docs_build/dev/reports/PR_26171_BETA_079-message-studio-parent-child-table-completion.md
docs_build/dev/reports/PR_26171_BETA_081-message-playback-through-tts-engine-manual-validation-notes.md
docs_build/dev/reports/PR_26171_BETA_081-message-playback-through-tts-engine-validation-report.md
docs_build/dev/reports/PR_26171_BETA_081-message-playback-through-tts-engine.md
src/dev-runtime/messages/messages-sqlite-service.mjs
tests/playwright/tools/MessagesTool.spec.mjs
toolbox/messages/messages.js
327 changes: 254 additions & 73 deletions docs_build/dev/reports/codex_review.diff

Large diffs are not rendered by default.

33 changes: 30 additions & 3 deletions src/dev-runtime/messages/messages-sqlite-service.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ function normalizeNumber(value, fallback) {
return Number.isFinite(numberValue) ? numberValue : fallback;
}

function emotionSettingKey(value) {
return normalizeText(value)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "neutral";
}

function normalizeInteger(value, fallback) {
const numberValue = Number(value);
return Number.isInteger(numberValue) ? numberValue : fallback;
Expand Down Expand Up @@ -182,12 +190,25 @@ function emotionProfileFromRow(row, usage = {}) {
};
}

function ttsProfileFromRow(row) {
function ttsEmotionSettingFromEmotionProfile(profile) {
return {
active: profile.active !== false,
emotion: emotionSettingKey(profile.name),
emotionLabel: profile.name,
pitch: Number(profile.pitch),
rate: Number(profile.rate),
ssmlLikePreset: "normal",
volume: Number(profile.volume),
};
}

function ttsProfileFromRow(row, emotionSettings = []) {
return {
active: activeFromDatabase(row.active),
createdAt: row.createdAt,
createdBy: row.createdBy,
description: row.description || "",
emotionSettings,
key: row.key,
language: row.language,
name: row.name,
Expand Down Expand Up @@ -593,18 +614,24 @@ export class MessagesSqliteService {
}

listTtsProfiles() {
const emotionSettings = this.listEmotionProfiles()
.filter((profile) => profile.active !== false)
.map(ttsEmotionSettingFromEmotionProfile);
return this.db().prepare(`
SELECT * FROM messages_tts_profiles
ORDER BY name COLLATE NOCASE ASC
`).all().map(ttsProfileFromRow);
`).all().map((row) => ttsProfileFromRow(row, emotionSettings));
}

getTtsProfile(key) {
const row = this.db().prepare("SELECT * FROM messages_tts_profiles WHERE key = ?").get(key);
if (!row) {
throw httpError("TTS profile was not found.", 404);
}
return ttsProfileFromRow(row);
const emotionSettings = this.listEmotionProfiles()
.filter((profile) => profile.active !== false)
.map(ttsEmotionSettingFromEmotionProfile);
return ttsProfileFromRow(row, emotionSettings);
}

findTtsProfileByName(name) {
Expand Down
30 changes: 27 additions & 3 deletions tests/playwright/tools/MessagesTool.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -240,13 +240,34 @@ test("Message Studio renders Messages with child Message Parts and plays ordered
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");
const ttsProfilesResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/tts-profiles`);
expect(ttsProfilesResult.response.ok).toBe(true);
expect(ttsProfilesResult.payload.data.ttsProfiles[0].emotionSettings).toEqual(expect.arrayContaining([
expect.objectContaining({
emotion: "urgent",
emotionLabel: "Urgent",
pitch: 1.08,
rate: 1.15,
volume: 1,
}),
]));

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.slice(-2).map((call) => call.text)).toEqual([
"Bats drop from the rafters.",
"Keep your torch high.",
expect(speechCalls.slice(-2)).toEqual([
expect.objectContaining({
pitch: 1,
rate: 1,
text: "Bats drop from the rafters.",
volume: 1,
}),
expect.objectContaining({
pitch: 1.08,
rate: 1.15,
text: "Keep your torch high.",
volume: 1,
}),
]);
expect(speechCalls.at(-1)).toEqual(expect.objectContaining({
lang: "en-US",
Expand All @@ -262,9 +283,12 @@ test("Message Studio renders Messages with child Message Parts and plays ordered
await expect(page.locator("[data-messages-log]")).toHaveText("Play Part queued Part 2 using Default Balanced TTS Profile.");
speechCalls = await page.evaluate(() => window.__messagesSpeechCalls);
expect(speechCalls.at(-1)).toEqual(expect.objectContaining({
pitch: 1.08,
rate: 1.15,
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();
Expand Down
48 changes: 45 additions & 3 deletions toolbox/messages/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,16 @@ import { createMessageStudioTtsServiceRegistry } from "./message-tts-service-reg

const NEW_ROW_KEY = "__new__";
const DEFAULT_TTS_PROFILE_KEY = "__default-balanced-tts__";
const DEFAULT_TTS_EMOTION_SETTINGS = Object.freeze([
Object.freeze({ active: true, emotion: "calm", emotionLabel: "Calm", pitch: 1, rate: 1, ssmlLikePreset: "normal", volume: 1 }),
Object.freeze({ active: true, emotion: "urgent", emotionLabel: "Urgent", pitch: 1.08, rate: 1.15, ssmlLikePreset: "normal", volume: 1 }),
Object.freeze({ active: true, emotion: "whisper", emotionLabel: "Whisper", pitch: 0.95, rate: 0.9, ssmlLikePreset: "normal", volume: 0.55 }),
Object.freeze({ active: true, emotion: "angry", emotionLabel: "Angry", pitch: 0.98, rate: 1.1, ssmlLikePreset: "normal", volume: 1 }),
Comment on lines +15 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cover all seeded emotions in fallback TTS profile

When there are no active API TTS profiles, activeTtsProfileOptions() falls back to DEFAULT_TTS_PROFILE; however the fallback Emotion Settings added here only cover Calm/Urgent/Whisper/Angry while the seeded active emotions also include Excited, Sad, and Mysterious. In that no-active-profile fallback path, any message or part using one of the omitted seeded emotions now fails in speakTarget() with a missing Emotion Setting error instead of playing with the emotion values as it did before. Include all seeded emotions or derive these fallback settings from state.emotionProfiles.

Useful? React with 👍 / 👎.

]);
const DEFAULT_TTS_PROFILE = Object.freeze({
active: true,
description: "Balanced local browser playback option until authored TTS profiles are available.",
emotionSettings: DEFAULT_TTS_EMOTION_SETTINGS,
key: DEFAULT_TTS_PROFILE_KEY,
language: "en-US",
name: "Default Balanced TTS Profile",
Expand Down Expand Up @@ -264,6 +271,32 @@ function emotionProfileByKey(profileKey) {
return state.emotionProfiles.find((profile) => profile.key === profileKey) || null;
}

function emotionSettingKey(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "neutral";
}

function selectedEmotionSettingForProfile(profile, emotionProfile) {
const settings = Array.isArray(profile?.emotionSettings)
? profile.emotionSettings.filter((setting) => setting?.active !== false)
: [];
const selectedEmotionKey = emotionSettingKey(emotionProfile?.name);
const setting = settings.find((candidate) => (
emotionSettingKey(candidate.emotion) === selectedEmotionKey
|| emotionSettingKey(candidate.emotionLabel) === selectedEmotionKey
));
Comment on lines +287 to +290

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match emotion settings without slug collisions

If two active Emotion Profiles have distinct names that normalize to the same slug, such as High Energy and High-Energy, the API permits both names but this lookup can return the first setting for either profile because it checks the slugified candidate.emotion before the exact label. Playback then applies the wrong pitch/rate/volume for whichever profile appears later in the settings list. Prefer matching by a stable emotion profile key, or at least check the exact emotionLabel before the slug.

Useful? React with 👍 / 👎.

if (!setting) {
return {
message: `Selected TTS Profile "${profile?.name || "Unknown"}" does not include an Emotion Setting for "${emotionProfile?.name || "Unknown"}".`,
ok: false,
};
}
return { ok: true, setting };
}

function activeTtsProfileOptions() {
const activeProfiles = state.ttsProfiles.filter((profile) => profile.active);
return activeProfiles.length ? activeProfiles : [DEFAULT_TTS_PROFILE];
Expand Down Expand Up @@ -428,6 +461,10 @@ function speechTestReadiness() {
if (!target.emotionProfile) {
return { message: "Selected item needs an Emotion before testing speech.", ok: false };
}
const emotionSetting = selectedEmotionSettingForProfile(profile, target.emotionProfile);
if (!emotionSetting.ok) {
return { message: emotionSetting.message, ok: false };
Comment on lines +464 to +466

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate speech readiness against the target profile

When a message or part has its own TTS profile selected in the row, selectedSpeechTarget() stores that profile and testSelectedSpeech() passes target.profile to speakTarget(), but this new readiness check validates the separate Inspector dropdown profile instead. With per-profile Emotion Settings, Test Speech can be disabled by an unrelated profile or shown as ready and then fail on click. Check target.profile || selectedTtsProfile() here so readiness validates the same profile that will actually speak.

Useful? React with 👍 / 👎.

}
if (!String(target.text || "").trim()) {
return { message: "Selected item needs message text before testing speech.", ok: false };
}
Expand Down Expand Up @@ -923,18 +960,23 @@ function speakTarget(service, target, profile) {
if (!target.emotionProfile) {
return visiblePlaybackError("Selected message or part needs an Emotion before playback.");
}
const emotionSetting = selectedEmotionSettingForProfile(profile, target.emotionProfile);
if (!emotionSetting.ok) {
return visiblePlaybackError(emotionSetting.message);
}
Comment on lines +963 to +966

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preflight all parts before queuing playback

When Play Message is used on a multi-part message and a later part lacks a matching Emotion Setting, earlier loop iterations have already called speakTarget() and queued audio before this new guard returns the error. That means the user hears a partial message even though playback is reported as blocked for a missing setting. Resolve settings for every active part before calling the TTS registry, or otherwise stop/avoid queuing earlier parts when any part cannot be played.

Useful? React with 👍 / 👎.

if (!String(target.text || "").trim()) {
return visiblePlaybackError("Selected message or part needs text before playback.");
}
return ttsServiceRegistry.speak(service.key, {
language: profile.language,
pitch: target.emotionProfile.pitch ?? profile.pitch ?? 1,
rate: target.emotionProfile.rate ?? profile.rate ?? 1,
pitch: emotionSetting.setting.pitch ?? profile.pitch ?? 1,
rate: emotionSetting.setting.rate ?? profile.rate ?? 1,
speechItemId: target.id,
speechItemName: target.name,
ssmlLikePreset: emotionSetting.setting.ssmlLikePreset || "normal",
text: target.text,
voice: profile.voiceName,
volume: target.emotionProfile.volume ?? profile.volume ?? 1,
volume: emotionSetting.setting.volume ?? profile.volume ?? 1,
});
}

Expand Down
Loading