PrimeBot 启元 Q1 & T1 技能整活工作台
OFFICIAL HIGH-LEVEL SKILL · 联网搜歌与卡点蹦迪

《官方点歌台》高阶源码精讲

383 行全链路展示:口癖剥离点歌 ➔ 断点续传下载 ➔ ffmpeg 转 wav ➔ 自相关算 BPM ➔ 节拍网格伴舞

😜 调皮课代表大白话划重点(30 秒读懂核心)
这是官方已经上架 PrimeHub 的炫技天花板!如果你想挑战高难度的音频伴舞,这页源码里全是大宝贝:它不仅用纯 Python 标准库(wave + struct)手搓了音频能量包络与自相关 BPM 节拍检测,还总结出了真机实测的三条仲裁铁律(说话会杀音乐、interrupt=True 会杀音乐、动作提前 0.3 秒排队卡点)!

🔍 核心机关与修改指南

🎙️ 智能剥口癖点歌 user_request()

不管是喊「帮我放来一首稻香」还是读 media/songlist.txt 或抓网易云热歌榜,层层兜底绝不冷场。

⚡ 专治 CDN 掐网三连招 download_song()

只要 128k 小体积音频 + 慢速自杀重启 + curl -C - 断点续传,下完立刻调机上 ffmpeg 转成单声道 22050Hz 的 .wav。

🕺 节拍网格自由舞池 analyze_beats()

算出每拍秒数后,每 4 拍(一小节)从 MOVE_POOL 随机抽一个不重样的上半身动作,提前 0.30 秒发 interrupt=False 卡在拍子上起跳!

💻 完整源码与配置内容(共 383 行 · 支持行号定位与一键复制)

