@dsh-gis/dsh-video-studio-skill 0.4.0 → 0.7.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.
Files changed (38) hide show
  1. package/README.md +93 -18
  2. package/docs/styles/demo-results.json +76 -0
  3. package/docs/styles/sheet-8-styles.png +0 -0
  4. package/docs/styles/sheet-ai.png +0 -0
  5. package/docs/styles/sheet-craft.png +0 -0
  6. package/docs/styles/sheet-night.png +0 -0
  7. package/index.js +20 -7
  8. package/package.json +13 -3
  9. package/schemas/film.schema.json +599 -0
  10. package/skills/video-studio/SKILL.md +252 -28
  11. package/skills/video-studio/assets/film.json +78 -24
  12. package/skills/video-studio/assets/scenes.py +1005 -42
  13. package/skills/video-studio/assets/web/engine.js +18 -1
  14. package/skills/video-studio/assets/web/film.js +29 -4
  15. package/skills/video-studio/assets/web/index.html +5 -0
  16. package/skills/video-studio/references/audio-variation.md +158 -0
  17. package/skills/video-studio/references/audio.md +96 -6
  18. package/skills/video-studio/references/change-impact.md +54 -0
  19. package/skills/video-studio/references/quality-gates.md +1 -1
  20. package/skills/video-studio/references/style-packs.md +178 -0
  21. package/skills/video-studio/references/web-engine.md +1 -1
  22. package/skills/video-studio/requirements.txt +13 -1
  23. package/skills/video-studio/scripts/filmcheck.py +725 -0
  24. package/skills/video-studio/scripts/filmkit.py +953 -76
  25. package/skills/video-studio/scripts/gates.py +509 -0
  26. package/skills/video-studio/scripts/qa_frames.py +109 -19
  27. package/skills/video-studio/scripts/render.py +942 -45
  28. package/skills/video-studio/scripts/scorekit.py +1887 -27
  29. package/skills/video-studio/scripts/selfcheck.py +648 -2
  30. package/skills/video-studio/scripts/sfxkit.py +132 -30
  31. package/skills/video-studio/scripts/stagewatch.py +405 -0
  32. package/skills/video-studio/scripts/stylekit.py +1012 -0
  33. package/skills/video-studio/scripts/voicekit.py +880 -0
  34. package/skills/video-studio/scripts/webengine.py +68 -14
  35. package/tools/audio_similarity.py +279 -0
  36. package/tools/ci_check.py +450 -0
  37. package/tools/estimate.py +657 -0
  38. package/tools/selftest_noscipy.py +70 -0
