feat: migrate voice-generation skill
This commit is contained in:
parent
deed276fa1
commit
c3c56b9a74
|
|
@ -4,3 +4,4 @@ channels:
|
||||||
dependencies:
|
dependencies:
|
||||||
- python=3.11
|
- python=3.11
|
||||||
- pip
|
- 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 |
|
| 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 |
|
| `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
|
## 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