Skip to content

Commit bcc5d56

Browse files
authored
fix(azure): fix double-prosody and volume scale when prosody options are provided (#41)
Two bugs in ensureAzureSSMLStructure when rate/pitch/volume options are passed: 1. Double-nested <prosody>: this.properties defaults (rate="medium", pitch="medium", volume=100) are always truthy, so the first block always added a default prosody, then the options block wrapped it with a second one. Merged both blocks into one: options override properties defaults, and a prosody element is only emitted when at least one value differs from Azure's implicit defaults. 2. Volume scale: callers commonly pass volume as a 0-1 float (e.g. 0.8), but the template literal appended "%" directly, producing volume="0.8%" (essentially silent) instead of volume="80%". Values in the range (0, 1] are now treated as fractions and normalised to the 0-100 scale before formatting. Fixes #40
1 parent a26c0b7 commit bcc5d56

2 files changed

Lines changed: 69 additions & 30 deletions

File tree

src/__tests__/azure-mstts-namespace.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,5 +149,40 @@ describe("Azure MSTTS Namespace Handling", () => {
149149
expect(result).toMatch(/<voice[^>]*>\s*<prosody[^>]*>/);
150150
expect(result).toMatch(/<\/prosody>\s*<\/voice>/);
151151
});
152+
153+
it("should produce a single <prosody> element (not double-nested) when options are provided", async () => {
154+
// Regression test for: https://github.com/willwade/js-tts-wrapper/issues/40
155+
// this.properties defaults (rate="medium", pitch="medium", volume=100) were always
156+
// truthy, causing a first prosody to be added, then options adding a second one on top.
157+
const plainSSML = `<speak>Hello world</speak>`;
158+
const options = { rate: "fast", pitch: "high", volume: 80 };
159+
160+
const result = (client as any).ensureAzureSSMLStructure(plainSSML, "en-US-JennyNeural", options);
161+
162+
const prosodyMatches = result.match(/<prosody/g);
163+
expect(prosodyMatches?.length).toBe(1);
164+
});
165+
166+
it("should not emit <prosody> when all values are at Azure defaults", async () => {
167+
// No prosody element needed when everything is at the implicit default
168+
const plainSSML = `<speak>Hello world</speak>`;
169+
const options = { rate: "medium", pitch: "medium", volume: 100 };
170+
171+
const result = (client as any).ensureAzureSSMLStructure(plainSSML, "en-US-JennyNeural", options);
172+
173+
expect(result).not.toContain("<prosody");
174+
});
175+
176+
it("should normalise 0-1 volume fraction to 0-100 percentage", async () => {
177+
// Regression test for: https://github.com/willwade/js-tts-wrapper/issues/40
178+
// Callers commonly pass volume as a 0-1 float; 0.8 should become volume="80%", not "0.8%".
179+
const plainSSML = `<speak>Hello world</speak>`;
180+
const options = { volume: 0.8 };
181+
182+
const result = (client as any).ensureAzureSSMLStructure(plainSSML, "en-US-JennyNeural", options);
183+
184+
expect(result).toContain('volume="80%"');
185+
expect(result).not.toContain('volume="0.8%"');
186+
});
152187
});
153188
});

src/engines/azure.ts

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -616,38 +616,42 @@ export class AzureTTSClient extends AbstractTTSClient {
616616
}
617617
}
618618

619-
// Add prosody if properties are set
620-
if (this.properties.rate || this.properties.pitch || this.properties.volume) {
621-
// Extract content between voice tags or speak tags
622-
let content = "";
623-
if (ssml.includes("<voice")) {
624-
const match = ssml.match(/<voice[^>]*>(.*?)<\/voice>/s);
625-
if (match) {
626-
content = match[1];
627-
const prosodyContent = this.constructProsodyTag(content);
628-
ssml = ssml.replace(content, prosodyContent);
629-
}
630-
} else {
631-
const match = ssml.match(/<speak[^>]*>(.*?)<\/speak>/s);
632-
if (match) {
633-
content = match[1];
634-
const prosodyContent = this.constructProsodyTag(content);
635-
ssml = ssml.replace(content, prosodyContent);
636-
}
619+
// Build prosody attributes by merging this.properties defaults with per-call options.
620+
// Options take precedence. We only emit a <prosody> element when at least one
621+
// attribute differs from Azure's implicit defaults (medium/medium/100%), to avoid
622+
// wrapping content in a no-op element.
623+
{
624+
const DEFAULT_RATE = "medium";
625+
const DEFAULT_PITCH = "medium";
626+
const DEFAULT_VOLUME = 100;
627+
628+
const rate = options?.rate ?? (this.properties.rate as string | undefined);
629+
const pitch = options?.pitch ?? (this.properties.pitch as string | undefined);
630+
// volume: SpeakOptions types volume as 0-100. Guard against callers who pass a
631+
// 0-1 fraction by normalising: any value ≤ 1 (and > 0) is treated as a fraction
632+
// and scaled to 0-100.
633+
let rawVolume: number | undefined =
634+
options?.volume !== undefined
635+
? options.volume
636+
: (this.properties.volume as number | undefined);
637+
if (rawVolume !== undefined && rawVolume > 0 && rawVolume <= 1) {
638+
rawVolume = Math.round(rawVolume * 100);
637639
}
638-
}
640+
const volume = rawVolume !== undefined ? rawVolume : DEFAULT_VOLUME;
641+
642+
const hasNonDefaultProsody =
643+
(rate !== undefined && rate !== DEFAULT_RATE) ||
644+
(pitch !== undefined && pitch !== DEFAULT_PITCH) ||
645+
volume !== DEFAULT_VOLUME;
646+
647+
if (hasNonDefaultProsody) {
648+
const attrs: string[] = [];
649+
if (rate && rate !== DEFAULT_RATE) attrs.push(`rate="${rate}"`);
650+
if (pitch && pitch !== DEFAULT_PITCH) attrs.push(`pitch="${pitch}"`);
651+
if (volume !== DEFAULT_VOLUME) attrs.push(`volume="${volume}%"`);
639652

640-
// Also add prosody from options if provided
641-
if (options?.rate || options?.pitch || options?.volume !== undefined) {
642-
// Create prosody attributes
643-
const attrs: string[] = [];
644-
if (options.rate) attrs.push(`rate="${options.rate}"`);
645-
if (options.pitch) attrs.push(`pitch="${options.pitch}"`);
646-
if (options.volume !== undefined) attrs.push(`volume="${options.volume}%"`);
647-
648-
if (attrs.length > 0) {
649-
// Extract content from inside <voice> if present, otherwise from <speak>.
650-
// Prosody must be nested inside <voice>, not as a direct child of <speak>.
653+
// <prosody> must be nested inside <voice>, not as a direct child of <speak>.
654+
// Azure rejects: Node [speak] should not contain node [prosody] with type [Others].
651655
if (ssml.includes("<voice")) {
652656
const match = ssml.match(/<voice[^>]*>(.*?)<\/voice>/s);
653657
if (match) {

0 commit comments

Comments
 (0)