playhead-cli 0.1.1 → 0.1.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/README.md CHANGED
@@ -142,6 +142,24 @@ Scaffold one with `npx playhead init`.
142
142
  Add `--vision` to `render`/`verify` for an optional Claude review of the contact sheet (needs
143
143
  `ANTHROPIC_API_KEY`; skipped cleanly without one, so CI stays deterministic).
144
144
 
145
+ ### Narration voices
146
+
147
+ `output.audio.narration: tts` speaks each step's narration line. Two providers:
148
+
149
+ | `provider` | Voice | Deps | Platforms |
150
+ |---|---|---|---|
151
+ | `kokoro` (recommended) | Natural neural (Kokoro-82M). `voice:` = `heart` (**default**) or `michael` | `npm i -g kokoro-js` (~400MB, one-time; model ~90MB auto-downloads on first use) | Mac · Linux · Windows |
152
+ | `say` | macOS built-in (robotic). `voice:` = a `say` voice name | none | macOS only |
153
+
154
+ ```yaml
155
+ output:
156
+ audio: { narration: tts, provider: kokoro } # → Heart, offline, cross-platform
157
+ ```
158
+
159
+ Behind a TLS-intercepting proxy, the one-time model download needs the system CA:
160
+ `NODE_EXTRA_CA_CERTS=/etc/ssl/cert.pem playhead render …`. `playhead doctor --ci` reports which
161
+ providers are available.
162
+
145
163
  **Exit codes are a contract:** `0` publishable · `1` the app broke the flow (a partial bundle
146
164
  **and a watchable failure clip** are written) · `2` rendered but failed verification ·
147
165
  `3` infra · `4` bad spec/arguments.
@@ -1,8 +1,10 @@
1
1
  export interface NarrationOptions {
2
- /** Only 'say' (macOS local TTS) is implemented today; the seam is here for cloud providers. */
3
- provider: 'say';
2
+ /** 'say' macOS built-in (zero deps, Mac-only). 'kokoro' natural neural voice, offline,
3
+ * cross-platform, via the optional kokoro-js package. */
4
+ provider: 'say' | 'kokoro';
5
+ /** say: a macOS voice name (Samantha). kokoro: a voice id (af_heart, am_michael, …). */
4
6
  voice?: string;
5
- /** Words per minute (say-specific). */
7
+ /** say: words per minute. kokoro: playback speed × 100 (100 = 1.0×). */
6
8
  rate?: number;
7
9
  }
