honeydo 0.1.0
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/LICENSE +21 -0
- package/README.md +90 -0
- package/README.zh-CN.md +88 -0
- package/package.json +54 -0
- package/packages/cli/dist/index.d.ts +2 -0
- package/packages/cli/dist/index.js +171 -0
- package/packages/cli/dist/index.js.map +1 -0
- package/packages/doubao/dist/cli.d.ts +38 -0
- package/packages/doubao/dist/cli.d.ts.map +1 -0
- package/packages/doubao/dist/cli.js +206 -0
- package/packages/gcli/dist/cli.d.ts +465 -0
- package/packages/gcli/dist/cli.js +2017 -0
- package/packages/gcli/dist/cli.js.map +1 -0
- package/packages/lmedia/dist/index.d.ts +1 -0
- package/packages/lmedia/dist/index.js +1594 -0
- package/packages/lmedia/python/edit.py +107 -0
- package/packages/lmedia/python/esrgan_path.py +16 -0
- package/packages/lmedia/python/gen.py +131 -0
- package/packages/lmedia/python/serve.py +352 -0
- package/packages/lmedia/python/sfx.py +527 -0
- package/packages/lmedia/python/teacache.py +255 -0
- package/packages/lmedia/python/upscale.py +41 -0
- package/packages/minimax/dist/cli.d.ts +51 -0
- package/packages/minimax/dist/cli.js +307 -0
- package/packages/minimax/dist/cli.js.map +1 -0
- package/packages/minimax/dist/client.d.ts +20 -0
- package/packages/minimax/dist/client.js +55 -0
- package/packages/minimax/dist/client.js.map +1 -0
- package/packages/minimax/dist/tts.d.ts +33 -0
- package/packages/minimax/dist/tts.js +64 -0
- package/packages/minimax/dist/tts.js.map +1 -0
- package/packages/minimax/dist/validate.d.ts +29 -0
- package/packages/minimax/dist/validate.js +122 -0
- package/packages/minimax/dist/validate.js.map +1 -0
- package/packages/minimax/dist/voice-clone.d.ts +17 -0
- package/packages/minimax/dist/voice-clone.js +47 -0
- package/packages/minimax/dist/voice-clone.js.map +1 -0
- package/packages/minimax/dist/voices.d.ts +17 -0
- package/packages/minimax/dist/voices.js +20 -0
- package/packages/minimax/dist/voices.js.map +1 -0
- package/packages/qwen/dist/index.d.ts +1 -0
- package/packages/qwen/dist/index.js +311 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""lmedia sfx worker — Dasheng-AudioGen 本地音效生成 + 音频后处理产线(Apache 2.0)
|
|
3
|
+
|
|
4
|
+
输入: argv[1] = JSON payload,payload["op"] 分发:gen | batch | trim | recut | normalize | accept | abpage | probe
|
|
5
|
+
(无 op 视为 gen,向后兼容既有调用)
|
|
6
|
+
输出: stdout **末行单行 compact JSON** 结果(内部禁换行);人读进度一律走 stderr
|
|
7
|
+
|
|
8
|
+
管线(gen/batch):生成(10s/16kHz) → 质量门(峰值≥-25dBFS 且 SNR≥20dB,全废自动加掷≤2) → 剪裁(两级静音检测+簇截断) → 段内两遍峰值归一 -6dBFS
|
|
9
|
+
归一铁律:两遍法——先剪到 tmp 测「段内」峰值再增益(整掷峰值归一会让窗口内容低 20dB,已修)
|
|
10
|
+
降噪铁律:禁 afftdn(填平间隙毁剪裁);prompt 必须纯英文场景描述(flan-t5-large 不懂中文 → 中文=人声废片)
|
|
11
|
+
"""
|
|
12
|
+
import html
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import soundfile as sf
|
|
25
|
+
|
|
26
|
+
GATE_PEAK = -25.0
|
|
27
|
+
GATE_SNR = 20.0
|
|
28
|
+
PAD = 0.15
|
|
29
|
+
PEAK_DB = -6.0
|
|
30
|
+
FADE_IN = 0.01
|
|
31
|
+
FADE_OUT = 0.08
|
|
32
|
+
SILENT_PEAK_DB = -60.0 # 低于此峰值视为全静音(跳过增益,透传副本)
|
|
33
|
+
TRIM_MIN_CONTENT = 0.5 # 检测不到内容(整条≈静音)→ 降阈值重试
|
|
34
|
+
MIN_RESULT = 0.3 # 成品最小时长,否则回退全段
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ———————————————————————————— 探针 ————————————————————————————
|
|
38
|
+
|
|
39
|
+
def run(args, **kw):
|
|
40
|
+
return subprocess.run(args, capture_output=True, text=True, **kw)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def probe_dur(path):
|
|
44
|
+
out = run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
45
|
+
"-of", "default=nk=1:nw=1", path]).stdout
|
|
46
|
+
try:
|
|
47
|
+
return float(out.strip())
|
|
48
|
+
except (ValueError, TypeError):
|
|
49
|
+
raise RuntimeError(f"无法读取音频时长(文件不存在、非音频或已损坏): {path}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def probe_sr(path):
|
|
53
|
+
out = run(["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries",
|
|
54
|
+
"stream=sample_rate", "-of", "default=nk=1:nw=1", path]).stdout
|
|
55
|
+
sr = int(out.strip() or 0)
|
|
56
|
+
if sr <= 0:
|
|
57
|
+
raise RuntimeError(f"无法读取采样率(文件不存在、非音频或已损坏): {path}")
|
|
58
|
+
return sr
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def vol_stats(path):
|
|
62
|
+
"""volumedetect → (max_volume, mean_volume) dB;数字静音时 ffmpeg 报 -inf,归一为 -99"""
|
|
63
|
+
r = run(["ffmpeg", "-hide_banner", "-i", path, "-af", "volumedetect", "-f", "null", "-"])
|
|
64
|
+
|
|
65
|
+
def grab(key):
|
|
66
|
+
m = re.search(key + r":\s*(-?[\d.]+|-inf)\s*dB", r.stderr)
|
|
67
|
+
if not m:
|
|
68
|
+
return 0.0
|
|
69
|
+
return -99.0 if m.group(1) == "-inf" else float(m.group(1))
|
|
70
|
+
|
|
71
|
+
return grab("max_volume"), grab("mean_volume")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def peak_volume_db(path):
|
|
75
|
+
return vol_stats(path)[0]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def integrated_lufs(path):
|
|
79
|
+
r = run(["ffmpeg", "-hide_banner", "-i", path, "-af", "ebur128", "-f", "null", "-"])
|
|
80
|
+
if "Summary:" not in r.stderr:
|
|
81
|
+
return None
|
|
82
|
+
m = re.search(r"I:\s*(-?[\d.]+)\s*LUFS", r.stderr.split("Summary:")[-1])
|
|
83
|
+
return float(m.group(1)) if m else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def detect_silences(path, total, thresh_db=-35.0, min_d=0.2):
|
|
87
|
+
"""silencedetect → [(start, end), ...];文件末尾未闭合的静音补 total"""
|
|
88
|
+
r = run(["ffmpeg", "-hide_banner", "-i", path, "-af",
|
|
89
|
+
f"silencedetect=noise={thresh_db:.0f}dB:d={min_d}", "-f", "null", "-"])
|
|
90
|
+
sil, start = [], None
|
|
91
|
+
for line in r.stderr.splitlines():
|
|
92
|
+
m = re.search(r"silence_start: ([\d.]+)", line)
|
|
93
|
+
if m:
|
|
94
|
+
start = float(m.group(1))
|
|
95
|
+
m = re.search(r"silence_end: ([\d.]+)", line)
|
|
96
|
+
if m and start is not None:
|
|
97
|
+
sil.append((start, float(m.group(1))))
|
|
98
|
+
start = None
|
|
99
|
+
if start is not None:
|
|
100
|
+
sil.append((start, total))
|
|
101
|
+
return sil
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def stats_db(arr):
|
|
105
|
+
peak = 20 * np.log10(np.abs(arr).max() + 1e-12)
|
|
106
|
+
rms = 20 * np.log10(np.sqrt((arr ** 2).mean()) + 1e-12)
|
|
107
|
+
return round(float(peak), 1), round(float(rms), 1)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ———————————————————————————— 生成 ————————————————————————————
|
|
111
|
+
|
|
112
|
+
def load_model():
|
|
113
|
+
import torch # 延迟导入:ffmpeg 后处理 op 不需要模型栈
|
|
114
|
+
from transformers import AutoModel
|
|
115
|
+
return AutoModel.from_pretrained("mispeech/Dasheng-AudioGen", trust_remote_code=True,
|
|
116
|
+
torch_dtype=torch.float32).to("mps")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def generate_arr(model, composed):
|
|
120
|
+
wav = model.generate(composed, num_steps=25, guidance_scale=5.0)
|
|
121
|
+
if isinstance(wav, tuple):
|
|
122
|
+
wav = wav[0]
|
|
123
|
+
return wav.detach().cpu().float().numpy().squeeze()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def gate_reason(peak, snr):
|
|
127
|
+
low_peak, low_snr = peak < GATE_PEAK, snr < GATE_SNR
|
|
128
|
+
if low_peak and low_snr:
|
|
129
|
+
return "峰值低于门限且 SNR 不足"
|
|
130
|
+
if low_peak:
|
|
131
|
+
return "峰值低于门限"
|
|
132
|
+
if low_snr:
|
|
133
|
+
return "SNR 不足"
|
|
134
|
+
return ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ———————————————————————————— 两遍归一构建 ————————————————————————————
|
|
138
|
+
|
|
139
|
+
def build_two_pass(src, dst, a, b, target=PEAK_DB, fade_in=FADE_IN, fade_out=FADE_OUT):
|
|
140
|
+
"""两遍法:atrim 到 tmp → 测段内峰值 → gain = target − 段内峰值 → fade+volume+44.1kHz mono"""
|
|
141
|
+
dur = b - a
|
|
142
|
+
tmp = dst + ".cut.wav"
|
|
143
|
+
try:
|
|
144
|
+
r = subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src, "-af",
|
|
145
|
+
f"atrim=start={a:.3f}:end={b:.3f},asetpts=N/SR/TB", tmp])
|
|
146
|
+
if r.returncode != 0:
|
|
147
|
+
raise RuntimeError(f"atrim 失败: {src} [{a:.3f},{b:.3f}]")
|
|
148
|
+
gain = target - peak_volume_db(tmp)
|
|
149
|
+
r = subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", tmp, "-af",
|
|
150
|
+
f"afade=t=in:d={fade_in},afade=t=out:st={max(0, dur - fade_out):.3f}:d={fade_out},"
|
|
151
|
+
f"volume={gain:.2f}dB,aresample=44100",
|
|
152
|
+
"-ac", "1", dst])
|
|
153
|
+
if r.returncode != 0:
|
|
154
|
+
raise RuntimeError(f"归一编码失败: {dst}")
|
|
155
|
+
finally:
|
|
156
|
+
if os.path.exists(tmp):
|
|
157
|
+
os.remove(tmp)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def trim_bounds(src, total, thresh_db, pad):
|
|
161
|
+
"""贴边静音认定(起点 ≤0.05s / 终点 ≥ total−0.05s)+ pad"""
|
|
162
|
+
sil = detect_silences(src, total, thresh_db, 0.2)
|
|
163
|
+
lead = sil[0][1] if sil and sil[0][0] <= 0.05 else 0.0
|
|
164
|
+
tail_start = sil[-1][0] if sil and sil[-1][1] >= total - 0.05 else total
|
|
165
|
+
return sil, lead, tail_start, max(0.0, lead - pad), min(total, tail_start + pad)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def signature_window(src, total, thresh_db=-35.0, pad=PAD):
|
|
169
|
+
"""签名音剪裁窗口:两级阈值回退(thresh → thresh−15 → 不剪)+ 首个 ≥0.8s 内部间隙截断。
|
|
170
|
+
gen / batch / trim 三路共用同一实现。返回 (a, b, b_cut):b=去首尾静音后的窗口终点,b_cut=簇截断后的 short 终点。"""
|
|
171
|
+
sil, lead, tail_start, a, b = trim_bounds(src, total, thresh_db, pad)
|
|
172
|
+
if b - a < TRIM_MIN_CONTENT: # 电平过低整条判静音 → 宽阈值重试
|
|
173
|
+
sil, lead, tail_start, a, b = trim_bounds(src, total, thresh_db - 15.0, pad)
|
|
174
|
+
if b - a < TRIM_MIN_CONTENT: # 仍检不出 → 不剪(sil 保留供内部间隙参考)
|
|
175
|
+
sil, lead, tail_start, a, b = detect_silences(src, total, thresh_db, 0.2), 0.0, total, 0.0, total
|
|
176
|
+
b_cut = b
|
|
177
|
+
internal = [(s, e) for s, e in sil if s > lead + 0.3 and e < tail_start - 0.3 and e - s >= 0.8]
|
|
178
|
+
if internal:
|
|
179
|
+
b_cut = min(b, internal[0][0] + pad)
|
|
180
|
+
return a, b, b_cut
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def write_ops(path, op, argv, info_in, info_out):
|
|
184
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
185
|
+
json.dump({"op": op, "argv": argv, "in": info_in, "out": info_out,
|
|
186
|
+
"at": datetime.now(timezone.utc).isoformat(timespec="seconds")},
|
|
187
|
+
f, ensure_ascii=False, indent=1)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ———————————————————————————— 剪裁 / 重剪 ————————————————————————————
|
|
191
|
+
|
|
192
|
+
def trim_file(src, thresh_db=-35.0, pad=PAD):
|
|
193
|
+
"""两级阈值(thresh → thresh−15 → 不剪)+ 内部间隙 ≥0.8s 簇截断 → <base>.trim.wav + <base>.short.wav"""
|
|
194
|
+
total = probe_dur(src)
|
|
195
|
+
a, b, b_cut = signature_window(src, total, thresh_db, pad)
|
|
196
|
+
|
|
197
|
+
base = os.path.splitext(src)[0]
|
|
198
|
+
trim_path, short_path = f"{base}.trim.wav", f"{base}.short.wav"
|
|
199
|
+
peak_in = peak_volume_db(src)
|
|
200
|
+
build_two_pass(src, trim_path, a, b)
|
|
201
|
+
trim_dur = probe_dur(trim_path)
|
|
202
|
+
|
|
203
|
+
if b_cut < b:
|
|
204
|
+
build_two_pass(src, short_path, a, b_cut)
|
|
205
|
+
short_dur = probe_dur(short_path)
|
|
206
|
+
else:
|
|
207
|
+
shutil.copy2(trim_path, short_path)
|
|
208
|
+
short_dur = trim_dur
|
|
209
|
+
|
|
210
|
+
write_ops(f"{base}.ops.json", "trim",
|
|
211
|
+
{"thresh": f"{thresh_db:.0f}dB", "pad": pad},
|
|
212
|
+
{"path": src, "dur": round(total, 3), "peak": peak_in},
|
|
213
|
+
{"trim": {"path": trim_path, "dur": round(trim_dur, 3), "peak": peak_volume_db(trim_path)},
|
|
214
|
+
"short": {"path": short_path, "dur": round(short_dur, 3), "peak": peak_volume_db(short_path)},
|
|
215
|
+
"cut": [round(a, 3), round(b, 3)]})
|
|
216
|
+
return {"in": src, "trim": trim_path, "short": short_path,
|
|
217
|
+
"durIn": round(total, 2), "durTrim": round(trim_dur, 2), "durShort": round(short_dur, 2),
|
|
218
|
+
"peakIn": peak_in, "peakOut": peak_volume_db(trim_path)}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def recut_file(src, thresh_db=-40.0, min_d=0.15, cap=3.5, pad=PAD):
|
|
222
|
+
"""灵敏重剪:-40dB/0.15s 检测 + 内部间隙 ≥0.45s 截断 + cap 硬帽 → <base>.short.wav 覆写"""
|
|
223
|
+
total = probe_dur(src)
|
|
224
|
+
sil = detect_silences(src, total, thresh_db, min_d)
|
|
225
|
+
lead = sil[0][1] if sil and sil[0][0] <= 0.05 else 0.0
|
|
226
|
+
tail = sil[-1][0] if sil and sil[-1][1] >= total - 0.05 else total
|
|
227
|
+
a, b = max(0.0, lead - pad), min(total, tail + pad)
|
|
228
|
+
if b - a < TRIM_MIN_CONTENT:
|
|
229
|
+
a, b, lead, tail = 0.0, total, 0.0, total
|
|
230
|
+
internal = [(s, e) for s, e in sil if s > a + 0.3 and e < b - 0.3 and e - s >= 0.45]
|
|
231
|
+
if internal:
|
|
232
|
+
b = min(b, internal[0][0] + PAD)
|
|
233
|
+
if b - a > cap: # 硬帽(留 0.2s 给淡出)
|
|
234
|
+
b = a + cap - 0.2
|
|
235
|
+
if b - a < MIN_RESULT:
|
|
236
|
+
a, b = 0.0, total
|
|
237
|
+
|
|
238
|
+
base = os.path.splitext(src)[0]
|
|
239
|
+
dst = f"{base}.short.wav"
|
|
240
|
+
peak_in = peak_volume_db(src)
|
|
241
|
+
build_two_pass(src, dst, a, b)
|
|
242
|
+
dur_out, peak_out = probe_dur(dst), peak_volume_db(dst)
|
|
243
|
+
write_ops(f"{dst}.ops.json", "recut",
|
|
244
|
+
{"thresh": f"{thresh_db:.0f}dB", "cap": cap, "minSilence": min_d, "pad": pad},
|
|
245
|
+
{"path": src, "dur": round(total, 3), "peak": peak_in},
|
|
246
|
+
{"path": dst, "dur": round(dur_out, 3), "peak": peak_out, "cut": [round(a, 3), round(b, 3)]})
|
|
247
|
+
return {"in": src, "short": dst, "durIn": round(total, 2), "durOut": round(dur_out, 2),
|
|
248
|
+
"peakIn": peak_in, "peakOut": peak_out}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ———————————————————————————— 归一 ————————————————————————————
|
|
252
|
+
|
|
253
|
+
def normalize_file(src, target=PEAK_DB, loudness=None, out_dir=None):
|
|
254
|
+
"""sfx 两遍峰值归一(默认 -6dBFS)| ambient loudnorm 两遍 I=<LUFS>:TP=-2:LRA=7 linear=true
|
|
255
|
+
全静音(peak < -60dBFS)→ 跳过增益但写透传副本到 <out>(不产 NaN/削波)"""
|
|
256
|
+
peak_in, _mean = vol_stats(src)
|
|
257
|
+
out = os.path.join(out_dir, os.path.basename(src)) if out_dir else src
|
|
258
|
+
os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
|
|
259
|
+
tmp = out + ".proc.wav"
|
|
260
|
+
dur_in, sr_in = probe_dur(src), probe_sr(src)
|
|
261
|
+
base = {"in": src, "out": out, "durIn": round(dur_in, 2), "peakIn": peak_in}
|
|
262
|
+
|
|
263
|
+
if peak_in < SILENT_PEAK_DB: # 全静音:透传副本(exit 0 路径,非报错)
|
|
264
|
+
shutil.copy2(src, tmp)
|
|
265
|
+
os.replace(tmp, out)
|
|
266
|
+
return {**base, "mode": "loudness" if loudness is not None else "peak", "skipped": True,
|
|
267
|
+
"reason": f"全静音(峰值 {peak_in:.0f}dB < {SILENT_PEAK_DB:.0f}dB),跳过增益已写透传副本",
|
|
268
|
+
"durOut": round(probe_dur(out), 2), "peakOut": peak_in, "gain": 0.0}
|
|
269
|
+
|
|
270
|
+
if loudness is not None: # loudnorm 两遍;第二遍必须 aresample 回输入采样率(loudnorm 默认升 192k)
|
|
271
|
+
stats = run(["ffmpeg", "-hide_banner", "-i", src, "-af",
|
|
272
|
+
f"loudnorm=I={loudness}:TP=-2:LRA=7:print_format=json", "-f", "null", "-"])
|
|
273
|
+
m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", stats.stderr, re.S)
|
|
274
|
+
if not m:
|
|
275
|
+
raise RuntimeError(f"loudnorm 测量失败: {src}")
|
|
276
|
+
s = json.loads(m.group(0))
|
|
277
|
+
ln = (f"loudnorm=I={loudness}:TP=-2:LRA=7:measured_I={s['input_i']}:measured_TP={s['input_tp']}:"
|
|
278
|
+
f"measured_LRA={s['input_lra']}:measured_thresh={s['input_thresh']}:"
|
|
279
|
+
f"offset={s['target_offset']}:linear=true,aresample={sr_in}")
|
|
280
|
+
if subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src, "-af", ln, tmp]).returncode != 0:
|
|
281
|
+
raise RuntimeError(f"loudnorm 应用失败: {src}")
|
|
282
|
+
os.replace(tmp, out)
|
|
283
|
+
return {**base, "mode": "loudness", "skipped": False, "reason": "", "target": loudness,
|
|
284
|
+
"lufsIn": integrated_lufs(src), "lufsOut": integrated_lufs(out),
|
|
285
|
+
"durOut": round(probe_dur(out), 2), "peakOut": peak_volume_db(out), "gain": None,
|
|
286
|
+
"sampleRate": probe_sr(out)}
|
|
287
|
+
|
|
288
|
+
gain = target - peak_in
|
|
289
|
+
if abs(gain) < 0.5: # 已达标:跳过增益但仍写副本
|
|
290
|
+
shutil.copy2(src, tmp)
|
|
291
|
+
os.replace(tmp, out)
|
|
292
|
+
return {**base, "mode": "peak", "skipped": True, "reason": f"已达标(|增益| {abs(gain):.1f}dB < 0.5dB)",
|
|
293
|
+
"durOut": round(probe_dur(out), 2), "peakOut": peak_volume_db(out), "gain": round(gain, 2),
|
|
294
|
+
"sampleRate": probe_sr(out)}
|
|
295
|
+
if subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", src, "-af",
|
|
296
|
+
f"volume={gain:.2f}dB", tmp]).returncode != 0:
|
|
297
|
+
raise RuntimeError(f"归一失败: {src}")
|
|
298
|
+
os.replace(tmp, out)
|
|
299
|
+
return {**base, "mode": "peak", "skipped": False, "reason": "", "target": target,
|
|
300
|
+
"durOut": round(probe_dur(out), 2), "peakOut": peak_volume_db(out), "gain": round(gain, 2),
|
|
301
|
+
"sampleRate": probe_sr(out)}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ———————————————————————————— 验收 / A/B ————————————————————————————
|
|
305
|
+
|
|
306
|
+
def accept_probe(path, max_dur, min_dur):
|
|
307
|
+
dur = probe_dur(path)
|
|
308
|
+
mx, mn = vol_stats(path)
|
|
309
|
+
flags = []
|
|
310
|
+
if dur > max_dur:
|
|
311
|
+
flags.append("过长")
|
|
312
|
+
if dur < min_dur:
|
|
313
|
+
flags.append("过短")
|
|
314
|
+
if mx > -1.0:
|
|
315
|
+
flags.append("近削波")
|
|
316
|
+
if mx < -8:
|
|
317
|
+
flags.append("偏轻")
|
|
318
|
+
return {"path": path, "dur": round(dur, 2), "peak": mx, "mean": mn,
|
|
319
|
+
"flags": flags, "pass": not flags}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def abpage(groups, out_html):
|
|
323
|
+
"""候选音频拷贝到输出目录 ab_files/,html 用相对路径引用(file:// 下绝对路径跨文件媒体受限)"""
|
|
324
|
+
out_dir = os.path.dirname(os.path.abspath(out_html))
|
|
325
|
+
ab_files = os.path.join(out_dir, "ab_files")
|
|
326
|
+
os.makedirs(ab_files, exist_ok=True)
|
|
327
|
+
cards, total = [], 0
|
|
328
|
+
for gi, g in enumerate(groups, 1):
|
|
329
|
+
rows = []
|
|
330
|
+
for ci, cand in enumerate(g.get("candidates", []), 1):
|
|
331
|
+
total += 1
|
|
332
|
+
name = os.path.basename(cand)
|
|
333
|
+
rel = f"ab_files/g{gi:02d}c{ci}_{name}"
|
|
334
|
+
shutil.copy2(cand, os.path.join(out_dir, rel))
|
|
335
|
+
rows.append(f'<div class="track"><span class="tname">{html.escape(g.get("name", f"组{gi}"))} · '
|
|
336
|
+
f'候选{ci} · {html.escape(name)}</span>\n'
|
|
337
|
+
f'<audio controls preload="none" src="{html.escape(rel, quote=True)}"></audio></div>')
|
|
338
|
+
cards.append(f'<div class="card"><div class="key">{html.escape(g.get("name", f"组{gi}"))}'
|
|
339
|
+
f'<span class="note">({len(rows)} 候选)</span></div>'
|
|
340
|
+
+ "\n".join(rows) + '</div>')
|
|
341
|
+
page = ("<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"UTF-8\">\n"
|
|
342
|
+
"<title>音效 A/B 试听</title>\n<style>\n"
|
|
343
|
+
"body{font-family:-apple-system,\"PingFang SC\",sans-serif;background:#faf9f5;color:#2d3260;"
|
|
344
|
+
"padding:24px;max-width:880px;margin:0 auto;}\n"
|
|
345
|
+
"h1{font-size:19px;margin-bottom:4px}.sub{color:#7d8296;font-size:13px;margin-bottom:16px}\n"
|
|
346
|
+
".card{background:#fff;border:1px solid #f0efe9;border-radius:12px;padding:12px 16px;margin-bottom:10px}\n"
|
|
347
|
+
".card .key{font-family:ui-monospace,monospace;font-weight:700}.note{color:#7d8296;font-size:12px;margin-left:8px}\n"
|
|
348
|
+
".track{display:flex;align-items:center;gap:10px;font-size:13px;padding:3px 0}\n"
|
|
349
|
+
".tname{color:#7d8296;min-width:220px}audio{flex:1;height:32px}\n</style>\n</head>\n<body>\n"
|
|
350
|
+
"<h1>\U0001F3A7 音效 A/B 试听</h1>\n"
|
|
351
|
+
"<div class=\"sub\">逐候选试听后择优;页面零外部依赖,直接浏览器打开。</div>\n"
|
|
352
|
+
+ "\n".join(cards) + "\n</body>\n</html>\n")
|
|
353
|
+
with open(out_html, "w", encoding="utf-8") as f:
|
|
354
|
+
f.write(page)
|
|
355
|
+
return {"out": out_html, "groups": len(groups), "candidates": total,
|
|
356
|
+
"files": [os.path.join(ab_files, f) for f in sorted(os.listdir(ab_files))]}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ———————————————————————————— ops ————————————————————————————
|
|
360
|
+
|
|
361
|
+
def op_gen(payload):
|
|
362
|
+
prompt, out = payload["prompt"], payload["out"]
|
|
363
|
+
rolls, keep_rolls = int(payload.get("rolls", 3)), bool(payload.get("keepRolls", False))
|
|
364
|
+
do_trim = payload.get("trim", True)
|
|
365
|
+
os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
|
|
366
|
+
t0 = time.time()
|
|
367
|
+
model = load_model()
|
|
368
|
+
composed = model.compose_prompt(caption=prompt)
|
|
369
|
+
tmpdir = tempfile.mkdtemp(prefix="lmedia-sfx-")
|
|
370
|
+
try:
|
|
371
|
+
results, max_attempts = [], rolls + 2 # 全废自动加掷 ≤2
|
|
372
|
+
for i in range(max_attempts):
|
|
373
|
+
arr = generate_arr(model, composed)
|
|
374
|
+
path = os.path.join(tmpdir, f"r{i + 1}.wav")
|
|
375
|
+
sf.write(path, arr, 16000)
|
|
376
|
+
peak, rms = stats_db(arr)
|
|
377
|
+
snr = round(peak - rms, 1)
|
|
378
|
+
ok = peak >= GATE_PEAK and snr >= GATE_SNR
|
|
379
|
+
results.append({"roll": i + 1, "path": path, "peak": peak, "snr": snr, "pass": ok,
|
|
380
|
+
"reason": gate_reason(peak, snr)})
|
|
381
|
+
print(f" r{i + 1}: peak={peak} snr={snr:.0f} {'✓' if ok else '✗'}", file=sys.stderr, flush=True)
|
|
382
|
+
if i + 1 >= rolls and any(r["pass"] for r in results):
|
|
383
|
+
break
|
|
384
|
+
passing = [r for r in results if r["pass"]]
|
|
385
|
+
best = max(passing, key=lambda r: r["snr"]) if passing else max(results, key=lambda r: r["peak"])
|
|
386
|
+
if do_trim:
|
|
387
|
+
a, b, _cut = signature_window(best["path"], probe_dur(best["path"]))
|
|
388
|
+
build_two_pass(best["path"], out, a, b)
|
|
389
|
+
dur = probe_dur(out)
|
|
390
|
+
else:
|
|
391
|
+
shutil.copy2(best["path"], out)
|
|
392
|
+
dur = probe_dur(out)
|
|
393
|
+
if keep_rolls:
|
|
394
|
+
keep_dir = out.rsplit(".", 1)[0] + ".rolls"
|
|
395
|
+
os.makedirs(keep_dir, exist_ok=True)
|
|
396
|
+
for r in results:
|
|
397
|
+
shutil.copy2(r["path"], os.path.join(keep_dir, f"r{r['roll']}.wav"))
|
|
398
|
+
emit({"out": out, "rolls": len(results), "bestRoll": best["roll"], "peak": best["peak"],
|
|
399
|
+
"snr": best["snr"], "dur": round(dur, 2), "gatePassed": bool(passing),
|
|
400
|
+
"trimmed": bool(do_trim), "genSec": round(time.time() - t0, 1)})
|
|
401
|
+
finally:
|
|
402
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def op_batch(payload):
|
|
406
|
+
items = payload["items"]
|
|
407
|
+
out_dir = payload["outDir"]
|
|
408
|
+
rolls, keep_rolls = int(payload.get("rolls", 3)), bool(payload.get("keepRolls", False))
|
|
409
|
+
do_trim = payload.get("trim", True)
|
|
410
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
411
|
+
t0 = time.time()
|
|
412
|
+
model = load_model()
|
|
413
|
+
report_items = []
|
|
414
|
+
for it in items:
|
|
415
|
+
key, prompt = it["key"], it["prompt"]
|
|
416
|
+
t1 = time.time()
|
|
417
|
+
composed = model.compose_prompt(caption=prompt)
|
|
418
|
+
tmpdir = tempfile.mkdtemp(prefix=f"lmedia-sfx-{key}-")
|
|
419
|
+
try:
|
|
420
|
+
candidates, max_attempts = [], rolls + 2
|
|
421
|
+
for i in range(max_attempts):
|
|
422
|
+
arr = generate_arr(model, composed)
|
|
423
|
+
path = os.path.join(tmpdir, f"r{i + 1}.wav")
|
|
424
|
+
sf.write(path, arr, 16000)
|
|
425
|
+
peak, rms = stats_db(arr)
|
|
426
|
+
snr = round(peak - rms, 1)
|
|
427
|
+
ok = peak >= GATE_PEAK and snr >= GATE_SNR
|
|
428
|
+
candidates.append({"roll": i + 1, "path": path, "peak": peak, "snr": snr, "pass": ok,
|
|
429
|
+
"reason": gate_reason(peak, snr)})
|
|
430
|
+
print(f" · {key} r{i + 1}: peak={peak} snr={snr:.0f} {'✓' if ok else '✗'}",
|
|
431
|
+
file=sys.stderr, flush=True)
|
|
432
|
+
if i + 1 >= rolls and any(c["pass"] for c in candidates):
|
|
433
|
+
break # 掷满基础次数且有合格即收;全废用加掷续命
|
|
434
|
+
passing = [c for c in candidates if c["pass"]]
|
|
435
|
+
best = max(passing, key=lambda c: c["snr"]) if passing else max(candidates, key=lambda c: c["peak"])
|
|
436
|
+
winner_path = os.path.join(out_dir, f"{key}.best.wav")
|
|
437
|
+
if do_trim:
|
|
438
|
+
a, b, _cut = signature_window(best["path"], probe_dur(best["path"]))
|
|
439
|
+
build_two_pass(best["path"], winner_path, a, b)
|
|
440
|
+
else:
|
|
441
|
+
shutil.copy2(best["path"], winner_path)
|
|
442
|
+
if keep_rolls: # 掷样落盘后报告里的候选路径指向落盘产物(审计可回放)
|
|
443
|
+
for c in candidates:
|
|
444
|
+
kept = os.path.join(out_dir, f"{key}.r{c['roll']}.wav")
|
|
445
|
+
shutil.copy2(c["path"], kept)
|
|
446
|
+
c["path"] = kept
|
|
447
|
+
report_items.append({
|
|
448
|
+
"key": key, "prompt": prompt, "candidates": candidates,
|
|
449
|
+
"winner": {"path": winner_path, "score": best["snr"] if passing else best["peak"]},
|
|
450
|
+
"anyPass": bool(passing), "genSec": round(time.time() - t1, 1)})
|
|
451
|
+
print(f"✓ {key}: best=r{best['roll']} score={best['snr'] if passing else best['peak']} "
|
|
452
|
+
f"{'PASS' if passing else '⚠️全废待人工'}({time.time() - t1:.0f}s)", file=sys.stderr, flush=True)
|
|
453
|
+
finally:
|
|
454
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
455
|
+
report = {"generatedAt": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
456
|
+
"rolls": rolls, "items": report_items}
|
|
457
|
+
with open(os.path.join(out_dir, "report.json"), "w", encoding="utf-8") as f:
|
|
458
|
+
json.dump(report, f, ensure_ascii=False, indent=1)
|
|
459
|
+
n_pass = sum(1 for it in report_items if it["anyPass"])
|
|
460
|
+
print(f"完成: {n_pass}/{len(report_items)} key 有合格生成 → {os.path.join(out_dir, 'report.json')}",
|
|
461
|
+
file=sys.stderr, flush=True)
|
|
462
|
+
emit({"dir": os.path.abspath(out_dir), "report": report,
|
|
463
|
+
"items": [{"key": it["key"], "winner": it["winner"], "anyPass": it["anyPass"]} for it in report_items],
|
|
464
|
+
"genSec": round(time.time() - t0, 1)})
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def op_trim(payload):
|
|
468
|
+
items = [trim_file(f, thresh_db=float(payload.get("threshDb", -35.0)),
|
|
469
|
+
pad=float(payload.get("pad", PAD))) for f in payload["files"]]
|
|
470
|
+
emit({"items": items})
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def op_recut(payload):
|
|
474
|
+
items = [recut_file(f, thresh_db=float(payload.get("threshDb", -40.0)),
|
|
475
|
+
min_d=float(payload.get("minSilence", 0.15)),
|
|
476
|
+
cap=float(payload.get("cap", 3.5))) for f in payload["files"]]
|
|
477
|
+
emit({"items": items})
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def op_normalize(payload):
|
|
481
|
+
out_dir = payload.get("outDir")
|
|
482
|
+
if out_dir:
|
|
483
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
484
|
+
items = [normalize_file(f, target=float(payload.get("target", PEAK_DB)),
|
|
485
|
+
loudness=payload.get("loudness"), out_dir=out_dir) for f in payload["files"]]
|
|
486
|
+
emit({"items": items})
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def op_accept(payload):
|
|
490
|
+
items = [accept_probe(f, float(payload.get("maxDur", 4.0)), float(payload.get("minDur", 0.4)))
|
|
491
|
+
for f in payload["files"]]
|
|
492
|
+
emit({"items": items})
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def op_abpage(payload):
|
|
496
|
+
emit(abpage(payload["groups"], payload["out"]))
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def op_probe(payload):
|
|
500
|
+
items = []
|
|
501
|
+
for f in payload["files"]:
|
|
502
|
+
peak, mean = vol_stats(f)
|
|
503
|
+
items.append({"path": f, "dur": round(probe_dur(f), 3), "peak": peak, "mean": mean,
|
|
504
|
+
"sampleRate": probe_sr(f)})
|
|
505
|
+
emit({"items": items})
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def emit(obj):
|
|
509
|
+
"""结果行必须是单行 compact JSON(内部禁换行;TS 取 stdout 末行解析)"""
|
|
510
|
+
print(json.dumps(obj, ensure_ascii=False, separators=(",", ":")), flush=True)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
OPS = {"gen": op_gen, "batch": op_batch, "trim": op_trim, "recut": op_recut,
|
|
514
|
+
"normalize": op_normalize, "accept": op_accept, "abpage": op_abpage, "probe": op_probe}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def main():
|
|
518
|
+
payload = json.loads(sys.argv[1])
|
|
519
|
+
op = payload.get("op") or "gen" # 无 op 视为 gen,向后兼容
|
|
520
|
+
if op not in OPS:
|
|
521
|
+
print(f"未知 op: {op}", file=sys.stderr)
|
|
522
|
+
sys.exit(2)
|
|
523
|
+
OPS[op](payload)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
if __name__ == "__main__":
|
|
527
|
+
main()
|