Skip to content

Commit 2122063

Browse files
committed
PR_26171_BETA_081 message playback through tts engine
1 parent a4f823c commit 2122063

8 files changed

Lines changed: 410 additions & 85 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# PR_26171_BETA_081 Manual Validation Notes
2+
3+
## Review
4+
- Confirmed /api/messages/tts-profiles returns server-owned emotionSettings for playback.
5+
- Confirmed Play Message queues each active Message Part through the TextToSpeechEngine-backed registry.
6+
- Confirmed Play Part uses the selected part TTS Profile and matching Emotion Setting values.
7+
- Confirmed Stop continues through the TextToSpeechEngine-backed registry stop path.
8+
- Confirmed missing Emotion Settings produce a visible validation error instead of falling back silently.
9+
10+
## Manual Browser Coverage
11+
- Covered by targeted Playwright validation for Message Studio playback, TTS profile emotion settings, Play Message, Play Part, and Stop.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# PR_26171_BETA_081 Validation Report
2+
3+
## Commands
4+
- PASS: node --check toolbox/messages/messages.js
5+
- PASS: node --check src/dev-runtime/messages/messages-sqlite-service.mjs
6+
- PASS: npx playwright test tests/playwright/tools/MessagesTool.spec.mjs
7+
- PASS: npx playwright test tests/playwright/tools/TextToSpeechFunctional.spec.mjs --reporter=list
8+
- PASS: node --test tests/tools/Text2SpeechShell.test.mjs
9+
- PASS: npm run test:workspace-v2
10+
- PASS: git diff --check
11+
12+
## Targeted Results
13+
- Message Studio Playwright tests: 2 passed.
14+
- Text To Speech compatibility Playwright tests: 3 passed.
15+
- Text2Speech Node contract tests: 4 passed.
16+
- Project Workspace legacy validation: 5 passed.
17+
18+
## Notes
19+
- 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.
20+
- npm run test:workspace-v2 is a legacy command name; user-facing language remains Project Workspace.
21+
- Standard generated validation-report churn was restored before staging this PR.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# PR_26171_BETA_081-message-playback-through-tts-engine
2+
3+
## Team Ownership
4+
- TEAM: BETA
5+
- Ownership source: docs_build/dev/PROJECT_MULTI_PC.txt
6+
- Scope confirmed: Audio, Messages, Text To Speech, and TTS are owned by Team BETA.
7+
8+
## Summary
9+
- Added server-owned Emotion Settings to Messages TTS profile API responses without changing database schema.
10+
- Updated Message Studio playback readiness to require a matching Emotion Setting on the selected TTS Profile.
11+
- 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.
12+
- Kept Stop routed through the existing TextToSpeechEngine stop path.
13+
14+
## Scope Guard
15+
- No database schema changes.
16+
- No engine core changes.
17+
- No silent playback fallback when a selected TTS Profile lacks the selected Emotion Setting.
18+
- Theme V2 and external JS only.
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
docs_build/dev/reports/codex_changed_files.txt
22
docs_build/dev/reports/codex_review.diff
3-
docs_build/dev/reports/PR_26171_BETA_079-message-studio-parent-child-table-completion-manual-validation-notes.md
4-
docs_build/dev/reports/PR_26171_BETA_079-message-studio-parent-child-table-completion-validation-report.md
5-
docs_build/dev/reports/PR_26171_BETA_079-message-studio-parent-child-table-completion.md
3+
docs_build/dev/reports/PR_26171_BETA_081-message-playback-through-tts-engine-manual-validation-notes.md
4+
docs_build/dev/reports/PR_26171_BETA_081-message-playback-through-tts-engine-validation-report.md
5+
docs_build/dev/reports/PR_26171_BETA_081-message-playback-through-tts-engine.md
6+
src/dev-runtime/messages/messages-sqlite-service.mjs
67
tests/playwright/tools/MessagesTool.spec.mjs
78
toolbox/messages/messages.js

docs_build/dev/reports/codex_review.diff

Lines changed: 254 additions & 73 deletions
Large diffs are not rendered by default.

src/dev-runtime/messages/messages-sqlite-service.mjs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ function normalizeNumber(value, fallback) {
9696
return Number.isFinite(numberValue) ? numberValue : fallback;
9797
}
9898

99+
function emotionSettingKey(value) {
100+
return normalizeText(value)
101+
.trim()
102+
.toLowerCase()
103+
.replace(/[^a-z0-9]+/g, "-")
104+
.replace(/^-+|-+$/g, "") || "neutral";
105+
}
106+
99107
function normalizeInteger(value, fallback) {
100108
const numberValue = Number(value);
101109
return Number.isInteger(numberValue) ? numberValue : fallback;
@@ -182,12 +190,25 @@ function emotionProfileFromRow(row, usage = {}) {
182190
};
183191
}
184192

