From 73a16522b43476759cbd0ec7e7babaf0bfd0bfe4 Mon Sep 17 00:00:00 2001 From: WorldSansha Date: Mon, 26 Oct 2026 15:47:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=BD=BD?= =?UTF-8?q?=E5=88=87=E6=AD=8C=E6=97=B6=E7=9A=84=E6=92=AD=E6=94=BE=E9=97=B4?= =?UTF-8?q?=E9=9A=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main/ipc/player.ts | 15 +-- electron/preload/index.ts | 4 +- native/audio-engine/index.d.ts | 6 +- .../src/bindings/player/preload.rs | 1 + .../src/bindings/player/transition.rs | 18 +++- native/audio-engine/src/decoder/source.rs | 6 ++ .../src/decoder/tests/transition_source.rs | 7 +- .../src/decoder/transition_source.rs | 91 +++++++++++++----- native/audio-engine/src/output/playback.rs | 7 +- native/audio-engine/src/player/crossfade.rs | 95 ++++++++++++++----- .../audio-engine/src/player/tests/preload.rs | 3 + shared/types/player.ts | 6 +- src/core/player/index.ts | 33 ++++--- src/services/autoClose.ts | 2 + src/services/nextTrackPreloader.spec.ts | 19 +++- src/services/nextTrackPreloader.ts | 95 ++++++++++++------- 16 files changed, 279 insertions(+), 129 deletions(-) diff --git a/electron/main/ipc/player.ts b/electron/main/ipc/player.ts index 3c279ce64..c2c17aa21 100644 --- a/electron/main/ipc/player.ts +++ b/electron/main/ipc/player.ts @@ -400,7 +400,7 @@ export const registerPlayerIpc = (): void => { id: string, source: string, remainingMs: number, - preference: TransitionPreference, + preference: TransitionPreference | null, options: LoadOptions = {}, ) => { const seq = loadSeq; @@ -411,15 +411,16 @@ export const registerPlayerIpc = (): void => { ? await streamingPlayback.prepareStreamingPlayback(options.meta, source) : undefined; if (seq !== loadSeq || current !== getPlayer()) return { success: false }; - playerLog.info("开始寻找曲尾交接点", { + playerLog.info(preference ? "开始寻找曲尾交接点" : "已排入下一曲 PCM,等待自然结尾", { to: options.meta?.title, preference, remainingMs: Math.round(remainingMs), }); + const cueEndMs = activeCueRange + ? activeCueRange.startMs + activeCueRange.durationMs + : undefined; const endMs = getTransitionEndMs( - activeCueRange - ? activeCueRange.startMs + activeCueRange.durationMs - : toMs(current.getDuration()), + cueEndMs ?? toMs(current.getDuration()), toMs(current.getPosition()), current.getSpeed(), ); @@ -431,11 +432,11 @@ export const registerPlayerIpc = (): void => { remainingSeconds, preference, options.meta?.cueEndMs == null ? undefined : options.meta.cueEndMs / 1000, - endMs / 1000, + preference ? endMs / 1000 : cueEndMs != null ? cueEndMs / 1000 : undefined, ); if (seq !== loadSeq || current !== getPlayer()) return { success: false }; if (!meta) { - playerLog.info("交叉过渡未启动,等待正常切歌"); + playerLog.info("预载接续未启动或已取消,沿用当前播放"); return { success: false }; } activeCueRange = cueRangeFromTrack(options.meta); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index c4acf4dbb..e5840fffe 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -96,7 +96,7 @@ const api = { * @param id - 预载槽位标识 * @param source - 下一曲缓存音源路径 * @param remainingMs - 当前曲目剩余的墙钟时间 - * @param preference - 曲尾交接时机与淡化时长偏好 + * @param preference - 淡化偏好,null 表示原样接续预载 PCM * @param options - 下一曲的权威元数据和播放上下文 * @returns 交接成功时返回下一曲信息 */ @@ -104,7 +104,7 @@ const api = { id: string, source: string, remainingMs: number, - preference: TransitionPreference, + preference: TransitionPreference | null, options: LoadOptions, ) => ipcRenderer.invoke("player:transitionPrepared", id, source, remainingMs, preference, options), diff --git a/native/audio-engine/index.d.ts b/native/audio-engine/index.d.ts index 195461d13..70c202128 100644 --- a/native/audio-engine/index.d.ts +++ b/native/audio-engine/index.d.ts @@ -143,16 +143,16 @@ export declare class AudioPlayer { */ analyzeTail(startSeconds: number, endSeconds: number): Promise /** - * 在当前输出流中交叉切换到已准备的下一曲 + * 在当前输出流中接续已准备的下一曲,指定偏好时进行交叉淡化 * @param id - 预载槽位标识 * @param source - 预载音源路径 * @param remainingSeconds - 当前曲目距离有效结束的墙钟秒数 - * @param preference - 曲尾交接时机与淡化时长偏好 + * @param preference - 淡化偏好,省略时原样接续 PCM * @param nextEndSeconds - 下一曲的 CUE 结束位置 * @param currentEndSeconds - 当前曲目的 CUE 结束位置 * @returns 成功交接时返回下一曲元信息,槽位失效时返回空值 */ - transitionToPrepared(id: string, source: string, remainingSeconds: number, preference: string, nextEndSeconds?: number | undefined | null, currentEndSeconds?: number | undefined | null): Promise + transitionToPrepared(id: string, source: string, remainingSeconds: number, preference?: string | undefined | null, nextEndSeconds?: number | undefined | null, currentEndSeconds?: number | undefined | null): Promise /** 创建新的播放器实例 */ constructor() } diff --git a/native/audio-engine/src/bindings/player/preload.rs b/native/audio-engine/src/bindings/player/preload.rs index e240fa7c5..9c99a5d34 100644 --- a/native/audio-engine/src/bindings/player/preload.rs +++ b/native/audio-engine/src/bindings/player/preload.rs @@ -139,6 +139,7 @@ impl AudioPlayer { #[napi] pub fn cancel_prepared(&self, id: String) { let mut player = self.inner.lock(); + player.cancel_prepared_transition(&id); if player.preload.id.as_deref() == Some(&id) { player.preload.clear(); } diff --git a/native/audio-engine/src/bindings/player/transition.rs b/native/audio-engine/src/bindings/player/transition.rs index 2eb408637..88034f332 100644 --- a/native/audio-engine/src/bindings/player/transition.rs +++ b/native/audio-engine/src/bindings/player/transition.rs @@ -36,11 +36,11 @@ impl TransitionWatchdog { #[napi] impl AudioPlayer { - /// 在当前输出流中交叉切换到已准备的下一曲 + /// 在当前输出流中接续已准备的下一曲,指定偏好时进行交叉淡化 /// @param id - 预载槽位标识 /// @param source - 预载音源路径 /// @param remainingSeconds - 当前曲目距离有效结束的墙钟秒数 - /// @param preference - 曲尾交接时机与淡化时长偏好 + /// @param preference - 淡化偏好,省略时原样接续 PCM /// @param nextEndSeconds - 下一曲的 CUE 结束位置 /// @param currentEndSeconds - 当前曲目的 CUE 结束位置 /// @returns 成功交接时返回下一曲元信息,槽位失效时返回空值 @@ -50,7 +50,7 @@ impl AudioPlayer { id: String, source: String, remaining_seconds: f64, - preference: String, + preference: Option, next_end_seconds: Option, current_end_seconds: Option, ) -> Result> { @@ -68,7 +68,7 @@ impl AudioPlayer { &id, &source, remaining_seconds, - &preference, + preference.as_deref(), next_end_seconds, current_end_seconds, ) @@ -115,6 +115,11 @@ impl AudioPlayer { player.emit_transition_state(true, Some(reason), Some(fade_seconds)); announced = true; } + if decision.load(Ordering::Acquire) + == crate::decoder::transition_source::TRANSITION_CANCELLED + { + break (0_u8, announced); + } if completed.load(Ordering::Acquire) { break (1_u8, announced); } @@ -135,7 +140,10 @@ impl AudioPlayer { player.emit_transition_state(false, None, None); } match outcome.0 { - 0 => return Ok(None), + 0 => { + player.finish_cancelled_transition(); + return Ok(None); + } 2 => { player.stop(); return Err(Error::from_reason("播放过渡未能完成,已停止失效输出")); diff --git a/native/audio-engine/src/decoder/source.rs b/native/audio-engine/src/decoder/source.rs index 7ce419273..e38333126 100644 --- a/native/audio-engine/src/decoder/source.rs +++ b/native/audio-engine/src/decoder/source.rs @@ -40,6 +40,12 @@ impl DecoderSampleReader { self.underrun = !self.started; } + /// 判断当前源是否因解码失败耗尽,避免将失败误当成自然曲尾 + /// @returns 解码线程已报告失败时返回 true + pub fn is_decode_failed(&self) -> bool { + self.shared.is_decode_failed() + } + pub fn is_underrun(&self) -> bool { self.underrun } diff --git a/native/audio-engine/src/decoder/tests/transition_source.rs b/native/audio-engine/src/decoder/tests/transition_source.rs index aa2ef5488..5a9c5c09f 100644 --- a/native/audio-engine/src/decoder/tests/transition_source.rs +++ b/native/audio-engine/src/decoder/tests/transition_source.rs @@ -69,19 +69,20 @@ fn stereo_buffered(samples: Vec) -> Arc { shared } +/// 构造交叉淡化测试使用的可选计划 fn plan( latest_sample: u64, fade_samples: u64, quiet_threshold: f32, windows: u8, -) -> TransitionPlan { - TransitionPlan { +) -> Option { + Some(TransitionPlan { earliest_sample: 0, latest_sample, fade_samples, quiet_threshold, quiet_windows_required: windows, - } + }) } #[test] diff --git a/native/audio-engine/src/decoder/transition_source.rs b/native/audio-engine/src/decoder/transition_source.rs index 92a1f179b..a326a1359 100644 --- a/native/audio-engine/src/decoder/transition_source.rs +++ b/native/audio-engine/src/decoder/transition_source.rs @@ -12,6 +12,7 @@ use crate::dsp::fft::FftAnalyzer; pub const TRANSITION_DECISION_QUIET: u8 = 1; const TRANSITION_DECISION_DEADLINE: u8 = 2; const TRANSITION_DECISION_SOURCE_END: u8 = 3; +pub const TRANSITION_CANCELLED: u8 = 4; /// 混音后的柔性峰值保护,避免两首相关音频在等功率曲线中硬削波 fn soft_ceiling(sample: f32) -> f32 { @@ -26,11 +27,7 @@ fn soft_ceiling(sample: f32) -> f32 { struct TransitionCommand { next: Box, intro_frame: Vec, - earliest_sample: u64, - latest_sample: u64, - fade_samples: u64, - quiet_threshold_sq: f32, - quiet_windows_required: u8, + plan: Option, started: Arc, completed: Arc, decision: Arc, @@ -87,9 +84,19 @@ impl TransitionControl { } /// 将已就绪的下一曲提交到实时输出回调 /// @param shared - 下一曲的 PCM 缓冲 - /// @param plan - 输出帧、能量阈值及持续窗口构成的交接计划 + /// @param plan - 交叉淡化计划,省略时原样接续下一曲 PCM /// @returns 回调开始和完成交接时置位的状态 - pub fn queue(&self, shared: Arc, plan: TransitionPlan) -> Result { + pub fn queue( + &self, + shared: Arc, + plan: Option, + ) -> Result { + if let Some(previous) = self.commands.pop() { + if previous.decision.load(Ordering::Acquire) != TRANSITION_CANCELLED { + let _ = self.commands.push(previous); + return Err(anyhow!("已有播放过渡正在等待提交")); + } + } let started = Arc::new(AtomicBool::new(false)); let completed = Arc::new(AtomicBool::new(false)); let decision = Arc::new(AtomicU8::new(0)); @@ -97,11 +104,10 @@ impl TransitionControl { let command = TransitionCommand { intro_frame: vec![0.0; shared.channels() as usize], next: Box::new(DecoderSampleReader::new(shared)), - earliest_sample: plan.earliest_sample, - latest_sample: plan.latest_sample, - fade_samples: plan.fade_samples.max(1), - quiet_threshold_sq: plan.quiet_threshold * plan.quiet_threshold, - quiet_windows_required: plan.quiet_windows_required, + plan: plan.map(|mut plan| { + plan.fade_samples = plan.fade_samples.max(1); + plan + }), started: Arc::clone(&started), completed: Arc::clone(&completed), decision: Arc::clone(&decision), @@ -140,6 +146,10 @@ pub struct TransitionSource { } impl TransitionSource { + /// 创建可在同一输出流接续备用 PCM 的音源 + /// @param shared - 当前曲的 PCM 缓冲 + /// @param fft - 输出频谱分析器 + /// @returns 可供设备回调读取的音源 pub fn new(shared: Arc, fft: Arc) -> Self { let channels = u64::from(shared.channels()); let energy_window_samples = (u64::from(shared.sample_rate()) / 20).max(1) * channels; @@ -158,7 +168,7 @@ impl TransitionSource { control: TransitionControl { speed_ratio: Arc::new(AtomicU32::new(1.0_f32.to_bits())), commands: Arc::new(ArrayQueue::new(1)), - retired: Arc::new(ArrayQueue::new(1)), + retired: Arc::new(ArrayQueue::new(2)), }, channels, energy_window_samples, @@ -186,6 +196,7 @@ impl TransitionSource { } } + /// 将旧音源交给控制线程回收,并发布接续完成状态 fn finish_transition(&mut self) { let Some(mut transition) = self.transition.take() else { return; @@ -201,6 +212,7 @@ impl TransitionSource { } impl TransitionSource { + /// 从当前曲逐样本取数,在同一回调内接续预载 PCM 或执行交叉淡化 fn next_sample(&mut self) -> Option { if self.transition.is_none() { if let Some(command) = self.control.commands.pop() { @@ -227,10 +239,43 @@ impl TransitionSource { } } + if self.transition.as_ref().is_some_and(|transition| { + transition.command.decision.load(Ordering::Acquire) == TRANSITION_CANCELLED + }) { + let cancelled = self.transition.take().unwrap(); + let _ = self.control.retired.push(RetiredSource { + _reader: cancelled.command.next, + _intro_frame: cancelled.command.intro_frame, + }); + } let Some(transition) = &mut self.transition else { return self.active.next(); }; let a = self.active.next(); + let Some(plan) = &transition.command.plan else { + if a.is_some() { + return a; + } + if self.active.is_decode_failed() { + transition + .command + .decision + .store(TRANSITION_CANCELLED, Ordering::Release); + return None; + } + transition + .command + .decision + .compare_exchange( + 0, + TRANSITION_DECISION_SOURCE_END, + Ordering::AcqRel, + Ordering::Acquire, + ) + .ok()?; + self.finish_transition(); + return self.active.next(); + }; transition.elapsed = transition.elapsed.saturating_add(1); transition.timeline_elapsed += self.speed_ratio; let frame_start = (transition.elapsed - 1) % self.channels == 0; @@ -253,11 +298,11 @@ impl TransitionSource { transition.intro_scanned += self.channels; let intro_limit = self .max_intro_samples - .min(transition.command.latest_sample.max(self.channels)); + .min(plan.latest_sample.max(self.channels)); transition.intro_done = audible || transition.intro_scanned >= intro_limit; } } - if transition.timeline_elapsed < transition.command.earliest_sample as f64 { + if transition.timeline_elapsed < plan.earliest_sample as f64 { return a; } @@ -271,7 +316,8 @@ impl TransitionSource { transition.energy_samples += 1; if transition.energy_samples >= self.energy_window_samples { if transition.energy_sum - < transition.command.quiet_threshold_sq * transition.energy_samples as f32 + < (plan.quiet_threshold * plan.quiet_threshold) + * transition.energy_samples as f32 { transition.quiet_windows = transition.quiet_windows.saturating_add(1); } else { @@ -281,11 +327,11 @@ impl TransitionSource { transition.energy_samples = 0; } } - let quiet_found = transition.quiet_windows >= transition.command.quiet_windows_required; + let quiet_found = transition.quiet_windows >= plan.quiet_windows_required; let start = transition.faded > 0 || (frame_start && (quiet_found - || transition.timeline_elapsed >= transition.command.latest_sample as f64 + || transition.timeline_elapsed >= plan.latest_sample as f64 || a.is_none())); if !start || !transition.intro_done { return a; @@ -314,9 +360,7 @@ impl TransitionSource { return Some(b); } if transition.faded == 0 { - let reason = if transition.timeline_elapsed < transition.command.latest_sample as f64 - && quiet_found - { + let reason = if transition.timeline_elapsed < plan.latest_sample as f64 && quiet_found { TRANSITION_DECISION_QUIET } else { TRANSITION_DECISION_DEADLINE @@ -325,14 +369,13 @@ impl TransitionSource { transition.command.started.store(true, Ordering::Release); } if transition.faded % self.channels == 0 { - let progress = - (transition.fade_progress / transition.command.fade_samples as f64).min(1.0) as f32; + let progress = (transition.fade_progress / plan.fade_samples as f64).min(1.0) as f32; (transition.gain_b, transition.gain_a) = (progress * FRAC_PI_2).sin_cos(); } let mixed = sample * transition.gain_a + b * transition.gain_b; transition.faded += 1; transition.fade_progress += self.speed_ratio; - if transition.fade_progress >= transition.command.fade_samples as f64 + if transition.fade_progress >= plan.fade_samples as f64 && transition.faded % self.channels == 0 { self.finish_transition(); diff --git a/native/audio-engine/src/output/playback.rs b/native/audio-engine/src/output/playback.rs index b67fe983a..b4c74e3aa 100644 --- a/native/audio-engine/src/output/playback.rs +++ b/native/audio-engine/src/output/playback.rs @@ -99,11 +99,14 @@ impl PlaybackHandle { }) } - /// 将备用 PCM 提交到现有输出流,由回调按音频帧执行过渡 + /// 将备用 PCM 提交到现有输出流,由回调按音频帧接续 + /// @param shared - 下一曲的就绪缓冲 + /// @param plan - 交叉淡化计划,省略时按自然结尾接续 + /// @returns 接续的开始、完成和取消状态 pub fn queue_transition( &self, shared: Arc, - plan: TransitionPlan, + plan: Option, ) -> Result { self.transition.drain_retired(); self.transition.queue(shared, plan) diff --git a/native/audio-engine/src/player/crossfade.rs b/native/audio-engine/src/player/crossfade.rs index 4e3aba295..cc80dc229 100644 --- a/native/audio-engine/src/player/crossfade.rs +++ b/native/audio-engine/src/player/crossfade.rs @@ -6,13 +6,15 @@ use anyhow::{ensure, Result}; use super::preload::PreparedPlayback; use super::{InnerPlayer, PlayerState}; use crate::decoder::buffer::Shared; -use crate::decoder::transition_source::TransitionPlan; +use crate::decoder::transition_source::{TransitionPlan, TRANSITION_CANCELLED}; use crate::dsp::{equalizer::Equalizer, tempo::StretchProcessor}; use crate::metadata::AudioMetadata; use parking_lot::Mutex; /// 在途过渡的第二路 DSP,供用户调整音效时同步两路参数 pub(super) struct TransitionDsp { + pub id: String, + pub decision: Arc, pub initial_speed: f32, pub shared: Arc, pub equalizer: Arc>, @@ -30,17 +32,27 @@ pub struct ArmedTransition { } impl InnerPlayer { - /// 校验备用槽位并在现有输出流中安排帧级交叉过渡 + /// 校验备用槽位并在当前输出流中接续,指定偏好时才进行交叉淡化 + /// @param id - 预载槽位标识 + /// @param source - 预载音源路径 + /// @param remaining_secs - 交叉淡化使用的剩余墙钟秒数 + /// @param preference - 淡化偏好,省略时保留两曲的全部 PCM + /// @param next_end_seconds - 下一曲的 CUE 终点 + /// @param current_end_seconds - 当前曲的 CUE 终点 + /// @returns 已交给输出回调的槽位,未命中时返回空值 pub fn arm_prepared_transition( &mut self, id: &str, source: &str, remaining_secs: f64, - preference: &str, + preference: Option<&str>, next_end_seconds: Option, current_end_seconds: Option, ) -> Result> { - if self.state != PlayerState::Playing || self.transitioning.load(Ordering::Acquire) { + if self.state != PlayerState::Playing + || self.is_finished() + || self.transitioning.load(Ordering::Acquire) + { return Ok(None); } let Some(output) = &self.output else { @@ -52,7 +64,7 @@ impl InnerPlayer { let Some(playback) = self.playback.as_ref().map(Arc::clone) else { return Ok(None); }; - if !remaining_secs.is_finite() || remaining_secs < 1.0 { + if preference.is_some() && (!remaining_secs.is_finite() || remaining_secs < 1.0) { return Ok(None); } let Some(ready) = self.take_prepared(Some(id), source) else { @@ -63,13 +75,13 @@ impl InnerPlayer { .unwrap_or(ready.metadata.duration_secs) .min(ready.metadata.duration_secs); let next_remaining = next_end - ready.start_position; - if !next_remaining.is_finite() || next_remaining < 2.0 { + if preference.is_some() && (!next_remaining.is_finite() || next_remaining < 2.0) { return Ok(None); } let current_end = current_end_seconds.unwrap_or(self.duration()); let remaining_secs = remaining_secs.min((current_end - self.position()).max(0.0) / f64::from(self.speed())); - if remaining_secs < 1.0 { + if preference.is_some() && remaining_secs < 1.0 { return Ok(None); } if current_end_seconds.is_some() { @@ -80,31 +92,33 @@ impl InnerPlayer { if next_end_seconds.is_some() { ready.shared.set_end_position(next_remaining); } - let (fade_limit, quiet_threshold, quiet_windows_required, search_secs) = match preference { - "conservative" => (3.0_f64, 0.04_f32, 4_u8, 1.0_f64), - "eager" => (5.0, 0.06, 3, 3.0), - _ => (3.6, 0.04, 4, 2.0), - }; - let fade_secs = fade_limit - .min(remaining_secs) - .min(next_remaining / f64::from(self.speed()) / 2.0); - let fade_samples = ((fade_secs * sample_rate as f64).round() as u64) * channels; - let latest_sample = - (((remaining_secs - fade_secs).max(0.0) * sample_rate as f64) as u64) * channels; - let earliest_sample = latest_sample - .saturating_sub(((search_secs * rate as f64) as u64) / channels * channels); - ready.shared.set_preloading(false); - self.transitioning.store(true, Ordering::Release); - let signals = playback.queue_transition( - Arc::clone(&ready.shared), + let mut fade_secs = 0.0; + let plan = preference.map(|preference| { + let (fade_limit, quiet_threshold, quiet_windows_required, search_secs) = + match preference { + "conservative" => (3.0_f64, 0.04_f32, 4_u8, 1.0_f64), + "eager" => (5.0, 0.06, 3, 3.0), + _ => (3.6, 0.04, 4, 2.0), + }; + fade_secs = fade_limit + .min(remaining_secs) + .min(next_remaining / f64::from(self.speed()) / 2.0); + let fade_samples = ((fade_secs * sample_rate as f64).round() as u64) * channels; + let latest_sample = + (((remaining_secs - fade_secs).max(0.0) * sample_rate as f64) as u64) * channels; + let earliest_sample = latest_sample + .saturating_sub(((search_secs * rate as f64) as u64) / channels * channels); + ready.shared.set_preloading(false); TransitionPlan { earliest_sample, latest_sample, fade_samples, quiet_threshold, quiet_windows_required, - }, - ); + } + }); + self.transitioning.store(true, Ordering::Release); + let signals = playback.queue_transition(Arc::clone(&ready.shared), plan); let signals = match signals { Ok(signals) => signals, Err(error) => { @@ -113,6 +127,8 @@ impl InnerPlayer { } }; self.transition_dsp = Some(TransitionDsp { + id: ready.id.clone(), + decision: Arc::clone(&signals.decision), initial_speed: ready.config.speed, shared: Arc::clone(&ready.shared), equalizer: Arc::clone(&ready.equalizer), @@ -128,7 +144,33 @@ impl InnerPlayer { })) } + /// 取消尚未接入的备用 PCM,不停止当前音频流 + /// @param id - 要取消的预载槽位标识 + pub fn cancel_prepared_transition(&mut self, id: &str) { + if let Some(next) = &self.transition_dsp { + if next.id == id { + let _ = next.decision.compare_exchange( + 0, + TRANSITION_CANCELLED, + Ordering::AcqRel, + Ordering::Acquire, + ); + } + } + } + + /// 清理已取消交接的控制状态,恢复当前曲的结束通知 + pub fn finish_cancelled_transition(&mut self) { + self.transition_dsp = None; + self.transitioning.store(false, Ordering::Release); + if let Some(playback) = &self.playback { + playback.drain_retired(); + } + } + /// 输出回调完成交接后,切换播放器的曲目状态和解码资源 + /// @param transition - 已经由输出回调接入的预载槽位 + /// @returns 下一曲元数据,已被其他播放操作取代时返回空值 pub fn commit_prepared_transition( &mut self, mut transition: ArmedTransition, @@ -138,6 +180,7 @@ impl InnerPlayer { { return None; } + transition.ready.shared.set_preloading(false); self.stop_position_timer(); if let Some(handle) = self.pending_load_handle.take() { handle.cancel(); diff --git a/native/audio-engine/src/player/tests/preload.rs b/native/audio-engine/src/player/tests/preload.rs index 9132a8a19..5f2c2f371 100644 --- a/native/audio-engine/src/player/tests/preload.rs +++ b/native/audio-engine/src/player/tests/preload.rs @@ -1,5 +1,6 @@ use super::*; +/// 调整音效时同步两路 DSP,并在停止后释放备用资源 #[test] fn transition_updates_both_dsp_slots_and_releases_them_on_stop() { let mut player = InnerPlayer::new().unwrap(); @@ -7,6 +8,8 @@ fn transition_updates_both_dsp_slots_and_releases_them_on_stop() { let equalizer = Arc::new(Mutex::new(Equalizer::new(48000, 2))); let tempo = Arc::new(Mutex::new(StretchProcessor::new(2, 48000))); player.transition_dsp = Some(crossfade::TransitionDsp { + id: "next".into(), + decision: Arc::new(std::sync::atomic::AtomicU8::new(0)), initial_speed: 1.0, shared: Arc::clone(&shared), equalizer: Arc::clone(&equalizer), diff --git a/shared/types/player.ts b/shared/types/player.ts index 93a93aff2..9ce810c9a 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -315,11 +315,11 @@ export interface PlayerApi { */ cancelPrepared(id: string): Promise; /** - * 在现有输出流中交叉切换到已准备的下一曲 + * 在现有输出流中接续已准备的下一曲 * @param id - 预载槽位标识 * @param source - 下一曲缓存音源路径 * @param remainingMs - 当前曲目剩余的墙钟时间 - * @param preference - 曲尾交接时机与淡化时长偏好 + * @param preference - 淡化偏好,null 表示原样接续预载 PCM * @param options - 下一曲的权威元数据和播放上下文 * @returns 交接成功时返回下一曲信息,未命中时返回失败响应 */ @@ -327,7 +327,7 @@ export interface PlayerApi { id: string, source: string, remainingMs: number, - preference: TransitionPreference, + preference: TransitionPreference | null, options: LoadOptions, ): Promise>; /** 加载音频(本地路径或网络地址);可选下发权威 meta 用于 SMTC/托盘 */ diff --git a/src/core/player/index.ts b/src/core/player/index.ts index ae48e2fd8..4bebbec8f 100644 --- a/src/core/player/index.ts +++ b/src/core/player/index.ts @@ -984,13 +984,14 @@ export const nextTrack = async (autoPlay = true): Promise => { await loadTrack(status.currentTrack, status.currentPlaybackContext, autoPlay); }; -/** 当前曲目是否已提交原生交叉过渡 */ +/** 当前曲目是否已提交原生 PCM 接续或交叉淡化 */ export const isSmartTransitionActive = (): boolean => transitionInFlight; /** - * 在当前曲目尾部尝试以备用槽位进行自动交接 + * 将就绪的备用 PCM 接入当前输出,启用交叉淡化时才等待曲尾窗口 * @param positionMs - 当前曲目的展示位置 - * @param preparedId - 后台通知对应的槽位,用于拒绝迟到通知 + * @param preparedId - 就绪通知对应的槽位,用于拒绝迟到通知 + * @param endPositionMs - 交叉淡化使用的有效曲尾位置 */ export const trySmartTransition = async ( positionMs: number, @@ -1001,7 +1002,6 @@ export const trySmartTransition = async ( const status = useStatusStore(); if ( transitionInFlight || - settings.player.transitionMode !== "crossfade" || !status.isPlaying || status.trackLoading || status.fmMode || @@ -1011,12 +1011,15 @@ export const trySmartTransition = async ( ) { return; } - const remainingMs = (endPositionMs ?? status.duration) - positionMs; + const crossfade = settings.player.transitionMode === "crossfade"; + const remainingMs = + (crossfade ? (endPositionMs ?? status.duration) : status.duration) - positionMs; const remainingWallMs = remainingMs / Math.max(status.speed, 0.1); if ( - !Number.isFinite(remainingWallMs) || - remainingWallMs > TRANSITION_LOOKAHEAD_MS[settings.player.transitionPreference] || - remainingWallMs < 1000 + crossfade && + (!Number.isFinite(remainingWallMs) || + remainingWallMs > TRANSITION_LOOKAHEAD_MS[settings.player.transitionPreference] || + remainingWallMs < 1000) ) return; const candidate = getNextTrackCandidate({ @@ -1028,7 +1031,7 @@ export const trySmartTransition = async ( shuffleMode: status.shuffleMode, }); if (!candidate) return; - const prepared = beginPreparedTransition(candidate.track, preparedId); + const prepared = beginPreparedTransition(candidate.track, preparedId, !crossfade); if (!prepared?.preparedId || !prepared.source) return; const shouldLog = lastLoggedTransitionId !== prepared.preparedId; if (shouldLog) lastLoggedTransitionId = prepared.preparedId; @@ -1040,8 +1043,8 @@ export const trySmartTransition = async ( const result = await window.api.player.transitionPrepared( prepared.preparedId, prepared.source.source, - remainingMs / Math.max(status.speed, 0.1), - settings.player.transitionPreference, + remainingWallMs, + crossfade ? settings.player.transitionPreference : null, { meta: candidate.track, context: status.currentPlaybackContext, @@ -1049,13 +1052,13 @@ export const trySmartTransition = async ( }, ); if (token !== trackToken) { - if (shouldLog) console.info("[player:transition] 交叉过渡已被新的播放操作取消"); + if (shouldLog) console.info("[player:transition] 音频交接已被新的播放操作取消"); return; } if (!result.success || !result.data) { if (shouldLog) { console.warn( - "[player:transition] 未能完成交叉过渡,等待正常切歌", + "[player:transition] 预载接续未完成,沿用当前播放", result.error ?? "备用槽位未命中或播放器状态已变化", ); } @@ -1454,7 +1457,9 @@ export const initPlayer = async (): Promise => { console.error("[player] requestSnapshot failed", error); } // 下一首预载的监听器 - installNextTrackPreloadWatchers(); + installNextTrackPreloadWatchers((id) => { + void trySmartTransition(useStatusStore().position, id).catch(console.warn); + }); scheduleNextTrackPreload(); }; diff --git a/src/services/autoClose.ts b/src/services/autoClose.ts index 68565e334..9ffb92d36 100644 --- a/src/services/autoClose.ts +++ b/src/services/autoClose.ts @@ -8,6 +8,7 @@ import { useStatusStore } from "@/stores/status"; import * as player from "@/core/player"; +import { invalidateNextTrackPreload } from "@/services/nextTrackPreloader"; /** 倒计时 setInterval 句柄 */ let tickHandle: ReturnType | null = null; @@ -39,6 +40,7 @@ const tick = (): void => { if (autoClose.waitSongEnd) { // 等本曲播完再停;标记一下,由 onTrackEnded 钩子处理 pendingPauseOnEnd = true; + invalidateNextTrackPreload(); } else { player.pause().catch(() => {}); cancel(); diff --git a/src/services/nextTrackPreloader.spec.ts b/src/services/nextTrackPreloader.spec.ts index 2ef56769d..9f9dab2ea 100644 --- a/src/services/nextTrackPreloader.spec.ts +++ b/src/services/nextTrackPreloader.spec.ts @@ -33,6 +33,9 @@ const mocks = vi.hoisted(() => ({ playIndex: 0, fmMode: false, shuffleMode: "off", + repeatMode: "list", + abLoop: { enable: false }, + autoClose: { enable: false }, }, candidate: { track: { id: "next", source: "netease" } }, resolve: vi.fn(), @@ -253,7 +256,9 @@ describe("下一曲真实预载", () => { preloader.scheduleNextTrackPreload(); await flushPromises(); expect(mocks.prepare).toHaveBeenCalledOnce(); - expect(preloader.peekPreparedTrack(mocks.candidate.track as Track)?.preparedId).toBeDefined(); + expect( + preloader.consumePreloadedTrack(mocks.candidate.track as Track)?.preparedId, + ).toBeDefined(); }); it.each([ @@ -277,7 +282,9 @@ describe("下一曲真实预载", () => { undefined, "standard", ); - expect(preloader.peekPreparedTrack(mocks.candidate.track as Track)?.preparedId).toBeDefined(); + expect( + preloader.consumePreloadedTrack(mocks.candidate.track as Track)?.preparedId, + ).toBeDefined(); }); it("关闭缓存时本地 CUE 仍按片段起点准备", async () => { @@ -302,7 +309,9 @@ describe("下一曲真实预载", () => { 120000, "standard", ); - expect(preloader.peekPreparedTrack(mocks.candidate.track as Track)?.preparedId).toBeDefined(); + expect( + preloader.consumePreloadedTrack(mocks.candidate.track as Track)?.preparedId, + ).toBeDefined(); }); it("等待缓存完成后才准备原生槽位,并将缓存路径与代次交给切歌", async () => { @@ -445,6 +454,8 @@ describe("下一曲真实预载", () => { expect( preloader.beginPreparedTransition(mocks.candidate.track as Track, "old-slot"), ).toBeNull(); - expect(preloader.peekPreparedTrack(mocks.candidate.track as Track)?.preparedId).toBeTruthy(); + expect( + preloader.consumePreloadedTrack(mocks.candidate.track as Track)?.preparedId, + ).toBeTruthy(); }); }); diff --git a/src/services/nextTrackPreloader.ts b/src/services/nextTrackPreloader.ts index 65ebb1554..d77a3578f 100644 --- a/src/services/nextTrackPreloader.ts +++ b/src/services/nextTrackPreloader.ts @@ -30,6 +30,12 @@ let cachedResult: NextTrackPreloadResult | null = null; let currentContextKey: string | null = null; let pendingCover: HTMLImageElement | null = null; let transitionPreparedId: string | null = null; +let continuousContext: { + key: string; + currentTrackId: string | undefined; + playIndex: number; +} | null = null; +let onPrepared: ((id: string) => void) | undefined; let stopContextWatch: (() => void) | null = null; /** @@ -96,9 +102,10 @@ const preloadCover = async (url: string): Promise => { */ export const invalidateNextTrackPreload = (): void => { currentToken++; + const id = nativePreloadId ?? (continuousContext ? transitionPreparedId : null); + if (id) void window.api.player.cancelPrepared(id).catch(console.warn); preloadAbort?.abort(); preloadAbort = null; - if (nativePreloadId) void window.api.player.cancelPrepared(nativePreloadId).catch(console.warn); nativePreloadId = null; cachedResult = null; currentContextKey = null; @@ -115,21 +122,12 @@ export const invalidateNextTrackPreload = (): void => { * @returns 匹配的预载结果,未命中或已作废则返回 null */ export const consumePreloadedTrack = (track: Track): NextTrackPreloadResult | null => { - if (!useSettingsStore().player.preloadNextTrack) { - invalidateNextTrackPreload(); - return null; - } - const currentKey = buildContextKey(track); - if (!cachedResult) { - invalidateNextTrackPreload(); - return null; - } - if (cachedResult.trackId !== track.id) { - invalidateNextTrackPreload(); - return null; - } - if (cachedResult.contextKey !== currentKey) { - // 上下文已改变(如切换了音质),当前缓存作废 + if ( + !useSettingsStore().player.preloadNextTrack || + !cachedResult || + cachedResult.trackId !== track.id || + cachedResult.contextKey !== buildContextKey(track) + ) { invalidateNextTrackPreload(); return null; } @@ -143,32 +141,35 @@ export const consumePreloadedTrack = (track: Track): NextTrackPreloadResult | nu }; /** - * 查询与候选曲目匹配的原生备用槽位,不改变其所有权 - * @param track - 即将交接的目标曲目 - * @returns 仍有效且原生 PCM 已就绪的预载结果 - */ -export const peekPreparedTrack = (track: Track): NextTrackPreloadResult | null => { - if (!useSettingsStore().player.preloadNextTrack) return null; - if (cachedResult?.trackId !== track.id || !cachedResult.preparedId) return null; - return cachedResult.contextKey === buildContextKey(track) ? cachedResult : null; -}; - -/** - * 将备用槽位所有权交给在途交接,设置变更从下一次预载开始生效 + * 将备用槽位交给输出,直接接续期间仍按队列和播放策略取消候选 * @param track - 本次交接的目标曲目 * @param expectedId - 后台通知对应的槽位标识 + * @param continuous - 是否等待自然结尾直接接续,期间允许作废候选 * @returns 已接管的预载结果,迟到通知或槽位失效时返回 null */ export const beginPreparedTransition = ( track: Track, expectedId?: string, + continuous = false, ): NextTrackPreloadResult | null => { - if (transitionPreparedId) return null; - const prepared = peekPreparedTrack(track); - if (!prepared?.source || (expectedId && prepared.preparedId !== expectedId)) return null; + const preparedId = cachedResult?.preparedId; + if ( + transitionPreparedId || + !preparedId || + !cachedResult?.source || + (expectedId && preparedId !== expectedId) + ) + return null; const result = consumePreloadedTrack(track); - if (!result?.preparedId) return null; - transitionPreparedId = result.preparedId; + if (!result) return null; + transitionPreparedId = preparedId; + continuousContext = continuous + ? { + key: result.contextKey, + currentTrackId: useStatusStore().currentTrack?.id, + playIndex: useStatusStore().playIndex, + } + : null; return result; }; @@ -179,6 +180,7 @@ export const beginPreparedTransition = ( export const finishPreparedTransition = (id: string): void => { if (transitionPreparedId !== id) return; transitionPreparedId = null; + continuousContext = null; void window.api.player.cancelPrepared(id).catch(console.warn); scheduleNextTrackPreload(); }; @@ -187,7 +189,7 @@ export const finishPreparedTransition = (id: string): void => { * 调度下一首预载任务 */ export const scheduleNextTrackPreload = (): void => { - if (transitionPreparedId) return; + if (transitionPreparedId && !continuousContext) return; const settings = useSettingsStore(); if (!settings.player.preloadNextTrack) { invalidateNextTrackPreload(); @@ -228,8 +230,20 @@ export const scheduleNextTrackPreload = (): void => { return; } const contextKey = buildContextKey(candidateTrack); + if (continuousContext) { + if ( + continuousContext.key !== contextKey || + continuousContext.currentTrackId !== currentTrack.id || + continuousContext.playIndex !== status.playIndex || + status.repeatMode === "one" || + status.abLoop.enable + ) + invalidateNextTrackPreload(); + return; + } // 上下文指纹一致且已有缓存,避免重复触发 if (cachedResult && cachedResult.contextKey === contextKey) { + if (cachedResult.preparedId) onPrepared?.(cachedResult.preparedId); preloadLyricForTrack(candidateTrack); return; } @@ -296,6 +310,7 @@ export const scheduleNextTrackPreload = (): void => { nativePreloadId = null; } cachedResult = { trackId: candidateTrack.id, source, contextKey, preparedId }; + if (preparedId) onPrepared?.(preparedId); if (!preparedId) { console.info("[player:preload] 下一曲音源已准备,PCM 未预载", { trackId: candidateTrack.id, @@ -315,8 +330,12 @@ export const scheduleNextTrackPreload = (): void => { })(); }; -/** 安装预载上下文监听 */ -export const installNextTrackPreloadWatchers = (): void => { +/** + * 安装预载上下文监听,并在 PCM 就绪时通知播放控制器 + * @param prepared - 就绪槽位的接续回调 + */ +export const installNextTrackPreloadWatchers = (prepared?: (id: string) => void): void => { + onPrepared = prepared; if (stopContextWatch) return; const settings = useSettingsStore(); const status = useStatusStore(); @@ -335,6 +354,9 @@ export const installNextTrackPreloadWatchers = (): void => { status.state, status.fmMode, status.shuffleMode, + status.repeatMode, + status.abLoop.enable, + status.autoClose.enable, settings.preset.skipKeywordsSongs, settings.preset.skipTrackKeywords.join(","), queue.queue.value, @@ -367,5 +389,6 @@ export const installNextTrackPreloadWatchers = (): void => { export const disposeNextTrackPreload = (): void => { stopContextWatch?.(); stopContextWatch = null; + onPrepared = undefined; invalidateNextTrackPreload(); };