Compare commits

..

2 Commits

Author SHA1 Message Date
wantsong ba685a2d9d fix: harden voice generation on Windows 2026-07-10 12:07:29 +08:00
wantsong 15ce3c0834 feat: make fix-title lightweight by default 2026-06-25 09:43:23 +08:00
17 changed files with 364 additions and 101 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

@ -1,6 +1,6 @@
---
name: fix-title
description: "Use when copied ChatGPT/LLM Markdown has semantically wrong heading hierarchy, especially discussion records with round headings and GPT reply blocks, or intake/artifact drafts with repeated accidental top-level headings. Agentic Markdown heading repair for one or more files: accept a file path array plus mode discussion or artifact, prefer subagent/thread isolation for large repairs, preserve originals, and produce fixed copies, heading plans, and reports."
description: "Use when copied ChatGPT/LLM Markdown has semantically wrong heading hierarchy, especially discussion records with round headings and GPT reply blocks, or intake/artifact drafts with repeated accidental top-level headings. Agentic Markdown heading repair for one or more files: default to lightweight in-place repair that overwrites the source Markdown without sidecar files; use audit mode only when the caller asks for preserved originals, fixed copies, heading maps, JSON plans, or reports."
---
# Fix Title
@ -16,37 +16,113 @@ Required inputs:
Optional inputs:
- `output_dir`: output folder. If omitted, use repo-local `tmp/fix-title-output` when available; otherwise use `fix-title-output` beside the first source file.
- `execution`: `auto`, `subagent`, `thread`, or `inline`. Default to `auto`.
- `output`: `quick` or `audit`. Default to `quick`.
- `execution`: `auto`, `subagent`, `thread`, or `inline`. Default to `inline` for quick single-file work and `auto` for audit or large batches.
- `output_dir`: audit output folder. Ignore in quick mode unless the caller also asks for backup or reports.
- `backup`: `never`, `always`, or `auto`. Default to `never`; rely on git unless the caller asks for backups or the file is outside version control.
Example user-facing request:
```text
Use $fix-title with mode=artifact on:
Use $fix-title with mode=artifact on C:\path\one.md
```
This defaults to quick mode and overwrites `C:\path\one.md` directly.
Audit example:
```text
Use $fix-title with mode=artifact and output=audit on:
- C:\path\one.md
- C:\path\two.md
Output to C:\path\tmp\fix-title-output
```
## Output Modes
### Quick
Use quick mode by default.
- Overwrite each source Markdown file in place.
- Do not write `heading-map.json`, `heading-plan.json`, `.fixed.md`, heading reports, or batch reports.
- Use in-memory edits and `scripts/semantic_heading_repair.py` helpers when practical.
- Return a concise summary with changed heading counts and skipped files.
- If a file is ambiguous, skip it and tell the caller why instead of creating audit artifacts by default.
Quick mode is for routine cleanup where the caller wants the document fixed, not a review package.
### Audit
Use audit mode only when the caller asks for auditability, preserved originals, fixed copies, maps, plans, reports, review evidence, batch reports, or no overwrite.
For each source file, write:
```text
<stem>.heading-map.json
<stem>.heading-plan.json
<stem>.fixed.md
<stem>.heading-report.md
```
For batch runs, also write:
```text
fix-title-batch-report.md
```
The plan JSON is the audit boundary. Every changed heading must include:
```json
{
"line": 25,
"from_level": 1,
"to_level": 3,
"reason": "This is a top-level section inside the GPT reply block under ## GPT."
}
```
The caller must verify returned files and reports in audit mode. Do not treat a subagent/thread message alone as proof of repair.
## Execution Isolation
Prefer isolation because semantic repair consumes context and does not depend on the caller's broader conversation.
Quick single-file repairs should usually run inline. Prefer isolation only when semantic repair consumes significant context or the caller asks for it.
- `subagent`: Use when the host supports delegated agents. Pass only the skill path, input files, mode, output directory, and output contract.
- `thread`: Use when the host supports child/background threads and the user explicitly asks for Thread/sub-session isolation or the batch is large enough to risk bloating the main context.
- `inline`: Use only when no subagent/thread mechanism is available or the file is small.
- `auto`: Choose `thread` when explicitly requested, otherwise choose `subagent` when available, otherwise `inline`.
The caller must verify returned files and reports. Do not treat a subagent/thread message alone as proof of repair.
- `inline`: Use for quick mode and small files.
- `subagent`: Use when the host supports delegated agents and the batch is large or audit mode is requested.
- `thread`: Use when the host supports child/background threads and the user explicitly asks for Thread/sub-session execution.
- `auto`: Choose `thread` when explicitly requested, otherwise choose `subagent` for audit or large batches when available, otherwise `inline`.
## Delegation Prompt
Send this shape to the isolated worker:
For quick mode, send this shape to the isolated worker only when isolation is warranted:
```text
Use $fix-title at <skill-path> to repair Markdown heading hierarchy.
Mode: <discussion|artifact>
Output: quick
Files:
- <absolute-path-1>
- <absolute-path-2>
Rules:
- Read and write source files as UTF-8.
- Overwrite each source Markdown file in place.
- Do not write heading-map, heading-plan, fixed-copy, heading-report, or batch-report files.
- Preserve non-heading content.
- Apply only deliberate heading-level edits.
- Flag ambiguous appended artifacts or uncertain heading relationships instead of silently making creative decisions.
- Return changed heading counts and skipped files.
```
For audit mode, send this shape:
```text
Use $fix-title at <skill-path> to repair Markdown heading hierarchy.
Mode: <discussion|artifact>
Output: audit
Output directory: <output-dir>
Files:
- <absolute-path-1>
@ -87,34 +163,6 @@ Use when copied GPT output is stored as a standalone planning/intake/artifact do
Example: after a root title, raw sibling headings `# 1.xxxx`, `# 2.xxx`, and `## 3.xxx` may all become `##` when they are peer sections.
## Output Contract
For each source file, write:
```text
<stem>.heading-map.json
<stem>.heading-plan.json
<stem>.fixed.md
<stem>.heading-report.md
```
For batch runs, also write:
```text
fix-title-batch-report.md
```
The plan JSON is the audit boundary. Every changed heading must include:
```json
{
"line": 25,
"from_level": 1,
"to_level": 3,
"reason": "This is a top-level section inside the GPT reply block under ## GPT."
}
```
## Internal Helper
Use `scripts/semantic_heading_repair.py` only as an internal importable helper. It is not a user-facing CLI.
@ -124,13 +172,16 @@ Useful helper API:
- `inspect_payload(path)`
- `write_inspection(source, output, output_format="json")`
- `prepare_batch_scaffold(files, mode, output_dir)`
- `apply_edits(source, edits, output, report=None, mode="quick")`
- `apply_edits_in_place(source, edits, mode="quick", report=None)`
- `apply_plan(source, plan, output, report)`
The helper protects fenced code blocks, quoted headings, and indented code. It does not make semantic decisions.
## Repair Rules
- Never overwrite the source file unless the user explicitly asks for replacement.
- Quick mode overwrites the source file by default.
- Audit mode never overwrites the source file unless the caller explicitly asks for replacement after reviewing the fixed copy.
- Do not mechanically add or subtract the same number of heading levels across a whole file.
- Do not change non-heading content.
- Do not change headings inside fenced code blocks, block quotes, or indented code.

