fix(ncm §25): 切音质后重播同一曲被卡「缓冲中」——play intent 以 seq 标识(同 idx 再 play 重启换档)

真机反馈:切音质后再播同一首歌,声音已出但播放页恒显示「缓冲中」。

根因(app/play/player_manager.c):同 idx 的新播放意图不被解码主循环识别。
切音质后重播同曲 = player_manager_play(idx),而 idx 与在播曲相同——cur_song 不变
(仍是 idx)、state 由 PLAYING 置 BUFFER、seq++。旧仍_current(cur==idx && state==BUFFER
恒真)与解码主循环(只认 cur!=idx / STOPPED)均不放过,于是:
  ①旧曲(旧档位)继续出声、state 停在 BUFFER → UI 恒「缓冲中…」(dl_done=0 无百分比);
  ②新档位下载/缓存条目从未发起;
  ③旧曲收尾后外层把 BUFFER 当「自然播完」顺延下一首——新档位永不生效。
三条取数路径(缓存命中/边下边播/.part续传)统一受影响(根因在 intent 层)。

修法:play intent 以 seq 标识,层层判别「我的 intent 是否仍有效」:
  - 新增 still_intent(idx, my_seq)(cur==idx && state==BUFFER && seq==my_seq),取代
    仍_current(同 idx 重播时 seq 变即判过期),贯穿 预缓冲/重试退避/物化/解码 open 各护栏;
  - run_song 入口捕获 my_seq,沿 song_local_file/song_download_attempt 下传;
  - 解码主循环 seq != my_seq → break 回外层重跑 run_song(换档重播);
  - 外层「自然播完」收紧为 state==PLAYING,不把 BUFFER(挂起新 intent)当播完顺延下一首。

验证:player_flow_sim 新增 P12(同 idx 重播换档重启)——修前恒 PLAYING 不了/键仍旧档/
downloads=0/eff=-1;修后 state 回 PLAYING、下载新档位、缓存键 1000_lossless、eff_level
按非会员钳顶回填 EXHIGH。P1-P11 零回归(宿主+qemu-mipsel ALL PASS)。build_ncm.sh
自有代码 -Wall -Wextra 0 warning。0 真实 NCM 请求。未部署真机(留主会话)。
REPORT.md 另记(docs commit)。
This commit is contained in:
MeowOS committed 2026-09-01 20:30:59 +08:00
1 parent 51281e609f
commit a01e5ca18a
4 files changed
+79 -18

No files matched your search

