Files
MagnetismHeroes/tools/audio/generate_voice.py
T

341 lines
18 KiB
Python

"""Produce pinned local Qwen Korean voice assets; validation never loads a model."""
import argparse
from datetime import datetime, timezone
import hashlib
import importlib.metadata
import json
from pathlib import Path
import re
import subprocess
import sys
import time
import uuid
import numpy as np
import soundfile as sf
MODEL_ID = 'Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice'
def digest(value):
return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True,
separators=(',', ':')).encode('utf-8')).hexdigest()
def file_hash(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def load_json(path):
return json.loads(Path(path).read_text(encoding='utf-8'))
def write_json(path, value):
Path(path).write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
def validate_inputs(catalog, profile):
if catalog.get('schemaVersion') != 1 or profile.get('schemaVersion') != 1:
raise ValueError('catalog/profile schemaVersion must be 1')
clips = catalog.get('clips')
if not isinstance(clips, list) or not clips:
raise ValueError('catalog must contain clips')
ids = set()
for clip in clips:
clip_id = clip.get('id', '')
if not re.fullmatch(r'[a-z][a-z0-9_]*', clip_id) or clip_id in ids:
raise ValueError(f'{clip_id}: invalid or duplicate ID')
ids.add(clip_id)
if not isinstance(clip.get('text'), str) or not clip['text'].strip():
raise ValueError(f'{clip_id}: text is empty')
if not re.search('[가-힣]', clip['text']) or re.search(r'[0-9]', clip['text']):
raise ValueError(f'{clip_id}: use a complete Korean utterance, not numeral digits')
if not isinstance(clip.get('required'), bool) or not clip.get('usage'):
raise ValueError(f'{clip_id}: required/usage missing')
if not isinstance(clip.get('stageIds'), list) or not clip['stageIds'] or any(
type(n) is not int or n < 1 or n > 30 for n in clip['stageIds']):
raise ValueError(f'{clip_id}: stageIds must be within 1..30')
for field in ('modelRevision', 'instruct', 'device', 'dtype', 'attention', 'generationParameters', 'seed'):
if field not in profile:
raise ValueError(f'profile: missing {field}')
if profile.get('modelId') != MODEL_ID or profile.get('language') != 'Korean' or profile.get('speaker') != 'Sohee':
raise ValueError('profile must retain approved model/Korean/Sohee')
if not re.fullmatch('[0-9a-f]{40}', profile['modelRevision']):
raise ValueError('profile must pin a full model revision')
if profile['device'] != 'cuda:0' or profile['dtype'] not in ('bfloat16', 'float16') or profile['attention'] not in ('sdpa', 'eager'):
raise ValueError('profile requires approved CUDA configuration')
if not isinstance(profile['generationParameters'], dict) or type(profile['seed']) is not int:
raise ValueError('invalid generation settings')
def select_clips(catalog, ids=None, required_only=False):
by_id = {c['id']: c for c in catalog['clips']}
if ids is None:
return [c for c in catalog['clips'] if not required_only or c['required']]
missing = set(ids) - by_id.keys()
if missing:
raise ValueError('unknown IDs: ' + ', '.join(sorted(missing)))
if not ids or len(ids) != len(set(ids)):
raise ValueError('requested IDs must be nonempty and unique')
return [by_id[key] for key in ids]
def waveform_metadata(path):
samples, sample_rate = sf.read(path, dtype='float32', always_2d=True)
if sample_rate <= 0 or samples.size == 0 or not np.isfinite(samples).all():
raise ValueError('empty or nonfinite waveform')
peak = float(np.max(np.abs(samples)))
return {'sha256': file_hash(path), 'sampleRate': sample_rate, 'channels': samples.shape[1],
'frames': samples.shape[0], 'duration': samples.shape[0] / sample_rate,
'peak': peak, 'rms': float(np.sqrt(np.mean(samples ** 2))),
'qualityWarnings': (['near silence'] if peak < .001 else []) +
(['clipping candidate'] if peak >= .999 else [])}
def load_run_inputs(manifest, root):
run_id = manifest.get('runId', '')
if not isinstance(run_id, str) or not re.fullmatch(r'[A-Za-z0-9_]+', run_id):
raise ValueError('invalid input snapshot runId')
references = manifest.get('inputSnapshots', {'catalog': 'catalog.json', 'profile': 'profile.json'})
if references != {'catalog': 'catalog.json', 'profile': 'profile.json'}:
raise ValueError('input snapshots must reside in their immutable run directory')
run_dir = Path(root) / 'generated' / run_id
catalog = load_json(run_dir / references['catalog'])
profile = load_json(run_dir / references['profile'])
if not isinstance(catalog, dict) or not isinstance(profile, dict):
raise ValueError('input snapshots must contain JSON objects')
if digest(catalog) != manifest.get('catalogHash') or digest(profile) != manifest.get('profileHash'):
raise ValueError('input snapshot hash differs from manifest')
validate_inputs(catalog, profile)
if profile['modelRevision'] != manifest.get('modelRevision'):
raise ValueError('input snapshot model revision differs from manifest')
clips = {clip['id']: clip for clip in catalog['clips']}
for entry in manifest.get('clips', []):
clip = clips.get(entry.get('id'))
if not clip or entry.get('textHash') != digest(clip['text']) or entry.get('settingsHash') != digest(profile):
raise ValueError(f"{entry.get('id')}: input snapshot differs from clip text/settings hashes")
return catalog, profile
def clip_errors(entry, clip, profile, root):
errors = []
if entry.get('result') not in ('generated', 'reused'):
return [f"{clip['id']}: no successful generation"]
if entry.get('textHash') != digest(clip['text']) or entry.get('settingsHash') != digest(profile):
errors.append(f"{clip['id']}: current text/profile differs")
try:
path = (root / entry['file']).resolve()
if not path.is_relative_to(root.resolve()):
raise ValueError('waveform escapes source directory')
actual = waveform_metadata(path)
for key in ('sha256', 'sampleRate', 'channels', 'frames', 'duration'):
if actual[key] != entry.get(key):
errors.append(f"{clip['id']}: {key} mismatch")
if actual['channels'] != 1:
errors.append(f"{clip['id']}: expected mono")
run_id = entry['originalRunId']
if not isinstance(run_id, str) or not re.fullmatch(r'[A-Za-z0-9_]+', run_id):
raise ValueError('invalid original generation runId')
origin = load_json(root / 'generated' / run_id / 'manifest.json')
if origin.get('runId') != run_id:
raise ValueError('original manifest runId mismatch')
load_run_inputs(origin, root)
original_entries = [c for c in origin['clips'] if c.get('id') == clip['id']]
if len(original_entries) != 1 or original_entries[0].get('result') != 'generated':
raise ValueError('missing successful original generation entry')
for key in ('file', 'sha256', 'textHash', 'settingsHash'):
if original_entries[0].get(key) != entry.get(key):
raise ValueError(f'original generation {key} differs')
except (KeyError, OSError, ValueError, sf.LibsndfileError) as error:
errors.append(f"{clip['id']}: invalid source artifact: {error}")
return errors
def validate_manifest(manifest, catalog, profile, root, ids=None):
validate_inputs(catalog, profile)
requested = select_clips(catalog, ids, required_only=ids is None)
errors = []
try:
load_run_inputs(manifest, root)
except (KeyError, OSError, ValueError, TypeError) as error:
errors.append(f'input snapshots invalid: {error}')
if manifest.get('schemaVersion') != 1 or manifest.get('modelRevision') != profile['modelRevision']:
errors.append('manifest schema/revision differs')
if manifest.get('catalogHash') != digest(catalog) or manifest.get('profileHash') != digest(profile):
errors.append('manifest does not describe current catalog/profile')
entries = manifest.get('clips', [])
by_id = {c.get('id'): c for c in entries}
if len(by_id) != len(entries):
errors.append('manifest contains duplicate IDs')
for clip in requested:
if clip['id'] not in by_id:
errors.append(f"{clip['id']}: missing required/requested ID")
else:
errors.extend(clip_errors(by_id[clip['id']], clip, profile, root))
return errors
def reusable_entries(root, catalog, profile):
result = {}
by_id = {c['id']: c for c in catalog['clips']}
for path in sorted((root / 'generated').glob('*/manifest.json'), reverse=True):
try:
manifest = load_json(path)
load_run_inputs(manifest, root)
for entry in manifest.get('clips', []):
clip = by_id.get(entry.get('id'))
if clip and clip['id'] not in result and not clip_errors(entry, clip, profile, root):
result[clip['id']] = entry
except (OSError, ValueError, TypeError):
continue
return result
def reuse_environment(profile, entries, root):
environment = {'python': sys.version, 'packages': {name: importlib.metadata.version(name) for name in
('qwen-tts', 'torch', 'torchaudio', 'transformers', 'soundfile', 'huggingface-hub')},
'modelLoad': 'not_needed', 'requestedDevice': profile['device'], 'sourceGenerations': []}
try:
probe = subprocess.run(['nvidia-smi', '-i', '0', '--query-gpu=name', '--format=csv,noheader'],
check=True, capture_output=True, text=True, timeout=5)
environment['device'] = probe.stdout.strip()
environment['deviceProbe'] = 'nvidia-smi; no model or CUDA inference executed'
except (OSError, subprocess.SubprocessError) as error:
environment['deviceProbeError'] = str(error)
for run_id in sorted({entry['originalRunId'] for entry in entries.values()}):
manifest_path = Path(root) / 'generated' / run_id / 'manifest.json'
source = load_json(manifest_path)
environment['sourceGenerations'].append({'runId': run_id,
'manifest': manifest_path.relative_to(root).as_posix(),
'manifestSha256': file_hash(manifest_path),
'environment': source['environment']})
return environment
def generate(catalog, profile, root, ids=None, synthesize=None, environment=None, regenerate=False):
validate_inputs(catalog, profile)
selected = select_clips(catalog, ids)
root = Path(root)
previous = {} if regenerate else reusable_entries(root, catalog, profile)
run_id = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ_') + uuid.uuid4().hex[:8]
run_dir = root / 'generated' / run_id
run_dir.mkdir(parents=True)
write_json(run_dir / 'catalog.json', catalog)
write_json(run_dir / 'profile.json', profile)
manifest = {'schemaVersion': 1, 'runId': run_id, 'createdUtc': datetime.now(timezone.utc).isoformat(),
'modelRevision': profile['modelRevision'], 'catalogHash': digest(catalog),
'profileHash': digest(profile), 'scope': 'selected' if ids else 'all',
'regenerateRequested': regenerate,
'inputSnapshots': {'catalog': 'catalog.json', 'profile': 'profile.json'},
'requestedIds': [c['id'] for c in selected], 'environment': environment or {}, 'clips': []}
try:
for clip in selected:
clip_id = clip['id']
if clip_id in previous:
entry = dict(previous[clip_id], result='reused')
else:
entry = {'id': clip_id, 'textHash': digest(clip['text']), 'settingsHash': digest(profile),
'originalRunId': run_id, 'file': f'generated/{run_id}/{clip_id}.wav',
'review': 'not_listened'}
started = time.perf_counter()
try:
if synthesize is None:
raise ValueError('no synthesizer available')
samples, rate = synthesize(clip)
samples = np.asarray(samples, dtype=np.float32)
if samples.ndim != 1 or not samples.size or not np.isfinite(samples).all() or rate <= 0:
raise ValueError('synthesizer returned empty/nonfinite/nonmono audio')
path = root / entry['file']
sf.write(path, samples, rate, subtype='FLOAT')
entry.update(waveform_metadata(path), result='generated', generationSeconds=time.perf_counter() - started)
except Exception as error:
entry.update(result='failed', error=f'{type(error).__name__}: {error}')
manifest['clips'].append(entry)
write_json(run_dir / 'manifest.json', manifest)
print(f"{clip_id}: {entry['result']}", flush=True)
finally:
write_json(run_dir / 'manifest.json', manifest)
return manifest
def load_synthesizer(profile):
import torch
from huggingface_hub import snapshot_download
from qwen_tts import Qwen3TTSModel
if not torch.cuda.is_available():
raise RuntimeError('CUDA unavailable; refusing an unapproved CPU fallback')
probe = torch.arange(8, device=profile['device'], dtype=torch.float32).square().sum().item()
if probe != 140:
raise RuntimeError('CUDA arithmetic probe failed')
snapshot = snapshot_download(repo_id=profile['modelId'], revision=profile['modelRevision'])
started = time.perf_counter()
torch.cuda.reset_peak_memory_stats()
model = Qwen3TTSModel.from_pretrained(snapshot, device_map=profile['device'],
dtype=getattr(torch, profile['dtype']), attn_implementation=profile['attention'])
model_device = str(next(model.model.parameters()).device)
if model_device != profile['device']:
raise RuntimeError(f'loaded model device {model_device} differs from requested CUDA device')
if 'sohee' not in [s.lower() for s in model.get_supported_speakers()] or 'korean' not in [s.lower() for s in model.get_supported_languages()]:
raise RuntimeError('Sohee/Korean not supported by loaded model')
environment = {'python': sys.version, 'packages': {name: importlib.metadata.version(name) for name in
('qwen-tts', 'torch', 'torchaudio', 'transformers', 'soundfile', 'huggingface-hub')},
'device': torch.cuda.get_device_name(0), 'cuda': torch.version.cuda,
'modelDevice': model_device,
'cudaProbe': probe, 'modelLoadSeconds': time.perf_counter() - started,
'modelPeakVramBytes': torch.cuda.max_memory_allocated(),
'modelDefaults': model.generate_defaults,
'effectiveGenerationParameters': model._merge_generate_kwargs(**profile['generationParameters'])}
def synthesize(clip):
torch.manual_seed(profile['seed'])
torch.cuda.manual_seed_all(profile['seed'])
waves, rate = model.generate_custom_voice(text=clip['text'], language=profile['language'],
speaker=profile['speaker'], instruct=profile['instruct'],
non_streaming_mode=True, **profile['generationParameters'])
torch.cuda.synchronize()
environment['peakVramBytes'] = torch.cuda.max_memory_allocated()
return waves[0], rate
return synthesize, environment
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--catalog', required=True, type=Path)
parser.add_argument('--profile', required=True, type=Path)
parser.add_argument('--ids', help='comma separated IDs; absent means all catalog clips')
parser.add_argument('--validate-only', action='store_true')
parser.add_argument('--manifest', type=Path)
parser.add_argument('--regenerate', action='store_true', help='create a new take for selected IDs, preserving original runs')
args = parser.parse_args()
ids = args.ids.split(',') if args.ids else None
try:
catalog, profile = load_json(args.catalog), load_json(args.profile)
validate_inputs(catalog, profile)
root = args.catalog.parent
if args.validate_only:
if not args.manifest:
raise ValueError('--validate-only requires --manifest')
manifest = load_json(args.manifest)
errors = validate_manifest(manifest, catalog, profile, root, ids)
else:
selected = select_clips(catalog, ids)
reusable = reusable_entries(root, catalog, profile)
if not args.regenerate and all(c['id'] in reusable for c in selected):
synthesize = None
environment = reuse_environment(profile, {c['id']: reusable[c['id']] for c in selected}, root)
else:
synthesize, environment = load_synthesizer(profile)
manifest = generate(catalog, profile, root, ids, synthesize, environment, args.regenerate)
print('Manifest: ' + str(root / 'generated' / manifest['runId'] / 'manifest.json'))
errors = validate_manifest(manifest, catalog, profile, root, ids)
print(json.dumps({'validationScope': 'selected' if ids else 'all_required', 'ids': ids,
'errors': errors}, ensure_ascii=False))
return 1 if errors else 0
except Exception as error:
print(f'{type(error).__name__}: {error}', file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())