Skip to content

Commit 79f9ad6

Browse files
authored
fix(azure): nest <prosody> inside <voice>, not as direct child of <speak> (#39)
When rate, pitch, or volume were passed as options to ensureAzureSSMLStructure, the prosody element was being extracted from inside <speak> rather than from inside <voice>. This produced invalid SSML that Azure rejects with: "Node [speak] with type [RootSpeak] should not contain node [prosody] with type [Others]." Fix: when a <voice> element is present, replace content inside <voice> (mirroring the existing this.properties branch above). Add regression test. Fixes #38
1 parent 5340074 commit 79f9ad6

2 files changed

Lines changed: 37 additions & 6 deletions

File tree

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,5 +128,26 @@ describe("Azure MSTTS Namespace Handling", () => {
128128
const xmlnsMatches = result.match(/xmlns="http:\/\/www\.w3\.org\/2001\/10\/synthesis"/g);
129129
expect(xmlnsMatches?.length).toBe(1);
130130
});
131+
132+
it("should nest <prosody> inside <voice>, not as a direct child of <speak>", async () => {
133+
// Regression test for: https://github.com/willwade/js-tts-wrapper/issues/38
134+
// When rate/pitch/volume are passed as options, <prosody> was placed outside
135+
// <voice>, which Azure rejects with:
136+
// "Node [speak] with type [RootSpeak] should not contain node [prosody] with type [Others]"
137+
const plainSSML = `<speak>Hello world</speak>`;
138+
const options = { rate: "fast", pitch: "high", volume: 80 };
139+
140+
const result = (client as any).ensureAzureSSMLStructure(plainSSML, "en-US-JennyNeural", options);
141+
142+
// <prosody> must appear after <voice>, not before it
143+
const voiceIndex = result.indexOf("<voice");
144+
const prosodyIndex = result.indexOf("<prosody");
145+
expect(voiceIndex).toBeGreaterThan(-1);
146+
expect(prosodyIndex).toBeGreaterThan(voiceIndex);
147+
148+
// The structure must be <speak><voice><prosody>...</prosody></voice></speak>
149+
expect(result).toMatch(/<voice[^>]*>\s*<prosody[^>]*>/);
150+
expect(result).toMatch(/<\/prosody>\s*<\/voice>/);
151+
});
131152
});
132153
});

src/engines/azure.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -646,12 +646,22 @@ export class AzureTTSClient extends AbstractTTSClient {
646646
if (options.volume !== undefined) attrs.push(`volume="${options.volume}%"`);
647647

648648
if (attrs.length > 0) {
649-
// Extract content
650-
const match = ssml.match(/<speak[^>]*>(.*?)<\/speak>/s);
651-
if (match) {
652-
const content = match[1];
653-
const prosodyContent = `<prosody ${attrs.join(" ")}>${content}</prosody>`;
654-
ssml = ssml.replace(content, prosodyContent);
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>.
651+
if (ssml.includes("<voice")) {
652+
const match = ssml.match(/<voice[^>]*>(.*?)<\/voice>/s);
653+
if (match) {
654+
const content = match[1];
655+
const prosodyContent = `<prosody ${attrs.join(" ")}>${content}</prosody>`;
656+
ssml = ssml.replace(content, prosodyContent);
657+
}
658+
} else {
659+
const match = ssml.match(/<speak[^>]*>(.*?)<\/speak>/s);
660+
if (match) {
661+
const content = match[1];
662+
const prosodyContent = `<prosody ${attrs.join(" ")}>${content}</prosody>`;
663+
ssml = ssml.replace(content, prosodyContent);
664+
}
655665
}
656666
}
657667
}

0 commit comments

Comments
 (0)