retake-demos 0.14.1 → 0.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -122,6 +122,12 @@ a person who has heard it. It only fixes the voice and rate of narration that
122
122
  already exists, and strips any `viewport`/`scale` pin, which silently beats the
123
123
  preset and is the one way two takes of the same app come out different shapes.
124
124
 
125
+ Voices are names: `andrew`, `ava`, `brian`, `emma`, `aria`, `ryan` (Edge,
126
+ online) and `heart`, `michael`, `aiden` (local: they run on the person's Mac
127
+ and the first narrated render downloads the model). The voice a person picked
128
+ when they first opened Retake is the routine's voice. If the routine says
129
+ `silent` and they want narration, ask which voice — never guess an id.
130
+
125
131
  Writing the steps, the captions and the story is still entirely your job.
126
132
  `demos/style.md` is the taste that needs judgement; the routine is the part
127
133
  that only needs to be the same every time.
package/README.md CHANGED
@@ -324,7 +324,7 @@ On a new workspace, `retake ui` opens the first-demo flow and writes the manifes
324
324
 
325
325
  **Scenes** are the spine: `{ action: scene, label, caption }` at each beat. They take their timestamps from the actual run, so captions land on the frames they describe even when the app took 3 seconds this time and 11 the last. **Labels must be unique** — `thumbnail`, `until`, the stills and the proof log all address scenes by label, so two scenes called the same thing make every one of those ambiguous. `validate` rejects it.
326
326
 
327
- **Two kinds of take.** `mode: demo` (the default) proves how something works — a client walkthrough, a PR clip, a lesson — and keeps everything but the product out of the frame. `mode: launch` presents the product in public and may carry a title card, music, emphasis and a branded ending. `validate` warns if a demo is carrying launch furniture. **The launch cut.** `compressIdle: true` shows the app's long waits as ~1.5 s each (your own pacing `wait`s are never touched); `voiceover: true` reads the captions aloud (edge-tts, `pipx install edge-tts` once); `typing: brisk` makes typed text land fast with the pauses carrying the meaning. All render-time except typing. **Music.** `music: track.mp3` (or `{ file, gainDb, fadeOutMs }`) mixes a bed under the video at render time, looped or trimmed to fit and faded out at the end. Bring your own track — it ships inside your video, so it must be one you may use (CC0 or licensed). **Cards and callouts.** `intro:`/`outro:` add a title card in Retake's own look — rendered at render time, so changing a word is a re-render, and the intro's settled frame is written as `cover.png`, the poster. `{ action: callout, selector, label, ms }` draws an animated ring and label around an element while the take holds — recorded coordinates, drawn at render, moving with the camera. Sparingly: the captions carry the story.
327
+ **Two kinds of take.** `mode: demo` (the default) proves how something works — a client walkthrough, a PR clip, a lesson — and keeps everything but the product out of the frame. `mode: launch` presents the product in public and may carry a title card, music, emphasis and a branded ending. `validate` warns if a demo is carrying launch furniture. **The launch cut.** `compressIdle: true` shows the app's long waits as ~1.5 s each (your own pacing `wait`s are never touched); `voiceover: true` reads the captions aloud (edge-tts, `pipx install edge-tts` once). **Local voices** run on an Apple Silicon Mac and send nothing anywhere: `retake routine set voice heart` (Kokoro, female), `michael` (Kokoro, male) or `aiden` (Qwen3). They need [uv](https://docs.astral.sh/uv/) (`brew install uv`). The first narrated video downloads the voice engine and model once: about 2.6 GB for Kokoro, about 4 GB for Aiden. Kokoro reads demo words the demo way ("press re-CORD", "deh-mohz") and every local voice is levelled to Edge's loudness; `typing: brisk` makes typed text land fast with the pauses carrying the meaning. All render-time except typing. **Music.** `music: track.mp3` (or `{ file, gainDb, fadeOutMs }`) mixes a bed under the video at render time, looped or trimmed to fit and faded out at the end. Bring your own track — it ships inside your video, so it must be one you may use (CC0 or licensed). **Cards and callouts.** `intro:`/`outro:` add a title card in Retake's own look — rendered at render time, so changing a word is a re-render, and the intro's settled frame is written as `cover.png`, the poster. `{ action: callout, selector, label, ms }` draws an animated ring and label around an element while the take holds — recorded coordinates, drawn at render, moving with the camera. Sparingly: the captions carry the story.
328
328
 
329
329
  **Seeds** put the app in a known state before the camera: write a JSON file, run JS in the page, or run a shell command. **Setup** is the stuff that runs before recording and is trimmed off the front — logins live there. **`tempo`** speeds the finished video up or down at render time; **`lock`** names a shared resource so two demos that touch the same backend don't record over each other.
330
330
 
package/dist/cli.js CHANGED
@@ -1685,8 +1685,8 @@ routine
1685
1685
  const parsed = RoutineSchema.safeParse(next);
1686
1686
  if (!parsed.success)
