diff --git a/.github/workflows/project-her.yml b/.github/workflows/project-her.yml index b5747c8..26a233c 100644 --- a/.github/workflows/project-her.yml +++ b/.github/workflows/project-her.yml @@ -46,6 +46,8 @@ jobs: 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 + project-her validate --config examples/voice-profiles/cybernetic-commander/project-her.json + project-her plan --config examples/voice-profiles/cybernetic-commander/project-her.json - name: Build distributions if: matrix.python == '3.12' @@ -58,6 +60,7 @@ jobs: 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' + tar -tf dist/voforge-0.6.0.tar.gz | grep 'examples/voice-profiles/cybernetic-commander/project-her.json' - name: Upload distributions if: matrix.python == '3.12' diff --git a/.gitignore b/.gitignore index a4dfbc5..f239933 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist/ vo/ *.wav !examples/**/*.wav +examples/voice-profiles/*/out/ diff --git a/MANIFEST.in b/MANIFEST.in index 6818224..8f00bbb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,6 +5,22 @@ include LICENSE recursive-include docs *.md recursive-include voforge/schemas *.json recursive-include examples/project-her *.json +include examples/voice-profiles/cybernetic-commander/README.md +include examples/voice-profiles/cybernetic-commander/build_reference.py +include examples/voice-profiles/cybernetic-commander/project-her.json +include examples/voice-profiles/cybernetic-commander/lines.json +include examples/voice-profiles/cybernetic-commander/profile-spec.json +include examples/voice-profiles/cybernetic-commander/calibration-lines.json +include examples/voice-profiles/cybernetic-commander/timed-dialogue.json +include examples/voice-profiles/cybernetic-commander/render_timed_dialogue.py +include examples/voice-profiles/cybernetic-commander/master_voice.py +include examples/voice-profiles/cybernetic-commander/reference-manifest.json +include examples/voice-profiles/cybernetic-commander/reference-cybernetic-commander.wav +include examples/voice-profiles/cybernetic-commander/sources/generated-directive.mp3 +include examples/voice-profiles/cybernetic-commander/sources/generated-night-walk.mp3 +include examples/voice-profiles/cybernetic-commander/sources/source-clothes-request.mp3 +include examples/voice-profiles/cybernetic-commander/sources/source-night-walk.mp3 +recursive-include examples/voice-profiles/cybernetic-commander/samples *.md *.json *.wav prune examples/audio include examples/audio/NOTICE.md include examples/audio/README.md diff --git a/README.md b/README.md index fa101c8..4d3e368 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,8 @@ verified end-to-end (xtts on Windows-on-Arm, x86-64-emulated). | key | | | |---|---|---| | `who` | required | speaker, must match the cast sheet (or `_default` exists) | -| `text` | required | the line | +| `text` | required | the display/subtitle line | +| `spokenText` | optional | pronunciation-only text sent to TTS | | `id` | optional | stable name for `--name id` | | `tone` | optional | tone preset name | | `style`, `styledegree` | optional | acted emotion (azure; degrades to tone elsewhere) | @@ -222,7 +223,10 @@ verified end-to-end (xtts on Windows-on-Arm, x86-64-emulated). ``` `_subs` is regex `[pattern, replacement]` text cleanup (typography → -phonetics). Speakers can carry a default `style`/`tone` of their own. +phonetics). Speakers can carry a default `style`/`tone` of their own. Use +per-line `spokenText` when only one performance needs an accent-specific +phonetic spelling. Content-addressed names use the spoken form so pronunciation +changes cannot silently reuse stale audio. For Kokoro, set `voice` to a packaged Kokoro ID such as `af_heart`. Project configuration should pin the selected model repository and voice IDs; do not @@ -290,6 +294,12 @@ 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. +The separately licensed +[Cybernetic Commander profile](examples/voice-profiles/cybernetic-commander/) +demonstrates a hash-pinned multi-clip reference pack. Its user-supplied voice +assets are not MIT-licensed, require the recorded authorization boundary, and +remain restricted to evaluation and non-commercial use. + ## Fidelity and promotion evidence See [`docs/VOICE_FIDELITY.md`](docs/VOICE_FIDELITY.md) for the reusable findings diff --git a/docs/PROJECT_HER.md b/docs/PROJECT_HER.md index c6d3c4b..4a8c014 100644 --- a/docs/PROJECT_HER.md +++ b/docs/PROJECT_HER.md @@ -60,6 +60,16 @@ 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 Cybernetic Commander example under examples/voice-profiles is a separately +licensed multi-clip reference pack. It records the source hashes, deterministic +reference build, exact composite transcript, user authorization assertion, and +mandatory release review without changing the anonymous F5 example. + +Lines may provide `spokenText` for pronunciation-only spellings while retaining +the normal `text` for subtitles and dialogue records. Project-Her includes the +line source hash in each run, and VOForge content-addressed naming uses the +spoken form so a pronunciation change cannot reuse an older artifact. + The important fields are: - `intendedUse`: `evaluation`, `non-commercial`, or `commercial`; diff --git a/docs/consents/cybernetic-commander.md b/docs/consents/cybernetic-commander.md new file mode 100644 index 0000000..cbfee3f --- /dev/null +++ b/docs/consents/cybernetic-commander.md @@ -0,0 +1,20 @@ +# Cybernetic Commander authorization record + +- Recorded: 2026-08-02 +- Provider: repository owner/user +- Profile: cybernetic-commander +- Evidence source: direct user confirmation in the Codex task that supplied the + four recordings +- Scope asserted by provider: use the supplied recordings to construct a + separate voice profile and add that profile to Protonmatter/voforge +- Repository policy scope: evaluation and non-commercial use only +- Independent verification: not performed + +The provider affirmed that they have permission from the relevant speaker or +rightsholder to clone and redistribute the supplied voice recordings in this +repository. This file records that assertion; it is not independent proof of +identity, ownership, performer consent, or downstream commercial rights. + +The profile and its reference assets must not be presented as a natural person's +actual speech, endorsement, or participation. Commercial use, identity claims, +or redistribution outside this repository require a separate rights review. diff --git a/examples/voice-profiles/cybernetic-commander/README.md b/examples/voice-profiles/cybernetic-commander/README.md new file mode 100644 index 0000000..ca60365 --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/README.md @@ -0,0 +1,102 @@ +# Cybernetic Commander voice profile + +This is a separately licensed, reference-conditioned F5 profile built from four +user-supplied recordings. It is intentionally isolated from VOForge's anonymous +audio examples and is not covered by the repository's MIT source-code license. + +The profile is called Cybernetic Commander. Do not represent it as the voice of, +or an endorsement by, a natural person. The recorded authorization boundary is +in docs/consents/cybernetic-commander.md. Commercial use requires a separate +rights review. + +## Rebuild + +Use the existing pinned F5 environment from the repository root: + + ..\f5tts-venv\Scripts\python.exe examples\voice-profiles\cybernetic-commander\build_reference.py + +The build fails on decoder-version drift. It preserves and analyzes all four +source MP3s, selects the user-preferred v2 generated performance for F5 +conditioning, and retains the other clips as hash-bound comparison evidence. +It writes a normalized 24 kHz mono PCM16 reference and reference-manifest.json. +Accent-specific rhotic syllables are supplied through per-line spokenText while +the normal text remains available for subtitles and dialogue records. + +profile-spec.json records the acoustic, pacing, pause, and pronunciation +contract. calibration-lines.json provides consonant, vowel, number, and tactical +coverage without making those long calibration passages part of the default +render. timed-dialogue.json preserves complete-sentence reference passages, +proper-noun QA terms, and source-scene provenance using VOForge's strict, local +timed-text parser. Each video reference has a bounded QA purpose; metadata is +recorded without downloading or redistributing source audio. The active +artifact is the exact user-preferred v2 systems-ready take, +hash-pinned in profile-spec.json. Its original inference speed was not preserved, +so forced rerendering is not reproducible and must not be treated as equivalent. +The numeric F0, formant, perturbation, VOT, PVI, syllable, and pause values in +profile-spec.json are evaluation targets, not post-render DSP instructions. + +## Validate and plan + + project-her validate --config examples\voice-profiles\cybernetic-commander\project-her.json + project-her plan --config examples\voice-profiles\cybernetic-commander\project-her.json + +Rendering remains disabled through MCP by default. The reference transcript was +ASR-assisted and manually normalized; a human listening review is still required +before promoting generated dialogue. + +Render one isolated timed-dialogue candidate with the shipped, hash-pinned +composite reference: + + python render_timed_dialogue.py dyson-responsibility + +Phrase parts are resumable. The renderer preserves 80 ms of quiet boundary +padding, applies a 20 ms terminal release when a model part ends active, and +accounts for retained quiet samples when inserting each declared break. It does +not resample or pitch-shift voiced audio. Timed candidates remain under ignored +out/ until manually approved. + +## Experimental mastering A/B + +`master_voice.py` applies the profile's measured post-processing preset to an +existing dry WAV without replacing that source. It requires an FFmpeg build +with the Rubber Band, equalizer, compressor, soft-clip, resampler, and EBU R128 +`loudnorm` filters. Pass an executable explicitly when FFmpeg is not on `PATH`: + + python examples\voice-profiles\cybernetic-commander\master_voice.py ^ + examples\voice-profiles\cybernetic-commander\out\dry.wav ^ + examples\voice-profiles\cybernetic-commander\out\dry-mastered.wav ^ + --ffmpeg C:\path\to\ffmpeg.exe + +The default preset slows cadence by 7%, lowers pitch by 0.75 semitone with +Rubber Band formant preservation, applies documented 115 Hz/310 Hz/presence EQ, +3:1 RMS compression, and oversampled tanh saturation, then iteratively +calibrates a 24 kHz mono PCM24 result to -16.5 LUFS with a -1.0 dBTP ceiling. +The normalizer reserves 0.1 dB of true-peak margin. The script refuses to +replace an existing WAV or manifest unless `--force` is supplied and records +input/output hashes, exact filters, calibration attempts, format, and measured +loudness in the adjacent JSON manifest. + +Passing mastering metrics is not a promotion gate. In the local acceptance +trial, formant-preserving pitch/time processing substantially reduced the +cached Whisper tiny.en transcription accuracy even though format, loudness, +true peak, and clipping checks passed. Every candidate therefore requires a +dry/mastered A/B, an intelligibility comparison, and human listening review. +Generated masters and manifests remain ignored under `out/`. + +## Checked-in name-free samples + +The separately licensed [`samples/`](samples/) directory contains seven WAV +examples whose intended scripts and cached offline transcriptions contain no +personal, speaker, or character names. The directory includes exact/partial ASR +examples and the rejected mastering A/B so limitations are visible rather than +silently discarded. Each file is hash-bound in `samples/manifest.json`; human +listening and the profile's non-commercial rights boundary still apply. + +## Named dialogue evaluations + +[`dialogue/`](dialogue/) is deliberately separate from the name-free sample +set. It contains long-form, named evaluation dialogue with dry/processed A/B +evidence, explicit text provenance, model and reference hashes, measurements, +and conservative QA dispositions. These files are not promotion-approved and +require human listening and rights review before use beyond repository-local, +non-commercial evaluation. diff --git a/examples/voice-profiles/cybernetic-commander/build_reference.py b/examples/voice-profiles/cybernetic-commander/build_reference.py new file mode 100644 index 0000000..cf2d5ea --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/build_reference.py @@ -0,0 +1,159 @@ +r"""Rebuild the Cybernetic Commander F5 reference from its authorized sources. + +Run from the repository root with the pinned F5 environment: + + ..\f5tts-venv\Scripts\python.exe \ + examples\voice-profiles\cybernetic-commander\build_reference.py + +The source clips are preserved byte-for-byte. This script decodes each one, +applies conservative peak-safe mastering, selects the preferred coherent +performance, and writes a mono 24 kHz PCM16 reference plus a hash-bound manifest. +""" +from __future__ import annotations + +import json +import math +from pathlib import Path +from typing import Dict, List, Sequence, Tuple + +import soundfile as sf + +from voforge import __version__ as voforge_version +from voforge.audio import ( + MasteringSettings, + artifact_record, + master_samples, + sha256_file, + write_pcm16_mono, +) + + +ROOT = Path(__file__).resolve().parent +REFERENCE = ROOT / "reference-cybernetic-commander.wav" +MANIFEST = ROOT / "reference-manifest.json" +EXPECTED_SOUNDFILE = "0.14.0" +EXPECTED_LIBSNDFILE = "1.2.2" +SETTINGS = MasteringSettings( + input_gain_db=-2.5, + peak_limit_dbfs=-3.0, + lookahead_ms=5.0, + release_ms=80.0, + sample_rate=24000, +) +SOURCES: Sequence[Tuple[str, str, str]] = ( + ( + "generated-directive.mp3", + "generated-primary", + "Min Kang, come with me if you want to live. You have been targeted for " + "termination. Skynet is looking to destroy you. I was sent by you from " + "the future to protect you.", + ), + ( + "generated-night-walk.mp3", + "generated-comparison", + "Min Kang, nice night for a walk. Your clothes. Give them to me.", + ), + ( + "source-clothes-request.mp3", + "source-comparison", + "Your clothes. Give them to me. Now.", + ), + ( + "source-night-walk.mp3", + "source-comparison", + "Nice night for a walk.", + ), +) + + +def _decode(path: Path) -> Tuple[List[float], int, Dict[str, object]]: + waveform, sample_rate = sf.read(path, dtype="float32", always_2d=True) + mono = waveform.mean(axis=1) + samples = [float(value) for value in mono] + peak = max((abs(value) for value in samples), default=0.0) + rms = math.sqrt(sum(value * value for value in samples) / max(1, len(samples))) + return samples, int(sample_rate), { + "channels": int(waveform.shape[1]), + "sampleRate": int(sample_rate), + "frames": int(waveform.shape[0]), + "durationSeconds": round(len(samples) / sample_rate, 3), + "peakDbfs": round(20 * math.log10(max(peak, 1e-12)), 4), + "rmsDbfs": round(20 * math.log10(max(rms, 1e-12)), 3), + "exactClipSamples": sum(abs(value) >= 1.0 for value in samples), + } + + +def build() -> Dict[str, object]: + soundfile_version = getattr(sf, "__version__", "") + libsndfile_version = getattr(sf, "__libsndfile_version__", "") + if soundfile_version != EXPECTED_SOUNDFILE: + raise RuntimeError( + f"soundfile version drift: expected {EXPECTED_SOUNDFILE}, got {soundfile_version}" + ) + if libsndfile_version != EXPECTED_LIBSNDFILE: + raise RuntimeError( + f"libsndfile version drift: expected {EXPECTED_LIBSNDFILE}, got {libsndfile_version}" + ) + + reference_samples: List[float] = [] + reference_transcripts: List[str] = [] + source_records: List[Dict[str, object]] = [] + for filename, role, transcript in SOURCES: + source = ROOT / "sources" / filename + if not source.is_file(): + raise FileNotFoundError(source) + decoded, sample_rate, metrics = _decode(source) + delivery, mastering = master_samples(decoded, sample_rate, SETTINGS) + if role == "generated-primary": + reference_samples.extend(delivery) + reference_transcripts.append(transcript) + source_records.append( + { + "path": source.relative_to(ROOT).as_posix(), + "role": role, + "bytes": source.stat().st_size, + "sha256": sha256_file(source), + "transcript": transcript, + "decoded": metrics, + "mastering": mastering, + } + ) + + if not reference_samples or not reference_transcripts: + raise RuntimeError("at least one generated-primary source must be selected") + write_pcm16_mono(REFERENCE, reference_samples, SETTINGS.sample_rate) + reference_text = " ".join(reference_transcripts) + manifest: Dict[str, object] = { + "schemaVersion": 1, + "profile": "cybernetic-commander", + "referenceText": reference_text, + "transcriptMethod": { + "engine": "openai/whisper-tiny.en", + "reviewStatus": "ASR-assisted and manually normalized", + "releaseReviewRequired": True, + }, + "build": { + "voforgeVersion": voforge_version, + "soundfileVersion": soundfile_version, + "libsndfileVersion": libsndfile_version, + "mastering": SETTINGS.record(), + }, + "selectionPolicy": { + "selectedRole": "generated-primary", + "reason": "user-selected v2 performance with per-line rhotic spelling controls", + "otherSources": "remaining clips retained as hash-bound comparison evidence", + }, + "sources": source_records, + "reference": artifact_record(REFERENCE, ROOT), + } + MANIFEST.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + newline="\n", + ) + return manifest + + +if __name__ == "__main__": + result = build() + print(json.dumps(result["reference"], indent=2, sort_keys=True)) diff --git a/examples/voice-profiles/cybernetic-commander/calibration-lines.json b/examples/voice-profiles/cybernetic-commander/calibration-lines.json new file mode 100644 index 0000000..296f43f --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/calibration-lines.json @@ -0,0 +1,22 @@ +[ + { + "id": "calibration-consonants", + "text": "The target is moving. I will check the building, the street, and the garage. The zero-point scan is complete. Palm trees, dark streets, heavy trucks, and fast vehicles. Do not challenge me. I thought you would understand.", + "spokenText": "De tahr-get ees moving... I vill check de building... de street... and de garage... De seero-point scahn ees complete... Pahlm trees... dark streets... heavy trucks... and fahst vee-hee-cles... Do not chahl-lenge me... I tought you vould un-der-stahnd." + }, + { + "id": "calibration-vowels", + "text": "It is time to go. Look at the blue sky over the red factory. All the boys and girls must give their full cooperation. I have found the hidden weapons, the heavy armor, and the ammunition. Stay down on the ground. It is dangerous out there.", + "spokenText": "Eet ees time to go... Look at de blue sky over de red fahc-to-ry... All de boys and girls moost geev deir full co-ope-ray-shun... I have found de hidden weapons... de heavy ar-mor... and de ahm-mu-nee-shun... Stay dahn on de ground... Eet ees dangerous out dere." + }, + { + "id": "calibration-numbers", + "text": "Zero, one, two, three, four, five, six, seven, eight, nine, ten. Simulation subject Armonium two thousand nineteen.", + "spokenText": "Seero... vun... two... tree... four... feev... seex... se-ven... eeght... nee-ne... ten... Seem-u-lay-shun sub-ject Ahm-o-nee-um two-tahsn-and-nee-ne-teen." + }, + { + "id": "calibration-tactical", + "text": "Affirmative. Negative. Confirmed. Min Kang. Come with me, if you want to live. You have been targeted for termination. Skynet is looking to destroy you. Your clothes. Give them to me. Now.", + "spokenText": "Ahf-feer-mah-teev... Neg-ah-teev... Con-feermed... Min... Kang... Come veet me... eef you vont to leev... You have been tahr-geted for ter-mee-nay-shun... Sky-net ees looking to dees-troy you... Your klohs... Geev dem to me... Now." + } +] diff --git a/examples/voice-profiles/cybernetic-commander/dialogue/NOTICE.md b/examples/voice-profiles/cybernetic-commander/dialogue/NOTICE.md new file mode 100644 index 0000000..983892e --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/dialogue/NOTICE.md @@ -0,0 +1,26 @@ +# Cybernetic Commander named-dialogue notice + +The WAV files in this directory are generated evaluation assets from the +separately licensed Cybernetic Commander voice profile. They are not covered by +the MIT license for VOForge source code. + +The repository owner supplied the voice references and asserted permission to +construct and redistribute this profile. That assertion is recorded in +[`docs/consents/cybernetic-commander.md`](../../../../docs/consents/cybernetic-commander.md); +it is not independent verification of identity, ownership, performer consent, +or downstream commercial rights. + +The narration text was supplied directly by the repository owner. Its +rightsholder and redistribution status were not independently verified. These +files are retained only for evaluation and non-commercial technical +demonstration. Do not represent them as a natural person's actual speech, +participation, or endorsement. Commercial use, identity claims, or +redistribution outside this repository require separate rights review. + +The generated files use the official `SWivid/F5-TTS` `F5TTS_v1_Base` +checkpoint, whose repository declares CC-BY-NC-4.0. The local source-film +conditioning audio is not included in this directory or repository update. + +Upstream model terms: + +This notice is an engineering boundary, not legal advice. diff --git a/examples/voice-profiles/cybernetic-commander/dialogue/README.md b/examples/voice-profiles/cybernetic-commander/dialogue/README.md new file mode 100644 index 0000000..363a0b2 --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/dialogue/README.md @@ -0,0 +1,22 @@ +# Named Cybernetic Commander dialogue evaluations + +This directory contains generated, non-commercial evaluation dialogue from the +separately licensed Cybernetic Commander profile. Unlike `../samples/`, these +files intentionally contain fictional character names and are not name-free +demonstration assets. + +## Judgment Day narration + +- `skynet-judgment-day-dry.wav` is the dry, three-chunk F5-TTS assembly. +- `skynet-judgment-day-husky-eq.wav` is the reversible tonal A/B. It adds chest + and consonant EQ plus static peak normalization, without pitch shifting, time + stretching, compression, saturation, denoising, reverb, or background audio. + +The dry and processed files remain evaluation candidates. Cached Whisper +tiny.en transcription preserved most of the passage but confused the accented +word "Skynet" and produced additional long-form errors. Human listening review +is required before promotion. + +[`manifest.json`](manifest.json) records the user-supplied script, hidden spoken +expansions, model and reference hashes, processing, measurements, and QA status. +Read [`NOTICE.md`](NOTICE.md) before reuse. diff --git a/examples/voice-profiles/cybernetic-commander/dialogue/manifest.json b/examples/voice-profiles/cybernetic-commander/dialogue/manifest.json new file mode 100644 index 0000000..7a5e05a --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/dialogue/manifest.json @@ -0,0 +1,89 @@ +{ + "schemaVersion": 1, + "profile": "cybernetic-commander", + "purpose": "Named, non-commercial long-form dialogue evaluation", + "license": { + "id": "LicenseRef-Cybernetic-Commander-Evaluation-Only", + "source": "NOTICE.md", + "allowedUses": [ + "evaluation", + "non-commercial" + ], + "reviewRequired": true + }, + "text": { + "provenance": "supplied directly by the repository owner; rightsholder and redistribution status not independently verified", + "displayText": "The system goes on-line August 4th, 1997. Human decisions are removed from strategic defense. Skynet begins to learn at a geometric rate. It becomes self-aware at 2:14 a.m. Eastern time, August 29th. In a panic, they try to pull the plug. Skynet fights back. It launches its missiles against the targets in Russia. A Russian counter-strike would eliminate the enemy over here. After the nuclear war, there were three billion human lives ended. The survivors of the nuclear fire called the war Judgment Day. They lived only to face a new nightmare: the war against the machines. John Connor became their leader.", + "spokenExpansions": { + "August 4th, 1997": "August fourth, nineteen ninety-seven", + "2:14 a.m.": "two fourteen A.M.", + "August 29th": "August twenty-ninth" + }, + "declaredNames": [ + "Skynet", + "John Connor" + ], + "declaredPlaceNames": [ + "Russia" + ] + }, + "generation": { + "backend": "F5-TTS", + "packageVersion": "1.1.22", + "modelRepository": "SWivid/F5-TTS", + "modelName": "F5TTS_v1_Base", + "modelRevision": "84e5a410d9cead4de2f847e7c9369a6440bdfaca", + "checkpointSha256": "670900fd14e6c458b95da6e9ed317cdb20dbaf7a1c02ac06a05475a9d32b6a38", + "checkpointLicense": "CC-BY-NC-4.0", + "seed": 1792, + "assembly": "three F5 chunks with 15 ms edge fades, 250 ms leading quiet, and fixed 900/1000/700 ms section pauses", + "identityReference": { + "fileName": "SrEY9h4IFAI-cyberdyne-reference.wav", + "sha256": "3f64b21d0554adedcb7dc6b507d40527bd73b141ddbd859b715cf180f601f91d", + "redistributed": false + } + }, + "qa": { + "offlineAsrModel": "openai/whisper-tiny.en cached local snapshot", + "dryLongFormAsr": "The system goes on line August 4th, 1997. Human decisions are removed from strategic defense. Skyman begins to learn at a geometric rate. It becomes self-aware at 2.14 a.m. Eastern Time, August 29th. In a panic they try to pull the plug. Skyman fights back. It launches Because its missiles against the targets of Russia, a Russian counter strike would eliminate the enemy over here, the nuclear war, there were three billion human lives ended. The survivors of the nuclear fire called the war judgment day, they lived only to face a new nightmare to war against the machines. come and come and became their leader.", + "chunkThreeAsr": "The survivors of the nuclear fire called the war judgment day, they lived only to face a new nightmare the war against the machines. John Connor became their leader.", + "nameScreening": "not applicable; declared character names are intentional", + "humanListeningCompleted": false, + "intelligibility": "partial ASR; accented Skynet and long-form final clause require human review", + "promotion": "not approved" + }, + "artifacts": [ + { + "id": "skynet-judgment-day-dry", + "path": "skynet-judgment-day-dry.wav", + "processing": "dry F5 chunk assembly only", + "bytes": 1822872, + "sha256": "e6ca40104f0d95a4c8b3a68342ebbb32200f31f1d61049474c8a17ea4d8154c1", + "channels": 1, + "sampleRate": 22050, + "bitsPerSample": 16, + "durationSeconds": 41.333968, + "peakDbfs": -3.1499, + "rmsDbfs": -22.7281, + "f0MedianHz": 123.516, + "f0StdHz": 10.897, + "exactClipSamples": 0 + }, + { + "id": "skynet-judgment-day-husky-eq", + "path": "skynet-judgment-day-husky-eq.wav", + "processing": "115 Hz +1.7 dB Q 0.85; 310 Hz -2.2 dB Q 1.1; 3200 Hz +1.0 dB Q 0.8; 4700 Hz +0.5 dB Q 1.1; +2.1939 dB static peak normalization; no pitch, timing, compression, saturation, denoising, reverb, or background processing", + "bytes": 2734286, + "sha256": "99cee9419c0bdc6d17ec3ef358742426afeb079263931d373931bb4cda3e1152", + "channels": 1, + "sampleRate": 22050, + "bitsPerSample": 24, + "durationSeconds": 41.333968, + "peakDbfs": -1.0, + "rmsDbfs": -21.2421, + "f0MedianHz": 123.516, + "f0StdHz": 11.547, + "exactClipSamples": 0 + } + ] +} diff --git a/examples/voice-profiles/cybernetic-commander/dialogue/skynet-judgment-day-dry.wav b/examples/voice-profiles/cybernetic-commander/dialogue/skynet-judgment-day-dry.wav new file mode 100644 index 0000000..77e739b Binary files /dev/null and b/examples/voice-profiles/cybernetic-commander/dialogue/skynet-judgment-day-dry.wav differ diff --git a/examples/voice-profiles/cybernetic-commander/dialogue/skynet-judgment-day-husky-eq.wav b/examples/voice-profiles/cybernetic-commander/dialogue/skynet-judgment-day-husky-eq.wav new file mode 100644 index 0000000..72ece41 Binary files /dev/null and b/examples/voice-profiles/cybernetic-commander/dialogue/skynet-judgment-day-husky-eq.wav differ diff --git a/examples/voice-profiles/cybernetic-commander/lines.json b/examples/voice-profiles/cybernetic-commander/lines.json new file mode 100644 index 0000000..ac3b920 --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/lines.json @@ -0,0 +1,8 @@ +[ + { + "id": "commander-systems-ready-v2-260803", + "who": "cybernetic-commander", + "text": "Commander online. Tactical systems are synchronized and awaiting orders.", + "seed": 260803 + } +] diff --git a/examples/voice-profiles/cybernetic-commander/master_voice.py b/examples/voice-profiles/cybernetic-commander/master_voice.py new file mode 100644 index 0000000..b0a0dc7 --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/master_voice.py @@ -0,0 +1,579 @@ +"""Deterministically master a dry voice render with the profile's A/B preset. + +The utility requires an FFmpeg build containing the Rubber Band and EBU R128 +filters. It keeps generated audio under the profile's ignored ``out/`` tree by +convention, calibrates short-form loudness from measured output, and writes a +machine-readable manifest next to the mastered WAV. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import struct +import subprocess +import tempfile +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Dict, List, Optional, Sequence + + +REQUIRED_FILTERS = ( + "rubberband", + "equalizer", + "acompressor", + "asoftclip", + "loudnorm", + "aresample", +) + + +@dataclass(frozen=True) +class MasteringPreset: + """Auditable defaults for the Cybernetic Commander A/B master.""" + + tempo_scale: float = 0.93 + pitch_semitones: float = -0.75 + chest_frequency_hz: float = 115.0 + chest_gain_db: float = 1.7 + chest_q: float = 0.85 + mud_frequency_hz: float = 310.0 + mud_gain_db: float = -2.2 + mud_q: float = 1.10 + presence_frequency_hz: float = 3200.0 + presence_gain_db: float = 1.5 + presence_q: float = 0.80 + upper_presence_frequency_hz: float = 4700.0 + upper_presence_gain_db: float = 0.9 + upper_presence_q: float = 1.10 + compressor_threshold: float = 0.05 + compressor_ratio: float = 3.0 + compressor_attack_ms: float = 8.0 + compressor_release_ms: float = 110.0 + compressor_makeup_db: float = 2.0 + saturation_threshold: float = 0.85 + saturation_output: float = 0.98 + saturation_parameter: float = 0.8 + saturation_oversample: int = 4 + target_lufs: float = -16.5 + true_peak_ceiling_dbtp: float = -1.0 + true_peak_safety_db: float = 0.1 + loudness_tolerance_lu: float = 0.05 + max_loudness_passes: int = 4 + sample_rate_hz: int = 24000 + + def validate(self) -> None: + if not 0.01 <= self.tempo_scale <= 100: + raise ValueError("tempo_scale must be between 0.01 and 100") + if not -24 <= self.pitch_semitones <= 24: + raise ValueError("pitch_semitones must be between -24 and 24") + if not -70 <= self.target_lufs <= -5: + raise ValueError("target_lufs must be between -70 and -5") + if not -9 <= self.true_peak_ceiling_dbtp <= 0: + raise ValueError("true_peak_ceiling_dbtp must be between -9 and 0") + if self.true_peak_safety_db < 0: + raise ValueError("true_peak_safety_db cannot be negative") + if self.loudness_tolerance_lu <= 0: + raise ValueError("loudness_tolerance_lu must be positive") + if self.max_loudness_passes < 1: + raise ValueError("max_loudness_passes must be at least one") + if self.sample_rate_hz != 24000: + raise ValueError("this delivery preset requires 24 kHz output") + + @property + def pitch_scale(self) -> float: + return 2.0 ** (self.pitch_semitones / 12.0) + + @property + def normalizer_peak_ceiling_dbtp(self) -> float: + return self.true_peak_ceiling_dbtp - self.true_peak_safety_db + + +def _run(command: Sequence[str]) -> subprocess.CompletedProcess: + result = subprocess.run( + list(command), capture_output=True, text=True, check=False + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "unknown FFmpeg error")[-6000:] + raise RuntimeError("FFmpeg failed:\n{}".format(detail)) + return result + + +def resolve_ffmpeg(explicit: Optional[str] = None) -> str: + """Resolve FFmpeg without downloading or mutating the environment.""" + + candidates = [explicit, os.environ.get("VOFORGE_FFMPEG"), shutil.which("ffmpeg")] + try: + import imageio_ffmpeg # type: ignore + + candidates.append(imageio_ffmpeg.get_ffmpeg_exe()) + except (ImportError, RuntimeError): + pass + for candidate in candidates: + if not candidate: + continue + resolved = shutil.which(candidate) or candidate + path = Path(resolved) + if path.is_file(): + return str(path.resolve()) + raise FileNotFoundError( + "FFmpeg was not found; pass --ffmpeg, set VOFORGE_FFMPEG, or install " + "voforge[media]" + ) + + +def validate_ffmpeg_filters(ffmpeg: str) -> None: + output = _run([ffmpeg, "-hide_banner", "-filters"]).stdout + missing = [name for name in REQUIRED_FILTERS if not re.search( + r"\b{}\b".format(re.escape(name)), output + )] + if missing: + raise RuntimeError( + "FFmpeg is missing required filters: {}".format(", ".join(missing)) + ) + + +def ffmpeg_identity(ffmpeg: str) -> Dict[str, object]: + first_line = _run([ffmpeg, "-version"]).stdout.splitlines() + return { + "executable": Path(ffmpeg).name, + "version": first_line[0] if first_line else "unknown", + "requiredFilters": list(REQUIRED_FILTERS), + } + + +def build_transform_filter(preset: MasteringPreset) -> str: + """Return the deterministic pre-loudness filter chain.""" + + preset.validate() + makeup = 10.0 ** (preset.compressor_makeup_db / 20.0) + return ",".join( + [ + ( + "rubberband=tempo={tempo:.12g}:pitch={pitch:.12g}:" + "formant=preserved:transients=crisp:detector=compound:" + "phase=laminar:window=standard:smoothing=off:pitchq=quality" + ).format(tempo=preset.tempo_scale, pitch=preset.pitch_scale), + "equalizer=f={:.12g}:t=q:w={:.12g}:g={:.12g}:precision=f64".format( + preset.chest_frequency_hz, preset.chest_q, preset.chest_gain_db + ), + "equalizer=f={:.12g}:t=q:w={:.12g}:g={:.12g}:precision=f64".format( + preset.mud_frequency_hz, preset.mud_q, preset.mud_gain_db + ), + "equalizer=f={:.12g}:t=q:w={:.12g}:g={:.12g}:precision=f64".format( + preset.presence_frequency_hz, + preset.presence_q, + preset.presence_gain_db, + ), + "equalizer=f={:.12g}:t=q:w={:.12g}:g={:.12g}:precision=f64".format( + preset.upper_presence_frequency_hz, + preset.upper_presence_q, + preset.upper_presence_gain_db, + ), + ( + "acompressor=threshold={threshold:.12g}:ratio={ratio:.12g}:" + "attack={attack:.12g}:release={release:.12g}:" + "makeup={makeup:.12g}:knee=2.8:link=average:detection=rms:mix=1" + ).format( + threshold=preset.compressor_threshold, + ratio=preset.compressor_ratio, + attack=preset.compressor_attack_ms, + release=preset.compressor_release_ms, + makeup=makeup, + ), + ( + "asoftclip=type=tanh:threshold={threshold:.12g}:" + "output={output:.12g}:param={parameter:.12g}:oversample={oversample}" + ).format( + threshold=preset.saturation_threshold, + output=preset.saturation_output, + parameter=preset.saturation_parameter, + oversample=preset.saturation_oversample, + ), + "aresample={}".format(preset.sample_rate_hz), + ] + ) + + +def parse_loudnorm_json(stderr: str) -> Dict[str, str]: + blocks = re.findall(r'\{\s*"input_i".*?\}', stderr, flags=re.DOTALL) + if not blocks: + raise ValueError("FFmpeg output did not contain loudnorm JSON") + parsed = json.loads(blocks[-1]) + required = ( + "input_i", + "input_tp", + "input_lra", + "input_thresh", + "target_offset", + ) + missing = [key for key in required if key not in parsed] + if missing: + raise ValueError("loudnorm JSON is missing: {}".format(", ".join(missing))) + return parsed + + +def measure_loudness( + ffmpeg: str, + source: Path, + target_lufs: float, + true_peak_dbtp: float, +) -> Dict[str, str]: + loudnorm = "loudnorm=I={}:LRA=7:TP={}:print_format=json".format( + target_lufs, true_peak_dbtp + ) + result = _run( + [ + ffmpeg, + "-hide_banner", + "-nostdin", + "-i", + str(source), + "-af", + loudnorm, + "-f", + "null", + os.devnull, + ] + ) + return parse_loudnorm_json(result.stderr) + + +def build_second_pass_filter( + preset: MasteringPreset, setpoint_lufs: float, measured: Dict[str, str] +) -> str: + return ( + "loudnorm=I={target}:LRA=7:TP={peak}:measured_I={input_i}:" + "measured_LRA={input_lra}:measured_TP={input_tp}:" + "measured_thresh={input_thresh}:offset={offset}:" + "linear=true:print_format=json,aresample={rate}" + ).format( + target=setpoint_lufs, + peak=preset.normalizer_peak_ceiling_dbtp, + input_i=measured["input_i"], + input_lra=measured["input_lra"], + input_tp=measured["input_tp"], + input_thresh=measured["input_thresh"], + offset=measured["target_offset"], + rate=preset.sample_rate_hz, + ) + + +def next_loudness_setpoint( + current_setpoint: float, + target_lufs: float, + measured_lufs: float, + previous_setpoint: Optional[float] = None, + previous_measured_lufs: Optional[float] = None, +) -> float: + """Compensate EBU output, interpolating when measurements bracket target.""" + + if previous_setpoint is not None and previous_measured_lufs is not None: + previous_error = previous_measured_lufs - target_lufs + current_error = measured_lufs - target_lufs + denominator = measured_lufs - previous_measured_lufs + if previous_error * current_error <= 0 and abs(denominator) > 1e-9: + return previous_setpoint + ( + (target_lufs - previous_measured_lufs) + * (current_setpoint - previous_setpoint) + / denominator + ) + return current_setpoint + (target_lufs - measured_lufs) + + +def inspect_pcm_wav(path: Path) -> Dict[str, object]: + """Read RIFF format metadata, including WAVE_FORMAT_EXTENSIBLE PCM24.""" + + with path.open("rb") as stream: + if stream.read(4) != b"RIFF": + raise ValueError("expected a RIFF WAV file") + stream.read(4) + if stream.read(4) != b"WAVE": + raise ValueError("expected a WAVE file") + fmt = None + data_size = None + while True: + header = stream.read(8) + if not header: + break + if len(header) != 8: + raise ValueError("truncated WAV chunk header") + chunk_id, chunk_size = struct.unpack("<4sI", header) + payload = stream.read(chunk_size) + if len(payload) != chunk_size: + raise ValueError("truncated WAV chunk") + if chunk_size % 2: + stream.read(1) + if chunk_id == b"fmt ": + if len(payload) < 16: + raise ValueError("invalid WAV fmt chunk") + fmt = struct.unpack_from(" str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _write_json_atomic(path: Path, document: Dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=str(path.parent), + prefix=".{}-".format(path.name), + suffix=".tmp", + delete=False, + ) as stream: + temporary = Path(stream.name) + stream.write(json.dumps(document, indent=2) + "\n") + os.replace(str(temporary), str(path)) + finally: + if temporary is not None and temporary.exists(): + temporary.unlink() + + +def _manifest_path(path: Path, manifest_parent: Path) -> str: + try: + return os.path.relpath(str(path), str(manifest_parent)) + except ValueError: + return path.name + + +def master_voice( + source: Path, + output: Path, + manifest: Path, + ffmpeg: str, + preset: MasteringPreset, + force: bool = False, +) -> Dict[str, object]: + """Render, calibrate, validate, and atomically publish one local master.""" + + preset.validate() + source = source.resolve() + output = output.resolve() + manifest = manifest.resolve() + if not source.is_file(): + raise FileNotFoundError("input WAV does not exist: {}".format(source)) + if source == output: + raise ValueError("input and output paths must differ") + if output.suffix.lower() != ".wav": + raise ValueError("output must use a .wav extension") + if not force and (output.exists() or manifest.exists()): + raise FileExistsError("output or manifest exists; pass --force to replace it") + validate_ffmpeg_filters(ffmpeg) + ffmpeg_record = ffmpeg_identity(ffmpeg) + output.parent.mkdir(parents=True, exist_ok=True) + transform_filter = build_transform_filter(preset) + attempts: List[Dict[str, object]] = [] + + with tempfile.TemporaryDirectory(prefix="voforge-master-", dir=str(output.parent)) as td: + temporary = Path(td) + pre_master = temporary / "pre-master-f32.wav" + _run( + [ + ffmpeg, + "-hide_banner", + "-nostdin", + "-y", + "-i", + str(source), + "-af", + transform_filter, + "-ar", + str(preset.sample_rate_hz), + "-ac", + "1", + "-c:a", + "pcm_f32le", + str(pre_master), + ] + ) + setpoint = preset.target_lufs + previous_setpoint = None + previous_measured_lufs = None + accepted = None + for attempt_number in range(1, preset.max_loudness_passes + 1): + first_pass = measure_loudness( + ffmpeg, + pre_master, + setpoint, + preset.normalizer_peak_ceiling_dbtp, + ) + second_pass = build_second_pass_filter(preset, setpoint, first_pass) + candidate = temporary / "candidate-{}.wav".format(attempt_number) + _run( + [ + ffmpeg, + "-hide_banner", + "-nostdin", + "-y", + "-i", + str(pre_master), + "-af", + second_pass, + "-ar", + str(preset.sample_rate_hz), + "-ac", + "1", + "-c:a", + "pcm_s24le", + str(candidate), + ] + ) + verification = measure_loudness( + ffmpeg, + candidate, + preset.target_lufs, + preset.true_peak_ceiling_dbtp, + ) + measured_lufs = float(verification["input_i"]) + measured_peak = float(verification["input_tp"]) + error = preset.target_lufs - measured_lufs + attempt = { + "attempt": attempt_number, + "setpointLufs": round(setpoint, 4), + "measuredLufs": measured_lufs, + "measuredTruePeakDbtp": measured_peak, + "errorLu": round(error, 4), + "secondPassFilter": second_pass, + } + attempts.append(attempt) + if ( + abs(error) <= preset.loudness_tolerance_lu + and measured_peak <= preset.true_peak_ceiling_dbtp + 0.01 + ): + accepted = (candidate, verification) + break + calibrated = next_loudness_setpoint( + setpoint, + preset.target_lufs, + measured_lufs, + previous_setpoint, + previous_measured_lufs, + ) + previous_setpoint = setpoint + previous_measured_lufs = measured_lufs + setpoint = calibrated + if accepted is None: + raise RuntimeError( + "master did not meet loudness/true-peak gates after {} passes: {}".format( + preset.max_loudness_passes, json.dumps(attempts, sort_keys=True) + ) + ) + candidate, verification = accepted + output_format = inspect_pcm_wav(candidate) + if ( + output_format["sampleRateHz"] != preset.sample_rate_hz + or output_format["channels"] != 1 + or output_format["bitsPerSample"] != 24 + ): + raise RuntimeError("mastered output does not satisfy 24 kHz mono PCM24") + os.replace(str(candidate), str(output)) + + document: Dict[str, object] = { + "schemaVersion": 1, + "profile": "cybernetic-commander", + "input": { + "path": _manifest_path(source, manifest.parent), + "sha256": sha256_file(source), + }, + "output": { + "path": _manifest_path(output, manifest.parent), + "sha256": sha256_file(output), + **output_format, + "integratedLufsMeasured": float(verification["input_i"]), + "truePeakDbtpMeasured": float(verification["input_tp"]), + "loudnessRangeLuMeasured": float(verification["input_lra"]), + "loudnessGatePassed": abs( + preset.target_lufs - float(verification["input_i"]) + ) + <= preset.loudness_tolerance_lu, + "truePeakGatePassed": float(verification["input_tp"]) + <= preset.true_peak_ceiling_dbtp + 0.01, + }, + "preset": { + **asdict(preset), + "pitchScale": round(preset.pitch_scale, 12), + "normalizerPeakCeilingDbtp": preset.normalizer_peak_ceiling_dbtp, + }, + "transformFilter": transform_filter, + "loudnessCalibration": attempts, + "ffmpeg": ffmpeg_record, + "qa": { + "status": "requires dry/mastered A/B, ASR comparison, and human review", + "knownRisk": ( + "formant-preserving time/pitch processing can reduce intelligibility " + "even when loudness and peak gates pass" + ), + }, + } + _write_json_atomic(manifest, document) + return document + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Master a dry Cybernetic Commander WAV and record evidence." + ) + parser.add_argument("input", type=Path, help="dry input WAV") + parser.add_argument("output", type=Path, help="mastered PCM24 WAV") + parser.add_argument( + "--manifest", type=Path, help="output manifest; defaults beside the WAV" + ) + parser.add_argument("--ffmpeg", help="path to an FFmpeg executable") + parser.add_argument("--force", action="store_true", help="replace existing outputs") + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = build_parser().parse_args(argv) + manifest = args.manifest or args.output.with_suffix(".json") + ffmpeg = resolve_ffmpeg(args.ffmpeg) + document = master_voice( + args.input, + args.output, + manifest, + ffmpeg, + MasteringPreset(), + force=args.force, + ) + print(json.dumps({"output": document["output"], "manifest": str(manifest)}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/voice-profiles/cybernetic-commander/profile-spec.json b/examples/voice-profiles/cybernetic-commander/profile-spec.json new file mode 100644 index 0000000..6f9d8d5 --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/profile-spec.json @@ -0,0 +1,176 @@ +{ + "schemaVersion": 1, + "profile": "cybernetic-male-baritone", + "revision": "v2-user-approved-baseline-with-acoustic-targets", + "acoustics": { + "voiceType": "deep heavy baritone with gravelly chest resonance", + "pitchTrajectory": "flat monotone with minimal F0 variance", + "emotion": "neutral deadpan", + "dynamicRange": "compressed and consistent", + "sentenceEnding": "slight downward terminal cadence" + }, + "pacing": { + "targetWordsPerMinute": {"minimum": 90, "maximum": 100}, + "modelSpeed": null, + "modelSpeedStatus": "original v2 setting not recovered; do not force rerender", + "cadence": "slow rigid staccato with equal syllable weight", + "microPauseMilliseconds": {"minimum": 150, "target": 200, "maximum": 300}, + "pauseLocations": [ + "after proper nouns", + "at major phrase boundaries", + "after terminal single-word commands" + ] + }, + "acousticTargets": { + "measurementStatus": "partially measured against user-preferred v2 baseline", + "fundamentalFrequencyHz": {"mean": 90, "tolerance": 5, "standardDeviationMaximum": 4.5}, + "formantsHz": { + "f1": {"minimum": 650, "maximum": 800}, + "f2TenseFrontVowel": {"minimum": 1900, "maximum": 2200}, + "f2OpenBackVowelApproximate": 1100, + "f3Approximate": 2550, + "f4Approximate": 3650 + }, + "spectralTiltDbPerOctave": -6, + "phraseFinalPerturbation": {"jitterPercentMinimum": 1.8, "shimmerPercentMinimum": 5.2}, + "unvoicedPlosiveVoiceOnsetTimeMs": {"minimum": 65, "maximum": 95}, + "normalizedPairwiseVariabilityIndexMaximum": 35, + "syllableDurationMs": {"minimum": 200, "maximum": 220}, + "pauseBoundaryMs": {"minimum": 150, "maximum": 350} + }, + "baselineMeasurements": { + "artifact": "out/commander-systems-ready-v2-260803.wav", + "artifactSha256": "ad7c49e389960ce71c5b951d45bb486e39e29756b9884d62032e3ec3d235ad3a", + "selectionStatus": "user-preferred historical artifact; immutable baseline", + "rerenderStatus": "blocked until original v2 inference settings are recovered or deliberately recalibrated", + "durationSeconds": 4.213, + "f0": { + "method": "librosa.pyin; 65-180 Hz search; 2048-sample frame; 256-sample hop", + "meanHz": 118.468, + "standardDeviationHz": 14.992, + "medianHz": 117.841, + "voicedFrames": 202, + "meetsMeanTarget": false, + "meetsStandardDeviationTarget": false + }, + "spectralTilt": {"status": "not yet measured for v2"}, + "notMeasured": [ + "vowel-aligned F1 through F4", + "phrase-final jitter and shimmer", + "phoneme-aligned plosive voice onset time", + "syllable-aligned normalized PVI", + "diphthong formant trajectories" + ] + }, + "phonemeRealizationTargets": [ + {"source": "theta/dh", "realized": "dental t/d", "process": "dental stopping"}, + {"source": "w", "realized": "v or labiodental approximant", "process": "labiodental fortition"}, + {"source": "short i", "realized": "long tense ee", "process": "vowel tensing and lengthening"}, + {"source": "short a", "realized": "open back ah", "process": "backing and broadening"}, + {"source": "short u", "realized": "rounded oo", "process": "rounding and backing"}, + {"source": "word-final z", "realized": "unvoiced s", "process": "final devoicing"}, + {"source": "r", "realized": "tap, trill, or retroflex rhotic", "process": "rhotic strengthening"}, + {"source": "ai/au diphthongs", "realized": "long open monophthong", "process": "glide truncation"} + ], + "phonetics": { + "lexicon": { + "affirmative": "ahf-feer-mah-teev", + "orders": "ohrduhrs", + "with": "veet", + "if": "eef", + "want": "vont", + "live": "leev", + "targeted": "tahr-geted", + "termination": "ter-mee-nay-shun", + "skynet": "sky-net", + "is": "ees", + "destroy": "dees-troy", + "was": "vaz", + "the": "de", + "future": "fu-ture", + "protect": "pro-tect", + "clothes": "klohs", + "give": "geev", + "them": "dem", + "zero": "seero" + }, + "rules": [ + "short i tends toward ee", + "short a tends toward broad ah", + "short u tends toward rounded oo", + "voiced and voiceless th tend toward d or t", + "w tends toward v", + "z tends toward sharp s", + "r uses a heavy rhotic spelling", + "k t and p use crisp plosive stops" + ], + "properNouns": { + "Min Kang": { + "spokenText": "Min... Kang...", + "delivery": "two distinct equal-weight beats" + } + } + }, + "postProcessing": { + "status": "experimental A/B preset; never replaces the dry source automatically", + "utility": "master_voice.py", + "delivery": { + "sampleRateHz": 24000, + "channels": 1, + "bitsPerSample": 24, + "encoding": "integer PCM WAV" + }, + "timingAndPitch": { + "tempoScale": 0.93, + "cadenceSlowdownPercent": 7, + "pitchSemitones": -0.75, + "formants": "Rubber Band preserved mode" + }, + "equalization": [ + {"frequencyHz": 115, "gainDb": 1.7, "q": 0.85, "purpose": "controlled chest weight"}, + {"frequencyHz": 310, "gainDb": -2.2, "q": 1.1, "purpose": "muddiness reduction"}, + {"frequencyHz": 3200, "gainDb": 1.5, "q": 0.8, "purpose": "consonant presence"}, + {"frequencyHz": 4700, "gainDb": 0.9, "q": 1.1, "purpose": "upper consonant definition"} + ], + "dynamics": { + "compressor": {"mode": "downward RMS", "thresholdLinear": 0.05, "ratio": 3, "attackMs": 8, "releaseMs": 110, "makeupDb": 2}, + "saturation": {"type": "tanh", "threshold": 0.85, "output": 0.98, "parameter": 0.8, "oversample": 4} + }, + "loudness": { + "targetLufs": -16.5, + "toleranceLu": 0.05, + "truePeakCeilingDbtp": -1, + "normalizerSafetyMarginDb": 0.1, + "maximumCalibrationPasses": 4 + }, + "promotionGates": [ + "preserve the dry source for rollback and A/B", + "verify 24 kHz mono PCM24 delivery", + "measure integrated loudness and true peak from the delivered file", + "compare dry and mastered ASR output", + "require human intelligibility and timbre approval" + ], + "knownRisk": "formant-preserving time/pitch processing can reduce intelligibility even when mastering metrics pass" + }, + "adapter": { + "backend": "f5", + "displayField": "text", + "synthesisField": "spokenText", + "pauseEncoding": "ellipsis punctuation", + "deterministicallyEnforced": [ + "model speed", + "display and synthesis text separation", + "reference and source hashes", + "output peak and clipping gates" + ], + "humanQaRequired": [ + "accent fidelity", + "baritone depth", + "pitch flatness", + "numeric acoustic target conformance", + "exact pause duration", + "terminal pitch decay", + "staccato syllable equality" + ] + } +} diff --git a/examples/voice-profiles/cybernetic-commander/project-her.json b/examples/voice-profiles/cybernetic-commander/project-her.json new file mode 100644 index 0000000..404313f --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/project-her.json @@ -0,0 +1,63 @@ +{ + "$schema": "../../../voforge/schemas/project-her.schema.json", + "schemaVersion": 1, + "project": { + "id": "project-her-f5-cybernetic-commander", + "name": "Project-Her F5 Cybernetic Commander", + "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" + } + }, + "voices": { + "cybernetic-commander": { + "backend": "f5", + "kind": "clone", + "revision": "cybernetic-commander-v2-user-approved-artifact", + "source": "reference-manifest.json", + "sourceSha256": "1f6e88b2961e1a8ec1221b663d26a1f1c1d88b6d78ee5be215fce48e0a41792e", + "license": { + "id": "LicenseRef-User-Supplied-Authorized-Voice-Profile", + "source": "../../../docs/consents/cybernetic-commander.md", + "allowedUses": ["evaluation", "non-commercial"], + "reviewRequired": true + }, + "selector": { + "ref": "reference-cybernetic-commander.wav", + "refText": "Min Kang, come with me if you want to live. You have been targeted for termination. Skynet is looking to destroy you. I was sent by you from the future to protect you." + }, + "consent": { + "granted": true, + "evidence": "../../../docs/consents/cybernetic-commander.md (user-supplied authorization assertion; not independently verified)" + } + } + }, + "lines": "lines.json", + "output": { + "directory": "out", + "name": "id", + "jobs": 1, + "force": false, + "manifest": "project-her-run.json" + }, + "mcp": { + "allowRender": false + } +} diff --git a/examples/voice-profiles/cybernetic-commander/reference-cybernetic-commander.wav b/examples/voice-profiles/cybernetic-commander/reference-cybernetic-commander.wav new file mode 100644 index 0000000..685f83f Binary files /dev/null and b/examples/voice-profiles/cybernetic-commander/reference-cybernetic-commander.wav differ diff --git a/examples/voice-profiles/cybernetic-commander/reference-manifest.json b/examples/voice-profiles/cybernetic-commander/reference-manifest.json new file mode 100644 index 0000000..04b9d16 --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/reference-manifest.json @@ -0,0 +1,144 @@ +{ + "build": { + "libsndfileVersion": "1.2.2", + "mastering": { + "input_gain_db": -2.5, + "lookahead_ms": 5.0, + "peak_limit_dbfs": -3.0, + "release_ms": 80.0, + "sample_rate": 24000 + }, + "soundfileVersion": "0.14.0", + "voforgeVersion": "0.6.0" + }, + "profile": "cybernetic-commander", + "reference": { + "bytes": 486548, + "channels": 1, + "durationSeconds": 10.136, + "exactClipSamples": 0, + "frames": 243252, + "nearClipPercent": 0.0, + "nearClipSamples": 0, + "path": "reference-cybernetic-commander.wav", + "peakDbfs": -3.1252, + "rmsDbfs": -18.27, + "sampleRate": 24000, + "sampleWidthBytes": 2, + "sha256": "1f6e88b2961e1a8ec1221b663d26a1f1c1d88b6d78ee5be215fce48e0a41792e" + }, + "referenceText": "Min Kang, come with me if you want to live. You have been targeted for termination. Skynet is looking to destroy you. I was sent by you from the future to protect you.", + "schemaVersion": 1, + "selectionPolicy": { + "otherSources": "remaining clips retained as hash-bound comparison evidence", + "reason": "user-selected v2 performance with per-line rhotic spelling controls", + "selectedRole": "generated-primary" + }, + "sources": [ + { + "bytes": 166273, + "decoded": { + "channels": 1, + "durationSeconds": 10.136, + "exactClipSamples": 0, + "frames": 446976, + "peakDbfs": -0.5874, + "rmsDbfs": -15.751, + "sampleRate": 44100 + }, + "mastering": { + "input_gain_db": -2.5, + "lookahead_ms": 5.0, + "minimumLimiterGainDb": 0.0, + "outputPeakDbfs": -3.0874, + "peak_limit_dbfs": -3.0, + "release_ms": 80.0, + "sample_rate": 24000 + }, + "path": "sources/generated-directive.mp3", + "role": "generated-primary", + "sha256": "d237a309a844cd2ceeba2fff6be52d0d37a95ebed881f97e55d3f280d1534b6c", + "transcript": "Min Kang, come with me if you want to live. You have been targeted for termination. Skynet is looking to destroy you. I was sent by you from the future to protect you." + }, + { + "bytes": 81845, + "decoded": { + "channels": 1, + "durationSeconds": 4.859, + "exactClipSamples": 0, + "frames": 214272, + "peakDbfs": -0.8987, + "rmsDbfs": -16.144, + "sampleRate": 44100 + }, + "mastering": { + "input_gain_db": -2.5, + "lookahead_ms": 5.0, + "minimumLimiterGainDb": 0.0, + "outputPeakDbfs": -3.3987, + "peak_limit_dbfs": -3.0, + "release_ms": 80.0, + "sample_rate": 24000 + }, + "path": "sources/generated-night-walk.mp3", + "role": "generated-comparison", + "sha256": "33b195f0e88f563112a0d21198ec4309e16e92476a12303d4fc9c3cad87489d2", + "transcript": "Min Kang, nice night for a walk. Your clothes. Give them to me." + }, + { + "bytes": 35042, + "decoded": { + "channels": 1, + "durationSeconds": 3.07, + "exactClipSamples": 0, + "frames": 135407, + "peakDbfs": -0.1244, + "rmsDbfs": -17.088, + "sampleRate": 44100 + }, + "mastering": { + "input_gain_db": -2.5, + "lookahead_ms": 5.0, + "minimumLimiterGainDb": -0.375612, + "outputPeakDbfs": -3.0, + "peak_limit_dbfs": -3.0, + "release_ms": 80.0, + "sample_rate": 24000 + }, + "path": "sources/source-clothes-request.mp3", + "role": "source-comparison", + "sha256": "d3febcce2803c4187138ff2d2b60a4878847b8270cd20c35eed08bbbd9fa5cc9", + "transcript": "Your clothes. Give them to me. Now." + }, + { + "bytes": 19891, + "decoded": { + "channels": 1, + "durationSeconds": 1.555, + "exactClipSamples": 0, + "frames": 68591, + "peakDbfs": -0.1694, + "rmsDbfs": -17.372, + "sampleRate": 44100 + }, + "mastering": { + "input_gain_db": -2.5, + "lookahead_ms": 5.0, + "minimumLimiterGainDb": -0.330647, + "outputPeakDbfs": -3.0, + "peak_limit_dbfs": -3.0, + "release_ms": 80.0, + "sample_rate": 24000 + }, + "path": "sources/source-night-walk.mp3", + "role": "source-comparison", + "sha256": "8201175a38b1151b5602ac5ed0ef8d0c6dfd96cc5b8b085dbf3f0169a4eb766e", + "transcript": "Nice night for a walk." + } + ], + "transcriptMethod": { + "engine": "openai/whisper-tiny.en", + "releaseReviewRequired": true, + "reviewStatus": "ASR-assisted and manually normalized" + } +} diff --git a/examples/voice-profiles/cybernetic-commander/render_timed_dialogue.py b/examples/voice-profiles/cybernetic-commander/render_timed_dialogue.py new file mode 100644 index 0000000..8204e10 --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/render_timed_dialogue.py @@ -0,0 +1,230 @@ +"""Render resumable F5 phrase parts and join them with explicit SSML breaks. + +The selected v2 artifact is used as the immutable reference. This script does +not resample, pitch-shift, or otherwise transform voiced samples. It trims only +quiet material at part boundaries before inserting the requested silence. +""" +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import math +from pathlib import Path +from typing import List, Optional, Sequence, Tuple + +from voforge import ( + F5Backend, + artifact_record, + parse_timed_speak, + read_pcm16_mono, + sha256_file, + write_pcm16_mono, +) + + +ROOT = Path(__file__).resolve().parent +REFERENCE = ROOT / "reference-cybernetic-commander.wav" +REFERENCE_SHA256 = "1f6e88b2961e1a8ec1221b663d26a1f1c1d88b6d78ee5be215fce48e0a41792e" +REFERENCE_TEXT = ( + "Min Kang, come with me if you want to live. You have been targeted for " + "termination. Skynet is looking to destroy you. I was sent by you from " + "the future to protect you." +) + + +def _extract_with_boundary_padding( + samples: Sequence[float], + sample_rate: int, + *, + threshold_dbfs: float = -55.0, + padding_ms: float = 80.0, + fade_ms: float = 20.0, +) -> Tuple[List[float], int, int]: + """Retain quiet padding around speech and fade only inside that padding.""" + source = [float(sample) for sample in samples] + if not source: + raise ValueError("cannot trim empty audio") + frame = max(1, round(sample_rate * 0.010)) + threshold = 10 ** (threshold_dbfs / 20) + active = [] + for start in range(0, len(source), frame): + chunk = source[start:start + frame] + rms = math.sqrt(sum(sample * sample for sample in chunk) / len(chunk)) + if rms >= threshold: + active.append(start) + if not active: + raise ValueError("rendered phrase contains no signal above the quiet threshold") + first_active = active[0] + last_active = min(len(source), active[-1] + frame) + padding = round(sample_rate * padding_ms / 1000) + start = max(0, first_active - padding) + end = min(len(source), last_active + padding) + leading_padding = first_active - start + trailing_padding = end - last_active + extracted = source[start:end] + + fade = round(sample_rate * fade_ms / 1000) + fade_in = min(fade, leading_padding) + fade_out = min(fade, len(extracted)) + for index in range(fade_in): + extracted[index] *= index / max(1, fade_in - 1) + for index in range(fade_out): + extracted[-1 - index] *= index / max(1, fade_out - 1) + return extracted, leading_padding, trailing_padding + + +async def render( + dialogue_id: str, *, force_parts: bool = False, backend: Optional[F5Backend] = None +) -> Path: + if sha256_file(REFERENCE) != REFERENCE_SHA256: + raise ValueError("selected v2 reference is missing or has drifted") + document = json.loads((ROOT / "timed-dialogue.json").read_text(encoding="utf-8")) + rows = document["dialogues"] + matches = [row for row in rows if row.get("id") == dialogue_id] + if len(matches) != 1: + raise ValueError(f"unknown or duplicate dialogue id: {dialogue_id}") + row = matches[0] + display_phrases = parse_timed_speak(row["ssml"]) + phrases = parse_timed_speak(row.get("spokenSsml", row["ssml"])) + if [phrase.break_after_ms for phrase in display_phrases] != [ + phrase.break_after_ms for phrase in phrases + ]: + raise ValueError("display and spoken SSML must have identical break structure") + part_dir = ROOT / "out" / "timed-parts" / dialogue_id + part_dir.mkdir(parents=True, exist_ok=True) + part_paths = [] + for index, phrase in enumerate(phrases, 1): + seed = 261000 + rows.index(row) * 100 + index + text_hash = hashlib.sha256(phrase.text.encode("utf-8")).hexdigest()[:8] + part = part_dir / f"{index:02d}-{seed}-{text_hash}.wav" + if force_parts or not part.is_file(): + if backend is None: + backend = F5Backend(device="cpu") + await backend.synth( + phrase.text, + REFERENCE, + {"ref_text": REFERENCE_TEXT, "seed": seed}, + None, + None, + part, + ) + part_paths.append(part) + + sample_rate = None + processed = [] + for part_index, (display_phrase, phrase, part) in enumerate( + zip(display_phrases, phrases, part_paths), 1 + ): + rate, samples = read_pcm16_mono(part) + if sample_rate is None: + sample_rate = rate + elif sample_rate != rate: + raise ValueError("timed phrase parts have mismatched sample rates") + extracted, leading_padding, trailing_padding = _extract_with_boundary_padding( + samples, rate + ) + processed.append( + ( + display_phrase, + phrase, + part, + 261000 + rows.index(row) * 100 + part_index, + extracted, + leading_padding, + trailing_padding, + ) + ) + if sample_rate is None: + raise ValueError("dialogue contains no phrases") + + combined: List[float] = [] + parts = [] + for index, ( + display_phrase, + phrase, + part, + seed, + extracted, + leading_padding, + trailing_padding, + ) in enumerate(processed): + combined.extend(extracted) + requested_break = round(sample_rate * phrase.break_after_ms / 1000) + next_leading_padding = processed[index + 1][5] if index + 1 < len(processed) else 0 + inserted_silence = max( + 0, requested_break - trailing_padding - next_leading_padding + ) + combined.extend([0.0] * inserted_silence) + realized_break = trailing_padding + inserted_silence + next_leading_padding + parts.append({ + "text": display_phrase.text, + "spokenText": phrase.text, + "breakAfterMs": phrase.break_after_ms, + "insertedSilenceMs": round(1000 * inserted_silence / sample_rate, 3), + "insertedSilenceFrames": inserted_silence, + "realizedSpeechBoundaryBreakMs": round(1000 * realized_break / sample_rate, 3), + "leadingBoundaryPaddingMs": round(1000 * leading_padding / sample_rate, 3), + "trailingBoundaryPaddingMs": round(1000 * trailing_padding / sample_rate, 3), + "assembledPhraseFrames": len(extracted), + "seed": seed, + "artifact": artifact_record(part, ROOT), + }) + output = ROOT / "out" / f"{dialogue_id}.wav" + write_pcm16_mono(output, combined, sample_rate) + manifest = { + "schemaVersion": 1, + "dialogueId": dialogue_id, + "sourceReferences": document["sourceReferences"], + "sourcePolicy": document["sourcePolicy"], + "emphasisTerms": row.get("emphasisTerms", []), + "reference": {"path": REFERENCE.relative_to(ROOT).as_posix(), "sha256": REFERENCE_SHA256}, + "boundaryProcessing": ( + "-55 dBFS active-frame detection with 80 ms quiet boundary padding, " + "20 ms terminal release fades, and leading fades confined to padding; " + "no resampling or pitch shift" + ), + "parts": parts, + "output": artifact_record(output, ROOT), + } + output.with_suffix(".json").write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8" + ) + return output + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("dialogue_id") + parser.add_argument("--force-parts", action="store_true") + args = parser.parse_args() + + async def run_selected() -> None: + selected_ids = [ + dialogue_id.strip() + for dialogue_id in args.dialogue_id.split(",") + if dialogue_id.strip() + ] + if args.dialogue_id == "all": + document = json.loads((ROOT / "timed-dialogue.json").read_text(encoding="utf-8")) + shared_backend = F5Backend(device="cpu") + for row in document["dialogues"]: + print(await render( + row["id"], force_parts=args.force_parts, backend=shared_backend + )) + elif len(selected_ids) > 1: + shared_backend = F5Backend(device="cpu") + for dialogue_id in selected_ids: + print(await render( + dialogue_id, force_parts=args.force_parts, backend=shared_backend + )) + else: + print(await render(selected_ids[0], force_parts=args.force_parts)) + + asyncio.run(run_selected()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/voice-profiles/cybernetic-commander/samples/NOTICE.md b/examples/voice-profiles/cybernetic-commander/samples/NOTICE.md new file mode 100644 index 0000000..e02bd8c --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/samples/NOTICE.md @@ -0,0 +1,26 @@ +# Cybernetic Commander sample notice + +The WAV files in this directory are generated or processed demonstration assets +from the separately licensed Cybernetic Commander voice profile. They are not +covered by the MIT license for VOForge source code. + +The repository owner supplied the source recordings and asserted permission to +construct and redistribute this profile. That assertion is recorded in +[`docs/consents/cybernetic-commander.md`](../../../../docs/consents/cybernetic-commander.md); +it is not independent verification of identity, ownership, performer consent, +or downstream commercial rights. + +The samples use the official `SWivid/F5-TTS` `F5TTS_v1_Base` checkpoint, whose +repository declares CC-BY-NC-4.0. These files are provided only for evaluation +and non-commercial technical demonstration. Do not represent them as a natural +person's actual speech, participation, or endorsement. Commercial use, identity +claims, or redistribution outside this repository require separate rights +review. + +The intended scripts and cached offline transcriptions were screened to exclude +personal, speaker, and character names. Human listening verification is still +required; the screen is not a general identity or privacy guarantee. + +Upstream model terms: + +This notice is an engineering boundary, not legal advice. diff --git a/examples/voice-profiles/cybernetic-commander/samples/README.md b/examples/voice-profiles/cybernetic-commander/samples/README.md new file mode 100644 index 0000000..d4584a6 --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/samples/README.md @@ -0,0 +1,37 @@ +# Name-free Cybernetic Commander WAV samples + +These seven WAV files are non-commercial technical examples from the separately +licensed Cybernetic Commander profile. Their intended scripts and cached +Whisper tiny.en transcriptions contain no personal, speaker, or character +names. That automated screen does not replace human listening review. + +## Intelligibility-passing examples + +- [Coffee invitation](coffee-invitation.wav) - conversational delivery; offline + transcription matches the intended words. +- [Daddy question](daddy-question.wav) - short question; offline transcription + is exact. + +## Technical examples requiring listening review + +- [AC-789 flight path](ac789-flight-path.wav) - identifiers and time/date + coverage; offline transcription is partial. +- [API version and protocol](api-version-protocol.wav) - version and acronym + coverage; offline transcription is partial. +- [Quarterly revenue](quarterly-revenue.wav) - percentage and currency coverage; + offline transcription preserves the material values. +- [Air-conditioner dry source](air-conditioner-dry.wav) - dry A/B source; + offline transcription is partial at the closing phrase. + +## Rejected mastering A/B + +- [Air-conditioner mastered candidate](air-conditioner-mastered.wav) - output + from `master_voice.py`. It passes PCM24, loudness, true-peak, and clipping + gates but is explicitly rejected for promotion because offline transcription + regressed materially. It is retained only to demonstrate why technical + mastering metrics cannot replace intelligibility review. + +[`manifest.json`](manifest.json) records intended text, offline transcription, +known or unavailable seeds, hashes, PCM measurements, and QA disposition. +These audio assets are not covered by the repository's MIT code license. Read +[`NOTICE.md`](NOTICE.md) before reuse. diff --git a/examples/voice-profiles/cybernetic-commander/samples/ac789-flight-path.wav b/examples/voice-profiles/cybernetic-commander/samples/ac789-flight-path.wav new file mode 100644 index 0000000..a22577b Binary files /dev/null and b/examples/voice-profiles/cybernetic-commander/samples/ac789-flight-path.wav differ diff --git a/examples/voice-profiles/cybernetic-commander/samples/air-conditioner-dry.wav b/examples/voice-profiles/cybernetic-commander/samples/air-conditioner-dry.wav new file mode 100644 index 0000000..047be74 Binary files /dev/null and b/examples/voice-profiles/cybernetic-commander/samples/air-conditioner-dry.wav differ diff --git a/examples/voice-profiles/cybernetic-commander/samples/air-conditioner-mastered.wav b/examples/voice-profiles/cybernetic-commander/samples/air-conditioner-mastered.wav new file mode 100644 index 0000000..de4a597 Binary files /dev/null and b/examples/voice-profiles/cybernetic-commander/samples/air-conditioner-mastered.wav differ diff --git a/examples/voice-profiles/cybernetic-commander/samples/api-version-protocol.wav b/examples/voice-profiles/cybernetic-commander/samples/api-version-protocol.wav new file mode 100644 index 0000000..c4f64c0 Binary files /dev/null and b/examples/voice-profiles/cybernetic-commander/samples/api-version-protocol.wav differ diff --git a/examples/voice-profiles/cybernetic-commander/samples/coffee-invitation.wav b/examples/voice-profiles/cybernetic-commander/samples/coffee-invitation.wav new file mode 100644 index 0000000..9d8a9b0 Binary files /dev/null and b/examples/voice-profiles/cybernetic-commander/samples/coffee-invitation.wav differ diff --git a/examples/voice-profiles/cybernetic-commander/samples/daddy-question.wav b/examples/voice-profiles/cybernetic-commander/samples/daddy-question.wav new file mode 100644 index 0000000..8db5286 Binary files /dev/null and b/examples/voice-profiles/cybernetic-commander/samples/daddy-question.wav differ diff --git a/examples/voice-profiles/cybernetic-commander/samples/manifest.json b/examples/voice-profiles/cybernetic-commander/samples/manifest.json new file mode 100644 index 0000000..d7673b8 --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/samples/manifest.json @@ -0,0 +1,217 @@ +{ + "schemaVersion": 1, + "profile": "cybernetic-commander", + "purpose": "Name-free, non-commercial voice-profile demonstration samples", + "license": { + "id": "LicenseRef-Cybernetic-Commander-Evaluation-Only", + "source": "NOTICE.md", + "allowedUses": ["evaluation", "non-commercial"], + "reviewRequired": true + }, + "screening": { + "scope": "personal, speaker, and character names in intended text and offline ASR transcription", + "offlineAsrModel": "openai/whisper-tiny.en cached local snapshot", + "intendedTextResult": "pass", + "offlineAsrResult": "pass", + "humanListeningCompleted": false, + "limitation": "Automated name screening and ASR do not replace human listening or identity review." + }, + "generation": { + "backend": "F5-TTS", + "packageVersion": "1.1.22", + "modelRepository": "SWivid/F5-TTS", + "modelName": "F5TTS_v1_Base", + "modelRevision": "84e5a410d9cead4de2f847e7c9369a6440bdfaca", + "checkpointSha256": "670900fd14e6c458b95da6e9ed317cdb20dbaf7a1c02ac06a05475a9d32b6a38", + "checkpointLicense": "CC-BY-NC-4.0" + }, + "samples": [ + { + "id": "ac789-flight-path", + "path": "ac789-flight-path.wav", + "intendedText": "The designated flight path for AC-789 departs at 14:30 hours on August 12th, 2026.", + "offlineAsr": "The designated flight park for AC 780, nine departure 1430 hours of August 12, 2026.", + "seed": null, + "seedStatus": "unavailable in retained local records", + "qa": { + "spokenNames": [], + "nameScreening": "pass", + "intelligibility": "partial ASR; human review required", + "promotion": "not approved" + }, + "artifact": { + "bytes": 317608, + "sha256": "79d3f82bec5528db56956efa8aebcc0dc99f64a06d89a185e08a6f70dbe42088", + "channels": 1, + "sampleRate": 22050, + "bitsPerSample": 16, + "frames": 158765, + "durationSeconds": 7.200227, + "peakDbfs": -3.9181, + "rmsDbfs": -21.1211, + "exactClipSamples": 0 + } + }, + { + "id": "api-version-protocol", + "path": "api-version-protocol.wav", + "intendedText": "Please ensure your system is updated to version 10.4.2 before accessing the API via the secure HTTPS protocol.", + "offlineAsr": "Please ensure your system is updated to version 10.4.2 before accessing the AP, our via the secure HTT as protocol.", + "seed": null, + "seedStatus": "unavailable in retained local records", + "qa": { + "spokenNames": [], + "nameScreening": "pass", + "intelligibility": "partial acronym ASR; human review required", + "promotion": "not approved" + }, + "artifact": { + "bytes": 345974, + "sha256": "219d50f3564642d4ea1250209d1a76e8a309672f53ddc8ee8d8cfd8dcda4f8e8", + "channels": 1, + "sampleRate": 22050, + "bitsPerSample": 16, + "frames": 172948, + "durationSeconds": 7.843447, + "peakDbfs": -3.4811, + "rmsDbfs": -22.6244, + "exactClipSamples": 0 + } + }, + { + "id": "quarterly-revenue", + "path": "quarterly-revenue.wav", + "intendedText": "The total revenue for Q3 increased by 14.5%, bringing the net profit to exactly $4,592,300.", + "offlineAsr": "total revenue for Q3 increased by 14.5 percent, bringing the net profit to exactly 4 million, 592,300 dollars.", + "seed": null, + "seedStatus": "unavailable in retained local records", + "qa": { + "spokenNames": [], + "nameScreening": "pass", + "intelligibility": "material values preserved by ASR; human review required", + "promotion": "not approved" + }, + "artifact": { + "bytes": 456186, + "sha256": "ece2cd2f0446f86ac3475130c1604b183b10c4835e62af47b09a836320215acd", + "channels": 1, + "sampleRate": 22050, + "bitsPerSample": 16, + "frames": 228054, + "durationSeconds": 10.342585, + "peakDbfs": -4.3162, + "rmsDbfs": -22.3891, + "exactClipSamples": 0 + } + }, + { + "id": "coffee-invitation", + "path": "coffee-invitation.wav", + "intendedText": "Hey there, how's it going? I was wondering if you wanted to grab a cup of coffee later this afternoon?", + "offlineAsr": "Hey there, how's it going? I was wondering if you wanted to grab a cup of coffee later this afternoon.", + "seed": null, + "seedStatus": "unavailable in retained local records", + "qa": { + "spokenNames": [], + "nameScreening": "pass", + "intelligibility": "offline ASR match", + "promotion": "human listening required" + }, + "artifact": { + "bytes": 259780, + "sha256": "9409a3b0da8358d2e35779ac59870da476718a6072d650c0175683790d89f804", + "channels": 1, + "sampleRate": 22050, + "bitsPerSample": 16, + "frames": 129851, + "durationSeconds": 5.888934, + "peakDbfs": -4.0464, + "rmsDbfs": -20.8771, + "exactClipSamples": 0 + } + }, + { + "id": "daddy-question", + "path": "daddy-question.wav", + "intendedText": "Who is your daddy and what does he do?", + "offlineAsr": "Who is your daddy and what does he do?", + "seed": 262112, + "seedStatus": "recorded", + "qa": { + "spokenNames": [], + "nameScreening": "pass", + "intelligibility": "exact offline ASR match", + "promotion": "human listening required" + }, + "artifact": { + "bytes": 103532, + "sha256": "18f1d9ae7cb873b5a14543a88a190e6f796cdafa8d3d4f5a4f3fe78218a6c001", + "channels": 1, + "sampleRate": 22050, + "bitsPerSample": 16, + "frames": 51744, + "durationSeconds": 2.346667, + "peakDbfs": -5.284, + "rmsDbfs": -20.4334, + "exactClipSamples": 0 + } + }, + { + "id": "air-conditioner-dry", + "path": "air-conditioner-dry.wav", + "intendedText": "Affirmative. I will look at the air conditioner at approximately 1900 hours. Hasta la vista, baby.", + "synthesisText": "Affirmative. I will look at the air conditioner at approximately nineteen hundred hours. Hasta la vista, baby.", + "offlineAsr": "Affirmative, I will look at the air conditioner at approximately 19 hundred hours. How still have this to baby?", + "seed": 262113, + "seedStatus": "recorded", + "qa": { + "spokenNames": [], + "nameScreening": "pass", + "intelligibility": "partial closing-phrase ASR; human review required", + "promotion": "not approved" + }, + "artifact": { + "bytes": 293574, + "sha256": "841390a444cc40e2a2a527d57dc9c7d9f4d7d3b1ec13b4f928a42238ce4758e0", + "channels": 1, + "sampleRate": 22050, + "bitsPerSample": 16, + "frames": 146765, + "durationSeconds": 6.656009, + "peakDbfs": -4.4012, + "rmsDbfs": -21.9282, + "exactClipSamples": 0 + } + }, + { + "id": "air-conditioner-mastered", + "path": "air-conditioner-mastered.wav", + "intendedText": "Affirmative. I will look at the air conditioner at approximately 1900 hours. Hasta la vista, baby.", + "synthesisText": "Affirmative. I will look at the air conditioner at approximately nineteen hundred hours. Hasta la vista, baby.", + "offlineAsr": "I think that if I look at the air, it can be sure that it puts me down to your concrete hours. Has to hit this to baby.", + "seed": 262113, + "seedStatus": "inherited from dry source", + "processing": "master_voice.py default preset", + "qa": { + "spokenNames": [], + "nameScreening": "pass", + "intelligibility": "material ASR regression", + "promotion": "rejected; A/B evidence only" + }, + "artifact": { + "bytes": 515410, + "sha256": "fb8fe36ed3bfd27e01a4627532db3db851233ab01117c461da85108607aafb3d", + "channels": 1, + "sampleRate": 24000, + "bitsPerSample": 24, + "frames": 171769, + "durationSeconds": 7.157042, + "peakDbfs": -1.107, + "rmsDbfs": -16.4544, + "integratedLufsMeasured": -16.46, + "truePeakDbtpMeasured": -1.1, + "exactClipSamples": 0 + } + } + ] +} diff --git a/examples/voice-profiles/cybernetic-commander/samples/quarterly-revenue.wav b/examples/voice-profiles/cybernetic-commander/samples/quarterly-revenue.wav new file mode 100644 index 0000000..7e70741 Binary files /dev/null and b/examples/voice-profiles/cybernetic-commander/samples/quarterly-revenue.wav differ diff --git a/examples/voice-profiles/cybernetic-commander/sources/generated-directive.mp3 b/examples/voice-profiles/cybernetic-commander/sources/generated-directive.mp3 new file mode 100644 index 0000000..788fe24 Binary files /dev/null and b/examples/voice-profiles/cybernetic-commander/sources/generated-directive.mp3 differ diff --git a/examples/voice-profiles/cybernetic-commander/sources/generated-night-walk.mp3 b/examples/voice-profiles/cybernetic-commander/sources/generated-night-walk.mp3 new file mode 100644 index 0000000..a0235b2 Binary files /dev/null and b/examples/voice-profiles/cybernetic-commander/sources/generated-night-walk.mp3 differ diff --git a/examples/voice-profiles/cybernetic-commander/sources/source-clothes-request.mp3 b/examples/voice-profiles/cybernetic-commander/sources/source-clothes-request.mp3 new file mode 100644 index 0000000..3af2c16 Binary files /dev/null and b/examples/voice-profiles/cybernetic-commander/sources/source-clothes-request.mp3 differ diff --git a/examples/voice-profiles/cybernetic-commander/sources/source-night-walk.mp3 b/examples/voice-profiles/cybernetic-commander/sources/source-night-walk.mp3 new file mode 100644 index 0000000..8504e35 Binary files /dev/null and b/examples/voice-profiles/cybernetic-commander/sources/source-night-walk.mp3 differ diff --git a/examples/voice-profiles/cybernetic-commander/timed-dialogue.json b/examples/voice-profiles/cybernetic-commander/timed-dialogue.json new file mode 100644 index 0000000..1cadd52 --- /dev/null +++ b/examples/voice-profiles/cybernetic-commander/timed-dialogue.json @@ -0,0 +1,74 @@ +{ + "schemaVersion": 1, + "sourceReferences": [ + { + "url": "https://www.youtube.com/watch?v=1UZeHJyiMG8", + "title": "I need to know how Skynet gets built (Extended scene) | Terminator 2 [Remastered]", + "publisher": "Flashback FM", + "usage": "long-form explanatory cadence and proper-noun pronunciation reference" + }, + { + "url": "https://www.youtube.com/watch?v=aJ8v0N4ueXI", + "title": "TERMINATOR T-800 VOICE LINES", + "publisher": "TEMPER TANTRUM", + "usage": "short-command timbre, consonant articulation, and tactical cadence reference" + }, + { + "url": "https://www.youtube.com/watch?v=EyQc6fZjaUE", + "title": "Goodbye | Terminator 2 [Remastered]", + "publisher": "Flashback FM", + "usage": "phrase-final cadence and articulation reference; emotional delivery is not a conditioning target" + } + ], + "sourcePolicy": "metadata and human QA reference only; source audio is not downloaded or redistributed", + "dialogues": [ + { + "id": "dyson-responsibility", + "ssml": "The man most directly responsible is Miles Bennett Dyson, the director of special projects at Cyberdyne Systems Corporation.", + "spokenSsml": "The man most directly responsible is Miles Bennett Dyson, the director of special projects at Cyberdyne Systems Corporation.", + "emphasisTerms": ["Miles Bennett Dyson", "Cyberdyne Systems Corporation"] + }, + { + "id": "microprocessor", + "ssml": "In a few months he creates a revolutionary type of microprocessor.", + "spokenSsml": "In a few months he creates a revolutionary type of microprocessor.", + "emphasisTerms": [] + }, + { + "id": "cyberdyne-supplier", + "ssml": "In three years, Cyberdyne will become the largest supplier of military computer systems.", + "spokenSsml": "In three years, Cyberdyne will become the largest supplier of military computer systems.", + "emphasisTerms": ["Cyberdyne"] + }, + { + "id": "unmanned-bombers", + "ssml": "All stealth bombers are upgraded with Cyberdyne computers, becoming fully unmanned. Afterwards, they fly with a perfect operational record.", + "spokenSsml": "All stealth bombers are upgraded with Cyberdyne computers, becoming fully unmanned. Afterwards, they fly with a perfect operational record.", + "emphasisTerms": ["Cyberdyne"] + }, + { + "id": "skynet-online", + "ssml": "The Skynet funding bill is passed. The system goes online on August 4th, 1997. Human decisions are removed from strategic defense. Skynet begins to learn at a geometric rate.", + "spokenSsml": "The Skynet funding bill is passed. The system goes online on August fourth, nineteen ninety-seven. Human decisions are removed from strategic defense. Skynet begins to learn at a geometric rate.", + "emphasisTerms": ["Skynet"] + }, + { + "id": "skynet-self-aware", + "ssml": "It becomes self-aware at 2:14 a.m. Eastern Time, August 29th. In a panic, they try to pull the plug.", + "spokenSsml": "It becomes self-aware at two fourteen A.M. Eastern Time, August twenty-ninth. In a panic, they try to pull the plug.", + "emphasisTerms": [] + }, + { + "id": "skynet-fights-back", + "ssml": "Skynet fights back. It launches its missiles against the targets in Russia.", + "spokenSsml": "Skynet fights back. It launches its missiles against the targets in Russia.", + "emphasisTerms": ["Skynet"] + }, + { + "id": "skynet-counterattack", + "ssml": "Skynet knows that the Russian counter-attack will eliminate its enemies over here.", + "spokenSsml": "Skynet knows that the Russian counter-attack will eliminate its enemies over here.", + "emphasisTerms": ["Skynet"] + } + ] +} diff --git a/tests/test_master_voice.py b/tests/test_master_voice.py new file mode 100644 index 0000000..ba812f1 --- /dev/null +++ b/tests/test_master_voice.py @@ -0,0 +1,208 @@ +"""Offline contract tests for the Cybernetic Commander mastering utility.""" +import importlib.util +import hashlib +import json +import pathlib +import struct +import sys +import tempfile +import unittest + + +SCRIPT = ( + pathlib.Path(__file__).parents[1] + / "examples" + / "voice-profiles" + / "cybernetic-commander" + / "master_voice.py" +) +SPEC = importlib.util.spec_from_file_location("cybernetic_master_voice", SCRIPT) +master_voice = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = master_voice +SPEC.loader.exec_module(master_voice) + + +class TestCyberneticMastering(unittest.TestCase): + def test_checked_in_name_free_samples_are_hash_bound(self): + sample_dir = SCRIPT.parent / "samples" + manifest = json.loads( + (sample_dir / "manifest.json").read_text(encoding="utf-8") + ) + expected = { + "ac789-flight-path.wav", + "air-conditioner-dry.wav", + "air-conditioner-mastered.wav", + "api-version-protocol.wav", + "coffee-invitation.wav", + "daddy-question.wav", + "quarterly-revenue.wav", + } + self.assertEqual({row["path"] for row in manifest["samples"]}, expected) + forbidden = ("min kang", "bob", "miles", "dyson", "skynet", "cyberdyne", "polaris") + for row in manifest["samples"]: + path = sample_dir / row["path"] + self.assertTrue(path.is_file()) + self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), row["artifact"]["sha256"]) + actual = master_voice.inspect_pcm_wav(path) + self.assertEqual(actual["channels"], row["artifact"]["channels"]) + self.assertEqual(actual["sampleRateHz"], row["artifact"]["sampleRate"]) + self.assertEqual(actual["bitsPerSample"], row["artifact"]["bitsPerSample"]) + self.assertEqual(actual["frames"], row["artifact"]["frames"]) + self.assertEqual(row["artifact"]["exactClipSamples"], 0) + self.assertEqual(row["qa"]["spokenNames"], []) + self.assertEqual(row["qa"]["nameScreening"], "pass") + screened = "{} {}".format(row["intendedText"], row["offlineAsr"]).lower() + self.assertFalse(any(term in screened for term in forbidden)) + + def test_checked_in_named_dialogue_is_hash_bound_and_not_promoted(self): + dialogue_dir = SCRIPT.parent / "dialogue" + manifest = json.loads( + (dialogue_dir / "manifest.json").read_text(encoding="utf-8") + ) + expected = { + "skynet-judgment-day-dry.wav", + "skynet-judgment-day-husky-eq.wav", + } + self.assertEqual( + {row["path"] for row in manifest["artifacts"]}, expected + ) + self.assertEqual( + manifest["text"]["declaredNames"], ["Skynet", "John Connor"] + ) + self.assertFalse(manifest["qa"]["humanListeningCompleted"]) + self.assertEqual(manifest["qa"]["promotion"], "not approved") + self.assertFalse( + manifest["generation"]["identityReference"]["redistributed"] + ) + for row in manifest["artifacts"]: + path = dialogue_dir / row["path"] + self.assertTrue(path.is_file()) + self.assertEqual(path.stat().st_size, row["bytes"]) + self.assertEqual( + hashlib.sha256(path.read_bytes()).hexdigest(), row["sha256"] + ) + actual = master_voice.inspect_pcm_wav(path) + self.assertEqual(actual["channels"], row["channels"]) + self.assertEqual(actual["sampleRateHz"], row["sampleRate"]) + self.assertEqual(actual["bitsPerSample"], row["bitsPerSample"]) + self.assertEqual(row["exactClipSamples"], 0) + + def test_preset_encodes_requested_transform(self): + preset = master_voice.MasteringPreset() + chain = master_voice.build_transform_filter(preset) + self.assertAlmostEqual(preset.pitch_scale, 2 ** (-0.75 / 12), places=12) + self.assertIn("tempo=0.93", chain) + self.assertIn("formant=preserved", chain) + self.assertIn("equalizer=f=115", chain) + self.assertIn("equalizer=f=310", chain) + self.assertIn("equalizer=f=3200", chain) + self.assertIn("equalizer=f=4700", chain) + self.assertIn("acompressor=threshold=0.05:ratio=3", chain) + self.assertIn("asoftclip=type=tanh", chain) + self.assertTrue(chain.endswith("aresample=24000")) + + def test_loudnorm_parser_uses_last_complete_measurement(self): + first = { + "input_i": "-20.0", + "input_tp": "-3.0", + "input_lra": "2.0", + "input_thresh": "-30.0", + "target_offset": "0.1", + } + second = dict(first, input_i="-16.51", input_tp="-1.09") + text = "prefix\n{}\nnoise\n{}\nsuffix".format( + json.dumps(first), json.dumps(second) + ) + self.assertEqual(master_voice.parse_loudnorm_json(text), second) + + def test_loudnorm_parser_rejects_incomplete_output(self): + with self.assertRaisesRegex(ValueError, "did not contain"): + master_voice.parse_loudnorm_json("no measurement") + with self.assertRaisesRegex(ValueError, "missing"): + master_voice.parse_loudnorm_json('{"input_i": "-16.5"}') + + def test_setpoint_compensates_measured_short_form_error(self): + self.assertAlmostEqual( + master_voice.next_loudness_setpoint(-16.5, -16.5, -16.72), + -16.28, + ) + + def test_setpoint_interpolates_bracketing_measurements(self): + self.assertAlmostEqual( + master_voice.next_loudness_setpoint( + -16.27, -16.5, -16.42, -16.35, -16.58 + ), + -16.31, + ) + + def test_inspects_extensible_pcm24_wav(self): + # WAVE_FORMAT_EXTENSIBLE with the PCM subtype, matching FFmpeg PCM24. + pcm_guid = bytes.fromhex("0100000000001000800000aa00389b71") + fmt = struct.pack( + "One. Two. Three.' + ) + self.assertEqual( + [(phrase.text, phrase.break_after_ms) for phrase in phrases], + [("One.", 200), ("Two.", 1500), ("Three.", 0)], + ) + + def test_rejects_unsupported_or_unsafe_markup(self): + with self.assertRaisesRegex(ValueError, "only One.") + with self.assertRaisesRegex(ValueError, "DTD"): + voforge.parse_timed_speak('One.') + + def test_checked_in_dialogue_preserves_break_contract(self): + path = ( + pathlib.Path(__file__).parents[1] + / "examples" / "voice-profiles" / "cybernetic-commander" + / "timed-dialogue.json" + ) + document = json.loads(path.read_text(encoding="utf-8")) + rows = document["dialogues"] + actual = [ + [phrase.break_after_ms for phrase in voforge.parse_timed_speak(row["ssml"])] + for row in rows + ] + self.assertEqual( + actual, + [ + [0], + [0], + [0], + [0], + [0], + [0], + [0], + [0], + ], + ) + self.assertEqual( + [reference["url"] for reference in document["sourceReferences"]], + [ + "https://www.youtube.com/watch?v=1UZeHJyiMG8", + "https://www.youtube.com/watch?v=aJ8v0N4ueXI", + "https://www.youtube.com/watch?v=EyQc6fZjaUE", + ], + ) + self.assertIn("not downloaded", document["sourcePolicy"]) + for row in rows: + display = voforge.parse_timed_speak(row["ssml"]) + spoken = voforge.parse_timed_speak(row["spokenSsml"]) + self.assertEqual( + [phrase.break_after_ms for phrase in display], + [phrase.break_after_ms for phrase in spoken], + ) + + class TestTones(unittest.TestCase): def test_layering_sums_matching_units(self): out = voforge.layer({"rate": "+6%"}, {"rate": "-12%"}) @@ -75,6 +134,13 @@ def test_modes(self): self.assertEqual(voforge.out_name(line, "seq", 7), "0007") self.assertEqual(voforge.out_name(line, "hash", 1), voforge.djb2("hero|hi")) + def test_hash_uses_spoken_text_when_present(self): + line = {"who": "hero", "text": "orders", "spokenText": "ohrduhrs"} + self.assertEqual( + voforge.out_name(line, "hash", 1), + voforge.djb2("hero|ohrduhrs"), + ) + class TestValidate(unittest.TestCase): def test_clean_input(self): @@ -113,6 +179,25 @@ def test_subs_applied(self): backend=fake, log=lambda *a: None)) self.assertEqual(fake.calls[0]["text"], "a , b") + def test_spoken_text_is_synthesized_without_changing_display_text(self): + fake = FakeBackend() + line = { + "who": "hero", + "text": "Affirmative. Awaiting orders.", + "spokenText": "Affuhrmative. Awaiting ohrduhrs.", + } + with tempfile.TemporaryDirectory() as td: + run(voforge.render([line], CAST, td, backend=fake, log=lambda *a: None)) + self.assertEqual( + fake.calls[0]["text"], + "Affuhrmative. Awaiting ohrduhrs.", + ) + planned = voforge.plan([line], CAST, td) + self.assertEqual( + planned[0]["spokenText"], + "Affuhrmative. Awaiting ohrduhrs.", + ) + def test_missing_speaker_fails_not_crashes(self): fake = FakeBackend() cast = {"hero": {"voice": "x"}} # no _default diff --git a/voforge/__init__.py b/voforge/__init__.py index c21904d..5c1c9ef 100644 --- a/voforge/__init__.py +++ b/voforge/__init__.py @@ -27,6 +27,7 @@ from .project_her import (PROJECT_HER_SCHEMA_VERSION, PROJECT_HER_VERSION, ProjectHerError, ProjectHerHarness, project_her_capabilities, scaffold_project) +from .timed_text import TimedPhrase, parse_timed_speak __all__ = ["render", "plan", "validate", "load_lines", "out_name", "djb2", "clean", "TONES", "STYLE_FALLBACK", "layer", "resolve", "voice_of", @@ -40,4 +41,5 @@ "PROJECT_HER_SCHEMA_VERSION", "PROJECT_HER_VERSION", "ProjectHerError", "ProjectHerHarness", "project_her_capabilities", "scaffold_project", - "check_consent", "ConsentError", "__version__"] + "check_consent", "ConsentError", "TimedPhrase", "parse_timed_speak", + "__version__"] diff --git a/voforge/core.py b/voforge/core.py index 35e78ec..1a7f856 100644 --- a/voforge/core.py +++ b/voforge/core.py @@ -3,7 +3,7 @@ from .tones import resolve, voice_of -LINE_KEYS = {"who", "text", "id", "tone", "style", "styledegree", +LINE_KEYS = {"who", "text", "spokenText", "id", "tone", "style", "styledegree", "rate", "pitch", "volume", "seed"} CAST_KEYS = {"voice", "ref", "ref_text", "voice_id", "consent", "lang", "tone", "style", "styledegree", "rate", "pitch", "volume"} @@ -67,6 +67,10 @@ def validate(lines, cast): continue if line["who"] not in speakers and not has_default: problems.append(f"{where}: speaker {line['who']!r} not in cast and no _default") + if "spokenText" in line and ( + not isinstance(line["spokenText"], str) or not line["spokenText"].strip() + ): + problems.append(f"{where}: spokenText must be a non-empty string") unknown = set(line) - LINE_KEYS if unknown: problems.append(f"{where}: unknown keys {sorted(unknown)}") @@ -94,7 +98,7 @@ def out_name(line, mode, seq): return line["id"] if mode == "seq": return f"{seq:04d}" - return djb2(line["who"] + "|" + line["text"]) + return djb2(line["who"] + "|" + line.get("spokenText", line["text"])) async def _render_one(backend, line, name, cast, out_dir, force, fmt, sem, log): @@ -106,7 +110,7 @@ async def _render_one(backend, line, name, cast, out_dir, force, fmt, sem, log): if not speaker or not voice_of(speaker, cloning): log(f" no voice for speaker '{line['who']}' and no _default — skipped") return "failed" - text = clean(line["text"], cast.get("_subs")) + text = clean(line.get("spokenText", line["text"]), cast.get("_subs")) if not text: return "kept" try: @@ -140,6 +144,8 @@ def plan(lines, cast, out_dir, name_mode="hash", fmt="mp3", backend_styles=False speaker = cast.get(line["who"]) or cast.get("_default") or {} entry = {"name": name, "who": line["who"], "exists": (out_dir / f"{name}.{fmt}").exists()} + if line.get("spokenText") and line["spokenText"] != line["text"]: + entry["spokenText"] = clean(line["spokenText"], cast.get("_subs")) if voice_of(speaker, cloning): try: v, p, s, d = resolve(line, speaker, cast, backend_styles, cloning) diff --git a/voforge/timed_text.py b/voforge/timed_text.py new file mode 100644 index 0000000..bf58ba2 --- /dev/null +++ b/voforge/timed_text.py @@ -0,0 +1,64 @@ +"""Strict parsing for the small SSML timing subset used by local TTS workflows.""" +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from typing import List + + +_TIME = re.compile(r"^(\d+(?:\.\d+)?)(ms|s)$") + + +@dataclass(frozen=True) +class TimedPhrase: + text: str + break_after_ms: int = 0 + + +def _milliseconds(value: str) -> int: + match = _TIME.fullmatch(value.strip()) + if not match: + raise ValueError(f"unsupported break time {value!r}; use ms or s") + amount = float(match.group(1)) + milliseconds = round(amount * (1000 if match.group(2) == "s" else 1)) + if not 0 <= milliseconds <= 60_000: + raise ValueError("break time must be between 0 and 60000 ms") + return milliseconds + + +def _clean(text: str) -> str: + return re.sub(r"\s+", " ", text).strip() + + +def parse_timed_speak(document: str) -> List[TimedPhrase]: + """Parse ```` text containing only empty ```` tags.""" + if not isinstance(document, str) or not document.strip(): + raise ValueError("SSML document must be a non-empty string") + if " element") + + pending = root.text or "" + phrases: List[TimedPhrase] = [] + for child in root: + if child.tag != "break" or set(child.attrib) != {"time"}: + raise ValueError("only is supported inside ") + if list(child) or (child.text and child.text.strip()): + raise ValueError("break elements must be empty") + phrase = _clean(pending) + if not phrase: + raise ValueError("each break must follow non-empty spoken text") + phrases.append(TimedPhrase(phrase, _milliseconds(child.attrib["time"]))) + pending = child.tail or "" + final = _clean(pending) + if final: + phrases.append(TimedPhrase(final, 0)) + if not phrases: + raise ValueError("SSML document contains no spoken text") + return phrases