@@ -0,0 +1,880 @@
1
+ # -*- coding: utf-8 -*-
2
+ """voicekit.py — 旁白声线(Voice Personas):让每部片的"谁在说话"也不一样。
3
+
4
+ v0.5.0 新增。v0.4 的 render.py 把 script.voice / rate / pitch 三个单值一路传给
5
+ filmkit.tts_batch,结果是所有片、所有段落都由同一个人用同一种语气念完 ——
6
+ 用户的原话是「旁白声音都差不多、整体画风也差不多,希望随内容变化」。
7
+
8
+ 本模块只做「策展 + 计划」:给出每句该用哪个声线、什么语气、语速/音高/音量抖多少、
9
+ 前后留白多少。真正的合成仍在 filmkit.tts(Lead 负责接线),因此本模块
10
+ **不 import filmkit**(render.py 会同时 import 两者,互相 import 会成环),
11
+ 只依赖 stylekit + 标准库。
12
+
13
+ 三层结构
14
+ --------
15
+ VOICE_PERSONAS 9 个声线角色(谁在说话):voice id + 基础语速/音高/音量 + 响度补偿
16
+ VOICE_TONES 6 档语气(怎么说):相对 deltas + 前后留白 lead/tail
17
+ _rules() 文本规则(说什么):引号/数字/问叹/长短句/转折词 → 语气与微调
18
+
19
+ 计划优先级(高到低)
20
+ --------------------
21
+ lines[].persona / lines[].voice / lines[].tone(显式)
22
+ > style_plan["voice"]["map"][kind](风格包)
23
+ > 文本规则(引号/数字 → 语气)
24
+ > kind 规则(hook/body/quote/outro 各自的缺省声线)
25
+ > 默认 persona
26
+
27
+ 确定性
28
+ ------
29
+ 所有抖动都来自 sha1(title|id|persona),禁 random / time / hash()(str hash 带盐)。
30
+ 同一份 film.json 两次 voice_plan() 完全相等;jitter=0 时与「无抖动」逐字段相等。
31
+
32
+ legacy 兼容
33
+ -----------
34
+ style_plan["legacy"] 为 True(film.json 没有 style 字段)时不换人、不加抖动、不叠语气:
35
+ 全部用 script 的 voice/rate/pitch 单值 + script 的 lead/tail,与 v0.4 逐字段一致。
36
+ 这是硬契约:legacy 路径的任何变化都会让老项目音频变样、缓存全失效。
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 并降速即可,其余字段不用改)。
57
+
58
+ 用法
59
+ ----
60
+ import voicekit as VK
61
+ plan = VK.voice_plan(script, ST.resolve_script_style(script))
62
+ VK.describe(plan)
63
+ VK.spec_key(dict(plan[0], text=script["lines"][0]["text"])) # v2 缓存键
64
+
65
+ python voicekit.py --list # 声线/语气表
66
+ python voicekit.py --plan film.json # 某份剧本的声线计划(可读表格)
67
+ python voicekit.py --calibrate # 联网实测 dur/RMS/cps,打印建议 gain_db(失败 SKIP 返回 0)
68
+ """
69
+ from __future__ import annotations
70
+
71
+ import argparse
72
+ import hashlib
73
+ import json
74
+ import math
75
+ import os
76
+ import re
77
+ import shutil
78
+ import subprocess
79
+ import sys
80
+ import tempfile
81
+ import wave
82
+
83
+ # ============================================================================
84
+ # 一、声线(谁在说话)
85
+ # ============================================================================
86
+ #
87
+ # 字段口径:
88
+ # voice edge-tts 的 voice id("-" 分隔且以 Neural 结尾)
89
+ # rate/pitch/volume 字符串,直接透传给 edge_tts.Communicate(只接受整数)
90
+ # gain_db mix 侧给该句加的分贝(正 = 该音色偏轻,需要补),已按校准文本实测
91
+ # desc 人话说明(打印 / --list 用)
92
+ # narrate 能否被"自动"选来念长段叙述(方言/少年音只适合引用与点缀,设 False)
93
+ # home 该声线的"主场"段落类型(hook/body/quote/outro),给 governor 与文档用
94
+
95
+ PERSONA_IDS = (
96
+ "narrator_male", "narrator_female", "warm_female", "bright_female",
97
+ "authority_male", "young_male", "teen_male",
98
+ "dialect_ne_female", "dialect_sx_female",
99
+ )
100
+
101
+ VOICE_PERSONAS = {
102
+ "narrator_male": {
103
+ "voice": "zh-CN-YunyangNeural", "rate": "+2%", "pitch": "+0Hz", "volume": "+0%",
104
+ "gain_db": 3.0, "narrate": True, "home": "body",
105
+ "desc": "新闻/纪录片男声,稳、清楚,默认叙述(实测最轻,+3dB 补偿)",
106
+ },
107
+ "narrator_female": {
108
+ "voice": "zh-CN-XiaoxiaoNeural", "rate": "+5%", "pitch": "+0Hz", "volume": "+0%",
109
+ "gain_db": -2.7, "narrate": True, "home": "body",
110
+ "desc": "标准女声叙述,清澈不甜,报告/复盘/知识片(实测最响,-2.7dB)",
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
+ },
147
+ }
148
+
149
+ # 校准日志:--calibrate 会用实测值对比这里,换 voice id 后重跑一次再改表
150
+ CALIBRATED_ON = "2026-11-09"
151
+ CALIB_TEXT = "这是一句用来校准的中文样本,长度大约二十个字。"
152
+ CALIBRATION_LOG = {
153
+ "narrator_male": {"dur": 4.56, "cps": 4.61, "rms_db": -24.39},
154
+ "narrator_female": {"dur": 4.63, "cps": 4.53, "rms_db": -18.28},
155
+ "warm_female": {"dur": 5.33, "cps": 3.94, "rms_db": -17.51},
156
+ "bright_female": {"dur": 4.73, "cps": 4.44, "rms_db": -20.40},
157
+ "authority_male": {"dur": 5.21, "cps": 4.03, "rms_db": -21.98},
158
+ "young_male": {"dur": 4.80, "cps": 4.38, "rms_db": -22.91},
159
+ "teen_male": {"dur": 4.85, "cps": 4.33, "rms_db": -21.27},
160
+ "dialect_ne_female": {"dur": 5.47, "cps": 3.84, "rms_db": -20.97},
161
+ "dialect_sx_female": {"dur": 5.09, "cps": 4.13, "rms_db": -20.63},
162
+ }
163
+
164
+ # ============================================================================
165
+ # 二、语气(怎么说):deltas 叠在 persona 上;lead/tail 是 v0.4 基准附近的偏移量
166
+ # ============================================================================
167
+ # body 必须等于 v0.4 现状:lead 0.62 / tail 0.92 / 所有 delta = 0,legacy 才可能逐字段一致。
168
+ BODY_LEAD = 0.62
169
+ BODY_TAIL = 0.92
170
+ TONE_IDS = ("body", "hook", "quote", "outro", "emphasis", "calm")
171
+
172
+ VOICE_TONES = {
173
+ "body": {"rate_delta": 0, "pitch_delta": 0, "volume_delta": 0, "gain_db": 0.0,
174
+ "lead": 0.62, "tail": 0.92, "desc": "正文,平稳(v0.4 基准)"},
175
+ "hook": {"rate_delta": 3, "pitch_delta": 1, "volume_delta": 2, "gain_db": 0.6,
176
+ "lead": 0.48, "tail": 0.78, "desc": "开场,提一点速压一点留白,抓人"},
177
+ "quote": {"rate_delta": -2, "pitch_delta": -1, "volume_delta": 1, "gain_db": 0.4,
178
+ "lead": 0.70, "tail": 1.00, "desc": "引用,降速、留白,像另一个人在说话"},
179
+ "outro": {"rate_delta": -3, "pitch_delta": -2, "volume_delta": 2, "gain_db": 0.8,
180
+ "lead": 0.72, "tail": 1.02, "desc": "收尾,慢下来、沉下去"},
181
+ "emphasis": {"rate_delta": 1, "pitch_delta": 2, "volume_delta": 4, "gain_db": 1.2,
182
+ "lead": 0.58, "tail": 0.86, "desc": "强调(数字/结论),抬音高加音量"},
183
+ "calm": {"rate_delta": -5, "pitch_delta": -3, "volume_delta": 1, "gain_db": 0.5,
184
+ "lead": 0.88, "tail": 1.25, "desc": "沉静,长留白,适合思辨/深夜"},
185
+ }
186
+
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
+ _RATE_LIMIT = (-15, 12) # 与 stylekit policy 的缺省一致
195
+ _PITCH_LIMIT = (-8, 8)
196
+ _VOL_LIMIT = (-10, 10)
197
+ _LEAD_LIMIT = (0.20, 1.60)
198
+ _TAIL_LIMIT = (0.20, 2.20)
199
+ _GAIN_LIMIT = 3.0
200
+
201
+ try:
202
+ import stylekit as _ST
203
+ except Exception: # noqa: BLE001 — stylekit 缺失时走兜底解析
204
+ _ST = None
205
+
206
+
207
+ # ============================================================================
208
+ # 三、小工具:参数解析/格式化、汉字计数、抖动
209
+ # ============================================================================
210
+ _PCT_RE = re.compile(r"^\s*([+-]?)(\d+)\s*%\s*$")
211
+ _HZ_RE = re.compile(r"^\s*([+-]?)(\d+)\s*Hz\s*$")
212
+
213
+
214
+ def _round_half_away(v):
215
+ """四舍五入而不是 Python 的银行家舍入:抖动量化要可预期、跨版本一致。"""
216
+ v = float(v)
217
+ return int(math.floor(v + 0.5)) if v >= 0 else -int(math.floor(-v + 0.5))
218
+
219
+
220
+ def parse_pct(v, default=0):
221
+ """'+4%' -> 4;'-3%' -> -3;数字原样取整;解析失败回落 default。"""
222
+ if isinstance(v, (int, float)) and not isinstance(v, bool):
223
+ return int(_round_half_away(v))
224
+ m = _PCT_RE.match(str(v if v is not None else ""))
225
+ if not m:
226
+ return int(default)
227
+ n = int(m.group(2))
228
+ return -n if m.group(1) == "-" else n
229
+
230
+
231
+ def parse_hz(v, default=0):
232
+ """'+2Hz' -> 2;解析失败回落 default。"""
233
+ if isinstance(v, (int, float)) and not isinstance(v, bool):
234
+ return int(_round_half_away(v))
235
+ m = _HZ_RE.match(str(v if v is not None else ""))
236
+ if not m:
237
+ return int(default)
238
+ n = int(m.group(2))
239
+ return -n if m.group(1) == "-" else n
240
+
241
+
242
+ def fmt_pct(v):
243
+ """4 -> '+4%';-3 -> '-3%';edge-tts 只认整数百分比。"""
244
+ return "%+d%%" % _round_half_away(v)
245
+
246
+
247
+ def fmt_hz(v):
248
+ """2 -> '+2Hz';edge-tts 只认整数 Hz。"""
249
+ return "%+dHz" % _round_half_away(v)
250
+
251
+
252
+ _CJK_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
253
+
254
+
255
+ def hanzi_count(text):
256
+ """数汉字(不含标点/数字/拉丁字母),长短句规则与 cps 都用它。"""
257
+ return len(_CJK_RE.findall(str(text or "")))
258
+
259
+
260
+ def is_edge_voice(name):
261
+ """形如 zh-CN-YunxiNeural 的原始 voice id(不是 persona 名)。"""
262
+ n = str(name or "").strip()
263
+ return ("-" in n) and n.endswith("Neural")
264
+
265
+
266
+ def _as_bool(v, default=False):
267
+ if v is None:
268
+ return default
269
+ if isinstance(v, str):
270
+ return v.strip().lower() not in ("0", "false", "no", "off", "")
271
+ return bool(v)
272
+
273
+
274
+ def _clamp(v, lo, hi):
275
+ return lo if v < lo else (hi if v > hi else v)
276
+
277
+
278
+ def _jitter(title, lid, who):
279
+ """sha1(title|id|persona) 派生确定性抖动:rate/pitch/lead/tail + 两个量化阈值。
280
+
281
+ rate/pitch 是浮点目标值(±1.5% / ±1Hz 再乘 policy.jitter),但 edge-tts 只收整数;
282
+ 若直接四舍五入,jitter=0.35 时 ±0.5 的小数会被吃光、抖动形同不存在。
283
+ 所以格式化前先用 _quantize() 做「确定性随机进位」,让长期期望值等于浮点目标。
284
+ """
285
+ h = hashlib.sha1(("%s|%s|%s" % (title, lid, who)).encode("utf-8")).hexdigest()
286
+
287
+ def u(i, j):
288
+ return int(h[i:j], 16) / float(65535 if j - i == 4 else 4294967295)
289
+
290
+ return ((u(0, 8) * 2 - 1) * 1.5, (u(8, 16) * 2 - 1) * 1.0,
291
+ (u(16, 24) * 2 - 1) * 0.04, (u(24, 32) * 2 - 1) * 0.04,
292
+ u(32, 36), u(36, 40))
293
+
294
+
295
+ def _quantize(v, u):
296
+ """确定性随机进位:`floor(v)+1` 的概率 = v 的小数部分,期望值仍是 v。
297
+
298
+ u 来自 sha1(同一句永远同一结果),用来把浮点抖动量化成 edge-tts 能收的整数。
299
+ v 本身是整数时(jitter=0)绝不进位,保证与「无抖动」逐字段相等。
300
+ """
301
+ base = int(math.floor(float(v)))
302
+ frac = float(v) - base
303
+ return base + (1 if (frac > 0 and frac > u) else 0)
304
+
305
+
306
+ # ============================================================================
307
+ # 四、文本规则(说什么):全部确定性,命中即返回理由
308
+ # ============================================================================
309
+ _QUOTE_OPEN = ('"', "\u201c", "\u300c", "\u300e", "\u300a")
310
+ _QUOTE_WORDS = ("他说", "她说", "有人问", "所谓", "据称")
311
+ _NUM_UNITS = ("%", "万", "亿", "年", "倍")
312
+ _TURN_WORDS = ("但是", "然而", "不过", "其实", "问题是")
313
+
314
+
315
+ def _rules(text):
316
+ """文本 -> {tone, rate, volume, tail, reasons}。
317
+
318
+ 优先级:引号 -> quote;数字/单位 -> emphasis(互斥,先命中先生效)。
319
+ 微调可叠加:问叹句 +2% 语速;>45 汉字 -2% 语速、尾巴 +0.10s;<12 汉字 +2% 语速;
320
+ 转折词 +2% 音量。所有判断只看字符串,不含任何随机。
321
+ """
322
+ t = str(text or "")
323
+ out = {"tone": "", "rate": 0, "volume": 0, "tail": 0.0, "reasons": []}
324
+ if any(q in t for q in _QUOTE_OPEN) or any(w in t for w in _QUOTE_WORDS):
325
+ out["tone"] = "quote"
326
+ out["reasons"].append("rule:quote")
327
+ elif re.search(r"\d{3,}", t) or any(u in t for u in _NUM_UNITS):
328
+ out["tone"] = "emphasis"
329
+ out["reasons"].append("rule:num")
330
+ if re.search(r"[??!!][\u201d\u300d\u300f\"']?\s*$", t):
331
+ out["rate"] += 2
332
+ out["reasons"].append("rule:qmark")
333
+ n = hanzi_count(t)
334
+ if n > 45:
335
+ out["rate"] -= 2
336
+ out["tail"] += 0.10
337
+ out["reasons"].append("rule:long")
338
+ elif n < 12:
339
+ out["rate"] += 2
340
+ out["reasons"].append("rule:short")
341
+ if any(w in t for w in _TURN_WORDS):
342
+ out["volume"] += 2
343
+ out["reasons"].append("rule:turn")
344
+ return out
345
+
346
+
347
+ # ============================================================================
348
+ # 五、计划:每句用谁、什么语气、多快多响多长留白
349
+ # ============================================================================
350
+ def _resolve(script, style_plan):
351
+ """拿 stylekit 的计划;没有就调 stylekit 算;stylekit 也没有就兜底。"""
352
+ if isinstance(style_plan, dict) and style_plan:
353
+ return style_plan
354
+ if _ST is not None:
355
+ try:
356
+ return _ST.resolve_script_style(script)
357
+ except Exception: # noqa: BLE001 — 坏剧本交给 filmkit 报错
358
+ pass
359
+ vis = script.get("visual") if isinstance(script.get("visual"), dict) else {}
360
+ voce = script.get("voice") if isinstance(script.get("voice"), dict) else {}
361
+ legacy = ("style" not in script) and not vis.get("theme")
362
+ return {
363
+ "legacy": bool(legacy),
364
+ "voice": {"map": {}, "default": voce.get("default"), "policy": {
365
+ "auto": not legacy, "jitter": 0.0 if legacy else 0.35,
366
+ "max_distinct": 2 if legacy else 3,
367
+ "rate_range": list(_RATE_LIMIT), "pitch_range": list(_PITCH_LIMIT)}},
368
+ "pacing": {"lead": float(script.get("lead", 0.55)),
369
+ "tail": float(script.get("tail", 0.85))},
370
+ }
371
+
372
+
373
+ def _fmt_num(v):
374
+ """计划里的 lead/tail 统一保留 3 位(避免 0.6200000000001 这种浮点尾巴)。"""
375
+ return round(float(v) + 0.0, 3)
376
+
377
+
378
+ def _legacy_plan(script, lines, pace):
379
+ """v0.4 路径:单值 voice/rate/pitch + script 的 lead/tail,无抖动无语气。"""
380
+ voice = str(script.get("voice") or LEGACY_VOICE)
381
+ rate = script.get("rate")
382
+ pitch = script.get("pitch")
383
+ rate_s = fmt_pct(parse_pct(rate)) if isinstance(rate, (int, float)) else str(rate or "+0%")
384
+ pitch_s = fmt_hz(parse_hz(pitch)) if isinstance(pitch, (int, float)) else str(pitch or "+0Hz")
385
+ lead = float(script.get("lead", pace.get("lead", 0.55)))
386
+ tail = float(script.get("tail", pace.get("tail", 0.85)))
387
+ rows = []
388
+ for i, ln in enumerate(lines):
389
+ ln = ln if isinstance(ln, dict) else {}
390
+ rows.append({
391
+ "id": str(ln.get("id") or ("l%02d" % (i + 1))),
392
+ "kind": str(ln.get("kind") or "body"),
393
+ "persona": None, "tone": "body", "voice": voice,
394
+ "rate": rate_s, "pitch": pitch_s, "volume": "+0%", "gain_db": 0.0,
395
+ "lead": _fmt_num(lead), "tail": _fmt_num(tail), "reason": "legacy",
396
+ })
397
+ return rows
398
+
399
+
400
+ def _pick_persona(line, kind, vmap, default, reasons):
401
+ """返回 (persona 或 None, 是否显式, 原始 voice id 或 None)。"""
402
+ for field in ("persona", "voice"):
403
+ raw = line.get(field)
404
+ if raw in (None, ""):
405
+ continue
406
+ name = str(raw).strip()
407
+ if name in VOICE_PERSONAS:
408
+ reasons.append("explicit:%s" % field)
409
+ return name, True, None
410
+ if is_edge_voice(name):
411
+ reasons.append("explicit:%s" % field)
412
+ return None, True, name
413
+ reasons.append("unknown_persona:%s" % name)
414
+ mapped = vmap.get(kind, vmap.get("*"))
415
+ if mapped:
416
+ name = str(mapped).strip()
417
+ if name in VOICE_PERSONAS:
418
+ reasons.append("map:%s" % kind)
419
+ return name, False, None
420
+ if is_edge_voice(name):
421
+ reasons.append("map:%s" % kind)
422
+ return None, False, name
423
+ reasons.append("unknown_map:%s" % name)
424
+ if default:
425
+ reasons.append("default")
426
+ return default, False, None
427
+ name = KIND_DEFAULT.get(kind, "narrator_male")
428
+ reasons.append("kind:%s" % kind)
429
+ return name, False, None
430
+
431
+
432
+ def _pick_tone(line, kind, rules, use_rules, reasons):
433
+ """显式 tone > 文本规则 > kind 同名语气 > body。"""
434
+ raw = line.get("tone")
435
+ name = str(raw).strip() if raw not in (None, "") else ""
436
+ if name in VOICE_TONES:
437
+ reasons.append("tone:explicit")
438
+ return name
439
+ if name:
440
+ reasons.append("unknown_tone:%s" % name)
441
+ if use_rules and rules["tone"]:
442
+ reasons.append("tone:%s" % rules["tone"])
443
+ return rules["tone"]
444
+ if kind in VOICE_TONES:
445
+ return kind
446
+ return "body"
447
+
448
+
449
+ def _govern(cand, prev, kind, prev_kind, used, max_distinct, explicit, fallback=None,
450
+ quote_rule=False):
451
+ """换人治理:同段落不换人;换段可换,但要过 max_distinct 预算。
452
+
453
+ 返回 (最终 persona, 附加 reason)。相邻两句不换人是防"广播剧"的关键:
454
+ 同一 kind 的连续句子必须由同一把声音念;只有显式指定、或到了 hook/body/quote/outro
455
+ 的段落边界(body->quote 是 spec 明说的允许点),才会换。
456
+ 预算用尽时优先回到"正文声线"(fallback),避免收尾句被引用声线一路念到底。
457
+ quote_rule=True 表示"这句文本有引号、该由 quote 声线念":这是 body->quote 的
458
+ spec 允许点,不受同 kind 限制、也不占 max_distinct 预算(reason 记 governor:quote)。
459
+ """
460
+ if explicit or cand is None or prev is None:
461
+ # 显式指定 / 风格包直接写了原始 voice id:一律放行,不参与换人治理
462
+ return cand, ""
463
+ if cand == prev:
464
+ return cand, ""
465
+ if quote_rule:
466
+ return cand, "governor:quote"
467
+ if kind == prev_kind:
468
+ return prev, "governor:glue"
469
+ if cand not in used and len(used) >= max_distinct:
470
+ # 预算用尽:只有"正文声线已经在用"时才能回落,否则会偷偷多出第 max_distinct+1 个人
471
+ if fallback and fallback in used and fallback != cand:
472
+ return fallback, "governor:cap"
473
+ return prev, "governor:cap"
474
+ return cand, "governor:switch"
475
+
476
+
477
+ def voice_plan(script, style_plan=None):
478
+ """把剧本拆成逐句的旁白计划(纯函数、确定性,两次调用结果完全相等)。
479
+
480
+ 参数
481
+ ----
482
+ script film.json 解析后的 dict(只读,不修改)
483
+ style_plan stylekit.resolve_script_style(script) 的返回值;None 时本模块自己算
484
+
485
+ 返回
486
+ ----
487
+ list[dict],每行:id / kind / persona / tone / voice / rate / pitch / volume /
488
+ gain_db / lead / tail / reason。rate/pitch/volume 是可直接透传 edge-tts 的字符串。
489
+ """
490
+ script = script if isinstance(script, dict) else {}
491
+ sp = _resolve(script, style_plan)
492
+ lines = [ln for ln in (script.get("lines") or [])]
493
+ vsec = sp.get("voice") if isinstance(sp.get("voice"), dict) else {}
494
+ policy = vsec.get("policy") if isinstance(vsec.get("policy"), dict) else {}
495
+ pace = sp.get("pacing") if isinstance(sp.get("pacing"), dict) else {}
496
+
497
+ if sp.get("legacy"):
498
+ return _legacy_plan(script, lines, pace)
499
+
500
+ jitter = max(0.0, float(policy.get("jitter", 0.35)))
501
+ use_rules = _as_bool(policy.get("auto"), True)
502
+ max_distinct = max(1, int(policy.get("max_distinct", 3)))
503
+ rate_lim = tuple(policy.get("rate_range") or _RATE_LIMIT)
504
+ pitch_lim = tuple(policy.get("pitch_range") or _PITCH_LIMIT)
505
+ vmap = vsec.get("map") if isinstance(vsec.get("map"), dict) else {}
506
+ default = str(vsec.get("default") or "").strip()
507
+ default = default if default in VOICE_PERSONAS else ""
508
+ base_lead = float(pace.get("lead", 0.55))
509
+ base_tail = float(pace.get("tail", 0.85))
510
+ title = str(script.get("title") or "")
511
+ # 正文声线锚点:预算用尽时回落用它,让"旁白"始终是正文那把声音
512
+ body_persona = vmap.get("body") if vmap.get("body") in VOICE_PERSONAS else None
513
+ body_persona = body_persona or default or KIND_DEFAULT.get("body", "narrator_male")
514
+
515
+ rows, used = [], []
516
+ prev_persona, prev_kind = None, None
517
+ for i, ln in enumerate(lines):
518
+ ln = ln if isinstance(ln, dict) else {}
519
+ lid = str(ln.get("id") or ("l%02d" % (i + 1)))
520
+ kind = str(ln.get("kind") or "body").strip() or "body"
521
+ reasons = []
522
+ rules = _rules(ln.get("text")) if use_rules else {
523
+ "tone": "", "rate": 0, "volume": 0, "tail": 0.0, "reasons": []}
524
+
525
+ cand, explicit, raw_voice = _pick_persona(ln, kind, vmap, default, reasons)
526
+ # 文本规则也要能换人:kind 仍标 body 的「他说:『…』」不能还是正文那条嗓子,
527
+ # 否则语气换了、声音没换,听感依旧是同一个人在念。显式 voice/tone 优先级更高。
528
+ quote_rule = False
529
+ if not explicit and rules["tone"] == "quote" and cand is not None and cand == body_persona:
530
+ quote_p = vmap.get("quote") if vmap.get("quote") in VOICE_PERSONAS else None
531
+ quote_p = quote_p or KIND_DEFAULT.get("quote", "narrator_female")
532
+ if quote_p != cand:
533
+ cand = quote_p
534
+ quote_rule = True
535
+ reasons.append("rule:quote:persona")
536
+ final, gov = _govern(cand, prev_persona, kind, prev_kind, used, max_distinct,
537
+ explicit, body_persona, quote_rule)
538
+ if gov:
539
+ reasons.append(gov)
540
+ if final is not None and final not in used:
541
+ used.append(final)
542
+
543
+ tone_name = _pick_tone(ln, kind, rules, use_rules, reasons)
544
+ tone = VOICE_TONES[tone_name]
545
+
546
+ if final is not None:
547
+ p = VOICE_PERSONAS[final]
548
+ voice = p["voice"]
549
+ rate_v = parse_pct(p["rate"]) + tone["rate_delta"]
550
+ pitch_v = parse_hz(p["pitch"]) + tone["pitch_delta"]
551
+ vol_v = parse_pct(p["volume"]) + tone["volume_delta"]
552
+ gain = float(p["gain_db"]) + float(tone["gain_db"])
553
+ else: # 显式原始 voice id:没有 persona 底子,只叠语气
554
+ voice = raw_voice or LEGACY_VOICE
555
+ rate_v = tone["rate_delta"]
556
+ pitch_v = tone["pitch_delta"]
557
+ vol_v = tone["volume_delta"]
558
+ gain = float(tone["gain_db"])
559
+
560
+ rate_v += rules["rate"]
561
+ vol_v += rules["volume"]
562
+ if use_rules:
563
+ reasons.extend(rules["reasons"])
564
+
565
+ lead = base_lead + (float(tone["lead"]) - BODY_LEAD)
566
+ tail = base_tail + (float(tone["tail"]) - BODY_TAIL) + rules["tail"]
567
+
568
+ u_rate, u_pitch = 0.0, 0.0
569
+ if jitter > 0:
570
+ jr, jp, jl, jt, u_rate, u_pitch = _jitter(title, lid, final or voice)
571
+ rate_v += jr * jitter
572
+ pitch_v += jp * jitter
573
+ lead += jl * jitter
574
+ tail += jt * jitter
575
+ reasons.append("jitter:%.2f" % jitter)
576
+
577
+ rate_c = _clamp(rate_v, rate_lim[0], rate_lim[1])
578
+ pitch_c = _clamp(pitch_v, pitch_lim[0], pitch_lim[1])
579
+ vol_c = _clamp(vol_v, _VOL_LIMIT[0], _VOL_LIMIT[1])
580
+ if rate_c != rate_v:
581
+ reasons.append("clamp:rate")
582
+ if pitch_c != pitch_v:
583
+ reasons.append("clamp:pitch")
584
+ if _round_half_away(vol_c) != _round_half_away(vol_v):
585
+ reasons.append("clamp:volume")
586
+
587
+ # edge-tts 只收整数:jitter>0 时用确定性随机进位(长期期望=浮点目标),
588
+ # jitter=0 时退化为纯四舍五入,保证与无抖动逐字段相等。
589
+ if jitter > 0:
590
+ rate_s = "%+d%%" % _quantize(rate_c, u_rate)
591
+ pitch_s = "%+dHz" % _quantize(pitch_c, u_pitch)
592
+ else:
593
+ rate_s = fmt_pct(rate_c)
594
+ pitch_s = fmt_hz(pitch_c)
595
+
596
+ rows.append({
597
+ "id": lid, "kind": kind, "persona": final, "tone": tone_name,
598
+ "voice": voice, "rate": rate_s, "pitch": pitch_s,
599
+ "volume": fmt_pct(vol_c), "gain_db": round(gain, 2),
600
+ "lead": _fmt_num(_clamp(lead, _LEAD_LIMIT[0], _LEAD_LIMIT[1])),
601
+ "tail": _fmt_num(_clamp(tail, _TAIL_LIMIT[0], _TAIL_LIMIT[1])),
602
+ "reason": ";".join(reasons),
603
+ })
604
+ prev_persona = final if final is not None else prev_persona
605
+ # 生效类型:引号句虽然 kind 标 body,但已经按 quote 声线念了,
606
+ # 下一句普通正文才算"换段",否则会被同 kind 粘连规则永远锁在引用声线上。
607
+ prev_kind = "quote" if quote_rule else kind
608
+ return rows
609
+
610
+
611
+ # ============================================================================
612
+ # 六、缓存键(v2:trim 进 key,修 v0.4 的静默复用 bug)
613
+ # ============================================================================
614
+ def _norm_pct(v, default="+0%"):
615
+ if v in (None, ""):
616
+ return default
617
+ return fmt_pct(parse_pct(v))
618
+
619
+
620
+ def _norm_hz(v, default="+0Hz"):
621
+ if v in (None, ""):
622
+ return default
623
+ return fmt_hz(parse_hz(v))
624
+
625
+
626
+ def spec_key(spec):
627
+ """v2 合成缓存键:sha1("v2|text|voice|rate|pitch|volume|trim|trim_db")[:16]。
628
+
629
+ 与 v0.4 的 tts_key(text|voice|rate|pitch)不复用:v0.4 把 trim 漏在 key 外,
630
+ 同一句改 trim/trim_db 会静默复用旧音频。spec 传一行计划 + text 即可:
631
+ spec_key(dict(plan[0], text=line["text"]));trim/trim_db 缺省与 filmkit.tts 一致。
632
+ """
633
+ spec = spec if isinstance(spec, dict) else {}
634
+ try:
635
+ trim_db = float(spec.get("trim_db", -42.0))
636
+ except (TypeError, ValueError):
637
+ trim_db = -42.0
638
+ trim = 1 if _as_bool(spec.get("trim", True), True) else 0
639
+ raw = "v2|%s|%s|%s|%s|%s|%d|%.1f" % (
640
+ str(spec.get("text") or ""), str(spec.get("voice") or ""),
641
+ _norm_pct(spec.get("rate")), _norm_hz(spec.get("pitch")),
642
+ _norm_pct(spec.get("volume")), trim, trim_db)
643
+ return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
644
+
645
+
646
+ def plan_stats(plan):
647
+ """计划摘要:用了几个 persona / 哪些语气 / 是否 legacy(测试与日志用)。"""
648
+ rows = [r for r in (plan or []) if isinstance(r, dict)]
649
+ personas = []
650
+ tones = []
651
+ for r in rows:
652
+ p, t = r.get("persona"), r.get("tone")
653
+ if p and p not in personas:
654
+ personas.append(p)
655
+ if t and t not in tones:
656
+ tones.append(t)
657
+ return {"lines": len(rows), "personas": personas, "tones": tones,
658
+ "distinct": len(personas),
659
+ "legacy": bool(rows) and all(r.get("reason") == "legacy" for r in rows)}
660
+
661
+
662
+ # ============================================================================
663
+ # 七、人读输出
664
+ # ============================================================================
665
+ def describe(plan):
666
+ """把计划表打成人可读的多行文本(id/kind/persona/tone/voice/rate/lead/tail)。"""
667
+ rows = [r for r in (plan or []) if isinstance(r, dict)]
668
+ head = "%-8s %-7s %-18s %-9s %-28s %-6s %-6s %-6s" % (
669
+ "id", "kind", "persona", "tone", "voice", "rate", "lead", "tail")
670
+ out = [head, "-" * len(head)]
671
+ for r in rows:
672
+ out.append("%-8s %-7s %-18s %-9s %-28s %-6s %-6.2f %-6.2f" % (
673
+ str(r.get("id", "")), str(r.get("kind", "")),
674
+ str(r.get("persona") or "-"), str(r.get("tone", "")),
675
+ str(r.get("voice", "")), str(r.get("rate", "")),
676
+ float(r.get("lead", 0.0)), float(r.get("tail", 0.0))))
677
+ return "\n".join(out)
678
+
679
+
680
+ # ============================================================================
681
+ # 八、校准:真合成、真实测,失败优雅降级(返回 0,不阻塞主链)
682
+ # ============================================================================
683
+ def _measure_wav(path):
684
+ """读 24k 单声道 wav,返回 dur/cps/rms_db/peak_db(纯标准库,不用 numpy)。"""
685
+ with wave.open(path, "rb") as w:
686
+ sr = w.getframerate()
687
+ n = w.getnframes()
688
+ raw = w.readframes(n)
689
+ import array
690
+ data = array.array("h")
691
+ data.frombytes(raw)
692
+ if sys.byteorder == "big":
693
+ data.byteswap()
694
+ total = len(data)
695
+ dur = total / float(sr) if sr else 0.0
696
+ peak = max((abs(v) for v in data), default=0) / 32768.0
697
+ fl = max(1, int(0.02 * sr))
698
+ hop = max(1, int(0.01 * sr))
699
+ frames = []
700
+ for i in range(0, max(1, total - fl + 1), hop):
701
+ seg = data[i:i + fl]
702
+ if not seg:
703
+ continue
704
+ acc = 0
705
+ for v in seg:
706
+ acc += v * v
707
+ frames.append(math.sqrt(acc / float(len(seg))) / 32768.0)
708
+ gated = [x for x in frames if x > 10 ** (-45.0 / 20.0)]
709
+ rms = math.sqrt(sum(x * x for x in gated) / len(gated)) if gated else 1e-6
710
+ return {"dur": dur, "cps": hanzi_count(CALIB_TEXT) / dur if dur else 0.0,
711
+ "rms_db": 20.0 * math.log10(rms + 1e-12),
712
+ "peak_db": 20.0 * math.log10(peak + 1e-12)}
713
+
714
+
715
+ def calibrate(personas=None, log=None):
716
+ """逐个 persona 合成校准句,实测 dur/cps/门限 RMS,打印建议 gain_db。
717
+
718
+ 只在这里 import edge_tts(其余函数纯标准库,方便无网环境单测)。
719
+ 没装 edge_tts / 没 ffmpeg / 没网:打印 SKIP 并返回 0,绝不让主链失败。
720
+ """
721
+ log = log or (lambda s: None)
722
+ try:
723
+ import edge_tts
724
+ except Exception as e: # noqa: BLE001
725
+ log("[SKIP] 没装 edge_tts(%s: %s),跳过校准" % (type(e).__name__, e))
726
+ return 0
727
+ ffmpeg = shutil.which("ffmpeg")
728
+ if not ffmpeg:
729
+ log("[SKIP] 没找到 ffmpeg,无法把 mp3 解成 PCM 测 RMS,跳过校准")
730
+ return 0
731
+ ids = [p for p in (personas or PERSONA_IDS) if p in VOICE_PERSONAS]
732
+ tmp = tempfile.mkdtemp(prefix="voicekit_cal_")
733
+ rows = {}
734
+ try:
735
+ import asyncio
736
+ for pid in ids:
737
+ p = VOICE_PERSONAS[pid]
738
+ mp3 = os.path.join(tmp, pid + ".mp3")
739
+ wav = os.path.join(tmp, pid + ".wav")
740
+
741
+ async def _go(_p=p, _mp3=mp3):
742
+ await edge_tts.Communicate(CALIB_TEXT, _p["voice"], rate=_p["rate"],
743
+ pitch=_p["pitch"], volume=_p["volume"]).save(_mp3)
744
+
745
+ try:
746
+ asyncio.run(_go())
747
+ r = subprocess.run([ffmpeg, "-y", "-v", "error", "-i", mp3,
748
+ "-ac", "1", "-ar", "24000", "-sample_fmt", "s16", wav],
749
+ capture_output=True, text=True)
750
+ if r.returncode != 0:
751
+ raise RuntimeError((r.stderr or "")[-200:])
752
+ m = _measure_wav(wav)
753
+ rows[pid] = m
754
+ log(" OK %-18s %-28s dur %.2fs cps %.2f rms %.2f dBFS"
755
+ % (pid, p["voice"], m["dur"], m["cps"], m["rms_db"]))
756
+ except Exception as e: # noqa: BLE001 — 单条失败不该中断整表
757
+ log(" SKIP %-18s %s: %s" % (pid, type(e).__name__, str(e)[:110]))
758
+ finally:
759
+ shutil.rmtree(tmp, ignore_errors=True)
760
+ if not rows:
761
+ log("[SKIP] 所有音色都合成失败(多半是没网或 edge 端点临时不可用),未产生校准值")
762
+ return 0
763
+ target = sorted(r["rms_db"] for r in rows.values())[len(rows) // 2]
764
+ log("")
765
+ log("目标响度(%d 个 persona 门限 RMS 中位数)= %.2f dBFS" % (len(rows), target))
766
+ log("%-18s %-28s %6s %6s %8s %8s %8s" % (
767
+ "persona", "voice", "dur", "cps", "rms", "建议gain", "当前gain"))
768
+ for pid in rows:
769
+ m = rows[pid]
770
+ want = _clamp(target - m["rms_db"], -_GAIN_LIMIT, _GAIN_LIMIT)
771
+ cur = float(VOICE_PERSONAS[pid]["gain_db"])
772
+ log("%-18s %-28s %6.2f %6.2f %8.2f %+8.2f %+8.2f%s" % (
773
+ pid, VOICE_PERSONAS[pid]["voice"], m["dur"], m["cps"], m["rms_db"],
774
+ want, cur, "" if abs(want - cur) < 0.35 else " <-- 建议改表"))
775
+ log("")
776
+ log("把上面「建议gain」抄回 VOICE_PERSONAS[pid]['gain_db'],并把 CALIBRATED_ON 改成今天;")
777
+ log("换 voice id 必须重跑本命令 —— 不同音色的响度差 3-7 dB,凭感觉调会一句响一句轻。")
778
+ return 0
779
+
780
+
781
+ # ============================================================================
782
+ # 九、CLI
783
+ # ============================================================================
784
+ def _setup_stdio():
785
+ """Windows 控制台默认 GBK,中文/箭头会炸;能改就改,改不了不致命。"""
786
+ for stream in (sys.stdout, sys.stderr):
787
+ try:
788
+ stream.reconfigure(encoding="utf-8", errors="replace")
789
+ except Exception: # noqa: BLE001
790
+ pass
791
+
792
+
793
+ def _cli_list():
794
+ print("voicekit 声线表(v0.5.0;校准于 %s,edge-tts 免费端点只有 8 个 zh-CN 音色)"
795
+ % CALIBRATED_ON)
796
+ print("%-18s %-28s %6s %7s %7s %8s %-8s %-8s %s" % (
797
+ "persona", "voice", "rate", "pitch", "volume", "gain_db", "narrate", "home", "desc"))
798
+ print("-" * 150)
799
+ for pid in PERSONA_IDS:
800
+ p = VOICE_PERSONAS[pid]
801
+ print("%-18s %-28s %6s %7s %7s %+8.2f %-8s %-8s %s" % (
802
+ pid, p["voice"], p["rate"], p["pitch"], p["volume"], p["gain_db"],
803
+ "yes" if p["narrate"] else "no", p["home"], p["desc"]))
804
+ print("")
805
+ print("语气档(body 必须等于 v0.4 基准:lead 0.62 / tail 0.92 / 所有 delta=0)")
806
+ print("%-10s %8s %9s %9s %8s %6s %6s %s" % (
807
+ "tone", "rate", "pitch", "volume", "gain_db", "lead", "tail", "desc"))
808
+ print("-" * 120)
809
+ for tid in TONE_IDS:
810
+ t = VOICE_TONES[tid]
811
+ print("%-10s %+8d %+9d %+9d %+8.2f %6.2f %6.2f %s" % (
812
+ tid, t["rate_delta"], t["pitch_delta"], t["volume_delta"], t["gain_db"],
813
+ t["lead"], t["tail"], t["desc"]))
814
+ print("")
815
+ print("注:warm_female 用 zh-TW-HsiaoChenNeural(台湾腔软糯女声);要大陆标准音就换成")
816
+ print(" zh-CN-XiaoxiaoNeural 并把 rate 改成 '-3%',其余字段不用动。")
817
+ return 0
818
+
819
+
820
+ def _cli_plan(path):
821
+ try:
822
+ with open(path, "r", encoding="utf-8") as f:
823
+ script = json.load(f)
824
+ except (OSError, ValueError) as e:
825
+ print("[剧本格式错误] %s: %s" % (path, e))
826
+ return 2
827
+ if not isinstance(script, dict) or not script.get("lines"):
828
+ print("[剧本格式错误] %s 顶层必须是对象且含非空 lines" % path)
829
+ return 2
830
+ sp = _resolve(script, None)
831
+ plan = voice_plan(script, sp)
832
+ print("剧本:%s(%d 句)" % (path, len(plan)))
833
+ print("风格:%s legacy=%s voice.map=%s"
834
+ % (sp.get("style", "?"), bool(sp.get("legacy")),
835
+ json.dumps(sp.get("voice", {}).get("map", {}), ensure_ascii=False)))
836
+ print("")
837
+ print(describe(plan))
838
+ print("")
839
+ for r, ln in zip(plan, script["lines"]):
840
+ print(" %-8s rate=%-5s pitch=%-6s vol=%-5s gain=%+5.2f lead=%-5.2f tail=%-5.2f %s"
841
+ % (r["id"], r["rate"], r["pitch"], r["volume"], r["gain_db"],
842
+ r["lead"], r["tail"], r["reason"]))
843
+ st = plan_stats(plan)
844
+ print("")
845
+ print("声线 %d 种:%s" % (st["distinct"], ", ".join(st["personas"]) or "-"))
846
+ print("语气 %d 档:%s" % (len(st["tones"]), ", ".join(st["tones"]) or "-"))
847
+ print("提示:同一份剧本两次 voice_plan() 结果完全相同;legacy 剧本走 v0.4 单值路径。")
848
+ return 0
849
+
850
+
851
+ def main(argv=None):
852
+ _setup_stdio()
853
+ ap = argparse.ArgumentParser(
854
+ prog="voicekit.py", description="v0.5 旁白声线/语气计划(不合成,只出计划)")
855
+ ap.add_argument("--list", action="store_true", help="列出声线表与语气档")
856
+ ap.add_argument("--plan", metavar="FILM_JSON", help="打印某份 film.json 的声线计划")
857
+ ap.add_argument("--calibrate", action="store_true", help="联网实测各 persona 的 dur/RMS,打印建议 gain_db")
858
+ ap.add_argument("--only", metavar="PERSONA", help="--calibrate 时只测这些 persona(逗号分隔)")
859
+ a = ap.parse_args(argv)
860
+ if a.list:
861
+ return _cli_list()
862
+ if a.calibrate:
863
+ only = [x.strip() for x in (a.only or "").split(",") if x.strip()] or None
864
+ if only:
865
+ unknown = [x for x in only if x not in VOICE_PERSONAS]
866
+ if unknown:
867
+ # 以前拼错 persona 会静默变成空集合 -> 打出"所有音色都合成失败(多半是没网)",
868
+ # 把配置错误伪装成网络错误。这里先点名校验,给人话可照抄的可用清单。
869
+ print("[未知 persona] %s;可用:%s"
870
+ % ("、".join(unknown), " / ".join(PERSONA_IDS)), file=sys.stderr)
871
+ return 2
872
+ return calibrate(only, log=print)
873
+ if a.plan:
874
+ return _cli_plan(a.plan)
875
+ ap.print_help()
876
+ return 0
877
+
878
+
879
+ if __name__ == "__main__":
880
+ sys.exit(main())