前言
比如在 B 站看到有人用那个 C++ 调用 Windows 的底层音频,然后实现了《勾指起誓》的演奏,然后我大受震撼,觉得这种好厉害,就是我也想试一试,然后用 Python 来播放这个音乐。
用visual studio 2022演奏《勾指起誓》,效果简直炸裂……
我先是问了 AI 有没有可以使用的第三方库,然后 AI 给了我一个 musicpy 库,我就找到这个库的 GitHub 界面,然后简单学习了一下,其实也没有学习——只是浏览了一下他的 wiki,但是也没有深入的了解。
过程
我把这个库下载下来,然后下了个 Pygame 用来驱动音乐的播放,然后简单用了一下这个设计程序,效果好像还不错,然后我就有兴趣上来了。
import musicpy as mp
sound = (mp.C('CM7', 3, 1 / 4, 1 / 8) ^ 2 |
mp.C('G7sus', 2, 1 / 4, 1 / 8) ^ 2 |
mp.C('A7sus', 2, 1 / 4, 1 / 8) ^ 2 |
mp.C('Em7', 2, 1 / 4, 1 / 8) ^ 2 |
mp.C('FM7', 2, 1 / 4, 1 / 8) ^ 2 |
mp.C('CM7', 3, 1 / 4, 1 / 8) @ 1 |
mp.C('AbM7', 2, 1 / 4, 1 / 8) ^ 2 |
mp.C('G7sus', 2, 1 / 4, 1 / 8) ^ 2) * 2
mp.play(sound, bpm=100, instrument=1, save_as_file=False, wait=True)
我找了一首喜欢的曲子:《银河与星斗》,然后在网络上面找了一个乐谱,是独奏的那个乐谱。
然后问题来了,我又看不懂乐谱,又不会把它转成对应的代码,就是那个库的那种代码,我是真的理解不了。毕竟这个库还是要求一点乐理基础的,但是我的乐理基础真的是负数。
于是豆包 DeepSeek 轮番上阵,豆包负责把乐谱识别成 markdown 格式,然后把 markdown 交给 DeepSeek,先让 DeepSeek 预先读取了一下这个 wiki 文件。没错,我把 wiki 文件给d(下载)下来,然后让 DeepSeek 学习,让他教我怎么做——不对,让他学习怎么做。
最后就是前后折腾了大概一两个钟头吧,我拿到了 DeepSeek 给的《银河与星斗》,当然中间还是吃了不少苦:一堆报错,然后不断的修正几个词,最后勉强成功吧。效果也不是很让人满意,就是没 B 站的那种大佬弄的好。本来是想发个视频庆祝一下,但是这质量太低了,还是不发了。
最后呢,今天是为了更新我的这个博客网站项目,修一点 bug,顺便就把这篇文章给写在这儿了。
结果
我把《银河与星斗》和《团子大家族》的代码放在这儿吧。
《银河与星斗》
import musicpy as mp
# 调号:1=bB -> Bb major
scale = mp.S('Bb major')
def expand_notes(num_str, base_dur=1 / 8):
"""将连写数字串(如 "21", "331", "1'7")拆分为多个音符,时值平分 base_dur"""
# 先分离数字和八度标记
tokens = []
i = 0
while i < len(num_str):
ch = num_str[i]
if ch.isdigit():
start = i
while i < len(num_str) and num_str[i].isdigit():
i += 1
num = num_str[start:i]
# 检查后面是否有八度标记
oct_shift = 0
if i < len(num_str) and num_str[i] in ("'", '.'):
if num_str[i] == "'":
oct_shift = 1
else:
oct_shift = -1
i += 1
tokens.append((num, oct_shift))
else:
# 忽略其他字符(如波浪线 ~)
i += 1
# 每个音符时长相等
dur = base_dur / len(tokens)
result = []
for num, oct_shift in tokens:
# 如果 num 长度 >1,还需要进一步拆分(如 "21" 拆成 "2","1")
if len(num) > 1:
for ch in num:
result.append((ch, dur, oct_shift))
else:
result.append((num, dur, oct_shift))
return result
# 构建整个旋律(空和弦)
full = mp.chord([])
# 小节序列(保持原样,已展开反复)
bars = [
# 前奏(第一行)
["0.", "1", "|", "1'", "7", "5", "3", "5", "5.", "1", "|", "2", "1", "2", "3", "3.", "1", "|", "1'", "7", "5", "3",
"5", "5", "21", "|", "2", "3", "7.", "12", "1", "-"],
# 括号内
["1'", "7", "5", "3", "5", "5", "|", "2", "1", "2", "3", "3.", "3", "|", "2", "1", "1", "3", "2", "1", "1", "|",
"2", "2", "2", "1", "1"],
# 主歌反复段(展开一次)
["0", "63", "3", "21", "2123", "3.", "1", "|", "2", "21", "2", "21", "4", "3", "2", "3", "|", "0", "63", "3", "35",
"533", "3", "21", "|", "2", "23", "211", "1"],
["0", "63", "3", "21", "21~3", "3.", "1", "|", "2", "21", "2", "21", "4", "3~2", "2", "3", "|", "5", "1", "5", "1",
"6", "53", "3", "21", "|", "2123", "2171", "1", "6.", "1"],
["5", "55", "5", "1'", "7", "55", "5", "21", "|", "2", "22", "3", "4", "3", "6.", "1", "|", "5", "55~", "5", "1'7",
"5", "5", "21", "|", "2", "23", "217~", "1.", "D.S", "1"],
# 副歌(晚风依旧很温柔)
["1'", "7", "5", "3", "5", "5.", "1", "|", "2", "1", "23~", "3.", "1", "|", "1'", "7", "5", "3", "5", "5", "21",
"|", "2", "3", "7", "2", "1.", "1"],
# 从来都没有理由
["1'", "7", "5", "3", "5", "5", "21", "|", "2", "1", "2", "6", "5.", "3", "|", "2116'", "1", "3", "2116'", "1", "|",
"5'", "2", "1", "1"],
# 括号内(结尾)
["1'", "7", "5", "3", "5", "5", "|", "2", "1", "2", "3", "3.", "3", "|", "2", "1", "6.", "3", "2", "1", "6.", "|",
"2.", "1", "1"],
# 结束句(D.S.后)
["5'", "2", "1", "1."]
]
for bar in bars:
for token in bar:
# 忽略分隔符和记号
if token in ('|', '-', '~', 'D.S'):
continue
# 休止符
if token == '0':
full = full | mp.rest(1 / 4)
continue
if token == '0.':
full = full | mp.rest(3 / 8)
continue
# 处理附点(注意:附点可能在数字后面,也可能在数字串末尾)
dotted = False
if token.endswith('.'):
dotted = True
token = token[:-1]
# 处理高音和低音标记(高音 ' 或低音 . 但低音已处理?这里只处理高音)
# 注意:低音点已经在 token 中被表示为 '.',但我们已经把附点去掉了,剩下的 '.' 可能是低音?
# 实际上在 bars 中,低音是用 "7." 表示的,但附点也是 '.',容易混淆。
# 为了解决歧义,约定:如果 token 结尾的 '.' 是附点,则已经在上面去掉了;如果还有 '.' 则是低音标记。
# 但这里简谱中低音标记是数字右下角的点,在文本中用 "." 表示,且附点也在数字后,所以一个数字后可能同时有附点和低音?不会。
# 简单处理:检查 token 中是否包含 '.'(除了末尾的附点)
oct_shift = 0
if "'" in token:
oct_shift = 1
token = token.replace("'", "")
if '.' in token: # 低音标记
oct_shift = -1
token = token.replace(".", "")
# 现在 token 应该是纯数字串(可能多位)
if not token.isdigit():
# 如果还有非数字,跳过(如波浪线等已过滤)
continue
# 处理连写数字(多位数字串)
# 每个数字对应一个音符,时值平分四分音符
notes = expand_notes(token) # 返回 (数字, 时长, 八度偏移) 列表
for num, dur, oct_shift2 in notes:
degree = int(num) - 1
if degree < 0 or degree >= len(scale.notes):
print(f"警告:数字 {num} 超出音阶范围,已跳过")
continue
base_note = scale.notes[degree]
new_oct = base_note.num + oct_shift + oct_shift2
note_name = f"{base_note.name}{new_oct}"
actual_dur = dur * (1.5 if dotted else 1)
single = mp.translate(f"{note_name}[l:{actual_dur}; i:{actual_dur}]")
full = full | single if full.notes else single
# 播放
mp.play(full, bpm=81, wait=True, save_as_file=False, instrument=1)
《团子大家族》
import musicpy as mp
# 调号:1=#G -> G# major
scale = mp.S('G# major')
# 辅助函数:将连写数字(如 "331")拆分为多个音符,时值平分四分音符
def expand_notes(num_str, base_dur=1 / 4):
"""返回 (数字, 时长) 列表,每个时长 = base_dur / len(num_str)"""
dur = base_dur / len(num_str)
return [(ch, dur) for ch in num_str]
# 解析一个 token(如 "6̣5̣1"、"4·4"、"17̇711")
def parse_token(token, default_dur=1 / 4):
"""返回音符列表,每个音符为 (数字, 时长, 是否附点, 八度偏移)"""
# 先处理低音点(̣)和高音点(̇)
# 由于这些是 Unicode 组合字符,直接替换为标记
token = token.replace('̣', 'L') # L 表示低音
token = token.replace('̇', 'H') # H 表示高音
# 处理附点(·)
dotted = False
if '·' in token:
dotted = True
token = token.replace('·', '')
# 分离数字和八度标记
result = []
i = 0
while i < len(token):
ch = token[i]
if ch.isdigit():
# 收集连续数字
num_start = i
while i < len(token) and token[i].isdigit():
i += 1
num_str = token[num_start:i]
# 检查后面是否有八度标记
oct_shift = 0
if i < len(token) and token[i] in ('L', 'H'):
if token[i] == 'L':
oct_shift = -1
else:
oct_shift = 1
i += 1
# 如果数字串长度 >1,需要拆分为多个音符
if len(num_str) > 1:
notes = expand_notes(num_str, default_dur)
for n, dur in notes:
result.append((n, dur, dotted, oct_shift))
else:
result.append((num_str, default_dur, dotted, oct_shift))
else:
# 忽略其他字符(如空格、括号等)
i += 1
return result
# 构建旋律(按演奏顺序,已手动展开反复和跳房子)
# 下面列出所有小节(每个小节是一个 token 列表,按谱面顺序)
bars_tokens = [
# 前奏
["6̣5̣1", "1", "2", "2", "3", "1", "5", "6̣5̣1", "1", "2", "2", "331", "0",
"6̣5̣1", "1", "2", "2", "3", "1", "5", "6̣5̣1", "1", "2", "1", "0", "0", "0",
"6̣5̣1", "1", "2", "2", "3", "1", "5", "6̣5̣1", "1", "2", "3", "2", "0",
"6̣5̣1", "1", "2", "2", "3", "1", "5", "6̣5̣1", "1", "2", "1", "0", "0", "0",
"6̣5̣1", "1", "2", "2", "3", "1", "5", "6̣5̣1", "1", "2", "2", "3", "2", "0",
"6̣5̣1", "1", "2", "2", "3", "1", "5", "6̣5̣1", "1", "2", "1", "0", "0", "0",
"431", "6", "126", "1", "5", "6", "1", "4316", "126", "1", "0", "0", "0",
"4316", "126", "1", "5", "6", "1", "5̣5̣1", "4·4", "0", "0", "2512"],
# 反复段(展开一次)
["2", "16", "123", "52", "3", "1512", "2", "16", "215", "3", "0", "2512",
"2", "1", "6", "123", "523", "1", "7·6", "67717̇710", "2512", "2", "16", "123",
"523", "15", "12", "2", "16", "215", "3", "0", "2512", "2", "16", "123",
"523", "1", "7", "6.", "7", "17̇711", "0", "0"],
# 跳房子1(展开)
["0", "0", "0", "0", "2151", "21", "53", "0", "0", "0", "21", "61", "21", "53", "0",
"0", "1", "2", "1", "1", "7", "6̣6̣", "5", "21", "61", "21", "53", "0", "0", "6", "21",
"51", "21", "53", "0", "0", "1", "21", "61", "21", "53", "0", "0", "1", "2", "572", "577",
"7", "5̣5̣", "0", "0", "25", "1", "2", "2"],
# 跳房子2(展开)
["2", "6", "7", "17̇711", "25", "1", "2"],
# 跳房子3(展开)
["6̣5̣1", "1", "2", "2", "6", "-", "7", "17̇711", "-", "0", "3", "1", "5",
"6̣5̣1", "1", "2", "2", "331", "0", "6̣5̣1", "1", "2", "2", "3", "1", "5",
"6̣5̣1", "1", "22", "1", "0", "0", "0", "6̣5̣1", "1", "2", "2", "3", "1", "5",
"6̣5̣1", "1", "2", "2", "331", "0", "6̣5̣1", "1", "2", "2", "3", "1", "5",
"6̣5̣1", "1", "22", "1", "0", "0", "0"]
]
# 构建完整旋律
full = mp.chord([])
for bar in bars_tokens:
for token in bar:
if token == '0':
full = full | mp.rest(1 / 4)
continue
if token == '-':
continue # 延音线忽略
# 解析 token 得到音符列表
notes = parse_token(token)
for num, dur, dotted, oct_shift in notes:
degree = int(num) - 1
base_note = scale.notes[degree]
new_oct = base_note.num + oct_shift
note_name = f"{base_note.name}{new_oct}"
actual_dur = dur * (1.5 if dotted else 1)
single = mp.translate(f"{note_name}[l:{actual_dur}; i:{actual_dur}]")
full = full | single if full.notes else single
# 播放
mp.play(full, bpm=100, wait=True, save_as_file=False)