fix: harden voice generation on Windows
This commit is contained in:
parent
15ce3c0834
commit
ba685a2d9d
|
|
@ -28,6 +28,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill regr
|
||||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-bundle-audit
|
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-bundle-audit
|
||||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-context-builder
|
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-context-builder
|
||||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill routing-behavior-diff-audit
|
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill routing-behavior-diff-audit
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voice-generation
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `-Force` to back up and replace an existing installed copy:
|
Use `-Force` to back up and replace an existing installed copy:
|
||||||
|
|
@ -40,6 +41,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill regr
|
||||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-bundle-audit -Force
|
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-bundle-audit -Force
|
||||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-context-builder -Force
|
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-context-builder -Force
|
||||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill routing-behavior-diff-audit -Force
|
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill routing-behavior-diff-audit -Force
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voice-generation -Force
|
||||||
```
|
```
|
||||||
|
|
||||||
`-All` installs only registry rows with an installable status and `default` install policy. Optional Skills must be named explicitly.
|
`-All` installs only registry rows with an installable status and `default` install policy. Optional Skills must be named explicitly.
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ Existing CCPE content is not migrated here.
|
||||||
| `review-bundle-audit` | Preflight review, handoff, release, CCRA, Web upload, or code-review bundle directories for required files, manifests, sidecars, reports, zip readability, warnings, and blockers. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\review-bundle-audit` | `installed` | `default` | `C:\Users\wangq\.agents\skills\review-bundle-audit` | none |
|
| `review-bundle-audit` | Preflight review, handoff, release, CCRA, Web upload, or code-review bundle directories for required files, manifests, sidecars, reports, zip readability, warnings, and blockers. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\review-bundle-audit` | `installed` | `default` | `C:\Users\wangq\.agents\skills\review-bundle-audit` | none |
|
||||||
| `review-context-builder` | Build file-first review, audit, planning, release, handoff, PR, or Agent invocation context indexes and manifests from configured local source roots, patterns, and metadata. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\review-context-builder` | `installed` | `default` | `C:\Users\wangq\.agents\skills\review-context-builder` | none |
|
| `review-context-builder` | Build file-first review, audit, planning, release, handoff, PR, or Agent invocation context indexes and manifests from configured local source roots, patterns, and metadata. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\review-context-builder` | `installed` | `default` | `C:\Users\wangq\.agents\skills\review-context-builder` | none |
|
||||||
| `routing-behavior-diff-audit` | Compare before/after selector, classifier, or routing outputs for targeted changes, collateral non-target changes, no-call changes, missing cases, and expected-route mismatch changes. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\routing-behavior-diff-audit` | `installed` | `default` | `C:\Users\wangq\.agents\skills\routing-behavior-diff-audit` | none |
|
| `routing-behavior-diff-audit` | Compare before/after selector, classifier, or routing outputs for targeted changes, collateral non-target changes, no-call changes, missing cases, and expected-route mismatch changes. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\routing-behavior-diff-audit` | `installed` | `default` | `C:\Users\wangq\.agents\skills\routing-behavior-diff-audit` | 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 |
|
| `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. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\voice-generation` | `installed` | `conditional` | `C:\Users\wangq\.agents\skills\voice-generation` | none |
|
||||||
|
|
||||||
## Status Values
|
## Status Values
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,9 @@ C:\Users\wangq\.conda\envs\skills-vault\python.exe -m pip install -e .\skills\vo
|
||||||
Install and authenticate MiniMax `mmx`:
|
Install and authenticate MiniMax `mmx`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm install -g mmx-cli
|
npm.cmd install -g mmx-cli
|
||||||
mmx auth login
|
cmd /c mmx auth login
|
||||||
|
cmd /c mmx auth status
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install Skill Wrapper
|
## Install Skill Wrapper
|
||||||
|
|
@ -60,18 +61,27 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voic
|
||||||
|
|
||||||
Testing is intentionally separate from migration.
|
Testing is intentionally separate from migration.
|
||||||
|
|
||||||
Run unit tests only after installation is ready:
|
Run source tests from the repository root. Disabling pytest's cache provider avoids writes under a read-only installed Skill directory:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
New-Item -ItemType Directory -Force -Path .\tmp | Out-Null
|
New-Item -ItemType Directory -Force -Path .\tmp | Out-Null
|
||||||
python -m pytest .\skills\voice-generation\tests --basetemp .\tmp\pytest-voice-generation
|
conda run -n skills-vault python -B -m pytest .\skills\voice-generation\tests -p no:cacheprovider --basetemp .\tmp\pytest-voice-generation
|
||||||
```
|
```
|
||||||
|
|
||||||
Or without activating the environment:
|
Validate the Skill structure and installed copy separately:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
New-Item -ItemType Directory -Force -Path .\tmp | Out-Null
|
conda run -n skills-vault python -B .\scripts\quick_validate.py .\skills\voice-generation
|
||||||
C:\Users\wangq\.conda\envs\skills-vault\python.exe -m pytest .\skills\voice-generation\tests --basetemp .\tmp\pytest-voice-generation
|
conda run -n skills-vault python -B .\scripts\quick_validate.py C:\Users\wangq\.agents\skills\voice-generation
|
||||||
|
C:\Users\wangq\.conda\envs\skills-vault\python.exe -B -m pytest C:\Users\wangq\.agents\skills\voice-generation\tests -p no:cacheprovider --basetemp .\tmp\pytest-voice-generation-installed
|
||||||
```
|
```
|
||||||
|
|
||||||
Smoke tests may call real MiniMax services and consume quota. Do not run them unless explicitly requested.
|
Smoke tests may call real MiniMax services and consume quota. Do not run them unless explicitly requested.
|
||||||
|
|
||||||
|
For a real long-form test, create an isolated project under the repository `tmp` directory, populate `voices.json` and `scripts\`, then run from that project root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
conda run -n skills-vault voice-gen gen --voice <voice-name> scripts --out output --format mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
The command sends the extracted narration text to MiniMax. Confirm that external transmission is intended before running it.
|
||||||
|
|
|
||||||
|
|
@ -69,3 +69,25 @@ conda environment: shared skills-vault
|
||||||
editable package installed in shared env: yes
|
editable package installed in shared env: yes
|
||||||
old dedicated conda env removed: C:\Users\wangq\miniconda3\envs\voice-gen
|
old dedicated conda env removed: C:\Users\wangq\miniconda3\envs\voice-gen
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Post-Migration Windows Hardening
|
||||||
|
|
||||||
|
Validated on 2026-07-10:
|
||||||
|
|
||||||
|
- Resolve the npm `mmx.CMD` shim and execute it through `cmd /c` on Windows.
|
||||||
|
- Pass narration through a temporary UTF-8 file with `--text-file`; do not pass long text through `--text` or use mmx-cli's Windows-incompatible `--text-file -` path.
|
||||||
|
- Remove the temporary text file after success or failure.
|
||||||
|
- Escape non-ASCII path characters in CLI status lines as `\uXXXX` so `conda run` remains safe on GBK consoles; generated files retain their Unicode names.
|
||||||
|
- Force repository tests to import the source tree instead of an older `.agents` installation.
|
||||||
|
|
||||||
|
Verification evidence:
|
||||||
|
|
||||||
|
```text
|
||||||
|
source tests: 40 passed, 1 skipped
|
||||||
|
installed-copy tests: 40 passed, 1 skipped
|
||||||
|
real long-form voice: BroTsong-2026-06-10
|
||||||
|
real input: 2602 extracted characters / 7030 UTF-8 bytes
|
||||||
|
real output: tmp\voice-generation-e2e-20260710-01\output\01.从想法到作品口播文案.mp3
|
||||||
|
audio: 505.152 seconds, 32000 Hz, 128000 bps, 14032 MPEG frames
|
||||||
|
temporary text files after completion: 0
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voic
|
||||||
External requirements:
|
External requirements:
|
||||||
|
|
||||||
- Conda
|
- Conda
|
||||||
- `mmx` CLI v1.0.16+ (`npm install -g mmx-cli`)
|
- `mmx` CLI v1.0.16+ (`npm.cmd install -g mmx-cli` on Windows)
|
||||||
- `mmx auth login` once
|
- `cmd /c mmx auth login` once on Windows
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|
@ -70,7 +70,9 @@ The CLI wraps MiniMax voice operations:
|
||||||
- Uploaded files are registered through the MiniMax voice clone API.
|
- Uploaded files are registered through the MiniMax voice clone API.
|
||||||
- Synthesis uses `mmx speech synthesize`.
|
- Synthesis uses `mmx speech synthesize`.
|
||||||
|
|
||||||
On Windows, `mmx` is a `.cmd` shim from npm, so subprocess calls are routed through `cmd /c` where needed.
|
On Windows, `mmx` is a `.cmd` shim from npm, so subprocess calls are routed through `cmd /c` where needed. Synthesis text is written to a short-lived UTF-8 file next to the requested output and passed with `--text-file`; the file is removed after each call. This avoids Windows command-line length and quoting limits. It also avoids mmx-cli's current `--text-file -` stdin implementation, which resolves `/dev/stdin` as a drive-local path on Windows.
|
||||||
|
|
||||||
|
CLI status lines escape non-ASCII path characters as ASCII `\uXXXX` sequences so `conda run` remains safe on GBK Windows consoles. The files themselves retain their original Unicode names.
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
|
|
@ -78,7 +80,6 @@ On Windows, `mmx` is a `.cmd` shim from npm, so subprocess calls are routed thro
|
||||||
skills/voice-generation/
|
skills/voice-generation/
|
||||||
SKILL.md
|
SKILL.md
|
||||||
INSTALL.md
|
INSTALL.md
|
||||||
environment.yml
|
|
||||||
pyproject.toml
|
pyproject.toml
|
||||||
src/voice_gen/
|
src/voice_gen/
|
||||||
tests/
|
tests/
|
||||||
|
|
@ -96,13 +97,15 @@ output/
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Inside the skills-vault Conda env
|
# From the repository root
|
||||||
python -m pytest
|
conda run -n skills-vault python -B -m pytest skills/voice-generation/tests -p no:cacheprovider --basetemp tmp/pytest-voice-generation
|
||||||
|
|
||||||
# Smoke tests may call real MiniMax services and consume quota.
|
# Smoke tests may call real MiniMax services and consume quota.
|
||||||
python -m pytest --run-smoke
|
conda run -n skills-vault python -B -m pytest skills/voice-generation/tests --run-smoke -p no:cacheprovider --basetemp tmp/pytest-voice-generation-smoke
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The test configuration forces imports from this repository's `src` tree so a separately installed `.agents/skills` copy cannot make source tests pass accidentally.
|
||||||
|
|
||||||
## As An Agentic Skill
|
## As An Agentic Skill
|
||||||
|
|
||||||
The root `SKILL.md` exposes this tool to Agentic systems. The public installed Skill path is:
|
The root `SKILL.md` exposes this tool to Agentic systems. The public installed Skill path is:
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ This Skill operates the local `voice-gen` Python CLI. The CLI wraps MiniMax `mmx
|
||||||
|
|
||||||
- Activate the configured Conda environment before running the CLI.
|
- 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 `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`.
|
- Ensure the `mmx` CLI is installed and authenticated. On Windows, verify with `cmd /c mmx auth status` to avoid PowerShell shim policy issues.
|
||||||
- Run CLI commands from the user's project root unless the user specifies another working directory.
|
- Run CLI commands from the user's project root unless the user specifies another working directory.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
@ -61,6 +61,8 @@ Use `voice-gen voices pull --force` only when overwriting existing local entries
|
||||||
|
|
||||||
- Input scripts are Markdown files matching `*.md`.
|
- Input scripts are Markdown files matching `*.md`.
|
||||||
- Frontmatter, headings, code fences, list markers, bold, italic, and inline code markup are stripped before synthesis.
|
- Frontmatter, headings, code fences, list markers, bold, italic, and inline code markup are stripped before synthesis.
|
||||||
|
- Synthesis text is passed to mmx through a temporary UTF-8 file, not a command-line `--text` value or `--text-file -`; the temporary file is removed after the call.
|
||||||
|
- Status output escapes non-ASCII path characters as `\uXXXX` sequences for Windows console safety; generated files retain their original Unicode names.
|
||||||
- Output files are named `<script-stem>.<format>`.
|
- Output files are named `<script-stem>.<format>`.
|
||||||
- The local voice registry is `voices.json` in the current working directory.
|
- The local voice registry is `voices.json` in the current working directory.
|
||||||
|
|
||||||
|
|
@ -68,7 +70,7 @@ Use `voice-gen voices pull --force` only when overwriting existing local entries
|
||||||
|
|
||||||
- If `voice-gen voices list` is empty, ask the user to register or pull voices first.
|
- 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 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 `mmx` authentication fails on Windows, ask the user to run `cmd /c mmx auth status` and `cmd /c mmx auth login`.
|
||||||
- If quota fails, surface the quota error and suggest waiting for the next quota window or upgrading.
|
- 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.
|
- If the request is for music, sound effects, video, image, or non-MiniMax TTS, do not use this Skill.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ _MMX_CONFIG_PATH = Path.home() / ".mmx" / "config.json"
|
||||||
_oauth_token_cache: str | None = None
|
_oauth_token_cache: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _ascii_safe(value: object) -> str:
|
||||||
|
"""Return deterministic ASCII text for status output on Windows consoles."""
|
||||||
|
return str(value).encode("ascii", errors="backslashreplace").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
def _load_oauth_token() -> str:
|
def _load_oauth_token() -> str:
|
||||||
"""Load the OAuth access token from ~/.mmx/config.json (cached)."""
|
"""Load the OAuth access token from ~/.mmx/config.json (cached)."""
|
||||||
global _oauth_token_cache
|
global _oauth_token_cache
|
||||||
|
|
@ -441,10 +446,13 @@ def cmd_gen(args: argparse.Namespace) -> int:
|
||||||
out_path=out_path,
|
out_path=out_path,
|
||||||
format=args.format,
|
format=args.format,
|
||||||
)
|
)
|
||||||
print(f"OK {md.name} -> {out_path}")
|
print(f"OK {_ascii_safe(md.name)} -> {_ascii_safe(out_path)}")
|
||||||
successes += 1
|
successes += 1
|
||||||
except Exception as e: # noqa: BLE001 - log and continue
|
except Exception as e: # noqa: BLE001 - log and continue
|
||||||
print(f"ERROR {md.name}: {e}", file=sys.stderr)
|
print(
|
||||||
|
f"ERROR {_ascii_safe(md.name)}: {_ascii_safe(e)}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
failures += 1
|
failures += 1
|
||||||
print(f"done: {successes} ok, {failures} failed")
|
print(f"done: {successes} ok, {failures} failed")
|
||||||
return 0 if failures == 0 else 1
|
return 0 if failures == 0 else 1
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
|
@ -11,7 +12,7 @@ from .errors import MmxError
|
||||||
|
|
||||||
|
|
||||||
def _build_cmd(
|
def _build_cmd(
|
||||||
text: str,
|
text_file: Path,
|
||||||
voice_id: int | str,
|
voice_id: int | str,
|
||||||
out_path: Path,
|
out_path: Path,
|
||||||
format: str = "mp3",
|
format: str = "mp3",
|
||||||
|
|
@ -25,7 +26,7 @@ def _build_cmd(
|
||||||
"synthesize",
|
"synthesize",
|
||||||
"--non-interactive",
|
"--non-interactive",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
"--text", text,
|
"--text-file", str(text_file),
|
||||||
"--voice", str(voice_id),
|
"--voice", str(voice_id),
|
||||||
"--format", format,
|
"--format", format,
|
||||||
"--out", str(out_path),
|
"--out", str(out_path),
|
||||||
|
|
@ -79,8 +80,22 @@ def synthesize(
|
||||||
"""
|
"""
|
||||||
if run is None:
|
if run is None:
|
||||||
run = _default_run
|
run = _default_run
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="\n",
|
||||||
|
suffix=".txt",
|
||||||
|
prefix=".voice-gen-",
|
||||||
|
dir=out_path.parent,
|
||||||
|
delete=False,
|
||||||
|
) as text_handle:
|
||||||
|
text_handle.write(text)
|
||||||
|
text_path = Path(text_handle.name)
|
||||||
|
|
||||||
|
try:
|
||||||
cmd = _build_cmd(
|
cmd = _build_cmd(
|
||||||
text=text,
|
text_file=text_path,
|
||||||
voice_id=voice_id,
|
voice_id=voice_id,
|
||||||
out_path=out_path,
|
out_path=out_path,
|
||||||
format=format,
|
format=format,
|
||||||
|
|
@ -88,7 +103,6 @@ def synthesize(
|
||||||
volume=volume,
|
volume=volume,
|
||||||
pitch=pitch,
|
pitch=pitch,
|
||||||
)
|
)
|
||||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
result = run(cmd)
|
result = run(cmd)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise MmxError(
|
raise MmxError(
|
||||||
|
|
@ -103,4 +117,6 @@ def synthesize(
|
||||||
stderr=f"mmx exited 0 but {out_path} was not created",
|
stderr=f"mmx exited 0 but {out_path} was not created",
|
||||||
command=" ".join(cmd),
|
command=" ".join(cmd),
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
text_path.unlink(missing_ok=True)
|
||||||
return out_path
|
return out_path
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE_ROOT = Path(__file__).resolve().parents[1] / "src"
|
||||||
|
sys.path.insert(0, str(SOURCE_ROOT))
|
||||||
|
|
||||||
|
|
||||||
def pytest_addoption(parser):
|
def pytest_addoption(parser):
|
||||||
parser.addoption("--run-smoke", action="store_true", default=False,
|
parser.addoption("--run-smoke", action="store_true", default=False,
|
||||||
help="Run real-mmx smoke tests (consumes quota)")
|
help="Run real-mmx smoke tests (consumes quota)")
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,41 @@ def test_gen_batch_end_to_end(tmp_path, monkeypatch):
|
||||||
assert cmd[i + 1] == "anchor"
|
assert cmd[i + 1] == "anchor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gen_status_output_is_ascii_safe(tmp_path, monkeypatch):
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
assert main(["init"]) == 0
|
||||||
|
(tmp_path / "scripts" / "中文口播.md").write_text("测试。", encoding="utf-8")
|
||||||
|
(tmp_path / "voices.json").write_text(json.dumps({
|
||||||
|
"voices": {
|
||||||
|
"anchor": {
|
||||||
|
"file_id": 42,
|
||||||
|
"filename": "a.mp3",
|
||||||
|
"created_at": "2026-07-10T00:00:00Z",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), encoding="utf-8")
|
||||||
|
|
||||||
|
from voice_gen import synthesizer
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
out = Path(cmd[cmd.index("--out") + 1])
|
||||||
|
out.write_bytes(b"audio")
|
||||||
|
return type("R", (), {"returncode": 0, "stderr": ""})()
|
||||||
|
|
||||||
|
monkeypatch.setattr(synthesizer, "_default_run", fake_run)
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
ascii_stdout = io.TextIOWrapper(buffer, encoding="ascii", errors="strict")
|
||||||
|
monkeypatch.setattr(sys, "stdout", ascii_stdout)
|
||||||
|
|
||||||
|
assert main(["gen", "--voice", "anchor", "scripts"]) == 0
|
||||||
|
ascii_stdout.flush()
|
||||||
|
output = buffer.getvalue().decode("ascii")
|
||||||
|
assert "\\u4e2d\\u6587\\u53e3\\u64ad.md" in output
|
||||||
|
|
||||||
|
|
||||||
def test_voices_add_rejects_duplicate_local_name(tmp_path, monkeypatch):
|
def test_voices_add_rejects_duplicate_local_name(tmp_path, monkeypatch):
|
||||||
"""Re-adding a file whose stem matches an existing local entry errors out."""
|
"""Re-adding a file whose stem matches an existing local entry errors out."""
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import voice_gen
|
||||||
|
|
||||||
|
|
||||||
|
def test_tests_import_repository_source():
|
||||||
|
expected = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "src"
|
||||||
|
/ "voice_gen"
|
||||||
|
/ "__init__.py"
|
||||||
|
)
|
||||||
|
assert Path(voice_gen.__file__).resolve() == expected
|
||||||
|
|
@ -8,13 +8,14 @@ from voice_gen.errors import MmxError
|
||||||
|
|
||||||
def test_build_cmd_minimum():
|
def test_build_cmd_minimum():
|
||||||
cmd = _build_cmd(
|
cmd = _build_cmd(
|
||||||
text="hi",
|
text_file=Path("input.txt"),
|
||||||
voice_id=123,
|
voice_id=123,
|
||||||
out_path=Path("out.mp3"),
|
out_path=Path("out.mp3"),
|
||||||
format="mp3",
|
format="mp3",
|
||||||
)
|
)
|
||||||
assert cmd[:5] == ["mmx", "speech", "synthesize", "--non-interactive", "--quiet"]
|
assert cmd[:5] == ["mmx", "speech", "synthesize", "--non-interactive", "--quiet"]
|
||||||
assert "--text" in cmd and "hi" in cmd
|
assert "--text-file" in cmd and "input.txt" in cmd
|
||||||
|
assert "--text" not in cmd
|
||||||
assert "--voice" in cmd and "123" in cmd
|
assert "--voice" in cmd and "123" in cmd
|
||||||
assert "--out" in cmd and "out.mp3" in cmd
|
assert "--out" in cmd and "out.mp3" in cmd
|
||||||
assert "--format" in cmd and "mp3" in cmd
|
assert "--format" in cmd and "mp3" in cmd
|
||||||
|
|
@ -45,6 +46,32 @@ def test_synthesize_invokes_mmx_and_returns_path(tmp_path):
|
||||||
assert captured["cmd"][captured["cmd"].index("--voice") + 1] == "999"
|
assert captured["cmd"][captured["cmd"].index("--voice") + 1] == "999"
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthesize_passes_long_utf8_text_via_temporary_file(tmp_path):
|
||||||
|
text = ("这是一段用于验证 Windows 长命令行安全性的中文口播稿。\n" * 500)[:9000]
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
captured["cmd"] = cmd
|
||||||
|
assert "--text" not in cmd
|
||||||
|
text_path = Path(cmd[cmd.index("--text-file") + 1])
|
||||||
|
captured["text_path"] = text_path
|
||||||
|
captured["text"] = text_path.read_text(encoding="utf-8")
|
||||||
|
out = Path(cmd[cmd.index("--out") + 1])
|
||||||
|
out.write_bytes(b"fake-audio")
|
||||||
|
return type("R", (), {"returncode": 0, "stderr": ""})()
|
||||||
|
|
||||||
|
out = synthesize(
|
||||||
|
text=text,
|
||||||
|
voice_id="test-voice",
|
||||||
|
out_path=tmp_path / "long.mp3",
|
||||||
|
run=fake_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert out.exists()
|
||||||
|
assert captured["text"] == text
|
||||||
|
assert not captured["text_path"].exists()
|
||||||
|
|
||||||
|
|
||||||
def test_synthesize_raises_mmxerror_on_nonzero(tmp_path):
|
def test_synthesize_raises_mmxerror_on_nonzero(tmp_path):
|
||||||
def fake_run(cmd, **kwargs):
|
def fake_run(cmd, **kwargs):
|
||||||
return type("R", (), {"returncode": 3, "stderr": "auth failed"})()
|
return type("R", (), {"returncode": 3, "stderr": "auth failed"})()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue