一切的一切,要从 YesPlayMusic 这个播放器说起。

YesPlayMusic 篇

YesPlayMusic 是一款使用 Electron 编写的第三方网易云播放器,也是我目前认为在 Linux 上最好用的播放器。虽然这样说,但她仍然有很多的不足,比如不支持桌面歌词。后来我无意中发现了一款为 Linux 设计的桌面歌词软件:OSDLyrics

这个软件的原理是通过 Linux 的 MPRIS 接口来获取当前播放媒体的曲目标题,然后在本地指定目录查找同名的 LRC 文件。如果没有找到,就联网进行查找。然而,联网查找的准确度很低,经常会找到错误的 LRC 或者干脆找不到。为了使 YesPlayMusic 能够搭配 OSDLyrics 使用,如果能让 YesPlayMusic 每次更新 Mpris 信息的时候把 LRC 文件下载到本地不就行了吗?

在进行了一番研究之后,最后算是勉强实现了这个功能。关于详细的代码细节这里就不在叙述了,相关的 commit 在这里:a2508589d372e849fb00996297e44a4a2a6b51a2。并且发布了一个可以直接使用的 Fork 版本:v0.4.5-1

不过上面的都是前传。某一天我尝试上传自己找的 CD 音乐资源到网易云的音乐云盘的时候,奇怪地发现 YesPlayMusic 无法播放。一开始我以为是网络问题,排查了一番后发现没问题。难道是播放器的问题?为了对比验证,我使用手机端网易云 APP 进行播放,没有任何问题。那看来就是 YesPlayMusic 的问题了。

既然都贡献代码了,不妨来排查一下到底是哪的问题吧,说不定可以再混一个 pr 呢。

打开控制台:

尝试 lyric 属性时发现变量是 undefined。来看看这里的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async saveLyricsFile(track) {
if (!isLinux) return;
if (!store.state.settings.enableOsdlyricsSupport)
return this._updateMpris(track);
let lyricName = track.ar.map(ar => ar.name).join(', ') + '-' + track.name;
let lyricData = await getLyric(track.id);
if (!lyricData.lrc || !lyricData.lrc.lyric) {
return this._updateMpris(track);
}

ipcRenderer.send('saveLyric', {
name: lyricName,
lyric: lyricData.lrc.lyric,
});

ipcRenderer.on('saveLyricFinished', () => {
this._updateMpris(track);
});
}

这里在 lyricData 上读取了 lyric 属性,然而经过断电调试发现,lyricData 上虽然没有 lrc 属性,但经过判断之后应该不会报这个错才对。这说明问题不在这里。正在我想会是哪里有问题的时候,我随手点开了 Devtools 的 Network 面板,一看,一堆的 getLyrics 请求:

这就说明获取歌词的接口被循环调用,程序一直卡在了这个地方。那么到底是哪个地方一直在调用呢?于是我沿着上层的函数一个一个加断电,最终找到了下面的一段错误处理的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
this._howler.on('loaderror', (_, errCode) => {
// https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code
// code 3: MEDIA_ERR_DECODE
if (errCode === 3) {
this._playNextTrack(this._isPersonalFM);
} else {
const t = this.progress;
this._replaceCurrentTrackAudio(this.currentTrack, false, false).then(
replaced => {
// 如果 replaced 为 false,代表当前的 track 已经不是这里想要替换的track
// 此时则不修改当前的歌曲进度
if (replaced) {
this._howler?.seek(t);
this.play();
}
}
);
}
});

this._replaceCurrentTrackAudio 函数里,程序会尝试重新加载当前的曲目,最终调用到 getLyrics 函数。然而每次加载的时候又会触发 howler 的 loaderror 于是就有进到了这个地方,导致了重复的调用。

那么为什么 howler 会出现 error 呢?让我们来打印一下这里的 errCode 看看。

打印出来发现这里的 errCode 是 4,MDN 里对它的定义是:

