Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions doc/player-state-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@

## 10. 从决策到实现

### 10.1 实现清单(图外执行票,本图不建票、不落代码)
### 10.1 实现清单(已由图外执行票落地)

| 编号 | 内容 |
|---|---|
Expand All @@ -178,9 +178,9 @@
| C5 | 播放列表错误路径是否补 `ErrorAction::SkipTrack` 检查(§6.3 登记项) |
| C6 | 回调顺序 / 幂等 / 必须上报清单的补强用例(不改既有数值) |

实现路线沿用 #53 第 10 章:`/to-spec` 折叠 linked decisions → `/to-tickets` 拆执行票 + 原生阻塞边 → `/implement-spec` 在 frontier 上并行 implementer 子 agent、integration branch、一次 `/code-review`。**本图收口时 spec 刚产出,尚无可执行的执行票,故本轮不启动 `/implement-spec`。**
实现路线沿用 #53 第 10 章:`/to-spec` 折叠 linked decisions → `/to-tickets` 拆执行票 + 原生阻塞边 → `/implement-spec` 在 integration branch 上实现、一次 `/code-review`。**该链已于 2026-11-08 跑完**:Spec [#85](https://github.com/wt159/SoundBridge/issues/85)、执行票 [#86](https://github.com/wt159/SoundBridge/issues/86)–[#92](https://github.com/wt159/SoundBridge/issues/92)、集成分支 `implement/player-state-contract`;C1–C6 全部落地,逐项落点见 §10.4。

### 10.2 `player.h` 建议注释文本(交 C3 落地,本图不改代码)
### 10.2 `player.h` 建议注释文本(已由 [T01 / #86](https://github.com/wt159/SoundBridge/issues/86) 落地)