View File

@ -1,3 +1,3 @@
display_name: "Fix Title"
short_description: "Agentic repair for Markdown heading trees."
default_prompt: "Use $fix-title with files=[...] and mode=artifact or mode=discussion to repair Markdown heading hierarchy without overwriting sources."
short_description: "Lightweight repair for Markdown heading trees."
default_prompt: "Use $fix-title with files=[...] and mode=artifact or mode=discussion to repair Markdown heading hierarchy in place. Ask for output=audit when fixed copies, heading maps, JSON plans, or reports are needed."

View File

@ -7,9 +7,11 @@ Recommended files:
```text
raw-discussion.md
raw-artifact.md
heading-plan.json
audit-heading-plan.json
```
The raw files should preserve original copied LLM/GPT Markdown before heading repair.
The raw files should contain disposable copies of original LLM/GPT Markdown before heading repair.
Invoke the Skill through an agent prompt with `files=[...]`, `mode=discussion|artifact`, and an explicit `output_dir`. Do not overwrite the source during manual validation.
Default manual validation should invoke the Skill with `files=[...]` and `mode=discussion|artifact`, then confirm the source copy was repaired in place without sidecar files.
Use `output=audit` and an explicit `output_dir` only when validating fixed-copy, heading-map, heading-plan, and report generation.