Name Value Description
MEDIA_ERR_SRC_NOT_SUPPORTED 4 The associated resource or media provider object (such as a MediaStream) has been found to be unsuitable.

也就是说这里碰到了一个 MEDIA_ERR_SRC_NOT_SUPPORTED 的错误,翻译过来也就是不支持的媒体格式。奇怪,我在云盘里上传的 flac 文件有问题吗?可是本地播放器明明能正常播放的说。

不过为了先解决掉 YesPlayMusic 这边的问题,我在这里有加了一个判断,当 errCode 为 4 的时候也之间跳转到下一首曲目:

1
2
3
4
5
6
7
8
if (errCode === 3) {
this._playNextTrack(this._isPersonalFM);
} else if (errCode === 4) {
// code 4: MEDIA_ERR_SRC_NOT_SUPPORTED
store.dispatch('showToast', '无法播放:不支持的音频格式');
this._playNextTrack(this._isPersonalFM);
}
...

Chromium 篇

暂时解决了 YesPlayMusic 了之后,我决定再研究一下我的文件到底为什么不支持播放。这里的 MEDIA_ERR_SRC_NOT_SUPPORTED 错误是一个浏览器内核级别的错误。鉴于 Electron 的内核就是 Chromium,也就是说是 Chromium 的问题了。为了验证,我把这个有问题的 FLAC 文件拖到了 Chrome 里,果然报了同样的错误。同时,为了验证是 Chromium 的问题,我还试了在 Firefox 里播放这个文件,结果是 Firefox 可以正常播放。

那 Chromium 为什么播放不了这个文件呢?这时我想起 Chrome 有一个 media-internal 的日志页面,用来检测媒体解码器的日志。

在 Chrome 中打开 chrome://media-internals 页面,找到要播放的标签条目,发现了下面的报错:

1
2
error	"FFmpegDemuxer: open context failed"
error {"code":12,"data":{},"group":"PipelineStatus","message":"","stack":[{"file":"media/filters/ffmpeg_demuxer.cc","line":1257}]}

也就是说 Chrome 尝试调用 ffmpeg 解码器来播放这个文件,结果 ffmpeg 说它没法打开这个文件。

为什么 ffmpeg 打不开这个文件呢?我尝试在本地使用 ffmpeg 的附属播放器 ffplay 来播放这个文件,结果是可以正常播放。

于是我当时认定为 Chrome 在调用 ffmpeg 的解码器的时候出了问题,导致 ffmpeg 打不开这个文件。鉴于只有这个文件在会出问题,我当时的想法是这个文件比较特殊,破坏了 Chrome 内部的什么机制。

于是我决定向 Chromium 项目汇报这个问题:#1431655

很快啊,Chromium 开发组的人回复,说我提供的 flac 本身是具有问题的,还附上了一份用 ffmpeg 检测错误的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ffmpeg -err_detect explode -i obj.flac out.wav
ffmpeg version 5.1.2 Copyright (c) 2000-2022 the FFmpeg developers
built with Apple clang version 14.0.0 (clang-1400.0.29.202)
configuration: --prefix=/opt/homebrew/Cellar/ffmpeg/5.1.2_1 --enable-shared --enable-pthreads --enable-version3 --cc=clang --host-cflags= --host-ldflags= --enable-ffplay --enable-gnutls --enable-gpl --enable-libaom --enable-libbluray --enable-libdav1d --enable-libmp3lame --enable-libopus --enable-librav1e --enable-librist --enable-librubberband --enable-libsnappy --enable-libsrt --enable-libtesseract --enable-libtheora --enable-libvidstab --enable-libvmaf --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxml2 --enable-libxvid --enable-lzma --enable-libfontconfig --enable-libfreetype --enable-frei0r --enable-libass --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-libspeex --enable-libsoxr --enable-libzmq --enable-libzimg --disable-libjack --disable-indev=jack --enable-videotoolbox --enable-neon
libavutil 57. 28.100 / 57. 28.100
libavcodec 59. 37.100 / 59. 37.100
libavformat 59. 27.100 / 59. 27.100
libavdevice 59. 7.100 / 59. 7.100
libavfilter 8. 44.100 / 8. 44.100
libswscale 6. 7.100 / 6. 7.100
libswresample 4. 7.100 / 4. 7.100
libpostproc 56. 6.100 / 56. 6.100
[flac @ 0x157704b40] Could not read mimetype from an attached picture.
[flac @ 0x157704b40] Error parsing attached picture.
obj.flac: Invalid data found when processing input

