feat: migrate voice-generation skill

This commit is contained in:
wantsong 2026-06-15 06:45:39 +08:00
parent deed276fa1
commit c3c56b9a74
25 changed files with 1766 additions and 0 deletions

View File

@ -4,3 +4,4 @@ channels:
dependencies:
- python=3.11
- pip
- pytest>=7.0

View File

@ -14,6 +14,7 @@ Existing CCPE content is not migrated here.
| Skill | Purpose | Source | Status | Install Policy | Installed Path | CCPE Registration |
| --- | --- | --- | --- | --- | --- | --- |
| `fix-title` | Shift Markdown ATX heading depth for pasted LLM replies before insertion under parent sections. | `C:\Users\wangq\Documents\Codex\knowledge-vault\skills\fix-title` | `installed` | `default` | `C:\Users\wangq\.agents\skills\fix-title` | none |
| `voice-generation` | Generate spoken audio from Markdown scripts using the local `voice-gen` CLI and MiniMax `mmx`, including custom voice clone registration and voice registry management. | `D:\AI\ClaudeCode\voice-ge` | `installed` | `conditional` | `C:\Users\wangq\.agents\skills\voice-generation` | none |
## Status Values

14
skills/voice-generation/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.venv/
venv/
# Project: keep registry out of git, keep outputs out of git
voices.json
output/*
!output/.gitkeep
scripts/*
!scripts/.gitkeep

View File

@ -0,0 +1,77 @@
# Install voice-generation
`voice-generation` provides the `voice-gen` Python CLI and a Skill wrapper for Agentic tools.
## Conda Environment
Use the repository-level `skills-vault` Conda environment.
```powershell
conda env create -f environment.yml
conda activate skills-vault
```
If the environment already exists:
```powershell
conda activate skills-vault
```
This Skill does not currently require a dedicated Conda environment. Create one only if future dependencies conflict with the shared `skills-vault` environment.
## Install Python Package
From the repository root:
```powershell
pip install -e .\skills\voice-generation
```
If the current shell has not activated the environment, use the environment Python directly:
```powershell
C:\Users\wangq\.conda\envs\skills-vault\python.exe -m pip install -e .\skills\voice-generation
```
## External Requirements
Install and authenticate MiniMax `mmx`:
```powershell
npm install -g mmx-cli
mmx auth login
```
## Install Skill Wrapper
Use the repository installer from the repository root:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voice-generation
```
Replace an existing installed copy after backing it up:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voice-generation -Force -InstallClaudeLink
```
## Test
Testing is intentionally separate from migration.
Run unit tests only after installation is ready:
```powershell
New-Item -ItemType Directory -Force -Path .\tmp | Out-Null
python -m pytest .\skills\voice-generation\tests --basetemp .\tmp\pytest-voice-generation
```
Or without activating the environment:
```powershell
New-Item -ItemType Directory -Force -Path .\tmp | Out-Null
C:\Users\wangq\.conda\envs\skills-vault\python.exe -m pytest .\skills\voice-generation\tests --basetemp .\tmp\pytest-voice-generation
```
Smoke tests may call real MiniMax services and consume quota. Do not run them unless explicitly requested.

View File

@ -0,0 +1,70 @@
# voice-generation Migration
## Source
```text
D:\AI\ClaudeCode\voice-ge
```
## Target
```text
C:\Users\wangq\Documents\Codex\skills-vault\skills\voice-generation
```
## Naming Decision
The vault Skill name is `voice-generation`.
The Python package and command remain `voice-gen`:
```text
package: voice-gen
module: voice_gen
command: voice-gen
```
This preserves the existing CLI while giving the vault Skill a descriptive repository name.
## Migrated
```text
README.md
pyproject.toml
docs/
scripts/
src/
tests/
```
## Excluded
```text
.git/
.pytest_cache/
.claude/settings.local.json
output/
plan.md
environment.yml
install-skill.ps1
install-skill.sh
```
## Status
```text
migration status: source-migrated-and-installed
installed version updated: yes
installed path: C:\Users\wangq\.agents\skills\voice-generation
claude link: C:\Users\wangq\.claude\skills\voice-generation
old agents backup: C:\Users\wangq\.agents\skills\voice-gen.backup-20260615054951
old claude backup: C:\Users\wangq\.claude\skills\voice-gen.backup-20260615054951
unit test status: passed
unit test command: C:\Users\wangq\.conda\envs\skills-vault\python.exe -m pytest .\skills\voice-generation\tests --basetemp .\tmp\pytest-voice-generation
real generation test status: passed
real generation test cwd: D:\Mine\口播2
real generation command: C:\Users\wangq\.conda\envs\skills-vault\Scripts\voice-gen.exe gen --voice BroTsong-2026-06-10 scripts --out output --format mp3
real generation output: D:\Mine\口播2\output\hello.mp3
conda environment: shared skills-vault
editable package installed in shared env: yes
```

View File

@ -0,0 +1,114 @@
# voice-generation
Batch text-to-speech via the `mmx` CLI, with a local registry of custom MiniMax voice clones.
This vault Skill is named `voice-generation`. The Python package, module, and command remain:
```text
package: voice-gen
module: voice_gen
command: voice-gen
```
## Install
See [INSTALL.md](INSTALL.md) for the current `skills-vault` installation workflow.
Short version from the repository root:
```powershell
conda env create -f environment.yml
conda activate skills-vault
pip install -e .\skills\voice-generation
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voice-generation -Force -InstallClaudeLink
```
External requirements:
- Conda
- `mmx` CLI v1.0.16+ (`npm install -g mmx-cli`)
- `mmx auth login` once
## Quick Start
```bash
# 1. Initialize a project
voice-gen init
# 2. Register a custom voice
voice-gen voices add ./samples/wantsong.mp3
# 2b. Or pull existing voices from the server
voice-gen voices pull
# 3. Put Markdown scripts in scripts/
echo "Hello, this is a test." > scripts/hello.md
# 4. Generate audio
voice-gen gen --voice wantsong scripts
# -> output/hello.mp3
```
## Commands
| Command | Purpose |
| --- | --- |
| `voice-gen init` | Create `voices.json`, `scripts/`, and `output/` in the current working directory. |
| `voice-gen voices add <audio>` | Upload audio; friendly name is derived from the filename stem. |
| `voice-gen voices list [local\|server]` | List voices. Default scope is `local`; `server` queries MiniMax files. |
| `voice-gen voices pull [--auto] [--force] [--dry-run]` | Sync server-side voices into the local registry. |
| `voice-gen voices cleanup` | Dedupe by source filename, keeping the oldest. |
| `voice-gen gen --voice <name> <input-dir> [--out DIR] [--format mp3\|wav\|flac\|pcm\|opus]` | Batch synthesize Markdown scripts. |
## How It Works
The CLI wraps MiniMax voice operations:
- Voice audio is uploaded with a direct MiniMax file upload call.
- Uploaded files are registered through the MiniMax voice clone API.
- Synthesis uses `mmx speech synthesize`.
On Windows, `mmx` is a `.cmd` shim from npm, so subprocess calls are routed through `cmd /c` where needed.
## Project Layout
```text
skills/voice-generation/
SKILL.md
INSTALL.md
environment.yml
pyproject.toml
src/voice_gen/
tests/
docs/examples/
```
Runtime project files are created where the user runs `voice-gen init`:
```text
voices.json
scripts/
output/
```
## Development
```bash
# Inside the skills-vault Conda env
python -m pytest
# Smoke tests may call real MiniMax services and consume quota.
python -m pytest --run-smoke
```
## As An Agentic Skill
The root `SKILL.md` exposes this tool to Agentic systems. The public installed Skill path is:
```text
C:\Users\wangq\.agents\skills\voice-generation
```
Claude and other private Skill folders may link to that public installation path.

View File

@ -0,0 +1,77 @@
---
name: voice-generation
description: Use when the user wants to generate spoken audio/TTS from Markdown scripts, voice a markdown file, batch-generate narration audio, register or list custom MiniMax voice clones, or operate the local `voice-gen` CLI. Triggers include requests like "turn this script into speech", "generate podcast audio", "use BroTsong-Saying-2026-06-10 to read this md", "register a new voice", and "list my voices". Requires the `voice-gen` CLI plus MiniMax `mmx` authentication; do not use for music generation, sound effects, image/video generation, or generic non-MiniMax TTS.
---
# Voice Generation
This Skill operates the local `voice-gen` Python CLI. The CLI wraps MiniMax `mmx` for text-to-speech and maintains a local `voices.json` registry of custom cloned voices.
## Runtime Requirements
- Activate the configured Conda environment before running the CLI.
- Ensure the `voice-gen` package has been installed in editable mode from the repository source.
- Ensure the `mmx` CLI is installed and authenticated with `mmx auth login`.
- Run CLI commands from the user's project root unless the user specifies another working directory.
## Workflow
### Register A Voice
1. Ask for the path to the reference audio file (`.mp3`, `.wav`, `.flac`, or `.m4a`) if the user has not provided it.
2. Run:
```bash
voice-gen voices add <audio-path>
```
3. The friendly voice name is derived from the filename stem. Use timestamped filenames such as `BroTsong-Saying-2026-06-10.m4a` when the user wants a stable unique voice ID.
4. Confirm registration:
```bash
voice-gen voices list
```
To seed a fresh project with voices already on the server, run:
```bash
voice-gen voices pull
```
Use `voice-gen voices pull --force` only when overwriting existing local entries is intended.
### Generate Audio
1. Confirm the voice name. If needed, run:
```bash
voice-gen voices list
```
2. Confirm the input directory and output directory. Defaults are `scripts/` and `output/`.
3. Run:
```bash
voice-gen gen --voice <name> <input-dir> --out <output-dir> --format mp3
```
4. Report how many files succeeded, where outputs were written, and any failures.
## Conventions
- Input scripts are Markdown files matching `*.md`.
- Frontmatter, headings, code fences, list markers, bold, italic, and inline code markup are stripped before synthesis.
- Output files are named `<script-stem>.<format>`.
- The local voice registry is `voices.json` in the current working directory.
## Failure Handling
- If `voice-gen voices list` is empty, ask the user to register or pull voices first.
- If a voice is not found, list available voices and ask the user to choose one.
- If `mmx` authentication fails, ask the user to run `mmx auth status` and `mmx auth login`.
- If quota fails, surface the quota error and suggest waiting for the next quota window or upgrading.
- If the request is for music, sound effects, video, image, or non-MiniMax TTS, do not use this Skill.
## Known Limitation
MiniMax voice IDs cannot be fully unregistered by this tool. Deleting a source audio file may not free the voice name on the server. Prefer unique timestamped filenames for voice registration.

View File

@ -0,0 +1,9 @@
---
title: smoke test
voice: system-default
---
# Smoke Test
This is a short narration used by the smoke test to verify the
end-to-end pipeline writes a real audio file using the mmx CLI.

View File

@ -0,0 +1,23 @@
[project]
name = "voice-gen"
version = "0.1.0"
description = "Batch TTS via mmx CLI with custom voice clones"
requires-python = ">=3.10"
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=7.0"]
[project.scripts]
voice-gen = "voice_gen.cli:main"
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"

View File

View File

@ -0,0 +1 @@
__version__ = "0.1.0"

View File

@ -0,0 +1,6 @@
# src/voice_gen/__main__.py
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,479 @@
# src/voice_gen/cli.py
"""argparse-based CLI for voice-gen."""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from .registry import VoiceRegistry
_MMX_CONFIG_PATH = Path.home() / ".mmx" / "config.json"
_oauth_token_cache: str | None = None
def _load_oauth_token() -> str:
"""Load the OAuth access token from ~/.mmx/config.json (cached)."""
global _oauth_token_cache
if _oauth_token_cache is None:
if not _MMX_CONFIG_PATH.exists():
raise FileNotFoundError(
f"mmx config not found at {_MMX_CONFIG_PATH}; "
"run `mmx auth login` first"
)
data = json.loads(_MMX_CONFIG_PATH.read_text(encoding="utf-8"))
_oauth_token_cache = data["oauth"]["access_token"]
return _oauth_token_cache
def _get_mmx_resource_url() -> str:
"""Return the mmx API base URL from the user's config."""
if not _MMX_CONFIG_PATH.exists():
return "https://api.minimaxi.com"
data = json.loads(_MMX_CONFIG_PATH.read_text(encoding="utf-8"))
return data.get("oauth", {}).get("resource_url", "https://api.minimaxi.com")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="voice-gen",
description="Batch TTS via mmx CLI with custom voice clones.",
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("init", help="Create voices.json, scripts/, output/ in CWD")
voices = sub.add_parser("voices", help="Manage the voice registry")
voices_sub = voices.add_subparsers(dest="voices_command", required=True)
list_p = voices_sub.add_parser("list", help="List registered voices (default: local)")
list_p.add_argument("scope", nargs="?", default="local", choices=["local", "server"],
help="Which voices to list (default: local)")
add_p = voices_sub.add_parser("add", help="Upload an audio file; friendly name is derived from the filename stem")
add_p.add_argument("audio", help="Path to the reference audio file")
pull_p = voices_sub.add_parser("pull", help="Sync voices from server into the local registry")
pull_p.add_argument("--auto", action="store_true",
help="Always auto-derive friendly name from filename (default if non-TTY)")
pull_p.add_argument("--interactive", action="store_true",
help="Always prompt for each friendly name (default if TTY)")
pull_p.add_argument("--force", action="store_true",
help="Overwrite local entries that have the same name")
pull_p.add_argument("--dry-run", action="store_true",
help="Show what would be added/changed without modifying voices.json")
voices_sub.add_parser("cleanup", help="Delete duplicate voices that share the same source filename")
gen = sub.add_parser("gen", help="Synthesize audio from a directory of .md scripts")
gen.add_argument("--voice", required=True, help="Voice name from registry")
gen.add_argument("input", help="Input directory containing .md files")
gen.add_argument("--out", default="output", help="Output directory (default: output)")
gen.add_argument("--format", default="mp3", choices=["mp3", "wav", "flac", "pcm", "opus"])
return parser
def _registry() -> VoiceRegistry:
return VoiceRegistry.load(Path("voices.json"))
def _upload_voice_file(path: Path, purpose: str = "voice_clone") -> dict:
"""Upload a file to MiniMax storage via direct API call.
Bypasses `mmx file upload` because mmx CLI v1.0.16 POSTs to the wrong
endpoint (`/v1/files`) and gets a 404. The correct endpoint is
`/v1/files/upload` (multipart form).
"""
from .errors import MmxError
token = _load_oauth_token()
base_url = _get_mmx_resource_url()
curl = shutil.which("curl")
if not curl:
raise MmxError(
exit_code=1,
stderr="curl not found on PATH; required for direct API upload",
command="curl",
)
url = f"{base_url}/v1/files/upload"
cmd = [
curl, "-s", "-X", "POST", url,
"-H", f"Authorization: Bearer {token}",
"-F", f"file=@{path}",
"-F", f"purpose={purpose}",
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise MmxError(
exit_code=result.returncode,
stderr=result.stderr,
command=f"curl ... {url}",
)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as e:
raise MmxError(
exit_code=1,
stderr=f"invalid JSON from upload API: {result.stdout[:500]}",
command=f"curl ... {url}",
)
if "file" not in data or "file_id" not in data["file"]:
raise MmxError(
exit_code=1,
stderr=f"unexpected response from upload API: {data}",
command=f"curl ... {url}",
)
return {
"file_id": data["file"]["file_id"],
"filename": data["file"].get("filename", path.name),
}
def _delete_remote_file(file_id: int) -> bool:
"""Run `mmx file delete`. Raises MmxError on failure."""
from .errors import MmxError
cmd = [
"mmx", "file", "delete",
"--non-interactive", "--quiet",
"--file-id", str(file_id),
]
from .synthesizer import _run_mmx
result = _run_mmx(cmd)
if result.returncode != 0:
raise MmxError(exit_code=result.returncode, stderr=result.stderr, command=" ".join(cmd))
return True
def _clone_voice_file(file_id: int, voice_id: str) -> None:
"""Register an uploaded file as a voice clone via /v1/voice_clone.
After this call, `voice_id` (the user's friendly name) is usable in the
synthesis endpoint (`/v1/t2a_v2`). Without this step, the file_id alone
cannot be used as a voice_id ?the API will respond with
"voice id not exist".
"""
from .errors import MmxError
token = _load_oauth_token()
base_url = _get_mmx_resource_url()
curl = shutil.which("curl")
if not curl:
raise MmxError(
exit_code=1,
stderr="curl not found on PATH; required for direct API call",
command="curl",
)
url = f"{base_url}/v1/voice_clone"
payload = json.dumps({
"file_id": file_id,
"voice_id": voice_id,
"model": "speech-2.8-hd",
"text": "test",
"need_noise_reduction": False,
"need_volume_normalization": False,
})
cmd = [
curl, "-s", "-X", "POST", url,
"-H", f"Authorization: Bearer {token}",
"-H", "Content-Type: application/json",
"-d", payload,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise MmxError(
exit_code=result.returncode,
stderr=result.stderr,
command=f"curl ... {url}",
)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as e:
raise MmxError(
exit_code=1,
stderr=f"invalid JSON from voice_clone API: {result.stdout[:500]}",
command=f"curl ... {url}",
)
resp = data.get("base_resp", {})
if resp.get("status_code") != 0:
raise MmxError(
exit_code=1,
stderr=f"voice_clone failed: status_code={resp.get('status_code')} "
f"msg={resp.get('status_msg')} raw={data}",
command=f"curl ... {url}",
)
def cmd_init(_args: argparse.Namespace) -> int:
cwd = Path.cwd()
(cwd / "scripts").mkdir(exist_ok=True)
(cwd / "output").mkdir(exist_ok=True)
registry_path = cwd / "voices.json"
if not registry_path.exists():
registry_path.write_text(
json.dumps({"voices": {}}, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"initialized in {cwd}")
return 0
def cmd_voices_list(args: argparse.Namespace) -> int:
if args.scope == "server":
return _list_server_voices()
reg = _registry()
names = reg.list_names()
if not names:
print("(no voices registered; run `voice-gen voices add <audio>` or `voice-gen voices pull`)")
return 0
width = max(len(n) for n in names)
for n in names:
e = reg.get(n)
print(f"{n.ljust(width)} file_id={e.file_id} {e.filename}")
return 0
def _list_server_voices() -> int:
"""List server-side voice_clone files via mmx file list."""
from .synthesizer import _run_mmx
from .errors import MmxError
cmd = ["mmx", "file", "list", "--purpose", "voice_clone",
"--output", "json", "--quiet"]
result = _run_mmx(cmd)
if result.returncode != 0:
raise MmxError(exit_code=result.returncode, stderr=result.stderr,
command=" ".join(cmd))
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as e:
print(f"(server returned invalid JSON: {e})", file=sys.stderr)
return 1
files = data.get("files", [])
if not files:
print("(no voice_clone files on server)")
return 0
width = max(len(f.get("filename", "")) for f in files)
for f in files:
filename = f.get("filename", "?")
file_id = f.get("file_id", "?")
print(f"{filename.ljust(width)} file_id={file_id}")
return 0
def _derive_voice_name(audio_path: Path) -> str:
"""Derive a friendly voice name from an audio filename (stem, spaces to underscores)."""
return audio_path.stem.replace(" ", "_")
def cmd_voices_add(args: argparse.Namespace) -> int:
from .registry import VoiceEntry # local import keeps top of file clean
audio = Path(args.audio)
if not audio.exists():
print(f"audio file not found: {audio}", file=sys.stderr)
return 2
name = _derive_voice_name(audio)
reg = _registry()
if reg.has(name):
print(f"voice {name!r} already exists locally; pick a different filename", file=sys.stderr)
return 1
info = _upload_voice_file(audio)
_clone_voice_file(info["file_id"], name)
entry = VoiceEntry(
file_id=info["file_id"],
filename=info["filename"],
created_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
)
reg.add(name, entry)
reg.save()
print(f"registered {name!r} -> file_id={entry.file_id}")
return 0
def cmd_voices_pull(args: argparse.Namespace) -> int:
"""Pull server-side voice_clone files into the local registry."""
from .synthesizer import _run_mmx
from .errors import MmxError
from .registry import VoiceEntry
cmd = ["mmx", "file", "list", "--purpose", "voice_clone",
"--output", "json", "--quiet"]
result = _run_mmx(cmd)
if result.returncode != 0:
raise MmxError(exit_code=result.returncode, stderr=result.stderr,
command=" ".join(cmd))
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as e:
print(f"(server returned invalid JSON: {e})", file=sys.stderr)
return 1
files = data.get("files", [])
if not files:
print("(no voice_clone files on server)")
return 0
reg = _registry()
interactive = args.interactive or (not args.auto and sys.stdin.isatty())
added, skipped, overwritten = 0, 0, 0
for f in files:
filename = f.get("filename", "")
file_id = f.get("file_id")
if not filename or file_id is None:
continue
default_name = _derive_voice_name(Path(filename))
if interactive:
try:
user_input = input(f" file_id={file_id} filename={filename}\n friendly name [{default_name}]: ").strip()
except EOFError:
user_input = ""
name = user_input or default_name
else:
name = default_name
existing = reg.has(name)
if existing and not args.force:
print(f" 路 skip {name!r} (already local; use --force to overwrite)")
skipped += 1
continue
entry = VoiceEntry(
file_id=file_id,
filename=filename,
created_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
)
if args.dry_run:
action = "overwrite" if existing else "add"
print(f" 路 would {action} {name!r} file_id={file_id} filename={filename}")
continue
if existing:
reg.remove(name)
overwritten += 1
else:
added += 1
reg.add(name, entry)
print(f" + {name!r} file_id={file_id} filename={filename}")
if not args.dry_run:
reg.save()
summary = []
if added: summary.append(f"{added} added")
if overwritten: summary.append(f"{overwritten} overwritten")
if skipped: summary.append(f"{skipped} skipped")
if not summary:
summary.append("no changes")
prefix = "would pull" if args.dry_run else "pulled"
print(f"{prefix}: {', '.join(summary)}")
return 0
def cmd_voices_cleanup(_args: argparse.Namespace) -> int:
reg = _registry()
by_filename: dict[str, list[str]] = {}
for name in reg.list_names():
e = reg.get(name)
by_filename.setdefault(e.filename, []).append(name)
removed = 0
for filename, names in by_filename.items():
if len(names) <= 1:
continue
# keep the oldest by created_at
names_sorted = sorted(names, key=lambda n: reg.get(n).created_at)
keep = names_sorted[0]
for drop in names_sorted[1:]:
entry = reg.get(drop)
_delete_remote_file(entry.file_id)
reg.remove(drop)
print(f"removed duplicate {drop!r} (file_id={entry.file_id}, filename={filename})")
removed += 1
if removed:
reg.save()
print(f"cleanup done; removed {removed} duplicate(s)")
return 0
def cmd_gen(args: argparse.Namespace) -> int:
from .extractor import extract_text
from .synthesizer import synthesize
from .errors import VoiceNotFoundError
reg = _registry()
try:
voice = reg.get(args.voice)
except VoiceNotFoundError as e:
print(str(e), file=sys.stderr)
return 1
in_dir = Path(args.input)
if not in_dir.is_dir():
print(f"input is not a directory: {in_dir}", file=sys.stderr)
return 2
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
md_files = sorted(in_dir.glob("*.md"))
if not md_files:
print(f"no .md files in {in_dir}", file=sys.stderr)
return 1
successes, failures = 0, 0
for md in md_files:
out_path = out_dir / f"{md.stem}.{args.format}"
try:
text = extract_text(md)
# Use the friendly name (dict key) as the voice_id for synthesis.
# The file_id in the registry entry is just for tracking the
# source audio; the API uses the friendly name as voice_id after
# voice_clone registration.
synthesize(
text=text,
voice_id=args.voice,
out_path=out_path,
format=args.format,
)
print(f"OK {md.name} -> {out_path}")
successes += 1
except Exception as e: # noqa: BLE001 - log and continue
print(f"ERROR {md.name}: {e}", file=sys.stderr)
failures += 1
print(f"done: {successes} ok, {failures} failed")
return 0 if failures == 0 else 1
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
try:
args = parser.parse_args(argv)
except SystemExit as e:
# argparse calls sys.exit on parse errors; convert to return code
# so callers (e.g. tests) can observe it without exiting the process.
return e.code if isinstance(e.code, int) else 2
if args.command == "init":
return cmd_init(args)
if args.command == "voices" and args.voices_command == "list":
return cmd_voices_list(args)
if args.command == "voices" and args.voices_command == "add":
return cmd_voices_add(args)
if args.command == "voices" and args.voices_command == "pull":
return cmd_voices_pull(args)
if args.command == "voices" and args.voices_command == "cleanup":
return cmd_voices_cleanup(args)
if args.command == "gen":
return cmd_gen(args)
parser.error("unknown command")
return 2 # unreachable
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,26 @@
"""Typed exceptions for voice-gen."""
class VoiceGenError(Exception):
"""Base exception for all voice-gen errors."""
class VoiceNotFoundError(VoiceGenError):
"""Requested voice name is not in voices.json."""
def __init__(self, name: str):
self.name = name
super().__init__(f"voice not found in registry: {name!r}")
class MmxError(VoiceGenError):
"""mmx CLI returned a non-zero exit code."""
def __init__(self, exit_code: int, stderr: str, command: str = ""):
self.exit_code = exit_code
self.stderr = stderr
self.command = command
msg = f"mmx command failed (exit={exit_code}): {stderr.strip()}"
if command:
msg = f"{msg} [cmd: {command}]"
super().__init__(msg)

View File

@ -0,0 +1,60 @@
# src/voice_gen/extractor.py
"""Strip markdown to plain narration text suitable for TTS."""
from __future__ import annotations
import re
from pathlib import Path
_FRONTMATTER_RE = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
_FENCED_CODE_RE = re.compile(r"```.*?\n.*?\n```", re.DOTALL)
_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s+.*$", re.MULTILINE)
_LIST_MARKER_RE = re.compile(r"^\s{0,3}[-*+]\s+", re.MULTILINE)
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
_ITALIC_RE = re.compile(r"(?<!\*)\*([^*]+?)\*(?!\*)")
_CODE_INLINE_RE = re.compile(r"`([^`]+?)`")
_BLANK_RE = re.compile(r"\n+")
def _read_text_tolerant(md_path: Path) -> str:
"""Read a text file trying common encodings (utf-8, BOM variants, utf-16).
PowerShell on Windows writes `>` redirection as UTF-16 LE with a BOM,
which trips a strict UTF-8 reader. We detect the BOM first; without a
BOM we assume UTF-8 (the most common case) and fall back from there.
"""
raw_bytes = md_path.read_bytes()
# BOM-based detection (must come first — UTF-16 LE bytes are individually
# valid UTF-8, so a naive "try utf-8 first" loop never reaches utf-16).
if raw_bytes.startswith(b"\xef\xbb\xbf"):
return raw_bytes.decode("utf-8-sig")
if raw_bytes.startswith(b"\xff\xfe") or raw_bytes.startswith(b"\xfe\xff"):
return raw_bytes.decode("utf-16") # auto-detects endianness + strips BOM
# No BOM: assume UTF-8. If that fails, try utf-16 (heuristics-based).
try:
return raw_bytes.decode("utf-8")
except UnicodeDecodeError:
pass
try:
return raw_bytes.decode("utf-16")
except UnicodeDecodeError:
pass
return raw_bytes.decode("utf-8", errors="replace")
def extract_text(md_path: Path) -> str:
if not md_path.exists():
raise FileNotFoundError(f"markdown file not found: {md_path}")
raw = _read_text_tolerant(md_path)
raw = _FRONTMATTER_RE.sub("", raw, count=1)
raw = _FENCED_CODE_RE.sub("", raw)
raw = _HEADING_RE.sub("", raw)
raw = _LIST_MARKER_RE.sub("", raw)
raw = _BOLD_RE.sub(r"\1", raw)
raw = _ITALIC_RE.sub(r"\1", raw)
raw = _CODE_INLINE_RE.sub(r"\1", raw)
lines = [line.rstrip() for line in raw.splitlines()]
text = "\n".join(lines)
text = _BLANK_RE.sub("\n", text)
return text.strip()

View File

@ -0,0 +1,68 @@
"""voices.json read/write with a tiny dataclass API."""
from __future__ import annotations
import json
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from .errors import VoiceNotFoundError
@dataclass(frozen=True)
class VoiceEntry:
file_id: int
filename: str
created_at: str
class VoiceRegistry:
"""In-memory view of voices.json with save() to persist."""
def __init__(self, path: Path, voices: dict[str, VoiceEntry]):
self.path = path
self._voices = voices
@classmethod
def load(cls, path: Path) -> "VoiceRegistry":
if not path.exists():
return cls(path, {})
data = json.loads(path.read_text(encoding="utf-8"))
raw = data.get("voices", {})
voices = {name: VoiceEntry(**entry) for name, entry in raw.items()}
return cls(path, voices)
def list_names(self) -> list[str]:
return sorted(self._voices.keys())
def list_entries(self) -> list[VoiceEntry]:
return [self._voices[n] for n in self.list_names()]
def get(self, name: str) -> VoiceEntry:
if name not in self._voices:
raise VoiceNotFoundError(name)
return self._voices[name]
def has(self, name: str) -> bool:
return name in self._voices
def add(self, name: str, entry: VoiceEntry) -> None:
if name in self._voices:
raise ValueError(f"voice {name!r} already exists in registry")
self._voices[name] = entry
def remove(self, name: str) -> None:
if name not in self._voices:
raise VoiceNotFoundError(name)
del self._voices[name]
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
data = {
"voices": {n: asdict(e) for n, e in self._voices.items()}
}
self.path.write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)

View File

@ -0,0 +1,106 @@
# src/voice_gen/synthesizer.py
"""Wrap the `mmx speech synthesize` CLI as a Python function."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from typing import Callable, Optional
from .errors import MmxError
def _build_cmd(
text: str,
voice_id: int | str,
out_path: Path,
format: str = "mp3",
speed: Optional[float] = None,
volume: Optional[float] = None,
pitch: Optional[float] = None,
) -> list[str]:
cmd = [
"mmx",
"speech",
"synthesize",
"--non-interactive",
"--quiet",
"--text", text,
"--voice", str(voice_id),
"--format", format,
"--out", str(out_path),
]
if speed is not None:
cmd += ["--speed", str(speed)]
if volume is not None:
cmd += ["--volume", str(volume)]
if pitch is not None:
cmd += ["--pitch", str(pitch)]
return cmd
def _default_run(cmd: list[str]) -> subprocess.CompletedProcess:
return _run_mmx(cmd)
def _run_mmx(cmd: list[str]) -> subprocess.CompletedProcess:
"""Run an mmx CLI command, handling the Windows .cmd shim.
On Windows, `mmx` is installed as a `.cmd` file (npm shim). CreateProcess
cannot execute it directly; the standard fix is to invoke through
`cmd /c`. On POSIX systems the command runs unchanged.
"""
if sys.platform == "win32":
# Resolve the mmx binary path; .cmd files must go through cmd.exe.
# shutil.which respects PATHEXT and returns the actual .cmd path.
import shutil
exe_name = cmd[0]
resolved = shutil.which(exe_name)
if resolved and resolved.lower().endswith((".cmd", ".bat")):
cmd = ["cmd", "/c", exe_name, *cmd[1:]]
return subprocess.run(cmd, capture_output=True, text=True)
def synthesize(
text: str,
voice_id: int | str,
out_path: Path,
format: str = "mp3",
speed: Optional[float] = None,
volume: Optional[float] = None,
pitch: Optional[float] = None,
run: Optional[Callable[[list[str]], subprocess.CompletedProcess]] = None,
) -> Path:
"""Call mmx to synthesize one text blob. Returns out_path on success.
Raises MmxError on non-zero exit. `run` is injectable for tests; when
None, the module-level `_default_run` is resolved at call time so tests
can monkeypatch it.
"""
if run is None:
run = _default_run
cmd = _build_cmd(
text=text,
voice_id=voice_id,
out_path=out_path,
format=format,
speed=speed,
volume=volume,
pitch=pitch,
)
out_path.parent.mkdir(parents=True, exist_ok=True)
result = run(cmd)
if result.returncode != 0:
raise MmxError(
exit_code=result.returncode,
stderr=result.stderr or "",
command=" ".join(cmd),
)
if not out_path.exists():
# mmx sometimes writes hex-decoded content; treat as failure if file absent
raise MmxError(
exit_code=1,
stderr=f"mmx exited 0 but {out_path} was not created",
command=" ".join(cmd),
)
return out_path

View File

@ -0,0 +1,34 @@
import json
import pytest
def pytest_addoption(parser):
parser.addoption("--run-smoke", action="store_true", default=False,
help="Run real-mmx smoke tests (consumes quota)")
@pytest.fixture
def tmp_voices_json(tmp_path):
"""Return a path to a non-existent voices.json inside tmp_path."""
return tmp_path / "voices.json"
@pytest.fixture
def populated_voices_json(tmp_path):
"""Return a path to a voices.json pre-populated with one entry."""
p = tmp_path / "voices.json"
p.write_text(
json.dumps(
{
"voices": {
"wantsong": {
"file_id": 396061597295017,
"filename": "Wantsong人物录音.mp3",
"created_at": "2026-06-08T12:00:00Z",
}
}
}
),
encoding="utf-8",
)
return p

View File

@ -0,0 +1,300 @@
# tests/test_cli.py
import json
from pathlib import Path
from voice_gen.cli import main
def test_init_creates_skeleton(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
rc = main(["init"])
assert rc == 0
assert (tmp_path / "voices.json").exists()
assert (tmp_path / "scripts").is_dir()
assert (tmp_path / "output").is_dir()
# voices.json is empty registry
import json
data = json.loads((tmp_path / "voices.json").read_text(encoding="utf-8"))
assert data == {"voices": {}}
def test_init_is_idempotent(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
assert main(["init"]) == 0
(tmp_path / "voices.json").write_text('{"voices": {"x": {}}}', encoding="utf-8")
assert main(["init"]) == 0
# existing file should NOT be overwritten
assert (tmp_path / "voices.json").read_text(encoding="utf-8") == '{"voices": {"x": {}}}'
def test_no_args_prints_help_and_returns_2(capsys):
rc = main([])
assert rc == 2 # argparse default for missing required
captured = capsys.readouterr()
assert "usage:" in captured.err.lower()
def test_voices_add_uploads_and_registers(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
# bootstrap registry
assert main(["init"]) == 0
# fake audio file
audio = tmp_path / "anchor.mp3"
audio.write_bytes(b"fake")
# monkeypatch upload + clone (both hit external API)
from voice_gen import cli
calls = {"upload": None, "clone": None}
def fake_upload(path, purpose="voice_clone"):
calls["upload"] = {"path": path, "purpose": purpose}
return {"file_id": 555, "filename": path.name}
def fake_clone(file_id, voice_id):
calls["clone"] = {"file_id": file_id, "voice_id": voice_id}
monkeypatch.setattr(cli, "_upload_voice_file", fake_upload)
monkeypatch.setattr(cli, "_clone_voice_file", fake_clone)
rc = main(["voices", "add", str(audio)])
assert rc == 0
assert calls["upload"]["path"] == audio
assert calls["upload"]["purpose"] == "voice_clone"
# friendly name derived from filename stem (audio.stem == "anchor")
assert calls["clone"] == {"file_id": 555, "voice_id": "anchor"}
data = json.loads((tmp_path / "voices.json").read_text(encoding="utf-8"))
assert "anchor" in data["voices"]
assert data["voices"]["anchor"]["file_id"] == 555
assert data["voices"]["anchor"]["filename"] == "anchor.mp3"
# notes field removed; only file_id, filename, created_at remain
assert set(data["voices"]["anchor"].keys()) == {"file_id", "filename", "created_at"}
def test_voices_add_rejects_missing_audio(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
assert main(["init"]) == 0
rc = main(["voices", "add", str(tmp_path / "nope.mp3")])
assert rc == 2 # argparse usage error
def test_voices_cleanup_dedupes_by_filename(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
assert main(["init"]) == 0
from voice_gen import cli
deleted_ids = []
def fake_delete(file_id):
deleted_ids.append(file_id)
return True
monkeypatch.setattr(cli, "_delete_remote_file", fake_delete)
import json
reg_path = tmp_path / "voices.json"
reg_path.write_text(json.dumps({
"voices": {
"dup1": {"file_id": 1, "filename": "same.mp3", "created_at": "2026-06-01T00:00:00Z"},
"dup2": {"file_id": 2, "filename": "same.mp3", "created_at": "2026-06-02T00:00:00Z"},
"uniq": {"file_id": 3, "filename": "other.mp3", "created_at": "2026-06-03T00:00:00Z"},
}
}), encoding="utf-8")
rc = main(["voices", "cleanup"])
assert rc == 0
# The earlier-created one (lower file_id 1) should be kept; 2 deleted
assert 2 in deleted_ids
assert 1 not in deleted_ids
data = json.loads(reg_path.read_text(encoding="utf-8"))
assert "dup1" in data["voices"]
assert "dup2" not in data["voices"]
assert "uniq" in data["voices"]
def test_gen_batch_end_to_end(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
assert main(["init"]) == 0
# scripts/
scripts = tmp_path / "scripts"
scripts.mkdir(exist_ok=True)
(scripts / "intro.md").write_text("# Title\n\nFirst narration.", encoding="utf-8")
(scripts / "outro.md").write_text("Closing words.", encoding="utf-8")
# seed registry
import json
from datetime import datetime, timezone
(tmp_path / "voices.json").write_text(json.dumps({
"voices": {
"anchor": {
"file_id": 42, "filename": "a.mp3",
"created_at": datetime.now(timezone.utc).isoformat(),
}
}
}), encoding="utf-8")
# fake mmx run
from voice_gen import synthesizer
captured = []
def fake_run(cmd, **kwargs):
captured.append(cmd)
out = Path(cmd[cmd.index("--out") + 1])
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(b"audio")
return type("R", (), {"returncode": 0, "stderr": ""})()
monkeypatch.setattr(synthesizer, "_default_run", fake_run)
rc = main(["gen", "--voice", "anchor", "scripts"])
assert rc == 0
out_dir = tmp_path / "output"
assert (out_dir / "intro.mp3").exists()
assert (out_dir / "outro.mp3").exists()
assert len(captured) == 2
# voice_id passed to mmx is the friendly name (dict key), NOT the file_id.
# The file_id in the registry entry is just for tracking the source audio;
# after voice_clone, the API uses the friendly name as the voice_id.
for cmd in captured:
i = cmd.index("--voice")
assert cmd[i + 1] == "anchor"
def test_voices_add_rejects_duplicate_local_name(tmp_path, monkeypatch):
"""Re-adding a file whose stem matches an existing local entry errors out."""
monkeypatch.chdir(tmp_path)
assert main(["init"]) == 0
from voice_gen import cli
def fake_upload(path, purpose="voice_clone"):
return {"file_id": 999, "filename": path.name}
def fake_clone(file_id, voice_id):
pass
monkeypatch.setattr(cli, "_upload_voice_file", fake_upload)
monkeypatch.setattr(cli, "_clone_voice_file", fake_clone)
audio = tmp_path / "anchor.mp3"
audio.write_bytes(b"fake")
assert main(["voices", "add", str(audio)]) == 0 # first add: ok
assert main(["voices", "add", str(audio)]) == 1 # second add: collision
def test_voices_derive_name_replaces_spaces():
from voice_gen.cli import _derive_voice_name
from pathlib import Path
assert _derive_voice_name(Path("My Audio.mp3")) == "My_Audio"
assert _derive_voice_name(Path("BroTsong-Saying-2026-06-10.m4a")) == "BroTsong-Saying-2026-06-10"
assert _derive_voice_name(Path("录音.wav")) == "录音"
def test_voices_pull_adds_missing(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
assert main(["init"]) == 0
from voice_gen import synthesizer
server_payload = json.dumps({
"files": [
{"file_id": 111, "filename": "alice-2026-01-01.mp3", "bytes": 100, "created_at": 1},
{"file_id": 222, "filename": "bob-2026-01-15.mp3", "bytes": 200, "created_at": 2},
]
})
def fake_run(cmd, **kwargs):
return type("R", (), {"returncode": 0, "stderr": "", "stdout": server_payload})()
monkeypatch.setattr(synthesizer, "_run_mmx", fake_run)
rc = main(["voices", "pull", "--auto"])
assert rc == 0
data = json.loads((tmp_path / "voices.json").read_text(encoding="utf-8"))
assert set(data["voices"].keys()) == {"alice-2026-01-01", "bob-2026-01-15"}
assert data["voices"]["alice-2026-01-01"]["file_id"] == 111
assert data["voices"]["bob-2026-01-15"]["file_id"] == 222
def test_voices_pull_skips_existing_without_force(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
assert main(["init"]) == 0
# Seed registry with one entry
(tmp_path / "voices.json").write_text(json.dumps({
"voices": {
"alice-2026-01-01": {
"file_id": 999, "filename": "different-file.mp3",
"created_at": "2026-01-01T00:00:00Z",
}
}
}), encoding="utf-8")
from voice_gen import synthesizer
server_payload = json.dumps({
"files": [
{"file_id": 111, "filename": "alice-2026-01-01.mp3", "bytes": 100, "created_at": 1},
]
})
def fake_run(cmd, **kwargs):
return type("R", (), {"returncode": 0, "stderr": "", "stdout": server_payload})()
monkeypatch.setattr(synthesizer, "_run_mmx", fake_run)
rc = main(["voices", "pull", "--auto"])
assert rc == 0
data = json.loads((tmp_path / "voices.json").read_text(encoding="utf-8"))
# Original file_id preserved (no overwrite)
assert data["voices"]["alice-2026-01-01"]["file_id"] == 999
def test_voices_pull_dry_run(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
assert main(["init"]) == 0
from voice_gen import synthesizer
server_payload = json.dumps({
"files": [
{"file_id": 111, "filename": "alice.mp3", "bytes": 100, "created_at": 1},
]
})
def fake_run(cmd, **kwargs):
return type("R", (), {"returncode": 0, "stderr": "", "stdout": server_payload})()
monkeypatch.setattr(synthesizer, "_run_mmx", fake_run)
rc = main(["voices", "pull", "--auto", "--dry-run"])
assert rc == 0
# voices.json should NOT have been written
data = json.loads((tmp_path / "voices.json").read_text(encoding="utf-8"))
assert data["voices"] == {}
def test_voices_list_server(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
assert main(["init"]) == 0
from voice_gen import synthesizer
server_payload = json.dumps({
"files": [
{"file_id": 111, "filename": "alice.mp3", "bytes": 100, "created_at": 1},
]
})
def fake_run(cmd, **kwargs):
return type("R", (), {"returncode": 0, "stderr": "", "stdout": server_payload})()
monkeypatch.setattr(synthesizer, "_run_mmx", fake_run)
rc = main(["voices", "list", "server"])
assert rc == 0
# (output captured by capsys would normally be checked; just verify rc=0 here)

View File

@ -0,0 +1,22 @@
from voice_gen.errors import VoiceGenError, VoiceNotFoundError, MmxError
def test_voice_gen_error_is_base_exception():
err = VoiceGenError("boom")
assert isinstance(err, Exception)
assert str(err) == "boom"
def test_voice_not_found_inherits_base():
err = VoiceNotFoundError("wantsong")
assert isinstance(err, VoiceGenError)
assert "wantsong" in str(err)
def test_mmx_error_carries_exit_code_and_stderr():
err = MmxError(exit_code=3, stderr="auth failed")
assert isinstance(err, VoiceGenError)
assert err.exit_code == 3
assert err.stderr == "auth failed"
assert "exit=3" in str(err)
assert "auth failed" in str(err)

View File

@ -0,0 +1,100 @@
# tests/test_extractor.py
import pytest
from voice_gen.extractor import extract_text
def write_md(tmp_path, body: str):
p = tmp_path / "script.md"
p.write_text(body, encoding="utf-8")
return p
def test_plain_paragraph(tmp_path):
p = write_md(tmp_path, "Hello world.")
assert extract_text(p) == "Hello world."
def test_strips_yaml_frontmatter(tmp_path):
body = (
"---\n"
"title: hello\n"
"author: me\n"
"---\n"
"\n"
"Actual narration starts here.\n"
)
p = write_md(tmp_path, body)
assert extract_text(p) == "Actual narration starts here."
def test_strips_atx_headings(tmp_path):
body = "# Title\n\nFirst paragraph.\n\n## Sub\n\nSecond paragraph.\n"
p = write_md(tmp_path, body)
out = extract_text(p)
assert "Title" not in out
assert "Sub" not in out
assert "First paragraph." in out
assert "Second paragraph." in out
def test_strips_bold_italic_inline(tmp_path):
body = "This is **bold** and *italic* text."
p = write_md(tmp_path, body)
assert extract_text(p) == "This is bold and italic text."
def test_strips_unordered_list_markers(tmp_path):
body = "- First item\n- Second item\n- Third item\n"
p = write_md(tmp_path, body)
out = extract_text(p)
assert "First item" in out
assert "Second item" in out
assert "Third item" in out
assert "-" not in out.split("\n")[0]
def test_drops_fenced_code_blocks(tmp_path):
body = (
"Intro line.\n"
"\n"
"```python\n"
"print('do not read')\n"
"```\n"
"\n"
"Outro line.\n"
)
p = write_md(tmp_path, body)
out = extract_text(p)
assert "print" not in out
assert "do not read" not in out
assert "Intro line." in out
assert "Outro line." in out
def test_collapses_blank_lines(tmp_path):
body = "Para A.\n\n\n\nPara B.\n"
p = write_md(tmp_path, body)
out = extract_text(p)
assert out == "Para A.\nPara B."
def test_missing_file_raises(tmp_path):
with pytest.raises(FileNotFoundError):
extract_text(tmp_path / "nope.md")
def test_reads_utf16_le_with_bom(tmp_path):
"""PowerShell on Windows writes `>` redirection as UTF-16 LE WITH BOM.
The extractor must accept that, not just UTF-8.
"""
p = tmp_path / "script.md"
p.write_bytes(b"\xff\xfe" + "Hello world.\n".encode("utf-16-le"))
assert extract_text(p) == "Hello world."
def test_reads_utf8_bom_file(tmp_path):
"""Some Windows editors (Notepad, older VS Code) save UTF-8 with a BOM."""
p = tmp_path / "script.md"
p.write_bytes(b"\xef\xbb\xbfHello world.")
assert extract_text(p) == "Hello world."

View File

@ -0,0 +1,76 @@
import json
import pytest
from voice_gen.registry import VoiceRegistry, VoiceEntry
from voice_gen.errors import VoiceNotFoundError
def test_load_missing_file_returns_empty_registry(tmp_voices_json):
reg = VoiceRegistry.load(tmp_voices_json)
assert reg.path == tmp_voices_json
assert reg.list_names() == []
def test_load_populated_file(populated_voices_json):
reg = VoiceRegistry.load(populated_voices_json)
assert reg.list_names() == ["wantsong"]
def test_get_returns_entry(populated_voices_json):
reg = VoiceRegistry.load(populated_voices_json)
entry = reg.get("wantsong")
assert entry.file_id == 396061597295017
assert entry.filename == "Wantsong人物录音.mp3"
assert entry.created_at == "2026-06-08T12:00:00Z"
def test_get_unknown_raises(populated_voices_json):
reg = VoiceRegistry.load(populated_voices_json)
with pytest.raises(VoiceNotFoundError) as exc:
reg.get("nope")
assert "nope" in str(exc.value)
def test_add_appends_and_persists(tmp_voices_json):
reg = VoiceRegistry.load(tmp_voices_json)
entry = VoiceEntry(
file_id=123,
filename="sample.mp3",
created_at="2026-06-09T00:00:00Z",
)
reg.add("alice", entry)
assert reg.has("alice")
assert reg.get("alice") is entry
reg.save()
reloaded = VoiceRegistry.load(tmp_voices_json)
assert reloaded.has("alice")
assert reloaded.get("alice").file_id == 123
def test_add_duplicate_raises(tmp_voices_json):
reg = VoiceRegistry.load(tmp_voices_json)
entry = VoiceEntry(
file_id=1, filename="a.mp3", created_at="2026-06-09T00:00:00Z"
)
reg.add("dup", entry)
with pytest.raises(ValueError, match="already exists"):
reg.add("dup", entry)
def test_remove_existing(tmp_voices_json):
reg = VoiceRegistry.load(tmp_voices_json)
reg.add("bob", VoiceEntry(file_id=2, filename="b.mp3", created_at="2026-06-09T00:00:00Z"))
reg.save()
reg2 = VoiceRegistry.load(tmp_voices_json)
reg2.remove("bob")
reg2.save()
reg3 = VoiceRegistry.load(tmp_voices_json)
assert not reg3.has("bob")
def test_remove_missing_raises(tmp_voices_json):
reg = VoiceRegistry.load(tmp_voices_json)
with pytest.raises(VoiceNotFoundError):
reg.remove("ghost")

View File

@ -0,0 +1,41 @@
# tests/test_smoke.py
"""Optional end-to-end test that calls real mmx. Skipped by default.
Run with: pytest --run-smoke -v
"""
import os
import shutil
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
def mmx_available() -> bool:
return shutil.which("mmx") is not None
pytestmark = pytest.mark.skipif(
not mmx_available(),
reason="mmx CLI not on PATH",
)
@pytest.mark.skipif(
"--run-smoke" not in os.sys.argv,
reason="pass --run-smoke to execute real mmx calls",
)
def test_smoke_synthesize_with_default_voice(tmp_path):
from voice_gen.synthesizer import _default_run
out = tmp_path / "smoke.mp3"
cmd = [
"mmx", "speech", "synthesize",
"--non-interactive", "--quiet",
"--text", "Smoke test.",
"--out", str(out),
]
result = _default_run(cmd)
assert result.returncode == 0, result.stderr
assert out.exists()
assert out.stat().st_size > 100

View File

@ -0,0 +1,61 @@
# tests/test_synthesizer.py
import json
import pytest
from pathlib import Path
from voice_gen.synthesizer import synthesize, _build_cmd
from voice_gen.errors import MmxError
def test_build_cmd_minimum():
cmd = _build_cmd(
text="hi",
voice_id=123,
out_path=Path("out.mp3"),
format="mp3",
)
assert cmd[:5] == ["mmx", "speech", "synthesize", "--non-interactive", "--quiet"]
assert "--text" in cmd and "hi" in cmd
assert "--voice" in cmd and "123" in cmd
assert "--out" in cmd and "out.mp3" in cmd
assert "--format" in cmd and "mp3" in cmd
def test_synthesize_invokes_mmx_and_returns_path(tmp_path):
captured = {}
def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
captured["kwargs"] = kwargs
out = Path(cmd[cmd.index("--out") + 1])
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(b"fake-audio")
return type("R", (), {"returncode": 0, "stderr": ""})()
out = synthesize(
text="hello",
voice_id=999,
out_path=tmp_path / "x.mp3",
format="mp3",
run=fake_run,
)
assert out == tmp_path / "x.mp3"
assert out.exists()
assert captured["cmd"][2] == "synthesize"
assert "--voice" in captured["cmd"]
assert captured["cmd"][captured["cmd"].index("--voice") + 1] == "999"
def test_synthesize_raises_mmxerror_on_nonzero(tmp_path):
def fake_run(cmd, **kwargs):
return type("R", (), {"returncode": 3, "stderr": "auth failed"})()
with pytest.raises(MmxError) as exc:
synthesize(
text="x",
voice_id=1,
out_path=tmp_path / "x.mp3",
format="mp3",
run=fake_run,
)
assert exc.value.exit_code == 3
assert "auth failed" in str(exc.value)