前言

比如在 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)

相关参考

musicpy

银河与星斗钢琴曲 钢琴简易简谱独奏

银河与星斗 - 网易云音乐

《银河与星斗简谱》

《[日]だんご大家族简谱》