Add pinned local Qwen GPU voice generation and first puzzle drafts

This commit is contained in:
NINI
2026-10-05 01:17:47 +09:00
parent 729afb3da7
commit cd32491bbb
46 changed files with 3138 additions and 0 deletions
+266
View File
@@ -0,0 +1,266 @@
"""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 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 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")
except (KeyError, OSError, ValueError, sf.LibsndfileError) as error:
errors.append(f"{clip['id']}: invalid WAV: {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 = []
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)
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 generate(catalog, profile, root, ids=None, synthesize=None, environment=None):
validate_inputs(catalog, profile)
selected = select_clips(catalog, ids)
root = Path(root)
previous = 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)
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',
'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)
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)
synthesize, environment = (None, {'modelLoad': 'not_needed'}) if all(c['id'] in reusable for c in selected) else load_synthesizer(profile)
manifest = generate(catalog, profile, root, ids, synthesize, environment)
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())
+73
View File
@@ -0,0 +1,73 @@
"""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())
+118
View File
@@ -0,0 +1,118 @@
import copy
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
import numpy as np
import soundfile as sf
SPEC = importlib.util.spec_from_file_location('generate_voice', Path(__file__).with_name('generate_voice.py'))
voice = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(voice)
class VoiceHistoryTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.catalog = {'schemaVersion': 1, 'clips': [
{'id': 'count_01', 'text': '하나', 'usage': 'count', 'stageIds': [1], 'required': True},
{'id': 'guide_collect', 'text': '친구를 찾아 끌어와 볼까?', 'usage': 'guide', 'stageIds': [1], 'required': True}]}
self.profile = {'schemaVersion': 1, 'modelId': voice.MODEL_ID, 'modelRevision': 'a' * 40,
'language': 'Korean', 'speaker': 'Sohee', 'instruct': '천천히 읽어 주세요.',
'device': 'cuda:0', 'dtype': 'bfloat16', 'attention': 'sdpa', 'seed': 42,
'generationParameters': {'max_new_tokens': 2048}}
self.calls = []
def tearDown(self):
self.temp.cleanup()
def fake(self, clip):
self.calls.append(clip['id'])
return np.sin(np.linspace(0, 20, 2400)).astype(np.float32) * .2, 24000
def run_generation(self, catalog=None, profile=None, ids=None):
return voice.generate(catalog or self.catalog, profile or self.profile, self.root,
ids=ids, synthesize=self.fake, environment={'test': True})
def test_partial_changed_clip_regenerates_and_preserves_original(self):
first = self.run_generation()
old = (self.root / first['clips'][0]['file']).read_bytes()
revised = copy.deepcopy(self.catalog)
revised['clips'][1]['text'] = '친구를 끌어와 볼까?'
second = self.run_generation(catalog=revised)
self.assertEqual(['reused', 'generated'], [c['result'] for c in second['clips']])
self.assertEqual(['count_01', 'guide_collect', 'guide_collect'], self.calls)
self.assertEqual(old, (self.root / first['clips'][0]['file']).read_bytes())
self.assertNotEqual(first['runId'], second['runId'])
self.assertEqual([], voice.validate_manifest(second, revised, self.profile, self.root))
def test_empty_text_duplicate_ids_and_bad_stage_rejected(self):
for edit in ('empty', 'duplicate', 'stage'):
bad = copy.deepcopy(self.catalog)
if edit == 'empty': bad['clips'][0]['text'] = ' '
if edit == 'duplicate': bad['clips'][1]['id'] = 'count_01'
if edit == 'stage': bad['clips'][0]['stageIds'] = [31]
with self.assertRaises(ValueError): self.run_generation(catalog=bad)
self.assertEqual([], self.calls)
def test_missing_corrupt_and_profile_mismatch_cannot_validate(self):
manifest = self.run_generation()
changed = copy.deepcopy(self.profile)
changed['instruct'] = '밝게 읽어 주세요.'
self.assertTrue(voice.validate_manifest(manifest, self.catalog, changed, self.root))
wav = self.root / manifest['clips'][0]['file']
wav.write_bytes(b'not wav')
self.assertTrue(voice.validate_manifest(manifest, self.catalog, self.profile, self.root))
wav.unlink()
self.assertTrue(voice.validate_manifest(manifest, self.catalog, self.profile, self.root))
def test_partial_scope_does_not_claim_all_required(self):
manifest = self.run_generation(ids=['count_01'])
self.assertEqual('selected', manifest['scope'])
self.assertEqual([], voice.validate_manifest(manifest, self.catalog, self.profile, self.root, ['count_01']))
self.assertTrue(voice.validate_manifest(manifest, self.catalog, self.profile, self.root))
def test_failed_or_invalid_waveform_not_success(self):
def broken(clip): return np.array([np.nan]), 24000
manifest = voice.generate(self.catalog, self.profile, self.root, synthesize=broken)
self.assertEqual(['failed', 'failed'], [c['result'] for c in manifest['clips']])
self.assertTrue(voice.validate_manifest(manifest, self.catalog, self.profile, self.root))
def test_changed_profile_and_tampered_waveform_force_regeneration(self):
first = self.run_generation()
path = self.root / first['clips'][0]['file']
sf.write(path, np.zeros(2400), 24000)
second = self.run_generation()
self.assertEqual(['generated', 'reused'], [c['result'] for c in second['clips']])
revised = copy.deepcopy(self.profile)
revised['seed'] = 43
third = self.run_generation(profile=revised)
self.assertEqual(['generated', 'generated'], [c['result'] for c in third['clips']])
def test_unknown_requested_id_rejected(self):
with self.assertRaises(ValueError): self.run_generation(ids=['missing'])
def test_pcm_export_resamples_and_preserves_existing_runtime(self):
import sys
sys.path.insert(0, str(Path(__file__).parent))
from prepare_voice import prepare
manifest = self.run_generation()
target = self.root / 'runtime'
selection = prepare(manifest, self.catalog, self.profile, self.root, target)
info = sf.info(target / 'count_01.wav')
self.assertEqual((44100, 1, 'PCM_16'), (info.samplerate, info.channels, info.subtype))
self.assertAlmostEqual(.1, info.duration)
self.assertEqual('not_listened', selection['selections'][0]['review'])
original = (target / 'count_01.wav').read_bytes()
sf.write(target / 'count_01.wav', np.ones(4410) * .1, 44100)
changed = (target / 'count_01.wav').read_bytes()
with self.assertRaises(ValueError): prepare(manifest, self.catalog, self.profile, self.root, target)
self.assertEqual(changed, (target / 'count_01.wav').read_bytes())
self.assertNotEqual(original, changed)
if __name__ == '__main__':
unittest.main()