diff --git a/README.md b/README.md index 0ca21b2..7cfa11b 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,14 @@ None of these is required. Each one changes what the workflow does when it is pr |---|---|---| | Jira (an Atlassian MCP server, plus any `JIRA_*` or `ATLASSIAN_*` variable) | Work is tracked on a ticket, and branches are named after it | No ticket; branches are named from a short description | | Notion (a Notion MCP server, plus `NOTION_PAGE_URL`) | Design docs are published to Notion | Design docs stay as local files | -| ElevenLabs (`ELEVEN_LABS_API_KEY`) | Each architecture doc's walkthrough video is narrated | The video is silent, with the narration as subtitles | -| A macOS voice (`WALKTHROUGH_MAC_VOICE`) | The walkthrough video is narrated by that voice, when ElevenLabs isn't configured | With `ELEVEN_LABS_API_KEY` also unset, the video is silent, with the narration as subtitles | +| ElevenLabs (`ELEVEN_LABS_API_KEY`) | Each architecture doc's walkthrough video is narrated | Kokoro or a macOS voice narrates when selected or set, else the video is silent, with the narration as subtitles | +| Kokoro (`WALKTHROUGH_VOICE=kokoro`, needs `uv`) | Each architecture doc's walkthrough video is narrated locally, instead of ElevenLabs or a macOS voice even when they are configured | ElevenLabs narrates when configured, else a macOS voice, else the video is silent, with the narration as subtitles | +| A macOS voice (`WALKTHROUGH_MAC_VOICE`) | The walkthrough video is narrated by that voice: always with `WALKTHROUGH_VOICE=mac`, otherwise when `WALKTHROUGH_VOICE` is unset or empty and ElevenLabs isn't configured | `WALKTHROUGH_VOICE=mac` fails the render; otherwise ElevenLabs or Kokoro narrates when selected, else the video is silent, with the narration as subtitles | | The board (`ai-workflow-app`) | Questions and approvals go to the board | The workflow asks in the terminal | | CodeRabbit | Its review is part of the pull request loop | Only the agent review runs | +To use Kokoro, install [`uv`](https://docs.astral.sh/uv/) (`brew install uv` on macOS) and set `WALKTHROUGH_VOICE` to `kokoro` in the `env` block of a settings file (see [Environment variables](#environment-variables)). The first narrated render downloads Python 3.12, PyTorch and the Kokoro model, so it needs network access and takes longer than later renders, which reuse that download. + The board is a separate application and is not published. Every variable the plugin reads is listed under [Environment variables](#environment-variables) below. @@ -90,9 +93,11 @@ These are Claude Code's own settings. The workflow depends on what they do. |---|---| | `JIRA_*`, `ATLASSIAN_*` | Any variable whose name starts with either prefix and whose value is not blank turns Jira on. What the value says doesn't matter. Unset: no tickets. | | `NOTION_PAGE_URL` | The Notion page design docs are published under. Unset: design docs stay as local files. | -| `ELEVEN_LABS_API_KEY` | ElevenLabs API key used to narrate an architecture doc's walkthrough video. Unset: a macOS voice narrates it if `WALKTHROUGH_MAC_VOICE` is set; otherwise the video is silent, with the narration as subtitles. | +| `ELEVEN_LABS_API_KEY` | ElevenLabs API key used to narrate an architecture doc's walkthrough video. A blank value counts as unset. Unset: with `WALKTHROUGH_VOICE` unset or empty a macOS voice narrates it if `WALKTHROUGH_MAC_VOICE` is set, otherwise the video is silent, with the narration as subtitles; with `kokoro` or `mac`, that voice narrates; with `elevenlabs`, the render fails. | | `ELEVEN_LABS_VOICE_ID` | The ElevenLabs voice for that narration. Default: the voice the CLI pins. | -| `WALKTHROUGH_MAC_VOICE` | A macOS voice, named as `say -v '?'` lists it, that narrates the walkthrough video when `ELEVEN_LABS_API_KEY` is unset. Unset (and `ELEVEN_LABS_API_KEY` unset too): the video is silent, with the narration as subtitles. Premium voices (System Settings → Accessibility → Read & Speak → System voice → Manage Voices…; Spoken Content before macOS 26) sound far better than the built-in ones. | +| `WALKTHROUGH_VOICE` | The voice that narrates walkthrough videos. `kokoro` narrates locally with Kokoro through `uv`. `elevenlabs` narrates with ElevenLabs and fails the render when `ELEVEN_LABS_API_KEY` is not set. `mac` narrates with a macOS voice and fails the render when `WALKTHROUGH_MAC_VOICE` is not set. Any other non-empty value fails the render. Unset or empty: ElevenLabs when its key is set, else a macOS voice when `WALKTHROUGH_MAC_VOICE` is set, else silent. To add a voice, see "Adding a voice" in `skills/architecture/references/walkthrough.md`. | +| `KOKORO_VOICE` | The Kokoro voice for that narration. It must be an American or British English voice (prefix `a` or `b`). Default: `af_heart`. | +| `WALKTHROUGH_MAC_VOICE` | A macOS voice, named as `say -v '?'` lists it. A blank value counts as unset. `WALKTHROUGH_VOICE=mac` narrates with it, and fails the render when this is unset. `WALKTHROUGH_VOICE` unset or empty: it narrates when `ELEVEN_LABS_API_KEY` is unset, and with that key set ElevenLabs narrates and this is ignored; with both unset, the video is silent, with the narration as subtitles. Any other `WALKTHROUGH_VOICE` ignores it. Premium voices (System Settings → Accessibility → Read & Speak → System voice → Manage Voices…; Spoken Content before macOS 26) sound far better than the built-in ones. | | `AI_WORKFLOW_APP_BIN` | Path to the board's `ai-workflow-app` binary. Default: `ai-workflow-app` found on `PATH`. If the path you give does not exist, the board is treated as absent; `PATH` is not searched instead. | | `PERMISSION_HOLD_DEADLINE_S` | When a tool call needs permission, the prompt is also posted to the board, and whichever answer comes first wins. This is how many seconds the board side waits. Default: `570`. A value that is not a whole number below `600` uses the default. | | `PERMISSION_POLL_INTERVAL_S` | Seconds between checks for the board's answer to that prompt. Default: `3`. | diff --git a/skills/architecture/references/walkthrough.md b/skills/architecture/references/walkthrough.md index 9b32ca6..3067523 100644 --- a/skills/architecture/references/walkthrough.md +++ b/skills/architecture/references/walkthrough.md @@ -1,11 +1,11 @@ # Walkthrough Videos The Architecture skill gives each architecture doc a walkthrough video at the top of its hosted -Notion page: narrated by ElevenLabs or a macOS voice when one is configured, and silent with the -narration as subtitles when neither is. The video tells the story of the work in a few minutes, so -the user can review the design by watching it. The skill writes the script after the doc and -renders it before hosting. Whenever a revision changes what the video says, it re-renders before -re-hosting. +Notion page: narrated by Kokoro, ElevenLabs or a macOS voice when one is configured, and silent +with the narration as subtitles when none is. The video tells the story of the work in a few +minutes, so the user can review the design by watching it. The skill writes the script after the +doc and renders it before hosting. Whenever a revision changes what the video says, it re-renders +before re-hosting. ## Files @@ -15,7 +15,7 @@ All of these sit beside the doc in `docs/plans/`. `` is the doc's | File | What it is | |---|---| | `.walkthrough.json` | The script. You write it. | -| `.walkthrough.cache/` | Narration audio, cached by text and voice, and by the model and speed the CLI pins for ElevenLabs or by the scene's cues for a macOS voice. `walkthrough render` writes it; keep it between renders. | +| `.walkthrough.cache/` | Narration audio, cached by text, voice, and the speed the CLI pins, plus the ElevenLabs model it pins, the Kokoro package versions it pins (Kokoro's model is not pinned), or the scene's cues for a macOS voice. `walkthrough render` writes it; keep it between renders. | | `.walkthrough.mp4` | The video. | The doc's first line under its title embeds the video: `![Walkthrough](.walkthrough.mp4)`. @@ -24,20 +24,50 @@ Hosting turns that line into a Notion video (`skills/workflow-guide/references/n ## Choosing the voice -Run `check-tools --require elevenlabs,mac-voice` first. A `failed` result for either one: stop -and print the reason. Otherwise the first configured voice narrates the video: -- `elevenlabs` configured: ElevenLabs narrates it. `ELEVEN_LABS_VOICE_ID` overrides the voice the - CLI pins, per project. -- else `mac-voice` configured: the macOS voice `WALKTHROUGH_MAC_VOICE` names narrates it, free and - local. The name is as `say -v '?'` lists it. Premium voices, downloaded under System Settings → - Accessibility → Read & Speak → System voice → Manage Voices… (Spoken Content before macOS 26), +Run `check-tools --require elevenlabs,kokoro,mac-voice` first. Only the voice that narrates reads +`configured`: `WALKTHROUGH_VOICE` names it (`elevenlabs`, `kokoro` or `mac`), and when it is unset or +empty, ElevenLabs narrates if `ELEVEN_LABS_API_KEY` is set, else the macOS voice if +`WALKTHROUGH_MAC_VOICE` is set. +- any `failed`: stop and print the reason. A non-empty `WALKTHROUGH_VOICE` that names no voice fails + all three tools; `WALKTHROUGH_VOICE=elevenlabs` without `ELEVEN_LABS_API_KEY` fails `elevenlabs` + and `WALKTHROUGH_VOICE=mac` without `WALKTHROUGH_MAC_VOICE` fails `mac-voice`, not a missing one; + `walkthrough render` exits 1 on each. +- `kokoro` `configured` (`WALKTHROUGH_VOICE=kokoro`): go on. Kokoro narrates locally through `uv`, + and neither ElevenLabs nor a macOS voice is used even when configured. The first run downloads + the model and a Python environment with PyTorch. +- `elevenlabs` `configured`: go on. ElevenLabs narrates. With `WALKTHROUGH_VOICE` unset or empty and + `WALKTHROUGH_MAC_VOICE` set too, ElevenLabs wins and `walkthrough render` says on stderr that the + macOS voice is ignored. +- `mac-voice` `configured`: go on. The macOS voice `WALKTHROUGH_MAC_VOICE` names narrates it, free + and local. The name is as `say -v '?'` lists it. Premium voices, downloaded under System Settings + → Accessibility → Read & Speak → System voice → Manage Voices… (Spoken Content before macOS 26), sound far better than the built-in compact ones. -- neither: `walkthrough render` makes the video without a voice: each scene lasts as long as - reading its narration takes, and the narration shows as subtitles. Tell the user in one line - that the walkthrough is silent and subtitled because no voice is configured. - -With both configured, ElevenLabs wins and `walkthrough render` says on stderr that the macOS voice -is ignored. +- none configured: go on. `walkthrough render` makes the video without a voice: each scene lasts as + long as reading its narration takes, and the narration shows as subtitles. Tell the user in one + line that the walkthrough is silent and subtitled because no voice is configured. + +Each voice defaults to the one the CLI pins: `KOKORO_VOICE` overrides Kokoro's, and +`ELEVEN_LABS_VOICE_ID` overrides ElevenLabs'. `KOKORO_VOICE` must be an American or British English +voice (prefix `a` or `b`); any other voice gives no word timings, and the render exits 1. + +### Adding a voice + +- Add its name to the `VoiceName` union in `tools/cli/src/walkthrough/voice.ts`. +- Implement `Voice` in its own module under `tools/cli/src/walkthrough/`, saying in + `chargedPerCharacter` whether it charges for each character it voices. +- List it in `VOICES` in `tools/cli/src/walkthrough/voices.ts`. It joins the order used when + `WALKTHROUGH_VOICE` is unset or empty only through `selectDefaultVoice` there. +- Give it a `TOOL_SPECS` entry in `tools/cli/src/check-tools/specs.ts`. +- Add its tool name to the `check-tools --require` line above. `check-tools` reports only the tools + that line names, so a voice left off it never appears: the listed voices all read + `unconfigured` while it is selected, and the video is reported silent. +- Add its name to the `walkthrough render` help text in `tools/cli/src/commands/walkthrough.ts`. +- Check and update every sentence in `README.md` and `skills/` that names the existing voices, and + document the new voice's variables: + `git grep -n -i -e kokoro -e elevenlabs -e mac-voice -e WALKTHROUGH_MAC_VOICE -- README.md skills`. +- Update the tests that hard-code the voice names: the no-voice notice, the unknown-voice message + ("elevenlabs, kokoro, mac") and the voice lists in the check-tools expectations, all in + `tools/cli/src/commands/walkthrough.test.ts`. ## Write the script @@ -150,17 +180,19 @@ walkthrough render --script "docs/plans/.walkthrough.json" --out " problems means fix the script and re-run. - Exit 2 means a tool it needs isn't usable: - `ffmpeg -version` doesn't run cleanly, whether `ffmpeg` is missing or broken; - - `ELEVEN_LABS_API_KEY` is unset, `WALKTHROUGH_MAC_VOICE` is set, and `say` or `ffprobe` doesn't - run cleanly, whether it is missing or broken (`say` is macOS-only); + - the macOS voice narrates and `say` or `ffprobe` doesn't run cleanly, whether it is missing or + broken (`say` is macOS-only); - `hyperframes browser ensure`, run through `npx`, doesn't exit 0. For example: `npx` is missing, the HyperFrames package can't be fetched, or no Chrome can be found or downloaded. -- An ElevenLabs error exits 1 with its status. Stop and report ElevenLabs as broken, the same as - `check-tools`' `onFailure`. Scenes already voiced stay cached. -- With `ELEVEN_LABS_API_KEY` unset, a `WALKTHROUGH_MAC_VOICE` naming a voice that isn't installed - exits 1 before anything renders. - Stop and report it, the same as `check-tools`' `onFailure` for `mac-voice`. +- A voice error exits 1: ElevenLabs with its status, Kokoro with its exit, the macOS voice with the + `say` or `ffprobe` failure. Stop and report that voice as broken, the same as `check-tools`' + `onFailure`. Scenes already voiced stay cached. With Kokoro selected, a missing `uv` exits 2 like + any other missing tool. +- When the macOS voice narrates, a `WALKTHROUGH_MAC_VOICE` naming a voice that isn't installed exits + 1 before anything renders. Stop and report it, the same as `check-tools`' `onFailure` for + `mac-voice`. - stdout is a JSON summary. `narrated: false` means the video was rendered silent, with subtitles. - `voice` says which voice narrated it: `elevenlabs`, `mac`, or `null`. + `voice` says which voice narrated it: `elevenlabs`, `kokoro`, `mac`, or `null`. `overNotionLimit: true` means the video is over 20 MiB and won't upload. Host the doc without the video and tell the user. diff --git a/tools/cli/src/check-tools/main.ts b/tools/cli/src/check-tools/main.ts index 6884da5..8a3228f 100644 --- a/tools/cli/src/check-tools/main.ts +++ b/tools/cli/src/check-tools/main.ts @@ -17,7 +17,7 @@ export const runCheckTools = (tools: readonly string[]): void => { return } -const classifyTool = (tool: string, env: NodeJS.ProcessEnv): ToolResult => { +export const classifyTool = (tool: string, env: NodeJS.ProcessEnv): ToolResult => { const spec = TOOL_SPECS[tool] if (!spec) return unknownTool(tool) @@ -25,6 +25,11 @@ const classifyTool = (tool: string, env: NodeJS.ProcessEnv): ToolResult => { const missing = findMissingEnv(spec, env) const accepted = acceptedEnv(spec) + const problem = spec.envProblem?.(env) + if (problem !== undefined) { + return result(spec, 'failed', present, missing, accepted, problem) + } + if (missing.length > 0 && present.length > 0) { return result( spec, @@ -36,7 +41,9 @@ const classifyTool = (tool: string, env: NodeJS.ProcessEnv): ToolResult => { ) } - if (present.length > 0) return result(spec, 'configured', present, missing, accepted) + if (spec.inUse?.(env) ?? present.length > 0) { + return result(spec, 'configured', present, missing, accepted) + } return result( spec, @@ -44,7 +51,7 @@ const classifyTool = (tool: string, env: NodeJS.ProcessEnv): ToolResult => { present, missing, accepted, - `env vars not set: ${accepted.join(', ')}` + spec.unusedReason?.(env) ?? `env vars not set: ${accepted.join(', ')}` ) } diff --git a/tools/cli/src/check-tools/specs.ts b/tools/cli/src/check-tools/specs.ts index 2c0fc6e..44ed2a7 100644 --- a/tools/cli/src/check-tools/specs.ts +++ b/tools/cli/src/check-tools/specs.ts @@ -1,5 +1,37 @@ +import { elevenlabs } from '../walkthrough/elevenlabs.ts' +import { kokoro } from '../walkthrough/kokoro.ts' +import { mac } from '../walkthrough/mac-voice.ts' +import type { Voice } from '../walkthrough/voice.ts' +import { selectVoice } from '../walkthrough/voices.ts' import type { ToolSpec } from './types.ts' +// A voice is configured only while it is the one render would narrate with, and fails with the +// problem render would stop on: any selection problem, or its own when it is the one selected. +const voiceSpec = ( + voice: Voice, + spec: Omit +): ToolSpec => ({ + ...spec, + envProblem: (env) => { + const selection = selectVoice(env) + return selection.voice === undefined || selection.voice === voice + ? selection.problem + : undefined + }, + inUse: (env) => selectVoice(env).voice === voice, + unusedReason: (env) => { + const { voice: selected } = selectVoice(env) + if (selected === undefined) { + return undefined + } + if (env.WALKTHROUGH_VOICE) { + return `WALKTHROUGH_VOICE selects ${selected.name}` + } + const ownEnvSet = (spec.requiresAll ?? []).every((name) => env[name]?.trim()) + return ownEnvSet ? `${selected.name} is selected ahead of it` : undefined + }, +}) + export const TOOL_SPECS: Readonly> = { jira: { tool: 'jira', @@ -15,18 +47,25 @@ export const TOOL_SPECS: Readonly> = { 'STOP and tell the user Notion is configured but the Notion MCP tools are not working. Do NOT fall back.', requiresAll: ['NOTION_PAGE_URL'], }, - elevenlabs: { + elevenlabs: voiceSpec(elevenlabs, { tool: 'elevenlabs', use: 'ElevenLabs narration for walkthrough videos', onFailure: 'STOP and tell the user ElevenLabs is configured but walkthrough render cannot voice the narration. Do NOT fall back.', requiresAll: ['ELEVEN_LABS_API_KEY'], - }, - 'mac-voice': { + }), + kokoro: voiceSpec(kokoro, { + tool: 'kokoro', + use: 'Local Kokoro narration for walkthrough videos', + onFailure: + 'STOP and tell the user walkthrough render cannot voice the narration, with the reason given. Do NOT fall back.', + requiresAll: ['WALKTHROUGH_VOICE'], + }), + 'mac-voice': voiceSpec(mac, { tool: 'mac-voice', use: 'A macOS voice narrating walkthrough videos', onFailure: 'STOP and tell the user a macOS voice is configured but walkthrough render cannot voice the narration with it. Do NOT fall back.', requiresAll: ['WALKTHROUGH_MAC_VOICE'], - }, + }), } diff --git a/tools/cli/src/check-tools/types.ts b/tools/cli/src/check-tools/types.ts index 52d77de..b210847 100644 --- a/tools/cli/src/check-tools/types.ts +++ b/tools/cli/src/check-tools/types.ts @@ -6,6 +6,11 @@ export type ToolSpec = { readonly onFailure: string readonly requiresAll?: readonly string[] readonly acceptsAnyPrefix?: readonly string[] + readonly envProblem?: (env: NodeJS.ProcessEnv) => string | undefined + // Replaces env presence in deciding configured or unconfigured. + readonly inUse?: (env: NodeJS.ProcessEnv) => boolean + // Why the tool is unconfigured when `inUse` says no, if env presence does not say it. + readonly unusedReason?: (env: NodeJS.ProcessEnv) => string | undefined } export type EnvSummary = { diff --git a/tools/cli/src/commands/walkthrough.test.ts b/tools/cli/src/commands/walkthrough.test.ts index f60f95f..5b49a83 100644 --- a/tools/cli/src/commands/walkthrough.test.ts +++ b/tools/cli/src/commands/walkthrough.test.ts @@ -10,9 +10,10 @@ import { fileURLToPath } from 'node:url' import { getRequestedExitCode, setRequestedExitCode } from '../runtime-exit-code.ts' import { stderrBeyondNodeWarnings } from '../testing/subprocess-stderr.ts' import { SCENE_TAIL } from '../walkthrough/compose.ts' +import { classifyTool } from '../check-tools/main.ts' import { runWalkthroughRender } from '../walkthrough/main.ts' import { readingAlignment } from '../walkthrough/silent.ts' -import { DEFAULT_VOICE_ID } from '../walkthrough/tts.ts' +import { DEFAULT_VOICE_ID } from '../walkthrough/elevenlabs.ts' const mainTsPath = fileURLToPath(new URL('../main.ts', import.meta.url)) @@ -35,7 +36,16 @@ const runCli = (env: NodeJS.ProcessEnv, dir: string) => test('CLI: walkthrough render exits 2 when ffmpeg is not on PATH', () => { const dir = mkdtempSync(join(tmpdir(), 'walkthrough-cli-test-')) try { - const result = runCli({ ...process.env, ELEVEN_LABS_API_KEY: 'k', PATH: dir }, dir) + const result = runCli( + { + ...process.env, + WALKTHROUGH_VOICE: '', + WALKTHROUGH_MAC_VOICE: '', + ELEVEN_LABS_API_KEY: 'k', + PATH: dir, + }, + dir + ) assert.equal(result.status, 2) assert.equal(result.stdout, '') assert.equal( @@ -186,7 +196,16 @@ test('CLI: walkthrough render exits 2 naming the signal when ffmpeg is killed', try { writeFileSync(join(dir, 'ffmpeg'), '#!/bin/sh\nkill -ABRT $$\n') chmodSync(join(dir, 'ffmpeg'), 0o755) - const result = runCli({ ...process.env, ELEVEN_LABS_API_KEY: 'k', PATH: dir }, dir) + const result = runCli( + { + ...process.env, + WALKTHROUGH_VOICE: '', + WALKTHROUGH_MAC_VOICE: '', + ELEVEN_LABS_API_KEY: 'k', + PATH: dir, + }, + dir + ) assert.equal(result.status, 2) assert.equal(result.stdout, '') assert.equal( @@ -202,7 +221,16 @@ test('CLI: walkthrough render exits 2 when ffmpeg cannot be run', () => { const dir = mkdtempSync(join(tmpdir(), 'walkthrough-cli-test-')) try { mkdirSync(join(dir, 'ffmpeg')) - const result = runCli({ ...process.env, ELEVEN_LABS_API_KEY: 'k', PATH: dir }, dir) + const result = runCli( + { + ...process.env, + WALKTHROUGH_VOICE: '', + WALKTHROUGH_MAC_VOICE: '', + ELEVEN_LABS_API_KEY: 'k', + PATH: dir, + }, + dir + ) assert.equal(result.status, 2) assert.equal(result.stdout, '') assert.equal( @@ -219,7 +247,16 @@ test('CLI: walkthrough render exits 2 when ffmpeg starts but exits nonzero', () try { writeFileSync(join(dir, 'ffmpeg'), '#!/bin/sh\nexit 3\n') chmodSync(join(dir, 'ffmpeg'), 0o755) - const result = runCli({ ...process.env, ELEVEN_LABS_API_KEY: 'k', PATH: dir }, dir) + const result = runCli( + { + ...process.env, + WALKTHROUGH_VOICE: '', + WALKTHROUGH_MAC_VOICE: '', + ELEVEN_LABS_API_KEY: 'k', + PATH: dir, + }, + dir + ) assert.equal(result.status, 2) assert.equal(result.stdout, '') assert.equal( @@ -356,7 +393,14 @@ test('walkthrough render: with no ElevenLabs key it renders silent, subtitled an const stdout: string[] = [] const stderr = mock.method(console, 'error', () => undefined) try { - for (const env of [{}, { ELEVEN_LABS_API_KEY: '' }]) { + for (const env of [ + {}, + { ELEVEN_LABS_API_KEY: '' }, + { ELEVEN_LABS_API_KEY: ' ' }, + { WALKTHROUGH_VOICE: '' }, + { WALKTHROUGH_MAC_VOICE: '' }, + { WALKTHROUGH_MAC_VOICE: ' ' }, + ]) { setRequestedExitCode(0) stdout.length = 0 stderr.mock.resetCalls() @@ -369,8 +413,10 @@ test('walkthrough render: with no ElevenLabs key it renders silent, subtitled an stderr.mock.calls.map((call) => call.arguments), [ [ - 'walkthrough: neither ELEVEN_LABS_API_KEY nor WALKTHROUGH_MAC_VOICE is set, so the ' + - 'walkthrough is rendered without a voice, with the narration as subtitles.', + 'walkthrough: no voice is selected: WALKTHROUGH_VOICE is unset or empty, ' + + 'ELEVEN_LABS_API_KEY is not set and WALKTHROUGH_MAC_VOICE is not set ' + + '(WALKTHROUGH_VOICE can name elevenlabs, kokoro, mac), so the walkthrough is ' + + 'rendered without a voice, with the narration as subtitles.', ], ] ) @@ -422,6 +468,8 @@ test('CLI: walkthrough render with no ElevenLabs key exits 0 and prints the sile for (const key of [undefined, '']) { const env: NodeJS.ProcessEnv = { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` } delete env.ELEVEN_LABS_API_KEY + delete env.WALKTHROUGH_VOICE + delete env.WALKTHROUGH_MAC_VOICE if (key !== undefined) { env.ELEVEN_LABS_API_KEY = key } @@ -429,8 +477,10 @@ test('CLI: walkthrough render with no ElevenLabs key exits 0 and prints the sile assert.equal(result.status, 0) assert.equal( stderrBeyondNodeWarnings(result.stderr), - 'walkthrough: neither ELEVEN_LABS_API_KEY nor WALKTHROUGH_MAC_VOICE is set, so the ' + - 'walkthrough is rendered without a voice, with the narration as subtitles.' + 'walkthrough: no voice is selected: WALKTHROUGH_VOICE is unset or empty, ' + + 'ELEVEN_LABS_API_KEY is not set and WALKTHROUGH_MAC_VOICE is not set ' + + '(WALKTHROUGH_VOICE can name elevenlabs, kokoro, mac), so the walkthrough is ' + + 'rendered without a voice, with the narration as subtitles.' ) assert.equal( result.stdout, @@ -453,6 +503,348 @@ test('CLI: walkthrough render with no ElevenLabs key exits 0 and prints the sile } }) +test('walkthrough render: a WALKTHROUGH_VOICE that names no voice exits 1 before any tool runs', async () => { + const dir = mkdtempSync(join(tmpdir(), 'walkthrough-cli-test-')) + writeFileSync(join(dir, 'ffmpeg'), `#!/bin/sh\necho ran > "${dir}/ffmpeg-ran"\nexit 0\n`) + chmodSync(join(dir, 'ffmpeg'), 0o755) + const path = process.env.PATH + process.env.PATH = `${dir}:${path ?? ''}` + const stdout: string[] = [] + const stderr = mock.method(console, 'error', () => undefined) + try { + await runWalkthroughRender(join(dir, 's.json'), join(dir, 'o.mp4'), { + env: { WALKTHROUGH_VOICE: 'piper', ELEVEN_LABS_API_KEY: 'k' }, + stdout: (chunk) => stdout.push(chunk), + }) + assert.equal(getRequestedExitCode(), 1) + assert.deepEqual(stdout, []) + assert.deepEqual( + stderr.mock.calls.map((call) => call.arguments), + [ + [ + 'walkthrough: WALKTHROUGH_VOICE is "piper"; the voices it can name are elevenlabs, kokoro, mac.', + ], + ] + ) + assert.throws(() => readFileSync(join(dir, 'ffmpeg-ran'))) + } finally { + stderr.mock.restore() + process.env.PATH = path + setRequestedExitCode(0) + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('walkthrough render: WALKTHROUGH_VOICE=kokoro without uv exits 2 before Chrome or narration', async () => { + const dir = mkdtempSync(join(tmpdir(), 'walkthrough-cli-test-')) + writeFileSync(join(dir, 'ffmpeg'), '#!/bin/sh\nexit 0\n') + writeFileSync(join(dir, 'npx'), `#!/bin/sh\necho ran > "${dir}/npx-ran"\nexit 0\n`) + for (const stub of ['ffmpeg', 'npx']) { + chmodSync(join(dir, stub), 0o755) + } + const path = process.env.PATH + process.env.PATH = dir + const script = join(dir, 's.json') + writeFileSync( + script, + JSON.stringify({ + title: 'T', + palette: 'classic', + scenes: [{ heading: 'A', narration: 'Hi.', visual: { kind: 'title' } }], + }) + ) + const stderr = mock.method(console, 'error', () => undefined) + try { + await runWalkthroughRender(script, join(dir, 'o.mp4'), { + env: { WALKTHROUGH_VOICE: 'kokoro' }, + stdout: () => undefined, + }) + assert.equal(getRequestedExitCode(), 2) + assert.deepEqual( + stderr.mock.calls.map((call) => call.arguments), + [['walkthrough: WALKTHROUGH_VOICE=kokoro needs uv on PATH; install it (brew install uv).']] + ) + assert.throws(() => readFileSync(join(dir, 'npx-ran'))) + } finally { + stderr.mock.restore() + process.env.PATH = path + setRequestedExitCode(0) + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('walkthrough render: WALKTHROUGH_VOICE=kokoro narrates with Kokoro even when an ElevenLabs key is set', async () => { + const dir = mkdtempSync(join(tmpdir(), 'walkthrough-cli-test-')) + let requests = 0 + const server = createServer((_, res) => { + requests++ + res.writeHead(500).end() + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + const port = typeof address === 'object' && address !== null ? address.port : 0 + writeFileSync( + join(dir, 'npx'), + `#!/bin/sh\n[ "$3" = render ] && /bin/cp "$4/index.html" "${dir}/composed.html"\nexit 0\n` + ) + writeFileSync( + join(dir, 'ffmpeg'), + `#!/bin/sh\n[ "$1" = -version ] && exit 0\nprintf '%s\\n' "$@" > "${dir}/ffmpeg-args"\nfor last; do :; done\nprintf mp3 > "$last"\n` + ) + writeFileSync( + join(dir, 'uv'), + [ + '#!/bin/sh', + '[ "$1" = --version ] && exit 0', + 'while [ $# -gt 0 ]; do', + ' case "$1" in', + ' --audio) audio="$2"; shift ;;', + ' --words) words="$2"; shift ;;', + ' esac', + ' shift', + 'done', + `cat > "${dir}/uv-stdin"`, + ': > "$audio"', + `printf '%s' '[{"text":"The","start":0,"end":0.2},{"text":"client","start":0.2,"end":0.6},{"text":"calls","start":0.6,"end":1},{"text":".","start":1,"end":1.1}]' > "$words"`, + '', + ].join('\n') + ) + for (const stub of ['ffmpeg', 'npx', 'uv']) { + chmodSync(join(dir, stub), 0o755) + } + const path = process.env.PATH + process.env.PATH = `${dir}:${path ?? ''}` + const script = join(dir, 's.json') + writeFileSync( + script, + JSON.stringify({ + title: 'T', + palette: 'classic', + scenes: [ + { + heading: 'B', + narration: 'The client calls.', + visual: { kind: 'diagram', d2: '*: {class: tool}\nclient -> server' }, + steps: [{ cue: 'client calls', show: ['server'] }], + }, + ], + }) + ) + const stdout: string[] = [] + const stderr = mock.method(console, 'error', () => undefined) + try { + setRequestedExitCode(0) + await runWalkthroughRender(script, join(dir, 'o.mp4'), { + env: { WALKTHROUGH_VOICE: 'kokoro', ELEVEN_LABS_API_KEY: 'k' }, + apiBase: `http://127.0.0.1:${port}`, + stdout: (chunk) => stdout.push(chunk), + }) + assert.equal(getRequestedExitCode(), 0) + assert.equal(requests, 0) + assert.deepEqual(stderr.mock.calls, []) + assert.equal(readFileSync(join(dir, 'uv-stdin'), 'utf-8'), 'The client calls.') + const summary: unknown = JSON.parse(stdout.join('')) + assert.ok(typeof summary === 'object' && summary !== null) + assert.equal('narrated' in summary && summary.narrated, true) + assert.equal('voice' in summary && summary.voice, 'kokoro') + assert.equal('synthesizedScenes' in summary && summary.synthesizedScenes, 1) + assert.match(readFileSync(join(dir, 'composed.html'), 'utf-8'), /