View File

@ -274,14 +274,10 @@ def parse_plan(plan_path: pathlib.Path) -> tuple[str, list[Edit]]:
return mode, edits
def apply_plan(
source: pathlib.Path,
plan: pathlib.Path,
output: pathlib.Path,
report: pathlib.Path | None,
) -> int:
lines = source.read_text(encoding="utf-8").splitlines(keepends=True)
mode, edits = parse_plan(plan)
def _apply_edits_to_lines(
lines: list[str],
edits: list[Edit],
) -> list[dict[str, Any]]:
headings_by_line = {heading.line: heading for heading in iter_headings(lines)}
changed: list[dict[str, Any]] = []
@ -312,23 +308,63 @@ def apply_plan(
}
)
return changed
def apply_edits(
source: pathlib.Path,
edits: list[Edit],
output: pathlib.Path,
report: pathlib.Path | None = None,
mode: str = "quick",
plan_label: str = "in-memory edits",
) -> int:
lines = source.read_text(encoding="utf-8").splitlines(keepends=True)
changed = _apply_edits_to_lines(lines, edits)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text("".join(lines), encoding="utf-8")
if report is not None:
report.parent.mkdir(parents=True, exist_ok=True)
report.write_text(
format_report(source, output, plan, mode, changed),
format_report(source, output, plan_label, mode, changed),
encoding="utf-8",
)
return len(changed)
def apply_edits_in_place(
source: pathlib.Path,
edits: list[Edit],
mode: str = "quick",
report: pathlib.Path | None = None,
) -> int:
return apply_edits(
source=source,
edits=edits,
output=source,
report=report,
mode=mode,
plan_label="in-place edits",
)
def apply_plan(
source: pathlib.Path,
plan: pathlib.Path,
output: pathlib.Path,
report: pathlib.Path | None,
) -> int:
mode, edits = parse_plan(plan)
return apply_edits(source, edits, output, report, mode, str(plan))
def format_report(
source: pathlib.Path,
output: pathlib.Path,
plan: pathlib.Path,
plan: pathlib.Path | str,
mode: str,
changed: list[dict[str, Any]],
) -> str:

View File

@ -159,6 +159,35 @@ class SemanticHeadingRepairTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "expected level 1"):
repair.apply_plan(source, plan, pathlib.Path(tmp) / "fixed.md", None)
def test_apply_edits_in_place_overwrites_source_without_sidecars(self):
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp)
source = root / "source.md"
source.write_text("# Title\n\n# Wrong peer\n", encoding="utf-8")
changed = repair.apply_edits_in_place(
source,
[
repair.Edit(
line=3,
from_level=1,
to_level=2,
reason="Artifact sections after the title are children.",
)
],
mode="artifact",
)
self.assertEqual(changed, 1)
self.assertEqual(
source.read_text(encoding="utf-8"),
"# Title\n\n## Wrong peer\n",
)
self.assertEqual(
sorted(path.name for path in root.iterdir()),
["source.md"],
)
def test_prepare_batch_scaffold_accepts_file_array(self):
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp)

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,8 +80,22 @@ def synthesize(
"""
if run is None:
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(
text=text,
text_file=text_path,
voice_id=voice_id,
out_path=out_path,
format=format,
@ -88,7 +103,6 @@ def synthesize(
volume=volume,
pitch=pitch,
)
out_path.parent.mkdir(parents=True, exist_ok=True)
result = run(cmd)
if result.returncode != 0:
raise MmxError(
@ -103,4 +117,6 @@ def synthesize(
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"})()