使用 PyAudioWPatch 转录QQ音乐歌曲
/ 点击 / 阅读耗时 7 分钟背景
研究这个东西的背景,是因为我的朋友送了我一个游泳用的耳机,这个耳机需要提前把 MP3 格式的音乐导入到耳机中。我是QQ音乐的会员,但是不幸的是,QQ音乐的歌曲下载下来是加密的格式,只能使用QQ音乐播放。我想,既然电脑可以播放,那我想办法把电脑的声音录下来,不就达到一样的目的了吗?
思路
由于我们的需求是录制来自于电脑输出设备(音响)的音频,而不是来自于输入设备(麦克风)的音频。因此,我们需要将电脑的音频输出设备设置为loopback设备,在Windows下,这种录制方式称为环回录制,loopback设备的API类型为WASAPI。

经过一番调研,我找到了一个Python音频库 PyAudioWPatch ,它是 PyAudio 的增强版。PyAudio是著名音频处理库 PortAudio 的Python封装。可惜的是,PyAudio对于WASAPI类型设备的支持并不好。于是有开发者参考 PyAudio,基于 PortAudio 开发了PyAudioWPatch这个库。
在确定录制工具之后,我们可以梳理出以下的开发思路:
- 将要录制的歌曲整理成歌单,并设法获取歌单中的每首歌的名字、歌手、时长
- 播放歌单,同时录制电脑音频数据,将录制下来的音频数据分段保存成音频文件
歌单信息采集
这里我采用的方式比较暴力,通过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"
}
]
录制音频
我们在录制音频的时候,音乐是一首连着一首持续播放的,我们需要判断什么时候开始录制第一首歌,以及每一首歌曲的开始和结束。这里我通过两个方式实现:
- pyaudiowpatch 中read()方法是阻塞的,只要设备中没有音频在播放,程序就会阻塞,我们可以先运行程序,再播放歌单,这样就实现了第一首歌曲开始的识别。
- 我们知道每首歌的时长,也设置了指定的采样频率。如果歌曲时长是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()