自建音乐服务 · 音质切换功能全链路实现(详细版)
从后端
ffmpeg转码到 Android 端 UI 切换,一次打通「原 / 320k / 128k」三档音质的完整实践。本文覆盖需求分析、架构设计、后端实现、客户端实现、部署、验证、踩坑七个部分。
一、需求与背景
1.1 项目背景
「道理鱼音乐」是一套自建音乐流媒体服务,特点是:
| 维度 | 说明 |
|---|---|
| 后端 | Python 3.12 + FastAPI + SQLite,扫描本地音乐库 |
| 前端(Web) | 单文件 HTML 播放器,浏览器直接打开 |
| 前端(Android) | 原生 App,Java + MediaPlayer |
| 部署 | 京东云 CentOS 7,systemd 托管 |
| 音乐库 | 大量无损:FLAC / WAV / APE / DSD,一首 30~50MB |
1.2 痛点
带宽是硬瓶颈。京东云轻量服务器公网带宽通常 1~5 Mbps,一首 30MB 的 FLAC:
- 1 Mbps:需要 4 分钟才能加载完(基本没法听)
- 3 Mbps:需要 80 秒(勉强)
- 5 Mbps:需要 48 秒(可接受)
而 320k mp3 只有 7MB,128k mp3 只有 3MB,同样带宽下:
- 320k:加载 19 秒
- 128k:加载 5 秒
降级到 mp3 能显著改善体验,尤其在移动网络下。
1.3 目标
- 后端按需转码:
?q=high|mid|low参数触发 ffmpeg 转码,缓存复用 - 客户端 UI 可切换:一键切档,切换不打断播放,保持进度
- 持久化记忆:杀 App 重开沿用上次选择
- 不影响现有功能:收藏、歌词、封面、队列、全屏歌词等全部照旧
- 优雅降级:转码失败退回原文件,不阻断播放
1.4 设计原则
| 原则 | 具体做法 |
|---|---|
| 按需转码 | 不是一次性全转,用户切到某档才转 |
| 缓存复用 | 转好的 mp3 落盘,下次秒播 |
| 缓存失效 | 文件名含源文件 mtime,源文件改动自动失效 |
| 缓存上限 | 5GB 上限,超了按 mtime 清理最旧 |
| 客户端最简 | 只加枚举 + 一个 URL 参数,不动现有播放逻辑 |
| 缓存隔离 | 本地缓存只在 HIGH 档使用,避免高低音质混播 |
| 向后兼容 | streamUrl(guid) 保留,新加重载不破坏接口 |
| 优雅降级 | ffmpeg 报错退回原文件,用户最多听到原版 |
二、整体架构
2.1 数据流
┌────────────────────────────────────────────────────┐
│ Android App │
│ ┌──────────────────────────────────────────────┐ │
│ │ PlayerActivity │ │
│ │ 顶部"音质"标签 ← 点击切换 │ │
│ │ ├ Toast 提示 │ │
│ │ └ 更新标签文案 │ │
│ └──────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ PlaybackService │ │
│ │ currentQuality = AudioQuality.load() │ │
│ │ setQuality(q): │ │
│ │ ├ 保存 SharedPreferences │ │
│ │ ├ 通知 listener 刷新 UI │ │
│ │ └ prepareCurrent(wasPlaying) │ │
│ │ ├ 保存 pendingSeekMs │ │
│ │ └ 拼 URL → streamUrl(guid, quality) │ │
│ └──────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ DaoliyuApiClient │ │
│ │ /stream?token=xxx&q=mid │ │
│ └──────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
│ HTTP Range 请求
▼
┌────────────────────────────────────────────────────┐
│ FastAPI 后端 │
│ ┌──────────────────────────────────────────────┐ │
│ │ /api/tracks/{id}/stream?q=mid │ │
│ │ ├ 参数校验(high/mid/low) │ │
│ │ ├ 查库拿源文件路径 │ │
│ │ └ transcode.ensure_transcoded() │ │
│ │ ├ 缓存命中 → 直接返回 │ │
│ │ ├ 未命中 → ffmpeg 转码 │ │
│ │ │ → 原子写入缓存 │ │
│ │ │ → 缓存清理 │ │
│ │ └ 返回缓存文件路径 │ │
│ │ └ range_response() 支持 HTTP Range │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ DATA_DIR/transcoded/ │
│ ├ xxxx_mid.mp3 (320k,约 7MB) │
│ └ xxxx_low.mp3 (128k,约 3MB) │
└────────────────────────────────────────────────────┘
2.2 三档参数约定
| 档位 | q 参数 | 后端行为 | 文件大小(3 分钟) |
|---|---|---|---|
| 原始 | high | 直接返回源文件 | 30MB(FLAC) |
| 高品质 | mid | 320k mp3 转码 | 7MB |
| 标准 | low | 128k mp3 转码 | 3MB |
三、后端实现
3.1 config.py 配置
TRANSCODE_DIR = DATA_DIR / "transcoded"
TRANSCODE_PROFILES = {
"high": {"bitrate": 0, "codec": None, "label": "原始音质"},
"mid": {"bitrate": 320, "codec": "mp3", "label": "高品质 320k"},
"low": {"bitrate": 128, "codec": "mp3", "label": "标准 128k"},
}
DEFAULT_TRANSCODE_QUALITY = "high"
TRANSCODE_CACHE_MAX_BYTES = 5 * 1024 * 1024 * 1024 # 5 GB
FFMPEG_BIN = os.environ.get("FFMPEG_BIN", "ffmpeg")
TRANSCODE_TIMEOUT_SECONDS = 600
ensure_dirs() 里加上:
def ensure_dirs() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
COVER_DIR.mkdir(parents=True, exist_ok=True)
PLAYLIST_COVER_DIR.mkdir(parents=True, exist_ok=True)
TRANSCODE_DIR.mkdir(parents=True, exist_ok=True)
3.2 transcode.py(新文件)
核心接口:
def ensure_transcoded(track_id: str, source_path: Path, quality: str) -> Path:
"""确保某档位缓存已存在,返回可供流式响应的文件路径。"""
实现要点:
3.2.1 缓存 key 设计
def get_transcoded_path(track_id, source_path, quality):
if quality not in TRANSCODE_PROFILES:
quality = "high"
prof = TRANSCODE_PROFILES[quality]
if prof["codec"] is None:
return source_path # high 直接用源文件
try:
mtime = int(source_path.stat().st_mtime)
except OSError:
mtime = 0
safe_key = f"{track_id}|{quality}|{mtime}"
digest = hashlib.sha1(safe_key.encode("utf-8")).hexdigest()[:16]
return TRANSCODE_DIR / f"{digest}_{quality}.mp3"
为什么文件名里带 mtime? 源文件改动(比如重新压制的版本),mtime 变,缓存文件名也变,老的缓存自动失效,不需要手动清理。
3.2.2 ffmpeg 命令行
cmd = [
FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-y",
"-i", str(source_path),
"-vn", # 忽略视频流(封面)
"-map_metadata", "-1", # 不带元数据(减小体积)
"-c:a", "libmp3lame", # mp3 编码器
"-b:a", f"{bitrate}k", # 码率
"-f", "mp3", # 强制 mp3 格式
str(tmp), # 先写临时文件
]
-map_metadata -1 的作用:剥离所有标签元数据(专辑名、封面等),能减少 mp3 文件 100KB ~ 2MB。我们的封面已经走独立的 /api/cover 接口,不需要嵌在音频里。
先写 .mp3.part 临时文件,转码成功后再 os.replace() 原子替换:
tmp = target.with_suffix(".mp3.part")
# ... subprocess.run(cmd) ...
os.replace(tmp, target)
如果转码中途中断,不会留下半损坏的 mp3。
3.2.3 转码失败处理
try:
result = subprocess.run(cmd, timeout=TRANSCODE_TIMEOUT_SECONDS, ...)
except subprocess.TimeoutExpired:
_safe_unlink(tmp)
raise TranscodeError(f"转码超时(>{TRANSCODE_TIMEOUT_SECONDS}s)")
except FileNotFoundError:
_safe_unlink(tmp)
raise TranscodeError(f"找不到 ffmpeg:{FFMPEG_BIN}")
if result.returncode != 0:
_safe_unlink(tmp)
err = (result.stderr or b"").decode("utf-8", "replace").strip()
raise TranscodeError(f"ffmpeg 退出码 {result.returncode}:{err[:300]}")
3.2.4 缓存清理
def _cleanup_cache_if_needed() -> None:
"""缓存超过上限时,按 mtime 从旧到新删除。"""
files = []
total = 0
for entry in TRANSCODE_DIR.iterdir():
if not entry.is_file():
continue
st = entry.stat()
files.append((st.st_mtime, st.st_size, entry))
total += st.st_size
if total <= TRANSCODE_CACHE_MAX_BYTES:
return
files.sort(key=lambda x: x[0]) # 旧 -> 新
removed_bytes = 0
for _, size, path in files:
if total - removed_bytes <= TRANSCODE_CACHE_MAX_BYTES:
break
_safe_unlink(path)
removed_bytes += size
简单粗暴的 LRU(其实按 mtime 是 LFU 的近似),够用。
3.3 main.py 改 stream 接口
@app.get("/api/tracks/{track_id}/stream")
def stream_track(
track_id: str,
request: Request,
token: str = "",
q: str = "",
authorization: str = Header(None),
):
# 1. 认证
user = _resolve_stream_user(token, authorization or "")
if user is None:
raise HTTPException(401, "登录已失效,请重新登录")
# 2. 参数校验
quality = (q or "").strip().lower() or "high"
if not is_quality_supported(quality):
raise HTTPException(400, f"不支持的音质档位:{quality}")
# 3. 查库
with get_db() as conn:
row = conn.execute(
"SELECT path FROM tracks WHERE id = ?", (track_id,)
).fetchone()
if row is None:
raise HTTPException(404, "歌曲不存在")
audio_path = safe_join(MUSIC_ROOT, row["path"])
if audio_path is None or not audio_path.is_file():
raise HTTPException(404, "音频文件不存在")
# 4. 确保转码
try:
play_path = ensure_transcoded(track_id, audio_path, quality)
except TranscodeError as e:
logger.warning("转码失败 %s [%s]: %s", track_id, quality, e)
play_path = audio_path # ★ 优雅降级:退回原文件
# 注意:不 raise,让用户至少能听原版
# 5. 流式响应(支持 Range)
range_header = (request.headers.get("range")
or request.headers.get("Range"))
return range_response(play_path, range_header)
关键设计:转码失败不阻断播放。这是很重要的一步:用户切到 mid 档,如果 ffmpeg 挂了(比如磁盘满、文件损坏),不应该看到"播放失败",最多听到原版。退回原文件是最符合用户体验的选择。
3.4 新增 /api/qualities 接口
@app.get("/api/qualities")
def api_qualities(user: dict = Depends(require_auth)):
"""给客户端列出可用音质档位。"""
return {"items": list_qualities()}
返回:
{"items": [
{"key": "high", "label": "原始音质", "bitrate": 0},
{"key": "mid", "label": "高品质 320k", "bitrate": 320},
{"key": "low", "label": "标准 128k", "bitrate": 128}
]}
客户端可以在设置页动态拉取,将来加档位不用改 App。
四、Android 端实现
4.1 AudioQuality 枚举(新文件)
app/src/main/java/com/feiniu/yumusic/data/AudioQuality.java:
public enum AudioQuality {
HIGH("high", "原", "原始音质"),
MID("mid", "320", "高品质 320k"),
LOW("low", "128", "标准 128k");
public final String key; // 传给后端的 q 参数
public final String shortLabel; // 按钮上显示的短标签
public final String label; // Toast / 面板上显示的全称
AudioQuality(String key, String shortLabel, String label) {
this.key = key;
this.shortLabel = shortLabel;
this.label = label;
}
// SharedPreferences 持久化
private static final String PREFS = "audio_settings";
private static final String KEY_QUALITY = "audio_quality";
public static AudioQuality load(Context ctx) {
if (ctx == null) return HIGH;
SharedPreferences sp = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String saved = sp.getString(KEY_QUALITY, HIGH.key);
return fromKey(saved);
}
public static void save(Context ctx, AudioQuality q) {
if (ctx == null || q == null) return;
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_QUALITY, q.key).apply();
}
public static AudioQuality fromKey(String key) {
if (key != null) {
for (AudioQuality q : values()) {
if (q.key.equals(key)) return q;
}
}
return HIGH;
}
/** 循环切换:原 → 320 → 128 → 原 */
public AudioQuality next() {
switch (this) {
case HIGH: return MID;
case MID: return LOW;
case LOW: return HIGH;
default: return HIGH;
}
}
}
为什么用枚举而不是字符串常量?
- 类型安全:
AudioQuality.MID比"mid"更容易被 IDE 提示,避免"hi"/"hight"这种拼写错误 - 行为内聚:
next()循环、load()/save()持久化、fromKey()反序列化,全在这一个文件里 - 易扩展:将来加一档
LOSSLESS("flac", "FLAC", "无损 FLAC"),UI 自动多出一档
4.2 DaoliyuApiClient 加重载
只加不删:
/** 默认音质:HIGH(原始)。保持向后兼容。 */
@Override
public String streamUrl(String guid) {
return streamUrl(guid, AudioQuality.HIGH);
}
/**
* 带音质档位的流地址。
*
* 后端三档:
* high → 原始文件
* mid → 320k mp3(服务端转码缓存)
* low → 128k mp3
*/
public String streamUrl(String guid, AudioQuality quality) {
String base = session.getBaseUrl();
AudioQuality q = (quality == null) ? AudioQuality.HIGH : quality;
return base + API_PREFIX + "/tracks/" + Uri.encode(guid) + "/stream"
+ "?token=" + Uri.encode(session.getToken())
+ "&q=" + q.key;
}
为什么用重载而不是改方法签名?
MusicSource 接口里已经定义了 streamUrl(String guid)。如果直接改签名,所有实现类(FeiniuApiClient 等)都要改。用重载,老调用不变,新调用多一个参数。
4.3 PlaybackService 核心逻辑
4.3.1 加字段
private AudioQuality currentQuality = AudioQuality.HIGH;
/** 音质切换监听器,供 UI 层注册。 */
private final List<OnQualityChangedListener> qualityListeners =
new CopyOnWriteArrayList<OnQualityChangedListener>();
public interface OnQualityChangedListener {
void onQualityChanged(AudioQuality q);
}
CopyOnWriteArrayList 保证并发安全(UI 线程注册/注销,MediaPlayer 回调线程通知)。
4.3.2 onCreate 里读持久化
@Override
public void onCreate() {
super.onCreate();
// ... 原有初始化
currentQuality = AudioQuality.load(this); // ★ 新增
// ... 后续初始化
}
4.3.3 对外 API
public AudioQuality getQuality() {
return currentQuality;
}
/**
* 切换音质:
* - 若正在播放:保持当前进度和播放状态,重新加载当前曲
* - 若暂停中:仅更新状态,下次播放时用新档位
*/
public void setQuality(AudioQuality q) {
if (q == null || q == currentQuality) return;
currentQuality = q;
AudioQuality.save(this, q);
notifyQualityChanged(q);
Track cur = currentIndex >= 0 && currentIndex < queue.size()
? queue.get(currentIndex) : null;
if (cur == null) return;
int pos = getPositionMs();
boolean wasPlaying = isPlaying();
// ★ 关键:让 prepareInternal 里的 pendingSeekMs 生效,保持进度
pendingSeekMs = Math.max(0, pos);
playWhenPrepared = wasPlaying;
prepareCurrent(wasPlaying);
}
public void addQualityListener(OnQualityChangedListener l) {
if (l != null && !qualityListeners.contains(l)) qualityListeners.add(l);
}
public void removeQualityListener(OnQualityChangedListener l) {
qualityListeners.remove(l);
}
private void notifyQualityChanged(AudioQuality q) {
for (OnQualityChangedListener l : qualityListeners) {
try { l.onQualityChanged(q); } catch (Exception ignored) { }
}
}
关键点解读:
save(this, q)立刻持久化,即使 App 被系统杀掉也不会丢pendingSeekMs是切档保持进度的核心:prepareCurrent会走releasePlayer+createPlayer,MediaPlayer 实例是新的,需要在onPrepared时 seek 回原位置playWhenPrepared = wasPlaying保证暂停状态下切档不会突然开始播
4.3.4 prepareInternal 改 2 处
改 1:本地缓存只在 HIGH 档使用
// 原来
File localFile = allowLocalCache ? trackCache.getCompleteFile(track.guid) : null;
// 现在
File localFile = (allowLocalCache && currentQuality == AudioQuality.HIGH)
? trackCache.getCompleteFile(track.guid) : null;
为什么? TrackFileCache 按 guid 命名,不区分音质。如果 mid 档也写缓存,切回 high 时会误用 mp3 文件。所以:
- HIGH:走本地缓存 + 后台预下载
- MID/LOW:直接 HTTP 流播,不写缓存
改 2:拼 URL 时带音质
// 原来
String url = source.streamUrl(track.guid);
// 现在
String url;
if (source instanceof DaoliyuApiClient) {
url = ((DaoliyuApiClient) source).streamUrl(track.guid, currentQuality);
} else {
// 其他音源(飞牛等)暂未实现音质降级
url = source.streamUrl(track.guid);
}
为什么用 instanceof? 因为 MusicSource 接口里只有 streamUrl(String),streamUrl(String, AudioQuality) 是 DaoliyuApiClient 独有的。用 instanceof 分支检查,其他音源走老方法,不影响。
将来如果要把音质降级推广到所有音源,再改 MusicSource 接口加默认方法。
4.3.5 prefetchTrack 加守卫
private void prefetchTrack(final Track track) {
// ★ 只在原始音质下做缓存,避免高低音质共用缓存文件
if (currentQuality != AudioQuality.HIGH) return;
// ... 原有逻辑
}
切到 mid 或 low 档时,后台不再预下载文件。一是因为转码后的 mp3 服务端已经缓存了,二是避免本地磁盘被非原档文件占满。
4.4 PlayerActivity UI 层
4.4.1 加监听器字段
/** 音质切换监听器(UI 主动刷新用)。 */
private final PlaybackService.OnQualityChangedListener qualityListener =
new PlaybackService.OnQualityChangedListener() {
@Override public void onQualityChanged(AudioQuality q) {
runOnUiThread(new Runnable() {
@Override public void run() { updateQualityLabel(); }
});
}
};
runOnUiThread 保证更新 UI 在主线执行。
4.4.2 onCreate 绑定点击
quality = (TextView) findViewById(R.id.player_quality);
// ★ 顶部"音质"标签:点击循环切换档位
quality.setOnClickListener(new View.OnClickListener() {
@Override public void onClick(View v) { onQualityClick(); }
});
4.4.3 点击处理
/** 点击顶部音质标签:循环切到下一档。 */
private void onQualityClick() {
if (playback == null) {
Toast.makeText(this, "播放服务未就绪", Toast.LENGTH_SHORT).show();
return;
}
AudioQuality next = playback.getQuality().next();
playback.setQuality(next);
updateQualityLabel();
Toast.makeText(this, "音质 · " + next.label, Toast.LENGTH_SHORT).show();
}
/** 刷新顶部音质标签:档位 + 源文件参数(如 FLAC 24bit 96kHz)。 */
private void updateQualityLabel() {
if (quality == null || playback == null) return;
AudioQuality q = playback.getQuality();
Track track = playback.snapshot().track;
String codec = (track != null) ? track.qualityLabel() : "";
if (codec == null || codec.length() == 0) {
quality.setText("音质 · " + q.shortLabel);
} else {
quality.setText("音质 · " + q.shortLabel + " · " + codec);
}
}
4.4.4 updateUi() 里改一行
// 原来
quality.setText(track.qualityLabel());
// 现在
updateQualityLabel();
4.4.5 注册/注销监听
ServiceConnection.onServiceConnected:
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
playback = ((PlaybackService.LocalBinder) service).getService();
bound = true;
playback.addQualityListener(qualityListener); // ★ 注册
updateUi();
updateQualityLabel(); // ★ 首次同步
}
onStop:
@Override protected void onStop() {
if (bound && playback != null) {
playback.removeQualityListener(qualityListener); // ★ 注销
}
handler.removeCallbacks(update);
if (bound) {
unbindService(connection);
bound = false;
playback = null;
}
super.onStop();
}
为什么在 onStop 注销? 避免 Activity 已销毁时 Service 还在回调(内存泄漏)。
4.5 布局文件
4.5.1 activity_player.xml 改 player_quality
<!-- 原来:纯展示 -->
<TextView
android:id="@+id/player_quality"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:includeFontPadding="false"
android:text="音质"
android:textColor="@color/primary"
android:textSize="11sp"
android:textStyle="bold" />
<!-- 现在:可点击胶囊按钮 -->
<TextView
android:id="@+id/player_quality"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:background="@drawable/bg_quality_chip"
android:clickable="true"
android:focusable="true"
android:includeFontPadding="false"
android:paddingLeft="10dp"
android:paddingTop="3dp"
android:paddingRight="10dp"
android:paddingBottom="3dp"
android:text="音质 · 原"
android:textColor="@color/primary"
android:textSize="11sp"
android:textStyle="bold" />
4.5.2 新增 bg_quality_chip.xml
app/src/main/res/drawable/bg_quality_chip.xml:
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1AFFFFFF" />
<corners android:radius="12dp" />
<stroke android:width="1dp" android:color="#33FFFFFF" />
</shape>
一个半透明白色的圆角胶囊,跟播放页深色主题搭配。
五、部署
5.1 后端部署
依赖 ffmpeg。CentOS 7 上最省事的方案是静态编译版:
cd /tmp
wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
tar xvf ffmpeg-release-amd64-static.tar.xz
sudo cp ffmpeg-*-static/ffmpeg /usr/local/bin/
sudo cp ffmpeg-*-static/ffprobe /usr/local/bin/
sudo chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe
# 验证(任何目录都能跑)
ffmpeg -version | head -1
为什么不折腾 EPEL?
CentOS 官方源和 EPEL 都不包含 ffmpeg(专利许可问题)。常见的解决方案有:
- 加 Nux Dextop 仓库:
http://li.nux.ro/.../nux-dextop-release-*.rpm - 加 RPM Fusion 仓库:
https://download1.rpmfusion.org/... - 用静态编译版:
johnvansickle.com/ffmpeg/
前两个虽然能装上,但依赖关系复杂,容易和其他包的依赖冲突。静态版就是两个可执行文件,扔到 PATH 里就能用,卸载就是删两个文件,最干净。
重启服务:
systemctl restart mydaoliyu-music
tail -f /var/log/mydaoliyu-music.log
启动日志应显示:
TRANSCODE_DIR = /usr/local/music_player/data/transcoded
FFMPEG_BIN = ffmpeg
5.2 Android 端部署
- 把 6 个文件对应覆盖到项目里(新增 2 个 + 替换 4 个)
Build → Clean Project→Build → Rebuild Project- 生成 APK 安装
唯一坑:bg_quality_chip.xml 必须放在 res/drawable/,不是 res/layout/。如果放错,AAPT 会报:
error: resource drawable/bg_quality_chip not found
六、验证
6.1 服务端验证(最直接)
# 看转码缓存目录
ls -lh /usr/local/music_player/data/transcoded/
# 看实时日志
tail -f /var/log/mydaoliyu-music.log | grep -i transcode
切档后应看到文件出现:
-rw-r--r-- 1 root root 7.2M xxxx_mid.mp3
-rw-r--r-- 1 root root 2.9M xxxx_low.mp3
以及日志:
2026-09-25 15:08:18 [INFO] transcode: 转码完成 [low] 周杰伦 - 定格.flac -> 2.9 MB,用时 5.0s
2026-09-25 15:08:34 [INFO] transcode: 转码完成 [low] 顺德老莫 - 得意的笑.flac -> 3.8 MB,用时 6.8s
6.2 客户端 Logcat
adb logcat -s PlaybackService:* | grep -E "prepareInternal|Prepared"
切档应出现:
prepareInternal: url http://xxx:42121/api/tracks/trk_xxx/stream?token=xxx&q=mid
Prepared stream in 1234 ms, source=url http://xxx...&q=mid
URL 里有 &q=mid 就说明客户端拼对了。
6.3 网络抓包(最铁)
Android Studio → App Inspection → Network Inspector,看三档请求:
| 档位 | Content-Type | Content-Length |
|---|---|---|
| high | audio/flac | ~30 MB |
| mid | audio/mpeg | ~7 MB |
| low | audio/mpeg | ~3 MB |
或者用 Charles / Fiddler / mitmproxy 抓包,看 q= 参数和 Content-Type。
6.4 听感对比(主观)
选一首动态范围大的曲子(古典、交响、人声清唱),来回切 high ↔ low:
| 档位 | 听感 |
|---|---|
| high | 高频延伸好、乐器分离清晰、背景黑 |
| low | 高频发闷、齿音变粗、低频松散 |
如果完全一样,说明切档没真正生效。
6.5 持久化验证
- 切到 320,杀 App 进程
- 重开 App,进入播放页
- 顶部标签应该还是"音质 · 320"
6.6 一键验证脚本
服务端跑:
#!/bin/bash
TOKEN="你的token"
TRACK="trk_xxx"
BASE="http://127.0.0.1:42121"
echo "=== 三档响应 ==="
for Q in high mid low; do
echo "--- q=$Q ---"
curl -sI "$BASE/api/tracks/$TRACK/stream?token=$TOKEN&q=$Q" \
| grep -iE 'HTTP/|content-type|content-length'
echo
done
echo "=== 转码缓存 ==="
ls -lh /usr/local/music_player/data/transcoded/ | tail -10
echo "=== 最近转码日志 ==="
tail -20 /var/log/mydaoliyu-music.log | grep -i transcode
6.7 综合判断表
| 现象 | 结论 |
|---|---|
UI 标签变了 + Logcat URL 带 &q=mid | ✅ 客户端侧生效 |
服务端 transcoded/ 出现 xxx_mid.mp3 | ✅ 服务端侧生效 |
Content-Type 从 audio/flac 变 audio/mpeg | ✅ 真正换成 mp3 流 |
| mid 文件约 7MB / low 约 3MB | ✅ 转码正确 |
| 切换时进度不归零、不中断 | ✅ setQuality 逻辑正确 |
| 杀 App 重开还是上次档位 | ✅ SharedPreferences 生效 |
| 三档听感明显不同 | ✅ 端到端全通 |
任意 3 项通过,基本可以确认功能正常。
七、踩坑记录
坑 1:EPEL 里没有 ffmpeg
yum install ffmpeg 在 CentOS 7 上一定失败。EPEL 不包含 ffmpeg(专利许可问题)。
解决:用静态编译版。不污染系统包管理,卸载就是删两个文件。
坑 2:bg_quality_chip.xml 放错目录
AAPT 编译报 resource drawable/bg_quality_chip not found。
原因:把文件放到了 res/layout/,而不是 res/drawable/。
教训:drawable 资源和 layout 是平级目录,别顺手放一起。
快速排查:
Get-ChildItem -Recurse -Filter "bg_quality_chip.xml" E:\SourceCode\FeiniuYuMusic
应该只输出一行,路径里包含 app\src\main\res\drawable\。
坑 3:切档后进度归零
第一次实现时忘了在 setQuality 里保存 pendingSeekMs。切档走 prepareCurrent,MediaPlayer 会重置,进度就丢了。
解决:
pendingSeekMs = Math.max(0, getPositionMs());
playWhenPrepared = isPlaying();
prepareCurrent(playWhenPrepared);
坑 4:切档后暂停
prepareCurrent(true) 里的 autoPlay 参数要用 isPlaying() 的值,不能写死 true。否则暂停状态下切档会突然开始播。
坑 5:高低音质共用缓存导致混播
TrackFileCache 按 guid 命名,不区分音质。如果 mid 档也写缓存,切回 high 时会误用 mp3 文件。
解决:只在 HIGH 档使用缓存,其他档位直接走 HTTP 流播。
File localFile = (allowLocalCache && currentQuality == AudioQuality.HIGH)
? trackCache.getCompleteFile(track.guid) : null;
坑 6:转码首次很慢
FLAC → 320k mp3 大约 5~15 秒(看服务器 CPU)。用户切档后要等这段时间。
当前方案:接受这个延迟(有转码日志可观察进度)。
可选优化:
- 加"转码中…" Toast 提示
- 后台预转码(不推荐,浪费磁盘)
- 转码进度推送
坑 7:transcode.py 里的 subprocess.run 需要 check=True 或手动检查 returncode
# 错误写法:ffmpeg 报错时不抛异常,后续处理会拿到半损坏的文件
result = subprocess.run(cmd, ...)
# 正确写法:显式检查 returncode
if result.returncode != 0:
raise TranscodeError(...)
因为我们要先写临时文件再 os.replace,所以不用 check=True(check=True 会立刻抛 CalledProcessError,不容易做清理)。
坑 8:转码缓存 mtime 设计
最初设计是把缓存文件名取 f"{track_id}_{quality}.mp3",源文件改动后缓存不会失效。后来改为:
python
safe_key = f"{track_id}|{quality}|{source_mtime}"
digest = hashlib.sha1(safe_key.encode()).hexdigest()[:16]
源文件改动 → mtime 变 → 缓存文件名变 → 老的缓存自动失效。同时也能避免频繁覆盖缓存文件。
八、最终效果
8.1 功能对比
| 功能 | 改动前 | 改动后 |
|---|---|---|
| 播放 URL | /stream?token=xxx | `/stream?token=xxx&q=high |
| 顶部音质标签 | 纯展示 | 可点击,显示"音质 · 320 · FLAC 24bit 96kHz" |
| 切换方式 | 无 | 点击循环:原 → 320 → 128 → 原 |
| 切换行为 | — | 保持进度和播放状态重新加载 |
| 持久化 | — | SharedPreferences 记住上次选择 |
| 本地缓存 | 所有档位共用 | 只有 HIGH 档使用 |
| 后台预下载 | 总是启动 | 只有 HIGH 档启动 |
| 服务端行为 | 只发原文件 | mid/low 触发 ffmpeg 转码缓存 |
8.2 代码量
| 类型 | 数量 |
|---|---|
| 新增文件(Android) | 2(AudioQuality.java、bg_quality_chip.xml) |
| 修改文件(Android) | 4(DaoliyuApiClient、PlaybackService、PlayerActivity、activity_player.xml) |
| 新增文件(后端) | 1(transcode.py) |
| 修改文件(后端) | 2(config.py、main.py) |
| 总净增代码 | 约 260 行 |
8.3 用户感知变化
- 播放页顶部原来的"音质"纯文字,变成小胶囊按钮
- 点一下从"原"切到"320",声音重新加载但进度不变
- 再点变"128",网络流量少很多(一首歌 30MB → 3MB)
- 杀 App 重开,还是上次选的档位
九、扩展方向
9.1 移动网络自动降级
监听网络类型:
ConnectivityManager cm = (ConnectivityManager)
getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkCapabilities caps = cm.getNetworkCapabilities(cm.getActiveNetwork());
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
// 4G → 自动切 low
playback.setQuality(AudioQuality.LOW);
} else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
// WiFi → 自动切 high
playback.setQuality(AudioQuality.HIGH);
}
搭配"仅移动网络下自动降级"的开关,用户体验更好。
9.2 下载也接音质
DownloadManagerActivity 里让下载走同一个档位:
AudioQuality q = AudioQuality.load(this);
String url = client.streamUrl(track.guid, q);
或者下载时弹出选择对话框,让用户决定下载哪个音质。
9.3 音质面板换对话框
现在是循环切换,可以改成 AlertDialog:
new AlertDialog.Builder(this)
.setTitle("选择音质")
.setSingleChoiceItems(labels, currentIdx, (dialog, which) -> {
playback.setQuality(all[which]);
dialog.dismiss();
})
.show();
9.4 服务端预转码
如果用户群体固定(比如家庭成员都在 WiFi 下听),可以在扫描后批量预转 mid 档:
# scanner.py 尾部
for row in conn.execute("SELECT id, path FROM tracks").fetchall():
src = MUSIC_ROOT / row["path"]
if src.is_file():
try:
ensure_transcoded(row["id"], src, "mid")
except Exception:
pass
不推荐一开始就开,浪费磁盘和 CPU。用户实际听的歌可能只占 20%,按需转码更经济。
9.5 转码进度可视化
在 stream_track 里加个 SSE 接口,转码过程中实时推送进度:
@app.get("/api/tracks/{id}/transcode-progress")
async def transcode_progress(track_id: str, quality: str):
# 通过读取 ffmpeg 的 stderr 解析进度
...
用户体验会好很多,但复杂度也上一个台阶。暂时没做。
十、总结
这次改动代码量不大,但涉及端到端全链路:
- 后端:ffmpeg 转码 + 缓存策略 + Range 流响应
- 客户端:枚举设计 + SharedPreferences + Service/Activity 通信
- UI:点击切换 + 状态同步 + Toast 反馈
- 运维:静态 ffmpeg 部署、systemd 配置、双服务共存
核心收获
- 枚举化比散落的字符串常量好一万倍,改一处全生效
- 切档要保进度和播放状态,用户感知是无缝的
- 本地缓存只在原档使用,避免高低音质混用
- 转码失败要优雅降级(退回原文件),而不是直接报错
- CentOS 装 ffmpeg 用静态版,别折腾 EPEL
- 缓存 key 带 mtime,源文件改了自动失效,无需手动清
验证套路
- 服务端:看
transcoded/目录 + 日志 - 客户端:看 Logcat 里 URL 带没带
&q= - 网络:抓包看
Content-Type变没变 - 耳朵:听 high ↔ low 有没差异
四层验证,任意三层通过就可以放心。
