74 lines
3.7 KiB
Python
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())
|