Featured image of post 自建音乐服务音质切换功能全链路实现

自建音乐服务音质切换功能全链路实现

从后端 ffmpeg 转码、缓存策略,到 Android 端枚举设计与 UI 切换,完整实现「原 / 320k / 128k」三档音质的按需降级。含后端 Range 流响应、客户端保进度切档、缓存失效设计、CentOS 静态 ffmpeg 部署,以及端到端验证方法。

字数: 8266

自建音乐服务 · 音质切换功能全链路实现(详细版)

从后端 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 目标

  1. 后端按需转码:?q=high|mid|low 参数触发 ffmpeg 转码,缓存复用
  2. 客户端 UI 可切换:一键切档,切换不打断播放,保持进度
  3. 持久化记忆:杀 App 重开沿用上次选择
  4. 不影响现有功能:收藏、歌词、封面、队列、全屏歌词等全部照旧
  5. 优雅降级:转码失败退回原文件,不阻断播放

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)
高品质mid320k mp3 转码7MB
标准low128k 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;
        }
    }
}

为什么用枚举而不是字符串常量?

  1. 类型安全:AudioQuality.MID 比 "mid" 更容易被 IDE 提示,避免 "hi" / "hight" 这种拼写错误
  2. 行为内聚:next() 循环、load() / save() 持久化、fromKey() 反序列化,全在这一个文件里
  3. 易扩展:将来加一档 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 端部署

  1. 把 6 个文件对应覆盖到项目里(新增 2 个 + 替换 4 个)
  2. Build → Clean Project → Build → Rebuild Project
  3. 生成 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-TypeContent-Length
highaudio/flac~30 MB
midaudio/mpeg~7 MB
lowaudio/mpeg~3 MB

或者用 Charles / Fiddler / mitmproxy 抓包,看 q= 参数和 Content-Type。

6.4 听感对比(主观)

选一首动态范围大的曲子(古典、交响、人声清唱),来回切 high ↔ low:

档位听感
high高频延伸好、乐器分离清晰、背景黑
low高频发闷、齿音变粗、低频松散

如果完全一样,说明切档没真正生效。

6.5 持久化验证

  1. 切到 320,杀 App 进程
  2. 重开 App,进入播放页
  3. 顶部标签应该还是"音质 · 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 用户感知变化

  1. 播放页顶部原来的"音质"纯文字,变成小胶囊按钮
  2. 点一下从"原"切到"320",声音重新加载但进度不变
  3. 再点变"128",网络流量少很多(一首歌 30MB → 3MB)
  4. 杀 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 配置、双服务共存

核心收获

  1. 枚举化比散落的字符串常量好一万倍,改一处全生效
  2. 切档要保进度和播放状态,用户感知是无缝的
  3. 本地缓存只在原档使用,避免高低音质混用
  4. 转码失败要优雅降级(退回原文件),而不是直接报错
  5. CentOS 装 ffmpeg 用静态版,别折腾 EPEL
  6. 缓存 key 带 mtime,源文件改了自动失效,无需手动清

验证套路

  • 服务端:看 transcoded/ 目录 + 日志
  • 客户端:看 Logcat 里 URL 带没带 &q=
  • 网络:抓包看 Content-Type 变没变
  • 耳朵:听 high ↔ low 有没差异

四层验证,任意三层通过就可以放心。

发布了 13 篇文章 | 共 43.6K 字 | 总访客数: Loading | 总访问量: Loading
Hugo theme Stack designed by Jimmy | 黔ICP备2025049100号-1