feat: migrate voice-generation skill
This commit is contained in:
parent
deed276fa1
commit
c3c56b9a74
|
|
@ -4,3 +4,4 @@ channels:
|
|||
dependencies:
|
||||
- python=3.11
|
||||
- pip
|
||||
- pytest>=7.0
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1 @@
|
|||
__version__ = "0.1.0"
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# src/voice_gen/__main__.py
|
||||
import sys
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -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())
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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."
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue