music-lens 0.1.2 → 0.1.4
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/README.md +4 -0
- package/bin/music-lens.mjs +57 -0
- package/package.json +1 -1
- package/scripts/analyze.py +52 -0
package/README.md
CHANGED
|
@@ -23,10 +23,14 @@ music-lens analyze song.mp3
|
|
|
23
23
|
```powershell
|
|
24
24
|
$node = "C:\Users\86173\Documents\deepseek-harness\default-workspace\snowluma\SnowLuma-v2.2.1\node.exe"
|
|
25
25
|
& $node .\bin\music-lens.mjs doctor # 体检:python / 依赖 / 模型
|
|
26
|
+
& $node .\bin\music-lens.mjs spectrogram "song.mp3" # 10 秒出图,只要 ffmpeg
|
|
26
27
|
& $node .\bin\music-lens.mjs separate "song.mp3" # 分轨
|
|
27
28
|
& $node .\bin\music-lens.mjs analyze "song.mp3" --model medium
|
|
28
29
|
```
|
|
29
30
|
|
|
31
|
+
`spectrogram` 是**零依赖**的快速预览:频谱图 + 波形图,不装 Python、不下模型,
|
|
32
|
+
只要机器上有 ffmpeg(`MUSIC_LENS_FFMPEG` 可指定路径)。
|
|
33
|
+
|
|
30
34
|
`setup` 会自己装 python 依赖并从 hf-mirror 下模型,**默认只下 small(461 MB)**:
|
|
31
35
|
|
|
32
36
|
```powershell
|
package/bin/music-lens.mjs
CHANGED
|
@@ -77,6 +77,22 @@ function findPython() {
|
|
|
77
77
|
return null
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// ffmpeg is optional: only the zero-dependency `spectrogram` command needs it.
|
|
81
|
+
function findFfmpeg() {
|
|
82
|
+
const cands = []
|
|
83
|
+
if (process.env.MUSIC_LENS_FFMPEG) cands.push(process.env.MUSIC_LENS_FFMPEG)
|
|
84
|
+
cands.push('ffmpeg')
|
|
85
|
+
if (process.platform === 'win32') {
|
|
86
|
+
cands.push(path.join(os.homedir(), '.dsh', 'bili-whale', 'bin', 'ffmpeg.exe'))
|
|
87
|
+
cands.push('C:\\ffmpeg\\bin\\ffmpeg.exe')
|
|
88
|
+
}
|
|
89
|
+
for (const c of cands) {
|
|
90
|
+
const r = spawnSync(c, ['-version'], { encoding: 'utf8' })
|
|
91
|
+
if (r.status === 0) return c
|
|
92
|
+
}
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
function run(cmd, args, opts = {}) {
|
|
81
97
|
const r = spawnSync(cmd, args, { stdio: 'inherit', env: { ...process.env, ...(opts.env || {}) } })
|
|
82
98
|
return r.status ?? 1
|
|
@@ -221,6 +237,44 @@ async function cmdAnalyze(args) {
|
|
|
221
237
|
return run(py, argv)
|
|
222
238
|
}
|
|
223
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Zero-dependency quick look: needs only ffmpeg, no python, no models.
|
|
242
|
+
* Produces a mel-ish spectrogram and a waveform picture.
|
|
243
|
+
*/
|
|
244
|
+
async function cmdSpectrogram(args) {
|
|
245
|
+
const audio = args._[0]
|
|
246
|
+
if (!audio) die('usage: music-lens spectrogram <audio> [--out DIR] [--size 1600x900]')
|
|
247
|
+
const src = path.resolve(audio)
|
|
248
|
+
if (!fs.existsSync(src)) die(`audio not found: ${src}`)
|
|
249
|
+
|
|
250
|
+
const ff = findFfmpeg()
|
|
251
|
+
if (!ff) {
|
|
252
|
+
die('ffmpeg not found.\n install: winget install Gyan.FFmpeg\n or set: MUSIC_LENS_FFMPEG=C:\\path\\to\\ffmpeg.exe')
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const outDir = path.resolve(args.out || path.join(path.dirname(src), path.parse(src).name + '_look'))
|
|
256
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
257
|
+
const size = args.size || '1600x900'
|
|
258
|
+
|
|
259
|
+
log(`ffmpeg : ${ff}`)
|
|
260
|
+
log(`output : ${outDir}`)
|
|
261
|
+
|
|
262
|
+
const spec = path.join(outDir, 'spectrum.png')
|
|
263
|
+
const wave = path.join(outDir, 'waveform.png')
|
|
264
|
+
|
|
265
|
+
const st1 = run(ff, ['-y', '-hide_banner', '-loglevel', 'error', '-i', src,
|
|
266
|
+
'-lavfi', `showspectrumpic=s=${size}:legend=1:scale=log`, '-frames:v', '1', spec])
|
|
267
|
+
log(` spectrum: ${st1 === 0 ? spec : 'FAILED'}`)
|
|
268
|
+
|
|
269
|
+
const st2 = run(ff, ['-y', '-hide_banner', '-loglevel', 'error', '-i', src,
|
|
270
|
+
'-lavfi', `showwavespic=s=${size}:split_channels=0:colors=#4a7ebb`, '-frames:v', '1', wave])
|
|
271
|
+
log(` waveform: ${st2 === 0 ? wave : 'FAILED'}`)
|
|
272
|
+
|
|
273
|
+
if (st1 !== 0 && st2 !== 0) return 1
|
|
274
|
+
log('\nquick look done. for lyrics / key / tempo / numbers, run: music-lens analyze "<same file>"')
|
|
275
|
+
return 0
|
|
276
|
+
}
|
|
277
|
+
|
|
224
278
|
async function cmdSeparate(args) {
|
|
225
279
|
const audio = args._[0]
|
|
226
280
|
if (!audio) die('usage: music-lens separate <audio> [--out DIR]')
|
|
@@ -242,6 +296,8 @@ function usage() {
|
|
|
242
296
|
[--model medium] small | medium | large-v3 | path
|
|
243
297
|
[--lang auto] language code or auto
|
|
244
298
|
[--out DIR] output directory
|
|
299
|
+
music-lens spectrogram <audio> quick look: spectrum + waveform (ffmpeg only,
|
|
300
|
+
[--size 1600x900] no python, no models, ~10 seconds)
|
|
245
301
|
music-lens separate <audio> split vocals / instrumental (demucs)
|
|
246
302
|
|
|
247
303
|
Outputs per song: 00_summary.md, 01_lyrics.txt, 02_transcript.json,
|
|
@@ -257,6 +313,7 @@ const map = {
|
|
|
257
313
|
setup: cmdSetup,
|
|
258
314
|
analyze: cmdAnalyze,
|
|
259
315
|
separate: cmdSeparate,
|
|
316
|
+
spectrogram: cmdSpectrogram,
|
|
260
317
|
}
|
|
261
318
|
|
|
262
319
|
if (cmd === 'help' || cmd === '--help' || cmd === '-h') { usage(); process.exit(0) }
|
package/package.json
CHANGED
package/scripts/analyze.py
CHANGED
|
@@ -16,6 +16,7 @@ Usage:
|
|
|
16
16
|
import argparse
|
|
17
17
|
import json
|
|
18
18
|
import os
|
|
19
|
+
import re
|
|
19
20
|
import sys
|
|
20
21
|
import warnings
|
|
21
22
|
from pathlib import Path
|
|
@@ -47,6 +48,35 @@ MINOR = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.
|
|
|
47
48
|
NOTES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
|
48
49
|
NOTES_ZH = ["C", "升C", "D", "升D", "E", "F", "升F", "G", "升G", "A", "升A", "B"]
|
|
49
50
|
|
|
51
|
+
# Whisper famously fabricates credits/subtitles when the audio carries no speech
|
|
52
|
+
# (intros, outros, instrumental breaks). These are stock phrases from its training
|
|
53
|
+
# data -- e.g. "作词 李宗盛" is a near-universal Chinese lyric-video credit line.
|
|
54
|
+
HALLUCINATION_PATTERNS = [
|
|
55
|
+
r"作\s*[詞词]\s*[::]?\s*\S{2,4}\s*作曲", # "作词 X 作曲 Y"
|
|
56
|
+
r"作词曲", # "作词曲 李宗盛"
|
|
57
|
+
r"[詞词曲]\s*[::]\s*李宗盛",
|
|
58
|
+
r"李宗盛",
|
|
59
|
+
r"字幕\s*(by|BY|由|组|組|提供|制作|製作)",
|
|
60
|
+
r"中文字幕",
|
|
61
|
+
r"字幕组",
|
|
62
|
+
r"优优独播剧场",
|
|
63
|
+
r"YoYo Television",
|
|
64
|
+
r"Zither Harp",
|
|
65
|
+
r"请不吝点赞",
|
|
66
|
+
r"点赞.{0,4}订阅",
|
|
67
|
+
r"明镜与点点",
|
|
68
|
+
r"^\s*谢谢(观看|收看|收听)",
|
|
69
|
+
r"^\s*Thanks? for watching",
|
|
70
|
+
r"^\s*Subtitles?\s*by",
|
|
71
|
+
r"^\s*Amara\.org",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
_HALLUCINATION_RE = re.compile("|".join(HALLUCINATION_PATTERNS), re.IGNORECASE)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_hallucination(text: str) -> bool:
|
|
78
|
+
return bool(_HALLUCINATION_RE.search(text))
|
|
79
|
+
|
|
50
80
|
|
|
51
81
|
def estimate_key(chroma_mean):
|
|
52
82
|
best = (-2.0, None)
|
|
@@ -68,6 +98,8 @@ def main():
|
|
|
68
98
|
ap.add_argument("--device", default="cpu")
|
|
69
99
|
ap.add_argument("--skip-whisper", action="store_true")
|
|
70
100
|
ap.add_argument("--vad", action="store_true", help="enable VAD (good for speech, bad for singing)")
|
|
101
|
+
ap.add_argument("--keep-hallucinations", action="store_true",
|
|
102
|
+
help="keep fabricated credit/subtitle lines instead of dropping them")
|
|
71
103
|
args = ap.parse_args()
|
|
72
104
|
|
|
73
105
|
src = Path(args.audio).expanduser().resolve()
|
|
@@ -131,6 +163,7 @@ def main():
|
|
|
131
163
|
# ------------------------------------------------------------- transcription
|
|
132
164
|
segments = []
|
|
133
165
|
info = {}
|
|
166
|
+
dropped = []
|
|
134
167
|
if not args.skip_whisper:
|
|
135
168
|
print(f"[2/6] transcribing with faster-whisper '{args.model}' (lang={args.lang}) ...")
|
|
136
169
|
from faster_whisper import WhisperModel
|
|
@@ -170,6 +203,21 @@ def main():
|
|
|
170
203
|
if len(deduped) != len(segments):
|
|
171
204
|
print(f" collapsed duplicates: {len(segments)} -> {len(deduped)} lines")
|
|
172
205
|
segments = deduped
|
|
206
|
+
|
|
207
|
+
# drop fabricated credits / subtitles (whisper filler on instrumental parts)
|
|
208
|
+
dropped = []
|
|
209
|
+
if not args.keep_hallucinations:
|
|
210
|
+
kept = []
|
|
211
|
+
for seg in segments:
|
|
212
|
+
if is_hallucination(seg["text"]):
|
|
213
|
+
dropped.append(seg)
|
|
214
|
+
print(f" dropped hallucination @{seg['start']:6.1f}s {seg['text']}")
|
|
215
|
+
else:
|
|
216
|
+
kept.append(seg)
|
|
217
|
+
if kept:
|
|
218
|
+
segments = kept
|
|
219
|
+
elif dropped:
|
|
220
|
+
print(" (all segments looked like hallucinations -- keeping them)")
|
|
173
221
|
else:
|
|
174
222
|
print("[2/6] whisper skipped")
|
|
175
223
|
|
|
@@ -253,6 +301,7 @@ def main():
|
|
|
253
301
|
"vocal_time_ratio": round(sing_ratio, 2),
|
|
254
302
|
"sections": sections,
|
|
255
303
|
"segments_count": len(segments),
|
|
304
|
+
"dropped_hallucinations": len(dropped),
|
|
256
305
|
}
|
|
257
306
|
with open(out / "05_summary.json", "w", encoding="utf-8") as f:
|
|
258
307
|
json.dump(summary, f, ensure_ascii=False, indent=2)
|
|
@@ -269,6 +318,9 @@ def main():
|
|
|
269
318
|
L.append(f"- 动态范围:{lo_d} dB ~ {hi_d} dB(相对最响处)")
|
|
270
319
|
L.append(f"- 人声占比:约 **{sing_ratio*100:.0f}%** 时长检测到人声")
|
|
271
320
|
L.append(f"- 歌词字数:约 {total_lyrics} 字,{len(segments)} 句\n")
|
|
321
|
+
if dropped:
|
|
322
|
+
L.append(f"> 已过滤 {len(dropped)} 条疑似幻觉(片头/署名/字幕类)。"
|
|
323
|
+
f"想保留请加 `--keep-hallucinations`。\n")
|
|
272
324
|
|
|
273
325
|
L.append("## 段落结构(按响度自动切分)\n")
|
|
274
326
|
for s in sections:
|