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

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

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`: 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.

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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(): 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"})()