```
// 回调在 SDK 内部单一串行线程上同步发出,不在调用者线程、不并发。
Expand All @@ -193,6 +193,22 @@

- fog「公开 API 的兼容 / 演进策略」→ 因不改签名而闭合,删除(§4)。
- fog「能力查询是否独立成 API」→ 裁定不需要,删除(§7)。
- fog「契约漂移的长期观测口径」→ 不在本图收口,作为后续执行 effort 的输入;若像 #68 的 StreamingBudget 一样只记录不设门槛,须写明「不作为失败门禁」。
- fog「契约漂移的长期观测口径」→ 本图与后续实现 effort 均未裁决,仍交后续 effort;若像 #68 的 StreamingBudget 一样只记录不设门槛,须写明「不作为失败门禁」。
- **已知代价**:三态无法区分「准备中」与「停止」,调用方只能靠 `trackCount()` / 回调组合判断;error→state 的顺序调整会让播放列表错误路径的 UI 提示相对状态晚一步,属可接受代价(避免 autoSkip 场景下先发一个不存在的状态)。
- 端到端 drift / 切换开销 / 观测指标判据仍是 #42 残留 fog 与 #68 交棒的另一 effort,与本图无关。

### 10.4 实现落地记录(2026-11-08)

| 编号 | 落点 |
|---|---|
| C1 | `sdk/MusicPlayer.cpp::onMusicPlayListError` 先 `onError`、仅在「不跳轨 / 跳轨失败」时发状态;`updatePlayState` 以「最近真正发出去的状态」去重 |
| C2 | `reportPosition` 统一 seek / 消费循环 / 切歌三个位置出口,用 `m_positionFloor` + `m_positionTrackIndex` + `m_positionEpoch` 保证同曲单调、换曲归零、旧会话在途任务失效 |
| C3 | `sdk/include/soundbridge/player.h` 按 §10.2 落地五条注释 |
| C4 | `sdk/test/unit/test_player_integration.cpp` 两条空操作判据(无曲目 `duration()==0` / 起播后超界 seek) |
| C5 | 播放列表错误路径补 `ErrorAction::SkipTrack` 检查;`sdk/test/unit/test_error.cpp` 的 action 表锁住口径输入值 |
| C6 | 曲目三连、重复命令幂等、状态序列无相邻重复的补强用例 |

- **回归与审查**:本地 `ctest -j4` 14/14 绿(含坏输入四段与覆盖度);一次 `code-review` 提出的 4 条 Major(会话代际漏钳制、状态去重键错误、用例与目标路径错配、裸 sleep / 无声卡 flaky)与 4 条 Minor 已在一个修复提交里全部处理,细节见各票 resolution comment。既有容差数值、坏输入 55 例、14 个 `sdk_*` 目标与 TIMEOUT 全部未动。
- **务实取舍**:C1–C6 都改同一批文件,且本机 Qt/ffmpeg 依赖与 `build/` 缓存绑定主工作区,多 worktree 独立编译不可行;故 implementer 按拓扑序在同一 integration branch 上逐票提交,task graph、原生阻塞边与一次 `code-review` 均保留。
- **已知判据缺口**:C1 的 error→state 顺序与 C5 的「设备错误不跳轨」缺端到端判据 —— 现有接缝无法稳定注入「带状态变化的错误」,而新增可注入接缝违反 §8「不新造接缝」。这两条由生产链改动 + 代码审查 + 全量回归锁定;将来若出现可控的错误注入接缝,应补判据。
- **fog**:契约漂移的长期观测口径本 effort 未裁决,交后续 effort;若只记录不设门槛,须写明「不作为失败门禁」。
120 changes: 101 additions & 19 deletions sdk/MusicPlayer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ class MusicPlayer::Impl : public MusicPlayListCallback {

private:
void updatePlayState(MusicPlayerState state);
void reportPosition(uint64_t positionMs, int trackIndex, bool resetFloor,
uint64_t epoch);
void bumpPositionSession();
std::string newTraceId();
void bumpTraceId(const char *reason);
bool shouldAutoSkipOnError() const;
Expand Down Expand Up @@ -89,6 +92,18 @@ class MusicPlayer::Impl : public MusicPlayListCallback {
std::atomic<bool> m_shuttingDown;
// 本次播放(一次 _play)里本次曲目的解码失败只上报一次,避免 10ms 消费循环刷屏
std::atomic<bool> m_decodeErrorHandled;
// 契约(规格 §3 / §5 / #88):同曲会话内 onPositionChanged 单调不回退、换曲归零。
// 六个回调都在同一条 WorkQueue 串行线程发出,这里只钳制二次 asyncRunTask
// 回投 priority_queue 时同刻乱序带来的旧值;position() 查询口径不变。
std::atomic<uint64_t> m_positionFloor;
std::atomic<int> m_positionTrackIndex;
// 会话代际:seek / 换曲 / 从停机重播都会递增,让回投队列里同曲的旧任务整体失效,
// 否则 reset 之前已投递、携带较大值的 position 任务会把新会话的基准重新抬高。
std::atomic<uint64_t> m_positionEpoch;
// 状态通知去重以「最近真正发出去的事件」为键,而不是 m_state 的初值:
// 初值 Stopped 从未对外发过,首播失败 / skip 失败时仍必须通知一次。
bool m_stateNotified;
MusicPlayerState m_lastReportedState;
WorkQueue m_workQueue;
WorkQueue::TaskID m_consumerTaskID;
};
Expand All @@ -105,6 +120,11 @@ MusicPlayer::Impl::Impl(MusicPlayerListener *lister, std::string &logDir)
, m_switching(false)
, m_shuttingDown(false)
, m_decodeErrorHandled(false)
, m_positionFloor(0)
, m_positionTrackIndex(-1)
, m_positionEpoch(0)
, m_stateNotified(false)
, m_lastReportedState(MusicPlayerState::StoppedState)
, m_workQueue()
, m_consumerTaskID(WorkQueue::TaskID_Error)
{
Expand Down Expand Up @@ -275,6 +295,9 @@ void MusicPlayer::Impl::_play()
m_decodeErrorHandled.store(false);
if (m_state == MusicPlayerState::StoppedState) {
bumpTraceId("play");
// 一次 _play 从停机起步 = 新的播放会话:递增代际并归零基准,
// 让在途旧任务失效,且重播同曲(不经过 putMusicPlayListCurBuf)不被上一轮 floor 钳住。
bumpPositionSession();
}
MusicPropertiesPtr cur;
{
Expand Down Expand Up @@ -389,7 +412,9 @@ void MusicPlayer::Impl::_setPosition(uint64_t pos)
// 消费端不再自己维护字节偏移,避免 seek 目标与播放进度两套口径互相打架。
cur->signalProperties.curPositionMs = pos;
task->decoder->seekToMs(pos);
m_listener->onMusicPlayerPositionChanged(pos);
// seek 是合法回退:重置单调基准并立即上报一次(规格 §3 / §7)。
bumpPositionSession();
reportPosition(pos, static_cast<int>(cur->index), true, m_positionEpoch.load());
}

void MusicPlayer::Impl::_next()
Expand Down Expand Up @@ -531,9 +556,14 @@ void MusicPlayer::Impl::consumeAudioData()
signalProperties.curPositionMs = curPositionMs;

if (!isShuttingDown()) {
m_workQueue.asyncRunTask([this, curPositionMs]() {
if (!isShuttingDown()) {
m_listener->onMusicPlayerPositionChanged(curPositionMs);
const int trackIndex = static_cast<int>(cur->index);
const uint64_t epoch = m_positionEpoch.load();
m_workQueue.asyncRunTask([this, curPositionMs, trackIndex, epoch]() {
// 会话代际已变(seek / 换曲 / 重播)的迟到任务整体丢弃;
// 旧曲的迟到任务也不得写进新曲会话。
if (!isShuttingDown() && epoch == m_positionEpoch.load()
&& trackIndex == m_positionTrackIndex.load()) {
reportPosition(curPositionMs, trackIndex, false, epoch);
}
});
}
Expand Down Expand Up @@ -652,7 +682,10 @@ void MusicPlayer::Impl::putMusicPlayListCurBuf(MusicPropertiesPtr property)
}
m_listener->onMusicPlayerListCurrentIndexChanged(property->index);
m_listener->onMusicPlayerDurationChanged(property->signalProperties.durationMs);
m_listener->onMusicPlayerPositionChanged(property->signalProperties.curPositionMs);
// 新曲会话:递增代际、重置单调基准,位置从该曲的 curPositionMs(通常 0)重新起算。
bumpPositionSession();
reportPosition(property->signalProperties.curPositionMs,
static_cast<int>(property->index), true, m_positionEpoch.load());
}

void MusicPlayer::Impl::updateMusicList(std::vector<MusicPropertiesPtr> &list)
Expand Down Expand Up @@ -697,32 +730,81 @@ void MusicPlayer::Impl::onMusicPlayListError(ErrorCode code, const std::string &
m_audioDev->stop();
m_audioDev->close();
}
updatePlayState(MusicPlayerState::StoppedState);
// 契约(规格 §3 / #87):原因先于结果,onError 在状态变化之前。
if (m_listener != nullptr) {
m_listener->onMusicPlayerError(code, detail, index, path, m_traceId);
}
if (shouldAutoSkipOnError()) {
m_skipFailures.fetch_add(1);
m_workQueue.asyncRunTask([this]() {
if (isShuttingDown()) {
return;
}
if (m_musicList->skipToNextPlayable()) {
_play();
} else {
LogPrintfWithTrace(SdkLogLevel::Warning, LOG_TAG, m_traceId,
"auto-skip failed: no playable track");
}
});
// 规格 §6.3 / #90:与 handleDecodeError 统一,自动跳轨以 SkipTrack 为准;
// 设备错误是 StopPlayback,不该靠跳轨绕过。
const bool canAutoSkip = shouldAutoSkipOnError()
&& GetErrorInfo(code).action == ErrorAction::SkipTrack;
if (!canAutoSkip) {
// 不跳轨:停在当前处置结果上,发且仅发一次状态事件。
updatePlayState(MusicPlayerState::StoppedState);
return;
}
// autoSkip 成功时状态可能根本不变(切歌前后都是 Playing,不闪 Stopped);
// 只有跳轨失败才补那「一次状态事件」,避免先发一个不存在的状态。
m_skipFailures.fetch_add(1);
m_workQueue.asyncRunTask([this]() {
if (isShuttingDown()) {
return;
}
if (m_musicList->skipToNextPlayable()) {
_play();
} else {
LogPrintfWithTrace(SdkLogLevel::Warning, LOG_TAG, m_traceId,
"auto-skip failed: no playable track");
updatePlayState(MusicPlayerState::StoppedState);
}
});
}

void MusicPlayer::Impl::bumpPositionSession()
{
m_positionEpoch.fetch_add(1);
m_positionFloor.store(0);
}

void MusicPlayer::Impl::reportPosition(uint64_t positionMs, int trackIndex, bool resetFloor,
uint64_t epoch)
{
if (isShuttingDown() || m_listener == nullptr) {
return;
}
// 会话代际已变(seek / 换曲 / 重播)的迟到任务整体丢弃,不能让它把新基准抬高。
if (epoch != m_positionEpoch.load()) {
return;
}
// 回调单线程串行(#76 事实基线),普通 load/store 即可;这里只做通知侧的钳制,
// 不回写 signalProperties.curPositionMs,也不影响 position() 的实时查询。
if (resetFloor || trackIndex != m_positionTrackIndex.load()) {
m_positionTrackIndex.store(trackIndex);
m_positionFloor.store(positionMs);
m_listener->onMusicPlayerPositionChanged(positionMs);
return;
}
if (positionMs < m_positionFloor.load()) {
return;
}
m_positionFloor.store(positionMs);
m_listener->onMusicPlayerPositionChanged(positionMs);
}

void MusicPlayer::Impl::updatePlayState(MusicPlayerState state)
{
if (isShuttingDown()) {
return;
}
// 契约(规格 §3 / §4):重复控制命令不重复发 onStateChanged,状态序列无相邻重复。
// 去重键是「最近对外发出的状态」而非 m_state:初值 Stopped 从未发过,
// 首播即失败 / skip 失败时仍要通知一次,不能静默停在 Stopped。
if (m_stateNotified && m_lastReportedState == state) {
return;
}
m_state.store(state);
m_lastReportedState = state;
m_stateNotified = true;
m_listener->onMusicPlayerStateChanged(state);
}

Expand Down
7 changes: 7 additions & 0 deletions sdk/include/soundbridge/player.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ struct PlayerConfig {
bool autoSkipOnError = true;
};

// 回调在 SDK 内部单一串行线程上同步发出,不在调用者线程、不并发。
// 调用方自行 marshal 到自己的线程。回调内禁止调用 Player 的任何控制命令,
// 禁止析构 Player,禁止抛异常。callbacks 由调用方持有,须活过 Player 析构;
// 停机期间不保证也不发送任何回调。
//
// 完整契约(状态取值、回调顺序、控制命令语义、错误先后)见
// doc/player-state-contract.md。
class PlayerCallbacks {
public:
virtual ~PlayerCallbacks() = default;
Expand Down
Loading
Loading