Add pinned local Qwen GPU voice generation and first puzzle drafts
This commit is contained in:
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user