@dsh-gis/dsh-video-studio-skill 0.9.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +424 -0
- package/README.md +287 -327
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/package.json +9 -10
- package/schemas/design.schema.json +722 -0
- package/schemas/film.schema.json +4 -15
- package/schemas/proposal.schema.json +133 -0
- package/skills/video-studio/SKILL.md +1316 -1123
- package/skills/video-studio/assets/design_fixture.json +141 -0
- package/skills/video-studio/assets/film.json +1 -2
- package/skills/video-studio/assets/golden_frames.json +11 -8
- package/skills/video-studio/assets/scenes.py +67 -22
- package/skills/video-studio/assets/voice_loudness.json +16 -0
- package/skills/video-studio/references/audio-variation.md +15 -11
- package/skills/video-studio/references/audio.md +36 -34
- package/skills/video-studio/references/change-impact.md +3 -3
- package/skills/video-studio/references/frame-qa.md +2 -2
- package/skills/video-studio/references/proposal.md +177 -0
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/scene-grammar.md +28 -23
- package/skills/video-studio/references/sfx-design.md +1 -1
- package/skills/video-studio/references/style-packs.md +95 -257
- package/skills/video-studio/references/v08-quality.md +8 -8
- package/skills/video-studio/references/web-engine.md +1 -1
- package/skills/video-studio/references/word-timeline.md +2 -2
- package/skills/video-studio/scripts/designkit.py +2120 -0
- package/skills/video-studio/scripts/filmcheck.py +7 -33
- package/skills/video-studio/scripts/filmkit.py +153 -78
- package/skills/video-studio/scripts/grammar.py +67 -7
- package/skills/video-studio/scripts/musicgen.py +2043 -0
- package/skills/video-studio/scripts/preflight.py +10 -3
- package/skills/video-studio/scripts/previewkit.py +849 -0
- package/skills/video-studio/scripts/propose.py +825 -0
- package/skills/video-studio/scripts/qa_frames.py +45 -10
- package/skills/video-studio/scripts/qavmaf.py +77 -11
- package/skills/video-studio/scripts/render.py +472 -154
- package/skills/video-studio/scripts/scorekit.py +50 -1748
- package/skills/video-studio/scripts/selfcheck.py +658 -131
- package/skills/video-studio/scripts/stagewatch.py +21 -5
- package/skills/video-studio/scripts/stylekit.py +265 -741
- package/skills/video-studio/scripts/voicekit.py +502 -380
- package/skills/video-studio/scripts/webengine.py +15 -14
- package/tools/audio_similarity.py +57 -30
- package/tools/ci_check.py +7 -1
- package/tools/estimate.py +14 -8
- package/tools/golden_frames.py +56 -11
- package/tools/silhouette_qa.py +521 -168
- package/docs/demo-pil-sheet.png +0 -0
- package/docs/styles/demo-results.json +0 -76
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-before-after.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/skills/video-studio/assets/score_demo.json +0 -307
|
@@ -1,59 +1,57 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""voicekit.py —
|
|
2
|
+
"""voicekit.py — 旁白声线:直接给 edge voice id + 语气档 + 响度补偿表。
|
|
3
3
|
|
|
4
4
|
v0.5.0 新增。v0.4 的 render.py 把 script.voice / rate / pitch 三个单值一路传给
|
|
5
5
|
filmkit.tts_batch,结果是所有片、所有段落都由同一个人用同一种语气念完 ——
|
|
6
6
|
用户的原话是「旁白声音都差不多、整体画风也差不多,希望随内容变化」。
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
D3(v1.0)删掉了 persona 身份层
|
|
9
|
+
-------------------------------
|
|
10
|
+
以前有 9 个 persona(narrator_male 之类)当「谁在说话」的身份表。问题不在名字,
|
|
11
|
+
而在多了一层映射:design/style/line 里写 narrator_male,真正下发给 edge-tts 的却是
|
|
12
|
+
zh-CN-YunyangNeural。任何一层没对齐,表现都是「配了却没生效」,而且要等听成片才发现。
|
|
13
|
+
现在声线一律是 edge voice id(zh-CN-YunxiNeural),映射只发生一次、就在设计里。
|
|
14
|
+
|
|
15
|
+
被删掉的:PERSONA_IDS / VOICE_PERSONAS / _pick_persona / _govern / legacy 分支,
|
|
16
|
+
以及「文本里有引号就偷偷换人」那套规则 —— 身份是数据,不该由一个正则决定。
|
|
17
|
+
|
|
18
|
+
但 persona 表里有一件**不能丢**的东西:每把音色的实测响度补偿。删表不补补偿,
|
|
19
|
+
换音色就要人肉重新对电平,正是本次改造要根除的「改了不生效 / 要人调」。
|
|
20
|
+
所以补偿独立成一张按 voice id 的数据表 VOICE_LOUDNESS(没有任何身份含义)。
|
|
21
|
+
|
|
22
|
+
本模块只做「策展 + 计划」:给出每句用哪个音色、什么语气、语速/音高/音量抖多少、
|
|
9
23
|
前后留白多少。真正的合成仍在 filmkit.tts(Lead 负责接线),因此本模块
|
|
10
24
|
**不 import filmkit**(render.py 会同时 import 两者,互相 import 会成环),
|
|
11
25
|
只依赖 stylekit + 标准库。
|
|
12
26
|
|
|
13
27
|
三层结构
|
|
14
28
|
--------
|
|
15
|
-
|
|
29
|
+
VOICE_LOUDNESS 响度补偿(数据):每把音色实测的 level 补偿
|
|
16
30
|
VOICE_TONES 6 档语气(怎么说):相对 deltas + 前后留白 lead/tail
|
|
17
31
|
_rules() 文本规则(说什么):引号/数字/问叹/长短句/转折词 → 语气与微调
|
|
18
32
|
|
|
19
|
-
|
|
33
|
+
声线从哪来(高到低)
|
|
20
34
|
--------------------
|
|
21
|
-
lines[].
|
|
22
|
-
>
|
|
23
|
-
>
|
|
24
|
-
>
|
|
25
|
-
|
|
35
|
+
lines[].voice(显式 edge id)
|
|
36
|
+
> script.voice.map / design.audio.voice.map 的 kind 映射(D3 设计)
|
|
37
|
+
> style_plan["voice"]["map"][kind](风格包 / designkit.design_to_plan)
|
|
38
|
+
> script.voice.id / design.audio.voice.id / style_plan["voice"]["default"](主声线)
|
|
39
|
+
没有任何来源就报错(并给出迁移路径)——D3 的设计必须明确「谁在说话」。
|
|
40
|
+
|
|
41
|
+
声线一旦确定就**不再治理**:以前 _govern 会在段边界偷偷换人、用 max_distinct 限预算,
|
|
42
|
+
那层逻辑只对 persona 有意义(为了「别让一部片出现太多人」)。现在 map 怎么写就怎么念,
|
|
43
|
+
声线策略里的 max_distinct 随之退休 —— 想少换人就少写几个 kind,不需要中间层猜意图。
|
|
26
44
|
|
|
27
45
|
确定性
|
|
28
46
|
------
|
|
29
|
-
所有抖动都来自 sha1(title|id|
|
|
47
|
+
所有抖动都来自 sha1(title|id|voice),禁 random / time / hash()(str hash 带盐)。
|
|
30
48
|
同一份 film.json 两次 voice_plan() 完全相等;jitter=0 时与「无抖动」逐字段相等。
|
|
31
49
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
校准(实测定稿,换 voice id 必须重测)
|
|
39
|
-
--------------------------------------
|
|
40
|
-
2026-11-09 在本机用 edge-tts 7.3.1 合成同一句校准文本
|
|
41
|
-
「这是一句用来校准的中文样本,长度大约二十个字。」(20 个汉字),
|
|
42
|
-
用 ffmpeg 解成 24k 单声道后测门限 RMS(20ms 帧,低于 -45 dBFS 的静音帧不计)。
|
|
43
|
-
目标响度取 9 个 persona 的中位数 -20.97 dBFS;gain_db = 目标 - 实测,夹在 ±3 dB:
|
|
44
|
-
narrator_male Yunyang 4.56s 4.61cps -24.39dBFS -> +3.00
|
|
45
|
-
narrator_female Xiaoxiao 4.63s 4.53cps -18.28dBFS -> -2.69
|
|
46
|
-
warm_female HsiaoChen 5.33s 3.94cps -17.51dBFS -> -3.00(原始 -3.46,夹到 -3)
|
|
47
|
-
bright_female Xiaoyi 4.73s 4.44cps -20.40dBFS -> -0.57
|
|
48
|
-
authority_male Yunjian 5.21s 4.03cps -21.98dBFS -> +1.01
|
|
49
|
-
young_male Yunxi 4.80s 4.38cps -22.91dBFS -> +1.94
|
|
50
|
-
teen_male Yunxia 4.85s 4.33cps -21.27dBFS -> +0.30
|
|
51
|
-
dialect_ne_female Xiaobei 5.47s 3.84cps -20.97dBFS -> 0.00
|
|
52
|
-
dialect_sx_female Xiaoni 5.09s 4.13cps -20.63dBFS -> -0.34
|
|
53
|
-
edge-tts 免费端点只提供上面 8 个 zh-CN 音色(另有 zh-TW/zh-HK,可用但带腔),
|
|
54
|
-
故 9 个 persona 复用 8 个 voice id:warm_female 用 zh-TW-HsiaoChenNeural
|
|
55
|
-
(微软标注 "Warm, considerate",实测基频 194Hz 低于 Xiaoxiao 的 238Hz,确实更暖;
|
|
56
|
-
若目标观众只要大陆标准音,把它换成 zh-CN-XiaoxiaoNeural 并降速即可,其余字段不用改)。
|
|
50
|
+
迁移(老结构)
|
|
51
|
+
--------------
|
|
52
|
+
v0.4 的「顶层 voice 单值」和「没有 design 的 legacy 计划」不再支持:voice_plan 直接抛
|
|
53
|
+
ValueError,消息里给三条可照抄的迁移路径(design.audio.voice / lines[].voice / voice.map)。
|
|
54
|
+
不静默回落是刻意的:静默回落 = 配音变了却没人知道,比报错难查得多。
|
|
57
55
|
|
|
58
56
|
用法
|
|
59
57
|
----
|
|
@@ -61,14 +59,17 @@ edge-tts 免费端点只提供上面 8 个 zh-CN 音色(另有 zh-TW/zh-HK,
|
|
|
61
59
|
plan = VK.voice_plan(script, ST.resolve_script_style(script))
|
|
62
60
|
VK.describe(plan)
|
|
63
61
|
VK.spec_key(dict(plan[0], text=script["lines"][0]["text"])) # v2 缓存键
|
|
62
|
+
VK.calibrate_voice("zh-CN-YunxiNeural") # 联网实测;离线 measured=False,不抛
|
|
64
63
|
|
|
65
|
-
python voicekit.py --list
|
|
64
|
+
python voicekit.py --list # 音色响度表 + 语气档
|
|
66
65
|
python voicekit.py --plan film.json # 某份剧本的声线计划(可读表格)
|
|
67
|
-
python voicekit.py --calibrate
|
|
66
|
+
python voicekit.py --calibrate --only zh-CN-YunxiNeural # 联网实测,打印该抄回的数
|
|
68
67
|
"""
|
|
69
68
|
from __future__ import annotations
|
|
70
69
|
|
|
71
70
|
import argparse
|
|
71
|
+
import array
|
|
72
|
+
import asyncio
|
|
72
73
|
import hashlib
|
|
73
74
|
import json
|
|
74
75
|
import math
|
|
@@ -81,97 +82,75 @@ import tempfile
|
|
|
81
82
|
import wave
|
|
82
83
|
|
|
83
84
|
# ============================================================================
|
|
84
|
-
#
|
|
85
|
+
# 一、响度补偿(数据,不是身份):换音色不用手调电平
|
|
85
86
|
# ============================================================================
|
|
86
87
|
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
},
|
|
112
|
-
"warm_female": {
|
|
113
|
-
"voice": "zh-TW-HsiaoChenNeural", "rate": "-2%", "pitch": "-1Hz", "volume": "+2%",
|
|
114
|
-
"gain_db": -3.0, "narrate": True, "home": "outro",
|
|
115
|
-
"desc": "软糯女声(台湾腔),低基频 194Hz,适合纸感/人文/深夜",
|
|
116
|
-
},
|
|
117
|
-
"bright_female": {
|
|
118
|
-
"voice": "zh-CN-XiaoyiNeural", "rate": "+6%", "pitch": "+2Hz", "volume": "+3%",
|
|
119
|
-
"gain_db": -0.6, "narrate": True, "home": "hook",
|
|
120
|
-
"desc": "活泼女声,高基频 289Hz,适合活力/营销/上新",
|
|
121
|
-
},
|
|
122
|
-
"authority_male": {
|
|
123
|
-
"voice": "zh-CN-YunjianNeural", "rate": "+2%", "pitch": "-2Hz", "volume": "+2%",
|
|
124
|
-
"gain_db": 1.0, "narrate": True, "home": "hook",
|
|
125
|
-
"desc": "有力男声,慢而沉,适合开场/观点/史诗(实测 3.96cps 最慢)",
|
|
126
|
-
},
|
|
127
|
-
"young_male": {
|
|
128
|
-
"voice": "zh-CN-YunxiNeural", "rate": "+5%", "pitch": "+1Hz", "volume": "+1%",
|
|
129
|
-
"gain_db": 1.9, "narrate": True, "home": "quote",
|
|
130
|
-
"desc": "青年男声(v0.4 的缺省音色),适合引用、对话、年轻向内容",
|
|
131
|
-
},
|
|
132
|
-
"teen_male": {
|
|
133
|
-
"voice": "zh-CN-YunxiaNeural", "rate": "+7%", "pitch": "+1Hz", "volume": "+2%",
|
|
134
|
-
"gain_db": 0.3, "narrate": False, "home": "quote",
|
|
135
|
-
"desc": "少年音,基频 282Hz,只适合引用/角色台词,别整片念",
|
|
136
|
-
},
|
|
137
|
-
"dialect_ne_female": {
|
|
138
|
-
"voice": "zh-CN-liaoning-XiaobeiNeural", "rate": "+4%", "pitch": "+0Hz", "volume": "+0%",
|
|
139
|
-
"gain_db": 0.0, "narrate": False, "home": "quote",
|
|
140
|
-
"desc": "东北女声,自带喜感,适合引用/段子/接地气内容",
|
|
141
|
-
},
|
|
142
|
-
"dialect_sx_female": {
|
|
143
|
-
"voice": "zh-CN-shaanxi-XiaoniNeural", "rate": "+3%", "pitch": "+0Hz", "volume": "+0%",
|
|
144
|
-
"gain_db": -0.3, "narrate": False, "home": "quote",
|
|
145
|
-
"desc": "陕西女声,质地厚,适合引用/方言故事/纪录片片段",
|
|
146
|
-
},
|
|
88
|
+
# 为什么这张表不能跟着 persona 一起删:
|
|
89
|
+
# 9 个 persona 的实测响度跨度 6.0 dB(-3.0 ~ +3.0)。差别来自音色本身(基频、气声、
|
|
90
|
+
# 端点侧的归一化),跟台词、语气、机器负载都无关,但**换一把音色就会整体偏几 dB**。
|
|
91
|
+
# 删表不补补偿,每次换音色都要试听、手调 gain —— 那正是「改了不生效 / 要人调」。
|
|
92
|
+
#
|
|
93
|
+
# 口径:gain_db = 目标响度 - 实测 rms_db,夹在 ±3 dB(正 = 该音色偏轻,mix 侧补回来)。
|
|
94
|
+
# - 实测:2026-11-09 在本机用 edge-tts 7.3.1 合成同一句校准文本(CALIB_TEXT,21 个
|
|
95
|
+
# 汉字),ffmpeg 解成 24k 单声道,测 20ms 帧门限 RMS(低于 -45 dBFS 的静音帧不计);
|
|
96
|
+
# - 目标:CALIBRATION_LOG 里 9 条实测 rms_db 的中位数(-20.97 dBFS),由下方
|
|
97
|
+
# CALIB_TARGET_DB 现算,不另写第二个魔数。
|
|
98
|
+
#
|
|
99
|
+
# **换机 / 换 edge-tts 版本 / 换 voice id 都要重测**:同一把音色在不同机器、不同版本上
|
|
100
|
+
# 差 1~2 dB 很常见,把上次的数字跨机照抄会一句响一句轻。重测入口:
|
|
101
|
+
# python voicekit.py --calibrate(结果记 work/voice_calib.json,并打印该抄回的数)
|
|
102
|
+
VOICE_LOUDNESS = {
|
|
103
|
+
"zh-CN-YunyangNeural": 3.0, # 新闻/纪录片男声,实测最轻
|
|
104
|
+
"zh-CN-XiaoxiaoNeural": -2.7, # 标准女声,实测最响
|
|
105
|
+
"zh-TW-HsiaoChenNeural": -3.0, # 软糯女声(台湾腔),原始 -3.46,夹到 -3
|
|
106
|
+
"zh-CN-XiaoyiNeural": -0.6, # 活泼女声
|
|
107
|
+
"zh-CN-YunjianNeural": 1.0, # 有力男声
|
|
108
|
+
"zh-CN-YunxiNeural": 1.9, # 青年男声(v0.4 的缺省音色)
|
|
109
|
+
"zh-CN-YunxiaNeural": 0.3, # 少年音
|
|
110
|
+
"zh-CN-liaoning-XiaobeiNeural": 0.0, # 东北女声(本次校准的基准,补偿 0)
|
|
111
|
+
"zh-CN-shaanxi-XiaoniNeural": -0.3, # 陕西女声
|
|
147
112
|
}
|
|
148
113
|
|
|
149
|
-
#
|
|
114
|
+
# 表里没有的音色按 0 dB 处理(不猜),voice_plan 的 reason 会标 unmeasured_voice:
|
|
115
|
+
# 让人一眼看出「这句没补过」,而不是以为补过了。
|
|
150
116
|
CALIBRATED_ON = "2026-11-09"
|
|
151
117
|
CALIB_TEXT = "这是一句用来校准的中文样本,长度大约二十个字。"
|
|
152
118
|
CALIBRATION_LOG = {
|
|
153
|
-
"
|
|
154
|
-
"
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
"
|
|
161
|
-
"
|
|
119
|
+
"zh-CN-YunyangNeural": {"dur": 4.56, "cps": 4.61, "rms_db": -24.39},
|
|
120
|
+
"zh-CN-XiaoxiaoNeural": {"dur": 4.63, "cps": 4.53, "rms_db": -18.28},
|
|
121
|
+
"zh-TW-HsiaoChenNeural": {"dur": 5.33, "cps": 3.94, "rms_db": -17.51},
|
|
122
|
+
"zh-CN-XiaoyiNeural": {"dur": 4.73, "cps": 4.44, "rms_db": -20.40},
|
|
123
|
+
"zh-CN-YunjianNeural": {"dur": 5.21, "cps": 4.03, "rms_db": -21.98},
|
|
124
|
+
"zh-CN-YunxiNeural": {"dur": 4.80, "cps": 4.38, "rms_db": -22.91},
|
|
125
|
+
"zh-CN-YunxiaNeural": {"dur": 4.85, "cps": 4.33, "rms_db": -21.27},
|
|
126
|
+
"zh-CN-liaoning-XiaobeiNeural": {"dur": 5.47, "cps": 3.84, "rms_db": -20.97},
|
|
127
|
+
"zh-CN-shaanxi-XiaoniNeural": {"dur": 5.09, "cps": 4.13, "rms_db": -20.63},
|
|
162
128
|
}
|
|
163
129
|
|
|
130
|
+
|
|
131
|
+
def _median(values):
|
|
132
|
+
"""中位数(偶数个取上中位):定目标响度,不为了一个数去 import statistics。"""
|
|
133
|
+
xs = sorted(float(v) for v in values)
|
|
134
|
+
return xs[len(xs) // 2] if xs else 0.0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
CALIB_TARGET_DB = round(_median(r["rms_db"] for r in CALIBRATION_LOG.values()), 2)
|
|
138
|
+
|
|
164
139
|
# ============================================================================
|
|
165
|
-
# 二、语气(怎么说):deltas
|
|
140
|
+
# 二、语气(怎么说):deltas 叠在「裸 voice id」上;lead/tail 是基准行的偏移
|
|
166
141
|
# ============================================================================
|
|
167
|
-
# body
|
|
142
|
+
# body 是基准行:所有 delta = 0,lead/tail 就是 BODY_LEAD / BODY_TAIL。
|
|
143
|
+
# voice_plan 只在这个基准上加减,所以「某一句听起来不对」时先看 reason 里是 tone:xxx
|
|
144
|
+
# 还是 rule:xxx,改一处就够,不用同时去翻音色表和文本规则。
|
|
168
145
|
BODY_LEAD = 0.62
|
|
169
146
|
BODY_TAIL = 0.92
|
|
170
147
|
TONE_IDS = ("body", "hook", "quote", "outro", "emphasis", "calm")
|
|
171
148
|
|
|
149
|
+
# gain_db 是**语气自己的**电平增量(强调句要更响),与 VOICE_LOUDNESS 的「音色补偿」
|
|
150
|
+
# 是两回事:前者是表演,后者是设备;voice_plan 里两者相加,各自都能单独解释。
|
|
172
151
|
VOICE_TONES = {
|
|
173
152
|
"body": {"rate_delta": 0, "pitch_delta": 0, "volume_delta": 0, "gain_db": 0.0,
|
|
174
|
-
"lead": 0.62, "tail": 0.92, "desc": "
|
|
153
|
+
"lead": 0.62, "tail": 0.92, "desc": "正文,平稳(基准行)"},
|
|
175
154
|
"hook": {"rate_delta": 3, "pitch_delta": 1, "volume_delta": 2, "gain_db": 0.6,
|
|
176
155
|
"lead": 0.48, "tail": 0.78, "desc": "开场,提一点速压一点留白,抓人"},
|
|
177
156
|
"quote": {"rate_delta": -2, "pitch_delta": -1, "volume_delta": 1, "gain_db": 0.4,
|
|
@@ -184,13 +163,6 @@ VOICE_TONES = {
|
|
|
184
163
|
"lead": 0.88, "tail": 1.25, "desc": "沉静,长留白,适合思辨/深夜"},
|
|
185
164
|
}
|
|
186
165
|
|
|
187
|
-
# kind 规则:风格包没给映射时的缺省声线(风格包优先)
|
|
188
|
-
KIND_DEFAULT = {
|
|
189
|
-
"hook": "authority_male", "body": "narrator_male",
|
|
190
|
-
"quote": "narrator_female", "outro": "warm_female",
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
LEGACY_VOICE = "zh-CN-YunxiNeural" # 与 render.py 的 script.get("voice", ...) 缺省一致
|
|
194
166
|
_RATE_LIMIT = (-15, 12) # 与 stylekit policy 的缺省一致
|
|
195
167
|
_PITCH_LIMIT = (-8, 8)
|
|
196
168
|
_VOL_LIMIT = (-10, 10)
|
|
@@ -200,7 +172,7 @@ _GAIN_LIMIT = 3.0
|
|
|
200
172
|
|
|
201
173
|
try:
|
|
202
174
|
import stylekit as _ST
|
|
203
|
-
except Exception: # noqa: BLE001 — stylekit
|
|
175
|
+
except Exception: # noqa: BLE001 — stylekit 缺失时只少了风格包这一层
|
|
204
176
|
_ST = None
|
|
205
177
|
|
|
206
178
|
|
|
@@ -209,8 +181,8 @@ except Exception: # noqa: BLE001 — stylekit 缺失时
|
|
|
209
181
|
# ============================================================================
|
|
210
182
|
# 允许小数("+4.5%"):edge-tts 只收整数百分比,所以解析后按 _round_half_away 取整。
|
|
211
183
|
# 以前正则只认整数,"+4.5%" 解析失败 → 静默回落 0 —— 而 0 与 "+0%" 在 spec_key 里
|
|
212
|
-
# 是**同一个 TTS
|
|
213
|
-
#
|
|
184
|
+
# 是**同一个 TTS 缓存键**,于是「把语速从 0 调到 4.5%」完全没生效,还复用了旧音频。
|
|
185
|
+
# 这是配置写对了却不生效里最难查的一类:没有报错、没有日志、听起来就是「没反应」。
|
|
214
186
|
_PCT_RE = re.compile(r"^\s*([+-]?)(\d+(?:\.\d+)?)\s*%\s*$")
|
|
215
187
|
_HZ_RE = re.compile(r"^\s*([+-]?)(\d+(?:\.\d+)?)\s*Hz\s*$")
|
|
216
188
|
|
|
@@ -262,7 +234,7 @@ def hanzi_count(text):
|
|
|
262
234
|
|
|
263
235
|
|
|
264
236
|
def is_edge_voice(name):
|
|
265
|
-
"""形如 zh-CN-YunxiNeural 的原始 voice id(不是 persona
|
|
237
|
+
"""形如 zh-CN-YunxiNeural 的原始 voice id(不是 persona 名 / 不是路径)。"""
|
|
266
238
|
n = str(name or "").strip()
|
|
267
239
|
return ("-" in n) and n.endswith("Neural")
|
|
268
240
|
|
|
@@ -280,7 +252,7 @@ def _clamp(v, lo, hi):
|
|
|
280
252
|
|
|
281
253
|
|
|
282
254
|
def _jitter(title, lid, who):
|
|
283
|
-
"""sha1(title|id|
|
|
255
|
+
"""sha1(title|id|voice) 派生确定性抖动:rate/pitch/lead/tail + 两个量化阈值。
|
|
284
256
|
|
|
285
257
|
rate/pitch 是浮点目标值(±1.5% / ±1Hz 再乘 policy.jitter),但 edge-tts 只收整数;
|
|
286
258
|
若直接四舍五入,jitter=0.35 时 ±0.5 的小数会被吃光、抖动形同不存在。
|
|
@@ -297,7 +269,7 @@ def _jitter(title, lid, who):
|
|
|
297
269
|
|
|
298
270
|
|
|
299
271
|
def _quantize(v, u):
|
|
300
|
-
"""
|
|
272
|
+
"""确定性随机进位:floor(v)+1 的概率 = v 的小数部分,期望值仍是 v。
|
|
301
273
|
|
|
302
274
|
u 来自 sha1(同一句永远同一结果),用来把浮点抖动量化成 edge-tts 能收的整数。
|
|
303
275
|
v 本身是整数时(jitter=0)绝不进位,保证与「无抖动」逐字段相等。
|
|
@@ -310,10 +282,9 @@ def _quantize(v, u):
|
|
|
310
282
|
# ============================================================================
|
|
311
283
|
# 四、文本规则(说什么):全部确定性,命中即返回理由
|
|
312
284
|
# ============================================================================
|
|
313
|
-
#
|
|
314
|
-
# 实测踩过:把 《
|
|
315
|
-
#
|
|
316
|
-
# 旁白中途换人,听感像换了主播(而这句根本没有人引语)。
|
|
285
|
+
# 引语起始符。**不含书名号《》**:正文里出现「《设计模式》」是书名,不是引用某人在说话。
|
|
286
|
+
# 实测踩过:把 《 算进引语后,「今天我们聊《设计模式》」这句被判成 quote 语气 ——
|
|
287
|
+
# 整句降速、留白加长,听感像突然换了频道(而这句根本没有人引语)。
|
|
317
288
|
_QUOTE_OPEN = ('"', "\u201c", "\u300c", "\u300e")
|
|
318
289
|
_QUOTE_WORDS = ("他说", "她说", "有人问", "所谓", "据称")
|
|
319
290
|
_NUM_UNITS = ("%", "万", "亿", "年", "倍")
|
|
@@ -356,85 +327,120 @@ def _rules(text):
|
|
|
356
327
|
# 五、计划:每句用谁、什么语气、多快多响多长留白
|
|
357
328
|
# ============================================================================
|
|
358
329
|
def _resolve(script, style_plan):
|
|
359
|
-
"""
|
|
330
|
+
"""拿风格计划:显式参数 > script["_style"](render 已解析过)> stylekit 现算 > {}。
|
|
331
|
+
|
|
332
|
+
返回 {} 不是故障:D3 的声线来源是 design.audio.voice,本模块自己就能读;
|
|
333
|
+
stylekit 只负责风格包/节奏这一层,缺了它照样能出 voice 计划。
|
|
334
|
+
"""
|
|
360
335
|
if isinstance(style_plan, dict) and style_plan:
|
|
361
336
|
return style_plan
|
|
337
|
+
cached = script.get("_style")
|
|
338
|
+
if isinstance(cached, dict) and cached:
|
|
339
|
+
return cached
|
|
362
340
|
if _ST is not None:
|
|
363
341
|
try:
|
|
364
342
|
return _ST.resolve_script_style(script)
|
|
365
|
-
except Exception: # noqa: BLE001 — 坏剧本交给
|
|
343
|
+
except Exception: # noqa: BLE001 — 坏剧本交给 filmcheck 报错
|
|
366
344
|
pass
|
|
367
|
-
|
|
368
|
-
voce = script.get("voice") if isinstance(script.get("voice"), dict) else {}
|
|
369
|
-
legacy = ("style" not in script) and not vis.get("theme")
|
|
370
|
-
return {
|
|
371
|
-
"legacy": bool(legacy),
|
|
372
|
-
"voice": {"map": {}, "default": voce.get("default"), "policy": {
|
|
373
|
-
"auto": not legacy, "jitter": 0.0 if legacy else 0.35,
|
|
374
|
-
"max_distinct": 2 if legacy else 3,
|
|
375
|
-
"rate_range": list(_RATE_LIMIT), "pitch_range": list(_PITCH_LIMIT)}},
|
|
376
|
-
"pacing": {"lead": float(script.get("lead", 0.55)),
|
|
377
|
-
"tail": float(script.get("tail", 0.85))},
|
|
378
|
-
}
|
|
345
|
+
return {}
|
|
379
346
|
|
|
380
347
|
|
|
381
|
-
def
|
|
382
|
-
"""
|
|
383
|
-
return round(float(v) + 0.0, 3)
|
|
348
|
+
def _design_voice(script):
|
|
349
|
+
"""读 D3 设计的声线:返回 (主声线, {kind: id}, 策略 {auto, jitter})。
|
|
384
350
|
|
|
351
|
+
为什么直接读 script["design"] 而不只依赖 style_plan:D3 的单测/验收会直接
|
|
352
|
+
voice_plan(script),此时没有 render.py 解析过的 _style;而 stylekit 对「没有
|
|
353
|
+
design」的剧本已经直接报错,_resolve 只能返回 {}。design.audio.voice 是声线的
|
|
354
|
+
唯一事实来源,能读就读,别要求调用方先跑一遍 render。
|
|
385
355
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
"
|
|
401
|
-
"
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
356
|
+
策略跟着设计走(与 designkit.design_to_plan 对齐):裸 id = 单声线、不抖动、
|
|
357
|
+
不加文本规则;dict = 设计采样出来的多声线方案,auto=True、jitter 取设计值。
|
|
358
|
+
本模块不 import designkit(那会多一层依赖),所以这三项在这里读一遍。
|
|
359
|
+
"""
|
|
360
|
+
des = script.get("design") if isinstance(script.get("design"), dict) else {}
|
|
361
|
+
au = des.get("audio") if isinstance(des.get("audio"), dict) else {}
|
|
362
|
+
vo = au.get("voice")
|
|
363
|
+
if isinstance(vo, str):
|
|
364
|
+
return (vo.strip() or None), {}, {"auto": False, "jitter": 0.0}
|
|
365
|
+
if not isinstance(vo, dict):
|
|
366
|
+
return None, {}, {}
|
|
367
|
+
mode = str(vo.get("mode") or "").strip()
|
|
368
|
+
if mode == "wav":
|
|
369
|
+
raise ValueError(
|
|
370
|
+
"design.audio.voice.mode='wav':voicekit 只出 edge-tts 的声线计划,"
|
|
371
|
+
"wav 音色(录音/克隆)请直接交给混音侧,别从这里过。")
|
|
372
|
+
vid = str(vo.get("id") or "").strip() or None
|
|
373
|
+
raw = vo.get("map") if isinstance(vo.get("map"), dict) else {}
|
|
374
|
+
vmap = {}
|
|
375
|
+
for kind, v in raw.items():
|
|
376
|
+
if isinstance(v, dict):
|
|
377
|
+
v = v.get("id")
|
|
378
|
+
if v not in (None, ""):
|
|
379
|
+
vmap[str(kind)] = str(v).strip()
|
|
380
|
+
try:
|
|
381
|
+
jitter = max(0.0, float(vo.get("jitter", 0.0)))
|
|
382
|
+
except (TypeError, ValueError):
|
|
383
|
+
jitter = 0.0
|
|
384
|
+
return vid, vmap, {"auto": True, "jitter": jitter}
|
|
406
385
|
|
|
407
386
|
|
|
408
|
-
def
|
|
409
|
-
"""
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
387
|
+
def _script_voice(script):
|
|
388
|
+
"""v0.5 形状的 script.voice(dict):返回 (主声线, {kind: id})。"""
|
|
389
|
+
vo = script.get("voice")
|
|
390
|
+
if not isinstance(vo, dict):
|
|
391
|
+
return None, {}
|
|
392
|
+
vid = str(vo.get("id") or vo.get("default") or "").strip() or None
|
|
393
|
+
raw = vo.get("map") if isinstance(vo.get("map"), dict) else {}
|
|
394
|
+
vmap = {str(k): str(v).strip() for k, v in raw.items() if v not in (None, "")}
|
|
395
|
+
return vid, vmap
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _style_voice(sp):
|
|
399
|
+
"""style_plan["voice"]:返回 ({kind: id}, 主声线)。"""
|
|
400
|
+
vsec = sp.get("voice") if isinstance(sp.get("voice"), dict) else {}
|
|
401
|
+
raw = vsec.get("map") if isinstance(vsec.get("map"), dict) else {}
|
|
402
|
+
vmap = {str(k): str(v).strip() for k, v in raw.items() if v not in (None, "")}
|
|
403
|
+
default = str(vsec.get("default") or "").strip() or None
|
|
404
|
+
return vmap, default
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# 迁移说明只写一份:三处报错(persona / 老结构 / map 值)都指向它,避免各处各写一套。
|
|
408
|
+
_MIGRATE = (
|
|
409
|
+
"声线现在一律写 edge voice id(形如 zh-CN-YunxiNeural)。迁移任选一条:\n"
|
|
410
|
+
" 1) design.json:audio.voice = {\"mode\": \"edge_id\", \"id\": \"zh-CN-YunxiNeural\"};"
|
|
411
|
+
"要分段换声线再加 map: {\"hook\": \"zh-CN-YunjianNeural\"};\n"
|
|
412
|
+
" 2) film.json:lines[].voice 逐句写 id;\n"
|
|
413
|
+
" 3) 风格包 / voice.map:值改成 edge voice id。\n"
|
|
414
|
+
"可用音色与实测响度补偿:python voicekit.py --list"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _bad_voice(field, value):
|
|
419
|
+
return ValueError("%s = %r 不是 edge voice id,而 voicekit 从 D3 起已删掉 persona 身份表。\n%s"
|
|
420
|
+
% (field, value, _MIGRATE))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _old_structure(script, sp, scalar=None):
|
|
424
|
+
"""老结构(v0.4 单值 voice / 没有 design 的 legacy)不再支持:直说怎么迁移。"""
|
|
425
|
+
bits = []
|
|
426
|
+
if scalar:
|
|
427
|
+
bits.append("顶层 voice = %r,v0.4 的单值声线" % scalar)
|
|
428
|
+
if sp.get("legacy"):
|
|
429
|
+
bits.append("风格计划判定为 legacy(film.json 里没有 design/style)")
|
|
430
|
+
return ValueError(
|
|
431
|
+
"这份剧本还是老结构(%s),而 voicekit 从 D3 起已删掉 legacy / persona 路径。\n%s"
|
|
432
|
+
% (";".join(bits) or "没有 design.audio.voice,也没有 lines[].voice / voice.map", _MIGRATE))
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _validate_voice_map(field, vmap):
|
|
436
|
+
for kind, vid in vmap.items():
|
|
437
|
+
if not is_edge_voice(vid):
|
|
438
|
+
raise _bad_voice("%s[%r]" % (field, kind), vid)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _fmt_num(v):
|
|
442
|
+
"""计划里的 lead/tail 统一保留 3 位(避免 0.6200000000001 这种浮点尾巴)。"""
|
|
443
|
+
return round(float(v) + 0.0, 3)
|
|
438
444
|
|
|
439
445
|
|
|
440
446
|
def _pick_tone(line, kind, rules, use_rules, reasons):
|
|
@@ -454,74 +460,75 @@ def _pick_tone(line, kind, rules, use_rules, reasons):
|
|
|
454
460
|
return "body"
|
|
455
461
|
|
|
456
462
|
|
|
457
|
-
def _govern(cand, prev, kind, prev_kind, used, max_distinct, explicit, fallback=None,
|
|
458
|
-
quote_rule=False):
|
|
459
|
-
"""换人治理:同段落不换人;换段可换,但要过 max_distinct 预算。
|
|
460
|
-
|
|
461
|
-
返回 (最终 persona, 附加 reason)。相邻两句不换人是防"广播剧"的关键:
|
|
462
|
-
同一 kind 的连续句子必须由同一把声音念;只有显式指定、或到了 hook/body/quote/outro
|
|
463
|
-
的段落边界(body->quote 是 spec 明说的允许点),才会换。
|
|
464
|
-
预算用尽时优先回到"正文声线"(fallback),避免收尾句被引用声线一路念到底。
|
|
465
|
-
quote_rule=True 表示"这句文本有引号、该由 quote 声线念":这是 body->quote 的
|
|
466
|
-
spec 允许点,不受同 kind 限制、也不占 max_distinct 预算(reason 记 governor:quote)。
|
|
467
|
-
"""
|
|
468
|
-
if explicit or cand is None or prev is None:
|
|
469
|
-
# 显式指定 / 风格包直接写了原始 voice id:一律放行,不参与换人治理
|
|
470
|
-
return cand, ""
|
|
471
|
-
if cand == prev:
|
|
472
|
-
return cand, ""
|
|
473
|
-
if quote_rule:
|
|
474
|
-
return cand, "governor:quote"
|
|
475
|
-
if kind == prev_kind:
|
|
476
|
-
return prev, "governor:glue"
|
|
477
|
-
if cand not in used and len(used) >= max_distinct:
|
|
478
|
-
# 预算用尽:只有"正文声线已经在用"时才能回落,否则会偷偷多出第 max_distinct+1 个人
|
|
479
|
-
if fallback and fallback in used and fallback != cand:
|
|
480
|
-
return fallback, "governor:cap"
|
|
481
|
-
return prev, "governor:cap"
|
|
482
|
-
return cand, "governor:switch"
|
|
483
|
-
|
|
484
|
-
|
|
485
463
|
def voice_plan(script, style_plan=None):
|
|
486
464
|
"""把剧本拆成逐句的旁白计划(纯函数、确定性,两次调用结果完全相等)。
|
|
487
465
|
|
|
488
466
|
参数
|
|
489
467
|
----
|
|
490
468
|
script film.json 解析后的 dict(只读,不修改)
|
|
491
|
-
style_plan stylekit.resolve_script_style(
|
|
469
|
+
style_plan stylekit.resolve_script_style() 或 designkit.design_to_plan() 的返回;
|
|
470
|
+
None 时本模块自己解析(design.audio.voice 优先,其次 stylekit)
|
|
492
471
|
|
|
493
472
|
返回
|
|
494
473
|
----
|
|
495
474
|
list[dict],每行:id / kind / persona / tone / voice / rate / pitch / volume /
|
|
496
475
|
gain_db / lead / tail / reason。rate/pitch/volume 是可直接透传 edge-tts 的字符串。
|
|
476
|
+
persona 字段**保留但恒为 None**(老下游可能读它);voice 恒为 edge voice id。
|
|
497
477
|
"""
|
|
498
478
|
script = script if isinstance(script, dict) else {}
|
|
499
479
|
sp = _resolve(script, style_plan)
|
|
500
480
|
lines = [ln for ln in (script.get("lines") or [])]
|
|
481
|
+
if not lines:
|
|
482
|
+
return [] # 空剧本给空计划;缺 lines 由 filmcheck 报
|
|
501
483
|
vsec = sp.get("voice") if isinstance(sp.get("voice"), dict) else {}
|
|
502
484
|
policy = vsec.get("policy") if isinstance(vsec.get("policy"), dict) else {}
|
|
503
485
|
pace = sp.get("pacing") if isinstance(sp.get("pacing"), dict) else {}
|
|
504
486
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
487
|
+
# ---- 声线来源:显式覆盖 > 设计 > 风格包(合并时后者先铺、前者覆盖)----
|
|
488
|
+
s_vid, s_map = _script_voice(script)
|
|
489
|
+
d_vid, d_map, d_pol = _design_voice(script)
|
|
490
|
+
p_map, p_default = _style_voice(sp)
|
|
491
|
+
vmap = {}
|
|
492
|
+
vmap.update(p_map)
|
|
493
|
+
vmap.update(d_map)
|
|
494
|
+
vmap.update(s_map)
|
|
495
|
+
default = s_vid or d_vid or p_default
|
|
496
|
+
for field, m in (("script.voice.map", s_map), ("design.audio.voice.map", d_map),
|
|
497
|
+
("style_plan.voice.map", p_map)):
|
|
498
|
+
_validate_voice_map(field, m)
|
|
499
|
+
for field, vid in (("script.voice.id", s_vid), ("design.audio.voice.id", d_vid),
|
|
500
|
+
("style_plan.voice.default", p_default)):
|
|
501
|
+
if vid and not is_edge_voice(vid):
|
|
502
|
+
raise _bad_voice(field, vid)
|
|
503
|
+
|
|
504
|
+
scalar = script.get("voice")
|
|
505
|
+
has_line_voice = any(isinstance(ln, dict) and ln.get("voice") for ln in lines)
|
|
506
|
+
if isinstance(scalar, str) and scalar.strip():
|
|
507
|
+
# v0.4 的单值声线。D3 之后声线事实来源只有 design.audio.voice(或逐句 voice),
|
|
508
|
+
# 再读它会和 design 打架,而「谁生效」取决于调用顺序 —— 最难查的一类。
|
|
509
|
+
raise _old_structure(script, sp, scalar.strip())
|
|
510
|
+
if not (default or vmap or has_line_voice):
|
|
511
|
+
# 没有任何来源时**不猜**:以前会静默套 kind 缺省,整片悄悄换声线。
|
|
512
|
+
# 宁可报错一次,让人把 design.audio.voice 补上。
|
|
513
|
+
if isinstance(script.get("design"), dict):
|
|
514
|
+
raise ValueError(
|
|
515
|
+
"design.audio.voice 没有给出声线(id 与 map 都为空):D3 起设计必须明确"
|
|
516
|
+
"「谁在说话」。\n" + _MIGRATE)
|
|
517
|
+
raise _old_structure(script, sp)
|
|
518
|
+
|
|
519
|
+
# 策略:显式 style_plan 优先;没有(例如直接 voice_plan(script))就用设计里的策略。
|
|
520
|
+
# jitter 缺省 0.0 —— 抖动必须是设计采样出来的,不能由计划层凭空加。
|
|
521
|
+
if not policy and d_pol:
|
|
522
|
+
policy = d_pol
|
|
523
|
+
jitter = max(0.0, float(policy.get("jitter", 0.0)))
|
|
509
524
|
use_rules = _as_bool(policy.get("auto"), True)
|
|
510
|
-
max_distinct = max(1, int(policy.get("max_distinct", 3)))
|
|
511
525
|
rate_lim = tuple(policy.get("rate_range") or _RATE_LIMIT)
|
|
512
526
|
pitch_lim = tuple(policy.get("pitch_range") or _PITCH_LIMIT)
|
|
513
|
-
vmap = vsec.get("map") if isinstance(vsec.get("map"), dict) else {}
|
|
514
|
-
default = str(vsec.get("default") or "").strip()
|
|
515
|
-
default = default if default in VOICE_PERSONAS else ""
|
|
516
527
|
base_lead = float(pace.get("lead", 0.55))
|
|
517
528
|
base_tail = float(pace.get("tail", 0.85))
|
|
518
529
|
title = str(script.get("title") or "")
|
|
519
|
-
# 正文声线锚点:预算用尽时回落用它,让"旁白"始终是正文那把声音
|
|
520
|
-
body_persona = vmap.get("body") if vmap.get("body") in VOICE_PERSONAS else None
|
|
521
|
-
body_persona = body_persona or default or KIND_DEFAULT.get("body", "narrator_male")
|
|
522
530
|
|
|
523
|
-
rows
|
|
524
|
-
prev_persona, prev_kind = None, None
|
|
531
|
+
rows = []
|
|
525
532
|
for i, ln in enumerate(lines):
|
|
526
533
|
ln = ln if isinstance(ln, dict) else {}
|
|
527
534
|
lid = str(ln.get("id") or ("l%02d" % (i + 1)))
|
|
@@ -530,43 +537,42 @@ def voice_plan(script, style_plan=None):
|
|
|
530
537
|
rules = _rules(ln.get("text")) if use_rules else {
|
|
531
538
|
"tone": "", "rate": 0, "volume": 0, "tail": 0.0, "reasons": []}
|
|
532
539
|
|
|
533
|
-
|
|
534
|
-
#
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
540
|
+
# 身份只有一个来源,写错就报错,不再「悄悄回落到某个缺省声线」:
|
|
541
|
+
# 以前拼错 persona 名会静默换人(听感变了但没有任何提示),是本项目最忌讳的坑。
|
|
542
|
+
raw_p = ln.get("persona")
|
|
543
|
+
if raw_p not in (None, ""):
|
|
544
|
+
raise _bad_voice("lines[%d].persona" % i, raw_p)
|
|
545
|
+
raw_v = ln.get("voice")
|
|
546
|
+
if raw_v not in (None, ""):
|
|
547
|
+
voice = str(raw_v).strip()
|
|
548
|
+
if not is_edge_voice(voice):
|
|
549
|
+
raise _bad_voice("lines[%d].voice" % i, raw_v)
|
|
550
|
+
reasons.append("explicit:voice")
|
|
551
|
+
else:
|
|
552
|
+
mapped = vmap.get(kind, vmap.get("*"))
|
|
553
|
+
if mapped:
|
|
554
|
+
voice = mapped
|
|
555
|
+
reasons.append("map:%s" % kind)
|
|
556
|
+
elif default:
|
|
557
|
+
voice = default
|
|
558
|
+
reasons.append("default")
|
|
559
|
+
else:
|
|
560
|
+
raise ValueError(
|
|
561
|
+
"声线映射没覆盖 lines[%d].kind=%r,也没有全片主声线:不知道这句该谁念。\n"
|
|
562
|
+
"补 design.audio.voice.id(全片主声线)或 map[%r]。\n%s"
|
|
563
|
+
% (i, kind, kind, _MIGRATE))
|
|
550
564
|
|
|
551
565
|
tone_name = _pick_tone(ln, kind, rules, use_rules, reasons)
|
|
552
566
|
tone = VOICE_TONES[tone_name]
|
|
553
567
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
voice = raw_voice or LEGACY_VOICE
|
|
563
|
-
rate_v = tone["rate_delta"]
|
|
564
|
-
pitch_v = tone["pitch_delta"]
|
|
565
|
-
vol_v = tone["volume_delta"]
|
|
566
|
-
gain = float(tone["gain_db"])
|
|
567
|
-
|
|
568
|
-
rate_v += rules["rate"]
|
|
569
|
-
vol_v += rules["volume"]
|
|
568
|
+
# 音色补偿(设备)+ 语气补偿(表演);表里没有的音色按 0 dB 并把事实写进 reason。
|
|
569
|
+
gain = float(VOICE_LOUDNESS.get(voice, 0.0)) + float(tone["gain_db"])
|
|
570
|
+
if voice not in VOICE_LOUDNESS:
|
|
571
|
+
reasons.append("unmeasured_voice")
|
|
572
|
+
|
|
573
|
+
rate_v = tone["rate_delta"] + rules["rate"]
|
|
574
|
+
pitch_v = tone["pitch_delta"]
|
|
575
|
+
vol_v = tone["volume_delta"] + rules["volume"]
|
|
570
576
|
if use_rules:
|
|
571
577
|
reasons.extend(rules["reasons"])
|
|
572
578
|
|
|
@@ -575,7 +581,7 @@ def voice_plan(script, style_plan=None):
|
|
|
575
581
|
|
|
576
582
|
u_rate, u_pitch = 0.0, 0.0
|
|
577
583
|
if jitter > 0:
|
|
578
|
-
jr, jp, jl, jt, u_rate, u_pitch = _jitter(title, lid,
|
|
584
|
+
jr, jp, jl, jt, u_rate, u_pitch = _jitter(title, lid, voice)
|
|
579
585
|
rate_v += jr * jitter
|
|
580
586
|
pitch_v += jp * jitter
|
|
581
587
|
lead += jl * jitter
|
|
@@ -593,7 +599,7 @@ def voice_plan(script, style_plan=None):
|
|
|
593
599
|
reasons.append("clamp:volume")
|
|
594
600
|
|
|
595
601
|
# edge-tts 只收整数:jitter>0 时用确定性随机进位(长期期望=浮点目标),
|
|
596
|
-
# jitter=0
|
|
602
|
+
# jitter=0 时退化为纯四舍五入,保证与「无抖动」逐字段相等。
|
|
597
603
|
if jitter > 0:
|
|
598
604
|
rate_s = "%+d%%" % _quantize(rate_c, u_rate)
|
|
599
605
|
pitch_s = "%+dHz" % _quantize(pitch_c, u_pitch)
|
|
@@ -602,17 +608,13 @@ def voice_plan(script, style_plan=None):
|
|
|
602
608
|
pitch_s = fmt_hz(pitch_c)
|
|
603
609
|
|
|
604
610
|
rows.append({
|
|
605
|
-
"id": lid, "kind": kind, "persona":
|
|
611
|
+
"id": lid, "kind": kind, "persona": None, "tone": tone_name,
|
|
606
612
|
"voice": voice, "rate": rate_s, "pitch": pitch_s,
|
|
607
613
|
"volume": fmt_pct(vol_c), "gain_db": round(gain, 2),
|
|
608
614
|
"lead": _fmt_num(_clamp(lead, _LEAD_LIMIT[0], _LEAD_LIMIT[1])),
|
|
609
615
|
"tail": _fmt_num(_clamp(tail, _TAIL_LIMIT[0], _TAIL_LIMIT[1])),
|
|
610
616
|
"reason": ";".join(reasons),
|
|
611
617
|
})
|
|
612
|
-
prev_persona = final if final is not None else prev_persona
|
|
613
|
-
# 生效类型:引号句虽然 kind 标 body,但已经按 quote 声线念了,
|
|
614
|
-
# 下一句普通正文才算"换段",否则会被同 kind 粘连规则永远锁在引用声线上。
|
|
615
|
-
prev_kind = "quote" if quote_rule else kind
|
|
616
618
|
return rows
|
|
617
619
|
|
|
618
620
|
|
|
@@ -652,56 +654,53 @@ def spec_key(spec):
|
|
|
652
654
|
|
|
653
655
|
|
|
654
656
|
def plan_stats(plan):
|
|
655
|
-
"""
|
|
657
|
+
"""计划摘要:用了几把声音 / 哪些语气(测试与日志用)。"""
|
|
656
658
|
rows = [r for r in (plan or []) if isinstance(r, dict)]
|
|
657
|
-
|
|
658
|
-
tones = []
|
|
659
|
+
voices, tones = [], []
|
|
659
660
|
for r in rows:
|
|
660
|
-
|
|
661
|
-
if
|
|
662
|
-
|
|
661
|
+
v, t = r.get("voice"), r.get("tone")
|
|
662
|
+
if v and v not in voices:
|
|
663
|
+
voices.append(v)
|
|
663
664
|
if t and t not in tones:
|
|
664
665
|
tones.append(t)
|
|
665
|
-
return {"lines": len(rows), "
|
|
666
|
-
"distinct": len(personas),
|
|
667
|
-
"legacy": bool(rows) and all(r.get("reason") == "legacy" for r in rows)}
|
|
666
|
+
return {"lines": len(rows), "voices": voices, "tones": tones, "distinct": len(voices)}
|
|
668
667
|
|
|
669
668
|
|
|
670
669
|
# ============================================================================
|
|
671
670
|
# 七、人读输出
|
|
672
671
|
# ============================================================================
|
|
673
672
|
def describe(plan):
|
|
674
|
-
"""把计划表打成人可读的多行文本(id/kind/
|
|
673
|
+
"""把计划表打成人可读的多行文本(id/kind/voice/tone/rate/pitch/vol/gain/lead/tail)。"""
|
|
675
674
|
rows = [r for r in (plan or []) if isinstance(r, dict)]
|
|
676
|
-
head = "%-8s %-7s %-
|
|
677
|
-
"id", "kind", "
|
|
675
|
+
head = "%-8s %-7s %-28s %-9s %-6s %-6s %-6s %-6s %-6s %-6s" % (
|
|
676
|
+
"id", "kind", "voice", "tone", "rate", "pitch", "vol", "gain", "lead", "tail")
|
|
678
677
|
out = [head, "-" * len(head)]
|
|
679
678
|
for r in rows:
|
|
680
|
-
out.append("%-8s %-7s %-
|
|
681
|
-
str(r.get("id", "")), str(r.get("kind", "")),
|
|
682
|
-
str(r.get("
|
|
683
|
-
str(r.get("
|
|
679
|
+
out.append("%-8s %-7s %-28s %-9s %-6s %-6s %-6s %+6.2f %-6.2f %-6.2f" % (
|
|
680
|
+
str(r.get("id", "")), str(r.get("kind", "")), str(r.get("voice", "")),
|
|
681
|
+
str(r.get("tone", "")), str(r.get("rate", "")), str(r.get("pitch", "")),
|
|
682
|
+
str(r.get("volume", "")), float(r.get("gain_db", 0.0)),
|
|
684
683
|
float(r.get("lead", 0.0)), float(r.get("tail", 0.0))))
|
|
685
684
|
return "\n".join(out)
|
|
686
685
|
|
|
687
686
|
|
|
688
687
|
# ============================================================================
|
|
689
|
-
#
|
|
688
|
+
# 八、校准:真合成、真实测;离线优雅降级(不抛异常,不阻塞主链)
|
|
690
689
|
# ============================================================================
|
|
691
|
-
def _measure_wav(path):
|
|
692
|
-
"""读 24k 单声道 wav,返回 dur/cps/rms_db
|
|
690
|
+
def _measure_wav(path, text=CALIB_TEXT):
|
|
691
|
+
"""读 24k 单声道 16-bit wav,返回 dur/cps/rms_db(纯标准库,不用 numpy)。"""
|
|
693
692
|
with wave.open(path, "rb") as w:
|
|
694
693
|
sr = w.getframerate()
|
|
695
694
|
n = w.getnframes()
|
|
695
|
+
if w.getsampwidth() != 2:
|
|
696
|
+
raise ValueError("只接受 16-bit PCM wav(ffmpeg 要带 -sample_fmt s16)")
|
|
696
697
|
raw = w.readframes(n)
|
|
697
|
-
import array
|
|
698
698
|
data = array.array("h")
|
|
699
699
|
data.frombytes(raw)
|
|
700
700
|
if sys.byteorder == "big":
|
|
701
701
|
data.byteswap()
|
|
702
702
|
total = len(data)
|
|
703
703
|
dur = total / float(sr) if sr else 0.0
|
|
704
|
-
peak = max((abs(v) for v in data), default=0) / 32768.0
|
|
705
704
|
fl = max(1, int(0.02 * sr))
|
|
706
705
|
hop = max(1, int(0.01 * sr))
|
|
707
706
|
frames = []
|
|
@@ -715,73 +714,182 @@ def _measure_wav(path):
|
|
|
715
714
|
frames.append(math.sqrt(acc / float(len(seg))) / 32768.0)
|
|
716
715
|
gated = [x for x in frames if x > 10 ** (-45.0 / 20.0)]
|
|
717
716
|
rms = math.sqrt(sum(x * x for x in gated) / len(gated)) if gated else 1e-6
|
|
718
|
-
return {"dur": dur, "cps": hanzi_count(
|
|
719
|
-
"rms_db": 20.0 * math.log10(rms + 1e-12)
|
|
720
|
-
|
|
717
|
+
return {"dur": dur, "cps": hanzi_count(text) / dur if dur else 0.0,
|
|
718
|
+
"rms_db": 20.0 * math.log10(rms + 1e-12)}
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _gain_for(rms_db):
|
|
722
|
+
"""实测 RMS -> 建议补偿(目标响度 - 实测,夹在 ±3 dB,保留 2 位)。"""
|
|
723
|
+
return round(_clamp(CALIB_TARGET_DB - float(rms_db), -_GAIN_LIMIT, _GAIN_LIMIT), 2)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
_CALIB_TIMEOUT = 30.0 # 单条合成上限:没网时不能让主链挂死
|
|
727
|
+
_CALIB_CACHE = os.path.join("work", "voice_calib.json")
|
|
728
|
+
_CALIB_SCHEMA = 1
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _calib_path(cache_path=None):
|
|
732
|
+
return str(cache_path or _CALIB_CACHE)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _load_calib(cache_path):
|
|
736
|
+
"""读缓存 -> (校准文本, {voice: {rms_db, cps, dur}});读不了/结构不对 -> (None, {})。"""
|
|
737
|
+
try:
|
|
738
|
+
with open(cache_path, "r", encoding="utf-8") as f:
|
|
739
|
+
data = json.load(f)
|
|
740
|
+
except (OSError, ValueError):
|
|
741
|
+
return None, {}
|
|
742
|
+
if not isinstance(data, dict) or data.get("schema") != _CALIB_SCHEMA:
|
|
743
|
+
return None, {}
|
|
744
|
+
voices = data.get("voices")
|
|
745
|
+
return str(data.get("text") or ""), (voices if isinstance(voices, dict) else {})
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _store_calib(cache_path, text, row):
|
|
749
|
+
"""只缓存**成功测量**:失败也写会把一次断网固化成「测过」,之后永远读到假数据。"""
|
|
750
|
+
_, voices = _load_calib(cache_path)
|
|
751
|
+
voices = dict(voices)
|
|
752
|
+
voices[row["voice"]] = {"rms_db": row["rms_db"], "cps": row["cps"], "dur": row["dur"]}
|
|
753
|
+
# target_db 记下来只为审计(换机后一眼看出上次按多少 dBFS 补的);
|
|
754
|
+
# 读缓存时 gain 仍然用**当前** CALIB_TARGET_DB 现算,改目标不用清缓存。
|
|
755
|
+
data = {"schema": _CALIB_SCHEMA, "text": text, "target_db": CALIB_TARGET_DB,
|
|
756
|
+
"voices": voices}
|
|
757
|
+
try:
|
|
758
|
+
d = os.path.dirname(os.path.abspath(cache_path))
|
|
759
|
+
os.makedirs(d, exist_ok=True)
|
|
760
|
+
with open(cache_path, "w", encoding="utf-8") as f:
|
|
761
|
+
json.dump(data, f, ensure_ascii=False, indent=1)
|
|
762
|
+
except OSError as e: # 缓存写不进去不影响本次结果,但要说出来
|
|
763
|
+
return "%s: %s" % (type(e).__name__, e)
|
|
764
|
+
return ""
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
async def _synthesize(edge_tts, text, voice, out_mp3):
|
|
768
|
+
"""合成一句校准文本;wait_for 是防「没网时挂死」的硬上限。"""
|
|
769
|
+
await asyncio.wait_for(
|
|
770
|
+
edge_tts.Communicate(text, voice).save(out_mp3), timeout=_CALIB_TIMEOUT)
|
|
721
771
|
|
|
722
772
|
|
|
723
|
-
def
|
|
724
|
-
"""
|
|
773
|
+
def calibrate_voice(voice_id, cache_path=None, text=None):
|
|
774
|
+
"""实测一把音色的响度补偿;能联网才真测,其余情况优雅降级(绝不抛异常)。
|
|
725
775
|
|
|
726
|
-
|
|
727
|
-
|
|
776
|
+
参数
|
|
777
|
+
----
|
|
778
|
+
voice_id edge voice id(zh-CN-YunxiNeural)
|
|
779
|
+
cache_path 结果缓存,默认 work/voice_calib.json;同一把音色下次直接读
|
|
780
|
+
text 校准文本,默认 CALIB_TEXT(改文本会让旧缓存失效并重测)
|
|
781
|
+
|
|
782
|
+
返回 dict:
|
|
783
|
+
测得 {'voice', 'rms_db', 'cps', 'dur', 'gain_db', 'measured': True[, 'cached': True]}
|
|
784
|
+
降级 {'voice', 'rms_db': None, 'cps': None, 'gain_db': 表里的值,
|
|
785
|
+
'measured': False, 'reason': '为什么没测成'}
|
|
786
|
+
gain_db 沿用口径:CALIB_TARGET_DB - 实测 rms_db,夹在 ±3 dB。
|
|
787
|
+
|
|
788
|
+
为什么要缓存:edge-tts 是免费端点,校准句要真联网合成;每句计划都去测一遍既慢
|
|
789
|
+
也不稳定。但**只缓存成功测量**——失败也写缓存的话,一次断网就会把「没测」固化成
|
|
790
|
+
「测过」,下一台机器照抄一个假增益,比没补还糟。
|
|
728
791
|
"""
|
|
729
|
-
|
|
792
|
+
vid = str(voice_id or "").strip()
|
|
793
|
+
text = str(text or CALIB_TEXT)
|
|
794
|
+
cache = _calib_path(cache_path)
|
|
795
|
+
base = {"voice": vid, "rms_db": None, "cps": None, "dur": None,
|
|
796
|
+
"gain_db": float(VOICE_LOUDNESS.get(vid, 0.0)), "measured": False}
|
|
797
|
+
|
|
798
|
+
if not is_edge_voice(vid):
|
|
799
|
+
base["reason"] = ("不是 edge voice id:%r(persona 名已删除;写 zh-CN-XxxNeural)"
|
|
800
|
+
% vid)
|
|
801
|
+
return base
|
|
802
|
+
|
|
803
|
+
cached_text, voices = _load_calib(cache)
|
|
804
|
+
hit = voices.get(vid) if cached_text == text else None
|
|
805
|
+
try:
|
|
806
|
+
rms = float(hit["rms_db"]) if isinstance(hit, dict) else None
|
|
807
|
+
except (KeyError, TypeError, ValueError):
|
|
808
|
+
rms = None
|
|
809
|
+
if rms is not None:
|
|
810
|
+
try:
|
|
811
|
+
cps = float(hit.get("cps") or 0.0)
|
|
812
|
+
dur = float(hit.get("dur") or 0.0)
|
|
813
|
+
except (TypeError, ValueError):
|
|
814
|
+
cps, dur = 0.0, 0.0
|
|
815
|
+
base.update(rms_db=round(rms, 2), cps=round(cps, 2), dur=round(dur, 2),
|
|
816
|
+
gain_db=_gain_for(rms), measured=True, cached=True)
|
|
817
|
+
return base
|
|
818
|
+
|
|
730
819
|
try:
|
|
731
820
|
import edge_tts
|
|
732
|
-
except Exception as e:
|
|
733
|
-
|
|
734
|
-
return
|
|
821
|
+
except Exception as e: # noqa: BLE001 — 没装就降级,不拖垮主链
|
|
822
|
+
base["reason"] = "没装 edge_tts(%s: %s)" % (type(e).__name__, e)
|
|
823
|
+
return base
|
|
735
824
|
ffmpeg = shutil.which("ffmpeg")
|
|
736
825
|
if not ffmpeg:
|
|
737
|
-
|
|
738
|
-
return
|
|
739
|
-
|
|
826
|
+
base["reason"] = "没找到 ffmpeg,没法把 mp3 解成 PCM 测 RMS"
|
|
827
|
+
return base
|
|
828
|
+
|
|
740
829
|
tmp = tempfile.mkdtemp(prefix="voicekit_cal_")
|
|
741
|
-
rows = {}
|
|
742
830
|
try:
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
831
|
+
mp3 = os.path.join(tmp, "cal.mp3")
|
|
832
|
+
wav = os.path.join(tmp, "cal.wav")
|
|
833
|
+
coro = _synthesize(edge_tts, text, vid, mp3)
|
|
834
|
+
try:
|
|
835
|
+
asyncio.run(coro)
|
|
836
|
+
except Exception as e: # noqa: BLE001 — 没网/端点不可用都走这里
|
|
837
|
+
# 事件循环有时根本起不来(asyncio.run 在 await 之前就抛),协程没人接;
|
|
838
|
+
# 不关掉的话「never awaited」警告会盖住真正的原因,误导排查方向。
|
|
839
|
+
coro.close()
|
|
840
|
+
base["reason"] = ("合成失败(多半是没网或 edge 端点临时不可用):%s: %s"
|
|
841
|
+
% (type(e).__name__, str(e)[:160]))
|
|
842
|
+
return base
|
|
843
|
+
try:
|
|
844
|
+
r = subprocess.run([ffmpeg, "-y", "-v", "error", "-i", mp3,
|
|
845
|
+
"-ac", "1", "-ar", "24000", "-sample_fmt", "s16", wav],
|
|
846
|
+
capture_output=True, text=True)
|
|
847
|
+
if r.returncode != 0:
|
|
848
|
+
raise RuntimeError((r.stderr or "")[-200:])
|
|
849
|
+
m = _measure_wav(wav, text)
|
|
850
|
+
except Exception as e: # noqa: BLE001 — 解码/测量失败同样降级
|
|
851
|
+
base["reason"] = "ffmpeg 解码或测量失败:%s" % str(e)[:160]
|
|
852
|
+
return base
|
|
853
|
+
got = {"voice": vid, "rms_db": round(m["rms_db"], 2), "cps": round(m["cps"], 2),
|
|
854
|
+
"dur": round(m["dur"], 2), "gain_db": _gain_for(m["rms_db"]),
|
|
855
|
+
"measured": True}
|
|
856
|
+
err = _store_calib(cache, text, got)
|
|
857
|
+
if err:
|
|
858
|
+
got["cache_error"] = err
|
|
859
|
+
return got
|
|
766
860
|
finally:
|
|
767
861
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def calibrate(voices=None, log=None):
|
|
865
|
+
"""逐把音色实测,打印「实测 vs 表里」,并给出该抄回 VOICE_LOUDNESS 的数。
|
|
866
|
+
|
|
867
|
+
edge_tts 只在真测时 import(其余函数纯标准库,方便无网环境单测)。
|
|
868
|
+
没装 edge_tts / 没 ffmpeg / 没网:逐条 SKIP,返回 0,绝不让主链失败。
|
|
869
|
+
"""
|
|
870
|
+
log = log or (lambda s: None)
|
|
871
|
+
ids = [str(v).strip() for v in (voices or list(VOICE_LOUDNESS)) if str(v).strip()]
|
|
872
|
+
log("校准文本:%s(%d 个汉字)" % (CALIB_TEXT, hanzi_count(CALIB_TEXT)))
|
|
873
|
+
log("目标响度:%.2f dBFS(CALIBRATION_LOG 实测中位数);gain 夹在 ±%.1f dB"
|
|
874
|
+
% (CALIB_TARGET_DB, _GAIN_LIMIT))
|
|
772
875
|
log("")
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
876
|
+
ok = 0
|
|
877
|
+
for vid in ids:
|
|
878
|
+
r = calibrate_voice(vid)
|
|
879
|
+
if r["measured"]:
|
|
880
|
+
ok += 1
|
|
881
|
+
cur = float(VOICE_LOUDNESS.get(vid, 0.0))
|
|
882
|
+
log(" OK %-30s rms %7.2f dBFS cps %.2f gain %+6.2f 表 %+6.2f%s"
|
|
883
|
+
% (vid, r["rms_db"], r["cps"], r["gain_db"], cur,
|
|
884
|
+
"(缓存)" if r.get("cached") else ""))
|
|
885
|
+
else:
|
|
886
|
+
log(" SKIP %-30s %s" % (vid, r.get("reason")))
|
|
783
887
|
log("")
|
|
784
|
-
|
|
888
|
+
if not ok:
|
|
889
|
+
log("[SKIP] 没测到任何音色(多半是没网 / 没装 edge_tts / 没 ffmpeg);"
|
|
890
|
+
"VOICE_LOUDNESS 保持原样,主链不受影响")
|
|
891
|
+
return 0
|
|
892
|
+
log("把上面「gain」抄回 VOICE_LOUDNESS[voice],并把 CALIBRATED_ON 改成今天;")
|
|
785
893
|
log("换 voice id 必须重跑本命令 —— 不同音色的响度差 3-7 dB,凭感觉调会一句响一句轻。")
|
|
786
894
|
return 0
|
|
787
895
|
|
|
@@ -799,18 +907,24 @@ def _setup_stdio():
|
|
|
799
907
|
|
|
800
908
|
|
|
801
909
|
def _cli_list():
|
|
802
|
-
print("voicekit
|
|
910
|
+
print("voicekit 音色表(校准于 %s;edge-tts 免费端点只有 8 个 zh-CN 音色 + zh-TW/zh-HK)"
|
|
803
911
|
% CALIBRATED_ON)
|
|
804
|
-
print("
|
|
805
|
-
|
|
806
|
-
print("
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
912
|
+
print("gain_db = 目标 %.2f dBFS - 实测 RMS,夹在 ±%.1f dB(正 = 该音色偏轻,mix 侧补回来)"
|
|
913
|
+
% (CALIB_TARGET_DB, _GAIN_LIMIT))
|
|
914
|
+
print("%-30s %8s %9s %7s %7s" % ("voice", "gain_db", "实测RMS", "dur", "cps"))
|
|
915
|
+
print("-" * 70)
|
|
916
|
+
for vid, gain in VOICE_LOUDNESS.items():
|
|
917
|
+
m = CALIBRATION_LOG.get(vid) or {}
|
|
918
|
+
print("%-30s %+8.2f %9s %7s %7s" % (
|
|
919
|
+
vid, gain,
|
|
920
|
+
("%.2f" % m["rms_db"]) if m else "-",
|
|
921
|
+
("%.2f" % m["dur"]) if m else "-",
|
|
922
|
+
("%.2f" % m["cps"]) if m else "-"))
|
|
923
|
+
print("")
|
|
924
|
+
print("表里没有的音色按 0 dB 处理(不猜),voice_plan 的 reason 会标 unmeasured_voice。")
|
|
925
|
+
print("换机 / 换 edge-tts 版本 / 换 voice id 都要重测:python voicekit.py --calibrate")
|
|
812
926
|
print("")
|
|
813
|
-
print("语气档(body
|
|
927
|
+
print("语气档(body 是基准行:所有 delta = 0;lead/tail 相对它偏移)")
|
|
814
928
|
print("%-10s %8s %9s %9s %8s %6s %6s %s" % (
|
|
815
929
|
"tone", "rate", "pitch", "volume", "gain_db", "lead", "tail", "desc"))
|
|
816
930
|
print("-" * 120)
|
|
@@ -820,8 +934,8 @@ def _cli_list():
|
|
|
820
934
|
tid, t["rate_delta"], t["pitch_delta"], t["volume_delta"], t["gain_db"],
|
|
821
935
|
t["lead"], t["tail"], t["desc"]))
|
|
822
936
|
print("")
|
|
823
|
-
print("注:
|
|
824
|
-
print(" zh-CN-XiaoxiaoNeural
|
|
937
|
+
print("注:zh-TW-HsiaoChenNeural 是台湾腔软糯女声(微软标注 Warm);要大陆标准音就换")
|
|
938
|
+
print(" zh-CN-XiaoxiaoNeural,响度补偿按上表 -2.7 dB,其余字段不用动。")
|
|
825
939
|
return 0
|
|
826
940
|
|
|
827
941
|
|
|
@@ -836,46 +950,54 @@ def _cli_plan(path):
|
|
|
836
950
|
print("[剧本格式错误] %s 顶层必须是对象且含非空 lines" % path)
|
|
837
951
|
return 2
|
|
838
952
|
sp = _resolve(script, None)
|
|
839
|
-
|
|
953
|
+
try:
|
|
954
|
+
plan = voice_plan(script, sp)
|
|
955
|
+
except ValueError as e: # 配置错误要能照抄着改,不要 traceback
|
|
956
|
+
print("[声线配置错误] %s" % e)
|
|
957
|
+
return 2
|
|
958
|
+
voce = sp.get("voice") if isinstance(sp.get("voice"), dict) else {}
|
|
840
959
|
print("剧本:%s(%d 句)" % (path, len(plan)))
|
|
841
|
-
print("风格:%s
|
|
842
|
-
|
|
843
|
-
json.dumps(sp.get("voice", {}).get("map", {}), ensure_ascii=False)))
|
|
960
|
+
print("风格:%s voice.map=%s" % (sp.get("style") or "-",
|
|
961
|
+
json.dumps(voce.get("map") or {}, ensure_ascii=False)))
|
|
844
962
|
print("")
|
|
845
963
|
print(describe(plan))
|
|
846
964
|
print("")
|
|
847
|
-
for r
|
|
848
|
-
print(" %-8s rate=%-5s pitch=%-6s vol=%-5s gain=%+5.2f
|
|
849
|
-
|
|
965
|
+
for r in plan:
|
|
966
|
+
print(" %-8s voice=%-28s rate=%-5s pitch=%-6s vol=%-5s gain=%+5.2f"
|
|
967
|
+
" lead=%-5.2f tail=%-5.2f %s"
|
|
968
|
+
% (r["id"], r["voice"], r["rate"], r["pitch"], r["volume"], r["gain_db"],
|
|
850
969
|
r["lead"], r["tail"], r["reason"]))
|
|
851
970
|
st = plan_stats(plan)
|
|
852
971
|
print("")
|
|
853
|
-
print("声线 %d 种:%s" % (st["distinct"], ", ".join(st["
|
|
972
|
+
print("声线 %d 种:%s" % (st["distinct"], ", ".join(st["voices"]) or "-"))
|
|
854
973
|
print("语气 %d 档:%s" % (len(st["tones"]), ", ".join(st["tones"]) or "-"))
|
|
855
|
-
print("提示:同一份剧本两次 voice_plan() 结果完全相同;
|
|
974
|
+
print("提示:同一份剧本两次 voice_plan() 结果完全相同;persona 字段恒为 None"
|
|
975
|
+
"(只为兼容下游而保留)。")
|
|
856
976
|
return 0
|
|
857
977
|
|
|
858
978
|
|
|
859
979
|
def main(argv=None):
|
|
860
980
|
_setup_stdio()
|
|
861
981
|
ap = argparse.ArgumentParser(
|
|
862
|
-
prog="voicekit.py", description="
|
|
863
|
-
ap.add_argument("--list", action="store_true", help="
|
|
982
|
+
prog="voicekit.py", description="v1.0 旁白声线/语气计划(不合成,只出计划)")
|
|
983
|
+
ap.add_argument("--list", action="store_true", help="列出音色响度表与语气档")
|
|
864
984
|
ap.add_argument("--plan", metavar="FILM_JSON", help="打印某份 film.json 的声线计划")
|
|
865
|
-
ap.add_argument("--calibrate", action="store_true",
|
|
866
|
-
|
|
985
|
+
ap.add_argument("--calibrate", action="store_true",
|
|
986
|
+
help="联网实测各音色的 dur/RMS,打印建议 gain_db(失败 SKIP 返回 0)")
|
|
987
|
+
ap.add_argument("--only", metavar="VOICE_ID",
|
|
988
|
+
help="--calibrate 时只测这些 edge voice id(逗号分隔)")
|
|
867
989
|
a = ap.parse_args(argv)
|
|
868
990
|
if a.list:
|
|
869
991
|
return _cli_list()
|
|
870
992
|
if a.calibrate:
|
|
871
993
|
only = [x.strip() for x in (a.only or "").split(",") if x.strip()] or None
|
|
872
994
|
if only:
|
|
873
|
-
unknown = [x for x in only if
|
|
995
|
+
unknown = [x for x in only if not is_edge_voice(x)]
|
|
874
996
|
if unknown:
|
|
875
|
-
#
|
|
997
|
+
# 以前拼错名字会静默变成空集合 -> 打出「所有音色都合成失败(多半没网)」,
|
|
876
998
|
# 把配置错误伪装成网络错误。这里先点名校验,给人话可照抄的可用清单。
|
|
877
|
-
print("[
|
|
878
|
-
% ("、".join(unknown), " / ".join(
|
|
999
|
+
print("[未知音色] %s;可用:%s"
|
|
1000
|
+
% ("、".join(unknown), " / ".join(VOICE_LOUDNESS)), file=sys.stderr)
|
|
879
1001
|
return 2
|
|
880
1002
|
return calibrate(only, log=print)
|
|
881
1003
|
if a.plan:
|