Files
MagnetismHeroes/tools/audio/prepare_voice.py
T

74 lines
3.7 KiB
Python

"""Resample validated source WAVs into draft Unity clips without claiming listening approval."""
import argparse
from pathlib import Path
import math
import sys
import numpy as np
import soundfile as sf
from scipy.signal import resample_poly
from generate_voice import load_json, validate_manifest, waveform_metadata, write_json
def prepare(manifest, catalog, profile, root, target, ids=None):
errors = validate_manifest(manifest, catalog, profile, root, ids)
if errors:
raise ValueError('; '.join(errors))
selected = set(ids or [c['id'] for c in catalog['clips'] if c['required']])
for entry in manifest['clips']:
if entry['id'] in selected and entry['id'].startswith('count_') and entry['duration'] > 2:
raise ValueError(f"{entry['id']}: count utterance exceeds 2 seconds; review before runtime export")
target.mkdir(parents=True, exist_ok=True)
edited_dir = root / 'edited' / manifest['runId']
edited_dir.mkdir(parents=True, exist_ok=True)
selections = []
for entry in manifest['clips']:
if entry['id'] not in selected:
continue
wave, rate = sf.read(root / entry['file'], dtype='float32')
divisor = math.gcd(rate, 44100)
edited = resample_poly(wave, 44100 // divisor, rate // divisor)
if np.max(np.abs(edited)) >= 1:
raise ValueError(f"{entry['id']}: editing would clip; review level before export")
edited_path = edited_dir / (entry['id'] + '.wav')
sf.write(edited_path, edited, 44100, subtype='PCM_16')
runtime_path = target / edited_path.name
if runtime_path.exists() and runtime_path.read_bytes() != edited_path.read_bytes():
raise ValueError(f"{entry['id']}: existing runtime clip preserved; review new edited candidate before replacing it")
runtime_path.write_bytes(edited_path.read_bytes())
selections.append({'id': entry['id'], 'sourceRunId': entry['originalRunId'],
'sourceFile': entry['file'], 'sourceSha256': entry['sha256'],
'editedFile': edited_path.relative_to(root).as_posix(),
'runtimeFile': 'Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/' + runtime_path.name,
'editedMetadata': waveform_metadata(edited_path),
'review': 'not_listened', 'status': 'draft',
'processing': 'polyphase resample to mono PCM16 44100 Hz; no speech trimming'})
result = {'schemaVersion': 1, 'runId': manifest['runId'], 'profileHash': manifest['profileHash'],
'catalogHash': manifest['catalogHash'], 'selections': selections}
write_json(edited_dir / 'selection.json', result)
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--catalog', type=Path, required=True)
parser.add_argument('--profile', type=Path, required=True)
parser.add_argument('--manifest', type=Path, required=True)
parser.add_argument('--ids')
parser.add_argument('--target', type=Path, default=Path('Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice'))
args = parser.parse_args()
try:
selection = prepare(load_json(args.manifest), load_json(args.catalog), load_json(args.profile),
args.catalog.parent, args.target, args.ids.split(',') if args.ids else None)
for clip in selection['selections']:
print(f"{clip['id']}: draft {clip['editedMetadata']['duration']:.3f}s {clip['editedMetadata']['sha256']}")
return 0
except Exception as error:
print(str(error), file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())