diff --git a/.github/workflows/project-her.yml b/.github/workflows/project-her.yml new file mode 100644 index 0000000..b5747c8 --- /dev/null +++ b/.github/workflows/project-her.yml @@ -0,0 +1,68 @@ +name: Project-Her validation + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: project-her-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + offline-contract: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.9", "3.12"] + + steps: + - name: Check out repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: ${{ matrix.python }} + cache: pip + + - name: Install harness and build tooling + run: python -m pip install --upgrade pip build && python -m pip install -e . + + - name: Compile and test offline contracts + run: | + python -m compileall -q voforge tests + python -m unittest discover -s tests -v + python -m json.tool voforge/schemas/project-her.schema.json >/dev/null + + - name: Exercise Project-Her starter workflow + run: | + project-her init .project-her-smoke + project-her validate --config .project-her-smoke/project-her.json + project-her plan --config .project-her-smoke/project-her.json + project-her validate --config examples/f5-project-her.json + project-her plan --config examples/f5-project-her.json + + - name: Build distributions + if: matrix.python == '3.12' + run: python -m build + + - name: Verify packaged Project-Her contracts + if: matrix.python == '3.12' + run: | + tar -tf dist/voforge-0.6.0.tar.gz | grep 'docs/PROJECT_HER.md' + tar -tf dist/voforge-0.6.0.tar.gz | grep 'voforge/schemas/project-her.schema.json' + tar -tf dist/voforge-0.6.0.tar.gz | grep 'examples/project-her/project-her.json' + tar -tf dist/voforge-0.6.0.tar.gz | grep 'examples/f5-project-her.json' + + - name: Upload distributions + if: matrix.python == '3.12' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: project-her-python-${{ matrix.python }} + path: dist/* + if-no-files-found: error diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07a72ec --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Protonmatter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..6818224 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,14 @@ +include examples/cast.json +include examples/lines.json +include examples/f5-project-her.json +include LICENSE +recursive-include docs *.md +recursive-include voforge/schemas *.json +recursive-include examples/project-her *.json +prune examples/audio +include examples/audio/NOTICE.md +include examples/audio/README.md +include examples/audio/manifest.json +include examples/audio/reference-kokoro-af-heart.wav +include examples/audio/clone-f5-systems-online.wav +include examples/audio/clone-f5-synthetic-disclosure.wav diff --git a/README.md b/README.md index 7588d7c..fa101c8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# voforge +# Project-Her / VOForge Batch neural voice-over generator with **tones, emotional styles, and voice cloning**. Feed it a list of dialog lines and a cast sheet; it renders one @@ -9,6 +9,19 @@ Network is needed at **build time only** — the rendered pack ships with your project and plays offline. This is the generator behind VENTFALL's fully-voiced campaign (139 lines, seven actors). +**Project-Her** is the end-to-end harness layered on VOForge: versioned model +and voice selection, explicit per-component licenses, clone-consent evidence, +deterministic run manifests, upgrade workflows, and a local stdio MCP server +that any MCP-capable AI can use. Start with +[`docs/PROJECT_HER.md`](docs/PROJECT_HER.md). + +```bash +project-her init my-voice-project +project-her validate --config my-voice-project/project-her.json +project-her plan --config my-voice-project/project-her.json +project-her render --config my-voice-project/project-her.json --confirm +``` + ```bash pip install ./tools/voforge # or: pip install git+https://github.com/Protonmatter/ventfall#subdirectory=tools/voforge voforge --lines lines.json --cast cast.json --out vo/ @@ -269,6 +282,14 @@ voforge.render(..., backend=voforge.make_backend("azure")) python -m unittest discover -s tests # fully offline (FakeBackend) ``` +## Cloned WAV examples + +[`examples/audio/`](examples/audio/) contains a synthetic Kokoro reference and +two F5-cloned WAV demonstrations with exact transcripts, seeds, checkpoint and +artifact hashes, PCM measurements, and an explicit asset notice. They use no +natural-person or game-character reference audio and are excluded from the MIT +code license because the official F5 checkpoint is CC-BY-NC-4.0. + ## Fidelity and promotion evidence See [`docs/VOICE_FIDELITY.md`](docs/VOICE_FIDELITY.md) for the reusable findings @@ -281,7 +302,7 @@ that evidence path. ## License -MIT, same as the repository. Voice audio is generated through third-party models +Project-Her and VOForge source are MIT under [`LICENSE`](LICENSE). Voice audio is generated through third-party models or services; review the selected backend's terms. In particular, F5-TTS code is MIT but its official pretrained weights are CC-BY-NC. The Kokoro Python package declares Apache-2.0; model and packaged-voice terms must still be checked for the diff --git a/docs/PROJECT_HER.md b/docs/PROJECT_HER.md new file mode 100644 index 0000000..c6d3c4b --- /dev/null +++ b/docs/PROJECT_HER.md @@ -0,0 +1,178 @@ +# Project-Her + +Project-Her is VOForge's end-to-end orchestration harness for selecting, +upgrading, validating, and exposing voice-generation technology. It is not a +new foundation model and it does not relicense third-party weights. The harness +is MIT-licensed; every model, service, voice, reference, and generated asset +keeps its own explicit license and provenance record. + +## Architecture + +```mermaid +flowchart LR + A["Project JSON\nlicense and revision pins"] --> B["ProjectHerHarness\nvalidate and plan"] + B --> C["VOForge backend\nEdge, Azure, XTTS, F5, Kokoro, ElevenLabs"] + C --> D["Candidate audio"] + D --> E["Evidence manifest\nhashes, PCM metrics, sources"] + E --> F["Human listening review"] + F --> G["Versioned promotion\nwith rollback artifact"] + H["Any MCP-capable AI"] --> I["Local stdio MCP\nread-only by default"] + I --> B +``` + +The old `voforge` CLI and Python API remain available. Project-Her is an +additive policy layer that supplies the project registry, license checks, +root-confined paths, run evidence, and MCP controls. + +## Quick start + +```powershell +python -m pip install -e . +project-her init .\my-voice-project +project-her validate --config .\my-voice-project\project-her.json +project-her plan --config .\my-voice-project\project-her.json +project-her render --config .\my-voice-project\project-her.json --confirm +``` + +The starter selects an Edge stock voice and declares evaluation-only use until +the operator reviews the governing Microsoft service terms. Change the model, +voice, and license records before using it for a different purpose. + +`render` writes audio and `out/project-her-run.json`. The run record contains: + +- exact Project-Her and VOForge versions; +- project, model, and voice revision/license records; +- canonical hashes of the config and dialogue source; +- a voice-registry hash; +- output hashes and PCM measurements for WAV files; +- result counts and bounded render logs. + +## Project contract + +Each `project-her.json` chooses one backend/model revision and maps dialogue +speakers to versioned voice profiles. See +[`voforge/schemas/project-her.schema.json`](../voforge/schemas/project-her.schema.json) and the +complete [`examples/project-her`](../examples/project-her) starter. + +The examples root also includes `f5-project-her.json`, a non-commercial, +hash-pinned reproduction profile for the anonymous seed-1792 systems-online take. +It reuses the synthetic Kokoro reference and records the official F5 checkpoint +revision, checkpoint hash, exact reference transcript, inference speed, seed, +asset notice, and consent boundary. + +The important fields are: + +- `intendedUse`: `evaluation`, `non-commercial`, or `commercial`; +- `model.revision`: a model release, service-managed revision, or checkpoint; +- `model.license.allowedUses`: uses permitted by the reviewed model terms; +- `voices..revision`: the selected voice/profile revision; +- `voices..sourceSha256`: mandatory exact reference hash for a clone; +- `voices..license`: terms for the stock voice or authorized reference; +- `voices..consent`: required for cloned voices, with a durable evidence + locator rather than a secret or raw credential; +- `output.force`: disabled by default; +- `mcp.allowRender`: disabled by default. + +Project-Her rejects embedded API keys, tokens, passwords, and secrets. Hosted +backends continue to read credentials from their documented environment +variables. + +## Choosing and upgrading voices + +1. Add a new voice profile or update `revision`; do not erase the previous + approved config/run manifest. +2. Set `source`, `license.id`, `license.source`, and `allowedUses` from the + actual release or service terms. +3. For a clone, set `kind: "clone"`, provide an authorized reference and exact + transcript when required, then set `consent.granted: true` with a durable + consent evidence locator. +4. Use a new output directory or stable versioned line IDs for the candidate. +5. Run `validate`, then `plan`, then `render --confirm`. +6. Apply objective PCM/hash gates and a human listening test. Similarity, + warmth, accent, and emotion are human-review claims unless separately + calibrated. +7. Promote only the approved hash with `voforge.promote_artifact`, retaining a + known rollback artifact. + +For F5, pin the checkpoint/revision and keep a fixed seed on every line. The +official F5 checkpoint currently used by the examples is CC-BY-NC-4.0, so it +cannot satisfy a Project-Her project declaring `intendedUse: "commercial"`. + +## Local MCP server + +Project-Her implements the MCP `2025-06-18` lifecycle and newline-delimited +stdio transport without an additional Python dependency. Start it with: + +```powershell +project-her-mcp --config C:\absolute\path\project-her.json +``` + +Generic client configuration: + +```json +{ + "mcpServers": { + "project-her": { + "command": "project-her-mcp", + "args": ["--config", "C:\\absolute\\path\\project-her.json"] + } + } +} +``` + +Any MCP-capable AI can then discover these read-only tools: + +- `project_her_capabilities` +- `project_her_validate` +- `project_her_plan` +- `project_her_voice_registry` + +Rendering is intentionally absent by default. To expose it, both conditions +must be true: + +1. set `mcp.allowRender` to `true` in the fixed project config; +2. start the server with `--allow-render`. + +Every render call must additionally contain exactly `{"confirm": true}`. MCP +never force-overwrites an output, accepts no client-supplied config path, runs +sequentially over local stdio, and rate-limits tool calls. These controls follow +the official MCP guidance that tool inputs be validated and sensitive actions +remain visible to a human: + +- +- +- + +## License matrix + +| Component | Governing boundary | +|---|---| +| Project-Her and VOForge source | Repository [`LICENSE`](../LICENSE), MIT | +| F5-TTS source | Upstream F5-TTS source license | +| Official F5 pretrained checkpoint | Upstream checkpoint terms; current examples record CC-BY-NC-4.0 | +| Kokoro package | Upstream package license; the selected model/voice release must also be recorded | +| Edge, Azure, ElevenLabs | Applicable service and voice terms, recorded per project | +| XTTS | Upstream code/model terms plus the reference speaker's authorization | +| Voice references and generated audio | Their own provenance, consent, model, and project-use terms; not automatically MIT | + +This is an engineering license boundary, not legal advice. A license identifier +in a config records an operator's review; it does not create rights that the +upstream terms do not grant. + +## CI and release workflow + +`.github/workflows/project-her.yml` performs offline syntax/tests, scaffolds and +validates a starter project, exercises a dry plan and MCP lifecycle, builds the +wheel/source distribution, and checks packaged files. It deliberately does not +download heavyweight checkpoints, invoke paid APIs, or render a live service +voice. Those belong in an explicitly authorized model-specific validation job. + +Release gate: + +1. offline unit and protocol tests pass; +2. schema/config and license-use validation pass; +3. source and wheel builds succeed; +4. a model-specific candidate render completes in its pinned environment; +5. artifact hashes and PCM gates pass; +6. a named human reviewer approves the exact candidate hash; +7. promotion is additive and retains a rollback hash. diff --git a/examples/audio/NOTICE.md b/examples/audio/NOTICE.md new file mode 100644 index 0000000..ab41246 --- /dev/null +++ b/examples/audio/NOTICE.md @@ -0,0 +1,25 @@ +# Audio example notice + +The WAV files in this directory are demonstration assets, not natural-person +identity samples and not StarCraft-derived audio. They are excluded from the MIT +license covering VOForge's source code. + +`reference-kokoro-af-heart.wav` was synthesized from original VOForge text with +Kokoro 0.9.4, voice `af_heart`, and the `hexgrad/Kokoro-82M` v1.0 model. The +Kokoro model repository declares Apache-2.0. + +The two `clone-f5-*.wav` files were generated from that synthetic reference with +F5-TTS 1.1.22 and the official `SWivid/F5-TTS` `F5TTS_v1_Base` checkpoint. That +checkpoint repository declares CC-BY-NC-4.0. The cloned WAVs are provided solely +as non-commercial technical demonstrations under that same conservative use +boundary. Do not use them to imply a real speaker's identity, endorsement, or +consent. + +Upstream terms: + +- Kokoro: +- F5-TTS checkpoint: +- F5-TTS code: + +This notice is an engineering boundary, not legal advice. Review current +upstream terms before redistributing or using these assets. diff --git a/examples/audio/README.md b/examples/audio/README.md new file mode 100644 index 0000000..0dc8db5 --- /dev/null +++ b/examples/audio/README.md @@ -0,0 +1,19 @@ +# Cloned WAV examples + +These files demonstrate VOForge reference-conditioned synthesis without using +a natural person's voice or a game character. The reference was generated with +the Apache-2.0 Kokoro model and the two new lines were cloned from that synthetic +reference with the official F5-TTS checkpoint. + +- [Synthetic Kokoro reference](reference-kokoro-af-heart.wav) — the source + performance and exact transcript used by F5. +- [F5 clone: systems online](clone-f5-systems-online.wav) — seed 1792, default + 32 inference steps. +- [F5 clone: synthetic disclosure](clone-f5-synthetic-disclosure.wav) — seed + 4701, default 32 inference steps. + +[`manifest.json`](manifest.json) records the exact text, seeds, model revisions, +checkpoint hashes, inference settings, output hashes, and PCM measurements. + +These audio assets are excluded from VOForge's MIT code license. Read +[`NOTICE.md`](NOTICE.md) before reuse. diff --git a/examples/audio/clone-f5-synthetic-disclosure.wav b/examples/audio/clone-f5-synthetic-disclosure.wav new file mode 100644 index 0000000..cbce414 Binary files /dev/null and b/examples/audio/clone-f5-synthetic-disclosure.wav differ diff --git a/examples/audio/clone-f5-systems-online.wav b/examples/audio/clone-f5-systems-online.wav new file mode 100644 index 0000000..a6727e9 Binary files /dev/null and b/examples/audio/clone-f5-systems-online.wav differ diff --git a/examples/audio/manifest.json b/examples/audio/manifest.json new file mode 100644 index 0000000..c44f0c5 --- /dev/null +++ b/examples/audio/manifest.json @@ -0,0 +1,98 @@ +{ + "schemaVersion": 1, + "purpose": "Reference-conditioned synthesis examples using a synthetic, non-person reference", + "naturalPersonReference": false, + "reference": { + "path": "reference-kokoro-af-heart.wav", + "text": "VOForge reference sample. Every signal is verified before release, and every artifact keeps a clear record of its origin.", + "backend": "kokoro", + "packageVersion": "0.9.4", + "voice": "af_heart", + "modelRepository": "hexgrad/Kokoro-82M", + "modelRevision": "f3ff3571791e39611d31c381e3a41a3af07b4987", + "modelSha256": "496dba118d1a58f5f3db2efc88dbdc216e0483fc89fe6e47ee1f2c53f18ad1e4", + "modelLicense": "Apache-2.0", + "artifact": { + "bytes": 415244, + "sha256": "da932ec098079291273491a59115c2fc0d2d9f7d76c621e466ca2e9e9c506b2f", + "channels": 1, + "sampleWidthBytes": 2, + "sampleRate": 24000, + "frames": 207600, + "durationSeconds": 8.65, + "peakDbfs": -4.4794, + "rmsDbfs": -25.989, + "exactClipSamples": 0, + "nearClipSamples": 0 + } + }, + "clones": [ + { + "path": "clone-f5-systems-online.wav", + "text": "Systems online. The voice pipeline passed every deterministic audio gate.", + "seed": 1792, + "inference": { + "target_rms": 0.1, + "cross_fade_duration": 0.15, + "cfg_strength": 2.0, + "nfe_step": 32, + "speed": 1.0 + }, + "artifact": { + "bytes": 205138, + "sha256": "63e57ed2ee9ca816ad89096ac2df3f8b6cd053868fd6b11579294b7e331d7afe", + "channels": 1, + "sampleWidthBytes": 2, + "sampleRate": 22050, + "frames": 102547, + "durationSeconds": 4.651, + "peakDbfs": -8.1689, + "rmsDbfs": -24.884, + "exactClipSamples": 0, + "nearClipSamples": 0 + } + }, + { + "path": "clone-f5-synthetic-disclosure.wav", + "text": "This demonstration was cloned from a synthetic reference, not from a person.", + "seed": 4701, + "inference": { + "target_rms": 0.1, + "cross_fade_duration": 0.15, + "cfg_strength": 2.0, + "nfe_step": 32, + "speed": 1.0 + }, + "artifact": { + "bytes": 213606, + "sha256": "e03f07cac99d1a1a9341176afdd6fb105e9cba246918826b36eeff03f7d22aa4", + "channels": 1, + "sampleWidthBytes": 2, + "sampleRate": 22050, + "frames": 106781, + "durationSeconds": 4.843, + "peakDbfs": -8.6547, + "rmsDbfs": -24.715, + "exactClipSamples": 0, + "nearClipSamples": 0 + } + } + ], + "cloneBackend": { + "name": "f5", + "packageVersion": "1.1.22", + "modelRepository": "SWivid/F5-TTS", + "modelName": "F5TTS_v1_Base", + "modelRevision": "84e5a410d9cead4de2f847e7c9369a6440bdfaca", + "checkpoint": "F5TTS_v1_Base/model_1250000.safetensors", + "checkpointSha256": "670900fd14e6c458b95da6e9ed317cdb20dbaf7a1c02ac06a05475a9d32b6a38", + "checkpointLicense": "CC-BY-NC-4.0" + }, + "mastering": { + "input_gain_db": 0.0, + "peak_limit_dbfs": -1.0, + "lookahead_ms": 5.0, + "release_ms": 80.0, + "sample_rate": 22050 + } +} diff --git a/examples/audio/reference-kokoro-af-heart.wav b/examples/audio/reference-kokoro-af-heart.wav new file mode 100644 index 0000000..74c3243 Binary files /dev/null and b/examples/audio/reference-kokoro-af-heart.wav differ diff --git a/examples/f5-project-her.json b/examples/f5-project-her.json new file mode 100644 index 0000000..36c78e8 --- /dev/null +++ b/examples/f5-project-her.json @@ -0,0 +1,66 @@ +{ + "$schema": "../voforge/schemas/project-her.schema.json", + "schemaVersion": 1, + "project": { + "id": "project-her-f5-systems-online", + "name": "Project-Her F5 Systems Online", + "license": { + "id": "MIT", + "source": "../LICENSE" + } + }, + "intendedUse": "non-commercial", + "model": { + "id": "F5TTS_v1_Base", + "backend": "f5", + "revision": "84e5a410d9cead4de2f847e7c9369a6440bdfaca", + "source": "https://huggingface.co/SWivid/F5-TTS", + "sha256": "670900fd14e6c458b95da6e9ed317cdb20dbaf7a1c02ac06a05475a9d32b6a38", + "license": { + "id": "CC-BY-NC-4.0", + "source": "https://huggingface.co/SWivid/F5-TTS", + "allowedUses": ["evaluation", "non-commercial"], + "reviewRequired": true + }, + "options": { + "device": "cpu", + "infer_settings": { + "speed": 1.0 + } + } + }, + "voices": { + "systems-online": { + "backend": "f5", + "kind": "clone", + "revision": "systems-online-1792", + "source": "audio/reference-kokoro-af-heart.wav", + "sourceSha256": "da932ec098079291273491a59115c2fc0d2d9f7d76c621e466ca2e9e9c506b2f", + "license": { + "id": "LicenseRef-VOForge-Synthetic-Example-Asset", + "source": "audio/NOTICE.md", + "allowedUses": ["evaluation", "non-commercial"], + "reviewRequired": true + }, + "selector": { + "ref": "audio/reference-kokoro-af-heart.wav", + "refText": "VOForge reference sample. Every signal is verified before release, and every artifact keeps a clear record of its origin." + }, + "consent": { + "granted": true, + "evidence": "audio/NOTICE.md (synthetic reference; no natural-person identity sample)" + } + } + }, + "lines": "project-her/f5-lines.json", + "output": { + "directory": "project-her/f5-out", + "name": "id", + "jobs": 1, + "force": false, + "manifest": "project-her-run.json" + }, + "mcp": { + "allowRender": false + } +} diff --git a/examples/project-her/f5-lines.json b/examples/project-her/f5-lines.json new file mode 100644 index 0000000..fc839a7 --- /dev/null +++ b/examples/project-her/f5-lines.json @@ -0,0 +1,8 @@ +[ + { + "id": "systems-online-1792", + "who": "systems-online", + "text": "Systems online. The voice pipeline passed every deterministic audio gate.", + "seed": 1792 + } +] diff --git a/examples/project-her/lines.json b/examples/project-her/lines.json new file mode 100644 index 0000000..19f55c3 --- /dev/null +++ b/examples/project-her/lines.json @@ -0,0 +1,7 @@ +[ + { + "id": "welcome", + "who": "narrator", + "text": "Project Her is ready. Choose a voice, review its license, and begin." + } +] diff --git a/examples/project-her/project-her.json b/examples/project-her/project-her.json new file mode 100644 index 0000000..9825605 --- /dev/null +++ b/examples/project-her/project-her.json @@ -0,0 +1,54 @@ +{ + "$schema": "../../voforge/schemas/project-her.schema.json", + "schemaVersion": 1, + "project": { + "id": "project-her-example", + "name": "Project-Her Example", + "license": { + "id": "MIT", + "source": "../../LICENSE" + } + }, + "intendedUse": "evaluation", + "model": { + "id": "edge-neural-service", + "backend": "edge", + "revision": "service-managed", + "source": "https://learn.microsoft.com/azure/ai-services/speech-service/", + "license": { + "id": "LicenseRef-Microsoft-Services-Agreement", + "source": "https://www.microsoft.com/servicesagreement", + "allowedUses": ["evaluation"], + "reviewRequired": true + }, + "options": {} + }, + "voices": { + "narrator": { + "backend": "edge", + "kind": "stock", + "revision": "service-managed", + "source": "Microsoft Edge neural voice catalog", + "license": { + "id": "LicenseRef-Microsoft-Services-Agreement", + "source": "https://www.microsoft.com/servicesagreement", + "allowedUses": ["evaluation"], + "reviewRequired": true + }, + "selector": { + "voice": "en-US-AriaNeural" + } + } + }, + "lines": "lines.json", + "output": { + "directory": "out", + "name": "id", + "jobs": 1, + "force": false, + "manifest": "project-her-run.json" + }, + "mcp": { + "allowRender": false + } +} diff --git a/pyproject.toml b/pyproject.toml index c4eb67d..f00dc34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,15 @@ [build-system] -requires = ["setuptools>=64"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "voforge" -version = "0.5.0" -description = "Batch neural voice-over generator with tones, emotional styles, and voice cloning" +version = "0.6.0" +description = "Project-Her and VOForge: licensed, evidence-producing neural voice orchestration" readme = "README.md" requires-python = ">=3.9" -license = { text = "MIT" } +license = "MIT" +license-files = ["LICENSE"] dependencies = ["edge-tts>=6.1"] [project.optional-dependencies] @@ -19,6 +20,11 @@ media = ["imageio-ffmpeg>=0.4"] # bundled ffmpeg for --extract-ref/--record-ref [project.scripts] voforge = "voforge.cli:main" +project-her = "voforge.project_her_cli:main" +project-her-mcp = "voforge.project_her_mcp:main" [tool.setuptools] -packages = ["voforge"] +packages = ["voforge", "voforge.schemas"] + +[tool.setuptools.package-data] +voforge = ["schemas/*.json"] diff --git a/tests/test_project_her.py b/tests/test_project_her.py new file mode 100644 index 0000000..c7316b0 --- /dev/null +++ b/tests/test_project_her.py @@ -0,0 +1,210 @@ +import json +import pathlib +import tempfile +import unittest + +from voforge.project_her import ProjectHerError, ProjectHerHarness, scaffold_project +from voforge.project_her_mcp import ProjectHerMcpServer + + +def project_config(*, allow_render=False): + license_record = { + "id": "MIT", + "source": "https://opensource.org/license/mit", + "allowedUses": ["evaluation", "non-commercial", "commercial"], + } + return { + "schemaVersion": 1, + "project": { + "id": "test-project", + "name": "Test Project", + "license": {"id": "MIT", "source": "LICENSE"}, + }, + "intendedUse": "evaluation", + "model": { + "id": "fake-model", + "backend": "fake", + "revision": "1", + "source": "builtin:test-double", + "license": license_record, + "options": {}, + }, + "voices": { + "narrator": { + "backend": "fake", + "kind": "stock", + "revision": "1", + "source": "builtin:test-voice", + "license": license_record, + "selector": {"voice": "test-voice"}, + } + }, + "lines": "lines.json", + "output": { + "directory": "out", + "name": "id", + "jobs": 1, + "force": False, + "manifest": "project-her-run.json", + }, + "mcp": {"allowRender": allow_render}, + } + + +def write_project(root, config=None): + root = pathlib.Path(root) + config = config or project_config() + config_path = root / "project-her.json" + config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + (root / "lines.json").write_text( + json.dumps([{"id": "welcome", "who": "narrator", "text": "Hello."}]) + "\n", + encoding="utf-8", + ) + return config_path + + +class TestProjectHerValidation(unittest.TestCase): + def test_valid_project_plans_without_loading_backend(self): + with tempfile.TemporaryDirectory() as td: + harness = ProjectHerHarness.load(write_project(td)) + self.assertEqual(harness.validate(), []) + result = harness.plan() + self.assertEqual(result["rows"][0]["name"], "welcome") + self.assertEqual(result["summary"]["model"]["revision"], "1") + + def test_license_use_secret_and_output_escape_fail_closed(self): + with tempfile.TemporaryDirectory() as td: + config = project_config() + config["intendedUse"] = "commercial" + config["model"]["license"]["allowedUses"] = ["evaluation"] + config["model"]["options"] = {"api_key": "must-not-be-here"} + config["output"]["directory"] = "../escape" + harness = ProjectHerHarness.load(write_project(td, config)) + problems = "\n".join(harness.validate()) + self.assertIn("does not allow intended use 'commercial'", problems) + self.assertIn("must not contain secrets", problems) + self.assertIn("relative to the project directory", problems) + + def test_clone_requires_consent_evidence_transcript_and_seed(self): + with tempfile.TemporaryDirectory() as td: + root = pathlib.Path(td) + (root / "reference.wav").write_bytes(b"placeholder") + config = project_config() + config["model"]["backend"] = "f5" + config["voices"]["narrator"].update({ + "backend": "f5", + "kind": "clone", + "sourceSha256": "0" * 64, + "selector": {"ref": "reference.wav"}, + "consent": {"granted": False, "evidence": ""}, + }) + harness = ProjectHerHarness.load(write_project(root, config)) + problems = "\n".join(harness.validate()) + self.assertIn("consent.granted must be true", problems) + self.assertIn("sourceSha256 does not match the reference", problems) + self.assertIn("exact refText transcript", problems) + self.assertIn("F5 requires an integer seed", problems) + + def test_scaffold_is_valid_and_never_overwrites(self): + with tempfile.TemporaryDirectory() as td: + created = scaffold_project(td) + self.assertEqual(len(created), 2) + self.assertEqual(ProjectHerHarness.load(created[0]).validate(), []) + with self.assertRaisesRegex(ProjectHerError, "refusing to overwrite"): + scaffold_project(td) + + def test_checked_in_json_schema_is_valid_json(self): + schema = pathlib.Path(__file__).parents[1] / "voforge" / "schemas" / "project-her.schema.json" + data = json.loads(schema.read_text(encoding="utf-8")) + self.assertEqual(data["properties"]["schemaVersion"]["const"], 1) + + def test_checked_in_f5_profile_pins_reference_and_plans_offline(self): + root = pathlib.Path(__file__).parents[1] + harness = ProjectHerHarness.load(root / "examples" / "f5-project-her.json") + self.assertEqual(harness.validate(), []) + result = harness.plan() + self.assertEqual(result["summary"]["intendedUse"], "non-commercial") + self.assertEqual(result["rows"][0]["prosody"]["seed"], 1792) + self.assertEqual( + result["summary"]["voices"][0]["sourceSha256"], + "da932ec098079291273491a59115c2fc0d2d9f7d76c621e466ca2e9e9c506b2f", + ) + + +class TestProjectHerRender(unittest.TestCase): + def test_fake_render_writes_hash_bound_manifest_and_is_incremental(self): + with tempfile.TemporaryDirectory() as td: + harness = ProjectHerHarness.load(write_project(td)) + first = harness.render() + self.assertEqual(first["status"], "complete") + self.assertEqual(first["counts"], {"made": 1, "kept": 0, "failed": 0}) + self.assertEqual(first["artifacts"][0]["sha256"], + "a152d9568d84788386ed06560799f7b0f2220051af3399d2aa78862c556eabcb") + manifest_path = pathlib.Path(td) / first["manifest"] + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + self.assertEqual(manifest["runId"], first["runId"]) + self.assertEqual(len(manifest["sources"]["configSha256"]), 64) + second = harness.render() + self.assertEqual(second["counts"], {"made": 0, "kept": 1, "failed": 0}) + self.assertEqual(second["runId"], first["runId"]) + + +class TestProjectHerMcp(unittest.TestCase): + def initialize(self, server): + response = server.handle({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "test", "version": "1"}}, + }) + self.assertEqual(response["result"]["protocolVersion"], "2025-06-18") + self.assertIsNone(server.handle({ + "jsonrpc": "2.0", "method": "notifications/initialized" + })) + + def test_tools_require_initialization_and_render_is_hidden_by_default(self): + with tempfile.TemporaryDirectory() as td: + server = ProjectHerMcpServer(write_project(td)) + early = server.handle({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + self.assertEqual(early["error"]["code"], -32002) + self.initialize(server) + response = server.handle({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) + names = {tool["name"] for tool in response["result"]["tools"]} + self.assertIn("project_her_validate", names) + self.assertNotIn("project_her_render", names) + + def test_render_needs_all_three_gates_and_never_force_overwrites(self): + with tempfile.TemporaryDirectory() as td: + config_path = write_project(td, project_config(allow_render=True)) + server = ProjectHerMcpServer(config_path, allow_render=True) + self.initialize(server) + listed = server.handle({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) + self.assertIn("project_her_render", {t["name"] for t in listed["result"]["tools"]}) + rejected = server.handle({ + "jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": {"name": "project_her_render", "arguments": {"confirm": False}}, + }) + self.assertTrue(rejected["result"]["isError"]) + accepted = server.handle({ + "jsonrpc": "2.0", "id": 4, "method": "tools/call", + "params": {"name": "project_her_render", "arguments": {"confirm": True}}, + }) + self.assertFalse(accepted["result"]["isError"]) + self.assertEqual(accepted["result"]["structuredContent"]["status"], "complete") + + def test_voice_registry_does_not_expose_selector_or_reference_path(self): + with tempfile.TemporaryDirectory() as td: + server = ProjectHerMcpServer(write_project(td)) + self.initialize(server) + response = server.handle({ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": "project_her_voice_registry", "arguments": {}}, + }) + payload = response["result"]["structuredContent"] + self.assertNotIn("selector", payload["voices"][0]) + self.assertEqual(payload["voices"][0]["revision"], "1") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_voforge.py b/tests/test_voforge.py index 4f7b7f6..f81a899 100644 --- a/tests/test_voforge.py +++ b/tests/test_voforge.py @@ -416,6 +416,27 @@ def test_promotion_rejects_candidate_and_rollback_drift(self): gate=voforge.PcmGate(sample_rate=24000), ) + def test_published_audio_examples_match_manifest_and_pcm_gates(self): + audio_root = pathlib.Path(__file__).resolve().parent.parent / "examples" / "audio" + manifest = json.loads((audio_root / "manifest.json").read_text(encoding="utf-8")) + self.assertFalse(manifest["naturalPersonReference"]) + entries = [manifest["reference"], *manifest["clones"]] + for entry in entries: + artifact = entry["artifact"] + path = audio_root / entry["path"] + failures = voforge.validate_pcm_artifact( + path, + artifact["sha256"], + voforge.PcmGate(sample_rate=artifact["sampleRate"]), + label=entry["path"], + ) + self.assertEqual(failures, []) + actual = voforge.candidate_record(path, root=audio_root) + for key in ("bytes", "sha256", "channels", "sampleWidthBytes", + "sampleRate", "frames", "durationSeconds", "peakDbfs", + "rmsDbfs", "exactClipSamples", "nearClipSamples"): + self.assertEqual(actual[key], artifact[key], f"{entry['path']}: {key}") + class TestPlan(unittest.TestCase): def test_plan_reports_without_writing(self): diff --git a/voforge/__init__.py b/voforge/__init__.py index 21cbe49..c21904d 100644 --- a/voforge/__init__.py +++ b/voforge/__init__.py @@ -12,6 +12,8 @@ See README.md for the cast-sheet and lines-file formats, and runtime/ for drop-in pack consumers (JS, Unity, Godot). """ +__version__ = "0.6.0" + from .core import (ConsentError, check_consent, clean, djb2, load_lines, out_name, plan, render, validate) from .backends import BACKENDS, F5Backend, KokoroBackend, make_backend @@ -22,8 +24,10 @@ from .evidence import (PcmGate, candidate_record, canonical_source_sha256, promote_artifact, validate_pcm_artifact) from .tones import STYLE_FALLBACK, TONES, layer, resolve, voice_of +from .project_her import (PROJECT_HER_SCHEMA_VERSION, PROJECT_HER_VERSION, + ProjectHerError, ProjectHerHarness, + project_her_capabilities, scaffold_project) -__version__ = "0.5.0" __all__ = ["render", "plan", "validate", "load_lines", "out_name", "djb2", "clean", "TONES", "STYLE_FALLBACK", "layer", "resolve", "voice_of", "make_backend", "BACKENDS", "F5Backend", "KokoroBackend", @@ -33,4 +37,7 @@ "resample_linear", "sha256_file", "write_pcm16_mono", "PcmGate", "candidate_record", "canonical_source_sha256", "promote_artifact", "validate_pcm_artifact", + "PROJECT_HER_SCHEMA_VERSION", "PROJECT_HER_VERSION", + "ProjectHerError", "ProjectHerHarness", "project_her_capabilities", + "scaffold_project", "check_consent", "ConsentError", "__version__"] diff --git a/voforge/project_her.py b/voforge/project_her.py new file mode 100644 index 0000000..4ba0e1b --- /dev/null +++ b/voforge/project_her.py @@ -0,0 +1,602 @@ +"""Project-Her: a licensed, evidence-producing orchestration layer for VOForge. + +Project-Her does not relicense model weights, stock voices, references, or +generated assets. Instead, every project declares those boundaries alongside +the selected model and voice revisions, then records them in each render run. +""" +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any, Dict, List, Mapping, Optional + +from . import __version__ +from .audio import artifact_record, sha256_file +from .backends import BACKENDS +from .core import check_consent, load_lines, plan, render, validate +from .evidence import canonical_source_sha256 + + +PROJECT_HER_SCHEMA_VERSION = 1 +PROJECT_HER_VERSION = "0.1.0" +INTENDED_USES = {"evaluation", "non-commercial", "commercial"} +SECRET_KEYS = { + "apikey", "token", "password", "secret", "clientsecret", "accesskey", + "refreshtoken", "privatekey", +} +SELECTOR_KEYS = { + "voice": "voice", + "ref": "ref", + "refText": "ref_text", + "voiceId": "voice_id", + "lang": "lang", + "tone": "tone", + "style": "style", + "styleDegree": "styledegree", + "rate": "rate", + "pitch": "pitch", + "volume": "volume", +} + +BACKEND_CAPABILITIES: Dict[str, Dict[str, Any]] = { + "edge": {"local": False, "network": True, "clones": False, "styles": False, "ext": "mp3"}, + "azure": {"local": False, "network": True, "clones": False, "styles": True, "ext": "mp3"}, + "xtts": {"local": True, "network": "first-model-download", "clones": True, "styles": False, "ext": "wav"}, + "f5": {"local": True, "network": "first-model-download", "clones": True, "styles": False, "ext": "wav"}, + "kokoro": {"local": True, "network": "first-model-download", "clones": False, "styles": False, "ext": "wav"}, + "elevenlabs": {"local": False, "network": True, "clones": True, "styles": False, "ext": "mp3"}, + "fake": {"local": True, "network": False, "clones": False, "styles": True, "ext": "mp3", "testingOnly": True}, +} + + +class ProjectHerError(ValueError): + """A Project-Her project is invalid or cannot be safely executed.""" + + +def _canonical_json(value: Any) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + + +def _json_hash(value: Any) -> str: + return hashlib.sha256(_canonical_json(value)).hexdigest() + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _secret_paths(value: Any, prefix: str = "") -> List[str]: + found: List[str] = [] + if isinstance(value, Mapping): + for key, child in value.items(): + path = f"{prefix}.{key}" if prefix else str(key) + normalized_key = "".join( + character for character in str(key).lower() if character.isalnum() + ) + if normalized_key in SECRET_KEYS: + found.append(path) + found.extend(_secret_paths(child, path)) + elif isinstance(value, list): + for index, child in enumerate(value): + found.extend(_secret_paths(child, f"{prefix}[{index}]")) + return found + + +def _is_sha256(value: Any) -> bool: + return ( + isinstance(value, str) + and len(value) == 64 + and all(character in "0123456789abcdefABCDEF" for character in value) + ) + + +def project_her_capabilities() -> Dict[str, Any]: + """Return stable, side-effect-free backend and policy capabilities.""" + + return { + "name": "Project-Her", + "version": PROJECT_HER_VERSION, + "schemaVersion": PROJECT_HER_SCHEMA_VERSION, + "voforgeVersion": __version__, + "backends": [ + {"name": name, **BACKEND_CAPABILITIES[name]} + for name in sorted(BACKEND_CAPABILITIES) + ], + "intendedUses": sorted(INTENDED_USES), + "licenseBoundary": ( + "The Project-Her harness license does not replace model, checkpoint, " + "voice, reference, service, or generated-asset licenses." + ), + "mcp": {"transport": "stdio", "renderDefault": "disabled"}, + } + + +class ProjectHerHarness: + """Load, validate, plan, and render one root-confined Project-Her project.""" + + def __init__(self, config_path: Path, config: Mapping[str, Any]): + self.config_path = Path(config_path).resolve() + self.root = self.config_path.parent + self.config: Dict[str, Any] = dict(config) + + @classmethod + def load(cls, config_path: os.PathLike) -> "ProjectHerHarness": + path = Path(config_path).resolve() + try: + data = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise ProjectHerError(f"cannot read project config: {exc}") from exc + except json.JSONDecodeError as exc: + raise ProjectHerError(f"project config is not valid JSON: {exc}") from exc + if not isinstance(data, dict): + raise ProjectHerError("project config must be a JSON object") + return cls(path, data) + + def _resolve(self, value: Any, label: str, *, must_exist: bool = False) -> Path: + if not isinstance(value, str) or not value.strip(): + raise ProjectHerError(f"{label} must be a non-empty relative path") + portable_paths = (PurePosixPath(value), PureWindowsPath(value)) + if any(path.is_absolute() or ".." in path.parts for path in portable_paths): + raise ProjectHerError(f"{label} must be relative to the project directory") + candidate = (self.root / value).resolve() + try: + candidate.relative_to(self.root) + except ValueError as exc: + raise ProjectHerError(f"{label} escapes the project directory: {value}") from exc + if must_exist and not candidate.is_file(): + raise ProjectHerError(f"{label} does not exist: {value}") + return candidate + + @property + def intended_use(self) -> str: + return str(self.config.get("intendedUse", "")) + + @property + def backend_name(self) -> str: + model = self.config.get("model") + return str(model.get("backend", "")) if isinstance(model, dict) else "" + + @property + def lines_path(self) -> Path: + return self._resolve(self.config.get("lines"), "lines", must_exist=True) + + @property + def output_dir(self) -> Path: + output = self.config.get("output") + value = output.get("directory", "out") if isinstance(output, dict) else "out" + return self._resolve(value, "output.directory") + + def _license_problems(self, value: Any, label: str, enforce_use: bool = True) -> List[str]: + if not isinstance(value, dict): + return [f"{label} must be an object"] + problems: List[str] = [] + if not isinstance(value.get("id"), str) or not value["id"].strip(): + problems.append(f"{label}.id must be a non-empty license identifier") + if not isinstance(value.get("source"), str) or not value["source"].strip(): + problems.append(f"{label}.source must identify the governing terms") + uses = value.get("allowedUses") + if enforce_use: + if not isinstance(uses, list) or not uses: + problems.append(f"{label}.allowedUses must be a non-empty array") + else: + unknown = sorted(set(map(str, uses)) - INTENDED_USES) + if unknown: + problems.append(f"{label}.allowedUses has unknown values {unknown}") + if self.intended_use not in uses: + problems.append( + f"{label} does not allow intended use {self.intended_use!r}" + ) + return problems + + def _voice_cast(self) -> Dict[str, Dict[str, Any]]: + voices = self.config.get("voices") + if not isinstance(voices, dict): + raise ProjectHerError("voices must be an object") + cast: Dict[str, Dict[str, Any]] = {} + for voice_id, profile in voices.items(): + if not isinstance(profile, dict): + raise ProjectHerError(f"voices.{voice_id} must be an object") + selector = profile.get("selector") + if not isinstance(selector, dict): + raise ProjectHerError(f"voices.{voice_id}.selector must be an object") + unknown = sorted(set(selector) - set(SELECTOR_KEYS)) + if unknown: + raise ProjectHerError(f"voices.{voice_id}.selector has unknown keys {unknown}") + spec = {SELECTOR_KEYS[key]: value for key, value in selector.items()} + if "ref" in spec: + spec["ref"] = str(self._resolve(spec["ref"], f"voices.{voice_id}.selector.ref", must_exist=True)) + consent = profile.get("consent", {}) + if isinstance(consent, dict): + spec["consent"] = consent.get("granted") is True + cast[str(voice_id)] = spec + return cast + + def validate(self) -> List[str]: + problems: List[str] = [] + if self.config.get("schemaVersion") != PROJECT_HER_SCHEMA_VERSION: + problems.append(f"schemaVersion must be {PROJECT_HER_SCHEMA_VERSION}") + unknown_top = sorted( + set(self.config) - { + "$schema", "schemaVersion", "project", "intendedUse", "model", + "voices", "lines", "output", "mcp", + } + ) + if unknown_top: + problems.append(f"project config has unknown keys {unknown_top}") + secrets = _secret_paths(self.config) + if secrets: + problems.append( + "project config must not contain secrets; use environment variables: " + + ", ".join(secrets) + ) + if self.intended_use not in INTENDED_USES: + problems.append(f"intendedUse must be one of {sorted(INTENDED_USES)}") + + project = self.config.get("project") + if not isinstance(project, dict): + problems.append("project must be an object") + else: + unknown = sorted(set(project) - {"id", "name", "license"}) + if unknown: + problems.append(f"project has unknown keys {unknown}") + for key in ("id", "name"): + if not isinstance(project.get(key), str) or not project[key].strip(): + problems.append(f"project.{key} must be a non-empty string") + problems.extend(self._license_problems(project.get("license"), "project.license", False)) + + model = self.config.get("model") + if not isinstance(model, dict): + problems.append("model must be an object") + else: + unknown = sorted( + set(model) - { + "id", "backend", "revision", "source", "sha256", "license", "options" + } + ) + if unknown: + problems.append(f"model has unknown keys {unknown}") + for key in ("id", "revision", "source"): + if not isinstance(model.get(key), str) or not model[key].strip(): + problems.append(f"model.{key} must be a non-empty string") + backend_name = model.get("backend") + if backend_name not in BACKENDS: + problems.append(f"model.backend must be one of {sorted(BACKENDS)}") + options = model.get("options", {}) + if not isinstance(options, dict): + problems.append("model.options must be an object") + if model.get("sha256") is not None and not _is_sha256(model.get("sha256")): + problems.append("model.sha256 must be a 64-character hexadecimal SHA-256") + problems.extend(self._license_problems(model.get("license"), "model.license")) + + voices = self.config.get("voices") + if not isinstance(voices, dict) or not voices: + problems.append("voices must be a non-empty object") + else: + for voice_id, profile in voices.items(): + label = f"voices.{voice_id}" + if not isinstance(profile, dict): + problems.append(f"{label} must be an object") + continue + unknown_profile = sorted( + set(profile) - { + "backend", "kind", "revision", "source", "supersedes", + "sourceSha256", "license", "selector", "consent", + } + ) + if unknown_profile: + problems.append(f"{label} has unknown keys {unknown_profile}") + if profile.get("kind") not in ("stock", "clone"): + problems.append(f"{label}.kind must be 'stock' or 'clone'") + for key in ("revision", "source"): + if not isinstance(profile.get(key), str) or not profile[key].strip(): + problems.append(f"{label}.{key} must be a non-empty string") + if profile.get("backend", self.backend_name) != self.backend_name: + problems.append(f"{label}.backend must match model.backend") + problems.extend(self._license_problems(profile.get("license"), f"{label}.license")) + selector = profile.get("selector") + if not isinstance(selector, dict): + problems.append(f"{label}.selector must be an object") + continue + unknown = sorted(set(selector) - set(SELECTOR_KEYS)) + if unknown: + problems.append(f"{label}.selector has unknown keys {unknown}") + if not any(selector.get(key) for key in ("voice", "ref", "voiceId")): + problems.append(f"{label}.selector needs voice, ref, or voiceId") + if profile.get("kind") == "clone": + consent = profile.get("consent") + if not isinstance(consent, dict) or consent.get("granted") is not True: + problems.append(f"{label}.consent.granted must be true for cloned voices") + elif not isinstance(consent.get("evidence"), str) or not consent["evidence"].strip(): + problems.append(f"{label}.consent.evidence must identify the consent record") + if selector.get("ref"): + try: + reference = self._resolve( + selector["ref"], f"{label}.selector.ref", must_exist=True + ) + if ( + profile.get("kind") == "clone" + and not _is_sha256(profile.get("sourceSha256")) + ): + problems.append(f"{label}.sourceSha256 must pin the cloned reference") + if ( + profile.get("kind") == "clone" + and _is_sha256(profile.get("sourceSha256")) + and sha256_file(reference).lower() + != str(profile["sourceSha256"]).lower() + ): + problems.append(f"{label}.sourceSha256 does not match the reference") + except ProjectHerError as exc: + problems.append(str(exc)) + + output = self.config.get("output") + if not isinstance(output, dict): + problems.append("output must be an object") + else: + unknown = sorted( + set(output) - {"directory", "name", "jobs", "force", "manifest"} + ) + if unknown: + problems.append(f"output has unknown keys {unknown}") + if output.get("name", "id") not in ("hash", "id", "seq"): + problems.append("output.name must be hash, id, or seq") + jobs = output.get("jobs", 1) + if not isinstance(jobs, int) or isinstance(jobs, bool) or not 1 <= jobs <= 32: + problems.append("output.jobs must be an integer from 1 through 32") + manifest_name = output.get("manifest", "project-her-run.json") + if ( + not isinstance(manifest_name, str) + or not manifest_name.strip() + or Path(manifest_name).name != manifest_name + or "/" in manifest_name + or "\\" in manifest_name + ): + problems.append("output.manifest must be a filename within output.directory") + try: + self.output_dir + except ProjectHerError as exc: + problems.append(str(exc)) + + mcp = self.config.get("mcp", {}) + if not isinstance(mcp, dict) or not isinstance(mcp.get("allowRender", False), bool): + problems.append("mcp.allowRender must be boolean") + + try: + lines_path = self.lines_path + lines = load_lines(lines_path) + except (ProjectHerError, OSError, json.JSONDecodeError, SystemExit) as exc: + problems.append(str(exc)) + lines = [] + try: + cast = self._voice_cast() + except ProjectHerError as exc: + problems.append(str(exc)) + cast = {} + if lines and cast: + if len(lines) > 10000: + problems.append("lines may contain at most 10000 entries") + problems.extend(validate(lines, cast)) + name_mode = output.get("name", "id") if isinstance(output, dict) else "id" + if name_mode == "id": + for index, line in enumerate(lines, 1): + if not isinstance(line, dict) or not line.get("id"): + problems.append(f"line {index}: output.name id requires an id") + if self.backend_name == "f5": + for index, line in enumerate(lines, 1): + speaker = cast.get(line.get("who"), {}) if isinstance(line, dict) else {} + if not isinstance(speaker.get("ref_text"), str) or not speaker["ref_text"].strip(): + problems.append(f"line {index}: F5 speaker needs an exact refText transcript") + if not isinstance(line.get("seed"), int) or isinstance(line.get("seed"), bool): + problems.append(f"line {index}: F5 requires an integer seed") + unconsented = check_consent(cast, self.backend_name in ("xtts", "f5", "elevenlabs")) + if unconsented: + problems.append("cloned voices lack consent: " + ", ".join(unconsented)) + return sorted(set(problems)) + + def ensure_valid(self) -> None: + problems = self.validate() + if problems: + raise ProjectHerError("; ".join(problems)) + + def public_summary(self, *, include_consent_evidence: bool = False) -> Dict[str, Any]: + self.ensure_valid() + project = self.config["project"] + model = self.config["model"] + voices = self.config["voices"] + voice_rows = [] + for voice_id, profile in sorted(voices.items()): + row = { + "id": voice_id, + "kind": profile["kind"], + "revision": profile["revision"], + "source": profile["source"], + "license": profile["license"], + } + if profile.get("supersedes"): + row["supersedes"] = profile["supersedes"] + if profile.get("sourceSha256"): + row["sourceSha256"] = profile["sourceSha256"] + if profile.get("kind") == "clone": + consent = profile.get("consent", {}) + row["consent"] = {"granted": consent.get("granted") is True} + if include_consent_evidence: + row["consent"]["evidence"] = consent.get("evidence") + else: + row["consent"]["evidenceRecorded"] = bool(consent.get("evidence")) + voice_rows.append(row) + return { + "project": {"id": project["id"], "name": project["name"], "license": project["license"]}, + "intendedUse": self.intended_use, + "model": { + key: model[key] + for key in ("id", "backend", "revision", "source", "sha256", "license") + if key in model + }, + "voices": voice_rows, + "configSha256": canonical_source_sha256(self.config_path), + "mcpRenderConfigured": bool(self.config.get("mcp", {}).get("allowRender", False)), + } + + def plan(self) -> Dict[str, Any]: + self.ensure_valid() + lines = load_lines(self.lines_path) + cast = self._voice_cast() + output = self.config["output"] + capability = BACKEND_CAPABILITIES[self.backend_name] + rows = plan( + lines, + cast, + self.output_dir, + output.get("name", "id"), + backend_styles=bool(capability["styles"]), + cloning=bool(capability["clones"]), + ext=str(capability["ext"]), + ) + for row in rows: + if "voice" in row: + row["voice"] = Path(str(row["voice"])).name if capability["clones"] else row["voice"] + return {"summary": self.public_summary(), "rows": rows} + + def _make_backend(self): + model = self.config["model"] + options = model.get("options", {}) + try: + return BACKENDS[self.backend_name](**options) + except TypeError as exc: + raise ProjectHerError(f"invalid model.options for {self.backend_name}: {exc}") from exc + except SystemExit as exc: + raise ProjectHerError(str(exc)) from exc + + async def render_async(self, *, force: Optional[bool] = None) -> Dict[str, Any]: + self.ensure_valid() + started = _utc_now() + lines = load_lines(self.lines_path) + cast = self._voice_cast() + output = self.config["output"] + backend = self._make_backend() + logs: List[str] = [] + counts = await render( + lines, + cast, + self.output_dir, + output.get("name", "id"), + output.get("jobs", 1), + output.get("force", False) if force is None else bool(force), + backend=backend, + log=logs.append, + ) + ext = getattr(backend, "ext", BACKEND_CAPABILITIES[self.backend_name]["ext"]) + rows = plan(lines, cast, self.output_dir, output.get("name", "id"), ext=ext) + artifacts: List[Dict[str, Any]] = [] + for row in sorted(rows, key=lambda value: value["name"]): + path = self.output_dir / f"{row['name']}.{ext}" + if not path.is_file(): + continue + if path.suffix.lower() == ".wav": + record = artifact_record(path, self.root) + else: + record = { + "path": path.relative_to(self.root).as_posix(), + "bytes": path.stat().st_size, + "sha256": sha256_file(path), + } + artifacts.append(record) + sources = { + "configSha256": canonical_source_sha256(self.config_path), + "linesSha256": canonical_source_sha256(self.lines_path), + "voiceRegistrySha256": _json_hash(self.config["voices"]), + } + run_id = _json_hash({"sources": sources, "artifacts": artifacts})[:20] + manifest = { + "schemaVersion": 1, + "runId": run_id, + "status": "failed" if counts.get("failed") else "complete", + "startedUtc": started, + "completedUtc": _utc_now(), + "projectHerVersion": PROJECT_HER_VERSION, + "voforgeVersion": __version__, + "summary": self.public_summary(include_consent_evidence=True), + "sources": sources, + "counts": counts, + "artifacts": artifacts, + "log": logs, + } + self.output_dir.mkdir(parents=True, exist_ok=True) + manifest_name = output.get("manifest", "project-her-run.json") + manifest_path = self._resolve( + str(Path(output.get("directory", "out")) / manifest_name), + "output.manifest", + ) + temporary = manifest_path.with_suffix(manifest_path.suffix + ".tmp") + temporary.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + os.replace(str(temporary), str(manifest_path)) + return { + "runId": run_id, + "status": manifest["status"], + "counts": counts, + "manifest": manifest_path.relative_to(self.root).as_posix(), + "artifacts": artifacts, + } + + def render(self, *, force: Optional[bool] = None) -> Dict[str, Any]: + return asyncio.run(self.render_async(force=force)) + + +def scaffold_project(directory: os.PathLike) -> List[Path]: + """Create a conservative Edge-backed starter without overwriting files.""" + + root = Path(directory).resolve() + root.mkdir(parents=True, exist_ok=True) + config_path = root / "project-her.json" + lines_path = root / "lines.json" + for path in (config_path, lines_path): + if path.exists(): + raise ProjectHerError(f"refusing to overwrite existing file: {path}") + terms = { + "id": "LicenseRef-Microsoft-Services-Agreement", + "source": "https://www.microsoft.com/servicesagreement", + "allowedUses": ["evaluation"], + "reviewRequired": True, + } + config = { + "schemaVersion": PROJECT_HER_SCHEMA_VERSION, + "project": { + "id": "hello-project-her", + "name": "Hello Project-Her", + "license": { + "id": "MIT", + "source": "https://opensource.org/license/mit", + }, + }, + "intendedUse": "evaluation", + "model": { + "id": "edge-neural-service", + "backend": "edge", + "revision": "service-managed", + "source": "https://learn.microsoft.com/azure/ai-services/speech-service/", + "license": terms, + "options": {}, + }, + "voices": { + "narrator": { + "backend": "edge", + "kind": "stock", + "revision": "service-managed", + "source": "Microsoft Edge neural voice catalog", + "license": terms, + "selector": {"voice": "en-US-AriaNeural"}, + } + }, + "lines": "lines.json", + "output": {"directory": "out", "name": "id", "jobs": 1, "force": False}, + "mcp": {"allowRender": False}, + } + lines = [ + {"id": "welcome", "who": "narrator", "text": "Project Her is ready."} + ] + config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + lines_path.write_text(json.dumps(lines, indent=2) + "\n", encoding="utf-8") + return [config_path, lines_path] diff --git a/voforge/project_her_cli.py b/voforge/project_her_cli.py new file mode 100644 index 0000000..48e3053 --- /dev/null +++ b/voforge/project_her_cli.py @@ -0,0 +1,84 @@ +"""Command-line entry point for the Project-Her VOForge harness.""" +import argparse +import json +import sys +from pathlib import Path + +from .project_her import ( + ProjectHerError, + ProjectHerHarness, + project_her_capabilities, + scaffold_project, +) + + +def _print(value): + print(json.dumps(value, indent=2, ensure_ascii=False)) + + +def main(argv=None): + parser = argparse.ArgumentParser( + prog="project-her", + description="Licensed, evidence-producing orchestration for VOForge.", + ) + commands = parser.add_subparsers(dest="command", required=True) + + init_parser = commands.add_parser("init", help="create a conservative starter project") + init_parser.add_argument("directory", type=Path) + + for name in ("validate", "plan", "render"): + command = commands.add_parser(name) + command.add_argument("--config", type=Path, default=Path("project-her.json")) + if name == "render": + command.add_argument( + "--confirm", + action="store_true", + help="confirm model/service use and creation of output artifacts", + ) + command.add_argument("--force", action="store_true", help="re-render existing outputs") + + commands.add_parser("capabilities", help="list backend and policy capabilities") + mcp_parser = commands.add_parser("mcp", help="run a local stdio MCP server") + mcp_parser.add_argument("--config", type=Path, default=Path("project-her.json")) + mcp_parser.add_argument( + "--allow-render", + action="store_true", + help="enable the render tool when mcp.allowRender is also true in the project", + ) + args = parser.parse_args(argv) + + try: + if args.command == "init": + paths = scaffold_project(args.directory) + _print({"created": [str(path) for path in paths]}) + return 0 + if args.command == "capabilities": + _print(project_her_capabilities()) + return 0 + if args.command == "mcp": + from .project_her_mcp import run_stdio + return run_stdio(args.config, allow_render=args.allow_render) + + harness = ProjectHerHarness.load(args.config) + if args.command == "validate": + problems = harness.validate() + _print({"valid": not problems, "problems": problems}) + return 0 if not problems else 2 + if args.command == "plan": + _print(harness.plan()) + return 0 + if args.command == "render": + if not args.confirm: + print("project-her: render requires --confirm", file=sys.stderr) + return 2 + result = harness.render(force=args.force) + _print(result) + return 1 if result["status"] == "failed" else 0 + except (ProjectHerError, OSError) as exc: + print(f"project-her: {exc}", file=sys.stderr) + return 2 + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/voforge/project_her_mcp.py b/voforge/project_her_mcp.py new file mode 100644 index 0000000..e3ccf55 --- /dev/null +++ b/voforge/project_her_mcp.py @@ -0,0 +1,266 @@ +"""Dependency-free local stdio MCP server for Project-Her. + +Only JSON-RPC messages are written to stdout. Render access is disabled by +default and requires three gates: server opt-in, project opt-in, and a true +``confirm`` argument on the individual tool call. +""" +from __future__ import annotations + +import json +import argparse +import contextlib +import sys +import time +from collections import deque +from pathlib import Path +from typing import Any, Deque, Dict, Mapping, Optional + +from .project_her import ProjectHerError, ProjectHerHarness, project_her_capabilities + + +PROTOCOL_VERSION = "2025-06-18" +SUPPORTED_PROTOCOL_VERSIONS = {PROTOCOL_VERSION, "2025-03-26"} + + +class RateLimiter: + def __init__(self, maximum: int = 30, window_seconds: float = 60.0): + self.maximum = maximum + self.window_seconds = window_seconds + self.calls: Deque[float] = deque() + + def allow(self) -> bool: + now = time.monotonic() + while self.calls and now - self.calls[0] >= self.window_seconds: + self.calls.popleft() + if len(self.calls) >= self.maximum: + return False + self.calls.append(now) + return True + + +def _schema(properties: Optional[Dict[str, Any]] = None, required=None) -> Dict[str, Any]: + return { + "type": "object", + "properties": properties or {}, + "required": list(required or []), + "additionalProperties": False, + } + + +def _output_schema() -> Dict[str, Any]: + return {"type": "object", "additionalProperties": True} + + +class ProjectHerMcpServer: + def __init__(self, config_path: Path, *, allow_render: bool = False): + self.config_path = Path(config_path).resolve() + self.allow_render = bool(allow_render) + self.initialized = False + self.rate_limiter = RateLimiter() + + def _harness(self) -> ProjectHerHarness: + return ProjectHerHarness.load(self.config_path) + + def _render_enabled(self) -> bool: + if not self.allow_render: + return False + try: + harness = self._harness() + return bool(harness.config.get("mcp", {}).get("allowRender", False)) + except ProjectHerError: + return False + + def tools(self): + tools = [ + { + "name": "project_her_capabilities", + "title": "Project-Her capabilities", + "description": "List supported voice backends and the license boundary. No files are changed.", + "inputSchema": _schema(), + "outputSchema": _output_schema(), + "annotations": {"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}, + }, + { + "name": "project_her_validate", + "title": "Validate Project-Her", + "description": "Validate the fixed server-side project configuration, licenses, consent, and paths.", + "inputSchema": _schema(), + "outputSchema": _output_schema(), + "annotations": {"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}, + }, + { + "name": "project_her_plan", + "title": "Plan Project-Her render", + "description": "Resolve voices and outputs without loading a model or writing files.", + "inputSchema": _schema(), + "outputSchema": _output_schema(), + "annotations": {"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}, + }, + { + "name": "project_her_voice_registry", + "title": "List Project-Her voices", + "description": "List configured voice revisions, provenance, and license records without exposing reference paths.", + "inputSchema": _schema(), + "outputSchema": _output_schema(), + "annotations": {"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}, + }, + ] + if self._render_enabled(): + tools.append( + { + "name": "project_her_render", + "title": "Render Project-Her project", + "description": ( + "Render missing voice artifacts and write an evidence manifest. " + "Requires explicit confirm=true; never force-overwrites outputs." + ), + "inputSchema": _schema( + {"confirm": {"type": "boolean", "description": "Explicit approval for this render."}}, + ["confirm"], + ), + "outputSchema": _output_schema(), + "annotations": {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True}, + } + ) + return tools + + @staticmethod + def _response(request_id: Any, result: Dict[str, Any]) -> Dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "result": result} + + @staticmethod + def _error(request_id: Any, code: int, message: str) -> Dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}} + + @staticmethod + def _tool_result(value: Dict[str, Any], *, is_error: bool = False) -> Dict[str, Any]: + return { + "content": [{"type": "text", "text": json.dumps(value, sort_keys=True)}], + "structuredContent": value, + "isError": bool(is_error), + } + + def _call_tool(self, name: str, arguments: Mapping[str, Any]) -> Dict[str, Any]: + if not self.rate_limiter.allow(): + return self._tool_result({"error": "tool call rate limit exceeded"}, is_error=True) + if name == "project_her_capabilities": + if arguments: + return self._tool_result({"error": "this tool accepts no arguments"}, is_error=True) + return self._tool_result(project_her_capabilities()) + if name == "project_her_validate": + if arguments: + return self._tool_result({"error": "this tool accepts no arguments"}, is_error=True) + harness = self._harness() + problems = harness.validate() + return self._tool_result({"valid": not problems, "problems": problems}) + if name == "project_her_plan": + if arguments: + return self._tool_result({"error": "this tool accepts no arguments"}, is_error=True) + return self._tool_result(self._harness().plan()) + if name == "project_her_voice_registry": + if arguments: + return self._tool_result({"error": "this tool accepts no arguments"}, is_error=True) + summary = self._harness().public_summary() + return self._tool_result({"voices": summary["voices"], "intendedUse": summary["intendedUse"]}) + if name == "project_her_render": + if not self._render_enabled(): + return self._tool_result({"error": "MCP rendering is disabled"}, is_error=True) + if set(arguments) != {"confirm"} or arguments.get("confirm") is not True: + return self._tool_result({"error": "render requires exactly confirm=true"}, is_error=True) + harness = self._harness() + if harness.config.get("output", {}).get("force", False): + return self._tool_result( + {"error": "MCP refuses projects configured to force-overwrite outputs"}, + is_error=True, + ) + # Some third-party inference libraries print progress to stdout. + # MCP reserves stdout exclusively for JSON-RPC, so redirect all + # incidental model output to stderr for the duration of a render. + with contextlib.redirect_stdout(sys.stderr): + rendered = harness.render(force=False) + return self._tool_result(rendered) + raise ProjectHerError(f"unknown tool: {name}") + + def handle(self, message: Mapping[str, Any]) -> Optional[Dict[str, Any]]: + request_id = message.get("id") + if message.get("jsonrpc") != "2.0" or not isinstance(message.get("method"), str): + return self._error(request_id, -32600, "Invalid Request") + method = message["method"] + if method == "initialize": + params = message.get("params", {}) + requested = params.get("protocolVersion") if isinstance(params, dict) else None + negotiated = requested if requested in SUPPORTED_PROTOCOL_VERSIONS else PROTOCOL_VERSION + return self._response( + request_id, + { + "protocolVersion": negotiated, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "project-her", "title": "Project-Her VOForge", "version": "0.1.0"}, + "instructions": "Rendering is disabled unless explicitly enabled by both server and project configuration.", + }, + ) + if method == "notifications/initialized": + self.initialized = True + return None + if method.startswith("notifications/"): + return None + if not self.initialized: + return self._error(request_id, -32002, "Server is not initialized") + if method == "ping": + return self._response(request_id, {}) + if method == "tools/list": + return self._response(request_id, {"tools": self.tools()}) + if method == "tools/call": + params = message.get("params") + if not isinstance(params, dict) or not isinstance(params.get("name"), str): + return self._error(request_id, -32602, "tools/call needs a tool name") + arguments = params.get("arguments", {}) + if not isinstance(arguments, dict): + return self._error(request_id, -32602, "tool arguments must be an object") + try: + result = self._call_tool(params["name"], arguments) + except ProjectHerError as exc: + if str(exc).startswith("unknown tool:"): + return self._error(request_id, -32602, str(exc)) + result = self._tool_result({"error": str(exc)}, is_error=True) + except Exception as exc: # protocol boundary: never leak a traceback to stdout + print(f"project-her-mcp: {type(exc).__name__}: {exc}", file=sys.stderr) + result = self._tool_result({"error": "internal tool execution failure"}, is_error=True) + return self._response(request_id, result) + return self._error(request_id, -32601, f"Method not found: {method}") + + def run(self) -> int: + for raw in sys.stdin.buffer: + try: + if len(raw) > 1_000_000: + response = self._error(None, -32600, "Message exceeds 1 MB") + sys.stdout.write(json.dumps(response, separators=(",", ":")) + "\n") + sys.stdout.flush() + continue + message = json.loads(raw.decode("utf-8")) + if not isinstance(message, dict): + response = self._error(None, -32600, "Invalid Request") + else: + response = self.handle(message) + except (UnicodeDecodeError, json.JSONDecodeError): + response = self._error(None, -32700, "Parse error") + if response is not None: + sys.stdout.write(json.dumps(response, separators=(",", ":"), ensure_ascii=False) + "\n") + sys.stdout.flush() + return 0 + + +def run_stdio(config_path: Path, *, allow_render: bool = False) -> int: + return ProjectHerMcpServer(config_path, allow_render=allow_render).run() + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(prog="project-her-mcp") + parser.add_argument("--config", type=Path, default=Path("project-her.json")) + parser.add_argument("--allow-render", action="store_true") + args = parser.parse_args(argv) + return run_stdio(args.config, allow_render=args.allow_render) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/voforge/schemas/__init__.py b/voforge/schemas/__init__.py new file mode 100644 index 0000000..5765889 --- /dev/null +++ b/voforge/schemas/__init__.py @@ -0,0 +1 @@ +"""Packaged JSON schemas for Project-Her configuration contracts.""" diff --git a/voforge/schemas/project-her.schema.json b/voforge/schemas/project-her.schema.json new file mode 100644 index 0000000..4d38257 --- /dev/null +++ b/voforge/schemas/project-her.schema.json @@ -0,0 +1,143 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://protonmatter.github.io/voforge/schemas/project-her.schema.json", + "title": "Project-Her project contract", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "project", "intendedUse", "model", "voices", "lines", "output"], + "properties": { + "$schema": {"type": "string"}, + "schemaVersion": {"const": 1}, + "project": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "license"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "name": {"type": "string", "minLength": 1}, + "license": {"$ref": "#/$defs/licenseIdentity"} + } + }, + "intendedUse": {"enum": ["evaluation", "non-commercial", "commercial"]}, + "model": { + "type": "object", + "additionalProperties": false, + "required": ["id", "backend", "revision", "source", "license"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "backend": {"enum": ["edge", "azure", "xtts", "f5", "kokoro", "elevenlabs", "fake"]}, + "revision": {"type": "string", "minLength": 1}, + "source": {"type": "string", "minLength": 1}, + "sha256": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"}, + "license": {"$ref": "#/$defs/runtimeLicense"}, + "options": {"type": "object"} + } + }, + "voices": { + "type": "object", + "minProperties": 1, + "additionalProperties": {"$ref": "#/$defs/voice"} + }, + "lines": {"type": "string", "minLength": 1}, + "output": { + "type": "object", + "additionalProperties": false, + "required": ["directory", "name"], + "properties": { + "directory": {"type": "string", "minLength": 1}, + "name": {"enum": ["hash", "id", "seq"]}, + "jobs": {"type": "integer", "minimum": 1, "maximum": 32}, + "force": {"type": "boolean"}, + "manifest": {"type": "string", "minLength": 1} + } + }, + "mcp": { + "type": "object", + "additionalProperties": false, + "properties": {"allowRender": {"type": "boolean"}} + } + }, + "$defs": { + "licenseIdentity": { + "type": "object", + "additionalProperties": false, + "required": ["id", "source"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "source": {"type": "string", "minLength": 1} + } + }, + "runtimeLicense": { + "type": "object", + "additionalProperties": false, + "required": ["id", "source", "allowedUses"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "source": {"type": "string", "minLength": 1}, + "allowedUses": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"enum": ["evaluation", "non-commercial", "commercial"]} + }, + "reviewRequired": {"type": "boolean"} + } + }, + "voice": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "revision", "source", "license", "selector"], + "allOf": [ + { + "if": { + "properties": { + "selector": {"required": ["ref"]} + }, + "required": ["selector"] + }, + "then": {"required": ["sourceSha256"]} + } + ], + "properties": { + "backend": {"type": "string"}, + "kind": {"enum": ["stock", "clone"]}, + "revision": {"type": "string", "minLength": 1}, + "source": {"type": "string", "minLength": 1}, + "sourceSha256": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"}, + "supersedes": {"type": "string"}, + "license": {"$ref": "#/$defs/runtimeLicense"}, + "selector": { + "type": "object", + "additionalProperties": false, + "properties": { + "voice": {"type": "string"}, + "ref": {"type": "string"}, + "refText": {"type": "string"}, + "voiceId": {"type": "string"}, + "lang": {"type": "string"}, + "tone": {"type": "string"}, + "style": {"type": "string"}, + "styleDegree": {}, + "rate": {"type": "string"}, + "pitch": {"type": "string"}, + "volume": {"type": "string"} + }, + "anyOf": [ + {"required": ["voice"]}, + {"required": ["ref"]}, + {"required": ["voiceId"]} + ] + }, + "consent": { + "type": "object", + "additionalProperties": false, + "required": ["granted", "evidence"], + "properties": { + "granted": {"type": "boolean"}, + "evidence": {"type": "string", "minLength": 1} + } + } + } + } + } +}