/** * textNormalize — shared text utilities for Voice mode. * * Two surfaces in voice rendering share the same source of truth for how * to strip / parse markdown-ish decorations that persona LLMs commonly * emit — single-asterisk roleplay actions ("*I smile*"), bold, italics, * strikethrough, inline code, headings, blockquotes, images, links. * * Motivation: without this step, the TTS engine literally reads the * asterisks out loud as "asterisk I smile asterisk" and the screen shows * raw ``*I smile*`` instead of rendering the action as emphasized text. * * Two exports: * * normalizeForSpeech(text) * Returns a plain-text string safe to hand to * ``window.speechSynthesis`` / the TTS backend. Strips every markdown * decoration but keeps the inner text. Drops image markdown entirely * (the user sees the image, no need for TTS to say "alt text"). * * parseVoiceInline(line, onImageClick?) * Returns a list of React nodes for the on-screen bubble. Renders * images + links as real elements (reusing the existing pattern in * VoiceModeGrok.parseInlineMarkdown) AND emphasis markers as italic * / bold spans instead of literal asterisks. Safe to mix inside the * existing line-by-line render loop. * * Both functions are pure, side-effect-free, and framework-agnostic * (the React node output uses ``React.createElement`` indirectly via * JSX; we import React only for typing). */ import React from 'react' // ── Regex vocabulary (shared) ─────────────────────────────────────────── // Keep these non-greedy so nested/adjacent markers don't collide. // ![alt](url) — image markdown const RE_MD_IMAGE = /!\[[^\]]*\]\([^)]+\)/g // [text](url) — link markdown const RE_MD_LINK = /\[([^\]]+)\]\([^)]+\)/g // ```code fence``` (multiline; non-greedy) const RE_MD_FENCE = /```[\s\S]*?```/g // `inline code` const RE_MD_CODE = /`([^`]+)`/g // ***bold italic*** — must match before **bold** and *italic* const RE_MD_STRONG_EM = /\*\*\*([^*]+)\*\*\*/g // **bold** or __bold__ const RE_MD_STRONG_STAR = /\*\*([^*]+)\*\*/g const RE_MD_STRONG_UNDER = /__([^_]+)__/g // *italic* — common for persona roleplay actions ("*I smile*") // Negative look-around avoids eating list-item markers ("* item") at line start. const RE_MD_EM_STAR = /(? quote" → "quote" const RE_MD_QUOTE = /^\s*>\s?/gm // ── TTS path ──────────────────────────────────────────────────────────── /** Collapse every markdown decoration into plain text for speech synthesis. * Stable surface for ``App.tsx`` / ``CallOverlay`` / ``CreatorStudioEditor`` * — kept parameter-compatible with the older ``stripMarkdownForSpeech``. */ export function normalizeForSpeech(text: string): string { if (!text) return '' return text // Images: drop entirely — the user sees them, speech shouldn't describe them. .replace(RE_MD_IMAGE, '') // Links: keep the visible label, drop the URL. .replace(RE_MD_LINK, '$1') // Arrow glyphs that commonly sit between a tool-call and the removed // image — "outfit → " leftover → "outfit". .replace(/\s*→\s*(?=$|\n)/g, '') // Code: strip fences + inline backticks, keep inner text. .replace(RE_MD_FENCE, (m) => m.slice(3, -3).trim()) .replace(RE_MD_CODE, '$1') // Emphasis: order matters. Strongest first so ``***x***`` doesn't // collapse to ``*x*`` halfway. .replace(RE_MD_STRONG_EM, '$1') .replace(RE_MD_STRONG_STAR, '$1') .replace(RE_MD_STRONG_UNDER, '$1') .replace(RE_MD_EM_STAR, '$1') .replace(RE_MD_EM_UNDER, '$1') .replace(RE_MD_STRIKE, '$1') // Block-level: headings, blockquotes. .replace(RE_MD_HEADING, '') .replace(RE_MD_QUOTE, '') // Tidy up the whitespace we left behind. .replace(/[ \t]{2,}/g, ' ') .replace(/\n{3,}/g, '\n\n') .trim() } // ── Display path ──────────────────────────────────────────────────────── /** Render a single line of text as React nodes, giving italic / bold / * strike / inline-code their correct visual treatment and delegating * image + link rendering to the caller's handler. Safe inside a * typewriter reveal — pure function, no state. */ export function parseVoiceInline( line: string, onImageClick?: (src: string) => void, renderImageLink?: (raw: string, onImageClick?: (src: string) => void) => React.ReactNode, ): React.ReactNode[] { if (!line) return [] const out: React.ReactNode[] = [] // First pass: split on combined markdown regex and emit typed nodes. // The caller still owns image/link rendering (needs backend URL // resolution, auth tokens, etc.) so we delegate that via renderImageLink. // If renderImageLink isn't provided, we leave the raw markdown in place // and only strip the ``*`` / ``_`` / ``~~`` markers — still a big win. const combined = new RegExp( [ '(!?\\[[^\\]]*\\]\\([^)]+\\))', // 1) image or link '(\\*\\*\\*([^*]+)\\*\\*\\*)', // 2) bold-italic ***x*** '(\\*\\*([^*]+)\\*\\*)', // 3) bold **x** '(__([^_]+)__)', // 4) bold __x__ '((? lastIndex) out.push(line.slice(lastIndex, match.index)) const raw = match[0] if (match[1]) { // Image or link — delegate if caller provided a renderer. out.push(renderImageLink ? renderImageLink(raw, onImageClick) : raw) } else if (match[2]) { out.push( {match[3]}, ) } else if (match[4]) { out.push({match[5]}) } else if (match[6]) { out.push({match[7]}) } else if (match[8]) { // *italic* — common roleplay action. Slightly subdued colour so // the speech bubble reads like narration rather than regular voice. out.push( {match[9]}, ) } else if (match[10]) { out.push( {match[11]}, ) } else if (match[12]) { out.push({match[13]}) } else if (match[14]) { out.push( {match[15]} , ) } lastIndex = match.index + raw.length } if (lastIndex < line.length) out.push(line.slice(lastIndex)) return out }