` XML tags to `[...]` bracket format. */
+export function convertExpressionTags(text: string): string {
+ return text
+ .replace(EXPRESSION_RE, (_m, value: string) => `[${value}]`)
+ .replace(SOUND_RE, (_m, value: string) => `[${value}]`);
+}
+
+const VALUE_ATTR_RE = /\b[\w-]+\s*=\s*"([^"]*)"/;
+
+/**
+ * Horizontal whitespace immediately before a tag. Every removal pattern captures it as
+ * `pre` so {@link dedupRemovalSpace} can decide whether to keep it; newlines are excluded
+ * so paragraph breaks are never touched.
+ *
+ * @internal
+ */
+export const LEADING_WS = '(?[^\\S\\r\\n]*)';
+
+/**
+ * Replacement text for a stripped tag, minus the space its removal would double.
+ *
+ * The instructions place an expression marker before *every* sentence, so a turn is
+ * written with a marker delimited like a word — a space on each side — at every sentence
+ * boundary:
+ *
+ * ```
+ * Oh no, I'm sorry to hear
+ * that. I can certainly see what
+ * we have available for you.
+ * ```
+ *
+ * Both spaces are correct while the marker is there. Stripping it for the transcript
+ * collapses its width to zero and leaves both behind, so every sentence lands with two
+ * spaces after its punctuation (`"to hear that. I can certainly"`) — every sentence of
+ * every expressive turn, not an edge case.
+ *
+ * When nothing of the tag survives and whitespace follows the match, the whitespace
+ * captured *before* it (the `pre` group) is therefore dropped so a single separator
+ * remains — matching what `dropBracketCues` already does for bracket cues.
+ *
+ * Whitespace before a tag at the very end of *text* is kept: it may be the separator for
+ * words still streaming in, and the sinks dedup that seam themselves.
+ *
+ * @param pre - The whitespace the pattern captured before the tag.
+ * @param kept - The text that survives the removal (a wrapping tag's inner text or the
+ * native tag it lowers to), or `''` when the tag vanishes entirely.
+ * @param source - The full string being replaced.
+ * @param matchEnd - Index just past the end of the match in `source`.
+ *
+ * @internal
+ */
+export function dedupRemovalSpace(
+ pre: string,
+ kept: string,
+ source: string,
+ matchEnd: number,
+): string {
+ if (kept) return pre + kept;
+ if (!pre) return '';
+ const nxt = source.slice(matchEnd, matchEnd + 1);
+ return nxt !== '' && /\s/.test(nxt) ? '' : pre;
+}
+
+/**
+ * A replacer that receives the named groups, the match offset and the source string.
+ *
+ * `String.prototype.replace` passes `(match, ...groups, offset, string, groups?)`; the
+ * groups object is only appended when the pattern has named groups, so the positional
+ * arguments have to be read from the tail.
+ *
+ * @internal
+ */
+export function replaceWithGroups(
+ text: string,
+ pattern: RegExp,
+ fn: (args: {
+ match: string;
+ groups: Record;
+ offset: number;
+ source: string;
+ }) => string,
+): string {
+ return text.replace(pattern, (...args: unknown[]): string => {
+ const match = args[0] as string;
+ const groups = (args[args.length - 1] ?? {}) as Record;
+ const source = args[args.length - 2] as string;
+ const offset = args[args.length - 3] as number;
+ return fn({ match, groups, offset, source });
+ });
+}
+
+/** A markup tag stripped from text: the XML tag name and its payload. */
+export type StrippedTag = [tag: string, value: string];
+
+/**
+ * Strip XML markup tags and collect the stripped tags in a single pass.
+ *
+ * One regex scan both removes the markup and records each removed tag, so stripping and
+ * extraction can never disagree about what counts as a tag.
+ *
+ * Only XML-shaped markup is recognized. Square brackets are left alone: in LLM output
+ * they are prose (`[text](url)` links) that a strip would mangle, and provider-native
+ * ones are removed at their source by `dropBracketCues`.
+ *
+ * Returns `[cleanText, tags]` where `tags` is a list of `[type, value]` pairs in order of
+ * appearance:
+ *
+ * - `type` is the XML tag name.
+ * - `value` is a content tag's inner text (`A7X9` -> `"A7X9"`), else its
+ * first quoted attribute value (`` -> `"happy"`), falling back
+ * to `""`. Names in `attributeTags` invert that preference — see the parameter.
+ *
+ * Wrapping tags keep their inner content in `cleanText` (only the delimiters are
+ * removed); self-closing and lone tags are removed entirely.
+ *
+ * @param text - The text containing markup.
+ * @param xmlTags - XML tag names to handle (e.g. `['emotion', 'sound']`).
+ * @param attributeTags - Tag names whose payload is an attribute, never their content
+ * (`expression`, `emotion`, ...). These are self-closing by definition, but a model
+ * that writes `Hello there` would otherwise have
+ * the spoken sentence recorded as the delivery label and published as `lk.expression`.
+ * `normalizeMarkup` repairs that tag shape only on the audio path, so the transcript
+ * sinks see the raw form and have to handle it here.
+ */
+export function extractAndStrip(
+ text: string,
+ xmlTags: string[],
+ attributeTags: ReadonlySet = new Set(),
+): [string, StrippedTag[]] {
+ if (xmlTags.length === 0) {
+ return [text, []];
+ }
+
+ const tagPattern = xmlTags.map(escapeRegExp).join('|');
+ const pattern = new RegExp(
+ // leading space is part of the match so removing a tag can't double the separator
+ LEADING_WS +
+ '(?:' +
+ // self-closing ``, matched first and terminal: it has no span, so it must
+ // never consume a following `` — that would swallow whatever sat between the
+ // two and record it as this tag's value
+ `<(?${tagPattern})\\b(?[^>]*?)\\s*/\\s*>` +
+ // `` optionally followed by inner
+ `|<(?${tagPattern})\\b(?[^>]*?)\\s*>` +
+ '(?:(?.*?)\\k\\s*>)?' +
+ // lone closing tag:
+ `|(?:${tagPattern})\\s*>` +
+ ')',
+ 'gs',
+ );
+
+ const tags: StrippedTag[] = [];
+
+ const replacer = ({
+ groups,
+ match,
+ offset,
+ source,
+ }: {
+ match: string;
+ groups: Record;
+ offset: number;
+ source: string;
+ }): string => {
+ const pre = groups.pre ?? '';
+ const end = offset + match.length;
+
+ if (groups.selfTag !== undefined) {
+ const attrMatch = VALUE_ATTR_RE.exec(groups.selfAttrs ?? '');
+ tags.push([groups.selfTag, attrMatch ? attrMatch[1]! : '']);
+ return dedupRemovalSpace(pre, '', source, end); // self-closing tags vanish
+ }
+
+ const tag = groups.tag;
+ if (tag !== undefined) {
+ const inner = groups.inner;
+ const attrMatch = VALUE_ATTR_RE.exec(groups.attrs ?? '');
+ const attrValue = attrMatch ? attrMatch[1]! : '';
+ // an attribute-carrying tag's payload is the attribute even when the model wrapped
+ // text in it; everything else is a content tag, whose inner text wins
+ let value: string;
+ if (attributeTags.has(tag)) {
+ value = attrValue || (inner?.trim() ?? '');
+ } else if (inner !== undefined && inner.trim()) {
+ value = inner.trim();
+ } else {
+ value = attrValue;
+ }
+ tags.push([tag, value]);
+ // wrapping tags keep their inner content; lone open tags vanish
+ return dedupRemovalSpace(pre, inner || '', source, end);
+ }
+ return dedupRemovalSpace(pre, '', source, end); // lone closing tag
+ };
+
+ // iterate to a fixed point so nested wrapping tags are fully removed: a single pass
+ // strips only the outer tag (e.g. hi -> keeps the
+ // inner hi), so repeat until the text stops changing. Each pass removes
+ // at least the matched delimiters, so this always terminates.
+ let clean = text;
+ let prev: string | undefined;
+ while (clean !== prev) {
+ prev = clean;
+ clean = replaceWithGroups(clean, pattern, replacer);
+ }
+ return [clean, tags];
+}
+
+/** @internal */
+export function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
diff --git a/agents/src/tts/mood.ts b/agents/src/tts/mood.ts
new file mode 100644
index 000000000..7b522a201
--- /dev/null
+++ b/agents/src/tts/mood.ts
@@ -0,0 +1,99 @@
+// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * Normalize a free-form delivery label into a small, fixed set of moods.
+ *
+ * The label space is open-ended: Fish Audio emits single words from a closed set, Inworld
+ * emits free-form English ("soft, with genuine care"), and models drift outside whichever
+ * set they were given. Matching here is best-effort, and happens agent-side so no client
+ * SDK needs its own copy of the keyword table.
+ */
+import { MOOD_KEYWORDS } from './mood_data.js';
+
+export type AgentMood =
+ | 'excited'
+ | 'happy'
+ | 'playful'
+ | 'curious'
+ | 'surprised'
+ | 'hopeful'
+ | 'empathetic'
+ | 'sad'
+ | 'angry'
+ | 'anxious'
+ | 'calm';
+
+/**
+ * Tie-break order, most specific first. Two moods scoring equally on a compound label
+ * resolve to whichever appears earlier here.
+ */
+export const MOOD_PRIORITY: AgentMood[] = [
+ 'angry',
+ 'sad',
+ 'anxious',
+ 'surprised',
+ 'playful',
+ 'empathetic',
+ 'excited',
+ 'curious',
+ 'hopeful',
+ 'happy',
+ 'calm',
+];
+
+/**
+ * `calm` is the most recessive mood, so an unmatched label reads as "no strong signal"
+ * rather than asserting a feeling the agent never expressed.
+ */
+export const DEFAULT_MOOD: AgentMood = 'calm';
+
+const ALPHA_RE = /[a-z]/i;
+
+function matchesWord(text: string, keyword: string): boolean {
+ // word starts only: matching mid-word read "like a pirate" as `angry`, via the "irate" stem
+ let start = 0;
+ for (;;) {
+ const at = text.indexOf(keyword, start);
+ if (at === -1) return false;
+ if (at === 0 || !ALPHA_RE.test(text[at - 1]!)) return true;
+ start = at + 1;
+ }
+}
+
+/**
+ * Match a raw delivery label to a mood.
+ *
+ * Matching is keyword-based and deliberately lossy: the label space is open-ended, so an
+ * unrecognized label resolves to `fallback` rather than a wrong guess.
+ *
+ * @param label - The raw delivery label, as the provider wrote it.
+ * @param fallback - Mood to return when nothing matches. Pass `null` to handle the miss
+ * yourself. Defaults to {@link DEFAULT_MOOD}.
+ *
+ * @example
+ * ```ts
+ * matchMood('soft, with genuine care'); // 'empathetic'
+ * matchMood('like a pirate'); // 'calm'
+ * ```
+ */
+export function matchMood(label: string, fallback: AgentMood | null = DEFAULT_MOOD) {
+ const text = label.toLowerCase();
+ let best: AgentMood | null = null;
+ let bestScore = 0;
+
+ for (const mood of MOOD_PRIORITY) {
+ let score = 0;
+ for (const [keyword, weight] of Object.entries(MOOD_KEYWORDS[mood])) {
+ if (matchesWord(text, keyword)) score += weight;
+ }
+ // strictly greater, so MOOD_PRIORITY breaks ties
+ if (score > bestScore) {
+ best = mood;
+ bestScore = score;
+ }
+ }
+
+ return best !== null ? best : fallback;
+}
diff --git a/agents/src/tts/mood_data.ts b/agents/src/tts/mood_data.ts
new file mode 100644
index 000000000..87b1ea0b8
--- /dev/null
+++ b/agents/src/tts/mood_data.ts
@@ -0,0 +1,358 @@
+// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
+//
+// SPDX-License-Identifier: Apache-2.0
+import type { AgentMood } from './mood.js';
+
+/**
+ * Weighted keywords per mood: the emotion words from Parrott's hierarchical classification
+ * of emotions (2001) mapped onto the moods in `mood.ts`, plus the delivery descriptors
+ * providers use in place of naming a feeling ("bright", "hushed", "clipped").
+ *
+ * Weight 2 names a mood outright; weight 1 is a supporting descriptor that loses to an
+ * explicit naming elsewhere in the same label. That is what lets "gently curious,
+ * welcoming" resolve to `curious` rather than `empathetic`.
+ */
+export const MOOD_KEYWORDS: Record> = {
+ excited: {
+ excit: 2,
+ elat: 2,
+ thrill: 2,
+ exhilarat: 2,
+ ecstat: 2,
+ euphor: 2,
+ zeal: 2,
+ zest: 2,
+ enthusias: 2,
+ eager: 2,
+ giddy: 2,
+ hyped: 2,
+ pumped: 2,
+ buzzing: 2,
+ jubilant: 2,
+ jubilation: 2,
+ exuberant: 2,
+ rapture: 2,
+ enthrall: 2,
+ triumph: 2,
+ gleeful: 2,
+ glee: 2,
+ punchy: 2,
+ upbeat: 1,
+ bright: 1,
+ energetic: 1,
+ energy: 1,
+ lively: 1,
+ animated: 1,
+ vibrant: 1,
+ spirited: 1,
+ peppy: 1,
+ snappy: 1,
+ fast: 1,
+ loud: 1,
+ },
+ happy: {
+ happy: 2,
+ happiness: 2,
+ joy: 2,
+ joviality: 2,
+ jolli: 2,
+ cheer: 2,
+ glad: 2,
+ delight: 2,
+ pleas: 2,
+ content: 2,
+ bliss: 2,
+ gaiety: 2,
+ enjoy: 2,
+ satisf: 2,
+ relief: 2,
+ relieved: 2,
+ grateful: 2,
+ thankful: 2,
+ affection: 2,
+ fond: 2,
+ adore: 2,
+ proud: 2,
+ pride: 2,
+ smil: 2,
+ sunny: 2,
+ merry: 2,
+ amiable: 2,
+ warm: 1,
+ inviting: 1,
+ welcom: 1,
+ friendly: 1,
+ kind: 1,
+ pleasant: 1,
+ positive: 1,
+ easy: 1,
+ },
+ playful: {
+ playful: 2,
+ jok: 2,
+ comedic: 2,
+ comic: 2,
+ sarcas: 2,
+ teas: 2,
+ witty: 2,
+ silly: 2,
+ goofy: 2,
+ mischiev: 2,
+ amus: 2,
+ humor: 2,
+ humour: 2,
+ cheeky: 2,
+ sassy: 2,
+ ironic: 2,
+ irony: 2,
+ deadpan: 2,
+ banter: 2,
+ laugh: 2,
+ giggl: 2,
+ chuckl: 2,
+ grin: 2,
+ unimpressed: 2,
+ smirk: 2,
+ wry: 1,
+ sly: 1,
+ impish: 1,
+ },
+ curious: {
+ curious: 2,
+ curiosity: 2,
+ inquisitive: 2,
+ intrigu: 2,
+ wonder: 2,
+ quizzical: 2,
+ probing: 2,
+ interested: 2,
+ engaged: 2,
+ attentive: 2,
+ suspense: 2,
+ questioning: 1,
+ question: 1,
+ prompting: 1,
+ exploring: 1,
+ },
+ surprised: {
+ surpris: 2,
+ amaz: 2,
+ astonish: 2,
+ astound: 2,
+ awe: 2,
+ shock: 2,
+ startl: 2,
+ incredulous: 2,
+ stunned: 2,
+ bewilder: 2,
+ flabbergast: 2,
+ disbelie: 2,
+ unexpected: 2,
+ dumbfound: 2,
+ wow: 2,
+ whoa: 2,
+ gasp: 2,
+ },
+ hopeful: {
+ hopeful: 2,
+ hope: 2,
+ optimis: 2,
+ encourag: 2,
+ uplift: 2,
+ inspir: 2,
+ motivat: 2,
+ promising: 2,
+ determined: 2,
+ resolute: 2,
+ forward: 2,
+ buoyant: 2,
+ expectant: 2,
+ heartened: 2,
+ confident: 2,
+ assured: 2,
+ },
+ empathetic: {
+ empath: 2,
+ sympath: 2,
+ compassion: 2,
+ concern: 2,
+ care: 2,
+ tender: 2,
+ consol: 2,
+ sorry: 2,
+ apolog: 2,
+ understanding: 2,
+ supportive: 2,
+ comfort: 2,
+ soothing: 2,
+ nurtur: 2,
+ patient: 2,
+ earnest: 2,
+ heartfelt: 2,
+ sensitive: 2,
+ pity: 2,
+ condolence: 2,
+ sentimental: 2,
+ sincere: 1,
+ gentle: 1,
+ gently: 1,
+ soft: 1,
+ quiet: 1,
+ hushed: 1,
+ mild: 1,
+ reassur: 1,
+ },
+ sad: {
+ sad: 2,
+ sorrow: 2,
+ mourn: 2,
+ somber: 2,
+ sombre: 2,
+ melanchol: 2,
+ grief: 2,
+ griev: 2,
+ regret: 2,
+ remorse: 2,
+ deject: 2,
+ downcast: 2,
+ gloom: 2,
+ glum: 2,
+ forlorn: 2,
+ wistful: 2,
+ disappoint: 2,
+ dismay: 2,
+ crestfallen: 2,
+ despond: 2,
+ despair: 2,
+ depress: 2,
+ anguish: 2,
+ agony: 2,
+ woe: 2,
+ miser: 2,
+ unhappy: 2,
+ tearful: 2,
+ weep: 2,
+ heartbroken: 2,
+ heartbreak: 2,
+ lonely: 2,
+ loneliness: 2,
+ ashamed: 2,
+ shame: 2,
+ guilt: 2,
+ humiliat: 2,
+ mortified: 2,
+ longing: 2,
+ homesick: 2,
+ defeated: 2,
+ heavy: 1,
+ subdued: 1,
+ flat: 1,
+ weary: 1,
+ resigned: 1,
+ hollow: 1,
+ },
+ angry: {
+ angry: 2,
+ anger: 2,
+ furious: 2,
+ fury: 2,
+ frustrat: 2,
+ annoy: 2,
+ irritat: 2,
+ irate: 2,
+ indignant: 2,
+ outrag: 2,
+ exasperat: 2,
+ incensed: 2,
+ livid: 2,
+ wrath: 2,
+ hostil: 2,
+ resent: 2,
+ bitter: 2,
+ disgust: 2,
+ revulsion: 2,
+ contempt: 2,
+ loathing: 2,
+ scorn: 2,
+ spite: 2,
+ aggravat: 2,
+ agitat: 2,
+ grouchy: 2,
+ grumpy: 2,
+ seething: 2,
+ fuming: 2,
+ scold: 2,
+ stern: 2,
+ harsh: 2,
+ sharp: 1,
+ clipped: 1,
+ terse: 1,
+ cold: 1,
+ biting: 1,
+ curt: 1,
+ },
+ anxious: {
+ anxious: 2,
+ anxiety: 2,
+ afraid: 2,
+ fear: 2,
+ fright: 2,
+ scared: 2,
+ nervous: 2,
+ worri: 2,
+ uneasy: 2,
+ unease: 2,
+ tense: 2,
+ apprehensive: 2,
+ panic: 2,
+ alarm: 2,
+ dread: 2,
+ terror: 2,
+ horror: 2,
+ hesitant: 2,
+ unsure: 2,
+ uncertain: 2,
+ timid: 2,
+ jittery: 2,
+ urgent: 2,
+ stress: 2,
+ distress: 2,
+ insecure: 2,
+ flustered: 2,
+ cautious: 1,
+ wary: 1,
+ guarded: 1,
+ },
+ calm: {
+ calm: 2,
+ contemplative: 2,
+ thoughtful: 2,
+ measured: 2,
+ serene: 2,
+ easygoing: 2,
+ neutral: 2,
+ relax: 2,
+ composed: 2,
+ collected: 2,
+ tranquil: 2,
+ peaceful: 2,
+ restrained: 2,
+ reflective: 2,
+ pensive: 2,
+ matter: 2,
+ plain: 2,
+ professional: 2,
+ formal: 2,
+ informative: 2,
+ factual: 2,
+ deliberate: 2,
+ unhurried: 2,
+ placid: 2,
+ slow: 2,
+ steady: 1,
+ grounded: 1,
+ balanced: 1,
+ straightforward: 1,
+ even: 1,
+ },
+};
diff --git a/agents/src/tts/provider_format.test.ts b/agents/src/tts/provider_format.test.ts
new file mode 100644
index 000000000..2584de12b
--- /dev/null
+++ b/agents/src/tts/provider_format.test.ts
@@ -0,0 +1,759 @@
+// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * Tests for the LiveKit expression marker (expr) dialect.
+ *
+ * The LLM emits a single marker tag — `` (self-closing for
+ * expression/break/sound, wrapping for prosody/spell) — and the framework lowers it to
+ * each provider's native markup before synthesis while stripping it from transcripts. The
+ * syntax is shared, but the kinds and label vocabularies are per provider: each provider's
+ * instruction block advertises only what that provider supports.
+ */
+import { describe, expect, it } from 'vitest';
+import { ChatContext, ChatMessage } from '../llm/chat_context.js';
+import { type TimedString, createTimedString } from '../voice/io.js';
+import { matchMood } from './mood.js';
+import {
+ TranscriptMarkupStripper,
+ convertMarkup,
+ dropBracketCues,
+ expressionAttribute,
+ llmInstructions,
+ maxInputLen,
+ normalizeMarkup,
+ sentenceTokenizer,
+ splitAllMarkup,
+ steeringInstructions,
+ stripAllMarkup,
+ stripExprMarkup,
+ supportedNonverbals,
+} from './provider_format.js';
+
+// Inworld-flavored turn: free-form expression + sound + break
+const JOKE =
+ ' Why did the burger go to the gym? ' +
+ ' Because it wanted better buns! ' +
+ '';
+
+describe('convertMarkup: expr -> xAI', () => {
+ it('lowers sounds, breaks and wrapping prosody', () => {
+ const text =
+ 'So I walked in and there it was! ' +
+ ' ' +
+ 'It was a secret the whole time.';
+ expect(convertMarkup('xai', text)).toBe(
+ 'So I walked in and [pause] there it was! [laugh] ' +
+ 'It was a secret the whole time.',
+ );
+ });
+
+ it('maps break durations to the two native pause levels', () => {
+ expect(convertMarkup('xai', '')).toBe('[pause]');
+ expect(convertMarkup('xai', '')).toBe('[long-pause]');
+ });
+
+ it('aliases an Inworld-style sound label to the native cue', () => {
+ expect(convertMarkup('xai', '')).toBe('[breath]');
+ });
+
+ it('normalizes multi-word prosody labels to hyphenated tag names', () => {
+ const text = 'no way';
+ expect(convertMarkup('xai', text)).toBe('no way');
+ });
+
+ it('unwraps an unknown prosody label', () => {
+ const text = 'ahoy there';
+ expect(convertMarkup('xai', text)).toBe('ahoy there');
+ });
+
+ it('drops a hallucinated expression marker from the audio path', () => {
+ // xAI has no free-form delivery descriptions; the marker still surfaces in
+ // transcript tags
+ const text = ' Hello!';
+ expect(convertMarkup('xai', text)).toBe(' Hello!');
+ });
+});
+
+describe('convertMarkup: expr -> Inworld', () => {
+ it('lowers expression/sound to brackets and keeps break as native SSML', () => {
+ expect(convertMarkup('inworld', JOKE)).toBe(
+ '[say playfully] Why did the burger go to the gym? ' +
+ ' Because it wanted better buns! [laugh]',
+ );
+ });
+
+ it('salvages a stray prosody marker as a delivery hint', () => {
+ const text = 'keep it secret';
+ expect(convertMarkup('inworld', text)).toBe('[whisper]keep it secret');
+ });
+});
+
+describe('convertMarkup: expr -> Cartesia', () => {
+ it('lowers expression to , keeps break, drops sound', () => {
+ const text =
+ ' We won! ' +
+ ' Unbelievable.';
+ // without leaving the space the dropped marker sat between behind as a doubled
+ // separator
+ expect(convertMarkup('cartesia', text)).toBe(
+ ' We won! Unbelievable.',
+ );
+ });
+
+ it('lowers spell to the native tag', () => {
+ const text = 'Your code is A7X9.';
+ expect(convertMarkup('cartesia', text)).toBe('Your code is A7X9.');
+ });
+
+ it('unwraps spell for providers that lack it', () => {
+ const text = 'Your code is A7X9.';
+ expect(convertMarkup('xai', text)).toBe('Your code is A7X9.');
+ expect(convertMarkup('inworld', text)).toBe('Your code is A7X9.');
+ });
+
+ it('lowers prosody labels to native speed/volume point controls', () => {
+ expect(convertMarkup('cartesia', ' One moment.')).toBe(
+ ' One moment.',
+ );
+ expect(convertMarkup('cartesia', ' We won!')).toBe(
+ ' We won!',
+ );
+ // wrapping form applies the control before the span
+ expect(convertMarkup('cartesia', 'bad news')).toBe(
+ 'bad news',
+ );
+ });
+
+ it('unwraps an unknown prosody label', () => {
+ const text = 'keep it secret';
+ expect(convertMarkup('cartesia', text)).toBe('keep it secret');
+ });
+
+ it('does not let a self-closing prosody marker swallow a later span', () => {
+ // Cartesia's prosody markers are self-closing point controls. Read as an opening tag,
+ // one would run to the next