diff --git a/docs/install-sync.md b/docs/install-sync.md index 52a6df7..88633db 100644 --- a/docs/install-sync.md +++ b/docs/install-sync.md @@ -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. diff --git a/registry/skills-index.md b/registry/skills-index.md index 750c194..9d85e5c 100644 --- a/registry/skills-index.md +++ b/registry/skills-index.md @@ -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 diff --git a/skills/voice-generation/INSTALL.md b/skills/voice-generation/INSTALL.md index 769d89c..48d0cad 100644 --- a/skills/voice-generation/INSTALL.md +++ b/skills/voice-generation/INSTALL.md @@ -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 scripts --out output --format mp3 +``` + +The command sends the extracted narration text to MiniMax. Confirm that external transmission is intended before running it. diff --git a/skills/voice-generation/MIGRATION.md b/skills/voice-generation/MIGRATION.md index e7e41cd..6fc0127 100644 --- a/skills/voice-generation/MIGRATION.md +++ b/skills/voice-generation/MIGRATION.md @@ -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 +``` diff --git a/skills/voice-generation/README.md b/skills/voice-generation/README.md index e321c44..ecca538 100644 --- a/skills/voice-generation/README.md +++ b/skills/voice-generation/README.md @@ -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: diff --git a/skills/voice-generation/SKILL.md b/skills/voice-generation/SKILL.md index c405f0c..afab932 100644 --- a/skills/voice-generation/SKILL.md +++ b/skills/voice-generation/SKILL.md @@ -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 `.`. - 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. diff --git a/skills/voice-generation/src/voice_gen/cli.py b/skills/voice-generation/src/voice_gen/cli.py index be38939..fdbec0d 100644 --- a/skills/voice-generation/src/voice_gen/cli.py +++ b/skills/voice-generation/src/voice_gen/cli.py @@ -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 diff --git a/skills/voice-generation/src/voice_gen/synthesizer.py b/skills/voice-generation/src/voice_gen/synthesizer.py index 93d0005..d949329 100644 --- a/skills/voice-generation/src/voice_gen/synthesizer.py +++ b/skills/voice-generation/src/voice_gen/synthesizer.py @@ -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 diff --git a/skills/voice-generation/tests/conftest.py b/skills/voice-generation/tests/conftest.py index 500e0f9..6c72ea8 100644 --- a/skills/voice-generation/tests/conftest.py +++ b/skills/voice-generation/tests/conftest.py @@ -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)") diff --git a/skills/voice-generation/tests/test_cli.py b/skills/voice-generation/tests/test_cli.py index f116aca..a716d3f 100644 --- a/skills/voice-generation/tests/test_cli.py +++ b/skills/voice-generation/tests/test_cli.py @@ -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) diff --git a/skills/voice-generation/tests/test_source_import.py b/skills/voice-generation/tests/test_source_import.py new file mode 100644 index 0000000..68c03d1 --- /dev/null +++ b/skills/voice-generation/tests/test_source_import.py @@ -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 diff --git a/skills/voice-generation/tests/test_synthesizer.py b/skills/voice-generation/tests/test_synthesizer.py index 596582e..19382d8 100644 --- a/skills/voice-generation/tests/test_synthesizer.py +++ b/skills/voice-generation/tests/test_synthesizer.py @@ -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"})()