8
10
  export interface NarrationLine {
@@ -1 +1 @@
1
- {"version":3,"file":"tts.d.ts","sourceRoot":"","sources":["../../src/audio/tts.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,gBAAgB;IAC/B,+FAA+F;IAC/F,QAAQ,EAAE,KAAK,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;CACpB;AAqCD;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,KAAK,EAAE,aAAa,EAAE,EACtB,IAAI,EAAE,gBAAgB,EACtB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAWrC"}
1
+ {"version":3,"file":"tts.d.ts","sourceRoot":"","sources":["../../src/audio/tts.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,gBAAgB;IAC/B;8DAC0D;IAC1D,QAAQ,EAAE,KAAK,GAAG,QAAQ,CAAC;IAC3B,wFAAwF;IACxF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;CACpB;AA6FD;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,KAAK,EAAE,aAAa,EAAE,EACtB,IAAI,EAAE,gBAAgB,EACtB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAYrC"}
package/dist/cli/index.js CHANGED
@@ -359,7 +359,10 @@ var init_schema = __esm({
359
359
  */
360
360
  audio: z.object({
361
361
  narration: z.enum(["none", "tts"]).default("none"),
362
- provider: z.enum(["say"]).default("say"),
362
+ // 'say' — macOS built-in (zero deps, Mac-only, robotic).
363
+ // 'kokoro' — natural neural voice, offline, cross-platform; needs the optional
364
+ // kokoro-js package (npm i kokoro-js). Voices: af_heart (default), am_michael, …
365
+ provider: z.enum(["say", "kokoro"]).default("say"),
363
366
  voice: z.string().optional(),
364
367
  rate: z.number().int().min(80).max(400).optional(),
365
368
  /** Click sound effects at click/select beats. Defaults on when this block is present. */
@@ -369,7 +372,10 @@ var init_schema = __esm({
369
372
  file: z.string(),
370
373
  gainDb: z.number().min(-40).max(6).default(-19)
371
374
  }).strict().optional()
372
- }).strict().optional(),
375
+ }).strict().refine(
376
+ (a) => a.provider !== "kokoro" || a.voice === void 0 || ["heart", "af_heart", "michael", "am_michael"].includes(a.voice),
377
+ { message: "kokoro voice must be 'heart' (default) or 'michael'", path: ["voice"] }
378
+ ).optional(),
373
379
  /** Closing card. When set, the video ends on a title-card-styled end card. */
374
380
  endCard: z.object({
375
381
  title: z.string().min(1),
@@ -4037,11 +4043,25 @@ import { join as join5 } from "path";
4037
4043
  import { createHash as createHash2 } from "crypto";
4038
4044
  import { createRequire as createRequire4 } from "module";
4039
4045
  function providerFor(opts) {
4040
- const voice = opts.voice ?? "Samantha";
4041
- const rate = opts.rate ?? 178;
4042
4046
  switch (opts.provider) {
4043
- case "say":
4047
+ case "say": {
4048
+ const voice = opts.voice ?? "Samantha";
4049
+ const rate = opts.rate ?? 178;
4044
4050
  return { provider: new SayProvider(voice, rate), voice, rate };
4051
+ }
4052
+ case "kokoro": {
4053
+ const KOKORO_VOICES = {
4054
+ heart: "af_heart",
4055
+ af_heart: "af_heart",
4056
+ michael: "am_michael",
4057
+ am_michael: "am_michael"
4058
+ };
4059
+ const requested = opts.voice ?? "heart";
4060
+ const voice = KOKORO_VOICES[requested];
4061
+ if (!voice) throw new Error(`kokoro voice must be 'heart' (default) or 'michael', got '${requested}'`);
4062
+ const rate = opts.rate ?? 100;
4063
+ return { provider: new KokoroProvider(voice, rate / 100), voice, rate };
4064
+ }
4045
4065
  default:
4046
4066
  throw new Error(`unknown TTS provider: ${opts.provider}`);
4047
4067
  }
@@ -4061,16 +4081,17 @@ async function probeDurationMs(file) {
4061
4081
  async function synthesizeNarration(lines, opts, cacheDir) {
4062
4082
  await mkdir2(cacheDir, { recursive: true });
4063
4083
  const { provider, voice, rate } = providerFor(opts);
4084
+ const ext = provider.name === "kokoro" ? "wav" : "aiff";
4064
4085
  const out = /* @__PURE__ */ new Map();
4065
4086
  for (const { stepRef, text } of lines) {
4066
4087
  const key = createHash2("sha256").update(`${provider.name}|${voice}|${rate}|${text}`).digest("hex").slice(0, 16);
4067
- const file = join5(cacheDir, `${key}.aiff`);
4088
+ const file = join5(cacheDir, `${key}.${ext}`);
4068
4089
  if (!existsSync2(file)) await provider.synth(text, file);
4069
4090
  out.set(stepRef, { stepRef, file, durationMs: await probeDurationMs(file) });
4070
4091
  }
4071
4092
  return out;
4072
4093
  }
4073
- var require3, ffprobePath2, exec, SayProvider;
4094
+ var require3, ffprobePath2, exec, SayProvider, KokoroProvider;
4074
4095
  var init_tts = __esm({
4075
4096
  "src/audio/tts.ts"() {
4076
4097
  "use strict";
@@ -4091,6 +4112,39 @@ var init_tts = __esm({
4091
4112
  await exec("say", ["-v", this.voice, "-r", String(this.rate), "-f", txt, "-o", outFile]);
4092
4113
  }
4093
4114
  };
4115
+ KokoroProvider = class {
4116
+ constructor(voice, speed) {
4117
+ this.voice = voice;
4118
+ this.speed = speed;
4119
+ }
4120
+ voice;
4121
+ speed;
4122
+ name = "kokoro";
4123
+ tts = null;
4124
+ load() {
4125
+ if (this.tts) return this.tts;
4126
+ this.tts = (async () => {
4127
+ let mod;
4128
+ try {
4129
+ mod = await import("kokoro-js");
4130
+ } catch {
4131
+ throw new Error(
4132
+ "the 'kokoro' voice needs the optional kokoro-js package \u2014 install it with `npm i -g kokoro-js` (or add it to your project). It bundles the neural model runtime (~400MB); the base playhead-cli stays lean."
4133
+ );
4134
+ }
4135
+ return await mod.KokoroTTS.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX", {
4136
+ dtype: "q8",
4137
+ device: "cpu"
4138
+ });
4139
+ })();
4140
+ return this.tts;
4141
+ }
4142
+ async synth(text, outFile) {
4143
+ const tts = await this.load();
4144
+ const audio = await tts.generate(text, { voice: this.voice, speed: this.speed });
4145
+ await audio.save(outFile);
4146
+ }
4147
+ };
4094
4148
  }
4095
4149
  });
4096
4150
 
@@ -6611,8 +6665,11 @@ program.command("doctor").description("check that ffmpeg, the browser, and fonts
6611
6665
  const { promisify: promisify3 } = await import("util");
6612
6666
  const which = promisify3(execFile3);
6613
6667
  const hasSay = await which("which", ["say"]).then(() => true).catch(() => false);
6614
- if (hasSay) log.ok("tts: macOS `say` available");
6615
- else log.warn("tts: no `say` binary \u2014 output.audio.narration will render SILENT on this host (Linux TTS provider planned)");
6668
+ if (hasSay) log.ok("tts (say): macOS `say` available");
6669
+ else log.info("tts (say): no `say` binary here \u2014 the `say` provider is macOS-only");
6670
+ const hasKokoro = await import("kokoro-js").then(() => true).catch(() => false);
6671
+ if (hasKokoro) log.ok("tts (kokoro): kokoro-js installed \u2014 the natural offline voice is available");
6672
+ else log.warn("tts (kokoro): kokoro-js not installed \u2014 `provider: kokoro` needs `npm i -g kokoro-js` (~400MB, one time)");
6616
6673
  if (process.platform === "linux") {
6617
6674
  log.info("linux: run chromium with a base image that provides fonts + sandbox deps (see Dockerfile), and give /dev/shm \u2265 512MB or pass --disable-dev-shm-usage");
6618
6675
  }