185-
function ttsProfileFromRow(row) {
193+
function ttsEmotionSettingFromEmotionProfile(profile) {
194+
return {
195+
active: profile.active !== false,
196+
emotion: emotionSettingKey(profile.name),
197+
emotionLabel: profile.name,
198+
pitch: Number(profile.pitch),
199+
rate: Number(profile.rate),
200+
ssmlLikePreset: "normal",
201+
volume: Number(profile.volume),
202+
};
203+
}
204+
205+
function ttsProfileFromRow(row, emotionSettings = []) {
186206
return {
187207
active: activeFromDatabase(row.active),
188208
createdAt: row.createdAt,
189209
createdBy: row.createdBy,
190210
description: row.description || "",
211+
emotionSettings,
191212
key: row.key,
192213
language: row.language,
193214
name: row.name,
@@ -593,18 +614,24 @@ export class MessagesSqliteService {
593614
}
594615

595616
listTtsProfiles() {
617+
const emotionSettings = this.listEmotionProfiles()
618+
.filter((profile) => profile.active !== false)
619+
.map(ttsEmotionSettingFromEmotionProfile);
596620
return this.db().prepare(`
597621
SELECT * FROM messages_tts_profiles
598622
ORDER BY name COLLATE NOCASE ASC
599-
`).all().map(ttsProfileFromRow);
623+
`).all().map((row) => ttsProfileFromRow(row, emotionSettings));
600624
}
601625

602626
getTtsProfile(key) {
603627
const row = this.db().prepare("SELECT * FROM messages_tts_profiles WHERE key = ?").get(key);
604628
if (!row) {
605629
throw httpError("TTS profile was not found.", 404);
606630
}
607-
return ttsProfileFromRow(row);
631+
const emotionSettings = this.listEmotionProfiles()
632+
.filter((profile) => profile.active !== false)
633+
.map(ttsEmotionSettingFromEmotionProfile);
634+
return ttsProfileFromRow(row, emotionSettings);
608635
}
609636

610637
findTtsProfileByName(name) {

tests/playwright/tools/MessagesTool.spec.mjs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,13 +240,34 @@ test("Message Studio renders Messages with child Message Parts and plays ordered
240240
await expect(page.locator("[data-messages-log]")).toHaveText("Updated message part 2.");
241241
await expect(page.locator("[data-messages-segment-row]")).toHaveCount(2);
242242
await expect(page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" })).toContainText("2");
243+
const ttsProfilesResult = await jsonRequest(`${failures.server.baseUrl}/api/messages/tts-profiles`);
244+
expect(ttsProfilesResult.response.ok).toBe(true);
245+
expect(ttsProfilesResult.payload.data.ttsProfiles[0].emotionSettings).toEqual(expect.arrayContaining([
246+
expect.objectContaining({
247+
emotion: "urgent",
248+
emotionLabel: "Urgent",
249+
pitch: 1.08,
250+
rate: 1.15,
251+
volume: 1,
252+
}),
253+
]));
243254

244255
await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Play Message" }).click();
245256
await expect(page.locator("[data-messages-log]")).toHaveText("Play Message queued 2 parts for Bat Encounter.");
246257
let speechCalls = await page.evaluate(() => window.__messagesSpeechCalls);
247-
expect(speechCalls.slice(-2).map((call) => call.text)).toEqual([
248-
"Bats drop from the rafters.",
249-
"Keep your torch high.",
258+
expect(speechCalls.slice(-2)).toEqual([
259+
expect.objectContaining({
260+
pitch: 1,
261+
rate: 1,
262+
text: "Bats drop from the rafters.",
263+
volume: 1,
264+
}),
265+
expect.objectContaining({
266+
pitch: 1.08,
267+
rate: 1.15,
268+
text: "Keep your torch high.",
269+
volume: 1,
270+
}),
250271
]);
251272
expect(speechCalls.at(-1)).toEqual(expect.objectContaining({
252273
lang: "en-US",
@@ -262,9 +283,12 @@ test("Message Studio renders Messages with child Message Parts and plays ordered
262283
await expect(page.locator("[data-messages-log]")).toHaveText("Play Part queued Part 2 using Default Balanced TTS Profile.");
263284
speechCalls = await page.evaluate(() => window.__messagesSpeechCalls);
264285
expect(speechCalls.at(-1)).toEqual(expect.objectContaining({
286+
pitch: 1.08,
287+
rate: 1.15,
265288
text: "Keep your torch high.",
266289
type: "speak",
267290
voiceName: "Test Voice",
291+
volume: 1,
268292
}));
269293

270294
await page.locator("[data-messages-row]").filter({ hasText: "Bat Encounter" }).getByRole("button", { name: "Edit Message" }).click();

toolbox/messages/messages.js

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,16 @@ import { createMessageStudioTtsServiceRegistry } from "./message-tts-service-reg
1212

1313
const NEW_ROW_KEY = "__new__";
1414
const DEFAULT_TTS_PROFILE_KEY = "__default-balanced-tts__";
15+
const DEFAULT_TTS_EMOTION_SETTINGS = Object.freeze([
16+
Object.freeze({ active: true, emotion: "calm", emotionLabel: "Calm", pitch: 1, rate: 1, ssmlLikePreset: "normal", volume: 1 }),
17+
Object.freeze({ active: true, emotion: "urgent", emotionLabel: "Urgent", pitch: 1.08, rate: 1.15, ssmlLikePreset: "normal", volume: 1 }),
18+
Object.freeze({ active: true, emotion: "whisper", emotionLabel: "Whisper", pitch: 0.95, rate: 0.9, ssmlLikePreset: "normal", volume: 0.55 }),
19+
Object.freeze({ active: true, emotion: "angry", emotionLabel: "Angry", pitch: 0.98, rate: 1.1, ssmlLikePreset: "normal", volume: 1 }),
20+
]);
1521
const DEFAULT_TTS_PROFILE = Object.freeze({
1622
active: true,
1723
description: "Balanced local browser playback option until authored TTS profiles are available.",
24+
emotionSettings: DEFAULT_TTS_EMOTION_SETTINGS,
1825
key: DEFAULT_TTS_PROFILE_KEY,
1926
language: "en-US",
2027
name: "Default Balanced TTS Profile",
@@ -264,6 +271,32 @@ function emotionProfileByKey(profileKey) {
264271
return state.emotionProfiles.find((profile) => profile.key === profileKey) || null;
265272
}
266273

274+
function emotionSettingKey(value) {
275+
return String(value || "")
276+
.trim()
277+
.toLowerCase()
278+
.replace(/[^a-z0-9]+/g, "-")
279+
.replace(/^-+|-+$/g, "") || "neutral";
280+
}
281+
282+
function selectedEmotionSettingForProfile(profile, emotionProfile) {
283+
const settings = Array.isArray(profile?.emotionSettings)
284+
? profile.emotionSettings.filter((setting) => setting?.active !== false)
285+
: [];
286+
const selectedEmotionKey = emotionSettingKey(emotionProfile?.name);
287+
const setting = settings.find((candidate) => (
288+
emotionSettingKey(candidate.emotion) === selectedEmotionKey
289+
|| emotionSettingKey(candidate.emotionLabel) === selectedEmotionKey
290+
));
291+
if (!setting) {
292+
return {
293+
message: `Selected TTS Profile "${profile?.name || "Unknown"}" does not include an Emotion Setting for "${emotionProfile?.name || "Unknown"}".`,
294+
ok: false,
295+
};
296+
}
297+
return { ok: true, setting };
298+
}
299+
267300
function activeTtsProfileOptions() {
268301
const activeProfiles = state.ttsProfiles.filter((profile) => profile.active);
269302
return activeProfiles.length ? activeProfiles : [DEFAULT_TTS_PROFILE];
@@ -428,6 +461,10 @@ function speechTestReadiness() {
428461
if (!target.emotionProfile) {
429462
return { message: "Selected item needs an Emotion before testing speech.", ok: false };
430463
}
464+
const emotionSetting = selectedEmotionSettingForProfile(profile, target.emotionProfile);
465+
if (!emotionSetting.ok) {
466+
return { message: emotionSetting.message, ok: false };
467+
}
431468
if (!String(target.text || "").trim()) {
432469
return { message: "Selected item needs message text before testing speech.", ok: false };
433470
}
@@ -923,18 +960,23 @@ function speakTarget(service, target, profile) {
923960
if (!target.emotionProfile) {
924961
return visiblePlaybackError("Selected message or part needs an Emotion before playback.");
925962
}
963+
const emotionSetting = selectedEmotionSettingForProfile(profile, target.emotionProfile);
964+
if (!emotionSetting.ok) {
965+
return visiblePlaybackError(emotionSetting.message);
966+
}
926967
if (!String(target.text || "").trim()) {
927968
return visiblePlaybackError("Selected message or part needs text before playback.");
928969
}
929970
return ttsServiceRegistry.speak(service.key, {
930971
language: profile.language,
931-
pitch: target.emotionProfile.pitch ?? profile.pitch ?? 1,
932-
rate: target.emotionProfile.rate ?? profile.rate ?? 1,
972+
pitch: emotionSetting.setting.pitch ?? profile.pitch ?? 1,
973+
rate: emotionSetting.setting.rate ?? profile.rate ?? 1,
933974
speechItemId: target.id,
934975
speechItemName: target.name,
976+
ssmlLikePreset: emotionSetting.setting.ssmlLikePreset || "normal",
935977
text: target.text,
936978
voice: profile.voiceName,
937-
volume: target.emotionProfile.volume ?? profile.volume ?? 1,
979+
volume: emotionSetting.setting.volume ?? profile.volume ?? 1,
938980
});
939981
}
940982

0 commit comments

Comments
 (0)