Skill: gizmogremlin/voiceai-voiceover-creator (version 0.1.3)

--- name: voiceai-creator-voiceover-pipeline description: "Turn scripts into publishable voiceovers with Voice.ai TTS, including segments, chapters, captions, and video muxing." version: 0.2.1 env: - VOICE_AI_API_KEY required_env: - VOICE_AI_API_KEY credentials: - VOICE_AI_API_KEY setup: "none — single file, runs directly with Node.js" runtime: "node>=20" optional_deps: "ffmpeg" --- # Voice.ai Creator Voiceover Pipeline > This skill follows the [Agent Skills specification](https://agentskills.io/specification). Turn any script into a **publish-ready voiceover** — complete with numbered segments, a stitched master, YouTube chapters, SRT captions, and a beautiful review page. Optionally, replace the audio track on an existing video. Built for creators who want studio-quality voiceovers without the studio. Powered by [Voice.ai](https://voice.ai). --- ## When to use this skill | Scenario | Why it fits | |---|---| | **YouTube long-form** | Full narration with chapter markers and captions | | **YouTube Shorts** | Quick hooks with the `shortform` template | | **Podcasts** | Consistent host voice, intro/outro templates | | **Course content** | Professional narration for educational videos | | **Quick iteration** | Smart caching — edit one section, only that segment re-renders | | **Video audio replacement** | Drop AI voiceover onto screen recordings or B-roll | --- ## The one-command workflow Have a script and a video? Turn them into a finished video with AI voiceover in one shot: ```bash node voiceai-vo.cjs build \ --input my-script.md \ --voice oliver \ --title "My Video" \ --video ./my-recording.mp4 \ --mux ``` This renders the voiceover, stitches the master audio, and drops it onto your video — all in one command. Output: - `out/my-video/muxed.mp4` — your video with the new voiceover - `out/my-video/master.wav` — the standalone audio - `out/my-video/review.html` — listen and review each segment - `out/my-video/chapters.txt` — YouTube-ready chapter timestamps - `out/my-video/captions.srt` — SRT captions Use `--sync pad` if the audio is shorter than the video, or `--sync trim` to cut it to match. --- ## Requirements - **Node.js 20+** — runtime (no npm install needed — the CLI is a single bundled file) - **VOICE_AI_API_KEY** — set as environment variable or in a `.env` file in the skill root. Get a key at [voice.ai/dashboard](https://voice.ai/dashboard). - **ffmpeg** (optional) — needed for master stitching, MP3 encoding, loudness normalization, and video muxing. The pipeline still produces individual segments, the review page, chapters, and captions without it. --- ## Configuration The skill reads `VOICE_AI_API_KEY` from (in order): 1. Environment variable `VOICE_AI_API_KEY` 2. Environment variable `VOICEAI_API_KEY` (alternate) 3. `.env` file in the skill root ```bash echo 'VOICE_AI_API_KEY=your-key-here' > .env ``` Use `--mock` on any command to run the full pipeline without an API key (produces placeholder audio). --- ## Commands ### `build` — Generate a voiceover from a script ```bash node voiceai-vo.cjs build \ --input <script.md or script.txt> \ --voice <voice-alias-or-uuid> \ --title "My Project" \ [--template youtube|podcast|shortform] \ [--language en] \ [--video input.mp4 --mux --sync shortest] \ [--force] [--mock] ``` **What it does:** 1. Reads the script and splits it into segments (by `##` headings for `.md`, or by sentence boundaries for `.txt`) 2. Optionally prepends/appends template intro/outro segments 3. Renders each segment via Voice.ai TTS as a numbered WAV file 4. Stitches a master audio file (if ffmpeg is available) 5. Generates chapters, captions, a review page, and metadata files 6. Optionally muxes the voiceover into an existing video **Full options:** | Option | Description | |---|---| | `-i, --input <path>` | Script file (.txt or .md) — **required** | | `-v, --voice <id>` | Voice alias or UUID — **required** | | `-t, --title <title>` | Project title (defaults to filename) | | `--template <name>` | `youtube`, `podcast`, or `shortform` | | `--mode <mode>` | `headings` or `auto` (default: headings for .md) | | `--max-chars <n>` | Max characters per auto-chunk (default: 1500) | | `--language <code>` | Language code (default: en) | | `--video <path>` | Input video for muxing | | `--mux` | Enable video muxing (requires --video) | | `--sync <policy>` | `shortest`, `pad`, or `trim` (default: shortest) | | `--force` | Re-render all segments (ignore cache) | | `--mock` | Mock mode — no API calls, placeholder audio | | `-o, --out <dir>` | Custom output directory | ### `replace-audio` — Swap the audio track on a video ```bash node voiceai-vo.cjs replace-audio \ --video ./input.mp4 \ --audio ./out/my-project/master.wav \ [--out ./out/my-project/muxed.mp4] \ [--sync shortest|pad|trim] ``` Requires ffmpeg. If not installed, generates helper shell/PowerShell scripts instead. | Sync policy | Behavior | |---|---| | `shortest` (default) | Output ends when the shorter track ends | | `pad` | Pad audio with silence to match video duration | | `trim` | Trim audio to match video duration | Video stream is copied without re-encoding (`-c:v copy`). Audio is encoded as AAC. A mux report is saved alongside the output. **Privacy:** Video processing is entirely local. Only script text is sent to Voice.ai for TTS. ### `voices` — List available voices ```bash node voiceai-vo.cjs voices [--limit 20] [--query "deep"] [--mock] ``` --- ## Available voices Use short aliases or full UUIDs with `--voice`: | Alias | Voice | Gender | Style | |----------|----------------------|--------|--------------------------| | `ellie` | Ellie | F | Youthful, vibrant vlogger| | `oliver` | Oliver | M | Friendly British | | `lilith` | Lilith | F | Soft, feminine | | `smooth` | Smooth Calm Voice | M | Deep, smooth narrator | | `corpse` | Corpse Husband | M | Deep, distinctive | | `skadi` | Skadi | F | Anime character | | `zhongli`| Zhongli | M | Deep, authoritative | | `flora` | Flora | F | Cheerful, high pitch | | `chief` | Master Chief | M | Heroic, commanding | The `voices` command also returns any additional voices available on the API. Voice list is cached for 10 minutes. --- ## Build outputs After a build, the output directory contains: ``` out/<title-slug>/ segments/ # Numbered WAV files (001-intro.wav, 002-section.wav, …) master.wav # Stitched audio (requires ffmpeg) master.mp3 # MP3 encode (requires ffmpeg) manifest.json # Build metadata: voice, template, segment list, hashes timeline.json # Segment durations and start times review.html # Interactive review page with audio players chapters.txt # YouTube-friendly chapter timestamps captions.srt # SRT captions using segment boundaries description.txt # YouTube description with chapters + Voice.ai credit ``` ### review.html A standalone HTML page with: - Master audio player (if stitched) - Individual segment players with titles and durations - Collapsible script text for each segment - Regeneration command hints --- ## Templates Templates auto-inject intro/outro segments around the script content: | Template | Prepends | Appends | |---|---|---| | `youtube` | `templates/youtube_intro.txt` | `templates/youtube_outro.txt` | | `podcast` | `templates/podcast_intro.txt` | — | | `shortform` | `templates/shortform_hook.txt` | — | Edit the files in `templates/` to customize the intro/outro text. --- ## Caching Segments are cached by a hash of: `text content + voice ID + language`. - Unchanged segments are **skipped** on rebuild — fast iteration - Modified segments are **re-rendered** automatically - Use `--force` to re-render everything - Cache manifest is stored in `segments/.cache.json` --- ## Multilingual support Voice.ai supports 11 languages. Use `--language <code>` to switch: `en`, `es`, `fr`, `de`, `it`, `pt`, `pl`, `ru`, `nl`, `sv`, `ca` The pipeline auto-selects the multilingual TTS model for non-English languages. --- ## Troubleshooting | Issue | Solution | |---|---| | **ffmpeg missing** | Pipeline still works — you get segments, review page, chapters, captions. Install ffmpeg for master stitching and video muxing. | | **Rate limits (429)** | Segments render sequentially, which stays under most limits. Wait and retry. | | **Insufficient credits (402)** | Top up at [voice.ai/dashboard](https://voice.ai/dashboard). Cached segments won't re-use credits on retry. | | **Long scripts** | Caching makes rebuilds fast. Text over 490 chars per segment is automatically split across API calls. | | **Windows paths** | Wrap paths with spaces in quotes: `--input "C:\My Scripts\script.md"` | See [`references/TROUBLESHOOTING.md`](references/TROUBLESHOOTING.md) for more. --- ## References - [Agent Skills Specification](https://agentskills.io/specification) - [Voice.ai](https://voice.ai) - [`references/VOICEAI_API.md`](references/VOICEAI_API.md) — API endpoints, audio formats, models - [`references/TROUBLESHOOTING.md`](references/TROUBLESHOOTING.md) — Common issues and fixes

path: README.md
content: # voiceai-creator-voiceover-pipeline

> Turn scripts into publishable voiceovers with Voice.ai — segments, chapters, captions, review page, and video muxing.

**📖 Skill documentation: [SKILL.md](SKILL.md)**

---

## Quick start

No install needed — just Node.js 20+ and the bundled CLI file.

```bash
# Set your API key
echo 'VOICE_AI_API_KEY=your-key-here' > .env

# Script + video → finished video with AI voiceover
node voiceai-vo.cjs build \
  --input my-script.md \
  --voice oliver \
  --title "My Video" \
  --video ./my-recording.mp4 \
  --mux

# Or just build the voiceover (no video)
node voiceai-vo.cjs build --input examples/youtube_script.md --voice ellie --title "My Video"

# List available voices
node voiceai-vo.cjs voices

# Test without an API key
node voiceai-vo.cjs build --input examples/youtube_script.md --voice ellie --title "My Video" --mock
```

