fix: harden voice generation on Windows

This commit is contained in:
wantsong 2026-07-10 12:07:29 +08:00
parent 15ce3c0834
commit ba685a2d9d
12 changed files with 190 additions and 45 deletions

View File

@ -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-context-builder
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:
@ -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-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 voice-generation -Force
```
`-All` installs only registry rows with an installable status and `default` install policy. Optional Skills must be named explicitly.

View File

@ -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-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 |
| `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

View File

@ -38,8 +38,9 @@ C:\Users\wangq\.conda\envs\skills-vault\python.exe -m pip install -e .\skills\vo
Install and authenticate MiniMax `mmx`:
```powershell
npm install -g mmx-cli
mmx auth login
npm.cmd install -g mmx-cli
cmd /c mmx auth login
cmd /c mmx auth status
```
## Install Skill Wrapper
@ -60,18 +61,27 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voic
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
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
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
conda run -n skills-vault python -B .\scripts\quick_validate.py .\skills\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.
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.

View File

@ -69,3 +69,25 @@ conda environment: shared skills-vault
editable package installed in shared env: yes
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
```

View File

@ -28,8 +28,8 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voic
External requirements:
- Conda
- `mmx` CLI v1.0.16+ (`npm install -g mmx-cli`)
- `mmx auth login` once
- `mmx` CLI v1.0.16+ (`npm.cmd install -g mmx-cli` on Windows)
- `cmd /c mmx auth login` once on Windows
## Quick Start
@ -70,7 +70,9 @@ The CLI wraps MiniMax voice operations:
- 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.
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
@ -78,7 +80,6 @@ On Windows, `mmx` is a `.cmd` shim from npm, so subprocess calls are routed thro
skills/voice-generation/
SKILL.md
INSTALL.md
environment.yml
pyproject.toml
src/voice_gen/
tests/
@ -96,13 +97,15 @@ output/
## Development
```bash
# Inside the skills-vault Conda env
python -m pytest
# From the repository root
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.
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
The root `SKILL.md` exposes this tool to Agentic systems. The public installed Skill path is:

View File

@ -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.
- 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.
## Workflow
@ -61,6 +61,8 @@ Use `voice-gen voices pull --force` only when overwriting existing local entries
- Input scripts are Markdown files matching `*.md`.
- 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>`.
- 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 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 the request is for music, sound effects, video, image, or non-MiniMax TTS, do not use this Skill.

View File

@ -16,6 +16,11 @@ _MMX_CONFIG_PATH = Path.home() / ".mmx" / "config.json"
_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:
"""Load the OAuth access token from ~/.mmx/config.json (cached)."""
global _oauth_token_cache
@ -441,10 +446,13 @@ def cmd_gen(args: argparse.Namespace) -> int:
out_path=out_path,
format=args.format,
)
print(f"OK {md.name} -> {out_path}")
print(f"OK {_ascii_safe(md.name)} -> {_ascii_safe(out_path)}")
successes += 1
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
print(f"done: {successes} ok, {failures} failed")
return 0 if failures == 0 else 1

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Callable, Optional
@ -11,7 +12,7 @@ from .errors import MmxError
def _build_cmd(
text: str,
text_file: Path,
voice_id: int | str,
out_path: Path,
format: str = "mp3",
@ -25,7 +26,7 @@ def _build_cmd(
"synthesize",
"--non-interactive",
"--quiet",
"--text", text,
"--text-file", str(text_file),
"--voice", str(voice_id),
"--format", format,
"--out", str(out_path),
@ -79,28 +80,43 @@ def synthesize(
"""
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),
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(
text_file=text_path,
voice_id=voice_id,
out_path=out_path,
format=format,
speed=speed,
volume=volume,
pitch=pitch,
)
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),
)
finally:
text_path.unlink(missing_ok=True)
return out_path

View File

@ -1,7 +1,14 @@
import json
import sys
from pathlib import Path
import pytest
SOURCE_ROOT = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE_ROOT))
def pytest_addoption(parser):
parser.addoption("--run-smoke", action="store_true", default=False,
help="Run real-mmx smoke tests (consumes quota)")

View File

@ -164,6 +164,41 @@ def test_gen_batch_end_to_end(tmp_path, monkeypatch):
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):
"""Re-adding a file whose stem matches an existing local entry errors out."""
monkeypatch.chdir(tmp_path)

View File

@ -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

View File

@ -8,13 +8,14 @@ from voice_gen.errors import MmxError
def test_build_cmd_minimum():
cmd = _build_cmd(
text="hi",
text_file=Path("input.txt"),
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 "--text-file" in cmd and "input.txt" in cmd
assert "--text" not 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
@ -45,6 +46,32 @@ def test_synthesize_invokes_mmx_and_returns_path(tmp_path):
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 fake_run(cmd, **kwargs):
return type("R", (), {"returncode": 3, "stderr": "auth failed"})()