Integrate Qwen validation evidence and retained anomalous take

This commit is contained in:
NINI
2026-10-05 01:25:30 +09:00
5 changed files with 136 additions and 30 deletions
@@ -0,0 +1,75 @@
{
"schemaVersion": 1,
"runId": "20261004T162201751189Z_a400364e",
"createdUtc": "2026-10-04T16:22:01.751932+00:00",
"modelRevision": "0c0e3051f131929182e2c023b9537f8b1c68adfe",
"catalogHash": "491c9cd0bb7068a47b33bdd0b19ece1ba634217ec0ed9cc3e2c5aa7bd3802755",
"profileHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"scope": "selected",
"regenerateRequested": true,
"requestedIds": [
"count_05"
],
"environment": {
"python": "3.12.15 | packaged by Anaconda, Inc. | (main, Oct 1 2026, 20:14:50) [MSC v.1942 64 bit (AMD64)]",
"packages": {
"qwen-tts": "0.1.1",
"torch": "2.11.0+cu128",
"torchaudio": "2.11.0",
"transformers": "4.57.3",
"soundfile": "0.14.0",
"huggingface-hub": "0.36.2"
},
"device": "NVIDIA GeForce RTX 4080 SUPER",
"cuda": "12.8",
"modelDevice": "cuda:0",
"cudaProbe": 140.0,
"modelLoadSeconds": 2.973746000090614,
"modelPeakVramBytes": 4181138432,
"modelDefaults": {
"do_sample": true,
"repetition_penalty": 1.05,
"temperature": 0.9,
"top_p": 1.0,
"top_k": 50,
"subtalker_dosample": true,
"subtalker_temperature": 0.9,
"subtalker_top_p": 1.0,
"subtalker_top_k": 50,
"max_new_tokens": 8192
},
"effectiveGenerationParameters": {
"do_sample": true,
"top_k": 50,
"top_p": 1,
"temperature": 0.9,
"repetition_penalty": 1.05,
"subtalker_dosample": true,
"subtalker_top_k": 50,
"subtalker_top_p": 1,
"subtalker_temperature": 0.9,
"max_new_tokens": 8192
},
"peakVramBytes": 4386499584
},
"clips": [
{
"id": "count_05",
"textHash": "85662c79a7da0157be376388613950f14efd961d11dc4918f7ceb3f8b5a87c6d",
"settingsHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"originalRunId": "20261004T162201751189Z_a400364e",
"file": "generated/20261004T162201751189Z_a400364e/count_05.wav",
"review": "not_listened",
"sha256": "f832eddc80fb27dfae338214205308ee8119c75a82037bf79d309100c84828f1",
"sampleRate": 24000,
"channels": 1,
"frames": 218880,
"duration": 9.12,
"peak": 0.443359375,
"rms": 0.035190436989068985,
"qualityWarnings": [],
"result": "generated",
"generationSeconds": 22.271049299975857
}
]
}
+33 -25
View File
@@ -1,41 +1,45 @@
# Korean Qwen voice drafts
# 한국어 Qwen 음성 자산 기록
Status: actual local GPU synthesis and file validation completed; pronunciation, clipping at word boundaries, and voice suitability have **not been listened to or approved**. These are draft clips, not final voice assets. SFX and BGM will be supplied/selected by the user later.
현재 상태: 로컬 GPU 음성 생성과 파일 검사를 완료했습니다. **발음·어절 잘림·음색 적합성은 아직 직접 듣고 검수하지 않았습니다.** 게임에 연결한 파일은 초안이며 최종 음성으로 승인한 파일이 아닙니다. 효과음과 배경음악은 사용자가 나중에 추가하기로 했습니다.
## Sources and conditions
## 출처와 이용조건
- Checked 2026-10-05 KST: [official Qwen3-TTS repository](https://github.com/QwenLM/Qwen3-TTS) and [model card](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice).
- Model: `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice`, revision `0c0e3051f131929182e2c023b9537f8b1c68adfe`, Korean/Sohee, existing conda environment `qwen3-tts`. Full weights, tokenizer and processor snapshot are cached outside the repository. No model/runtime generation service is included in the game.
- Model card declares Apache-2.0. Installed qwen-tts 0.1.1 contains the Apache-2.0 code license. The actual model card and code license are recorded in `SourceAssets/Audio/Voice/licenses/`. This is a record of those published conditions, not a claim of exclusive rights to generated voices.
- Profile contains the exact prompt, seed 42, generation parameters and device/dtype/attention; no machine-specific model cache path. Initial prompt: `5~7세 아이에게 이야기하듯 따뜻하고 밝게, 천천히 또렷하게 읽어 주세요. 숫자는 정확하게 읽고 과장된 비명이나 웃음소리는 넣지 마세요.`
- 확인 날짜: 2026-10-05 한국 시간. [공식 Qwen3-TTS 저장소](https://github.com/QwenLM/Qwen3-TTS), [모델 카드](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice)를 확인했습니다.
- 모델: `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice`, 고정 revision `0c0e3051f131929182e2c023b9537f8b1c68adfe`, 한국어/Sohee. 기존 conda 환경 `qwen3-tts`를 재사용했습니다. 가중치·토크나이저·프로세서 전체 snapshot은 저장소 밖에 캐시하며 게임에는 포함하지 않습니다.
- 모델 카드는 Apache-2.0을 선언하고, 설치된 qwen-tts 0.1.1에는 Apache-2.0 코드 라이선스가 포함되어 있습니다. 실제 모델 카드와 코드 라이선스 원문은 `SourceAssets/Audio/Voice/licenses/`에 보존했습니다. 공개된 이용조건을 기록한 것이며 생성 음성의 독점권을 주장하지 않습니다.
- 공유 프로필에는 정확한 지시문·seed 42·생성 인자·장치·dtype·attention을 기록하고 기기별 캐시 절대경로를 넣지 않았습니다. 초기 지시문: `5~7세 아이에게 이야기하듯 따뜻하고 밝게, 천천히 또렷하게 읽어 주세요. 숫자는 정확하게 읽고 과장된 비명이나 웃음소리는 넣지 마세요.`
## Actual environment and generation evidence
## 실제 환경과 생성 증거
Only the installed CPU torch was replaced with the official `torch==2.11.0+cu128` wheel, matching the installed torchaudio 2.11.0 version. The direct torch requirement `setuptools<82` required adjusting setuptools 83.0.0 to 81.0.0. No environment recreation or unrelated upgrades.
기존 CPU torch를 설치된 torchaudio 2.11.0과 버전이 맞는 공식 `torch==2.11.0+cu128` wheel로 교체했습니다. torch의 직접 요구조건 `setuptools<82`를 충족하도록 setuptools 83.0.0만 81.0.0으로 변경했습니다. 환경 재생성이나 무관한 패키지 일괄 업그레이드는 하지 않았습니다.
Python 3.12.15, qwen-tts 0.1.1, torch 2.11.0+cu128, torchaudio 2.11.0+cpu, transformers 4.57.3, soundfile 0.14.0, huggingface-hub 0.36.2. CUDA 12.8 / NVIDIA GeForce RTX 4080 SUPER. CUDA square/sum probe returned 140. Torch CUDA, qwen-tts and torchaudio imports succeeded. SDPA/bfloat16 worked for actual GPU synthesis without installing FlashAttention or SoX.
실제 환경은 Python 3.12.15, qwen-tts 0.1.1, torch 2.11.0+cu128, torchaudio 2.11.0+cpu, transformers 4.57.3, soundfile 0.14.0, huggingface-hub 0.36.2입니다. CUDA 12.8 / NVIDIA GeForce RTX 4080 SUPER에서 CUDA 제곱·합 연산 결과 140을 확인했습니다. torch CUDA·qwen-tts·torchaudio import가 성공했고 SDPA/bfloat16으로 실제 GPU 추론이 성공했습니다. FlashAttention과 SoX는 설치하지 않았습니다.
First selected sample run `20261004T161148012934Z_47f3a009`: count_01/count_04/count_07/count_10/guide_collect. Model loading 3.214 s, peak allocated GPU memory 4,232,247,296 bytes; native output 24,000 Hz mono FLOAT WAV. Each clip's exact duration, frame count, generation time and SHA-256 are in the immutable run manifest.
첫 표본 회차 `20261004T161148012934Z_47f3a009`에서 count_01/count_04/count_07/count_10/guide_collect를 생성했습니다. 모델 로딩 3.214초, GPU 최대 할당 메모리 4,232,247,296바이트, 원본 출력 24,000 Hz 모노 FLOAT WAV입니다. 클립별 길이·프레임 수·생성 시간·SHA-256은 해당 회차 manifest에 기록했습니다.
Expanded run `20261004T161231511126Z_aa20f404`: five prior clips reused with matching text/profile/hash; remaining numbers and guide_count actually generated. Its manifest records original run/file links, actual settings/defaults, environment, hashes and per-clip results. Successful synthesis/file validation does not imply pronunciation approval.
확대 회차 `20261004T161231511126Z_aa20f404`에서는 대본·프로필·파일 해시가 같은 이전 다섯 클립을 재사용하고 나머지 숫자와 guide_count를 실제 생성했습니다. manifest에는 원래 생성 회차와 파일, 실제 인자와 모델 기본값, 환경, 해시, 클립별 결과를 기록했습니다. 생성 성공과 파일 검사 성공을 발음 합격으로 해석하지 않습니다.
## Runtime draft selection
후속 GPU 확인은 실제 모델 장치 `cuda:0`, 로딩 2.976초, 생성 1.826초, 최대 할당 메모리 4,227,715,072바이트, 24,000 Hz / 13,440프레임 / 0.56초를 확인했습니다. 해당 증거와 파형은 무시된 검사 산출물 `artifacts/verification/FirstSlice/Voice/gpu-probe.json` 및 `gpu-probe.wav`에 있습니다.
Drafts live under `Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/`. Source FLOAT WAVs remain unchanged; edited candidates use true polyphase resampling to 44,100 Hz mono PCM16, without speech trimming. The corresponding `edited/<runId>/selection.json` links original source hash and edited/runtime hash. Existing runtime files with different contents are preserved by the preparation tool until reviewed.
## 게임 연결용 초안
| ID | Duration | Status |
초안 경로는 `Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/`입니다. 원본 FLOAT WAV는 보존하고 실제 polyphase 리샘플링으로 44,100 Hz 모노 PCM16 편집본을 만들었습니다. 발화 구간은 잘라내지 않았습니다. `edited/<runId>/selection.json`에서 원본 해시와 편집본·게임 파일 해시를 연결합니다. 기존 게임 파일과 새 편집본 내용이 다르면 제작 도구는 기존 파일을 덮어쓰지 않습니다.
| ID | 길이 | 상태 |
| --- | ---: | --- |
| count_01 | 0.56 s | T2 required draft, not listened |
| guide_collect | 2.00 s | T2 required draft, not listened |
| guide_count | 3.60 s | T2 required draft, not listened |
| count_02,03,04,06,07,08,09,10 | 0.48–1.44 s | Optional future counting drafts, not listened |
| count_05 | 9.12 s | Excluded from runtime selection; anomalous long silence and later energy, requires listening/recreation |
| count_01 | 0.56초 | T2 필수 초안, 청취 미검수 |
| guide_collect | 2.00초 | T2 필수 초안, 청취 미검수 |
| guide_count | 3.60초 | T2 필수 초안, 청취 미검수 |
| count_02,03,04,06,07,08,09,10 | 0.48~1.44초 | 이후 계수용 초안, 청취 미검수 |
| count_05 | 9.12초 | 긴 공백과 후반 소리의 이상 후보로 게임 연결에서 제외 |
`count_05` retains its complete original and edited candidate for diagnosis. A single-word number longer than two seconds is blocked from draft export by the preparation tool. This threshold is a review signal, not an automated pronunciation test.
count_05의 전체 원본과 편집 후보는 원인 확인을 위해 보존했습니다. 숫자 한 단어가 2초보다 길면 초안 내보내기를 차단합니다. 이 기준은 검수 필요 신호이며 자동 발음 검사가 아닙니다. 같은 프로필로 특정 클립을 다시 만들 때는 `--ids count_05 --regenerate`를 사용하며 이전 회차를 보존합니다.
## Verification
동일 프로필·seed 42로 count_05만 다시 생성한 회차 `20261004T162201751189Z_a400364e`에서도 9.12초 출력이 재현되었습니다. 실제 `cuda:0` 모델 로딩 2.974초, 추론 22.271초, 최대 GPU 할당 4,386,499,584바이트였습니다. 생성 자체는 성공했지만 정상 초안으로 채택하지 않았고 두 회차의 원본을 모두 보존했습니다. 파일 길이만으로 발음 합격을 선언하지 않으며, 이후 직접 청취한 뒤 같은 모델·목소리 범위에서 지시문·생성 설정을 검토해야 합니다.
Environment commands use the existing environment Python or `conda run --no-capture-output -n qwen3-tts`, never the global Python. Commands run at the project root:
## 검증 명령과 결과
모든 명령은 기존 환경의 Python 또는 `conda run --no-capture-output -n qwen3-tts`로 실행합니다. 아래 명령의 실행 위치는 프로젝트 루트입니다.
```powershell
python -m unittest discover -s tools/audio -p 'test_*.py' -v
@@ -44,6 +48,10 @@ python tools/audio/generate_voice.py --catalog SourceAssets/Audio/Voice/voice-ca
python tools/audio/generate_voice.py --catalog SourceAssets/Audio/Voice/voice-catalog.json --profile SourceAssets/Audio/Voice/qwen-profile.json --validate-only --manifest SourceAssets/Audio/Voice/generated/20261004T161231511126Z_aa20f404/manifest.json
```
Initial RED exit 1: generator module absent. GREEN: eight behavioral Python tests, exit 0, cover invalid catalogs, partial/current-profile validation, failed/nonfinite WAVs, missing/corrupt WAVs, changed clips/settings, hash tampering, preservation of originals, resampling and refusal to overwrite a different runtime draft. Actual sample synthesis, expanded synthesis and validation-only each exited 0. Validation-only neither loads nor downloads the model. Validation without IDs covers all currently required T2 clips; `--ids` records the narrower selected scope.
초기 RED는 생성기 모듈이 없어 종료 코드 1이었습니다. 최종 GREEN에서는 동작 검증 테스트 10개가 종료 코드 0으로 통과했습니다. 잘못된 카탈로그, 현재 대본·프로필과 부분 검사 범위, 실패하거나 비정상적인 WAV, 누락·손상 WAV, 대본·설정 변경, 해시 변조, 이전 원본 보존, 리샘플링, 다른 게임 파일 덮어쓰기 거부, 긴 숫자 출력 차단, 특정 클립만 새 회차로 생성하는 동작을 검사합니다.
Unity `.meta` files must be generated through actual Editor import after integration; they are not handwritten. Required follow-up: listen to each selected utterance, record reviewer/date/approved hashes, review count_05 and future stage guidance, and test queue/mix playback in the Android device build.
첫 표본 생성·확대 생성·검사 전용 명령은 각각 종료 코드 0이었습니다. `--validate-only`는 모델을 로딩하거나 다운로드하지 않습니다. ID를 생략하면 현재 T2 필수 세 클립 전체를 검사하고, `--ids`를 지정하면 선택 범위를 결과에 기록합니다. 생성한 열두 클립을 모두 지정한 파일·이력 검사는 종료 코드 0이었지만 count_05의 별도 음질 문제는 합격으로 처리하지 않았습니다. 잘못된 ID `missing`은 해당 ID를 포함한 오류와 종료 코드 1을 반환했습니다. 마지막 `pip check`는 종료 코드 0으로 충돌이 없었습니다.
실제 후속 로그는 구현 worktree의 `artifacts/verification/FirstSlice/Voice/` 아래 `python-tests.log`, `validate-all-generated.log`, `validate-invalid-id.log`입니다. 산출물은 Git에서 제외하고 원본 manifest와 선택 파일 해시는 추적합니다.
Unity `.meta`는 통합 후 실제 Editor import로 생성해야 하며 직접 작성하지 않았습니다. 남은 검수는 선택한 각 발화를 듣고 검수자·날짜·승인 해시를 기록하는 작업, count_05 및 이후 안내 음성 검수, Android 빌드의 음성 큐·믹스 재생 확인입니다.
+6 -4
View File
@@ -147,17 +147,18 @@ def reusable_entries(root, catalog, profile):
return result
def generate(catalog, profile, root, ids=None, synthesize=None, environment=None):
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 = reusable_entries(root, catalog, profile)
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)
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,
'requestedIds': [c['id'] for c in selected], 'environment': environment or {}, 'clips': []}
try:
for clip in selected:
@@ -236,6 +237,7 @@ def main():
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:
@@ -250,8 +252,8 @@ def main():
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)
synthesize, environment = (None, {'modelLoad': 'not_needed'}) if not args.regenerate and all(c['id'] in reusable for c in selected) else 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,
+22 -1
View File
@@ -1,6 +1,5 @@
import copy
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
@@ -113,6 +112,28 @@ class VoiceHistoryTests(unittest.TestCase):
self.assertEqual(changed, (target / 'count_01.wav').read_bytes())
self.assertNotEqual(original, changed)
def test_long_count_is_blocked_before_export(self):
import sys
sys.path.insert(0, str(Path(__file__).parent))
from prepare_voice import prepare
def long_take(clip): return np.ones(72000, dtype=np.float32) * .1, 24000
manifest = voice.generate(self.catalog, self.profile, self.root, synthesize=long_take)
target = self.root / 'runtime'
with self.assertRaisesRegex(ValueError, 'exceeds 2 seconds'):
prepare(manifest, self.catalog, self.profile, self.root, target)
self.assertFalse(target.exists())
def test_new_take_preserves_original_and_other_clips(self):
first = self.run_generation()
original = (self.root / first['clips'][0]['file']).read_bytes()
second = voice.generate(self.catalog, self.profile, self.root, ids=['count_01'],
synthesize=self.fake, regenerate=True)
self.assertEqual('generated', second['clips'][0]['result'])
self.assertTrue(second['regenerateRequested'])
self.assertEqual(1, len(second['clips']))
self.assertEqual(original, (self.root / first['clips'][0]['file']).read_bytes())
self.assertEqual(['count_01', 'guide_collect', 'count_01'], self.calls)
if __name__ == '__main__':
unittest.main()