🔍 核心机关与修改指南
🎙️ 智能剥口癖点歌 user_request()
不管是喊「帮我放来一首稻香」还是读 media/songlist.txt 或抓网易云热歌榜,层层兜底绝不冷场。
⚡ 专治 CDN 掐网三连招 download_song()
只要 128k 小体积音频 + 慢速自杀重启 + curl -C - 断点续传,下完立刻调机上 ffmpeg 转成单声道 22050Hz 的 .wav。
🕺 节拍网格自由舞池 analyze_beats()
算出每拍秒数后,每 4 拍(一小节)从 MOVE_POOL 随机抽一个不重样的上半身动作,提前 0.30 秒发 interrupt=False 卡在拍子上起跳!
💻 完整源码与配置内容(共 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 | |
| 17 | import json |
| 18 | import os |
| 19 | import random |
| 20 | import subprocess |
| 21 | import sys |
| 22 | import time |
| 23 | import urllib.parse |
| 24 | import urllib.request |
| 25 | |
| 26 | # ★★★ 现场开发者改这里 ★★★ |
| 27 | # 点歌优先级(从高到低,取到第一个就点它): |
| 28 | # ① 用户语音/启动参数:机器人把用户说的话传进来(argv[1:] 每词一个,或环境变量 JUKEBOX_REQUEST), |
| 29 | # 例如「放稻香」→ 机器人传"放稻香" → 代码剥掉"放/来一首/我想听"等口癖 → 搜"稻香" |
| 30 | # ② 固定歌单:技能包 media/songlist.txt(一行一首)或下面 PLAYLIST |
| 31 | # ③ 兜底:联网热歌榜随机抽(纯 API,不写死任何歌) |
| 32 | # 搜不到/下不动自动换下一首重试;已播记录 /tmp/jukebox_history.txt |
| 33 | PLAYLIST = [] # 常驻歌单(可选);空 = 语音参数 → 热歌榜 |
| 34 | REQUEST_ENV = "JUKEBOX_REQUEST" # 机器人传歌名的环境变量名(和 argv 二选一) |
| 35 | LAT_ANIM = 0.30 # anim 请求→真正起跳的实测延迟(提前发) |
| 36 | LAT_MUSIC = 0.06 # 音频状态翻 1 与真实出声的差(实测 +0.056s) |
| 37 | INTRO = "收到点歌!我去网上找这首歌,找到就马上开跳!" |
| 38 | SONG_ANNOUNCE = "歌曲就位!歌名 %s,节奏 %d 拍每分钟,开跳!" |
| 39 | OUTRO = "这首歌跳完了,还想听什么,尽管点!" |
| 40 | MAX_DANCE_SEC = 90 # 最多跳这么久就收尾(保护电机) |
| 41 | SEARCH_API = "https://music-api.gdstudio.xyz/api.php" |
| 42 | HOT_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" # 网易云「热门歌曲」搜索 |
| 43 | HISTORY = "/tmp/jukebox_history.txt" |
| 44 | WAV = "/tmp/jukebox_song.wav" |
| 45 | |
| 46 | # 自由舞步池(全部 upper_body,室内模式安全):持续随机抽取,舞蹈类加权 |
| 47 | MOVE_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 | |
| 57 | def next_move(prev): |
| 58 | """持续随机:小节级随机抽取,避免和上一个小节重复。""" |
| 59 | pool = [m for m in MOVE_POOL if m != prev] |
| 60 | return random.choice(pool) |
| 61 | |
| 62 | |
| 63 | # ---------- 联网搜歌+下载 ---------- |
| 64 | def 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 | |
| 75 | def 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="放稻香" |
| 88 | VOICE_FILLERS = ("放", "来一首", "我想听", "我要听", "帮我放", "给我放", "点一首", "播一首", |
| 89 | "唱一首", "跳一首", "放首", "来首", "听首", "点歌", "跳个舞", "点个", |
| 90 | "请放", "想听", "音乐", "首歌", "点首歌") |
| 91 | |
| 92 | |
| 93 | def 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 | |
| 115 | def 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 | |
| 128 | def 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 | |
| 138 | def 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 | |
| 166 | def 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) ---------- |
| 208 | def 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 | # ---------- 音频状态 ---------- |
| 253 | def 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 | |
| 260 | def music_on(): |
| 261 | p, v = audio() |
| 262 | return p == "interaction_slave" and v == 1 |
| 263 | |
| 264 | |
| 265 | def 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 | |
| 279 | def 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 | # ---------- 演出 ---------- |
| 290 | speak(INTRO) |
| 291 | requested, source = pick_song() |
| 292 | print("PICK: %s (source=%s)" % (requested, source), flush=True) |
| 293 | if not requested: |
| 294 | speak("网上没拉到歌单,稍后再试一次!") |
| 295 | raise SystemExit("NO_SONGLIST") |
| 296 | # 用户点名的歌搜不到/下不动 → 报歉 + 从热歌榜换一首继续(不静默换:点名失败要告诉用户) |
| 297 | song = None |
| 298 | path = None |
| 299 | tried = [requested] |
| 300 | for _ 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) |
| 321 | if not path: |
| 322 | speak("这首歌下载失败了,换一首试试!") |
| 323 | raise SystemExit("DOWNLOAD_FAILED") |
| 324 | |
| 325 | info = analyze_beats(path) |
| 326 | print("BEATS: %s" % info, flush=True) |
| 327 | if not info: |
| 328 | speak("这首歌节奏太怪,我先随便跳了!") |
| 329 | beat, first = 0.5, 1.0 |
| 330 | else: |
| 331 | beat, first = info["beat"], info["first"] |
| 332 | |
| 333 | speak(SONG_ANNOUNCE % (song.get("name"), round(60.0 / beat))) |
| 334 | time.sleep(0.5) |
| 335 | robot.play_light(color=1, mode=3) |
| 336 | |
| 337 | robot.play_audio_file(path) |
| 338 | t0 = time.time() |
| 339 | anchor = None |
| 340 | while 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) |
| 347 | if anchor is None: |
| 348 | anchor = time.time() + 0.2 # 兜底 |
| 349 | time.sleep(0.2) |
| 350 | print("MUSIC_ANCHOR +%.3f" % (anchor - t0), flush=True) |
| 351 | |
| 352 | # 自由卡点:每 4 拍(一小节)从动作池取下一个,按拍点提前 LAT_ANIM 排队 |
| 353 | i = 0 |
| 354 | prev_anim = None |
| 355 | dur_est = info["dur"] if info else 60.0 |
| 356 | stop_at = time.time() + min(dur_est - first - 1.0, MAX_DANCE_SEC) |
| 357 | events = [] |
| 358 | while 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 | |
| 374 | print("FIRED %d bars, tail:" % len(events), events[-4:], flush=True) |
| 375 | # 音乐自然结束后谢幕(铁律①:TTS 只在音乐外说;按已知时长等待,状态抖动不可信) |
| 376 | remaining = max(0.5, min(dur_est - (time.time() - anchor), MAX_DANCE_SEC)) |
| 377 | deadline = time.time() + remaining |
| 378 | while time.time() < deadline and not music_really_over(anchor, dur_est): |
| 379 | time.sleep(0.5) |
| 380 | speak(OUTRO) |
| 381 | robot.play_animation(3005, interrupt=True) |
| 382 | time.sleep(4.0) |
| 383 | print("JUKEBOX_DONE bars=%d" % len(events), flush=True) |