📄 共 383 行
1#!/usr/bin/env python3
2"""jukebox_q1 —— 点歌台(Q1·联网点歌 + 任意歌自动卡点自由舞)
3
4流程:报幕(TTS)→ 联网搜歌(gdstudio→netease)→ 下载 → ffmpeg 转 wav(mono/22050)
5 → 能量包络+自相关离线节拍分析 → 音乐起(锚点=播放态翻 1)
6 → 舞步在节拍网格上排队(anim interrupt=False,与音乐共存)→ 音乐停谢幕。
7
8真机实测的三条仲裁铁律(编排全靠它们):
9 ① TTS 抢占杀音乐,且 TTS 结束后音乐不自动恢复 → 报幕/谢幕词全部在音乐外说
10 ② play_animation(interrupt=True) 杀音乐 → 卡点舞全程禁用
11 ③ play_animation(interrupt=False) 与音乐共存(动作在运动队列里依次执行)
12 → 动作发起提前量 LAT_ANIM=0.30s(请求→task_engine 真正创建动画的实测延迟)
13
14音频格式:板载只吃 wav(mp3 放了没声);ffmpeg 转码 -ac 1 -ar 22050。
15"""
16
17import json
18import os
19import random
20import subprocess
21import sys
22import time
23import urllib.parse
24import urllib.request
25
26# ★★★ 现场开发者改这里 ★★★
27# 点歌优先级(从高到低,取到第一个就点它):
28# ① 用户语音/启动参数:机器人把用户说的话传进来(argv[1:] 每词一个,或环境变量 JUKEBOX_REQUEST),
29# 例如「放稻香」→ 机器人传"放稻香" → 代码剥掉"放/来一首/我想听"等口癖 → 搜"稻香"
30# ② 固定歌单:技能包 media/songlist.txt(一行一首)或下面 PLAYLIST
31# ③ 兜底:联网热歌榜随机抽(纯 API,不写死任何歌)
32# 搜不到/下不动自动换下一首重试;已播记录 /tmp/jukebox_history.txt
33PLAYLIST = [] # 常驻歌单(可选);空 = 语音参数 → 热歌榜
34REQUEST_ENV = "JUKEBOX_REQUEST" # 机器人传歌名的环境变量名(和 argv 二选一)
35LAT_ANIM = 0.30 # anim 请求→真正起跳的实测延迟(提前发)
36LAT_MUSIC = 0.06 # 音频状态翻 1 与真实出声的差(实测 +0.056s)
37INTRO = "收到点歌!我去网上找这首歌,找到就马上开跳!"
38SONG_ANNOUNCE = "歌曲就位!歌名 %s,节奏 %d 拍每分钟,开跳!"
39OUTRO = "这首歌跳完了,还想听什么,尽管点!"
40MAX_DANCE_SEC = 90 # 最多跳这么久就收尾(保护电机)
41SEARCH_API = "https://music-api.gdstudio.xyz/api.php"
42HOT_CHART_API = "https://music.163.com/api/search/get?s=%E7%83%AD%E9%97%A8%E6%AD%8C%E6%9B%B2&type=1&limit=10" # 网易云「热门歌曲」搜索
43HISTORY = "/tmp/jukebox_history.txt"
44WAV = "/tmp/jukebox_song.wav"
45
46# 自由舞步池(全部 upper_body,室内模式安全):持续随机抽取,舞蹈类加权
47MOVE_POOL = [
48 3046, 3047, 3048, # 舞蹈A/B/C —— 主力舞步
49 3008, # 加油
50 3001, 3002, 3003, 3004, # 招手/握手/碰拳/挥手
51 3005, 3007, # 开心/比心
52 3010, # 伸懒腰
53 3046, 3046, 3047, # 舞蹈加权(更常跳)
54]
55
56
57def next_move(prev):
58 """持续随机:小节级随机抽取,避免和上一个小节重复。"""
59 pool = [m for m in MOVE_POOL if m != prev]
60 return random.choice(pool)
61
62
63# ---------- 联网搜歌+下载 ----------
64def http_json(url, tries=3):
65 for i in range(tries):
66 try:
67 req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
68 return json.load(urllib.request.urlopen(req, timeout=20))
69 except Exception as e:
70 if i == tries - 1:
71 raise
72 time.sleep(2)
73
74
75def find_song(name):
76 q = urllib.parse.quote(name)
77 songs = http_json(SEARCH_API + "?types=search&source=netease&name=" + q + "&count=5")
78 for s in songs:
79 if s.get("id") and s.get("url_id"):
80 return s
81 return songs[0] if songs else None
82
83
84# ---------- 点歌:① 语音/启动参数 ② 固定歌单 ③ 热歌榜兜底 ----------
85# 机器人侧把用户说的话传进来的两条通道(都试):
86# a) 启动参数 argv:python main.py 稻香 / "放稻香"
87# b) 环境变量 JUKEBOX_REQUEST="放稻香"
88VOICE_FILLERS = ("放", "来一首", "我想听", "我要听", "帮我放", "给我放", "点一首", "播一首",
89 "唱一首", "跳一首", "放首", "来首", "听首", "点歌", "跳个舞", "点个",
90 "请放", "想听", "音乐", "首歌", "点首歌")
91
92
93def user_request():
94 """剥掉口癖取歌名:「放稻香」→「稻香」;「放 周杰伦 晴天」→「周杰伦 晴天」。
95 剥完只剩口癖(「点首歌」)→ 没点歌,返回 None。"""
96 words = [w for w in sys.argv[1:] if w.strip()]
97 env = os.environ.get(REQUEST_ENV, "").strip()
98 if env and not words:
99 words = [env]
100 if not words:
101 return None
102 text = " ".join(words).strip()
103 # 反复剥口癖前缀(「帮我放来一首晴天」这种叠加也要剥干净;允许剥到空=全是口癖)
104 changed = True
105 while changed:
106 changed = False
107 for f in VOICE_FILLERS:
108 if text.startswith(f) and text:
109 text = text[len(f):].lstrip("一首个的 ")
110 changed = True
111 text = text.strip()
112 return text or None
113
114
115def load_songlist():
116 """固定歌单(可选):技能包 media/songlist.txt + PLAYLIST。"""
117 candidates = []
118 here = os.path.dirname(os.path.abspath(__file__))
119 for rel in ("media/songlist.txt", "../media/songlist.txt", "../../media/songlist.txt"):
120 p = os.path.join(here, rel)
121 if os.path.exists(p):
122 lines = [l.strip() for l in open(p, encoding="utf-8") if l.strip() and not l.startswith("#")]
123 candidates += lines
124 candidates += [p for p in PLAYLIST if p.strip()]
125 return candidates
126
127
128def hot_chart():
129 """兜底歌单:联网热歌榜。"""
130 try:
131 d = http_json(HOT_CHART_API)
132 return ["%s %s" % (s.get("name"), "/".join(a["name"] for a in s.get("artists", [])))
133 for s in d.get("result", {}).get("songs", [])]
134 except Exception:
135 return []
136
137
138def pick_song():
139 """点歌决策:① 用户说的歌名(原样优先,绝不随机换)→ ② 固定歌单随机 → ③ 热歌榜随机。
140 返回 (歌名, 来源标记)。①/② 记历史防重样;① 不挑重样(用户点名就是要这首)。"""
141 req = user_request()
142 if req:
143 return req, "user"
144 songs = load_songlist()
145 source = "list"
146 if not songs:
147 songs = hot_chart()
148 source = "chart"
149 if not songs:
150 return None, "none"
151 try:
152 played = set(l.strip() for l in open(HISTORY, encoding="utf-8"))
153 except OSError:
154 played = set()
155 fresh = [s for s in songs if s not in played]
156 pool = fresh or songs # 全播过了就允许重复
157 song = random.choice(pool)
158 try:
159 with open(HISTORY, "a", encoding="utf-8") as f:
160 f.write(song + "\n")
161 except OSError:
162 pass
163 return song, source
164
165
166def download_song(song):
167 """下载+转 wav。提速三招(实测 CDN 会随机掐连接、整首从 145KB/s 掉到 0B/s 停死):
168 ① 先要 128k mp3(无损 flac 一首 100MB+;跳舞音质 128k 足够,小 30 倍)
169 ② 慢速自杀 + 断点续传:--speed-limit 12s<2KB/s 就主动断,-C - 从断点续(不从头再下)
170 ③ 外层续传循环:单次 curl 限 45s,断了循环补,最多 6 轮——10 次实测 8 次一次成、2 次靠续传补齐,0 失败
171 """
172 import os
173 d = http_json(SEARCH_API + "?types=url&source=netease&id=" + str(song["url_id"]) + "&br=128")
174 if not d.get("url"):
175 d = http_json(SEARCH_API + "?types=url&source=netease&id=" + str(song["url_id"]))
176 if not d.get("url"):
177 return None
178 want = d.get("size") or 0
179 url = d["url"]
180 raw = "/tmp/jukebox_raw"
181 # ②+③:限速自杀 → 断点续传 → 外层循环补
182 for _ in range(6):
183 subprocess.run(
184 ["curl", "-s", "-A", "Mozilla/5.0", "-o", raw, "-C", "-",
185 "--speed-limit", "2048", "--speed-time", "12",
186 "--connect-timeout", "6", "--max-time", "45", url],
187 timeout=50)
188 got = os.path.getsize(raw) if os.path.exists(raw) else 0
189 if want and got >= want:
190 break
191 if not want and got > 1024:
192 # API 没给 size:下到了且这轮没断(curl 正常返回)就收
193 break
194 got = os.path.getsize(raw) if os.path.exists(raw) else 0
195 if got < 1024 or (want and got < want):
196 try:
197 os.remove(raw)
198 except OSError:
199 pass
200 return None
201 # 转 wav(板载只吃 wav)
202 subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", raw,
203 "-ac", "1", "-ar", "22050", WAV], timeout=120)
204 return WAV if os.path.exists(WAV) else None
205
206
207# ---------- 离线节拍分析(纯 stdlib) ----------
208def analyze_beats(wav_path):
209 import struct
210 import wave
211 w = wave.open(wav_path)
212 sr, n = w.getframerate(), w.getnframes()
213 ch = w.getnchannels()
214 raw = w.readframes(n)
215 samples = struct.unpack("<%dh" % (len(raw) // 2), raw)
216 mono = [samples[i] for i in range(0, len(samples), ch)]
217 # 能量包络 20ms
218 hop = int(sr * 0.02)
219 env = []
220 for i in range(0, len(mono) - hop, hop):
221 seg = mono[i:i + hop]
222 env.append(sum(abs(x) for x in seg) / hop)
223 if not env:
224 return None
225 # onset 强度 = 一阶差分(正部分)
226 diff = [max(0.0, env[i] - env[i - 1]) for i in range(1, len(env))]
227 mx = max(diff) or 1.0
228 diff = [d / mx for d in diff]
229 # 自相关找拍周期(0.3~1.2s = 50~200BPM)
230 def mean(x):
231 return sum(x) / len(x)
232 m = mean(diff)
233 cen = [x - m for x in diff]
234 best_lag, best_v = None, -1e18
235 for lag in range(int(0.3 / 0.02), int(1.2 / 0.02)):
236 s = sum(cen[i] * cen[i + lag] for i in range(0, len(cen) - lag, 3))
237 v = s / (len(cen) - lag)
238 if v > best_v:
239 best_v, best_lag = v, lag
240 beat = best_lag * 0.02
241 # 首拍 = 第一个强 onset
242 thresh = 0.25
243 first = None
244 for i, d in enumerate(diff):
245 if d > thresh:
246 first = (i + 1) * 0.02
247 break
248 dur = n / sr
249 return {"beat": beat, "first": first or 1.0, "dur": dur, "bpm": 60.0 / beat}
250
251
252# ---------- 音频状态 ----------
253def audio():
254 s = robot.get_audio_play_state()
255 if not isinstance(s, dict):
256 return ("?", 0)
257 return (s.get("pkg_name"), s.get("state", {}).get("value", 0))
258
259
260def music_on():
261 p, v = audio()
262 return p == "interaction_slave" and v == 1
263
264
265def music_really_over(anchor, dur):
266 """音频状态对长音乐播放会 1↔2 抖动(实测),不能信瞬时值。
267 判定标准:超过预计结束时刻 anchor+dur-0.5,或连续 3.5s 状态全空。"""
268 if time.time() > anchor + dur - 0.5:
269 return True
270 quiet = 0.0
271 t0 = time.time()
272 while time.time() - t0 < 3.5:
273 time.sleep(0.25)
274 if audio() == ("interaction_slave", 1):
275 return False
276 return True
277
278
279def speak(text):
280 robot.play_tts(text)
281 deadline = time.time() + max(len(text) * 0.35, 3.0) + 8.0
282 time.sleep(0.3)
283 while time.time() < deadline and audio() != ("tts", 1):
284 time.sleep(0.1)
285 while time.time() < deadline and audio() == ("tts", 1):
286 time.sleep(0.1)
287
288
289# ---------- 演出 ----------
290speak(INTRO)
291requested, source = pick_song()
292print("PICK: %s (source=%s)" % (requested, source), flush=True)
293if not requested:
294 speak("网上没拉到歌单,稍后再试一次!")
295 raise SystemExit("NO_SONGLIST")
296# 用户点名的歌搜不到/下不动 → 报歉 + 从热歌榜换一首继续(不静默换:点名失败要告诉用户)
297song = None
298path = None
299tried = [requested]
300for _ in range(3):
301 song = find_song(requested)
302 if not song:
303 print("SEARCH_MISS: %s" % requested, flush=True)
304 else:
305 print("SONG: %s - %s" % (song.get("name"), "/".join(song.get("artist", []) or [])), flush=True)
306 path = download_song(song)
307 if path:
308 break
309 print("DOWNLOAD_RETRY", flush=True)
310 if source == "user":
311 speak("这首 %s 没找到能下载的版本,我换首热门的接着跳!" % requested)
312 source = "chart"
313 songs = [s for s in hot_chart() if s not in tried]
314 if not songs:
315 songs = [s for s in load_songlist() or hot_chart() if s not in tried]
316 if not songs:
317 break
318 requested = random.choice(songs)
319 tried.append(requested)
320 print("PICK: %s (source=fallback)" % requested, flush=True)
321if not path:
322 speak("这首歌下载失败了,换一首试试!")
323 raise SystemExit("DOWNLOAD_FAILED")
324
325info = analyze_beats(path)
326print("BEATS: %s" % info, flush=True)
327if not info:
328 speak("这首歌节奏太怪,我先随便跳了!")
329 beat, first = 0.5, 1.0
330else:
331 beat, first = info["beat"], info["first"]
332
333speak(SONG_ANNOUNCE % (song.get("name"), round(60.0 / beat)))
334time.sleep(0.5)
335robot.play_light(color=1, mode=3)
336
337robot.play_audio_file(path)
338t0 = time.time()
339anchor = None
340while time.time() - t0 < 6.0 and anchor is None:
341 if music_on():
342 time.sleep(1.0)
343 # 1s 后还在播(或刚好处在抖动空档也算)→可信锚点
344 if time.time() - t0 > 0.8:
345 anchor = time.time() - 1.0
346 time.sleep(0.05)
347if anchor is None:
348 anchor = time.time() + 0.2 # 兜底
349 time.sleep(0.2)
350print("MUSIC_ANCHOR +%.3f" % (anchor - t0), flush=True)
351
352# 自由卡点:每 4 拍(一小节)从动作池取下一个,按拍点提前 LAT_ANIM 排队
353i = 0
354prev_anim = None
355dur_est = info["dur"] if info else 60.0
356stop_at = time.time() + min(dur_est - first - 1.0, MAX_DANCE_SEC)
357events = []
358while time.time() < stop_at:
359 bar = i
360 target = anchor + first + bar * 4 * beat - LAT_MUSIC - LAT_ANIM
361 d = target - time.time()
362 if d > 0:
363 time.sleep(d)
364 if music_really_over(anchor, dur_est):
365 break # 音乐真结束了(抖动滤波)
366 anim = next_move(prev_anim)
367 prev_anim = anim
368 robot.play_animation(anim, interrupt=False) # 铁律③:不打断=与音乐共存
369 events.append((round(time.time() - anchor, 3), bar, anim))
370 i += 1
371 if i > 200:
372 break
373
374print("FIRED %d bars, tail:" % len(events), events[-4:], flush=True)
375# 音乐自然结束后谢幕(铁律①:TTS 只在音乐外说;按已知时长等待,状态抖动不可信)
376remaining = max(0.5, min(dur_est - (time.time() - anchor), MAX_DANCE_SEC))
377deadline = time.time() + remaining
378while time.time() < deadline and not music_really_over(anchor, dur_est):
379 time.sleep(0.5)
380speak(OUTRO)
381robot.play_animation(3005, interrupt=True)
382time.sleep(4.0)
383print("JUKEBOX_DONE bars=%d" % len(events), flush=True)