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/dist/index.js CHANGED
@@ -327,7 +327,10 @@ var specSchema = z.object({
327
327
  */
328
328
  audio: z.object({
329
329
  narration: z.enum(["none", "tts"]).default("none"),
330
- provider: z.enum(["say"]).default("say"),
330
+ // 'say' — macOS built-in (zero deps, Mac-only, robotic).
331
+ // 'kokoro' — natural neural voice, offline, cross-platform; needs the optional
332
+ // kokoro-js package (npm i kokoro-js). Voices: af_heart (default), am_michael, …
333
+ provider: z.enum(["say", "kokoro"]).default("say"),
331
334
  voice: z.string().optional(),
332
335
  rate: z.number().int().min(80).max(400).optional(),
333
336
  /** Click sound effects at click/select beats. Defaults on when this block is present. */
@@ -337,7 +340,10 @@ var specSchema = z.object({
337
340
  file: z.string(),
338
341
  gainDb: z.number().min(-40).max(6).default(-19)
339
342
  }).strict().optional()
340
- }).strict().optional(),
343
+ }).strict().refine(
344
+ (a) => a.provider !== "kokoro" || a.voice === void 0 || ["heart", "af_heart", "michael", "am_michael"].includes(a.voice),
345
+ { message: "kokoro voice must be 'heart' (default) or 'michael'", path: ["voice"] }
346
+ ).optional(),
341
347
  /** Closing card. When set, the video ends on a title-card-styled end card. */
342
348
  endCard: z.object({
343
349
  title: z.string().min(1),
@@ -3777,12 +3783,59 @@ var SayProvider = class {
3777
3783
  await exec("say", ["-v", this.voice, "-r", String(this.rate), "-f", txt, "-o", outFile]);
3778
3784
  }
3779
3785
  };
3786
+ var KokoroProvider = class {
3787
+ constructor(voice, speed) {
3788
+ this.voice = voice;
3789
+ this.speed = speed;
3790
+ }
3791
+ voice;
3792
+ speed;
3793
+ name = "kokoro";
3794
+ tts = null;
3795
+ load() {
3796
+ if (this.tts) return this.tts;
3797
+ this.tts = (async () => {
3798
+ let mod;
3799
+ try {
3800
+ mod = await import("kokoro-js");
3801
+ } catch {
3802
+ throw new Error(
3803
+ "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."
3804
+ );
3805
+ }
3806
+ return await mod.KokoroTTS.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX", {
3807
+ dtype: "q8",
3808
+ device: "cpu"
3809
+ });
3810
+ })();
3811
+ return this.tts;
3812
+ }
3813
+ async synth(text, outFile) {
3814
+ const tts = await this.load();
3815
+ const audio = await tts.generate(text, { voice: this.voice, speed: this.speed });
3816
+ await audio.save(outFile);
3817
+ }
3818
+ };
3780
3819
  function providerFor(opts) {
3781
- const voice = opts.voice ?? "Samantha";
3782
- const rate = opts.rate ?? 178;
3783
3820
  switch (opts.provider) {
3784
- case "say":
3821
+ case "say": {
3822
+ const voice = opts.voice ?? "Samantha";
3823
+ const rate = opts.rate ?? 178;
3785
3824
  return { provider: new SayProvider(voice, rate), voice, rate };
3825
+ }
3826
+ case "kokoro": {
3827
+ const KOKORO_VOICES = {
3828
+ heart: "af_heart",
3829
+ af_heart: "af_heart",
3830
+ michael: "am_michael",
3831
+ am_michael: "am_michael"
3832
+ };
3833
+ const requested = opts.voice ?? "heart";
3834
+ const voice = KOKORO_VOICES[requested];
3835
+ if (!voice) throw new Error(`kokoro voice must be 'heart' (default) or 'michael', got '${requested}'`);
3836
+ const rate = opts.rate ?? 100;
3837
+ return { provider: new KokoroProvider(voice, rate / 100), voice, rate };
3838
+ }
3786
3839
  default:
3787
3840
  throw new Error(`unknown TTS provider: ${opts.provider}`);
3788
3841
  }
@@ -3802,10 +3855,11 @@ async function probeDurationMs(file) {
3802
3855
  async function synthesizeNarration(lines, opts, cacheDir) {
3803
3856
  await mkdir2(cacheDir, { recursive: true });
3804
3857
  const { provider, voice, rate } = providerFor(opts);
3858
+ const ext = provider.name === "kokoro" ? "wav" : "aiff";
3805
3859
  const out = /* @__PURE__ */ new Map();
3806
3860
  for (const { stepRef, text } of lines) {
3807
3861
  const key = createHash2("sha256").update(`${provider.name}|${voice}|${rate}|${text}`).digest("hex").slice(0, 16);
3808
- const file = join5(cacheDir, `${key}.aiff`);
3862
+ const file = join5(cacheDir, `${key}.${ext}`);
3809
3863
  if (!existsSync2(file)) await provider.synth(text, file);
3810
3864
  out.set(stepRef, { stepRef, file, durationMs: await probeDurationMs(file) });
3811
3865
  }