+45 -18
View File
@@ -17,6 +17,12 @@
* 下载完成由播放线程 commit(rename);缓存命中用 final=size 的静态 stream_src(P1: * 下载完成由播放线程 commit(rename);缓存命中用 final=size 的静态 stream_src(P1:
* 不再 mmap 整文件);http 30s 绝对 deadline 已改空闲超时(P2)。.part 续传/LRU 防淘汰 * 不再 mmap 整文件);http 30s 绝对 deadline 已改空闲超时(P2)。.part 续传/LRU 防淘汰
* 留阶段 3。 * 留阶段 3。
* §25:切音质后重播同一曲——同 idx 再 play 时 cur/state 都不变(cur 同、state 由 PLAYING
* 置 BUFFER),解码主循环此前只认 cur!=idx / STOPPED,导致:
* ①旧曲(旧档)继续出声而 state 停在 BUFFER(UI 恒「缓冲中」);②旧曲收尾后外层把
* BUFFER 当「自然播完」顺延下一首,新档位永不生效。修法:以 seq 标识 play intent
* (still_intent 取代仍_current),decode 主循环 seq 变化即 break,外层「自然播完」仅指
* state==PLAYING——同 idx 新 intent 回到外层重跑 run_song(换档重播)。
*/ */
#include "../ncm_app_internal.h" #include "../ncm_app_internal.h"
#include <stdio.h> #include <stdio.h>
@@ -292,12 +298,16 @@ static void audio_cache_key(char *out, size_t sz, const char *id, int level)
snprintf(out, sz, "%s_%s", id, ncm_api_level_key(level)); snprintf(out, sz, "%s_%s", id, ncm_api_level_key(level));
} }
/* 切歌竞态护栏(§18):物化/下载/预缓冲期间用户可能已切歌——只有仍是当前目标且处于 /* 同曲重播护栏(§25 切音质后重播同一曲,替代 §18 的 still_current):仅 cur/state 不够——
* BUFFER(本次播放意图未过期)才继续,否则直接回外层(外层会以新 cur 重跑 run_song)。 */ * 切音质时 play_manager_play
static int still_current(int idx) * 用的 idx 与在播曲相同(cur 不变)且置 state=BUFFER,仍会被 still_current 当「我的 intent
* 有效」而漏判。play/next 都会 seq++(新播放意图),故以 seq 判别「本次 intent 是否仍有效」。
* 调用方需持有 run_song 开始时捕获的 my_seq(本曲 intent 的 seq)。 */
static int still_intent(int idx, int my_seq)
{ {
pthread_mutex_lock(&g_play.mu); pthread_mutex_lock(&g_play.mu);
int ok = (g_play.cur_song == idx) && (g_play.state == NCM_PC_BUFFER); int ok = (g_play.cur_song == idx) && (g_play.state == NCM_PC_BUFFER) &&
(g_play.seq == my_seq);
pthread_mutex_unlock(&g_play.mu); pthread_mutex_unlock(&g_play.mu);
return ok; return ok;
} }
@@ -306,7 +316,7 @@ static int still_current(int idx)
* 返回:NCM_OK=src 就绪(下载在后台继续);DL_RETRYABLE=预缓冲前网络失败(调用方整首重试); * 返回:NCM_OK=src 就绪(下载在后台继续);DL_RETRYABLE=预缓冲前网络失败(调用方整首重试);
* DL_ABORTED=切歌/退出(静默放弃,不重试);其它负数=硬错误(不重试)。 * DL_ABORTED=切歌/退出(静默放弃,不重试);其它负数=硬错误(不重试)。
* *real_err 回填预缓冲失败的真实错误码。失败时 ss->w/src/dl_thr 已清理干净。 */ * *real_err 回填预缓冲失败的真实错误码。失败时 ss->w/src/dl_thr 已清理干净。 */
static int song_download_attempt(const ncm_song_t *s, int idx, const char *key, int want, static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const char *key, int want,
song_src_t *ss, char *path, size_t psz, int64_t *bytes, song_src_t *ss, char *path, size_t psz, int64_t *bytes,
int *real_err) int *real_err)
{ {
@@ -366,16 +376,16 @@ static int song_download_attempt(const ncm_song_t *s, int idx, const char *key,
ss->dl_thr_on = 1; ss->dl_thr_on = 1;
/* 预缓冲:≥128KB(minimp3 强制门槛,F3)才放行 open_cb;final/failed 提前出。 /* 预缓冲:≥128KB(minimp3 强制门槛,F3)才放行 open_cb;final/failed 提前出。
* still_current 护栏覆盖本阻塞点:等待期间切歌/停止 → 保留 .part 静默放弃。 */ * still_intent 护栏覆盖本阻塞点:等待期间切歌/停止/同曲重播 → 保留 .part 静默放弃。 */
for (;;) { for (;;) {
int wr = stream_src_wait(ss->src, PREBUFFER_BYTES, 100); int wr = stream_src_wait(ss->src, PREBUFFER_BYTES, 100);
if (wr != 0) break; if (wr != 0) break;
if (g_app_quit || !still_current(idx)) { if (g_app_quit || !still_intent(idx, seq)) {
stream_src_abort(ss->src); stream_src_abort(ss->src);
pthread_join(ss->dl_thr, NULL); ss->dl_thr_on = 0; pthread_join(ss->dl_thr, NULL); ss->dl_thr_on = 0;
ncm_cache_abort_part(ss->w); ss->w = NULL; /* 切歌/退出:保留残体供下次续传 */ ncm_cache_abort_part(ss->w); ss->w = NULL; /* 切歌/退出:保留残体供下次续传 */
stream_src_close(ss->src); ss->src = NULL; stream_src_close(ss->src); ss->src = NULL;
return DL_ABORTED; /* 调用方 still_current 复核后静默返回 */ return DL_ABORTED; /* 调用方 still_intent 复核后静默返回 */
} }
} }
if (stream_src_aborted(ss->src)) { if (stream_src_aborted(ss->src)) {
@@ -411,7 +421,7 @@ static int song_download_attempt(const ncm_song_t *s, int idx, const char *key,
* (8=免费听低音质,播放允许)。本地缓存命中优先于门禁(缓存可播不拦)。 * (8=免费听低音质,播放允许)。本地缓存命中优先于门禁(缓存可播不拦)。
* - VIP 档上限:用户档超会员权限不硬拒,播放时先钳到会员允许的最高档再逐级降级 * - VIP 档上限:用户档超会员权限不硬拒,播放时先钳到会员允许的最高档再逐级降级
* (免去注定空 URL 的高档请求);实际生效档回填 g_play.eff_level。 */ * (免去注定空 URL 的高档请求);实际生效档回填 g_play.eff_level。 */
static int song_local_file(const ncm_song_t *s, int idx, song_src_t *ss, static int song_local_file(const ncm_song_t *s, int idx, int seq, song_src_t *ss,
char *path, size_t psz, int64_t *bytes) char *path, size_t psz, int64_t *bytes)
{ {
memset(ss, 0, sizeof *ss); memset(ss, 0, sizeof *ss);
@@ -467,10 +477,10 @@ static int song_local_file(const ncm_song_t *s, int idx, song_src_t *ss,
meow_log(MEOW_LOG_WARN, "play: download retry %d/%d id=%s backoff=%dms", meow_log(MEOW_LOG_WARN, "play: download retry %d/%d id=%s backoff=%dms",
attempt, STREAM_RETRY_MAX, id, STREAM_RETRY_BACKOFF_MS); attempt, STREAM_RETRY_MAX, id, STREAM_RETRY_BACKOFF_MS);
usleep(STREAM_RETRY_BACKOFF_MS * 1000); /* 退避:不轰炸 */ usleep(STREAM_RETRY_BACKOFF_MS * 1000); /* 退避:不轰炸 */
if (g_app_quit || !still_current(idx)) return NCM_ERR_IO; if (g_app_quit || !still_intent(idx, seq)) return NCM_ERR_IO;
} }
int real_err = NCM_ERR_NET; int real_err = NCM_ERR_NET;
int rc = song_download_attempt(s, idx, key, want, ss, path, psz, bytes, &real_err); int rc = song_download_attempt(s, idx, seq, key, want, ss, path, psz, bytes, &real_err);
if (rc == NCM_OK) return NCM_OK; if (rc == NCM_OK) return NCM_OK;
if (rc == DL_RETRYABLE) { last_rc = real_err; continue; } /* 重试整首 */ if (rc == DL_RETRYABLE) { last_rc = real_err; continue; } /* 重试整首 */
return rc; /* DL_ABORTED / 硬错误:不重试 */ return rc; /* DL_ABORTED / 硬错误:不重试 */
@@ -492,12 +502,21 @@ static void run_song(int idx)
{ {
/* §17 滑窗兼容:物化目标曲(窗内命中 / 盘缓存 / song_detail 单曲)并整体快照进 /* §17 滑窗兼容:物化目标曲(窗内命中 / 盘缓存 / song_detail 单曲)并整体快照进
* g_play.play_song——之后本曲所有字段读快照,窗口滑出当前播放曲不影响播放。 */ * g_play.play_song——之后本曲所有字段读快照,窗口滑出当前播放曲不影响播放。 */
/* §25:本条 run_song 服务的 play intent 用 seq 标识(play/next 递增)。切音质后重播
* 同一曲(同 idx 再 play)也会 seq++,故「我的 intent 是否仍有效」必须以 seq 判别
* (仅 cur/state 不够:同 idx 重播时 cur 不变、state 仍 BUFFER,详见 still_intent)。 */
pthread_mutex_lock(&g_play.mu);
int my_seq = g_play.seq;
int my_cur = g_play.cur_song;
pthread_mutex_unlock(&g_play.mu);
if (my_cur != idx) return; /* 外层已同步过,防御 */
ncm_song_t s; ncm_song_t s;
if (appstate_queue_song(idx, &s) != 0) { /* §23:按 queue_src 取(窗口 或 搜索队列) */ if (appstate_queue_song(idx, &s) != 0) { /* §23:按 queue_src 取(窗口 或 搜索队列) */
set_err(NCM_ERR_ARG, "load failed"); set_err(NCM_ERR_ARG, "load failed");
return; return;
} }
if (!still_current(idx)) return; /* 物化期间已切歌/停止:不写过期快照 */ if (!still_intent(idx, my_seq)) return; /* 物化期间已切歌/重播:不写过期快照 */
pthread_mutex_lock(&g_play.mu); pthread_mutex_lock(&g_play.mu);
g_play.play_song = s; g_play.play_song = s;
g_play.play_song_valid = 1; g_play.play_song_valid = 1;
@@ -506,16 +525,16 @@ static void run_song(int idx)
song_src_t ss; song_src_t ss;
int64_t file_bytes = 0; int64_t file_bytes = 0;
char path[512]; char path[512];
int rc = song_local_file(&s, idx, &ss, path, sizeof path, &file_bytes); int rc = song_local_file(&s, idx, my_seq, &ss, path, sizeof path, &file_bytes);
if (rc != NCM_OK) { if (rc != NCM_OK) {
if (!still_current(idx)) return; /* 下载期间已切歌:静默放弃(已落盘部分不亏) */ if (!still_intent(idx, my_seq)) return; /* 期间已切歌/同曲重播:静默放弃(已落盘部分不亏) */
const char *m = (rc == NCM_ERR_VIP) ? "VIP only (fee gate)" const char *m = (rc == NCM_ERR_VIP) ? "VIP only (fee gate)"
: (rc == NCM_ERR_NOTFOUND) ? "no stream (VIP/copyright)" : (rc == NCM_ERR_NOTFOUND) ? "no stream (VIP/copyright)"
: (rc == NCM_ERR_AUTH) ? "session expired" : "load failed"; : (rc == NCM_ERR_AUTH) ? "session expired" : "load failed";
set_err(rc, m); set_err(rc, m);
return; return;
} }
if (!still_current(idx)) { /* 预缓冲完成但已切歌:勿开解码/勿动状态 */ if (!still_intent(idx, my_seq)) { /* 预缓冲完成但已切歌/同曲重播:勿开解码/勿动状态 */
song_src_dispose(&ss); song_src_dispose(&ss);
return; return;
} }
@@ -531,7 +550,7 @@ static void run_song(int idx)
int64_t total_frames = 0; int64_t total_frames = 0;
if (ncm_dec_open_src(&dec, ss.src, &rate, &ch, &bits, &total_frames) != 0) { if (ncm_dec_open_src(&dec, ss.src, &rate, &ch, &bits, &total_frames) != 0) {
song_src_dispose(&ss); song_src_dispose(&ss);
if (!still_current(idx)) return; /* open 期间(flac 元数据等待)已切歌 */ if (!still_intent(idx, my_seq)) return; /* open 期间(flac 元数据等待)已切歌/重播 */
set_err(NCM_ERR_IO, "decode open failed"); return; set_err(NCM_ERR_IO, "decode open failed"); return;
} }
if (dur <= 0 && total_frames > 0 && rate > 0) dur = total_frames * 1000 / rate; if (dur <= 0 && total_frames > 0 && rate > 0) dur = total_frames * 1000 / rate;
@@ -570,10 +589,16 @@ static void run_song(int idx)
int64_t seek_ms = g_play.cmd_seek_ms; int64_t seek_ms = g_play.cmd_seek_ms;
int cur = g_play.cur_song; int cur = g_play.cur_song;
int st = g_play.state; int st = g_play.state;
int seq = g_play.seq;
pthread_mutex_unlock(&g_play.mu); pthread_mutex_unlock(&g_play.mu);
if (cur != idx) { meow_log(MEOW_LOG_INFO, "play: switch idx=%d -> %d", idx, cur); break; } /* 切歌 → 回外层 */ if (cur != idx) { meow_log(MEOW_LOG_INFO, "play: switch idx=%d -> %d", idx, cur); break; } /* 切歌 → 回外层 */
if (st == NCM_PC_STOPPED) { meow_log(MEOW_LOG_INFO, "play: stopped idx=%d", idx); break; } if (st == NCM_PC_STOPPED) { meow_log(MEOW_LOG_INFO, "play: stopped idx=%d", idx); break; }
if (seq != my_seq) { /* §25:同 idx 新播放意图(切音质后重播同一曲)——seq 变
* 即新 intent,必须打断当前曲回外层重跑(换档重播)。 */
meow_log(MEOW_LOG_INFO, "play: new intent idx=%d seq=%d->%d -> restart", idx, my_seq, seq);
break; /* 当前曲(旧档)立即停,外层按新 cur/state 重跑 run_song */
}
if (cmd == NCM_CTL_SEEK && seek_ms >= 0) { need_seek_ms = (int)seek_ms; } if (cmd == NCM_CTL_SEEK && seek_ms >= 0) { need_seek_ms = (int)seek_ms; }
@@ -688,9 +713,11 @@ static void *player_thread(void *arg)
int cur2 = g_play.cur_song; int cur2 = g_play.cur_song;
int st2 = g_play.state; int st2 = g_play.state;
pthread_mutex_unlock(&g_play.mu); pthread_mutex_unlock(&g_play.mu);
/* 自然播完且无新命令 → 顺延下一首;否则回到循环等待 */ /* 自然播完(decode 循环正常退出,state 仍 PLAYING)且无新命令 → 顺延下一首;
* 若是因同 idx 重播(seq 变、state=BUFFER)返回,则循环顶会按当前 cur/state 重跑
* run_song(§25:切音质后重播同一曲换档生效),**不得**当播完顺延下一首。 */
int qc = appstate_queue_count(); int qc = appstate_queue_count();
if (cur2 >= 0 && cur2 < qc && st2 != NCM_PC_STOPPED && cur2 == cur) { if (cur2 >= 0 && cur2 < qc && st2 == NCM_PC_PLAYING && cur2 == cur) {
int nx = cur + 1; int nx = cur + 1;
if (nx < qc) { player_manager_next(1); } if (nx < qc) { player_manager_next(1); }
else { player_manager_stop(); } else { player_manager_stop(); }
+34
View File
@@ -11,6 +11,9 @@
* P4 下载途中切歌 → 旧曲下载完成后**不得**把状态改回 PLAYING/写过期快照 * P4 下载途中切歌 → 旧曲下载完成后**不得**把状态改回 PLAYING/写过期快照
* (still_current 护栏;旧代码在切歌后 ~下载完成点 会把 BUFFER 覆盖成 PLAYING); * (still_current 护栏;旧代码在切歌后 ~下载完成点 会把 BUFFER 覆盖成 PLAYING);
* P5 错误后 play(cur) 重试 → 状态重新走 BUFFER→PLAYING,err 清零。 * P5 错误后 play(cur) 重试 → 状态重新走 BUFFER→PLAYING,err 清零。
* P12 切音质后重播同一曲(§25):同 idx 在播时再 play —— 修前被解码主循环忽略
* (旧曲继续出声、state 恒 BUFFER/「缓冲中」,收尾被外层当播完顺延下一首);
* 修后 seq 变化即重启换档,状态回 PLAYING、新档位下载、eff_level 回填。
* *
* 编译(宿主,ncm_app 目录下): * 编译(宿主,ncm_app 目录下):
* gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ * gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \
@@ -508,6 +511,37 @@ int main(void)
CHECK(player_manager_error() != 0, "err set after retry exhausted (rc=%d)", player_manager_error()); CHECK(player_manager_error() != 0, "err set after retry exhausted (rc=%d)", player_manager_error());
g_dl_fail = 0; g_dl_fail = 0;
printf("== P12: 切音质后重播同一曲(同 idx 在播时再 play)-> 换档重启,非卡「缓冲中」(§25)==\n");
usleep(300 * 1000);
g_eof = 0; g_reads_left = -1; g_cache_ok = 1; g_url_rc = NCM_OK; g_fee = 0;
g_part_size = 0; g_http_status = 200; g_416_first = 0; g_dl_delay_ms = 0; g_dl_fail = 0;
g_vip = NCM_VIP_NONE; g_writes = 0; g_downloads = 0; g_url_calls = 0; g_last_key[0] = 0;
/* 以 standard 档播放 song0(缓存命中,PLAYING) */
player_manager_set_quality(NCM_LEVEL_STANDARD, 10);
player_manager_play(0);
CHECK(wait_state(NCM_PC_PLAYING, 3000), "song0 playing at standard (cache hit)");
CHECK(g_writes > 0, "standard song actually emitting audio (writes=%d)", g_writes);
/* 切音质到 lossless,然后重播同一曲(同 idx=0 再次 play)。
* 新档位 = 新缓存条目(lookup 必 miss)→ 如实置 g_cache_ok=0 强制走下载(边下边播)。 */
player_manager_set_quality(NCM_LEVEL_LOSSLESS, 10);
g_cache_ok = 0;
player_manager_play(0);
/* §25 修前:同 idx 重播被解码主循环忽略(cur 不变、state 仍 BUFFER)→ 旧曲继续出声、
* 恒「缓冲中」,且旧曲收尾被外层当「自然播完」顺延下一首(新档位永不生效)。
* 修后:seq 变化即 break → 外层重跑 run_song(0) 换档下载 → 状态回到 PLAYING
* (UI 显示采样率/时间而非「缓冲中」)。 */
CHECK(wait_state(NCM_PC_PLAYING, 4000),
"re-tap same idx -> back to PLAYING (expect 换档开播,非卡 缓冲中)");
CHECK(g_downloads >= 1, "re-tap started new-quality download (downloads=%d)", g_downloads);
CHECK(strcmp(g_last_key, "1000_lossless") == 0,
"re-tap cache key=%s (expect 1000_lossless = 新档位)", g_last_key);
/* sim 账号非会员 → lossless 被 §20 钳顶到 EXHIGH;关键是 eff_level 已按新意图回填
* (UI 据此显示实际生效档,不再停 BUFFER/「缓冲中」)。 */
CHECK(player_manager_eff_level() == NCM_LEVEL_EXHIGH,
"re-tap eff_level=%d (expect EXHIGH=非会员钳顶的新档意图)", player_manager_eff_level());
g_eof = 1;
wait_state(NCM_PC_STOPPED, 3000);
usleep(200 * 1000); usleep(200 * 1000);
player_manager_stop_thread(); player_manager_stop_thread();
printf(g_fail ? "== PLAYER FLOW SIM: FAIL ==\n" : "== PLAYER FLOW SIM: ALL PASS ==\n"); printf(g_fail ? "== PLAYER FLOW SIM: FAIL ==\n" : "== PLAYER FLOW SIM: ALL PASS ==\n");
Binary file not shown.
Binary file not shown.