1687
1687
  throw new Error(parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "));
1688
- if (key === "voice" && value !== "silent" && !VOICES[value] && !/^[a-z]{2}-[A-Z]{2}-/.test(value)) {
1689
- say(`ℹ "${value}" is not one of ${Object.keys(VOICES).join(", ")} and does not look like an edge-tts id (en-US-AriaNeural). \`retake routine voices\` lists what is here.`);
1688
+ if (key === "voice" && value !== "silent" && !VOICES[value] && !/^[a-z]{2}-[A-Z]{2}-/.test(value) && !/^(kokoro|qwen):[a-z_]+$/.test(value)) {
1689
+ say(`ℹ "${value}" is not one of ${Object.keys(VOICES).join(", ")} and does not look like an edge-tts id (en-US-AriaNeural) or a local one (kokoro:af_heart). \`retake routine voices\` lists what is here.`);
1690
1690
  }
1691
1691
  if (typeof parsed.data.music === "string" && !fs.existsSync(path.resolve(parsed.data.music))) {
1692
1692
  say(`ℹ ${parsed.data.music} is not there yet — the render will fail until it is. It ships inside your video, so it must be a track you may use.`);
@@ -1701,7 +1701,7 @@ routine
1701
1701
  .option("--line <text>", "what they should say", "This is the voice your demos will use, at the pace they will use it.")
1702
1702
  .option("--rate <percent>", "try a speed", "0")
1703
1703
  .action(async (opts) => {
1704
- const { synthesize } = await import("./voice.js");
1704
+ const { synthesize, LOCAL_VOICE, localModelReady } = await import("./voice.js");
1705
1705
  const dir = path.resolve("outputs", ".voices");
1706
1706
  fs.mkdirSync(dir, { recursive: true });
1707
1707
  const rate = Number(opts.rate);
@@ -1710,6 +1710,11 @@ routine
1710
1710
  for (const [name, v] of Object.entries(VOICES)) {
1711
1711
  const out = path.join(dir, `${name}.mp3`);
1712
1712
  process.stdout.write(` ${name.padEnd(14)} ${v.id} … `);
1713
+ // A sample is not worth a download: local voices are heard once chosen.
1714
+ if (LOCAL_VOICE.test(v.id) && !localModelReady(v.id)) {
1715
+ process.stdout.write(`local, not downloaded yet — ${v.note}\n`);
1716
+ continue;
1717
+ }
1713
1718
  try {
1714
1719
  synthesize(opts.line, v.id, out, rate);
1715
1720
  process.stdout.write(`${path.relative(process.cwd(), out)}\n`);
@@ -0,0 +1,14 @@
1
+ /** Where a demo's flags live and how they are read — on its own so that
2
+ `verify` (which checks them) and `flags` (which raises them, and asks
3
+ `verify` to judge) do not import each other. */
4
+ import fs from "node:fs";
5
+ export const flagsPath = (manifestFile) => manifestFile.replace(/\.(ya?ml|json)$/i, "") + ".flags.json";
6
+ export function readFlags(manifestFile) {
7
+ try {
8
+ const v = JSON.parse(fs.readFileSync(flagsPath(manifestFile), "utf8"));
9
+ return Array.isArray(v.flags) ? v.flags : [];
10
+ }
11
+ catch {
12
+ return [];
13
+ }
14
+ }
package/dist/ext/flags.js CHANGED
@@ -23,16 +23,8 @@ import { pickJudge, pool } from "./judge.js";
23
23
  import { judgeWith } from "./verify.js";
24
24
  import { clipFor } from "./clip.js";
25
25
  import { noteCheck } from "./checked.js";
26
- export const flagsPath = (manifestFile) => manifestFile.replace(/\.(ya?ml|json)$/i, "") + ".flags.json";
27
- export function readFlags(manifestFile) {
28
- try {
29
- const v = JSON.parse(fs.readFileSync(flagsPath(manifestFile), "utf8"));
30
- return Array.isArray(v.flags) ? v.flags : [];
31
- }
32
- catch {
33
- return [];
34
- }
35
- }
26
+ export { flagsPath, readFlags } from "./flag-store.js";
27
+ import { flagsPath, readFlags } from "./flag-store.js";
36
28
  function writeFlags(manifestFile, flags) {
37
29
  fs.writeFileSync(flagsPath(manifestFile), JSON.stringify({
38
30
  _readme: "Things you flagged on this demo. Each one is also an `expect:` on its scene in the manifest — that is what actually gets checked. Delete an entry here and in the manifest to stop watching it.",
package/dist/ext/heal.js CHANGED
@@ -64,7 +64,7 @@ export function findOrphans(outRoot, demosDir) {
64
64
  * came from, and this is not cosmetic. A take recorded with `--from` or
65
65
  * `--until` keeps the PARENT demo's name in its used manifest, so restoring
66
66
  * two fragments of one demo produced three files all declaring
67
- * `name: avex-full-flow` — and the window, which keys on that name, showed the
67
+ * `name: booking-full-flow` — and the window, which keys on that name, showed the
68
68
  * same demo three times. It looked broken because it was.
69
69
  */
70
70
  export function healOrphans(orphans, demosDir) {
package/dist/ext/sense.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * `verify` judges one frame: is the button readable, is the card inside its
5
5
  * box. This judges the whole run: what went IN against what came OUT.
6
6
  *
7
- * The bug that produced it: an Avex demo entered two legs — SGN→SIN and
7
+ * The bug that produced it: a flight-booking demo entered two legs — SGN→SIN and
8
8
  * SIN→SGN — and quoted a single price of 17,400. Nothing caught it. Every
9
9
  * step passed, every frame looked fine, and the take recorded both halves:
10
10
  *
@@ -23,7 +23,7 @@
23
23
  import fs from "node:fs";
24
24
  import path from "node:path";
25
25
  import { askAsync, pickJudge, pool, readJson, why as short } from "./judge.js";
26
- import { readFlags } from "./flags.js";
26
+ import { readFlags } from "./flag-store.js";
27
27
  import { NO_INTENT_NOTE, intentBlock } from "./intent.js";
28
28
  import { tag } from "./evidence.js";
29
29
  import { noteCheck } from "./checked.js";
package/dist/media-bin.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import { bundledFfmpeg } from "./self-repair.js";
3
3
  let cachedBin;
4
4
  /** Resolve the bundled media binary first, then fall back to PATH. */
@@ -21,3 +21,18 @@ export function ffmpegBin() {
21
21
  }
22
22
  return (cachedBin = "ffmpeg");
23
23
  }
24
+ /** ffmpeg -i probe: duration and stream size, no ffprobe needed. */
25
+ export function probe(file) {
26
+ const out = String(spawnSync(ffmpegBin(), ["-hide_banner", "-i", file], { encoding: "utf8" }).stderr);
27
+ const d = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(out);
28
+ const v = /Video:.*?\s(\d{2,5})x(\d{2,5})[\s,].*?(\d+(?:\.\d+)?)\s*fps/.exec(out) ?? /Video:.*?\s(\d{2,5})x(\d{2,5})/.exec(out);
29
+ if (!d)
30
+ throw new Error(`could not read duration of ${file}`);
31
+ return {
32
+ duration: Number(d[1]) * 3600 + Number(d[2]) * 60 + Number(d[3]),
33
+ width: v ? Number(v[1]) : 0,
34
+ height: v ? Number(v[2]) : 0,
35
+ fps: v && v[3] ? Number(v[3]) : 0,
36
+ };
37
+ }
38
+ export const videoDuration = (file) => probe(file).duration;
@@ -383,7 +383,7 @@ server.registerTool("env", {
383
383
  }
384
384
  });
385
385
  server.registerTool("routine", {
386
- description: "Call this FIRST in any session. The workspace's locked standard — output format, captions, voice and speaking rate, music — and, when the product has one, its full recording brief: where the app runs, how to seed it, the selectors that hold, and what breaks. READ THIS BEFORE DRAFTING and do not set `preset`, `layout`, `captions`, `viewport`, `scale`, `voiceover.voice`, `voiceover.ratePercent` or `music` yourself — they are not yours to choose, and choosing them is how two videos from the same workspace come back looking and sounding different. Write the steps and the captions; call routine_apply when the manifest exists.",
386
+ description: "Call this FIRST in any session. The workspace's locked standard — output format, captions, voice and speaking rate, music — and, when the product has one, its full recording brief: where the app runs, how to seed it, the selectors that hold, and what breaks. READ THIS BEFORE DRAFTING and do not set `preset`, `layout`, `captions`, `viewport`, `scale`, `voiceover.voice`, `voiceover.ratePercent` or `music` yourself — they are not yours to choose, and choosing them is how two videos from the same workspace come back looking and sounding different. Write the steps and the captions; call routine_apply when the manifest exists. Voices are names (andrew, ava, brian, emma, aria, ryan online; heart, michael, aiden local on the person's Mac): if the routine says silent and they want narration, ask which one.",
387
387
  inputSchema: {},
388
388
  annotations: READ_ONLY,
389
389
  }, async () => {
package/dist/record.js CHANGED
@@ -19,7 +19,7 @@ import { execFileSync, spawn } from "node:child_process";
19
19
  import YAML from "yaml";
20
20
  import { recordPage, moveCursorToPoint } from "testreel";
21
21
  import { expandEnv, signInAt, resolve } from "./manifest.js";
22
- import { ffmpegBin, videoDuration } from "./render.js";
22
+ import { ffmpegBin, videoDuration } from "./media-bin.js";
23
23
  import { assessActionProof, proofMatches, snapshotActionProof, waitForActionProof } from "./action-proof.js";
24
24
  /** What the *recording* depends on — nothing that render can change afterwards.
25
25
  A scene's caption, hold and camera zoom are render-time, so editing them must
@@ -1003,7 +1003,26 @@ export async function record(m, opts) {
1003
1003
  fs.rmSync(path.join(opts.outDir, "reached.json"), { force: true });
1004
1004
  }
1005
1005
  catch { /* fine */ }
1006
- const trimBefore = sec(setupEnd - t0);
1006
+ // The start is marked by painting the empty tab (paintFirstFrame), which
1007
+ // holds on an ordinary machine. On a loaded one — CI, a laptop running a
1008
+ // test suite — the browser can begin the video before that paint lands,
1009
+ // and the cut falls on the blank page. So check the picture itself: if the
1010
+ // take's first frame is flat while the page was proven painted, move the
1011
+ // start to the first real frame and every step with it.
1012
+ let late = 0;
1013
+ if (video) {
1014
+ late = blankLead(video, sec(setupEnd - t0));
1015
+ if (late > 0) {
1016
+ for (const e of timeline) {
1017
+ e.start = sec(e.start + late);
1018
+ e.end = sec(e.end + late);
1019
+ }
1020
+ for (const e of pageErrors)
1021
+ e.at = sec(e.at + late);
1022
+ log(`start: the video began ${late.toFixed(2)}s before the marked start — every step moved to match the picture`);
1023
+ }
1024
+ }
1025
+ const trimBefore = sec(setupEnd - t0 + late);
1007
1026
  // The recording keeps rolling while the browser is torn down, so the raw
1008
1027
  // video always has a tail of nothing after the last step. The take ends
1009
1028
  // where the demo ends (plus a breath), never where the recorder stopped —
@@ -1566,6 +1585,23 @@ export function describe(step) {
1566
1585
  return `scene: ${step.label}`;
1567
1586
  }
1568
1587
  }
1588
+ /** Seconds of flat, empty picture from `at` onward, before the first frame
1589
+ with anything on it — 0 when `at` already shows the page. Bounded: past
1590
+ 4s it is the app, not the clock, and the take is left alone. */
1591
+ export function blankLead(video, at, maxSeconds = 4) {
1592
+ try {
1593
+ const out = execFileSync(ffmpegBin(), ["-hide_banner", "-ss", at.toFixed(3), "-t", String(maxSeconds), "-i", video,
1594
+ "-vf", "fps=20,signalstats,metadata=print:file=-", "-f", "null", "-"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 30_000 });
1595
+ const frames = [...out.matchAll(/pts_time:([\d.]+)[\s\S]*?YMIN=(\d+)[\s\S]*?YMAX=(\d+)/g)].map((m) => ({ t: Number(m[1]), flat: Number(m[3]) - Number(m[2]) <= 8 }));
1596
+ if (!frames.length || !frames[0].flat)
1597
+ return 0;
1598
+ const first = frames.find((f) => !f.flat);
1599
+ return first ? Math.round(first.t * 1000) / 1000 : 0;
1600
+ }
1601
+ catch {
1602
+ return 0;
1603
+ }
1604
+ }
1569
1605
  /** Paint the empty tab and return the wall-clock ms of that paint — the
1570
1606
  video's first frame. Null if the page would not paint (then the old
1571
1607
  estimate stands). It happens during setup, so it is trimmed away. */
package/dist/render.js CHANGED
@@ -24,7 +24,7 @@ import { bandHeightFor, maxCharsFor, wrap } from "./captions.js";
24
24
  import { renderCard, renderCalloutOverlay, renderPoster, renderTitledCover } from "./cards.js";
25
25
  import { describeIdle, planIdle, warpFilterArgs, warpTake } from "./pace.js";
26
26
  import { holdFilterArgs, holdTake, planHolds } from "./hold.js";
27
- import { DEFAULT_VOICE, audioSeconds, cueGroups, cuesPerParagraph, cutPoints, silences, synthesize, synthesizeWithCues } from "./voice.js";
27
+ import { DEFAULT_VOICE, LOCAL_VOICE, LOCAL_VOICE_VERSION, voiceId, audioSeconds, cueGroups, cuesPerParagraph, cutPoints, silences, synthesize, synthesizeWithCues } from "./voice.js";
28
28
  import { expandEnv, resolve, Manifest } from "./manifest.js";
29
29
  import { ffmpegBin } from "./media-bin.js";
30
30
  export { ffmpegBin } from "./media-bin.js";
@@ -41,7 +41,7 @@ export function renderHash(m, take) {
41
41
  }
42
42
  catch {
43
43
  return null;
44
- } }) : null], idle: m.compressIdle, vo: m.voiceover, music: m.music ? [m.music, (() => { try {
44
+ } }) : null], idle: m.compressIdle, vo: m.voiceover, voEngine: typeof m.voiceover === "object" && LOCAL_VOICE.test(voiceId(m.voiceover.voice)) ? LOCAL_VOICE_VERSION : undefined, music: m.music ? [m.music, (() => { try {
45
45
  const f = typeof m.music === "string" ? m.music : m.music.file;
46
46
  const st = fs.statSync(path.resolve(f));
47
47
  return [st.size, Math.round(st.mtimeMs)];
@@ -67,21 +67,8 @@ function ff(args, log) {
67
67
  log?.(`$ ffmpeg ${args.join(" ")}`);
68
68
  execFileSync(ffmpegBin(), ["-hide_banner", "-loglevel", "error", "-y", ...args], { stdio: "inherit" });
69
69
  }
70
- /** ffmpeg -i probe: duration and stream size, no ffprobe needed. */
71
- export function probe(file) {
72
- const out = String(spawnSync(ffmpegBin(), ["-hide_banner", "-i", file], { encoding: "utf8" }).stderr);
73
- const d = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(out);
74
- const v = /Video:.*?\s(\d{2,5})x(\d{2,5})[\s,].*?(\d+(?:\.\d+)?)\s*fps/.exec(out) ?? /Video:.*?\s(\d{2,5})x(\d{2,5})/.exec(out);
75
- if (!d)
76
- throw new Error(`could not read duration of ${file}`);
77
- return {
78
- duration: Number(d[1]) * 3600 + Number(d[2]) * 60 + Number(d[3]),
79
- width: v ? Number(v[1]) : 0,
80
- height: v ? Number(v[2]) : 0,
81
- fps: v && v[3] ? Number(v[3]) : 0,
82
- };
83
- }
84
- export const videoDuration = (file) => probe(file).duration;
70
+ export { probe, videoDuration } from "./media-bin.js";
71
+ import { probe } from "./media-bin.js";
85
72
  /** Whether a file carries a sound track at all. */
86
73
  export function probeHasAudio(file) {
87
74
  return /Stream #\d+:\d+.*: Audio:/.test(String(spawnSync(ffmpegBin(), ["-hide_banner", "-i", file], { encoding: "utf8" }).stderr));
package/dist/routine.js CHANGED
@@ -20,6 +20,7 @@ import path from "node:path";
20
20
  import YAML from "yaml";
21
21
  import { execFileSync } from "node:child_process";
22
22
  import * as mediaBin from "./media-bin.js";
23
+ import { VOICES, voiceId } from "./voice.js";
23
24
  import { z } from "zod";
24
25
  export const STYLES = {
25
26
  showcase: {
@@ -53,19 +54,7 @@ export const STYLES = {
53
54
  workspace; it is not a per-video decision and never was. The language rides
54
55
  on the voice — a Vietnamese routine is a Vietnamese voice id, not a separate
55
56
  setting that would have to agree with this one. */
56
- export const VOICES = {
57
- ryan: { id: "en-GB-RyanNeural", note: "British, easy, positive. The one the Capture demos use." },
58
- andrew: { id: "en-US-AndrewNeural", note: "Warm, confident, unhurried." },
59
- ava: { id: "en-US-AvaNeural", note: "Expressive and friendly, a little closer in." },
60
- brian: { id: "en-US-BrianNeural", note: "Casual and sincere — least like a narrator." },
61
- emma: { id: "en-US-EmmaNeural", note: "Clear and conversational, good over fast UI." },
62
- aria: { id: "en-US-AriaNeural", note: "A news read: even, flat, no colour." },
63
- };
64
- /** A routine's `voice` is a name above, "silent", or a raw edge-tts voice id
65
- * (`vi-VN-HoaiMyNeural`) for a language the named set does not cover. */
66
- export function voiceId(voice) {
67
- return VOICES[voice]?.id ?? voice;
68
- }
57
+ export { VOICES, voiceId } from "./voice.js";
69
58
  /* ─── the file ─────────────────────────────────────────────────────────────── */
70
59
  export const RoutineSchema = z.object({
71
60
  style: z.enum(["showcase", "fullscreen", "mobile"]).default("showcase"),
@@ -160,6 +149,20 @@ export function routineFile(demosDir) {
160
149
  export function starter() {
161
150
  return { use: "default", routines: { default: RoutineSchema.parse({ note: "The house standard. Change it here, not per video." }) } };
162
151
  }
152
+ /** The narration chosen in the window's first run, kept in the routine so
153
+ every later agent reads it — not only the one that was told. It sets
154
+ whose voice reads a script; nothing is narrated until one is approved. */
155
+ export function rememberNarration(demosDir, narration) {
156
+ if (narration === "silent")
157
+ return;
158
+ const voice = { edge: "andrew", kokoro: "heart", aiden: "aiden" }[narration];
159
+ const file = readRoutines(demosDir) ?? starter();
160
+ const merged = RoutineSchema.safeParse({ ...(file.routines[file.use] ?? {}), voice });
161
+ if (merged.success) {
162
+ file.routines[file.use] = merged.data;
163
+ writeRoutines(demosDir, file);
164
+ }
165
+ }
163
166
  export function readRoutines(demosDir) {
164
167
  const f = routineFile(demosDir);
165
168
  if (!fs.existsSync(f))
@@ -52,13 +52,25 @@ export function bundledFfmpeg() {
52
52
  }
53
53
  if (!bin)
54
54
  return null;
55
- if (fs.existsSync(bin))
55
+ if (runs(bin))
56
56
  return bin;
57
+ // Missing, or there but not working: an install cut off mid-download
58
+ // leaves a partial file that is not executable, and "it exists" used to be
59
+ // enough to hand it to every render (EACCES, nothing could render).
57
60
  const installer = path.join(path.dirname(require.resolve("ffmpeg-static/package.json")), "install.js");
58
61
  if (!fs.existsSync(installer))
59
62
  return null;
60
- say("… downloading ffmpeg (one time, ~45 MB — npm skipped it at install)");
63
+ const broken = fs.existsSync(bin);
64
+ if (broken)
65
+ fs.rmSync(bin, { force: true });
66
+ say(broken ? "… ffmpeg was only partly downloaded — fetching it again (~45 MB)" : "… downloading ffmpeg (one time, ~45 MB — npm skipped it at install)");
61
67
  spawnSync(process.execPath, [installer], { cwd: path.dirname(installer), stdio: ["ignore", "ignore", "inherit"] });
62
- return fs.existsSync(bin) ? bin : null;
68
+ return runs(bin) ? bin : null;
69
+ }
70
+ /** Whether this is an ffmpeg that actually starts. */
71
+ function runs(bin) {
72
+ if (!fs.existsSync(bin))
73
+ return false;
74
+ return spawnSync(bin, ["-version"], { stdio: "ignore", timeout: 15_000 }).status === 0;
63
75
  }
64
76
  patchRecorder();
package/dist/ui/chat.html CHANGED
@@ -352,6 +352,9 @@
352
352
  .ob-operator-question button, .ob-retry { min-height:42px; border:1px solid var(--ink); border-radius:11px; padding:0 15px; color:#fff; background:var(--ink); font-weight:600; cursor:pointer; }
353
353
  .ob-operator-question button.secondary { color:var(--ink); background:#fff; }
354
354
  .ob-operator-result { margin-top:18px; }
355
+ .ob-next { margin:18px auto 0; max-width:520px; color:var(--muted); font-size:13px; line-height:1.6; }
356
+ .ob-next code { display:inline-block; margin-top:4px; padding:3px 8px; border-radius:7px; background:#fff; border:1px solid var(--rule); color:var(--ink); font:12.5px ui-monospace,Menlo,monospace; }
357
+ .ob-copy { border:1px solid var(--rule2); border-radius:8px; background:#fff; padding:3px 9px; font:inherit; font-size:12px; cursor:pointer; }
355
358
  .ob-operator-result.bad { color:var(--bad); }
356
359
  @media (max-width:700px) {
357
360
  .wrap.onboarding-only { padding:14px 12px 36px; }
@@ -520,16 +523,16 @@
520
523
  <p class="ob-lead">The first draft stays silent. Narration comes after you approve the recording and script.</p>
521
524
  <div class="voice-grid" role="radiogroup" aria-label="Narration choice">
522
525
  <button class="voice-card selected" type="button" data-voice="silent" role="radio" aria-checked="true">
523
- <span class="vicon" aria-hidden="true">×</span><b>No voiceover</b><small>Skip this for now. You can add your own voice later.</small><span class="tag">No setup</span>
526
+ <span class="vicon" aria-hidden="true">×</span><b>No voiceover</b><small>Skip this for now. You can choose a voice later.</small><span class="tag">No setup</span>
524
527
  </button>
525
528
  <button class="voice-card" type="button" data-voice="edge" data-sample="edge" role="radio" aria-checked="false">
526
529
  <span class="vicon" aria-hidden="true">∞</span><b>Edge</b><small>Online. Small install, many voices, and fast generation. Your script goes to Microsoft's service.</small><span class="tag">Tap to hear</span>
527
530
  </button>
528
531
  <button class="voice-card" type="button" data-voice="aiden" data-sample="aiden" role="radio" aria-checked="false">
529
- <span class="vicon" aria-hidden="true">A</span><b>Aiden</b><small>Local Qwen voice. The natural voice tested and selected for Retake. About 2 GB.</small><span class="tag">Tap to hear</span>
532
+ <span class="vicon" aria-hidden="true">A</span><b>Aiden</b><small>Local Qwen voice. The most natural one, and the biggest: about 4 GB, once. Runs on your Mac, so your script never leaves it.</small><span class="tag">Tap to hear</span>
530
533
  </button>
531
534
  <button class="voice-card" type="button" data-voice="kokoro" data-sample="kokoro" role="radio" aria-checked="false">
532
- <span class="vicon" aria-hidden="true">K</span><b>Kokoro</b><small>Local and much smaller. Roughly 100–200 MB with many voices. This is Retake's first Kokoro test.</small><span class="tag">Tap to hear</span>
535
+ <span class="vicon" aria-hidden="true">K</span><b>Kokoro</b><small>Local and smaller: about 2.6 GB once, many voices. Runs on your Mac, so your script never leaves it.</small><span class="tag">Tap to hear</span>
533
536
  </button>
534
537
  </div>
535
538
  <div class="voice-demo" id="voice-demo"><video id="voice-demo-video" controls playsinline preload="metadata"></video><small id="voice-demo-label"></small></div>
@@ -642,8 +645,8 @@ function renderOnboarding() {
642
645
  consent.classList.toggle('show', needsConsent);
643
646
  box.checked = onboardingState.consent;
644
647
  if (onboardingState.voice === 'edge') $('voice-consent-copy').textContent = 'I understand that Edge sends the narration script to Microsoft’s online speech service.';
645
- if (onboardingState.voice === 'aiden') $('voice-consent-copy').textContent = 'I understand that Aiden needs a separate local model download of about 2 GB. Nothing downloads on this screen.';
646
- if (onboardingState.voice === 'kokoro') $('voice-consent-copy').textContent = 'I understand that Kokoro needs a separate local model download of roughly 100–200 MB. Nothing downloads on this screen.';
648
+ if (onboardingState.voice === 'aiden') $('voice-consent-copy').textContent = 'I understand that Aiden needs a one-time download of about 4 GB (its voice engine and model), the first time a video is narrated. Nothing downloads on this screen.';
649
+ if (onboardingState.voice === 'kokoro') $('voice-consent-copy').textContent = 'I understand that Kokoro needs a one-time download of about 2.6 GB (its voice engine and model), the first time a video is narrated. Nothing downloads on this screen.';
647
650
  $('onboard-next').disabled =
648
651
  (onboardingStep === 1 && !normalUrl($('onboard-url').value)) ||
649
652
  (onboardingStep === 2 && brandRead.state === 'reading') ||
@@ -798,7 +801,12 @@ function finishOnboardingOperator(data) {
798
801
  if (ok) {
799
802
  localStorage.removeItem(ONBOARD_KEY);
800
803
  sessionStorage.removeItem(ONBOARD_SIGNED_KEY);
801
- card.innerHTML = `<div class="ob-success" data-operator-done><div class="ob-icon" aria-hidden="true">✓</div><h2>Your first draft is ready</h2><p class="ob-lead">${esc(data.summary || 'Open it from the library.')}</p><button type="button" data-open-first>Open first draft</button></div>`;
804
+ card.innerHTML = `<div class="ob-success" data-operator-done><div class="ob-icon" aria-hidden="true">✓</div><h2>Your first draft is ready</h2><p class="ob-lead">${esc(data.summary || 'Open it from the library.')}</p><button class="ob-retry" type="button" data-open-first>Open first draft</button>${onboardingState.voice && onboardingState.voice !== 'silent' ? `<p class="ob-next">To add a voiceover, tell your coding agent: <code data-next-ask>Add a voiceover to my Retake demo “${esc(data.demo)}”, using the voice in my Retake routine.</code> <button type="button" class="ob-copy" data-copy-next>Copy</button></p>` : ''}</div>`;
805
+ const copyNext = card.querySelector('[data-copy-next]');
806
+ if (copyNext) copyNext.onclick = async () => {
807
+ try { await navigator.clipboard.writeText(card.querySelector('[data-next-ask]').textContent); copyNext.textContent = 'Copied'; }
808
+ catch { copyNext.textContent = 'Select and copy it'; }
809
+ };
802
810
  card.querySelector('[data-open-first]').onclick = async () => {
803
811
  onboardingSubmitted = false;
804
812
  await drawDemos();
package/dist/ui/server.js CHANGED
@@ -51,10 +51,10 @@ import { startOffer, startApp, listeningPorts } from "../appserver.js";
51
51
  import { PKG_ROOT, PROJECT_ROOT, VERSION, entry, isDemoFile } from "../paths.js";
52
52
  import { SECRET_NAME, missingSecrets, writeEnvFile } from "../env.js";
53
53
  import { captureMatchesTake, readPreviewReceipt } from "../preview.js";
54
- import { STYLES, VOICES, RoutineSchema, environmentFor, readRoutines, starter, voiceId, writeRoutines } from "../routine.js";
54
+ import { STYLES, VOICES, RoutineSchema, environmentFor, readRoutines, rememberNarration, starter, voiceId, writeRoutines } from "../routine.js";
55
55
  import { loadEnvSpec } from "../recording-env.js";
56
56
  import { describeSchedule, readSchedule, scheduleLoaded } from "../schedule.js";
57
- import { synthesize, voiceCachePath } from "../voice.js";
57
+ import { synthesize, voiceCachePath, LOCAL_VOICE, localModelReady } from "../voice.js";
58
58
  const ROOT = PROJECT_ROOT;
59
59
  const DEMOS = path.join(ROOT, "demos");
60
60
  /** Where takes land. Default outputs/ in the project; changeable in Settings
@@ -789,6 +789,16 @@ export function serve(port) {
789
789
  if (!Number.isInteger(rate) || rate < -50 || rate > 50)
790
790
  return json(res, 400, { error: "rate is a whole percent, -50 to 50" });
791
791
  const id = voiceId(voice);
792
+ // A local voice is never downloaded just to be previewed (and Aiden is
793
+ // ~30s to speak even when present, which would hold this whole window
794
+ // up): play the sample that ships with Retake instead.
795
+ if (LOCAL_VOICE.test(id) && (id.startsWith("qwen:") || !localModelReady(id))) {
796
+ const f = path.join(PKG_ROOT, "assets", "voice-samples", id.startsWith("qwen:") ? "aiden.mp4" : "kokoro.mp4");
797
+ if (!fs.existsSync(f))
798
+ return json(res, 404, { error: "no sample for this voice yet" });
799
+ res.writeHead(200, { "content-type": "video/mp4", "content-length": fs.statSync(f).size, "cache-control": "no-store" });
800
+ return stream(f, res);
801
+ }
792
802
  const line = "This is the voice your demos will use, at the pace they will use it.";
793
803
  const out = voiceCachePath(line, id, rate);
794
804
  try {
@@ -1226,13 +1236,17 @@ export function serve(port) {
1226
1236
  if (!b.describe?.trim())
1227
1237
  return json(res, 400, { error: "say what the demo should show" });
1228
1238
  const narration = ["silent", "edge", "aiden", "kokoro"].includes(b.narration) ? b.narration : "silent";
1239
+ // The choice outlives this session: it goes into the routine, which is
1240
+ // what any later agent reads. A video still stays silent until someone
1241
+ // approves a script; this only settles whose voice reads it.
1242
+ rememberNarration(DEMOS, narration);
1229
1243
  const sound = narration === "silent"
1230
1244
  ? "Make the first draft silent and do not add captions."
1231
1245
  : narration === "edge"
1232
- ? "Make the first draft silent and do not add captions. The person chose Edge for the final narration. Do not synthesize it until they approve both the recording and one continuous script."
1246
+ ? "Make the first draft silent and do not add captions. The person chose Edge for the final narration; the workspace routine now says `voice: andrew`. Do not synthesize it until they approve both the recording and one continuous script."
1233
1247
  : narration === "aiden"
1234
- ? "Make the first draft silent and do not add captions. The person chose the local Aiden Qwen voice for the final narration. Do not download a model or synthesize speech until they separately approve the install, recording, and one continuous script."
1235
- : "Make the first draft silent and do not add captions. The person chose the compact local Kokoro voice for the final narration. Do not download a model or synthesize speech until they separately approve the install, recording, and one continuous script.";
1248
+ ? "Make the first draft silent and do not add captions. The person chose the local Aiden voice for the final narration: `voiceover.voice: qwen:aiden` (the workspace routine now says `voice: aiden`). It runs on their Mac; its first use downloads about 4 GB (the voice engine and the model, once). Do not narrate until they approve the recording and one continuous script, and say the download is coming before the first narrated render."
1249
+ : "Make the first draft silent and do not add captions. The person chose the local Kokoro voice for the final narration: `voiceover.voice: kokoro:af_heart` (the workspace routine now says `voice: heart`). It runs on their Mac; its first use downloads about 2.6 GB (the voice engine and the model, once). Do not narrate until they approve the recording and one continuous script, and say the download is coming before the first narrated render.";
1236
1250
  const describe = `${b.describe.trim()}\n\n${sound}`;
1237
1251
  const prov = pickProvider();
1238
1252
  const which = prov?.name === "codex" ? "codex" : prov?.name === "claude-code" ? "claude-code" : null;
package/dist/voice.js CHANGED
@@ -3,23 +3,81 @@
3
3
  * scene's line is synthesized once and placed at the scene's start, so the
4
4
  * narration is honest to the take by construction.
5
5
  *
6
- * Engine: edge-tts (Microsoft's neural voices — free, network). Found as a
7
- * CLI (`edge-tts`, pipx/pip) or module (`python3 -m edge_tts`). Retake does
8
- * not bundle a voice engine; the error says how to install one.
6
+ * Engines: edge-tts (Microsoft's neural voices — free, network), found as a
7
+ * CLI (`edge-tts`, pipx/pip) or module (`python3 -m edge_tts`); and two local
8
+ * voices that run on this Mac and send nothing anywhere — Kokoro
9
+ * (`kokoro:af_heart`) and Qwen3 (`qwen:aiden`) — through scripts/local-voice.py
10
+ * under uv. Retake bundles neither; the errors say how to get them.
9
11
  */
10
12
  import fs from "node:fs";
11
13
  import path from "node:path";
12
14
  import os from "node:os";
13
15
  import { createHash } from "node:crypto";
14
16
  import { execFileSync, spawnSync } from "node:child_process";
17
+ import { createRequire } from "node:module";
18
+ import { PKG_ROOT } from "./paths.js";
15
19
  export const DEFAULT_VOICE = "en-US-JennyNeural";
16
20
  /** Content-addressed speech cache. Narration text and voice are immutable
17
21
  * inputs, so generating the same line again is pure wasted network time. */
18
22
  export function voiceCachePath(text, voice, ratePercent = 0) {
19
23
  const root = process.env.RETAKE_TTS_CACHE || path.join(os.homedir(), ".cache", "retake-tts", "edge");
20
- const key = createHash("sha256").update(`${voice}\0${ratePercent}\0${text}`).digest("hex");
24
+ // A local voice's output changes when its pronunciation or levelling does;
25
+ // the version is part of the key so an improvement is heard, not cached away.
26
+ const v = LOCAL_VOICE.test(voice) ? `${voice}\0${LOCAL_VOICE_VERSION}` : voice;
27
+ const key = createHash("sha256").update(`${v}\0${ratePercent}\0${text}`).digest("hex");
21
28
  return path.join(root, `${key}.mp3`);
22
29
  }
30
+ export const VOICES = {
31
+ ryan: { id: "en-GB-RyanNeural", note: "British, easy, positive. The one the Capture demos use." },
32
+ andrew: { id: "en-US-AndrewNeural", note: "Warm, confident, unhurried." },
33
+ ava: { id: "en-US-AvaNeural", note: "Expressive and friendly, a little closer in." },
34
+ brian: { id: "en-US-BrianNeural", note: "Casual and sincere — least like a narrator." },
35
+ emma: { id: "en-US-EmmaNeural", note: "Clear and conversational, good over fast UI." },
36
+ aria: { id: "en-US-AriaNeural", note: "A news read: even, flat, no colour." },
37
+ // Local: these run on this Mac (Apple Silicon) and send nothing anywhere.
38
+ heart: { id: "kokoro:af_heart", note: "Local (Kokoro). Warm, natural, female. First use downloads ~2.6 GB (voice engine and model)." },
39
+ michael: { id: "kokoro:am_michael", note: "Local (Kokoro). Steady, male. Shares Heart's download." },
40
+ aiden: { id: "qwen:aiden", note: "Local (Qwen3). Natural, male. First use downloads ~4 GB (voice engine and model; ~1.8 GB if Kokoro is already set up)." },
41
+ };
42
+ /** A routine's `voice` is a name above, "silent", a raw edge-tts voice id
43
+ * (`vi-VN-HoaiMyNeural`) for a language the named set does not cover, or a
44
+ * raw local id (`kokoro:af_bella`, `qwen:ryan`). */
45
+ export function voiceId(voice) {
46
+ // The names a person or an agent reaches for: a name above, Kokoro's own
47
+ // id (`af_heart` — what an agent guessed first, and Retake handed to Edge),
48
+ // or the full form. Anything else is taken as an edge-tts id.
49
+ if (VOICES[voice])
50
+ return VOICES[voice].id;
51
+ if (/^[a-z][fm]_[a-z]+$/.test(voice))
52
+ return `kokoro:${voice}`;
53
+ return voice;
54
+ }
55
+ /** Voices that run on this Mac: `kokoro:<voice>` and `qwen:<speaker>`. */
56
+ export const LOCAL_VOICE = /^(kokoro|qwen):[a-z_]+$/;
57
+ /** Bump when local pronunciation or levelling changes: it re-renders them. */
58
+ export const LOCAL_VOICE_VERSION = "local-voice-1";
59
+ const LOCAL_MODEL = { kokoro: { repo: "models--mlx-community--Kokoro-82M-8bit", size: "~2.6 GB with the voice engine, once" }, qwen: { repo: "models--mlx-community--Qwen3-TTS-12Hz-0.6B-CustomVoice-8bit", size: "~4 GB with the voice engine, once" } };
60
+ /** The local voice engine: Retake's script under uv, with the MLX audio stack. */
61
+ export function localEngine(voice, platform = process.platform, arch = process.arch) {
62
+ if (platform !== "darwin" || arch !== "arm64")
63
+ throw new Error(`voiceover: ${voice} is a local voice, and local voices run on Apple Silicon Macs for now — use an Edge voice here (andrew, ava, emma…)`);
64
+ if (spawnSync("uvx", ["--version"], { stdio: "ignore" }).status !== 0)
65
+ throw new Error(`voiceover: ${voice} runs locally through uv, which is not installed — install it once (\`brew install uv\`), then re-render`);
66
+ const kind = voice.split(":")[0];
67
+ if (!localModelReady(voice))
68
+ process.stderr.write(`… first use of ${voice}: downloading its voice model (${LOCAL_MODEL[kind].size}, one time). Nothing you narrate leaves this computer.\n`);
69
+ return { cmd: "uvx", args: ["--quiet", "--python", "3.12", "--from", "mlx-audio==0.5.5", "--with", "soundfile", "--with", "misaki[en]", "python", path.join(PKG_ROOT, "scripts", "local-voice.py")] };
70
+ }
71
+ /** Whether a local voice's model is already on this computer. */
72
+ export function localModelReady(voice) {
73
+ const kind = voice.split(":")[0];
74
+ if (!LOCAL_MODEL[kind])
75
+ return false;
76
+ return fs.existsSync(path.join(process.env.HF_HOME || path.join(os.homedir(), ".cache", "huggingface"), "hub", LOCAL_MODEL[kind].repo));
77
+ }
78
+ function engineFor(voice) {
79
+ return LOCAL_VOICE.test(voice) ? localEngine(voice) : engine();
80
+ }
23
81
  function engine() {
24
82
  const home = process.env.HOME ?? "";
25
83
  for (const c of ["edge-tts", path.join(home, ".local", "bin", "edge-tts")]) {
@@ -38,29 +96,62 @@ export function edgeFailure(stderr, cmd) {
38
96
  const upgrade = cmd === "python3" ? "`pip install -U edge-tts`" : "`pipx upgrade edge-tts` (or `pip install -U edge-tts`)";
39
97
  if (/\b403\b|WSServerHandshakeError/.test(stderr))
40
98
  return `voiceover: Microsoft's voice service refused the request (403). That almost always means edge-tts is out of date — run ${upgrade}, then re-render. If it is already current, check that this computer's clock is right.`;
99
+ if (/Invalid voice/i.test(stderr))
100
+ return `voiceover: Edge does not know that voice. Use a name — ${Object.keys(VOICES).join(", ")} (heart, michael and aiden run on this Mac) — or an edge-tts id like en-US-AriaNeural. Local voices can also be written kokoro:<voice> or qwen:<speaker>.`;
41
101
  if (/getaddrinfo|ClientConnectorError|Cannot connect|Network is unreachable|TimeoutError/i.test(stderr))
42
102
  return "voiceover: could not reach Microsoft's voice service — is the network up? (edge-tts is a network service)";
43
103
  const last = stderr.trim().split("\n").filter(Boolean).pop() ?? "";
44
104
  return `voiceover: edge-tts failed${last ? ` — ${last.slice(0, 200)}` : ""}`;
45
105
  }
46
106
  function runEdge(e, args, timeout) {
107
+ const local = e.cmd === "uvx";
108
+ // The first use of a local voice downloads its engine and model (~2.6 GB
109
+ // for Kokoro): on a slow line that is far past 15 minutes. Allow an hour
110
+ // then, keep the downloaders' progress bars out of the captured output
111
+ // (they overflowed it), and say which of the two went wrong.
112
+ const voice = args[args.indexOf("--voice") + 1] ?? "";
113
+ const firstUse = local && !localModelReady(voice);
47
114
  try {
48
- execFileSync(e.cmd, [...e.args, ...args], { stdio: "pipe", timeout });
115
+ execFileSync(e.cmd, [...e.args, ...args], {
116
+ stdio: "pipe", maxBuffer: 64 * 1024 * 1024,
117
+ timeout: local ? (firstUse ? 3_600_000 : Math.max(timeout, 900_000)) : timeout,
118
+ env: local ? { ...process.env, FFMPEG_BIN: ffmpegPath(), HF_HUB_DISABLE_PROGRESS_BARS: "1", TQDM_DISABLE: "1" } : process.env,
119
+ });
49
120
  }
50
121
  catch (err) {
51
122
  const x = err;
123
+ if (local) {
124
+ if (x.code === "ETIMEDOUT" || x.signal === "SIGTERM")
125
+ throw new Error(firstUse
126
+ ? "voiceover: the local voice's one-time setup (downloading its engine and model) did not finish within an hour — check the connection, then render again; what was downloaded is kept"
127
+ : "voiceover: the local voice took too long to speak — render again; if it keeps happening, use an Edge voice for now");
128
+ const last = String(x.stderr ?? "").trim().split("\n").filter(Boolean).pop() ?? "";
129
+ throw new Error(`voiceover: the local voice failed${last ? ` — ${last.slice(0, 240)}` : " without saying why"}${firstUse ? " (during its one-time setup)" : ""}`);
130
+ }
52
131
  if (x.code === "ETIMEDOUT" || x.signal === "SIGTERM")
53
132
  throw new Error("voiceover: Microsoft's voice service did not answer in time — try again in a minute");
54
133
  throw new Error(edgeFailure(String(x.stderr ?? ""), e.cmd));
55
134
  }
56
135
  }
136
+ /** ffmpeg for the local voice's levelling: the one Retake renders with. */
137
+ function ffmpegPath() {
138
+ try {
139
+ const require = createRequire(import.meta.url);
140
+ const p = require("ffmpeg-static");
141
+ if (p && fs.existsSync(p))
142
+ return p;
143
+ }
144
+ catch { /* system ffmpeg */ }
145
+ return "ffmpeg";
146
+ }
57
147
  export function synthesize(text, voice, out, ratePercent = 0) {
148
+ voice = voiceId(voice);
58
149
  const cached = voiceCachePath(text, voice, ratePercent);
59
150
  if (fs.existsSync(cached) && fs.statSync(cached).size >= 200) {
60
151
  fs.copyFileSync(cached, out);
61
152
  return;
62
153
  }
63
- const e = engine();
154
+ const e = engineFor(voice);
64
155
  const temp = `${out}.tmp-${process.pid}`;
65
156
  try {
66
157
  runEdge(e, ["--voice", voice, "--rate", `${ratePercent >= 0 ? "+" : ""}${ratePercent}%`, "--text", text, "--write-media", temp], 60_000);
@@ -102,13 +193,14 @@ function parseSrt(srt) {
102
193
  * performance at those points. edge-tts writes the sentence timings itself.
103
194
  */
104
195
  export function synthesizeWithCues(text, voice, out, ratePercent = 0) {
196
+ voice = voiceId(voice);
105
197
  const cached = voiceCachePath(`cues\0${text}`, voice, ratePercent);
106
198
  const cachedSrt = cached.replace(/\.mp3$/, ".srt");
107
199
  if (fs.existsSync(cached) && fs.statSync(cached).size >= 200 && fs.existsSync(cachedSrt)) {
108
200
  fs.copyFileSync(cached, out);
109
201
  return parseSrt(fs.readFileSync(cachedSrt, "utf8"));
110
202
  }
111
- const e = engine();
203
+ const e = engineFor(voice);
112
204
  const temp = `${out}.tmp-${process.pid}`, srt = `${out}.srt`;
113
205
  try {
114
206
  runEdge(e, ["--voice", voice, "--rate", `${ratePercent >= 0 ? "+" : ""}${ratePercent}%`, "--text", text, "--write-media", temp, "--write-subtitles", srt], 120_000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retake-demos",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
4
4
  "description": "Demo-as-code for coding agents: a YAML manifest in, a product walkthrough video out. Rerun the demo instead of re-recording it.",
5
5
  "keywords": [
6
6
  "demo",
@@ -39,6 +39,7 @@
39
39
  "docs/stitch.md",
40
40
  "LICENSE",
41
41
  "scripts/patch-testreel.mjs",
42
+ "scripts/local-voice.py",
42
43
  "AGENTS.md",
43
44
  "llms.txt",
44
45
  "site/releases.json"
@@ -51,7 +52,7 @@
51
52
  "retake": "tsx src/cli.ts",
52
53
  "ui": "tsx src/cli.ts ui",
53
54
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
54
- "test": "tsx --test test/*.test.ts",
55
+ "test": "tsx --test --test-concurrency=3 test/*.test.ts",
55
56
  "site:dev": "node scripts/serve-landing.mjs",
56
57
  "site:test": "node --test test/*-static.test.mjs",
57
58
  "site:test:browser": "node --test test/landing.test.mjs"
@@ -0,0 +1,131 @@
1
+ """Retake's local voices: Kokoro and Qwen3 (Aiden), on this Mac.
2
+
3
+ Speaks the way edge-tts does, so narration does not care which it got:
4
+ --voice kokoro:<voice> | qwen:<speaker> --rate +N% --text ...
5
+ --write-media out.mp3 [--write-subtitles out.srt]
6
+ Each sentence is its own take (the voice keeps its shape), joined with short
7
+ breaths; the SRT says where each falls. Levelled to Edge's loudness so a
8
+ comparison hears the voice, not the volume.
9
+
10
+ --pronounce "text" print the text as Kokoro will be given it (no models)
11
+
12
+ Run by Retake through uv: uvx --from mlx-audio==0.5.5 --with soundfile
13
+ --with "misaki[en]" python local-voice.py ... Apple Silicon only (MLX).
14
+ """
15
+ import argparse, os, re, subprocess, sys, tempfile
16
+
17
+ # ---------- words Kokoro's guesser gets wrong in demo narration ----------
18
+ # "record" is a heteronym: the guesser hears a music RE-cord in "press record".
19
+ # In a demo script it is almost always the verb (re-CORD); after a word that
20
+ # makes it a noun ("a record", "the record") it is left alone. "demos" came
21
+ # out "dee-moss". Overrides use misaki's own [word](/phonemes/) form.
22
+ NOUN_BEFORE = {"a", "an", "the", "this", "that", "my", "your", "our", "their", "its", "his", "her",
23
+ "any", "no", "new", "old", "track", "world", "public", "permanent", "every"}
24
+ FIXED = {"demos": "dˈɛmOz", "demo": "dˈɛmO"}
25
+ VERB = {"record": "ɹəkˈɔɹd", "records": "ɹəkˈɔɹdz"}
26
+
27
+
28
+ def pronounce(text: str) -> str:
29
+ out, prev = [], ""
30
+ for tok in re.split(r"(\W+)", text):
31
+ low = tok.lower()
32
+ if low in VERB and prev not in NOUN_BEFORE:
33
+ tok = f"[{tok}](/{VERB[low]}/)"
34
+ elif low in FIXED:
35
+ tok = f"[{tok}](/{FIXED[low]}/)"
36
+ if tok.strip() and re.match(r"\w", tok):
37
+ prev = low
38
+ out.append(tok)
39
+ return "".join(out)
40
+
41
+
42
+ def short_espeak_data() -> None:
43
+ """espeak-ng keeps its data path in a fixed-size buffer: past ~150
44
+ characters it silently falls back to the path it was built with
45
+ (/Users/runner/work/…) and exits the process. uv's cache path plus
46
+ site-packages reaches that with a long username or a custom cache
47
+ folder. So espeak reads a copy of its data (~20 MB, made once) from a
48
+ short folder. A link would not do: phonemizer resolves it back to the
49
+ long path. Set after misaki has imported, which sets the long path."""
50
+ import shutil
51
+ import espeakng_loader
52
+ from phonemizer.backend.espeak.wrapper import EspeakWrapper
53
+ try:
54
+ import misaki.espeak # noqa: F401 (sets the long path; overridden below)
55
+ except Exception:
56
+ pass
57
+ data = espeakng_loader.get_data_path()
58
+ if len(data) > 100:
59
+ short = os.path.join(os.path.expanduser("~/Library/Caches/retake"), "espeak-ng-data")
60
+ marker = os.path.join(short, ".copied-from")
61
+ fresh = os.path.isfile(marker) and open(marker).read() == data
62
+ if not fresh:
63
+ if os.path.lexists(short):
64
+ shutil.rmtree(short) if os.path.isdir(short) and not os.path.islink(short) else os.remove(short)
65
+ shutil.copytree(data, short)
66
+ with open(marker, "w") as f:
67
+ f.write(data)
68
+ data = short
69
+ EspeakWrapper.set_library(espeakng_loader.get_library_path())
70
+ EspeakWrapper.set_data_path(data)
71
+
72
+
73
+ EDGE_LUFS = -19.8 # measured: Edge's Andrew reading the same lines
74
+ SR = 24000
75
+ MODELS = {"kokoro": "mlx-community/Kokoro-82M-8bit", "qwen": "mlx-community/Qwen3-TTS-12Hz-0.6B-CustomVoice-8bit"}
76
+
77
+
78
+ def main() -> None:
79
+ p = argparse.ArgumentParser()
80
+ p.add_argument("--voice"); p.add_argument("--rate", default="+0%"); p.add_argument("--text")
81
+ p.add_argument("--write-media"); p.add_argument("--write-subtitles"); p.add_argument("--pronounce")
82
+ a = p.parse_args()
83
+ if a.pronounce is not None:
84
+ print(pronounce(a.pronounce))
85
+ return
86
+
87
+ import numpy as np, soundfile as sf
88
+ from mlx_audio.tts.utils import load_model
89
+
90
+ engine, name = a.voice.split(":", 1)
91
+ if engine == "kokoro":
92
+ short_espeak_data()
93
+ speed = 1 + int(re.sub(r"[%+]", "", a.rate) or 0) / 100
94
+ model = load_model(MODELS[engine])
95
+
96
+ def say(sentence: str):
97
+ if engine == "kokoro":
98
+ gen = model.generate(text=pronounce(sentence), voice=name, speed=speed, lang_code="a" if name[0] == "a" else "b")
99
+ else:
100
+ gen = model.generate(text=sentence, voice=name, speed=speed, lang_code="english", verbose=False)
101
+ return np.concatenate([np.array(r.audio, dtype=np.float32).reshape(-1) for r in gen])
102
+
103
+ paras = [x.strip() for x in re.split(r"\n\s*\n|\n", a.text) if x.strip()] or [a.text]
104
+ audio, cues, t = [], [], 0.0
105
+ for para in paras:
106
+ for sentence in [s for s in re.split(r"(?<=[.!?])\s+", para) if s.strip()]:
107
+ chunk = say(sentence)
108
+ audio.append(chunk); cues.append((t, t + len(chunk) / SR, sentence)); t += len(chunk) / SR
109
+ audio.append(np.zeros(int(SR * 0.3), dtype=np.float32)); t += 0.3
110
+ audio.append(np.zeros(int(SR * 0.35), dtype=np.float32)); t += 0.35
111
+
112
+ ff = os.environ.get("FFMPEG_BIN", "ffmpeg")
113
+ wav = tempfile.mktemp(suffix=".wav")
114
+ sf.write(wav, np.concatenate(audio), SR)
115
+ try:
116
+ meas = subprocess.run([ff, "-hide_banner", "-nostats", "-i", wav, "-af", "ebur128=framelog=quiet", "-f", "null", "-"], capture_output=True, text=True).stderr
117
+ found = re.findall(r"I:\s+(-?[\d.]+) LUFS", meas)
118
+ gain = EDGE_LUFS - float(found[-1]) if found else 0.0
119
+ subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-y", "-i", wav, "-af", f"volume={gain:.2f}dB,alimiter=limit=0.97",
120
+ "-c:a", "libmp3lame", "-b:a", "128k", "-f", "mp3", a.write_media], check=True)
121
+ finally:
122
+ os.remove(wav)
123
+ if a.write_subtitles:
124
+ ts = lambda s: f"{int(s // 3600):02d}:{int(s % 3600 // 60):02d}:{int(s % 60):02d},{int(round((s % 1) * 1000)) % 1000:03d}"
125
+ with open(a.write_subtitles, "w") as f:
126
+ for i, (st, en, tx) in enumerate(cues, 1):
127
+ f.write(f"{i}\n{ts(st)} --> {ts(en)}\n{tx}\n\n")
128
+
129
+
130
+ if __name__ == "__main__":
131
+ sys.exit(main())
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "_readme": "What's new, written for the person using Retake — not for whoever wrote it. Both the landing page and the Retake window read THIS file; there is no second copy. Rules for adding one: say what changed for them, name no files, no flags they will not type, no words like refactor or regression. If a line only makes sense to someone who has read the source, it does not belong here. Newest first. Add an entry on every release that changes what people see.",
3
3
  "releases": [
4
+ {
5
+ "version": "0.14.3",
6
+ "date": "2026-10-19",
7
+ "headline": "Choosing Kokoro or Aiden now works for someone starting from nothing",
8
+ "notes": [
9
+ "The voice you pick when you first open Retake is now remembered. Before, only the agent that made your first draft was told; the next one had to ask which voice you meant.",
10
+ "When your first draft is ready, the window shows the exact sentence to give your agent to add a voiceover, with a copy button.",
11
+ "Kokoro no longer fails to start when your Mac's user name or cache folder makes its files' path long.",
12
+ "Agents can name a voice the way they naturally would — heart, aiden, or Kokoro's own af_heart — and a voice name Retake does not know now says which names work.",
13
+ "The first use of a local voice downloads its engine as well as the voice: about 2.6 GB for Kokoro and 4 GB for Aiden. The window now says so, and allows up to an hour for it on a slow connection.",
14
+ "If an install was interrupted part-way through downloading ffmpeg, Retake now notices the broken copy and fetches it again instead of failing every render."
15
+ ]
16
+ },
17
+ {
18
+ "version": "0.14.2",
19
+ "date": "2026-10-16",
20
+ "headline": "The local voices you can pick are now the voices you get",
21
+ "notes": [
22
+ "Kokoro and Aiden, the two local voices offered when you first open Retake, now narrate your videos. Before, choosing one only told your agent your preference: Retake itself could speak only Edge. They run on your Mac (Apple Silicon), so your script never leaves it, and they come out exactly as loud as Edge.",
23
+ "Pick them by name: heart (Kokoro, female), michael (Kokoro, male) or aiden. The model downloads once, the first time a video is narrated: about 300 MB for Kokoro, 1.8 GB for Aiden.",
24
+ "Kokoro now says demo words the demo way: “press record” as re-CORD, not the music kind, and “demos” as deh-mohz.",
25
+ "Hearing a voice before you pick it never downloads anything, and never freezes the window.",
26
+ "On a busy computer, a recording could still start on a blank frame. Retake now checks the picture itself and starts on the first real frame."
27
+ ]
28
+ },
4
29
  {
5
30
  "version": "0.14.1",
6
31
  "date": "2026-10-16",
package/skill/SKILL.md CHANGED
@@ -338,7 +338,7 @@ typed, chose and clicked, against the frames that came out.
338
338
  Nothing needs declaring. The inputs are already in the take — this is the half
339
339
  of the recording nothing has ever read.
340
340
 
341
- It found a real one on its first outing. An Avex quote entered two legs:
341
+ It found a real one on its first outing. A flight-booking demo's quote entered two legs:
342
342
 
343
343
  ```
344
344
  type "SGN" → #leg-from-0 type "SIN" → #leg-to-0
@@ -505,6 +505,12 @@ narrated too fast, the next too slow, and a third in a voice nobody picked.
505
505
  person who has heard it; the routine only says which voice, at what pace,
506
506
  once somebody has written one.
507
507
 
508
+ Voices are names: `andrew`, `ava`, `brian`, `emma`, `aria`, `ryan` (Edge,
509
+ online) and `heart`, `michael`, `aiden` (local: they run on the person's Mac
510
+ and the first narrated render downloads the model). The voice a person picked
511
+ when they first opened Retake is the routine's voice. If the routine says
512
+ `silent` and they want narration, ask which voice — never guess an id.
513
+
508
514
  If there is no routine, the advice below applies and nothing is locked.
509
515
 
510
516
  ## Style — how the person wants their videos to look