Integrate immutable voice input snapshots and history validation

This commit is contained in:
NINI
2026-10-05 02:24:47 +09:00
15 changed files with 2616 additions and 2 deletions
@@ -0,0 +1,421 @@
{
"schemaVersion": 1,
"clips": [
{
"id": "count_01",
"text": "하나",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": true
},
{
"id": "count_02",
"text": "둘",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_03",
"text": "셋",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_04",
"text": "넷",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_05",
"text": "다섯",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_06",
"text": "여섯",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_07",
"text": "일곱",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_08",
"text": "여덟",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_09",
"text": "아홉",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_10",
"text": "열",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "guide_collect",
"text": "친구를 찾아 끌어와 볼까?",
"usage": "guide_collect",
"stageIds": [
1,
2,
3,
4,
5,
16,
17,
18,
19,
20
],
"required": true
},
{
"id": "guide_count",
"text": "모두 모았네! 하나씩 눌러 세어 보자.",
"usage": "guide_count",
"stageIds": [
1,
2,
3,
4,
5,
16,
17,
18,
19,
20
],
"required": true
}
]
}
@@ -0,0 +1,24 @@
{
"schemaVersion": 1,
"modelId": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"modelRevision": "0c0e3051f131929182e2c023b9537f8b1c68adfe",
"language": "Korean",
"speaker": "Sohee",
"instruct": "5~7세 아이에게 이야기하듯 따뜻하고 밝게, 천천히 또렷하게 읽어 주세요. 숫자는 정확하게 읽고 과장된 비명이나 웃음소리는 넣지 마세요.",
"device": "cuda:0",
"dtype": "bfloat16",
"attention": "sdpa",
"seed": 42,
"generationParameters": {
"do_sample": true,
"repetition_penalty": 1.05,
"temperature": 0.9,
"top_p": 1,
"top_k": 50,
"subtalker_dosample": true,
"subtalker_temperature": 0.9,
"subtalker_top_p": 1,
"subtalker_top_k": 50,
"max_new_tokens": 8192
}
}
@@ -0,0 +1,421 @@
{
"schemaVersion": 1,
"clips": [
{
"id": "count_01",
"text": "하나",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": true
},
{
"id": "count_02",
"text": "둘",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_03",
"text": "셋",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_04",
"text": "넷",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_05",
"text": "다섯",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_06",
"text": "여섯",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_07",
"text": "일곱",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_08",
"text": "여덟",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_09",
"text": "아홉",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_10",
"text": "열",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "guide_collect",
"text": "친구를 찾아 끌어와 볼까?",
"usage": "guide_collect",
"stageIds": [
1,
2,
3,
4,
5,
16,
17,
18,
19,
20
],
"required": true
},
{
"id": "guide_count",
"text": "모두 모았네! 하나씩 눌러 세어 보자.",
"usage": "guide_count",
"stageIds": [
1,
2,
3,
4,
5,
16,
17,
18,
19,
20
],
"required": true
}
]
}
@@ -0,0 +1,24 @@
{
"schemaVersion": 1,
"modelId": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"modelRevision": "0c0e3051f131929182e2c023b9537f8b1c68adfe",
"language": "Korean",
"speaker": "Sohee",
"instruct": "5~7세 아이에게 이야기하듯 따뜻하고 밝게, 천천히 또렷하게 읽어 주세요. 숫자는 정확하게 읽고 과장된 비명이나 웃음소리는 넣지 마세요.",
"device": "cuda:0",
"dtype": "bfloat16",
"attention": "sdpa",
"seed": 42,
"generationParameters": {
"do_sample": true,
"repetition_penalty": 1.05,
"temperature": 0.9,
"top_p": 1,
"top_k": 50,
"subtalker_dosample": true,
"subtalker_temperature": 0.9,
"subtalker_top_p": 1,
"subtalker_top_k": 50,
"max_new_tokens": 8192
}
}
@@ -0,0 +1,421 @@
{
"schemaVersion": 1,
"clips": [
{
"id": "count_01",
"text": "하나",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": true
},
{
"id": "count_02",
"text": "둘",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_03",
"text": "셋",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_04",
"text": "넷",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_05",
"text": "다섯",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_06",
"text": "여섯",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_07",
"text": "일곱",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_08",
"text": "여덟",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_09",
"text": "아홉",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_10",
"text": "열",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "guide_collect",
"text": "친구를 찾아 끌어와 볼까?",
"usage": "guide_collect",
"stageIds": [
1,
2,
3,
4,
5,
16,
17,
18,
19,
20
],
"required": true
},
{
"id": "guide_count",
"text": "모두 모았네! 하나씩 눌러 세어 보자.",
"usage": "guide_count",
"stageIds": [
1,
2,
3,
4,
5,
16,
17,
18,
19,
20
],
"required": true
}
]
}
@@ -0,0 +1,24 @@
{
"schemaVersion": 1,
"modelId": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"modelRevision": "0c0e3051f131929182e2c023b9537f8b1c68adfe",
"language": "Korean",
"speaker": "Sohee",
"instruct": "5~7세 아이에게 이야기하듯 따뜻하고 밝게, 천천히 또렷하게 읽어 주세요. 숫자는 정확하게 읽고 과장된 비명이나 웃음소리는 넣지 마세요.",
"device": "cuda:0",
"dtype": "bfloat16",
"attention": "sdpa",
"seed": 42,
"generationParameters": {
"do_sample": true,
"repetition_penalty": 1.05,
"temperature": 0.9,
"top_p": 1,
"top_k": 50,
"subtalker_dosample": true,
"subtalker_temperature": 0.9,
"subtalker_top_p": 1,
"subtalker_top_k": 50,
"max_new_tokens": 8192
}
}
@@ -0,0 +1,421 @@
{
"schemaVersion": 1,
"clips": [
{
"id": "count_01",
"text": "하나",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": true
},
{
"id": "count_02",
"text": "둘",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_03",
"text": "셋",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_04",
"text": "넷",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_05",
"text": "다섯",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_06",
"text": "여섯",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_07",
"text": "일곱",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_08",
"text": "여덟",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_09",
"text": "아홉",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_10",
"text": "열",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "guide_collect",
"text": "친구를 찾아 끌어와 볼까?",
"usage": "guide_collect",
"stageIds": [
1,
2,
3,
4,
5,
16,
17,
18,
19,
20
],
"required": true
},
{
"id": "guide_count",
"text": "모두 모았네! 하나씩 눌러 세어 보자.",
"usage": "guide_count",
"stageIds": [
1,
2,
3,
4,
5,
16,
17,
18,
19,
20
],
"required": true
}
]
}
@@ -0,0 +1,24 @@
{
"schemaVersion": 1,
"modelId": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"modelRevision": "0c0e3051f131929182e2c023b9537f8b1c68adfe",
"language": "Korean",
"speaker": "Sohee",
"instruct": "5~7세 아이에게 이야기하듯 따뜻하고 밝게, 천천히 또렷하게 읽어 주세요. 숫자는 정확하게 읽고 과장된 비명이나 웃음소리는 넣지 마세요.",
"device": "cuda:0",
"dtype": "bfloat16",
"attention": "sdpa",
"seed": 42,
"generationParameters": {
"do_sample": true,
"repetition_penalty": 1.05,
"temperature": 0.9,
"top_p": 1,
"top_k": 50,
"subtalker_dosample": true,
"subtalker_temperature": 0.9,
"subtalker_top_p": 1,
"subtalker_top_k": 50,
"max_new_tokens": 8192
}
}
@@ -0,0 +1,421 @@
{
"schemaVersion": 1,
"clips": [
{
"id": "count_01",
"text": "하나",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": true
},
{
"id": "count_02",
"text": "둘",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_03",
"text": "셋",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_04",
"text": "넷",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_05",
"text": "다섯",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_06",
"text": "여섯",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_07",
"text": "일곱",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_08",
"text": "여덟",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_09",
"text": "아홉",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "count_10",
"text": "열",
"usage": "count",
"stageIds": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30
],
"required": false
},
{
"id": "guide_collect",
"text": "친구를 찾아 끌어와 볼까?",
"usage": "guide_collect",
"stageIds": [
1,
2,
3,
4,
5,
16,
17,
18,
19,
20
],
"required": true
},
{
"id": "guide_count",
"text": "모두 모았네! 하나씩 눌러 세어 보자.",
"usage": "guide_count",
"stageIds": [
1,
2,
3,
4,
5,
16,
17,
18,
19,
20
],
"required": true
}
]
}
@@ -0,0 +1,184 @@
{
"schemaVersion": 1,
"runId": "20261004T171918683576Z_7a8cbd60",
"createdUtc": "2026-10-04T17:19:18.685577+00:00",
"modelRevision": "0c0e3051f131929182e2c023b9537f8b1c68adfe",
"catalogHash": "491c9cd0bb7068a47b33bdd0b19ece1ba634217ec0ed9cc3e2c5aa7bd3802755",
"profileHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"scope": "selected",
"regenerateRequested": false,
"inputSnapshots": {
"catalog": "catalog.json",
"profile": "profile.json"
},
"requestedIds": [
"count_01",
"guide_collect",
"guide_count"
],
"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"
},
"modelLoad": "not_needed",
"requestedDevice": "cuda:0",
"sourceGenerations": [
{
"runId": "20261004T161148012934Z_47f3a009",
"manifest": "generated/20261004T161148012934Z_47f3a009/manifest.json",
"manifestSha256": "302b162f50b541aca8f82461429a9d478cbd342afe062240010a4c80f9853be8",
"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",
"cudaProbe": 140.0,
"modelLoadSeconds": 3.2142242998816073,
"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": 4232247296
}
},
{
"runId": "20261004T161231511126Z_aa20f404",
"manifest": "generated/20261004T161231511126Z_aa20f404/manifest.json",
"manifestSha256": "a45ca839d8d826c1c5ca223c7b9aee9c8147cd475a27d8de33153ba8dac21422",
"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",
"cudaProbe": 140.0,
"modelLoadSeconds": 3.5885288000572473,
"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
}
}
],
"device": "NVIDIA GeForce RTX 4080 SUPER",
"deviceProbe": "nvidia-smi; no model or CUDA inference executed"
},
"clips": [
{
"id": "count_01",
"textHash": "4865b7d3cb44d273047c04c7f4ba1e2efdd2c489c365b9f5308f8e4c25c4ffe3",
"settingsHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"originalRunId": "20261004T161148012934Z_47f3a009",
"file": "generated/20261004T161148012934Z_47f3a009/count_01.wav",
"review": "not_listened",
"sha256": "cb7f5e25d8ba3e726b9557597002aef72c35e51edd6012defcb327728c10021f",
"sampleRate": 24000,
"channels": 1,
"frames": 13440,
"duration": 0.56,
"peak": 0.34375,
"rms": 0.07997916638851166,
"qualityWarnings": [],
"result": "reused",
"generationSeconds": 1.9430886001791805
},
{
"id": "guide_collect",
"textHash": "963c0253da8f4595da3ef968c576481f4590960d533b5059fcdfe8e791ee7a22",
"settingsHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"originalRunId": "20261004T161148012934Z_47f3a009",
"file": "generated/20261004T161148012934Z_47f3a009/guide_collect.wav",
"review": "not_listened",
"sha256": "c772e4d9996b129eb4fdd59d90d03e93efa7ef434112e342be2e455d203878e6",
"sampleRate": 24000,
"channels": 1,
"frames": 48000,
"duration": 2.0,
"peak": 0.60546875,
"rms": 0.11194184422492981,
"qualityWarnings": [],
"result": "reused",
"generationSeconds": 4.32505049998872
},
{
"id": "guide_count",
"textHash": "b1dc061607df3f064c4e2625adaf2579dabd9b15ef68bda16a3583035838e1a2",
"settingsHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"originalRunId": "20261004T161231511126Z_aa20f404",
"file": "generated/20261004T161231511126Z_aa20f404/guide_count.wav",
"review": "not_listened",
"sha256": "63086b0a17769a6c1a8e270f75db08db604b5eeb430456042e168ce95d02babe",
"sampleRate": 24000,
"channels": 1,
"frames": 86400,
"duration": 3.6,
"peak": 0.578125,
"rms": 0.06774810701608658,
"qualityWarnings": [],
"result": "reused",
"generationSeconds": 8.88178739999421
}
]
}
@@ -0,0 +1,24 @@
{
"schemaVersion": 1,
"modelId": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"modelRevision": "0c0e3051f131929182e2c023b9537f8b1c68adfe",
"language": "Korean",
"speaker": "Sohee",
"instruct": "5~7세 아이에게 이야기하듯 따뜻하고 밝게, 천천히 또렷하게 읽어 주세요. 숫자는 정확하게 읽고 과장된 비명이나 웃음소리는 넣지 마세요.",
"device": "cuda:0",
"dtype": "bfloat16",
"attention": "sdpa",
"seed": 42,
"generationParameters": {
"do_sample": true,
"repetition_penalty": 1.05,
"temperature": 0.9,
"top_p": 1,
"top_k": 50,
"subtalker_dosample": true,
"subtalker_temperature": 0.9,
"subtalker_top_p": 1,
"subtalker_top_k": 50,
"max_new_tokens": 8192
}
}
@@ -0,0 +1,74 @@
{
"schemaVersion": 1,
"createdUtc": "2026-10-04T17:19:17.322368+00:00",
"method": "verified_existing_hash_match_sidecar_backfill_no_inference",
"sourceCatalog": "voice-catalog.json",
"sourceProfile": "qwen-profile.json",
"preservedArtifactSha256": {
"SourceAssets/Audio/Voice/generated/20261004T161148012934Z_47f3a009/count_01.wav": "cb7f5e25d8ba3e726b9557597002aef72c35e51edd6012defcb327728c10021f",
"SourceAssets/Audio/Voice/generated/20261004T161148012934Z_47f3a009/count_04.wav": "c37c335baa80dbdade6e8c6163eb2339cda307d187258306e881b55ab7149f79",
"SourceAssets/Audio/Voice/generated/20261004T161148012934Z_47f3a009/count_07.wav": "af505fe8825eda62af188ee9dfe79b1ee91ff819cbe8e07596af6f741e0bf5a4",
"SourceAssets/Audio/Voice/generated/20261004T161148012934Z_47f3a009/count_10.wav": "23e41b1b1ab51160979ac665301bbc91251505dedcf0c574f0f9fc5e457ac73a",
"SourceAssets/Audio/Voice/generated/20261004T161148012934Z_47f3a009/guide_collect.wav": "c772e4d9996b129eb4fdd59d90d03e93efa7ef434112e342be2e455d203878e6",
"SourceAssets/Audio/Voice/generated/20261004T161231511126Z_aa20f404/count_02.wav": "ee8b600198b802248715beafe51848066ce4955c20ac600d5b577590372830cf",
"SourceAssets/Audio/Voice/generated/20261004T161231511126Z_aa20f404/count_03.wav": "572416e0d66821e1b939833cab328c23b5c97aef2233619428cd54c0603b6b12",
"SourceAssets/Audio/Voice/generated/20261004T161231511126Z_aa20f404/count_05.wav": "8b0c3a36c6608834783a6781d1ff4c2aabc81e4a4ccf841e19f1729087cfc3e5",
"SourceAssets/Audio/Voice/generated/20261004T161231511126Z_aa20f404/count_06.wav": "235c4b6a73be845080af4a10508c433e3d6bf05cb4e0445cba18e23e367d51f7",
"SourceAssets/Audio/Voice/generated/20261004T161231511126Z_aa20f404/count_08.wav": "172ebad2a6e150a9c43088c78ec7f45040cfe13ea1c8b0515c6ec0fdf8020b23",
"SourceAssets/Audio/Voice/generated/20261004T161231511126Z_aa20f404/count_09.wav": "c4a0a9183e1a108a4f54b8364971de3693083e27f5f1f69d8bdba7030313cea5",
"SourceAssets/Audio/Voice/generated/20261004T161231511126Z_aa20f404/guide_count.wav": "63086b0a17769a6c1a8e270f75db08db604b5eeb430456042e168ce95d02babe",
"SourceAssets/Audio/Voice/generated/20261004T162201751189Z_a400364e/count_05.wav": "f832eddc80fb27dfae338214205308ee8119c75a82037bf79d309100c84828f1",
"SourceAssets/Audio/Voice/generated/20261004T161148012934Z_47f3a009/manifest.json": "302b162f50b541aca8f82461429a9d478cbd342afe062240010a4c80f9853be8",
"SourceAssets/Audio/Voice/generated/20261004T161231511126Z_aa20f404/manifest.json": "a45ca839d8d826c1c5ca223c7b9aee9c8147cd475a27d8de33153ba8dac21422",
"SourceAssets/Audio/Voice/generated/20261004T162201751189Z_a400364e/manifest.json": "fafcf28f4a25a38f0aa17d4676609a90b311dd7d93b3757ba9a0f9cc37d41f1e",
"SourceAssets/Audio/Voice/generated/20261004T165608559600Z_21530dcf/manifest.json": "4799912675cd883bc5d4dc4ab2b9acde4f621be8ce496179d01c81bd22bb5ef1",
"SourceAssets/Audio/Voice/voice-catalog.json": "d1246280c8c0ecd0f0c72c8fdc1d923cd50a30ed2bc37c9d712d1ddbc9d15f96",
"SourceAssets/Audio/Voice/qwen-profile.json": "d3af2eb82bdd9a52e256b11723f561d45b5c251298b4a36bef568c0972d015f8",
"SourceAssets/Audio/Voice/edited/20261004T161231511126Z_aa20f404/selection.json": "8f614b20234df991562fb9b2c4c6fc7c97557fc446059872502c1521fb8bc847",
"Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/count_01.wav": "13a882ce1620140d447635e14f2d4758045d5e348960c17386df1c720050c100",
"Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/count_02.wav": "278dd0b6e96f2abca2237ff98eda453a011fa0b2e78d5575b7ddc9c9b3028b32",
"Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/count_03.wav": "4133b0150aa08e26f1b93b11d29ecab1282bbff236b3c92f8416e781109188c1",
"Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/count_04.wav": "c5c8c78ccd89011050d7317960a854130e3e445c8b92d23e9520eeb7490aa300",
"Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/count_06.wav": "d4800a23a73d1315987d9129d260baa0680621d6502351137845a6ee351c6274",
"Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/count_07.wav": "7d4ba785ccb28b8f2bf0d8f66053dc8adf1a19d332bedfab3bffd596208fe829",
"Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/count_08.wav": "6bde9265a8e4ac5a4209b1ddcebf57251430bd2eb5134a59af403f351a640aa2",
"Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/count_09.wav": "4a921bc405ed5181e26365764ef94c2d311af45ed5508b27ffd7ac6d53d83802",
"Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/count_10.wav": "b16ebccea20aedf93b884299edb0dd430f67e4deef069fb354b482f21b8e5fb8",
"Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/guide_collect.wav": "62e15c9e38c36f4094a6c7e9c5148510141290b5f41db5aad9dc1d8f1a158594",
"Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/guide_count.wav": "debe29b89f3e2856f2bf4c5e01707a5f0a0657e5768c03be385ec88fa2328b40"
},
"runs": [
{
"runId": "20261004T161148012934Z_47f3a009",
"originalManifestSha256": "302b162f50b541aca8f82461429a9d478cbd342afe062240010a4c80f9853be8",
"catalogHash": "491c9cd0bb7068a47b33bdd0b19ece1ba634217ec0ed9cc3e2c5aa7bd3802755",
"profileHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"catalogSnapshot": "generated/20261004T161148012934Z_47f3a009/catalog.json",
"profileSnapshot": "generated/20261004T161148012934Z_47f3a009/profile.json"
},
{
"runId": "20261004T161231511126Z_aa20f404",
"originalManifestSha256": "a45ca839d8d826c1c5ca223c7b9aee9c8147cd475a27d8de33153ba8dac21422",
"catalogHash": "491c9cd0bb7068a47b33bdd0b19ece1ba634217ec0ed9cc3e2c5aa7bd3802755",
"profileHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"catalogSnapshot": "generated/20261004T161231511126Z_aa20f404/catalog.json",
"profileSnapshot": "generated/20261004T161231511126Z_aa20f404/profile.json"
},
{
"runId": "20261004T162201751189Z_a400364e",
"originalManifestSha256": "fafcf28f4a25a38f0aa17d4676609a90b311dd7d93b3757ba9a0f9cc37d41f1e",
"catalogHash": "491c9cd0bb7068a47b33bdd0b19ece1ba634217ec0ed9cc3e2c5aa7bd3802755",
"profileHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"catalogSnapshot": "generated/20261004T162201751189Z_a400364e/catalog.json",
"profileSnapshot": "generated/20261004T162201751189Z_a400364e/profile.json"
},
{
"runId": "20261004T165608559600Z_21530dcf",
"originalManifestSha256": "4799912675cd883bc5d4dc4ab2b9acde4f621be8ce496179d01c81bd22bb5ef1",
"catalogHash": "491c9cd0bb7068a47b33bdd0b19ece1ba634217ec0ed9cc3e2c5aa7bd3802755",
"profileHash": "a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8",
"catalogSnapshot": "generated/20261004T165608559600Z_21530dcf/catalog.json",
"profileSnapshot": "generated/20261004T165608559600Z_21530dcf/profile.json"
}
]
}
+15 -1
View File
@@ -56,7 +56,7 @@ 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 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
``` ```
초기 RED는 생성기 모듈이 없어 종료 코드 1이었습니다. 최종 GREEN에서는 동작 검증 테스트 11개가 종료 코드 0으로 통과했습니다. 잘못된 카탈로그, 현재 대본·프로필과 부분 검사 범위, 실패하거나 비정상적인 WAV, 누락·손상 WAV, 대본·설정 변경, 해시 변조, 이전 원본 보존, 리샘플링, 다른 게임 파일 덮어쓰기 거부, 긴 숫자 출력 차단, 특정 클립만 새 회차로 생성하는 동작, 전부 재사용할 때 모델 로딩 없이 현재 패키지·장치와 원본 생성 환경을 연결하는 동작을 검사합니다. 초기 RED는 생성기 모듈이 없어 종료 코드 1이었습니다. 최종 GREEN에서는 동작 검증 테스트 16개가 종료 코드 0으로 통과했습니다. 잘못된 카탈로그, 현재 대본·프로필과 부분 검사 범위, 실패하거나 비정상적인 WAV, 누락·손상 WAV, 대본·설정 변경, 해시 변조, 이전 원본 보존, 리샘플링, 다른 게임 파일 덮어쓰기 거부, 긴 숫자 출력 차단, 특정 클립만 새 회차로 생성하는 동작, 전부 재사용할 때 모델 로딩 없이 현재 패키지·장치와 원본 생성 환경을 연결하는 동작을 검사합니다. 회차 입력 snapshot 보존·손상 검사와 검증 전용 경로의 비로딩 검사도 포함합니다.
첫 표본 생성·확대 생성·검사 전용 명령은 각각 종료 코드 0이었습니다. `--validate-only`는 모델을 로딩하거나 다운로드하지 않습니다. ID를 생략하면 현재 T2 필수 세 클립 전체를 검사하고, `--ids`를 지정하면 선택 범위를 결과에 기록합니다. 생성한 열두 클립을 모두 지정한 파일·이력 검사는 종료 코드 0이었지만 count_05의 별도 음질 문제는 합격으로 처리하지 않았습니다. 잘못된 ID `missing`은 해당 ID를 포함한 오류와 종료 코드 1을 반환했습니다. 마지막 `pip check`는 종료 코드 0으로 충돌이 없었습니다. 첫 표본 생성·확대 생성·검사 전용 명령은 각각 종료 코드 0이었습니다. `--validate-only`는 모델을 로딩하거나 다운로드하지 않습니다. ID를 생략하면 현재 T2 필수 세 클립 전체를 검사하고, `--ids`를 지정하면 선택 범위를 결과에 기록합니다. 생성한 열두 클립을 모두 지정한 파일·이력 검사는 종료 코드 0이었지만 count_05의 별도 음질 문제는 합격으로 처리하지 않았습니다. 잘못된 ID `missing`은 해당 ID를 포함한 오류와 종료 코드 1을 반환했습니다. 마지막 `pip check`는 종료 코드 0으로 충돌이 없었습니다.
@@ -65,3 +65,17 @@ python tools/audio/generate_voice.py --catalog SourceAssets/Audio/Voice/voice-ca
전부 재사용하는 실제 CLI 검사 회차 `20261004T165608559600Z_21530dcf`는 세 필수 클립을 재사용하고 종료 코드 0으로 통과했습니다. 새 manifest에 현재 Python·패키지 버전, `nvidia-smi`로 확인한 RTX 4080 SUPER 장치, `modelLoad=not_needed`, 서로 다른 두 원본 생성 회차의 manifest 경로·SHA-256·실제 생성 환경을 기록했습니다. 원본 manifest 해시도 대조했습니다. 모델 로딩·재추론은 하지 않았으며 기존 승인 파일과 기본 프로필을 변경하지 않았습니다. 해당 실행 로그는 `reuse-environment.log`입니다. 전부 재사용하는 실제 CLI 검사 회차 `20261004T165608559600Z_21530dcf`는 세 필수 클립을 재사용하고 종료 코드 0으로 통과했습니다. 새 manifest에 현재 Python·패키지 버전, `nvidia-smi`로 확인한 RTX 4080 SUPER 장치, `modelLoad=not_needed`, 서로 다른 두 원본 생성 회차의 manifest 경로·SHA-256·실제 생성 환경을 기록했습니다. 원본 manifest 해시도 대조했습니다. 모델 로딩·재추론은 하지 않았으며 기존 승인 파일과 기본 프로필을 변경하지 않았습니다. 해당 실행 로그는 `reuse-environment.log`입니다.
Unity `.meta`는 통합 후 실제 Editor import로 생성해야 하며 직접 작성하지 않았습니다. 남은 검수는 승인된 세 파일을 제외한 발화의 청취·승인 해시 기록, count_05 및 이후 안내 음성 검수, Android 빌드의 음성 큐·믹스 재생 확인입니다. Unity `.meta`는 통합 후 실제 Editor import로 생성해야 하며 직접 작성하지 않았습니다. 남은 검수는 승인된 세 파일을 제외한 발화의 청취·승인 해시 기록, count_05 및 이후 안내 음성 검수, Android 빌드의 음성 큐·믹스 재생 확인입니다.
## 회차별 실제 입력 보존 보완
이전 manifest는 실제 입력의 해시만 담고 있어 공유 카탈로그·프로필을 변경하면 과거 WAV의 대본과 말투 지시·seed 등을 직접 복원하기 어려웠습니다. 새 회차는 `generated/<runId>/catalog.json`과 `profile.json`에 전체 실제 입력을 보존합니다. manifest의 `inputSnapshots`가 이 두 파일을 가리키며 기존 `catalogHash`·`profileHash`와 구조화된 JSON의 SHA-256을 대조합니다. 프로필 snapshot에는 모델 ID와 고정 revision, 언어, 목소리, 지시문, 장치, dtype, attention, seed, 생성 인자가 그대로 남습니다. 실제 모델 기본값과 적용 인자는 기존 환경 기록으로 함께 확인합니다.
snapshot은 새 회차 폴더를 만들 때 한 번 저장하고 공유 입력 변경이나 다른 회차 생성으로 덮어쓰지 않습니다. 재사용은 원래 회차·파일·파일 해시·대본 및 설정 해시 연결을 유지하며, 해당 원본 manifest와 원본 입력 snapshot도 확인합니다. snapshot 누락·손상·해시 불일치 또는 현재 입력과 회차 입력의 불일치는 검증 실패입니다. `--validate-only`는 파일과 이력만 읽으며 모델이나 네트워크를 사용하지 않습니다.
기존 네 회차는 manifest와 WAV를 변경하지 않았습니다. 현재 고정 입력의 실제 구조화 해시가 각 과거 manifest의 `catalogHash=491c9cd0bb7068a47b33bdd0b19ece1ba634217ec0ed9cc3e2c5aa7bd3802755`, `profileHash=a5740d9db8a4aaebf08d0ac3b8f3225693f878aa2868143f01191a059e2680e8`과 정확히 일치함을 모두 대조한 후, 각 폴더에 입력 sidecar 두 파일씩 추가했습니다. 기존 manifest에는 snapshot 필드를 소급 작성하지 않았으며 고정 파일명 규칙으로 sidecar를 연결합니다. 전환 기록은 `input-snapshot-migration.json`에 기존 manifest 해시·검증한 입력 해시·sidecar 경로·날짜·보존 artifact 해시를 담았습니다. 기존 원본 WAV, 네 manifest, 공유 입력, 사용자 승인 selection, 게임 WAV 등 총 31개 artifact의 SHA-256이 전환 전후 동일했습니다. 새 추론을 수행한 것처럼 기록하지 않았습니다.
입력 보존 회귀 검사 네 개는 구현 전 RED(종료 1, 실패 3개·오류 1개)를 재현했고, 보완 후 전체 16개가 GREEN(종료 0)이었습니다. 공유 입력을 실제 변경한 후에도 과거 snapshot의 전체 대본·설정과 원본 WAV가 보존되는지 검사했습니다. legacy sidecar 검사와 원본 snapshot 누락을 통한 재사용 연결 실패도 확인했습니다.
새 회차 `20261004T171918683576Z_7a8cbd60`는 세 필수 음성을 전부 재사용하고 실제 입력 snapshot과 manifest를 기록했으며 CLI 종료 0이었습니다. 기존 네 회차와 새 회차의 검증 전용 명령은 각각 명시된 생성 범위에 대해 종료 0이었습니다. 이때 모델 loader·장치 명령을 차단했고 torch·qwen_tts·huggingface_hub가 import되지 않았음을 별도 JSON 증거에 기록했습니다. 원본을 건드리지 않는 검사 복사본에서 snapshot 누락·손상·해시 불일치, manifest 해시 불일치, 현재 프로필 불일치의 실제 CLI 다섯 건은 각각 종료 1을 반환했습니다. 승인된 세 게임 WAV와 기본 프로필은 변경하지 않았습니다.
증거 경로는 `artifacts/verification/FirstSlice/Voice/InputSnapshots/`의 `red-tests.log`, `green-tests.log`, `legacy-migration.log`, `reuse-new-snapshot-run.log`, `validate-only-proof.json` 및 `.log`, `negative-cli-proof.json` 및 `.log`입니다. 이는 입력·파일·이력 검증 증거이며 미검수 음성의 청각 합격을 뜻하지 않습니다.
+47 -1
View File
@@ -92,6 +92,31 @@ def waveform_metadata(path):
(['clipping candidate'] if peak >= .999 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): def clip_errors(entry, clip, profile, root):
errors = [] errors = []
if entry.get('result') not in ('generated', 'reused'): if entry.get('result') not in ('generated', 'reused'):
@@ -108,8 +133,21 @@ def clip_errors(entry, clip, profile, root):
errors.append(f"{clip['id']}: {key} mismatch") errors.append(f"{clip['id']}: {key} mismatch")
if actual['channels'] != 1: if actual['channels'] != 1:
errors.append(f"{clip['id']}: expected mono") 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: except (KeyError, OSError, ValueError, sf.LibsndfileError) as error:
errors.append(f"{clip['id']}: invalid WAV: {error}") errors.append(f"{clip['id']}: invalid source artifact: {error}")
return errors return errors
@@ -117,6 +155,10 @@ def validate_manifest(manifest, catalog, profile, root, ids=None):
validate_inputs(catalog, profile) validate_inputs(catalog, profile)
requested = select_clips(catalog, ids, required_only=ids is None) requested = select_clips(catalog, ids, required_only=ids is None)
errors = [] 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']: if manifest.get('schemaVersion') != 1 or manifest.get('modelRevision') != profile['modelRevision']:
errors.append('manifest schema/revision differs') errors.append('manifest schema/revision differs')
if manifest.get('catalogHash') != digest(catalog) or manifest.get('profileHash') != digest(profile): if manifest.get('catalogHash') != digest(catalog) or manifest.get('profileHash') != digest(profile):
@@ -139,6 +181,7 @@ def reusable_entries(root, catalog, profile):
for path in sorted((root / 'generated').glob('*/manifest.json'), reverse=True): for path in sorted((root / 'generated').glob('*/manifest.json'), reverse=True):
try: try:
manifest = load_json(path) manifest = load_json(path)
load_run_inputs(manifest, root)
for entry in manifest.get('clips', []): for entry in manifest.get('clips', []):
clip = by_id.get(entry.get('id')) clip = by_id.get(entry.get('id'))
if clip and clip['id'] not in result and not clip_errors(entry, clip, profile, root): if clip and clip['id'] not in result and not clip_errors(entry, clip, profile, root):
@@ -177,10 +220,13 @@ def generate(catalog, profile, root, ids=None, synthesize=None, environment=None
run_id = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ_') + uuid.uuid4().hex[:8] 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 = root / 'generated' / run_id
run_dir.mkdir(parents=True) 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(), manifest = {'schemaVersion': 1, 'runId': run_id, 'createdUtc': datetime.now(timezone.utc).isoformat(),
'modelRevision': profile['modelRevision'], 'catalogHash': digest(catalog), 'modelRevision': profile['modelRevision'], 'catalogHash': digest(catalog),
'profileHash': digest(profile), 'scope': 'selected' if ids else 'all', 'profileHash': digest(profile), 'scope': 'selected' if ids else 'all',
'regenerateRequested': regenerate, 'regenerateRequested': regenerate,
'inputSnapshots': {'catalog': 'catalog.json', 'profile': 'profile.json'},
'requestedIds': [c['id'] for c in selected], 'environment': environment or {}, 'clips': []} 'requestedIds': [c['id'] for c in selected], 'environment': environment or {}, 'clips': []}
try: try:
for clip in selected: for clip in selected:
+71
View File
@@ -152,6 +152,77 @@ class VoiceHistoryTests(unittest.TestCase):
self.assertEqual(voice.file_hash(self.root / source['manifest']), source['manifestSha256']) self.assertEqual(voice.file_hash(self.root / source['manifest']), source['manifestSha256'])
self.assertEqual({'test': True}, source['environment']) self.assertEqual({'test': True}, source['environment'])
def test_run_input_snapshots_preserve_text_and_settings_after_shared_files_change(self):
old_catalog, old_profile = copy.deepcopy(self.catalog), copy.deepcopy(self.profile)
first = self.run_generation()
run_dir = self.root / 'generated' / first['runId']
original_wav = (self.root / first['clips'][0]['file']).read_bytes()
self.catalog['clips'][0]['text'] = '하나예요.'
self.profile.update(seed=43, instruct='밝게 읽어 주세요.', dtype='float16', attention='eager')
voice.write_json(self.root / 'voice-catalog.json', self.catalog)
voice.write_json(self.root / 'qwen-profile.json', self.profile)
self.run_generation()
self.assertEqual(old_catalog, voice.load_json(run_dir / 'catalog.json'))
self.assertEqual(old_profile, voice.load_json(run_dir / 'profile.json'))
self.assertEqual(first['catalogHash'], voice.digest(voice.load_json(run_dir / 'catalog.json')))
self.assertEqual(first['profileHash'], voice.digest(voice.load_json(run_dir / 'profile.json')))
self.assertEqual(original_wav, (self.root / first['clips'][0]['file']).read_bytes())
self.assertTrue(voice.validate_manifest(first, self.catalog, self.profile, self.root))
def test_run_input_snapshots_missing_corrupt_or_hash_mismatch_fail_validation(self):
manifest = self.run_generation()
run_dir = self.root / 'generated' / manifest['runId']
for name, data in [('catalog.json', self.catalog), ('profile.json', self.profile)]:
path = run_dir / name
for contents in (None, 'not json', '{}'):
if contents is None:
path.unlink(missing_ok=True)
else:
path.write_text(contents, encoding='utf-8')
self.assertTrue(voice.validate_manifest(manifest, self.catalog, self.profile, self.root))
voice.write_json(path, data)
changed = copy.deepcopy(manifest)
changed['profileHash'] = 'f' * 64
self.assertTrue(voice.validate_manifest(changed, self.catalog, self.profile, self.root))
def test_reused_run_rejects_missing_original_input_snapshot(self):
first = self.run_generation()
second = self.run_generation()
self.assertEqual('reused', second['clips'][0]['result'])
(self.root / 'generated' / first['runId'] / 'profile.json').unlink(missing_ok=True)
self.assertTrue(voice.validate_manifest(second, self.catalog, self.profile, self.root))
def test_snapshot_validation_cli_does_not_load_model_or_use_network(self):
import sys
manifest = self.run_generation()
catalog = self.root / 'voice-catalog.json'
profile = self.root / 'qwen-profile.json'
voice.write_json(catalog, self.catalog)
voice.write_json(profile, self.profile)
args = ['generate_voice.py', '--catalog', str(catalog), '--profile', str(profile),
'--validate-only', '--manifest', str(self.root / 'generated' / manifest['runId'] / 'manifest.json')]
with mock.patch.object(sys, 'argv', args), \
mock.patch.object(voice, 'load_synthesizer', side_effect=AssertionError('model loaded')) as loader, \
mock.patch.object(voice.subprocess, 'run', side_effect=AssertionError('device command ran')), \
mock.patch.dict(sys.modules, {'torch': None, 'qwen_tts': None, 'huggingface_hub': None}):
self.assertEqual(0, voice.main())
snapshot = self.root / 'generated' / manifest['runId'] / 'catalog.json'
for contents in (None, 'not json', '{}'):
if contents is None:
snapshot.unlink(missing_ok=True)
else:
snapshot.write_text(contents, encoding='utf-8')
self.assertEqual(1, voice.main())
loader.assert_not_called()
def test_legacy_manifest_uses_verified_sidecar_without_manifest_rewrite(self):
manifest = self.run_generation()
legacy = copy.deepcopy(manifest)
legacy.pop('inputSnapshots')
saved_manifest = (self.root / 'generated' / manifest['runId'] / 'manifest.json').read_bytes()
self.assertEqual([], voice.validate_manifest(legacy, self.catalog, self.profile, self.root))
self.assertEqual(saved_manifest, (self.root / 'generated' / manifest['runId'] / 'manifest.json').read_bytes())
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()