Get your API key at [voice.ai/dashboard](https://voice.ai/dashboard).

## What it produces

```
out/<title>/
  segments/        # Numbered WAV files
  master.wav       # Stitched master (ffmpeg)
  master.mp3       # MP3 encode (ffmpeg)
  review.html      # Interactive review page
  chapters.txt     # YouTube chapters
  captions.srt     # SRT captions
  description.txt  # YouTube description
  manifest.json    # Build metadata
  timeline.json    # Segment timing
```

## Developer setup

To modify the source code:

```bash
git clone https://github.com/gizmoGremlin/VoiceAi-voiceover-creator.git
cd VoiceAi-voiceover-creator

# Install dev dependencies
npm install

# Build TypeScript
npm run build

# Re-bundle the CLI
npx esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=voiceai-vo.cjs

# Run tests
npm test
```

### FFmpeg (optional but recommended)

```bash
# macOS
brew install ffmpeg

# Ubuntu / Debian
sudo apt install ffmpeg

# Windows
choco install ffmpeg
```

## Learn more

- **[SKILL.md](SKILL.md)** — Full skill documentation: commands, voices, outputs, configuration
- **[references/VOICEAI_API.md](references/VOICEAI_API.md)** — Voice.ai API endpoints and formats
- **[references/TROUBLESHOOTING.md](references/TROUBLESHOOTING.md)** — Common issues and fixes

---

*Powered by [Voice.ai](https://voice.ai) · Follows the [Agent Skills specification](https://agentskills.io/specification)*


path: eslint.config.js
content: import tseslint from 'typescript-eslint';

export default tseslint.config(
  ...tseslint.configs.recommended,
  {
    rules: {
      '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
      '@typescript-eslint/no-explicit-any': 'warn',
    },
  },
  {
    ignores: ['dist/', 'out/', 'tmp/', 'node_modules/'],
  },
);


path: examples/podcast_script.md
content: # Deep Dive: Building a Content Empire with AI Tools

## Welcome to the Show

Hey everyone, welcome back to Creator Stack — the podcast where we break down the tools, tactics, and workflows that top creators use to scale their content.

I'm your host, and today we're talking about something I've been testing for the last few weeks that honestly blew my mind: AI-powered voiceover pipelines.

## The Problem Every Creator Faces

Let me paint a picture. You've got five video ideas, three blog posts to turn into audio, and a course module that needs narration. But you've only got two hours.

In the old world, that's impossible. Recording, editing, mixing — each piece of content takes at least an hour of audio work alone.

But what if you could write the scripts, hit a command, and have all of them rendered as professional voiceovers in minutes?

## Enter Voice.ai

Voice.ai is doing something really interesting in the TTS space. Instead of those robotic, flat text-to-speech outputs we're all used to, their neural voices actually sound like real people.

I'm talking natural pauses, proper emphasis, emotional range. The kind of audio that your listeners won't immediately clock as AI-generated.

And the variety is impressive — warm narrators, energetic hosts, calm storytellers, even sports commentator styles. You can genuinely match the voice to your brand.

## The Workflow I'm Using

Here's my actual workflow, step by step:

First, I write my podcast scripts in Markdown. Each section gets an H2 heading — that becomes a segment in the final output.

Then I run the voiceover pipeline with my preferred voice. It chunks the script, renders each segment, stitches them together, and generates a master audio file.

I also get chapters for YouTube uploads, SRT captions, and a beautiful review page where I can listen to each segment and decide if I want to regenerate any of them.

The caching is smart too — if I edit one section, only that segment gets re-rendered. Everything else stays cached.

## Tips for Best Results

A few things I've learned:

Keep your sentences conversational. Write like you talk, not like you're writing an essay. The AI voices handle natural speech patterns much better than formal prose.

Use the template system. The intro and outro templates give your content consistent branding without you having to copy-paste the same lines every time.

Experiment with voices. Don't just pick the first one that sounds good. Try a few different voices for the same script — you might be surprised which one resonates best.

## Wrapping Up

If you're a creator who's been putting off audio content because of the production overhead, this is your sign to start.

The tools are here. The quality is there. And the time savings are genuinely life-changing.

That's all for today's episode. If you enjoyed this, leave a review wherever you listen — it really helps the show. See you next week!


path: examples/shorts_script.txt
content: Stop recording your own voiceovers. Seriously.

I used to spend 45 minutes recording a single YouTube intro. Setting up the mic, doing five takes, editing out the ums and background noise.

Now? I write the script, pick a voice, and run one command. Done. Professional audio in seconds.

The secret? Voice.ai's neural TTS. It sounds so natural that my audience can't tell the difference.

And the best part — I can try ten different voices before breakfast. No studio. No mic. No excuses.

Link in the description if you want to try it yourself.


path: examples/youtube_script.md
content: # The Future of AI-Generated Voiceovers

## Introduction

Welcome back to the channel! Today we're diving into one of the most exciting shifts happening in content creation right now — AI-powered voiceovers.

Whether you're a YouTuber, a podcaster, or a brand building video content at scale, the tools available today are nothing short of incredible. And the best part? You don't need a studio, a microphone, or even your own voice to create polished, professional audio.

## Why Creators Are Switching to AI Voice

Let's be honest — recording voiceovers the traditional way is painful. You need a quiet room, decent gear, and the patience to do multiple takes. Then there's editing, noise removal, and leveling.

AI voiceover changes the game. You write your script, pick a voice, and in seconds you have broadcast-ready audio. No retakes. No background noise. Consistent quality every single time.

Platforms like Voice.ai give you access to dozens of natural-sounding voices — from warm narrators to energetic hosts — so you can find the perfect match for your content style.

## How the Pipeline Works

Here's the workflow we're demonstrating today:

1. Write your script in Markdown or plain text
2. Choose a voice from the Voice.ai catalog
3. Run the build command
4. Get individual segments, a stitched master file, chapters, captions, and a review page
5. Optionally drop that audio onto an existing video

That's it. Five steps from script to publish-ready content.

## Real-World Use Cases

This pipeline is perfect for:

- **YouTube long-form** — full narration with chapter markers
- **YouTube Shorts** — quick hooks with punchy delivery
- **Podcasts** — consistent co-host voice or solo narration
- **Course content** — professional audio for educational videos
- **Product demos** — clean voiceover for walkthroughs

## What About Video?

One of the coolest features is the ability to replace audio on an existing video. Got a screen recording with bad mic audio? Just run `replace-audio` and drop in your AI voiceover. The video stays untouched — only the audio changes.

You can even control sync behavior: trim to match video length, pad with silence, or use the default "shortest" policy.

## Closing Thoughts

AI voiceover isn't replacing human creativity — it's amplifying it. You still write the script. You still choose the voice. You still decide the pacing and structure.

What changes is the barrier to entry. Now anyone can produce professional audio content, regardless of their setup or vocal abilities.

Thanks for watching! If you found this helpful, smash that like button and subscribe for more creator tech breakdowns.


path: package.json
content: {
  "name": "voiceai-creator-voiceover-pipeline",
  "version": "0.2.1",
  "description": "Turn scripts into publishable voiceovers with Voice.ai TTS — creator-grade pipeline with segments, chapters, captions, and video muxing via ffmpeg.",
  "type": "module",
  "bin": {
    "voiceai-vo": "./dist/cli.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "start": "node dist/cli.js",
    "test": "vitest run",
    "test:watch": "vitest",
    "lint": "eslint src/",
    "format": "prettier --write \"src/**/*.ts\""
  },
  "engines": {
    "node": ">=20"
  },
  "keywords": [
    "voice.ai",
    "tts",
    "voiceover",
    "creator",
    "youtube",
    "podcast",
    "ffmpeg",
    "text-to-speech",
    "agent-skill"
  ],
  "author": "Voice.ai",
  "license": "MIT",
  "dependencies": {
    "chalk": "^5.3.0",
    "commander": "^12.1.0",
    "dotenv": "^16.4.7"
  },
  "devDependencies": {
    "@types/node": "^20.17.0",
    "esbuild": "^0.27.3",
    "eslint": "^9.14.0",
    "prettier": "^3.4.0",
    "typescript": "^5.6.0",
    "typescript-eslint": "^8.13.0",
    "vitest": "^2.1.0"
  }
}


path: references/TROUBLESHOOTING.md
content: # Troubleshooting

Common issues and solutions for the voiceai-creator-voiceover-pipeline.

---

## FFmpeg not found

**Symptom:** Warning about skipping master stitch or video muxing.

**Solution:** Install ffmpeg for your platform:

```bash
# macOS
brew install ffmpeg

# Ubuntu / Debian
sudo apt update && sudo apt install ffmpeg

# Windows (Chocolatey)
choco install ffmpeg

# Windows (winget)
winget install ffmpeg

# Windows (manual)
# Download from https://ffmpeg.org/download.html
# Add the bin/ folder to your PATH
```

After installing, verify:
```bash
ffmpeg -version
ffprobe -version
```

> **Note:** The pipeline still works without ffmpeg — you get individual segments, the review page, chapters, and captions. Only master stitching and video muxing require ffmpeg.

---

## Rate limits / API errors

**Symptom:** `Voice.ai TTS error 429` or similar.

**Solutions:**
- Wait a few minutes and retry
- Use `--mock` mode for pipeline testing
- For large scripts, the pipeline renders segments sequentially — this naturally stays under most rate limits
- If you hit limits frequently, consider adding retry logic in `src/api.ts` (a TODO for production use)

---

## Long scripts

**Symptom:** Script has many segments and takes a long time.

**Solutions:**
- Segments are cached by content hash — subsequent builds only re-render changed segments
- Use `--max-chars` to control segment size (larger = fewer API calls, but longer per-segment)
- Split very long scripts into separate build runs
- Use `--mock` mode for testing the pipeline before using real API credits

---

## Windows path quoting

**Symptom:** Paths with spaces cause errors.

**Solutions:**
```powershell
# PowerShell — wrap in quotes
voiceai-vo build --input "C:\Users\Me\My Scripts\script.md" --voice v-warm-narrator --mock

# Or use short paths / avoid spaces
voiceai-vo build --input C:\scripts\video.md --voice v-warm-narrator --mock
```

---

## "Real API not yet configured" error

**Symptom:** Error when running without `--mock` and without setting up real API endpoints.

**Explanation:** The current release uses placeholder API endpoints. The full pipeline works in `--mock` mode.

**Solutions:**
- Always use `--mock` flag until real API endpoints are configured
- See `references/VOICEAI_API.md` for what endpoints to fill in
- Contact the Voice.ai team for production API access

---

## Audio playback issues in review.html

**Symptom:** Audio players don't work in review.html.

**Solutions:**
- Open `review.html` directly in your browser (file:// protocol works)
- Make sure the `segments/` folder is next to `review.html`
- Some browsers block file:// audio — try a local server:
  ```bash
  cd out/my-project/
  python3 -m http.server 8000
  # Then open http://localhost:8000/review.html
  ```

---

## Segment caching

**How it works:**
- Each segment is hashed based on: text content + voice ID + language
- Hashes are stored in `segments/.cache.json`
- On rebuild, unchanged segments are skipped
- Use `--force` to regenerate everything

**To clear cache manually:**
```bash
rm out/my-project/segments/.cache.json
```


path: references/VOICEAI_API.md
content: # Voice.ai API Reference

Production API endpoints used by this pipeline.
Based on the [Voice.ai TTS SDK](https://github.com/gizmoGremlin/openclaw-skill-voice-ai-voices).

## Authentication

Bearer token in the `Authorization` header:

```
Authorization: Bearer <VOICE_AI_API_KEY>
```

Get your key at [voice.ai/dashboard](https://voice.ai/dashboard).

## Base URL

```
https://dev.voice.ai
```

Override via `VOICEAI_API_BASE` environment variable.

API path prefix: `/api/v1`

---

## Endpoints

### `GET /api/v1/tts/voices` — List available voices

**Query Parameters:**

| Param        | Type    | Default | Description                |
|--------------|---------|---------|----------------------------|
| `limit`      | integer | 10      | Max voices to return       |
| `offset`     | integer | 0       | Pagination offset          |
| `visibility` | string  | —       | `PUBLIC` or `PRIVATE`      |

**Response:**

```json
{
  "voices": [
    {
      "voice_id": "d1bf0f33-8e0e-4fbf-acf8-45c3c6262513",
      "name": "Ellie",
      "language": "en",
      "visibility": "PUBLIC",
      "status": "AVAILABLE"
    }
  ]
}
```

### `POST /api/v1/tts/speech` — Generate speech

**Request Body:**

```json
{
  "text": "Hello, world!",
  "voice_id": "d1bf0f33-8e0e-4fbf-acf8-45c3c6262513",
  "audio_format": "wav",
  "temperature": 1.0,
  "top_p": 0.8,
  "model": "voiceai-tts-v1-latest",
  "language": "en"
}
```

| Field          | Type   | Required | Default                     | Description                         |
|----------------|--------|----------|-----------------------------|-------------------------------------|
| `text`         | string | ✅       | —                           | Text to synthesize (max 5000 chars) |
| `voice_id`     | string | No       | built-in default            | Voice UUID or omit for default      |
| `audio_format` | string | No       | `mp3`                       | `mp3`, `wav`, `pcm`, etc.           |
| `temperature`  | number | No       | 1.0                         | Variation (0.0–2.0)                 |
| `top_p`        | number | No       | 0.8                         | Nucleus sampling (0.0–1.0)          |
| `model`        | string | No       | auto-selected by language   | `voiceai-tts-v1-latest` or `voiceai-tts-multilingual-v1-latest` |
| `language`     | string | No       | `en`                        | ISO 639-1 code                      |

**Response:** Binary audio data in the requested format.

### `POST /api/v1/tts/speech/stream` — Streaming speech

Same body as `/tts/speech`. Returns chunked transfer-encoded audio for low-latency playback.

---

## Audio Formats

| Format            | Description                |
|-------------------|----------------------------|
| `mp3`             | MP3 at 32kHz (default)     |
| `wav`             | WAV at 32kHz               |
| `pcm`             | Raw PCM 16-bit             |
| `mp3_44100_128`   | MP3 44.1kHz 128kbps        |
| `mp3_44100_192`   | MP3 44.1kHz 192kbps        |
| `wav_22050`       | WAV 22.05kHz               |
| `wav_24000`       | WAV 24kHz                  |
| `opus_48000_128`  | Opus 48kHz 128kbps         |

## Models

| Model ID                                    | Languages |
|---------------------------------------------|-----------|
| `voiceai-tts-v1-latest`                     | English   |
| `voiceai-tts-multilingual-v1-latest`        | 11 langs  |

Multilingual model supports: en, es, fr, de, it, pt, pl, ru, nl, sv, ca.

## Popular Voices

| Alias    | Voice ID (UUID)                              | Gender | Style                    |
|----------|----------------------------------------------|--------|--------------------------|
| `ellie`  | [REDACTED_SECRET]      | F      | Youthful, vibrant vlogger|
| `oliver` | [REDACTED_SECRET]      | M      | Friendly British         |
| `lilith` | [REDACTED_SECRET]      | F      | Soft, feminine           |
| `smooth` | [REDACTED_SECRET]      | M      | Deep, smooth narrator    |
| `corpse` | [REDACTED_SECRET]      | M      | Deep, distinctive        |
| `skadi`  | [REDACTED_SECRET]      | F      | Anime character          |
| `zhongli`| [REDACTED_SECRET]      | M      | Deep, authoritative      |
| `flora`  | [REDACTED_SECRET]      | F      | Cheerful, high pitch     |
| `chief`  | [REDACTED_SECRET]      | M      | Heroic, commanding       |

The CLI accepts both aliases (`--voice ellie`) and full UUIDs (`--voice d1bf0f33-...`).

---

## Error Codes

| Code | Meaning            | Action                           |
|------|--------------------|----------------------------------|
| 401  | Invalid API key    | Check `VOICE_AI_API_KEY`         |
| 402  | Out of credits     | Top up at voice.ai/dashboard     |
| 422  | Validation error   | Check text length, voice_id      |
| 429  | Rate limited       | Wait and retry                   |

---

## Mock Mode

When `--mock` is passed, the pipeline runs end-to-end without any network calls:

- Voice listing returns the 9 popular voices above
- TTS returns generated WAV files with an audible test tone
- No API key required
- All output files (review.html, chapters, captions, etc.) are produced identically


path: src/api.ts
content: /**
 * Voice.ai API client — all HTTP calls in one place.
 *
 * Real endpoints from https://dev.voice.ai/api/v1
 * Includes --mock mode for full pipeline testing without API key.
 *
 * Reference: https://github.com/gizmoGremlin/openclaw-skill-voice-ai-voices
 */
import { probeDuration } from './ffmpeg.js';

/* ------------------------------------------------------------------ */
/*  Constants                                                          */
/* ------------------------------------------------------------------ */

const BASE_URL = 'https://dev.voice.ai';
const API_VERSION = 'v1';

export const AUDIO_FORMATS = {
  MP3: 'mp3',
  WAV: 'wav',
  PCM: 'pcm',
  MP3_44100_128: 'mp3_44100_128',
  MP3_44100_192: 'mp3_44100_192',
  WAV_22050: 'wav_22050',
  WAV_24000: 'wav_24000',
} as const;

export const MODELS = {
  TTS_V1_LATEST: 'voiceai-tts-v1-latest',
  MULTILINGUAL_V1_LATEST: 'voiceai-tts-multilingual-v1-latest',
} as const;

export const LANGUAGES = {
  en: 'English', es: 'Spanish', fr: 'French', de: 'German',
  it: 'Italian', pt: 'Portuguese', pl: 'Polish', ru: 'Russian',
  nl: 'Dutch', sv: 'Swedish', ca: 'Catalan',
} as const;

/* ------------------------------------------------------------------ */
/*  Types                                                              */
/* ------------------------------------------------------------------ */

export interface Voice {
  id: string;           // voice_id from API, normalized to `id`
  name: string;
  language: string;
  visibility?: string;  // PUBLIC | PRIVATE
  status?: string;      // PENDING | PROCESSING | AVAILABLE | FAILED
  gender?: string;
  style?: string;
  description?: string;
}

export interface TTSRequest {
  text: string;
  voice_id: string;
  audio_format?: string;
  temperature?: number;
  top_p?: number;
  model?: string;
  language?: string;
}

export interface TTSResponse {
  audio_data: Buffer;
  duration_seconds: number;
  sample_rate: number;
  format: string;
}

export interface VoiceListResponse {
  voices: Voice[];
  total: number;
}

/* ------------------------------------------------------------------ */
/*  Popular voices (real Voice.ai IDs)                                 */
/* ------------------------------------------------------------------ */

export const POPULAR_VOICES: Voice[] = [
  { id: [REDACTED_SECRET], name: 'Ellie', language: 'en', gender: 'female', style: 'Youthful, vibrant fashion vlogger', description: 'Youthful, vibrant — perfect for vlogs and social content.' },
  { id: [REDACTED_SECRET], name: 'Oliver', language: 'en', gender: 'male', style: 'Friendly British, conversational', description: 'Friendly British tone — great for narration and tutorials.' },
  { id: [REDACTED_SECRET], name: 'Lilith', language: 'en', gender: 'female', style: 'Soft, feminine', description: 'Soft, feminine — ideal for ASMR and calm content.' },
  { id: [REDACTED_SECRET], name: 'Smooth Calm Voice', language: 'en', gender: 'male', style: 'Deep, smooth narrator', description: 'Deep, smooth narrator — documentaries and audiobooks.' },
  { id: [REDACTED_SECRET], name: 'Corpse Husband', language: 'en', gender: 'male', style: 'Deep, distinctive YouTuber', description: 'Deep, distinctive — gaming and entertainment.' },
  { id: [REDACTED_SECRET], name: 'Skadi', language: 'en', gender: 'female', style: 'Anime, Arknights character', description: 'Anime-style character voice.' },
  { id: [REDACTED_SECRET], name: 'Zhongli', language: 'en', gender: 'male', style: 'Deep, Genshin Impact character', description: 'Deep, authoritative — gaming and dramatic content.' },
  { id: [REDACTED_SECRET], name: 'Flora', language: 'en', gender: 'female', style: 'High pitch, cheerful', description: 'Cheerful, upbeat — kids content and bright promos.' },
  { id: [REDACTED_SECRET], name: 'Master Chief', language: 'en', gender: 'male', style: 'Deep heroic, Halo character', description: 'Heroic, commanding — gaming and action content.' },
];

/** Shorthand name → real voice_id lookup. */
export const VOICE_ALIASES: Record<string, string> = {
  ellie: 'd1bf0f33-8e0e-4fbf-acf8-45c3c6262513',
  oliver: 'f9e6a5eb-a7fd-4525-9e92-75125249c933',
  lilith: '4388040c-8812-42f4-a264-f457a6b2b5b9',
  smooth: 'dbb271df-db25-4225-abb0-5200ba1426bc',
  corpse: '72d2a864-b236-402e-a166-a838ccc2c273',
  skadi: '559d3b72-3e79-4f11-9b62-9ec702a6c057',
  zhongli: 'ed751d4d-e633-4bb0-8f5e-b5c8ddb04402',
  flora: 'a931a6af-fb01-42f0-a8c0-bd14bc302bb1',
  chief: 'bd35e4e6-6283-46b9-86b6-7cfa3dd409b9',
};

/** Resolve a voice alias ("ellie") or pass through a UUID unchanged. */
export function resolveVoiceId(input: string): string {
  return VOICE_ALIASES[input.toLowerCase()] ?? input;
}

/* ------------------------------------------------------------------ */
/*  WAV generator (mock mode)                                          */
/* ------------------------------------------------------------------ */

function generateMockWav(durationSeconds: number, sampleRate = 22050): Buffer {
  const numChannels = 1;
  const bitsPerSample = 16;
  const bytesPerSample = bitsPerSample / 8;
  const numSamples = Math.floor(sampleRate * durationSeconds);
  const dataSize = numSamples * numChannels * bytesPerSample;
  const headerSize = 44;
  const buffer = Buffer.alloc(headerSize + dataSize);

  // RIFF header
  buffer.write('RIFF', 0);
  buffer.writeUInt32LE(36 + dataSize, 4);
  buffer.write('WAVE', 8);

  // fmt sub-chunk (PCM)
  buffer.write('fmt ', 12);
  buffer.writeUInt32LE(16, 16);
  buffer.writeUInt16LE(1, 20);
  buffer.writeUInt16LE(numChannels, 22);
  buffer.writeUInt32LE(sampleRate, 24);
  buffer.writeUInt32LE(sampleRate * numChannels * bytesPerSample, 28);
  buffer.writeUInt16LE(numChannels * bytesPerSample, 32);
  buffer.writeUInt16LE(bitsPerSample, 34);

  // data sub-chunk
  buffer.write('data', 36);
  buffer.writeUInt32LE(dataSize, 40);

  // Generate an audible "mock speech" pattern
  const amp = 6000;
  const TWO_PI = 2 * Math.PI;

  for (let i = 0; i < numSamples; i++) {
    const t = i / sampleRate;
    const progress = i / numSamples;

    let envelope = 1.0;
    if (t < 0.05) envelope = t / 0.05;
    else if (progress > 0.9) envelope = (1.0 - progress) / 0.1;

    const vibrato = 1 + 0.15 * Math.sin(TWO_PI * 4.0 * t);
    const baseFreq = 180 * vibrato;
    let sample = Math.sin(TWO_PI * baseFreq * t);
    sample += 0.3 * Math.sin(TWO_PI * baseFreq * 1.5 * t);
    const cadence = 0.6 + 0.4 * Math.sin(TWO_PI * 2.0 * t);

    let cue = 0;
    if (t < 0.15) {
      const cueEnv = t < 0.02 ? t / 0.02 : (0.15 - t) / 0.13;
      cue = 0.7 * cueEnv * Math.sin(TWO_PI * 440 * t);
    }

    const value = Math.round(amp * envelope * (cadence * sample + cue));
    const clamped = Math.max(-32768, Math.min(32767, value));
    buffer.writeInt16LE(clamped, headerSize + i * 2);
  }

  return buffer;
}

/* ------------------------------------------------------------------ */
/*  Voice cache (in-memory, with TTL)                                  */
/* ------------------------------------------------------------------ */

interface CacheEntry<T> {
  data: T;
  expiresAt: number;
}

const voiceCache: { entry?: CacheEntry<VoiceListResponse> } = {};
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes

/* ------------------------------------------------------------------ */
/*  Helpers                                                            */
/* ------------------------------------------------------------------ */

/** Voice.ai per-request text length limit. */
const MAX_TEXT_LENGTH = 490; // API limit is 500; keep a small margin

/** Read the Voice.ai API key from environment (supports both naming conventions). */
export function getApiKey(): string | undefined {
  return process.env.VOICE_AI_API_KEY ?? process.env.VOICEAI_API_KEY;
}

/** Split text into chunks ≤ maxLen, respecting sentence boundaries. */
function splitTextForApi(text: string, maxLen = MAX_TEXT_LENGTH): string[] {
  if (text.length <= maxLen) return [text];

  const sentences = text.match(/[^.!?]+[.!?]+[\s]*/g);
  if (!sentences) {
    // No sentence boundaries — split by words
    const words = text.split(/\s+/);
    const chunks: string[] = [];
    let current = '';
    for (const word of words) {
      if ((current + ' ' + word).trim().length > maxLen && current) {
        chunks.push(current.trim());
        current = '';
      }
      current += (current ? ' ' : '') + word;
    }
    if (current.trim()) chunks.push(current.trim());
    return chunks.length ? chunks : [text.slice(0, maxLen)];
  }

  const chunks: string[] = [];
  let current = '';
  for (const sentence of sentences) {
    if ((current + sentence).length > maxLen && current) {
      chunks.push(current.trim());
      current = '';
    }
    current += sentence;
  }
  if (current.trim()) chunks.push(current.trim());
  return chunks;
}

/** Concatenate WAV buffers (all must share the same format). */
function concatWavBuffers(buffers: Buffer[]): Buffer {
  if (buffers.length === 1) return buffers[0];

  // Extract PCM data from each WAV (skip 44-byte header)
  const headerSize = 44;
  const dataParts = buffers.map((b) => b.subarray(headerSize));
  const totalDataSize = dataParts.reduce((s, d) => s + d.length, 0);

  // Build a new WAV with combined data, using header from first buffer
  const out = Buffer.alloc(headerSize + totalDataSize);
  buffers[0].copy(out, 0, 0, headerSize); // copy header from first
  out.writeUInt32LE(36 + totalDataSize, 4); // RIFF chunk size
  out.writeUInt32LE(totalDataSize, 40);      // data chunk size

  let offset = headerSize;
  for (const part of dataParts) {
    part.copy(out, offset);
    offset += part.length;
  }
  return out;
}

/** Concatenate raw audio buffers (MP3/other — just concat frames). */
function concatAudioBuffers(buffers: Buffer[]): Buffer {
  return buffers.length === 1 ? buffers[0] : Buffer.concat(buffers);
}

/** Estimate duration from audio buffer by writing to tmp and probing with ffprobe.
 *  Falls back to word-count heuristic if ffprobe unavailable. */
async function estimateDuration(audioBuffer: Buffer, text: string, tmpPath?: string): Promise<number> {
  if (tmpPath) {
    const { writeFile, unlink } = await import('node:fs/promises');
    try {
      await writeFile(tmpPath, audioBuffer);
      const dur = await probeDuration(tmpPath);
      await unlink(tmpPath).catch(() => {});
      if (dur !== null) return dur;
    } catch {
      // fall through
    }
  }
  // Heuristic: ~2.5 words/sec
  const words = text.split(/\s+/).filter(Boolean).length;
  return Math.max(0.5, words / 2.5);
}

/* ------------------------------------------------------------------ */
/*  API Client                                                         */
/* ------------------------------------------------------------------ */

export class VoiceAIClient {
  private [REDACTED_SECRET] | null;
  readonly mock: boolean;
  private baseUrl: string;

  constructor(options: { apiKey?: string; mock?: boolean }) {
    this.[REDACTED_SECRET] ?? null;
    this.mock = options.mock ?? false;
    this.baseUrl = process.env.VOICEAI_API_BASE ?? BASE_URL;
  }

  private endpoint(path: string): string {
    return `${this.baseUrl}/api/${API_VERSION}${path}`;
  }

  /* ---------- Voices ------------------------------------------------ */

  async listVoices(options?: { limit?: number; query?: string; offset?: number }): Promise<VoiceListResponse> {
    if (this.mock) return this.mockListVoices(options);

    // Check cache
    if (voiceCache.entry && Date.now() < voiceCache.entry.expiresAt) {
      return this.filterVoices(voiceCache.entry.data, options);
    }

    const params = new URLSearchParams();
    if (options?.limit) params.set('limit', String(options.limit));
    if (options?.offset) params.set('offset', String(options.offset));

    const url = `${this.endpoint('/tts/voices')}?${params.toString()}`;

    const res = await fetch(url, {
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        'User-Agent': 'voiceai-creator-voiceover-pipeline/0.1.0',
      },
    });

    if (!res.ok) {
      const body = await res.text().catch(() => '');
      throw new Error(`Voice.ai API error ${res.status}: ${body}`);
    }

    const json = (await res.json()) as { voices: Array<Record<string, unknown>> };

    // Normalize voice_id → id
    const voices: Voice[] = (json.voices ?? []).map((v) => ({
      id: String(v.voice_id ?? v.id ?? ''),
      name: String(v.name ?? 'Unnamed'),
      language: String(v.language ?? 'en'),
      visibility: String(v.visibility ?? ''),
      status: String(v.status ?? ''),
    }));

    const data: VoiceListResponse = { voices, total: voices.length };
    voiceCache.entry = { data, expiresAt: Date.now() + CACHE_TTL_MS };
    return this.filterVoices(data, options);
  }

  /* ---------- TTS --------------------------------------------------- */

  async generateSpeech(request: TTSRequest, tmpDir?: string): Promise<TTSResponse> {
    if (this.mock) return this.mockGenerateSpeech(request);

    const audioFormat = request.audio_format ?? 'mp3';
    const language = request.language ?? 'en';

    // Auto-select model: multilingual for non-English
    const model = request.model ?? (
      language !== 'en' ? MODELS.MULTILINGUAL_V1_LATEST : MODELS.TTS_V1_LATEST
    );

    // Split long text into API-safe chunks
    const textChunks = splitTextForApi(request.text);
    const audioBuffers: Buffer[] = [];

    for (const chunk of textChunks) {
      const buf = await this.callTtsEndpoint(chunk, {
        voice_id: request.voice_id,
        audio_format: audioFormat,
        language,
        model,
        temperature: request.temperature,
        top_p: request.top_p,
      });
      audioBuffers.push(buf);
    }

    // Concatenate audio parts
    const audioBuffer = audioFormat === 'wav'
      ? concatWavBuffers(audioBuffers)
      : concatAudioBuffers(audioBuffers);

    // Estimate duration via ffprobe or heuristic
    const tmpPath = tmpDir ? `${tmpDir}/_probe_tmp.${audioFormat}` : undefined;
    const duration = await estimateDuration(audioBuffer, request.text, tmpPath);

    return {
      audio_data: audioBuffer,
      duration_seconds: duration,
      sample_rate: 32000,
      format: audioFormat,
    };
  }

  /** Single TTS API call (text must be ≤ 500 chars). */
  private async callTtsEndpoint(
    text: string,
    opts: {
      voice_id: string;
      audio_format: string;
      language: string;
      model: string;
      temperature?: number;
      top_p?: number;
    },
  ): Promise<Buffer> {
    const body: Record<string, unknown> = {
      text,
      audio_format: opts.audio_format,
      language: opts.language,
      model: opts.model,
    };
    if (opts.voice_id) body.voice_id = opts.voice_id;
    if (opts.temperature !== undefined) body.temperature = opts.temperature;
    if (opts.top_p !== undefined) body.top_p = opts.top_p;

    const res = await fetch(this.endpoint('/tts/speech'), {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        'Content-Type': 'application/json',
        'User-Agent': 'voiceai-creator-voiceover-pipeline/0.1.0',
      },
      body: JSON.stringify(body),
    });

    if (!res.ok) {
      const errBody = await res.text().catch(() => '');
      if (res.status === 401) throw new Error('Voice.ai: Invalid or missing API key (401).');
      if (res.status === 402) throw new Error('Voice.ai: Insufficient credits (402). Check your dashboard.');
      if (res.status === 429) throw new Error('Voice.ai: Rate limited (429). Wait and retry.');
      throw new Error(`Voice.ai TTS error ${res.status}: ${errBody}`);
    }

    return Buffer.from(await res.arrayBuffer());
  }

  /* ---------- Mock implementations ---------------------------------- */

  private mockListVoices(options?: { limit?: number; query?: string }): VoiceListResponse {
    return this.filterVoices(
      { voices: POPULAR_VOICES, total: POPULAR_VOICES.length },
      options,
    );
  }

  private filterVoices(
    data: VoiceListResponse,
    options?: { limit?: number; query?: string },
  ): VoiceListResponse {
    let voices = [...data.voices];
    if (options?.query) {
      const q = options.query.toLowerCase();
      voices = voices.filter(
        (v) =>
          v.name.toLowerCase().includes(q) ||
          (v.style ?? '').toLowerCase().includes(q) ||
          (v.description ?? '').toLowerCase().includes(q) ||
          (v.gender ?? '').toLowerCase().includes(q),
      );
    }
    const total = voices.length;
    if (options?.limit) voices = voices.slice(0, options.limit);
    return { voices, total };
  }

  private mockGenerateSpeech(request: TTSRequest): TTSResponse {
    const wordCount = request.text.split(/\s+/).filter(Boolean).length;
    const duration = Math.max(0.5, wordCount / 2.5);
    const sampleRate = 22050;
    const audio = generateMockWav(duration, sampleRate);
    return {
      audio_data: audio,
      duration_seconds: duration,
      sample_rate: sampleRate,
      format: 'wav',
    };
  }
}


path: src/chunking.test.ts
content: /**
 * Tests for chunking module — markdown parsing, auto-chunking, segment hashing.
 */
import { describe, it, expect } from 'vitest';
import { parseMarkdownHeadings, autoChunk, chunkScript } from './chunking.js';

/* ------------------------------------------------------------------ */
/*  parseMarkdownHeadings                                              */
/* ------------------------------------------------------------------ */

describe('parseMarkdownHeadings', () => {
  it('splits markdown by ## headings', () => {
    const md = `# Title

Some preamble text.

## Introduction

This is the intro paragraph.

## Main Content

This is the main body with details.

## Conclusion

Wrapping up here.
`;
    const sections = parseMarkdownHeadings(md);
    expect(sections).toHaveLength(4); // preamble + 3 sections
    expect(sections[0].title).toBe('Title');
    expect(sections[0].text).toContain('preamble');
    expect(sections[1].title).toBe('Introduction');
    expect(sections[1].text).toContain('intro paragraph');
    expect(sections[2].title).toBe('Main Content');
    expect(sections[3].title).toBe('Conclusion');
  });

  it('returns empty array for markdown with no content', () => {
    const sections = parseMarkdownHeadings('');
    expect(sections).toHaveLength(0);
  });

  it('handles markdown with only headings and no body text', () => {
    const md = `## First

## Second

Some text here.
`;
    const sections = parseMarkdownHeadings(md);
    // "First" heading with empty text should be filtered out
    expect(sections.length).toBeGreaterThanOrEqual(1);
    expect(sections.some((s) => s.title === 'Second')).toBe(true);
  });

  it('handles single section with no headings', () => {
    const text = 'Just a plain paragraph of text without any markdown headings.';
    const sections = parseMarkdownHeadings(text);
    expect(sections).toHaveLength(1);
    expect(sections[0].title).toBe('Preamble');
    expect(sections[0].text).toBe(text);
  });

  it('uses H1 as preamble title', () => {
    const md = `# My Video Title

Some preamble text.

## First Section

Content here.
`;
    const sections = parseMarkdownHeadings(md);
    expect(sections[0].title).toBe('My Video Title');
    expect(sections[0].text).toContain('preamble');
    expect(sections[1].title).toBe('First Section');
  });
});

/* ------------------------------------------------------------------ */
/*  autoChunk                                                          */
/* ------------------------------------------------------------------ */

describe('autoChunk', () => {
  it('returns single chunk for short text', () => {
    const text = 'This is a short sentence.';
    const chunks = autoChunk(text, 1500);
    expect(chunks).toHaveLength(1);
    expect(chunks[0].title).toBe('Segment 1');
    expect(chunks[0].text).toBe(text);
  });

  it('chunks long text by sentence boundaries', () => {
    const sentences = Array.from({ length: 20 }, (_, i) =>
      `This is sentence number ${i + 1} with some padding text to make it longer.`
    );
    const text = sentences.join(' ');
    const chunks = autoChunk(text, 200);
    expect(chunks.length).toBeGreaterThan(1);

    // Each chunk should be under maxChars (approximately)
    for (const chunk of chunks) {
      // Allow some overshoot due to sentence boundaries
      expect(chunk.text.length).toBeLessThan(400);
    }
  });

  it('handles text with no sentence-ending punctuation', () => {
    const text = 'This is a very long text without proper punctuation ' +
      'that just keeps going and going and going and should still be handled';
    const chunks = autoChunk(text, 50);
    expect(chunks.length).toBeGreaterThanOrEqual(1);
  });

  it('assigns sequential titles', () => {
    const text = 'First sentence. Second sentence. Third sentence. Fourth sentence. Fifth sentence.';
    const chunks = autoChunk(text, 30);
    for (let i = 0; i < chunks.length; i++) {
      expect(chunks[i].title).toBe(`Segment ${i + 1}`);
    }
  });
});

/* ------------------------------------------------------------------ */
/*  chunkScript (integration)                                          */
/* ------------------------------------------------------------------ */

describe('chunkScript', () => {
  it('chunks markdown with headings mode', async () => {
    const md = `## Part One

First part content here.

## Part Two

Second part content here.
`;
    const segments = await chunkScript(md, {
      mode: 'headings',
      maxChars: 1500,
      voiceId: 'test-voice',
      templateDir: '/nonexistent',
    });

    expect(segments).toHaveLength(2);
    expect(segments[0].index).toBe(1);
    expect(segments[0].title).toBe('Part One');
    expect(segments[0].slug).toBe('part-one');
    expect(segments[0].source).toBe('heading');
    expect(segments[0].hash).toBeTruthy();
    expect(segments[1].index).toBe(2);
    expect(segments[1].title).toBe('Part Two');
  });

  it('falls back to auto when no headings found', async () => {
    const text = 'Just some text without headings. It should auto-chunk.';
    const segments = await chunkScript(text, {
      mode: 'headings',
      maxChars: 1500,
      voiceId: 'test-voice',
      templateDir: '/nonexistent',
    });

    expect(segments.length).toBeGreaterThanOrEqual(1);
    expect(segments[0].source).toBe('auto');
  });

  it('produces deterministic hashes for same input', async () => {
    const md = `## Section

Same text content.
`;
    const run1 = await chunkScript(md, {
      mode: 'headings',
      maxChars: 1500,
      voiceId: 'voice-a',
      templateDir: '/nonexistent',
    });
    const run2 = await chunkScript(md, {
      mode: 'headings',
      maxChars: 1500,
      voiceId: 'voice-a',
      templateDir: '/nonexistent',
    });

    expect(run1[0].hash).toBe(run2[0].hash);
  });

  it('produces different hashes for different voices', async () => {
    const md = `## Section

Same text content.
`;
    const run1 = await chunkScript(md, {
      mode: 'headings',
      maxChars: 1500,
      voiceId: 'voice-a',
      templateDir: '/nonexistent',
    });
    const run2 = await chunkScript(md, {
      mode: 'headings',
      maxChars: 1500,
      voiceId: 'voice-b',
      templateDir: '/nonexistent',
    });

    expect(run1[0].hash).not.toBe(run2[0].hash);
  });
});


path: src/chunking.ts
content: /**
 * Script ingestion and chunking — turns raw scripts into numbered segments.
 * Supports markdown heading mode and auto-chunking for plain text.
 */
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { slugify, contentHash, fileExists } from './utils.js';

/* ------------------------------------------------------------------ */
/*  Types                                                              */
/* ------------------------------------------------------------------ */

export interface Segment {
  index: number;
  title: string;
  slug: string;
  text: string;
  source: 'heading' | 'auto' | 'template';
  hash: string;
}

export type ChunkMode = 'headings' | 'auto';
export type TemplateName = 'youtube' | 'podcast' | 'shortform';

export interface ChunkOptions {
  mode: ChunkMode;
  maxChars: number;
  language?: string;
  template?: TemplateName;
  templateDir: string;
  voiceId: string;
}

/* ------------------------------------------------------------------ */
/*  Markdown heading parser                                            */
/* ------------------------------------------------------------------ */

/** Split markdown by ## headings into segments. */
export function parseMarkdownHeadings(content: string): { title: string; text: string }[] {
  const lines = content.split('\n');
  const sections: { title: string; text: string }[] = [];
  let currentTitle = '';
  let currentLines: string[] = [];
  let h1Title = ''; // Track H1 for preamble naming

  for (const line of lines) {
    const h2Match = line.match(/^##\s+(.+)$/);
    const h1Match = line.match(/^#\s+(.+)$/);

    if (h2Match) {
      // Save previous section if it has content
      if (currentTitle || currentLines.some((l) => l.trim())) {
        const fallbackTitle = h1Title || 'Preamble';
        sections.push({
          title: currentTitle || fallbackTitle,
          text: currentLines.join('\n').trim(),
        });
      }
      currentTitle = h2Match[1].trim();
      currentLines = [];
    } else if (h1Match && !currentTitle && sections.length === 0) {
      // Capture H1 as the preamble title (don't add H1 text to body)
      h1Title = h1Match[1].trim();
    } else {
      currentLines.push(line);
    }
  }

  // Push final section
  if (currentTitle || currentLines.some((l) => l.trim())) {
    const fallbackTitle = h1Title || 'Preamble';
    sections.push({
      title: currentTitle || fallbackTitle,
      text: currentLines.join('\n').trim(),
    });
  }

  // Filter out empty sections
  return sections.filter((s) => s.text.length > 0);
}

/* ------------------------------------------------------------------ */
/*  Auto-chunking by sentence boundaries                               */
/* ------------------------------------------------------------------ */

/** Split text into chunks of ~maxChars, respecting sentence boundaries. */
export function autoChunk(content: string, maxChars: number): { title: string; text: string }[] {
  const text = content.trim();
  if (text.length <= maxChars) {
    return [{ title: 'Segment 1', text }];
  }

  // Split on sentence endings
  const sentences = text.match(/[^.!?]+[.!?]+[\s]*/g) ?? [text];
  const chunks: { title: string; text: string }[] = [];
  let current = '';
  let chunkNum = 1;

  for (const sentence of sentences) {
    if (current.length + sentence.length > maxChars && current.length > 0) {
      chunks.push({ title: `Segment ${chunkNum}`, text: current.trim() });
      chunkNum++;
      current = '';
    }
    current += sentence;
  }

  if (current.trim()) {
    chunks.push({ title: `Segment ${chunkNum}`, text: current.trim() });
  }

  return chunks;
}

/* ------------------------------------------------------------------ */
/*  Template loading                                                   */
/* ------------------------------------------------------------------ */

const TEMPLATE_MAP: Record<TemplateName, { intro?: string; outro?: string }> = {
  youtube: { intro: 'youtube_intro.txt', outro: 'youtube_outro.txt' },
  podcast: { intro: 'podcast_intro.txt' },
  shortform: { intro: 'shortform_hook.txt' },
};

async function loadTemplate(
  templateDir: string,
  filename: string,
): Promise<string | null> {
  const filePath = join(templateDir, filename);
  if (await fileExists(filePath)) {
    const content = await readFile(filePath, 'utf-8');
    return content.trim();
  }
  return null;
}

/* ------------------------------------------------------------------ */
/*  Main chunking pipeline                                             */
/* ------------------------------------------------------------------ */

/** Full chunking pipeline: parse → chunk → apply templates → number & hash. */
export async function chunkScript(content: string, options: ChunkOptions): Promise<Segment[]> {
  // 1. Parse into raw sections
  let rawSections: { title: string; text: string; source: 'heading' | 'auto' | 'template' }[];

  if (options.mode === 'headings') {
    const headingSections = parseMarkdownHeadings(content);
    const hasRealHeadings = headingSections.some(
      (s) => s.title !== 'Preamble' && s.title !== 'Untitled Section',
    );

    if (hasRealHeadings && headingSections.length > 0) {
      rawSections = headingSections.map((s) => ({ ...s, source: 'heading' as const }));
    } else {
      // Fallback to auto if no real ## headings found
      rawSections = autoChunk(content, options.maxChars).map((s) => ({
        ...s,
        source: 'auto' as const,
      }));
    }
  } else {
    rawSections = autoChunk(content, options.maxChars).map((s) => ({
      ...s,
      source: 'auto' as const,
    }));
  }

  // 2. Apply template intro/outro
  if (options.template && TEMPLATE_MAP[options.template]) {
    const tmpl = TEMPLATE_MAP[options.template];
    if (tmpl.intro) {
      const introText = await loadTemplate(options.templateDir, tmpl.intro);
      if (introText) {
        rawSections.unshift({ title: 'Intro', text: introText, source: 'template' });
      }
    }
    if (tmpl.outro) {
      const outroText = await loadTemplate(options.templateDir, tmpl.outro);
      if (outroText) {
        rawSections.push({ title: 'Outro', text: outroText, source: 'template' });
      }
    }
  }

  // 3. Number, slug, and hash each segment
  const segments: Segment[] = rawSections.map((section, i) => {
    const index = i + 1;
    const slug = slugify(section.title);
    const hash = contentHash(section.text, options.voiceId, options.language ?? 'en');
    return {
      index,
      title: section.title,
      slug,
      text: section.text,
      source: section.source,
      hash,
    };
  });

  return segments;
}


path: src/cli.ts
content: #!/usr/bin/env node
/**
 * voiceai-vo — Voice.ai Creator Voiceover Pipeline
 * Turn scripts into publishable voiceovers with one command.
 */
import { config } from 'dotenv';
import { Command } from 'commander';
import { buildCommand } from './commands/build.js';
import { replaceAudioCommand } from './commands/replace-audio.js';
import { voicesCommand } from './commands/voices.js';

// Load .env from project root
config();

const program = new Command();

program
  .name('voiceai-vo')
  .description('Voice.ai Creator Voiceover Pipeline — script → segments → master → video')
  .version('0.1.0');

/* ------------------------------------------------------------------ */
/*  build                                                              */
/* ------------------------------------------------------------------ */

program
  .command('build')
  .description('Build a voiceover from a script file')
  .requiredOption('-i, --input <path>', 'Path to script file (.txt or .md)')
  .requiredOption('-v, --voice <id>', 'Voice ID (run `voices` to discover)')
  .option('-t, --title <title>', 'Project title (defaults to filename)')
  .option('--template <name>', 'Template: youtube, podcast, or shortform')
  .option('--mode <mode>', 'Chunk mode: headings or auto (default: headings for .md)')
  .option('--max-chars <n>', 'Max characters per auto-chunk (default: 1500)')
  .option('--language <code>', 'Language code (default: en)')
  .option('--video <path>', 'Input video file for muxing')
  .option('--mux', 'Enable video muxing (requires --video)')
  .option('--sync <policy>', 'Sync policy: shortest, pad, trim (default: shortest)')
  .option('--force', 'Force re-render all segments (ignore cache)')
  .option('--mock', 'Run in mock mode (no real API calls)')
  .option('-o, --out <dir>', 'Custom output directory')
  .action(buildCommand);

/* ------------------------------------------------------------------ */
/*  replace-audio                                                      */
/* ------------------------------------------------------------------ */

program
  .command('replace-audio')
  .description('Replace the audio track of a video with a voiceover')
  .requiredOption('--video <path>', 'Input video file')
  .requiredOption('--audio <path>', 'Audio file to mux in (e.g. master.wav)')
  .option('--out <path>', 'Output video path (default: muxed.mp4 next to audio)')
  .option('--sync <policy>', 'Sync policy: shortest, pad, trim (default: shortest)')
  .action(replaceAudioCommand);

/* ------------------------------------------------------------------ */
/*  voices                                                             */
/* ------------------------------------------------------------------ */

program
  .command('voices')
  .description('List available Voice.ai voices')
  .option('-l, --limit <n>', 'Max voices to show (default: 20)')
  .option('-q, --query <term>', 'Search voices by name or description')
  .option('--mock', 'Use mock voice catalog')
  .action(voicesCommand);

/* ------------------------------------------------------------------ */
/*  Parse + run                                                        */
/* ------------------------------------------------------------------ */

program.parse();


path: src/commands/build.ts
content: /**
 * `voiceai-vo build` — the main "wow" command.
 * Reads a script, chunks it, renders TTS, stitches master, generates outputs.
 */
import { resolve, extname, dirname, join } from 'node:path';
import chalk from 'chalk';
import { VoiceAIClient, getApiKey, resolveVoiceId } from '../api.js';
import { chunkScript, type ChunkMode, type TemplateName } from '../chunking.js';
import { renderSegments } from '../render.js';
import {
  checkFfmpeg,
  stitchSegments,
  encodeToMp3,
  normalizeLoudness,
  muxAudioVideo,
  generateMuxScripts,
  type SyncPolicy,
  type MuxResult,
} from '../ffmpeg.js';
import { writeAllOutputs } from '../outputs.js';
import { slugify, ensureDir, readTextFile, fileExists } from '../utils.js';

/* ------------------------------------------------------------------ */
/*  Option types                                                       */
/* ------------------------------------------------------------------ */

export interface BuildOptions {
  input: string;
  voice: string;
  title?: string;
  template?: string;
  mode?: string;
  maxChars?: string;
  language?: string;
  video?: string;
  mux?: boolean;
  sync?: string;
  force?: boolean;
  mock?: boolean;
  out?: string;
}

/* ------------------------------------------------------------------ */
/*  Command handler                                                    */
/* ------------------------------------------------------------------ */

export async function buildCommand(opts: BuildOptions): Promise<void> {
  const startTime = Date.now();

  // ── Validate inputs ────────────────────────────────────────────────
  if (!opts.input) {
    console.error(chalk.red('✗ --input is required. Pass a .txt or .md script file.'));
    process.exit(1);
  }

  const inputPath = resolve(opts.input);
  if (!(await fileExists(inputPath))) {
    console.error(chalk.red(`✗ Script not found: ${inputPath}`));
    process.exit(1);
  }

  const rawVoice = opts.voice;
  if (!rawVoice) {
    console.error(chalk.red('✗ --voice is required. Run `voiceai-vo voices` to see options.'));
    process.exit(1);
  }
  // Resolve alias ("ellie") or pass through UUID
  const voiceId = resolveVoiceId(rawVoice);

  const isMock = opts.mock ?? false;

  // API key check (only when not in mock mode)
  const [REDACTED_SECRET]);
  if (!isMock && !apiKey) {
    console.error(
      chalk.red('✗ VOICE_AI_API_KEY not set.\n') +
        chalk.yellow('  Set it in .env or your environment, or use --mock for testing.\n') +
        chalk.gray('  Get your key at https://voice.ai/dashboard'),
    );
    process.exit(1);
  }

  // ── Resolve settings ──────────────────────────────────────────────
  const ext = extname(inputPath).toLowerCase();
  const template = opts.template as TemplateName | undefined;
  const mode: ChunkMode = (opts.mode as ChunkMode) ?? (ext === '.md' ? 'headings' : 'auto');
  const maxChars = parseInt(opts.maxChars ?? '1500', 10);
  const language = opts.language ?? 'en';
  const force = opts.force ?? false;
  const syncPolicy: SyncPolicy = (opts.sync as SyncPolicy) ?? 'shortest';

  // Title → slug → output dir
  const rawTitle = opts.title ?? inputPath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '');
  const titleSlug = slugify(rawTitle);
  const outputDir = opts.out ? resolve(opts.out) : resolve('out', titleSlug);
  await ensureDir(outputDir);

  // Template directory (look for templates/ relative to cwd, or fallback to script location)
  const templateDir = resolve(process.cwd(), 'templates');

  // ── Print header ──────────────────────────────────────────────────
  console.log(chalk.bold('\n╔══════════════════════════════════════════════════╗'));
  console.log(chalk.bold('║') + chalk.cyan.bold('   Voice.ai Creator Voiceover Pipeline             ') + chalk.bold('║'));
  console.log(chalk.bold('╚══════════════════════════════════════════════════╝'));
  if (isMock) console.log(chalk.yellow.bold('   ⚡ Mock mode — no API calls will be made\n'));

  console.log(chalk.gray(`   Script:   ${inputPath}`));
  console.log(chalk.gray(`   Voice:    ${voiceId}`));
  console.log(chalk.gray(`   Mode:     ${mode} (max ${maxChars} chars)`));
  if (template) console.log(chalk.gray(`   Template: ${template}`));
  console.log(chalk.gray(`   Output:   ${outputDir}`));
  if (opts.video) console.log(chalk.gray(`   Video:    ${opts.video}`));

  // ── Initialize client ─────────────────────────────────────────────
  const client = new VoiceAIClient({ [REDACTED_SECRET] ?? undefined, mock: isMock });

  // ── Read & chunk script ───────────────────────────────────────────
  console.log(chalk.cyan('\n📄 Reading script…'));
  const scriptContent = await readTextFile(inputPath);

  const segments = await chunkScript(scriptContent, {
    mode,
    maxChars,
    language,
    template,
    templateDir,
    voiceId,
  });

  console.log(chalk.green(`   ✅ ${segments.length} segments extracted`));
  for (const seg of segments) {
    const tag = seg.source === 'template' ? chalk.yellow(' [template]') : '';
    console.log(chalk.gray(`      ${String(seg.index).padStart(2)}. ${seg.title}${tag}`));
  }

  // ── Render segments via TTS ───────────────────────────────────────
  const results = await renderSegments(segments, client, {
    voiceId,
    outputDir,
    force,
    mock: isMock,
    language,
  });

  // ── Check ffmpeg availability ─────────────────────────────────────
  const ffAvail = await checkFfmpeg();
  let hasMaster = false;

  // ── Stitch master ─────────────────────────────────────────────────
  if (ffAvail.ffmpeg) {
    console.log(chalk.cyan('\n🎶 Stitching master audio…'));
    const segPaths = results.map((r) => r.filePath);
    const masterWav = join(outputDir, 'master.wav');

    const stitched = await stitchSegments(segPaths, masterWav);
    if (stitched) {
      hasMaster = true;
      console.log(chalk.green(`   ✅ master.wav`));

      // Try mp3 encode
      const masterMp3 = join(outputDir, 'master.mp3');
      const mp3Ok = await encodeToMp3(masterWav, masterMp3);
      if (mp3Ok) console.log(chalk.green(`   ✅ master.mp3`));

      // Try loudness normalization
      const normalizedWav = join(outputDir, 'master_normalized.wav');
      const normOk = await normalizeLoudness(masterWav, normalizedWav);
      if (normOk) console.log(chalk.green(`   ✅ master_normalized.wav (loudnorm -16 LUFS)`));
    } else {
      console.log(chalk.yellow('   ⚠ Stitching failed — segments are still available individually.'));
    }
  } else {
    console.log(
      chalk.yellow('\n⚠ ffmpeg not found — skipping master stitch.') +
        chalk.gray('\n   Segments are available individually in segments/.') +
        chalk.gray('\n   Install ffmpeg for stitching: https://ffmpeg.org/download.html'),
    );
  }

  // ── Video muxing (if requested) ──────────────────────────────────
  let muxResult: MuxResult | undefined;

  if (opts.video || opts.mux) {
    const videoPath = opts.video ? resolve(opts.video) : undefined;

    if (!videoPath || !(await fileExists(videoPath))) {
      console.error(chalk.red(`\n✗ Video file not found: ${videoPath ?? '(not specified)'}`));
    } else if (!hasMaster) {
      console.log(chalk.yellow('\n⚠ No master audio to mux. Skipping video muxing.'));
    } else if (ffAvail.ffmpeg) {
      const muxedPath = join(outputDir, 'muxed.mp4');
      muxResult = await muxAudioVideo({
        videoPath,
        audioPath: join(outputDir, 'master.wav'),
        outputPath: muxedPath,
        syncPolicy,
      });
    } else {
      console.log(chalk.yellow('\n⚠ ffmpeg required for muxing. Generating helper scripts…'));
      const masterPath = join(outputDir, 'master.wav');
      const muxedPath = join(outputDir, 'muxed.mp4');
      await generateMuxScripts(videoPath, masterPath, muxedPath, syncPolicy, outputDir);
      console.log(chalk.green('   ✅ Helper scripts generated in ffmpeg/'));
    }
  }

  // ── Generate all output files ─────────────────────────────────────
  console.log(chalk.cyan('\n📦 Generating output files…'));

  await writeAllOutputs({
    outputDir,
    title: rawTitle,
    voiceId,
    template: template,
    language,
    mock: isMock,
    results,
    segments,
    hasMaster,
    muxResult,
  });

  console.log(chalk.green('   ✅ manifest.json'));
  console.log(chalk.green('   ✅ timeline.json'));
  console.log(chalk.green('   ✅ review.html'));
  console.log(chalk.green('   ✅ description.txt'));
  if (results.some((r) => r.duration > 0)) {
    console.log(chalk.green('   ✅ chapters.txt'));
    console.log(chalk.green('   ✅ captions.srt'));
  }

  // ── Summary ───────────────────────────────────────────────────────
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
  const totalDur = results.reduce((s, r) => s + r.duration, 0).toFixed(1);

  console.log(chalk.bold('\n╔══════════════════════════════════════════════════╗'));
  console.log(chalk.bold('║') + chalk.green.bold('   ✅ Build complete!                              ') + chalk.bold('║'));
  console.log(chalk.bold('╚══════════════════════════════════════════════════╝'));
  console.log(chalk.gray(`   Output:    ${outputDir}`));
  console.log(chalk.gray(`   Segments:  ${results.length}`));
  console.log(chalk.gray(`   Duration:  ${totalDur}s`));
  console.log(chalk.gray(`   Time:      ${elapsed}s`));
  if (muxResult?.success) console.log(chalk.gray(`   Video:     ${muxResult.outputPath}`));
  console.log(chalk.gray(`   Review:    ${join(outputDir, 'review.html')}`));
  console.log('');
}


path: src/commands/replace-audio.ts
content: /**
 * `voiceai-vo replace-audio` — mux audio into an existing video.
 */
import { resolve, join, dirname } from 'node:path';
import chalk from 'chalk';
import {
  checkFfmpeg,
  muxAudioVideo,
  generateMuxScripts,
  type SyncPolicy,
} from '../ffmpeg.js';
import { fileExists, writeOutputFile, ensureDir } from '../utils.js';

/* ------------------------------------------------------------------ */
/*  Option types                                                       */
/* ------------------------------------------------------------------ */

export interface ReplaceAudioOptions {
  video: string;
  audio: string;
  out?: string;
  sync?: string;
}

/* ------------------------------------------------------------------ */
/*  Command handler                                                    */
/* ------------------------------------------------------------------ */

export async function replaceAudioCommand(opts: ReplaceAudioOptions): Promise<void> {
  // ── Validate inputs ────────────────────────────────────────────────
  if (!opts.video) {
    console.error(chalk.red('✗ --video is required. Pass the input video file.'));
    process.exit(1);
  }

  if (!opts.audio) {
    console.error(chalk.red('✗ --audio is required. Pass the audio file to mux in.'));
    process.exit(1);
  }

  const videoPath = resolve(opts.video);
  const audioPath = resolve(opts.audio);

  if (!(await fileExists(videoPath))) {
    console.error(chalk.red(`✗ Video not found: ${videoPath}`));
    process.exit(1);
  }

  if (!(await fileExists(audioPath))) {
    console.error(chalk.red(`✗ Audio not found: ${audioPath}`));
    process.exit(1);
  }

  const syncPolicy: SyncPolicy = (opts.sync as SyncPolicy) ?? 'shortest';
  if (!['shortest', 'pad', 'trim'].includes(syncPolicy)) {
    console.error(
      chalk.red(`✗ Invalid sync policy: "${syncPolicy}". Use: shortest, pad, or trim.`),
    );
    process.exit(1);
  }

  const outputPath = opts.out ? resolve(opts.out) : resolve(dirname(audioPath), 'muxed.mp4');
  await ensureDir(dirname(outputPath));

  // ── Check ffmpeg ──────────────────────────────────────────────────
  console.log(chalk.bold('\n╔══════════════════════════════════════════════════╗'));
  console.log(chalk.bold('║') + chalk.cyan.bold('   Voice.ai — Replace Audio                        ') + chalk.bold('║'));
  console.log(chalk.bold('╚══════════════════════════════════════════════════╝'));

  const ffAvail = await checkFfmpeg();

  if (ffAvail.ffmpeg) {
    const result = await muxAudioVideo({
      videoPath,
      audioPath,
      outputPath,
      syncPolicy,
    });

    // Save mux report next to output
    const reportPath = outputPath.replace(/\.[^.]+$/, '') + '_mux_report.json';
    await writeOutputFile(reportPath, JSON.stringify(result, null, 2));
    console.log(chalk.gray(`   Report:  ${reportPath}`));

    if (result.success) {
      console.log(
        chalk.bold('\n✅ Done!') + chalk.gray(` → ${result.outputPath}`),
      );
    } else {
      console.error(chalk.red('\n✗ Mux failed. Check the report for details.'));
      process.exit(1);
    }
  } else {
    console.log(
      chalk.yellow('\n⚠ ffmpeg not found — cannot mux directly.') +
        chalk.gray('\n   Generating helper scripts instead…'),
    );

    const scriptDir = dirname(outputPath);
    await generateMuxScripts(videoPath, audioPath, outputPath, syncPolicy, scriptDir);

    console.log(chalk.green('\n   ✅ Helper scripts generated:'));
    console.log(chalk.gray(`      ${join(scriptDir, 'ffmpeg', 'replace-audio.sh')}`));
    console.log(chalk.gray(`      ${join(scriptDir, 'ffmpeg', 'replace-audio.ps1')}`));
    console.log(
      chalk.gray(
        '\n   Install ffmpeg, then run the script for your platform.',
      ),
    );
  }
  console.log('');
}


path: src/commands/voices.ts
content: /**
 * `voiceai-vo voices` — discover available Voice.ai voices.
 */
import chalk from 'chalk';
import { VoiceAIClient, getApiKey } from '../api.js';

/* ------------------------------------------------------------------ */
/*  Option types                                                       */
/* ------------------------------------------------------------------ */

export interface VoicesOptions {
  limit?: string;
  query?: string;
  mock?: boolean;
}

/* ------------------------------------------------------------------ */
/*  Command handler                                                    */
/* ------------------------------------------------------------------ */

export async function voicesCommand(opts: VoicesOptions): Promise<void> {
  const isMock = opts.mock ?? false;

  // API key check (only when not in mock mode)
  const [REDACTED_SECRET]);
  if (!isMock && !apiKey) {
    console.error(
      chalk.red('✗ VOICE_AI_API_KEY not set.\n') +
        chalk.yellow('  Set it in .env or your environment, or use --mock for testing.\n') +
        chalk.gray('  Get your key at https://voice.ai/dashboard'),
    );
    process.exit(1);
  }

  const client = new VoiceAIClient({ apiKey, mock: isMock });
  const limit = opts.limit ? parseInt(opts.limit, 10) : 20;
  const query = opts.query;

  console.log(chalk.bold('\n╔══════════════════════════════════════════════════╗'));
  console.log(chalk.bold('║') + chalk.cyan.bold('   Voice.ai — Available Voices                     ') + chalk.bold('║'));
  console.log(chalk.bold('╚══════════════════════════════════════════════════╝'));
  if (isMock) console.log(chalk.yellow('   ⚡ Mock mode\n'));

  try {
    const { voices, total } = await client.listVoices({ limit, query });

    if (voices.length === 0) {
      console.log(chalk.yellow('   No voices found.'));
      if (query) console.log(chalk.gray(`   Try a different search: --query "narrator"`));
      console.log('');
      return;
    }

    // Column widths
    const idW = Math.max(4, ...voices.map((v) => v.id.length));
    const nameW = Math.max(4, ...voices.map((v) => v.name.length));

    // Header
    console.log(
      chalk.gray(
        `   ${'ID'.padEnd(idW)}  ${'NAME'.padEnd(nameW)}  LANG  DESCRIPTION`,
      ),
    );
    console.log(chalk.gray(`   ${'─'.repeat(idW)}  ${'─'.repeat(nameW)}  ────  ─────────────`));

    // Rows
    for (const v of voices) {
      const id = chalk.cyan(v.id.padEnd(idW));
      const name = chalk.white.bold(v.name.padEnd(nameW));
      const lang = chalk.gray((v.language ?? '').padEnd(4));
      const desc = chalk.gray(v.description ?? v.style ?? '');
      console.log(`   ${id}  ${name}  ${lang}  ${desc}`);
    }

    console.log(chalk.gray(`\n   Showing ${voices.length} of ${total} voices.`));
    if (query) console.log(chalk.gray(`   Filter: "${query}"`));
    console.log(
      chalk.gray('\n   💡 Use a voice ID with: voiceai-vo build --voice <ID> --input <script>'),
    );
  } catch (err) {
    console.error(chalk.red(`\n✗ Failed to fetch voices: ${err}`));
    console.error(chalk.yellow('   Try --mock to see available mock voices.'));
    process.exit(1);
  }
  console.log('');
}


path: src/ffmpeg.ts
content: /**
 * FFmpeg/FFprobe integration — probing, stitching, muxing, encoding.
 * Falls back to generating helper scripts when ffmpeg is not installed.
 */
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { join } from 'node:path';
import { writeFile } from 'node:fs/promises';
import chalk from 'chalk';
import { ensureDir, writeOutputFile } from './utils.js';

const exec = promisify(execFile);

/* ------------------------------------------------------------------ */
/*  Types                                                              */
/* ------------------------------------------------------------------ */

export type SyncPolicy = 'shortest' | 'pad' | 'trim';

export interface MuxOptions {
  videoPath: string;
  audioPath: string;
  outputPath: string;
  syncPolicy: SyncPolicy;
}

export interface MuxResult {
  success: boolean;
  outputPath: string;
  videoDuration: number | null;
  audioDuration: number | null;
  syncPolicy: SyncPolicy;
  ffmpegCommand: string;
  error?: string;
}

export interface FfmpegAvailability {
  ffmpeg: boolean;
  ffprobe: boolean;
}

/* ------------------------------------------------------------------ */
/*  Availability check                                                 */
/* ------------------------------------------------------------------ */

export async function checkFfmpeg(): Promise<FfmpegAvailability> {
  const check = async (bin: string): Promise<boolean> => {
    try {
      await exec(bin, ['-version']);
      return true;
    } catch {
      return false;
    }
  };

  const [ffmpeg, ffprobe] = await Promise.all([check('ffmpeg'), check('ffprobe')]);
  return { ffmpeg, ffprobe };
}

/* ------------------------------------------------------------------ */
/*  Probe duration                                                     */
/* ------------------------------------------------------------------ */

export async function probeDuration(filePath: string): Promise<number | null> {
  try {
    const { stdout } = await exec('ffprobe', [
      '-v', 'quiet',
      '-print_format', 'json',
      '-show_format',
      filePath,
    ]);
    const data = JSON.parse(stdout);
    const dur = parseFloat(data?.format?.duration);
    return isNaN(dur) ? null : dur;
  } catch {
    return null;
  }
}

/* ------------------------------------------------------------------ */
/*  Stitch segments into master                                        */
/* ------------------------------------------------------------------ */

export async function stitchSegments(
  segmentPaths: string[],
  outputPath: string,
): Promise<boolean> {
  // Build concat file list
  const concatDir = join(outputPath, '..', '..');
  const concatFile = join(concatDir, 'tmp', 'concat.txt');
  await ensureDir(join(concatDir, 'tmp'));

  const concatContent = segmentPaths
    .map((p) => `file '${p.replace(/'/g, "'\\''")}'`)
    .join('\n');
  await writeFile(concatFile, concatContent);

  try {
    await exec('ffmpeg', [
      '-y',
      '-f', 'concat',
      '-safe', '0',
      '-i', concatFile,
      '-c', 'copy',
      outputPath,
    ]);
    return true;
  } catch (err) {
    console.error(chalk.yellow(`   ⚠ Stitch failed, trying re-encode…`));
    try {
      // Fallback: re-encode with consistent format
      await exec('ffmpeg', [
        '-y',
        '-f', 'concat',
        '-safe', '0',
        '-i', concatFile,
        '-ar', '22050',
        '-ac', '1',
        '-sample_fmt', 's16',
        outputPath,
      ]);
      return true;
    } catch {
      return false;
    }
  }
}

/* ------------------------------------------------------------------ */
/*  Encode to mp3                                                      */
/* ------------------------------------------------------------------ */

export async function encodeToMp3(inputPath: string, outputPath: string): Promise<boolean> {
  try {
    await exec('ffmpeg', ['-y', '-i', inputPath, '-codec:a', 'libmp3lame', '-q:a', '2', outputPath]);
    return true;
  } catch {
    return false;
  }
}

/* ------------------------------------------------------------------ */
/*  Simple loudness normalization                                      */
/* ------------------------------------------------------------------ */

export async function normalizeLoudness(inputPath: string, outputPath: string): Promise<boolean> {
  try {
    // Two-pass loudnorm (integrated -16 LUFS for YouTube/podcast)
    const { stdout: analysisJson } = await exec('ffmpeg', [
      '-y', '-i', inputPath,
      '-af', 'loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json',
      '-f', 'null', '-',
    ]);

    // Simple single-pass normalization (good enough for MVP)
    await exec('ffmpeg', [
      '-y', '-i', inputPath,
      '-af', 'loudnorm=I=-16:TP=-1.5:LRA=11',
      outputPath,
    ]);
    return true;
  } catch {
    return false;
  }
}

/* ------------------------------------------------------------------ */
/*  Mux audio into video                                               */
/* ------------------------------------------------------------------ */

function buildMuxArgs(opts: MuxOptions): string[] {
  const args = ['-y', '-i', opts.videoPath, '-i', opts.audioPath];

  switch (opts.syncPolicy) {
    case 'shortest':
      args.push('-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-map', '0:v:0', '-map', '1:a:0', '-shortest');
      break;
    case 'pad':
      // Pad audio with silence to match video — we add an apad filter
      args.push('-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-map', '0:v:0', '-map', '1:a:0', '-af', 'apad', '-shortest');
      break;
    case 'trim':
      // Trim audio to video duration — shortest does this naturally
      args.push('-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-map', '0:v:0', '-map', '1:a:0', '-shortest');
      break;
  }

  args.push(opts.outputPath);
  return args;
}

export async function muxAudioVideo(opts: MuxOptions): Promise<MuxResult> {
  const ffmpegArgs = buildMuxArgs(opts);
  const command = `ffmpeg ${ffmpegArgs.map((a) => (a.includes(' ') ? `"${a}"` : a)).join(' ')}`;

  const videoDuration = await probeDuration(opts.videoPath);
  const audioDuration = await probeDuration(opts.audioPath);

  console.log(chalk.cyan('\n🎬 Muxing audio into video…'));
  console.log(chalk.gray(`   Video: ${opts.videoPath} (${videoDuration?.toFixed(1) ?? '?'}s)`));
  console.log(chalk.gray(`   Audio: ${opts.audioPath} (${audioDuration?.toFixed(1) ?? '?'}s)`));
  console.log(chalk.gray(`   Sync:  ${opts.syncPolicy}`));

  try {
    await exec('ffmpeg', ffmpegArgs);
    console.log(chalk.green(`   ✅ Muxed → ${opts.outputPath}`));
    return {
      success: true,
      outputPath: opts.outputPath,
      videoDuration,
      audioDuration,
      syncPolicy: opts.syncPolicy,
      ffmpegCommand: command,
    };
  } catch (err) {
    const errMsg = err instanceof Error ? err.message : String(err);
    console.error(chalk.red(`   ✗ Mux failed: ${errMsg}`));
    return {
      success: false,
      outputPath: opts.outputPath,
      videoDuration,
      audioDuration,
      syncPolicy: opts.syncPolicy,
      ffmpegCommand: command,
      error: errMsg,
    };
  }
}

/* ------------------------------------------------------------------ */
/*  Fallback helper scripts (when ffmpeg not installed)                 */
/* ------------------------------------------------------------------ */

export async function generateMuxScripts(
  videoPath: string,
  audioPath: string,
  outputPath: string,
  syncPolicy: SyncPolicy,
  outDir: string,
): Promise<void> {
  const ffmpegDir = join(outDir, 'ffmpeg');
  await ensureDir(ffmpegDir);

  const args = buildMuxArgs({ videoPath, audioPath, outputPath, syncPolicy });
  const cmdString = `ffmpeg ${args.map((a) => (a.includes(' ') ? `"${a}"` : a)).join(' ')}`;

  // Bash script
  const bash = `#!/usr/bin/env bash
# Replace Audio — generated by voiceai-creator-voiceover-pipeline
# Sync policy: ${syncPolicy}
#
# Prerequisites: Install ffmpeg (https://ffmpeg.org/download.html)
#   macOS:   brew install ffmpeg
#   Ubuntu:  sudo apt install ffmpeg
#   Windows: choco install ffmpeg  OR  download from https://ffmpeg.org
#
# Usage: bash replace-audio.sh

set -euo pipefail

VIDEO_INPUT="${videoPath}"
AUDIO_INPUT="${audioPath}"
OUTPUT="${outputPath}"

echo "🎬 Muxing audio into video…"
echo "   Video: \$VIDEO_INPUT"
echo "   Audio: \$AUDIO_INPUT"
echo "   Sync:  ${syncPolicy}"
echo ""

${cmdString}

echo ""
echo "✅ Done → \$OUTPUT"
`;

  // PowerShell script
  const ps1 = `# Replace Audio — generated by voiceai-creator-voiceover-pipeline
# Sync policy: ${syncPolicy}
#
# Prerequisites: Install ffmpeg (https://ffmpeg.org/download.html)
#   Windows: choco install ffmpeg  OR  winget install ffmpeg
#
# Usage: .\\replace-audio.ps1

$ErrorActionPreference = "Stop"

$VideoInput = "${videoPath.replace(/\\/g, '\\\\')}"
$AudioInput = "${audioPath.replace(/\\/g, '\\\\')}"
$Output = "${outputPath.replace(/\\/g, '\\\\')}"

Write-Host "🎬 Muxing audio into video…"
Write-Host "   Video: $VideoInput"
Write-Host "   Audio: $AudioInput"
Write-Host "   Sync:  ${syncPolicy}"
Write-Host ""

${cmdString}

Write-Host ""
Write-Host "✅ Done → $Output"
`;

  await writeOutputFile(join(ffmpegDir, 'replace-audio.sh'), bash);
  await writeOutputFile(join(ffmpegDir, 'replace-audio.ps1'), ps1);
}


path: src/outputs.ts
content: /**
 * Output file generators — manifest, timeline, review page, chapters, captions, description.
 */
import { join } from 'node:path';
import { type Segment } from './chunking.js';
import { type RenderResult } from './render.js';
import { type MuxResult } from './ffmpeg.js';
import {
  writeOutputFile,
  youtubeTimestamp,
  srtTimestamp,
  formatDuration,
} from './utils.js';

/* ------------------------------------------------------------------ */
/*  Manifest                                                           */
/* ------------------------------------------------------------------ */

export interface ManifestData {
  title: string;
  voice_id: string;
  voice_name?: string;
  template?: string;
  language: string;
  mock: boolean;
  segments: {
    index: number;
    title: string;
    source_text: string;
    text_hash: string;
    file_path: string;
    duration_seconds: number;
  }[];
  created_at: string;
  total_duration_seconds: number;
}

export function buildManifest(
  title: string,
  voiceId: string,
  voiceName: string | undefined,
  template: string | undefined,
  language: string,
  mock: boolean,
  results: RenderResult[],
): ManifestData {
  const totalDuration = results.reduce((s, r) => s + r.duration, 0);
  return {
    title,
    voice_id: voiceId,
    voice_name: voiceName,
    template,
    language,
    mock,
    segments: results.map((r) => ({
      index: r.segment.index,
      title: r.segment.title,
      source_text: r.segment.text,
      text_hash: r.segment.hash,
      file_path: r.fileName,
      duration_seconds: r.duration,
    })),
    created_at: new Date().toISOString(),
    total_duration_seconds: totalDuration,
  };
}

/* ------------------------------------------------------------------ */
/*  Timeline                                                           */
/* ------------------------------------------------------------------ */

export interface TimelineEntry {
  index: number;
  title: string;
  start_seconds: number;
  duration_seconds: number;
  end_seconds: number;
}

export interface TimelineData {
  segments: TimelineEntry[];
  total_duration_seconds: number;
  has_durations: boolean;
}

export function buildTimeline(results: RenderResult[]): TimelineData {
  const hasDurations = results.every((r) => r.duration > 0);
  let cursor = 0;
  const segments: TimelineEntry[] = results.map((r) => {
    const entry: TimelineEntry = {
      index: r.segment.index,
      title: r.segment.title,
      start_seconds: cursor,
      duration_seconds: r.duration,
      end_seconds: cursor + r.duration,
    };
    cursor += r.duration;
    return entry;
  });

  return {
    segments,
    total_duration_seconds: cursor,
    has_durations: hasDurations,
  };
}

/* ------------------------------------------------------------------ */
/*  Chapters (YouTube-friendly)                                        */
/* ------------------------------------------------------------------ */

export function buildChapters(timeline: TimelineData): string | null {
  if (!timeline.has_durations) return null;

  const lines = timeline.segments.map(
    (s) => `${youtubeTimestamp(s.start_seconds)} ${s.title}`,
  );
  return lines.join('\n');
}

/* ------------------------------------------------------------------ */
/*  Captions (SRT)                                                     */
/* ------------------------------------------------------------------ */

export function buildCaptionsSrt(
  timeline: TimelineData,
  segments: Segment[],
): string | null {
  if (!timeline.has_durations) return null;

  const blocks = timeline.segments.map((t, i) => {
    const segText = segments[i]?.text ?? '';
    // Truncate very long segments to a summary line for caption blocks
    const captionText =
      segText.length > 200 ? segText.slice(0, 197) + '...' : segText;
    return `${i + 1}\n${srtTimestamp(t.start_seconds)} --> ${srtTimestamp(t.end_seconds)}\n${captionText}`;
  });

  return blocks.join('\n\n');
}

/* ------------------------------------------------------------------ */
/*  Description (YouTube/podcast)                                      */
/* ------------------------------------------------------------------ */

export function buildDescription(
  title: string,
  chapters: string | null,
): string {
  let desc = `${title}\n${'='.repeat(title.length)}\n\n`;

  if (chapters) {
    desc += `Chapters:\n${chapters}\n\n`;
  }

  desc += `---\nVoiceover generated with Voice.ai — https://voice.ai\n`;
  return desc;
}

/* ------------------------------------------------------------------ */
/*  Review HTML (the "wow" deliverable)                                */
/* ------------------------------------------------------------------ */

export function buildReviewHtml(
  title: string,
  results: RenderResult[],
  timeline: TimelineData,
  hasMaster: boolean,
  voiceId: string,
  mock: boolean,
): string {
  const segmentCards = results
    .map((r) => {
      const t = timeline.segments.find((s) => s.index === r.segment.index);
      const dur = t ? formatDuration(t.duration_seconds) : '—';
      return `
      <div class="segment-card">
        <div class="segment-header">
          <span class="segment-num">${String(r.segment.index).padStart(2, '0')}</span>
          <h3>${escapeHtml(r.segment.title)}</h3>
          <span class="segment-dur">${dur}</span>
        </div>
        <audio controls preload="metadata" src="segments/${escapeHtml(r.fileName)}"></audio>
        <details>
          <summary>View script text</summary>
          <p class="segment-text">${escapeHtml(r.segment.text)}</p>
        </details>
        <code class="regen-hint">voiceai-vo build --input &lt;script&gt; --voice ${escapeHtml(voiceId)} --force</code>
      </div>`;
    })
    .join('\n');

  const masterSection = hasMaster
    ? `
    <section class="master-section">
      <h2>🎧 Master Audio</h2>
      <audio controls preload="metadata" src="master.wav" style="width:100%"></audio>
    </section>`
    : '';

  const totalDur = formatDuration(timeline.total_duration_seconds);
  const mockBadge = mock
    ? '<span class="badge badge-mock">MOCK MODE</span>'
    : '';

  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>${escapeHtml(title)} — Voiceover Review</title>
  <style>
    :root {
      --bg: #0f0f13;
      --surface: #1a1a24;
      --border: #2a2a3a;
      --text: #e8e8f0;
      --text-dim: #8888a0;
      --accent: #6c5ce7;
      --accent-glow: #7c6cf7;
      --success: #00d2a0;
      --radius: 12px;
    }
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: var(--bg);
      color: var(--text);
      line-height: 1.6;
      padding: 2rem 1rem;
    }
    .container { max-width: 800px; margin: 0 auto; }
    header {
      text-align: center;
      margin-bottom: 2.5rem;
      padding: 2rem 1rem;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
      border-radius: var(--radius);
      border: 1px solid var(--border);
    }
    header h1 {
      font-size: 1.8rem;
      font-weight: 700;
      margin-bottom: 0.5rem;
      background: linear-gradient(135deg, var(--accent-glow), var(--success));
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
    }
    header .meta {
      color: var(--text-dim);
      font-size: 0.9rem;
    }
    .badge {
      display: inline-block;
      padding: 2px 8px;
      border-radius: 4px;
      font-size: 0.75rem;
      font-weight: 600;
      text-transform: uppercase;
      letter-spacing: 0.05em;
      margin-left: 0.5rem;
    }
    .badge-mock { background: #ff9f43; color: #000; }
    .master-section {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: var(--radius);
      padding: 1.5rem;
      margin-bottom: 2rem;
    }
    .master-section h2 { margin-bottom: 1rem; font-size: 1.2rem; }
    .segment-card {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: var(--radius);
      padding: 1.25rem;
      margin-bottom: 1rem;
      transition: border-color 0.2s;
    }
    .segment-card:hover { border-color: var(--accent); }
    .segment-header {
      display: flex;
      align-items: center;
      gap: 0.75rem;
      margin-bottom: 0.75rem;
    }
    .segment-num {
      background: var(--accent);
      color: #fff;
      width: 32px;
      height: 32px;
      border-radius: 8px;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 0.8rem;
      font-weight: 700;
      flex-shrink: 0;
    }
    .segment-header h3 { flex: 1; font-size: 1rem; font-weight: 600; }
    .segment-dur { color: var(--text-dim); font-size: 0.85rem; }
    audio {
      width: 100%;
      height: 36px;
      margin-bottom: 0.5rem;
      border-radius: 6px;
    }
    details { margin-top: 0.5rem; }
    details summary {
      cursor: pointer;
      color: var(--text-dim);
      font-size: 0.85rem;
      user-select: none;
    }
    details summary:hover { color: var(--text); }
    .segment-text {
      margin-top: 0.75rem;
      padding: 1rem;
      background: var(--bg);
      border-radius: 8px;
      font-size: 0.9rem;
      line-height: 1.7;
      white-space: pre-wrap;
    }
    .regen-hint {
      display: block;
      margin-top: 0.5rem;
      padding: 0.5rem 0.75rem;
      background: var(--bg);
      border-radius: 6px;
      font-size: 0.75rem;
      color: var(--text-dim);
      overflow-x: auto;
    }
    footer {
      text-align: center;
      margin-top: 3rem;
      padding-top: 1.5rem;
      border-top: 1px solid var(--border);
      color: var(--text-dim);
      font-size: 0.85rem;
    }
    footer a { color: var(--accent-glow); text-decoration: none; }
    footer a:hover { text-decoration: underline; }
  </style>
</head>
<body>
  <div class="container">
    <header>
      <h1>${escapeHtml(title)}</h1>
      <p class="meta">
        ${results.length} segments · ${totalDur} total · voice: <code>${escapeHtml(voiceId)}</code>
        ${mockBadge}
      </p>
    </header>

    ${masterSection}

    <section>
      <h2 style="margin-bottom: 1rem; font-size: 1.2rem;">📝 Segments</h2>
      ${segmentCards}
    </section>

    <footer>
      <p>Generated by <a href="https://voice.ai">Voice.ai</a> Creator Voiceover Pipeline</p>
    </footer>
  </div>
</body>
</html>`;
}

function escapeHtml(text: string): string {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

/* ------------------------------------------------------------------ */
/*  Write all output files                                             */
/* ------------------------------------------------------------------ */

export interface WriteOutputsOptions {
  outputDir: string;
  title: string;
  voiceId: string;
  voiceName?: string;
  template?: string;
  language: string;
  mock: boolean;
  results: RenderResult[];
  segments: Segment[];
  hasMaster: boolean;
  muxResult?: MuxResult;
}

export async function writeAllOutputs(opts: WriteOutputsOptions): Promise<void> {
  const {
    outputDir,
    title,
    voiceId,
    voiceName,
    template,
    language,
    mock,
    results,
    segments,
    hasMaster,
    muxResult,
  } = opts;

  // Manifest
  const manifest = buildManifest(title, voiceId, voiceName, template, language, mock, results);
  await writeOutputFile(join(outputDir, 'manifest.json'), JSON.stringify(manifest, null, 2));

  // Timeline
  const timeline = buildTimeline(results);
  await writeOutputFile(join(outputDir, 'timeline.json'), JSON.stringify(timeline, null, 2));

  // Chapters
  const chapters = buildChapters(timeline);
  if (chapters) {
    await writeOutputFile(join(outputDir, 'chapters.txt'), chapters);
  }

  // Captions (SRT)
  const captions = buildCaptionsSrt(timeline, segments);
  if (captions) {
    await writeOutputFile(join(outputDir, 'captions.srt'), captions);
  }

  // Description
  const description = buildDescription(title, chapters);
  await writeOutputFile(join(outputDir, 'description.txt'), description);

  // Review HTML
  const reviewHtml = buildReviewHtml(title, results, timeline, hasMaster, voiceId, mock);
  await writeOutputFile(join(outputDir, 'review.html'), reviewHtml);

  // Mux report (if applicable)
  if (muxResult) {
    await writeOutputFile(join(outputDir, 'mux_report.json'), JSON.stringify(muxResult, null, 2));
  }
}


path: src/render.ts
content: /**
 * TTS rendering pipeline — sends segments to Voice.ai and manages caching.
 */
import { join } from 'node:path';
import { readFile } from 'node:fs/promises';
import chalk from 'chalk';
import { type Segment } from './chunking.js';
import { type VoiceAIClient, type TTSResponse } from './api.js';
import { probeDuration } from './ffmpeg.js';
import { zeroPad, slugify, contentHash, ensureDir, writeOutputFile, fileExists } from './utils.js';

/* ------------------------------------------------------------------ */
/*  Types                                                              */
/* ------------------------------------------------------------------ */

export interface RenderOptions {
  voiceId: string;
  outputDir: string;
  force: boolean;
  mock: boolean;
  language?: string;
}

export interface RenderResult {
  segment: Segment;
  filePath: string;
  fileName: string;
  cached: boolean;
  duration: number;
}

interface CacheManifest {
  [segmentKey: string]: {
    hash: string;
    file: string;
    duration: number;
  };
}

/* ------------------------------------------------------------------ */
/*  Cache management                                                   */
/* ------------------------------------------------------------------ */

function cacheManifestPath(outputDir: string): string {
  return join(outputDir, 'segments', '.cache.json');
}

async function loadCacheManifest(outputDir: string): Promise<CacheManifest> {
  const path = cacheManifestPath(outputDir);
  if (await fileExists(path)) {
    try {
      const raw = await readFile(path, 'utf-8');
      return JSON.parse(raw) as CacheManifest;
    } catch {
      return {};
    }
  }
  return {};
}

async function saveCacheManifest(outputDir: string, manifest: CacheManifest): Promise<void> {
  await writeOutputFile(cacheManifestPath(outputDir), JSON.stringify(manifest, null, 2));
}

/* ------------------------------------------------------------------ */
/*  Segment rendering                                                  */
/* ------------------------------------------------------------------ */

function segmentFileName(segment: Segment): string {
  return `${zeroPad(segment.index)}-${segment.slug || slugify(segment.title)}.wav`;
}

function segmentCacheKey(segment: Segment): string {
  return `${zeroPad(segment.index)}-${segment.slug}`;
}

export async function renderSegments(
  segments: Segment[],
  client: VoiceAIClient,
  options: RenderOptions,
): Promise<RenderResult[]> {
  const segDir = join(options.outputDir, 'segments');
  await ensureDir(segDir);

  // Load cache
  const cache = options.force ? {} : await loadCacheManifest(options.outputDir);
  const results: RenderResult[] = [];
  let cachedCount = 0;
  let newCount = 0;

  // Determine what needs rendering
  for (const segment of segments) {
    const key = segmentCacheKey(segment);
    const fileName = segmentFileName(segment);
    const filePath = join(segDir, fileName);
    const currentHash = segment.hash;

    // Check cache: hash match + file exists
    if (
      !options.force &&
      cache[key]?.hash === currentHash &&
      cache[key]?.file === fileName &&
      (await fileExists(filePath))
    ) {
      cachedCount++;
      results.push({
        segment,
        filePath,
        fileName,
        cached: true,
        duration: cache[key].duration,
      });
    } else {
      newCount++;
      results.push({
        segment,
        filePath,
        fileName,
        cached: false,
        duration: 0,
      });
    }
  }

  // Progress header
  const mockLabel = options.mock ? chalk.yellow(' [mock]') : '';
  console.log(
    chalk.cyan(`\n🎙  Rendering ${segments.length} segments…`) +
      chalk.gray(` (${cachedCount} cached, ${newCount} new)`) +
      mockLabel,
  );

  // Render new segments
  const updatedCache: CacheManifest = { ...cache };

  for (const result of results) {
    if (result.cached) {
      console.log(chalk.gray(`   ✓ ${result.fileName} (cached)`));
      continue;
    }

    const { segment } = result;
    process.stdout.write(chalk.white(`   ⏳ ${result.fileName}…`));

    try {
      const ttsResponse: TTSResponse = await client.generateSpeech({
        text: segment.text,
        voice_id: options.voiceId,
        audio_format: 'wav',
        language: options.language,
      });

      await writeOutputFile(result.filePath, ttsResponse.audio_data);

      // Get accurate duration from ffprobe if available, else use API estimate
      const probed = await probeDuration(result.filePath);
      result.duration = probed ?? ttsResponse.duration_seconds;

      // Update cache
      updatedCache[segmentCacheKey(segment)] = {
        hash: segment.hash,
        file: result.fileName,
        duration: ttsResponse.duration_seconds,
      };

      process.stdout.write(
        `\r   ${chalk.green('✓')} ${result.fileName} ${chalk.gray(`(${ttsResponse.duration_seconds.toFixed(1)}s)`)}\n`,
      );
    } catch (err) {
      process.stdout.write(`\r   ${chalk.red('✗')} ${result.fileName}\n`);
      throw new Error(`Failed to render segment "${segment.title}": ${err}`);
    }
  }

  // Save updated cache
  await saveCacheManifest(options.outputDir, updatedCache);

  const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
  console.log(
    chalk.green(`\n   ✅ All segments rendered`) +
      chalk.gray(` — total duration: ${totalDuration.toFixed(1)}s`),
  );

  return results;
}


path: src/utils.test.ts
content: /**
 * Tests for utility functions — slugify, hashing, formatting.
 */
import { describe, it, expect } from 'vitest';
import {
  slugify,
  zeroPad,
  contentHash,
  formatDuration,
  youtubeTimestamp,
  srtTimestamp,
} from './utils.js';

/* ------------------------------------------------------------------ */
/*  slugify                                                            */
/* ------------------------------------------------------------------ */

describe('slugify', () => {
  it('converts simple text to slug', () => {
    expect(slugify('Hello World')).toBe('hello-world');
  });

  it('removes special characters', () => {
    expect(slugify('What!? A Test...')).toBe('what-a-test');
  });

  it('handles apostrophes', () => {
    expect(slugify("It's a Creator's Life")).toBe('its-a-creators-life');
  });

  it('collapses multiple hyphens', () => {
    expect(slugify('too   many   spaces')).toBe('too-many-spaces');
  });

  it('strips leading and trailing hyphens', () => {
    expect(slugify('---hello---')).toBe('hello');
  });

  it('truncates to 60 characters', () => {
    const long = 'a'.repeat(100);
    expect(slugify(long).length).toBeLessThanOrEqual(60);
  });

  it('handles empty string', () => {
    expect(slugify('')).toBe('');
  });

  it('handles unicode characters', () => {
    const result = slugify('Über Cool Ñoño');
    expect(result).toBe('ber-cool-o-o');
  });
});

/* ------------------------------------------------------------------ */
/*  zeroPad                                                            */
/* ------------------------------------------------------------------ */

describe('zeroPad', () => {
  it('pads single digit', () => {
    expect(zeroPad(3)).toBe('003');
  });

  it('pads double digit', () => {
    expect(zeroPad(42)).toBe('042');
  });

  it('does not pad triple digit', () => {
    expect(zeroPad(100)).toBe('100');
  });

  it('supports custom width', () => {
    expect(zeroPad(5, 5)).toBe('00005');
  });
});

/* ------------------------------------------------------------------ */
/*  contentHash                                                        */
/* ------------------------------------------------------------------ */

describe('contentHash', () => {
  it('produces consistent hash for same input', () => {
    const h1 = contentHash('hello', 'world');
    const h2 = contentHash('hello', 'world');
    expect(h1).toBe(h2);
  });

  it('produces different hash for different input', () => {
    const h1 = contentHash('hello', 'world');
    const h2 = contentHash('hello', 'earth');
    expect(h1).not.toBe(h2);
  });

  it('returns 16-char hex string', () => {
    const h = contentHash('test');
    expect(h).toMatch(/^[0-9a-f]{16}$/);
  });

  it('handles single argument', () => {
    const h = contentHash('single');
    expect(h).toMatch(/^[0-9a-f]{16}$/);
  });

  it('handles many arguments', () => {
    const h = contentHash('a', 'b', 'c', 'd', 'e');
    expect(h).toMatch(/^[0-9a-f]{16}$/);
  });
});

/* ------------------------------------------------------------------ */
/*  formatDuration                                                     */
/* ------------------------------------------------------------------ */

describe('formatDuration', () => {
  it('formats seconds under a minute', () => {
    expect(formatDuration(45)).toBe('0:45');
  });

  it('formats minutes', () => {
    expect(formatDuration(125)).toBe('2:05');
  });

  it('formats hours', () => {
    expect(formatDuration(3661)).toBe('1:01:01');
  });

  it('handles zero', () => {
    expect(formatDuration(0)).toBe('0:00');
  });
});

/* ------------------------------------------------------------------ */
/*  youtubeTimestamp                                                    */
/* ------------------------------------------------------------------ */

describe('youtubeTimestamp', () => {
  it('formats zero as 0:00', () => {
    expect(youtubeTimestamp(0)).toBe('0:00');
  });

  it('formats minutes correctly', () => {
    expect(youtubeTimestamp(90)).toBe('1:30');
  });

  it('formats hours correctly', () => {
    expect(youtubeTimestamp(7200)).toBe('2:00:00');
  });
});

/* ------------------------------------------------------------------ */
/*  srtTimestamp                                                        */
/* ------------------------------------------------------------------ */

describe('srtTimestamp', () => {
  it('formats zero', () => {
    expect(srtTimestamp(0)).toBe('00:00:00,000');
  });

  it('formats fractional seconds', () => {
    expect(srtTimestamp(1.5)).toBe('00:00:01,500');
  });

  it('formats minutes and hours', () => {
    expect(srtTimestamp(3723.25)).toBe('01:02:03,250');
  });
});


path: src/utils.ts
content: /**
 * Shared utilities: slugify, hashing, formatting, file helpers.
 */
import { createHash } from 'node:crypto';
import { mkdir, writeFile, readFile, stat } from 'node:fs/promises';
import { dirname } from 'node:path';

/* ------------------------------------------------------------------ */
/*  String helpers                                                     */
/* ------------------------------------------------------------------ */

/** Slugify a string for safe filenames and directory names. */
export function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/['']/g, '')
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 60);
}

/** Zero-pad a number (e.g. 3 → "003"). */
export function zeroPad(n: number, width = 3): string {
  return String(n).padStart(width, '0');
}

/* ------------------------------------------------------------------ */
/*  Hashing                                                            */
/* ------------------------------------------------------------------ */

/** SHA-256 content hash (first 16 hex chars). */
export function contentHash(...parts: string[]): string {
  const h = createHash('sha256');
  for (const p of parts) h.update(p);
  return h.digest('hex').slice(0, 16);
}

/* ------------------------------------------------------------------ */
/*  Duration / timestamp formatting                                    */
/* ------------------------------------------------------------------ */

/** Format seconds → "M:SS" or "H:MM:SS". */
export function formatDuration(seconds: number): string {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = Math.floor(seconds % 60);
  if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
  return `${m}:${String(s).padStart(2, '0')}`;
}

/** Format seconds → "HH:MM:SS" (YouTube chapter format). */
export function formatTimestamp(seconds: number): string {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = Math.floor(seconds % 60);
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}

/** Format seconds → YouTube-friendly "M:SS" or "H:MM:SS" (no leading zero on hours). */
export function youtubeTimestamp(seconds: number): string {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = Math.floor(seconds % 60);
  if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
  return `${m}:${String(s).padStart(2, '0')}`;
}

/** Format seconds → SRT timestamp "HH:MM:SS,mmm". */
export function srtTimestamp(seconds: number): string {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = Math.floor(seconds % 60);
  const ms = Math.round((seconds % 1) * 1000);
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(ms).padStart(3, '0')}`;
}

/* ------------------------------------------------------------------ */
/*  File system helpers                                                */
/* ------------------------------------------------------------------ */

/** Create directory (recursive). */
export async function ensureDir(dirPath: string): Promise<void> {
  await mkdir(dirPath, { recursive: true });
}

/** Write file, creating parent directories as needed. */
export async function writeOutputFile(filePath: string, content: string | Buffer): Promise<void> {
  await ensureDir(dirname(filePath));
  await writeFile(filePath, content);
}

/** Check if a path exists. */
export async function fileExists(filePath: string): Promise<boolean> {
  try {
    await stat(filePath);
    return true;
  } catch {
    return false;
  }
}

/** Read a text file to string. */
export async function readTextFile(filePath: string): Promise<string> {
  return readFile(filePath, 'utf-8');
}


path: templates/podcast_intro.txt
content: Welcome to the show! I'm really excited about today's topic. If you're new here, this is where we break down the tools and strategies that help creators level up. Let's dive right in.

path: templates/shortform_hook.txt
content: Wait — before you scroll, you need to hear this.

path: templates/youtube_intro.txt
content: Hey there! Welcome to the video. Before we jump in, make sure you hit that subscribe button and turn on notifications — you won't want to miss what's coming up. Alright, let's get into it.

path: templates/youtube_outro.txt
content: And that's a wrap! If you found this valuable, drop a like and leave a comment letting me know your biggest takeaway. I read every single one. Don't forget to subscribe, and I'll see you in the next video. Peace!

path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "sourceMap": true,
    "resolveJsonModule": true,
    "isolatedModules": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "out", "tmp"]
}


path: vitest.config.ts
content: import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    include: ['src/**/*.test.ts'],
    environment: 'node',
  },
});


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)