Skip to content
Open
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
15 changes: 8 additions & 7 deletions electron/main/ipc/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ export const registerPlayerIpc = (): void => {
id: string,
source: string,
remainingMs: number,
preference: TransitionPreference,
preference: TransitionPreference | null,
options: LoadOptions = {},
) => {
const seq = loadSeq;
Expand All @@ -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(),
);
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,15 @@ const api = {
* @param id - 预载槽位标识
* @param source - 下一曲缓存音源路径
* @param remainingMs - 当前曲目剩余的墙钟时间
* @param preference - 曲尾交接时机与淡化时长偏好
* @param preference - 淡化偏好,null 表示原样接续预载 PCM
* @param options - 下一曲的权威元数据和播放上下文
* @returns 交接成功时返回下一曲信息
*/
transitionPrepared: (
id: string,
source: string,
remainingMs: number,
preference: TransitionPreference,
preference: TransitionPreference | null,
options: LoadOptions,
) =>
ipcRenderer.invoke("player:transitionPrepared", id, source, remainingMs, preference, options),
Expand Down
6 changes: 3 additions & 3 deletions native/audio-engine/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,16 +143,16 @@ export declare class AudioPlayer {
*/
analyzeTail(startSeconds: number, endSeconds: number): Promise<number | null>
/**
* 在当前输出流中交叉切换到已准备的下一曲
* 在当前输出流中接续已准备的下一曲,指定偏好时进行交叉淡化
* @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<JsMusicMetadata | null>
transitionToPrepared(id: string, source: string, remainingSeconds: number, preference?: string | undefined | null, nextEndSeconds?: number | undefined | null, currentEndSeconds?: number | undefined | null): Promise<JsMusicMetadata | null>
/** 创建新的播放器实例 */
constructor()
}
Expand Down
1 change: 1 addition & 0 deletions native/audio-engine/src/bindings/player/preload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
18 changes: 13 additions & 5 deletions native/audio-engine/src/bindings/player/transition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 成功交接时返回下一曲元信息,槽位失效时返回空值
Expand All @@ -50,7 +50,7 @@ impl AudioPlayer {
id: String,
source: String,
remaining_seconds: f64,
preference: String,
preference: Option<String>,
next_end_seconds: Option<f64>,
current_end_seconds: Option<f64>,
) -> Result<Option<JsMusicMetadata>> {
Expand All @@ -68,7 +68,7 @@ impl AudioPlayer {
&id,
&source,
remaining_seconds,
&preference,
preference.as_deref(),
next_end_seconds,
current_end_seconds,
)
Expand Down Expand Up @@ -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);
}
Expand All @@ -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("播放过渡未能完成,已停止失效输出"));
Expand Down
6 changes: 6 additions & 0 deletions native/audio-engine/src/decoder/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
7 changes: 4 additions & 3 deletions native/audio-engine/src/decoder/tests/transition_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,20 @@ fn stereo_buffered(samples: Vec<f32>) -> Arc<Shared> {
shared
}

/// 构造交叉淡化测试使用的可选计划
fn plan(
latest_sample: u64,
fade_samples: u64,
quiet_threshold: f32,
windows: u8,
) -> TransitionPlan {
TransitionPlan {
) -> Option<TransitionPlan> {
Some(TransitionPlan {
earliest_sample: 0,
latest_sample,
fade_samples,
quiet_threshold,
quiet_windows_required: windows,
}
})
}

#[test]
Expand Down
91 changes: 67 additions & 24 deletions native/audio-engine/src/decoder/transition_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -26,11 +27,7 @@ fn soft_ceiling(sample: f32) -> f32 {
struct TransitionCommand {
next: Box<DecoderSampleReader>,
intro_frame: Vec<f32>,
earliest_sample: u64,
latest_sample: u64,
fade_samples: u64,
quiet_threshold_sq: f32,
quiet_windows_required: u8,
plan: Option<TransitionPlan>,
started: Arc<AtomicBool>,
completed: Arc<AtomicBool>,
decision: Arc<AtomicU8>,
Expand Down Expand Up @@ -87,21 +84,30 @@ impl TransitionControl {
}
/// 将已就绪的下一曲提交到实时输出回调
/// @param shared - 下一曲的 PCM 缓冲
/// @param plan - 输出帧、能量阈值及持续窗口构成的交接计划
/// @param plan - 交叉淡化计划,省略时原样接续下一曲 PCM
/// @returns 回调开始和完成交接时置位的状态
pub fn queue(&self, shared: Arc<Shared>, plan: TransitionPlan) -> Result<TransitionSignals> {
pub fn queue(
&self,
shared: Arc<Shared>,
plan: Option<TransitionPlan>,
) -> Result<TransitionSignals> {
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));
self.set_speed_ratio(1.0);
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),
Expand Down Expand Up @@ -140,6 +146,10 @@ pub struct TransitionSource {
}

impl TransitionSource {
/// 创建可在同一输出流接续备用 PCM 的音源
/// @param shared - 当前曲的 PCM 缓冲
/// @param fft - 输出频谱分析器
/// @returns 可供设备回调读取的音源
pub fn new(shared: Arc<Shared>, fft: Arc<FftAnalyzer>) -> Self {
let channels = u64::from(shared.channels());
let energy_window_samples = (u64::from(shared.sample_rate()) / 20).max(1) * channels;
Expand All @@ -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,
Expand Down Expand Up @@ -186,6 +196,7 @@ impl TransitionSource {
}
}

/// 将旧音源交给控制线程回收,并发布接续完成状态
fn finish_transition(&mut self) {
let Some(mut transition) = self.transition.take() else {
return;
Expand All @@ -201,6 +212,7 @@ impl TransitionSource {
}

impl TransitionSource {
/// 从当前曲逐样本取数,在同一回调内接续预载 PCM 或执行交叉淡化
fn next_sample(&mut self) -> Option<f32> {
if self.transition.is_none() {
if let Some(command) = self.control.commands.pop() {
Expand All @@ -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;
Expand All @@ -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;
}

Expand All @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand Down
Loading