惭愧的是我并不知道 ffmpeg 有错误检测的功能,在使用 ffmpeg 对问题文件执行同样的检查后,输出了同样的报错。

那就是这个 flac 文件确实有问题了,那到底有什么问题呢?为什么其他播放器都能播放呢?

观察 ffmpeg 的错误输出,它说无法读取附加图片的 mime-type。什么是 mime-type 呢?它是个用来指定媒体文件类型的通用标准。有了文件的 mime-type,就算不知道扩展名和文件头也可以正确地处理文件的数据。

为了验证问题所在,我使用 Kid3 给这个文件的嵌入封面加上了 mime type,然后拖到 chrome 里播放,果然可以正常播放了。

那为什么这个 flac 文件嵌入的图片封面没有 mime-type 呢?这个文件是哪里来的?这里我终于想起,这个 flac 文件是使用一个叫 flacon 的软件导出的。

Flacon 篇

Flacon 是一款用来对整个 CUE Track 和音频做分割的软件。在网上找到的 CD 音乐资源,下载下来大多数是一个特别大的 wav 文件和一个 cue 文件,有的还会带一张 cover.jpg 的专辑封面图片。CUE 格式的文件里记录着专辑里每个曲目的起始和终止位置。要想得到多个曲目的音频,就需要用专门的软件将它们分割开。分割之后的格式一般会选 wav 或 flac 格式,其中 flac 由于无损压缩的特性和丰富的标签支持在实际中更常见一些。

这里我使用的就是 flacon 来将专辑进行分割的。经过对比,发现用 flacon 导出的 flac 文件果然都缺少 MIME 信息,那么就得给 flacon 那边反馈了:#199

然而在进行了详细的反馈之后,开发者连续两天都没有理我。于是我斗胆决定把这个 C++ 的项目拉到本地进行调试,找找问题到底在哪。这里就要顺便提一下这种 Qt + CPP 的项目是真好编译,环境啥的随随便便就配了。以前跑别人的 Node 或是 Python 什么的项目需要配半天环境,最后也不见得能跑起来。

这里重点关注的地方就是这个 setCoverImage 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void FlacMetadataWriter::setCoverImage(const CoverImage &image)
{
if (!image.isEmpty()) {
TagLib::ByteVector dt(image.data().data(), image.data().size());

TagLib::FLAC::Picture *pic = new TagLib::FLAC::Picture();
pic->setType(TagLib::FLAC::Picture::Type::FrontCover);
pic->setData(dt);
pic->setMimeType(image.mimeType().toStdString());
pic->setWidth(image.size().width());
pic->setHeight(image.size().height());
pic->setColorDepth(image.depth());

mFile.addPicture(pic);
}
}

pic->setMimeType(image.mimeType().toStdString()); 这里断点,然后看看 mimeType() 到底有没有被写到 pic 里。

然而尴尬的是,在调试的时候发现 mime 类型确实正确写入了,而且输出的 flac 文件也没有任何问题。这就邪了门了。这时我突然想起,我本地安装的 flacon 版本和源码的版本好像隔了挺久的,难道是 flacon 那边已经把这个问题修了?怪不得开发者不理我…

遗憾的时我翻了半天也没找到到底哪一条 commit 把这个问题修了,大概率是我确实没找到。毕竟我连 C++ 一点都没学过。


总而言之,这次因为这个问题纠结来纠结去了好几天,最后却以这种离谱的方式解决了,怎么想都很气啊!