tour: separate spoken narration from bubble text

Welcome step's "Use Next or your arrow keys" hint stays in the bubble
but is dropped from the voice clip (its tail kept synthesizing with
gibberish). Steps gain an optional voiceText override, honored by both
the clip generator and the live-TTS fallback; the generator also takes
step ids as args to re-roll individual clips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Arjun 2026-07-02 22:31:50 +05:30
parent b028e56e44
commit 4b8334f457
3 changed files with 21 additions and 5 deletions

View file

@ -10,6 +10,10 @@
* Run whenever a step's narration text or the tour voice changes:
* cd apps/x && npm run deps # script imports core's built output
* node apps/renderer/scripts/generate-tour-audio.mjs
*
* Pass step ids to regenerate only those clips (e.g. to re-roll one whose
* synthesis came out glitchy):
* node apps/renderer/scripts/generate-tour-audio.mjs welcome done
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
@ -29,15 +33,24 @@ if (start === -1 || end === -1) throw new Error('Could not locate TOUR_STEPS in
const block = src.slice(start, end)
const steps = []
const re = /id: '([^']+)'[\s\S]*?text:\s*("[^"]*"|'[^']*')/g
// voiceText, when present, is the spoken variant of the bubble text.
const re = /id: '([^']+)'[\s\S]*?text:\s*("[^"]*"|'[^']*')(?:,\s*voiceText:\s*("[^"]*"|'[^']*'))?/g
for (let m; (m = re.exec(block)); ) {
// The capture is a JS string literal from our own source; evaluate it to
// resolve the quoting.
steps.push({ id: m[1], text: new Function(`return ${m[2]}`)() })
// The captures are JS string literals from our own source; evaluate them
// to resolve the quoting.
steps.push({ id: m[1], text: new Function(`return ${m[3] ?? m[2]}`)() })
}
if (steps.length === 0) throw new Error('Parsed zero tour steps — regex out of sync with product-tour.tsx?')
console.log(`Parsed ${steps.length} tour steps`)
const only = process.argv.slice(2)
if (only.length > 0) {
const unknown = only.filter((id) => !steps.some((s) => s.id === id))
if (unknown.length > 0) throw new Error(`Unknown step ids: ${unknown.join(', ')}`)
steps.splice(0, steps.length, ...steps.filter((s) => only.includes(s.id)))
console.log(`Regenerating only: ${only.join(', ')}`)
}
await mkdir(outDir, { recursive: true })
for (const step of steps) {
process.stdout.write(`synthesizing ${step.id}... `)

View file

@ -40,6 +40,8 @@ type TourStep = {
vignette?: TourVignetteKind
title: string
text: string
/** Spoken narration, when it should differ from the bubble text. */
voiceText?: string
}
const TOUR_STEPS: TourStep[] = [
@ -47,6 +49,7 @@ const TOUR_STEPS: TourStep[] = [
id: 'welcome',
title: 'All aboard! ⚓',
text: "I'm your captain for the next minute. The lights are down and the water's in — let me row you across Rowboat, one stop at a time. Use Next or your arrow keys.",
voiceText: "I'm your captain for the next minute. The lights are down and the water's in — let me row you across Rowboat, one stop at a time.",
},
{
id: 'home',
@ -518,7 +521,7 @@ export function ProductTour({
if (clip) {
speakUrlRef.current(clip)
} else if (ttsAvailableRef.current) {
speakRef.current(step.text)
speakRef.current(step.voiceText ?? step.text)
}
if (stepIndex === TOUR_STEPS.length - 1) {
setConfettiOn(true)