背景

研究这个东西的背景,是因为我的朋友送了我一个游泳用的耳机,这个耳机需要提前把 MP3 格式的音乐导入到耳机中。我是QQ音乐的会员,但是不幸的是,QQ音乐的歌曲下载下来是加密的格式,只能使用QQ音乐播放。我想,既然电脑可以播放,那我想办法把电脑的声音录下来,不就达到一样的目的了吗?

思路

由于我们的需求是录制来自于电脑输出设备(音响)的音频,而不是来自于输入设备(麦克风)的音频。因此,我们需要将电脑的音频输出设备设置为loopback设备,在Windows下,这种录制方式称为环回录制,loopback设备的API类型为WASAPI。

经过一番调研,我找到了一个Python音频库 PyAudioWPatch ,它是 PyAudio 的增强版。PyAudio是著名音频处理库 PortAudio 的Python封装。可惜的是,PyAudio对于WASAPI类型设备的支持并不好。于是有开发者参考 PyAudio,基于 PortAudio 开发了PyAudioWPatch这个库。

在确定录制工具之后,我们可以梳理出以下的开发思路:

  1. 将要录制的歌曲整理成歌单,并设法获取歌单中的每首歌的名字、歌手、时长
  2. 播放歌单,同时录制电脑音频数据,将录制下来的音频数据分段保存成音频文件

歌单信息采集

这里我采用的方式比较暴力,通过QQ音乐的歌单分享功能获取歌单链接,如林越的2025年度音乐歌单。直接用浏览器打开歌单链接,通过查看页面源代码,在浏览器控制台里面直接解析HTML来获取需要的信息。

const songList = [];
for (const doc of document.querySelectorAll('.songlist__item')) {
    const title = doc
        .querySelector('.songlist__songname_txt')
        .querySelector('a')
        .getAttribute('title');

    const singerLinks = doc
        .querySelector('.songlist__artist')
        .querySelectorAll('a');
    const singers = [];
    for (const sl of singerLinks) {
        const singerName = sl.getAttribute('title');
        singers.push(singerName);
    }
    const singer = singers.join('/');

    const time = doc.querySelector('.songlist__time').textContent;
    const [minutes, seconds] = time.split(':').map(Number);
    const interval = minutes * 60 + seconds;

    songList.push({title, interval, singer});
}
console.info("Number of songs:", songList.length);
console.info(JSON.stringify(songList));

将输出的JSON保存到一个json文件中,下面是一个JSON的示例。

[    
    {
        "title": "我想当风",
        "interval": 229,
        "singer": "鹿先森乐队"
    },
    {
        "title": "向云端",
        "interval": 251,
        "singer": "小霞/海洋Bo"
    }
]

录制音频

我们在录制音频的时候,音乐是一首连着一首持续播放的,我们需要判断什么时候开始录制第一首歌,以及每一首歌曲的开始和结束。这里我通过两个方式实现:

  1. pyaudiowpatch 中read()方法是阻塞的,只要设备中没有音频在播放,程序就会阻塞,我们可以先运行程序,再播放歌单,这样就实现了第一首歌曲开始的识别。
  2. 我们知道每首歌的时长,也设置了指定的采样频率。如果歌曲时长是10秒,采样频率是44100Hz,那么每秒就会产生44100个采样点数据(在 PyAudioWPatch 中,采样点称为 Frame),10 秒总共有 441000 个Frame,如果一次读取 1024 个 Frame,那么读取 360 次后,这首歌就结束了。(注意,这里不是严格意义的结束,毕竟 441000/1024 也不是整除的,会有少量的采样点被记录到下一首歌,但是考虑到大部分歌曲结尾和开头都是空白的,放到下一首歌也不会有异常)

在获取到每首歌的数据之后,我们把数据保存成对应的音频文件,就达到目标了。最后的代码如下:

import json
import wave
import pyaudiowpatch as pyaudio


def save_wave_file(filename, frames, channels, rate):
    with wave.open(filename, 'wb') as wf:
        wf.setnchannels(channels)
        wf.setsampwidth(pyaudio.PyAudio().get_sample_size(pyaudio.paInt16))
        wf.setframerate(rate)
        wf.writeframes(b''.join(frames))

if __name__ == "__main__":
    with open('songs.json', 'r', encoding='utf-8') as f:
        songs = json.load(f)

    # initialize pyaudio with WASAPI loopback
    pa = pyaudio.PyAudio()
    speaker = pa.get_default_wasapi_loopback()
    channels = speaker.get('maxInputChannels')
    rate = int(speaker.get('defaultSampleRate'))
    index = speaker.get('index')
    chunck = 1024
    print(f"Using device index {index} with {channels} channels at {rate} Hz")
    stream = pa.open(format=pyaudio.paInt16,
                     channels=channels,
                     rate=rate,
                     input=True,
                     frames_per_buffer=chunck,
                     input_device_index=index)

    for song in songs:
        print(f"Now recording: {song.title} by {song.singer} for {song.interval} seconds")
        frames = []
        record_interval = song.interval
        total_chunks = int(rate / chunck * record_interval)
        for _ in range(total_chunks):
            data = stream.read(chunck)
            frames.append(data)
        
        filename = f"{song.title} - {song.singer}.wav"
        save_wave_file(filename, frames, channels, rate)
        print(f"Saved recording to {filename}")

    stream.stop_stream()
    stream.close() 
    pa.terminate()