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-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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
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"})()
|
||||
|
|
|
|||
Loading…
Reference in New Issue