mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e6006408d | |||
| e770656a74 | |||
| 103c84895e | |||
| 75b56cdb41 | |||
| cdb22fba8e | |||
| 0ea783b395 |
@@ -95,6 +95,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* The theme scheduler can now switch your day/night theme pair based on your operating system's light/dark setting, in addition to the existing time-of-day schedule. Pick the trigger with a new Time of Day / System Theme switch; in system mode the two pickers read as Light and Dark theme. On Linux setups where the OS does not signal the change live, a hint notes it applies after restarting the app.
|
||||
|
||||
### AutoDJ — waveform edge-mix blend with min/max length
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1167](https://github.com/Psychotoxical/psysonic/pull/1167)**, algorithm by [@peri4ko](https://github.com/peri4ko)
|
||||
|
||||
* AutoDJ now blends tracks using the **shape of the audio at each edge** — it measures how each track ends and how the next one begins, then crossfades over exactly that musical region with matched gain curves instead of a generic equal-power fade. Transitions follow real fade-outs and intros more closely; smooth skip uses the same model from your current position.
|
||||
* Blend curves use **equal-power interpolation** between waveform-derived gain endpoints (constant perceived loudness during the overlap, like classic crossfade), and **hard loud↔loud** pairs get at least a ~2 s overlap so stabs and abrupt endings still feel blended.
|
||||
* New **Transition length** controls under Settings → Audio → Track transitions: optional **Min** and **Max** bounds (each with an **Auto** setting) cap how short or long an AutoDJ blend can be. Auto leaves the length entirely up to the audio.
|
||||
* Classic Crossfade and Gapless are unchanged.
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
|
||||
@@ -25,6 +25,27 @@ use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::state::{ChainedInfo, PreloadedTrack};
|
||||
|
||||
/// AutoDJ edge-mix linear blend parameters (additive `audio_play` arg). Sent by
|
||||
/// the frontend only in AutoDJ mode when an edge-mix plan is available; absent
|
||||
/// ⇒ the classic equal-power sin/cos crossfade path is used unchanged.
|
||||
///
|
||||
/// Field names are the locked contract (snake_case) from the implementation
|
||||
/// spec §16.5 — the frontend object literal uses these exact keys.
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize)]
|
||||
pub struct AutodjLinearMixParams {
|
||||
/// Desired mix overlap length (clamped 0.5..12 s, then to A's remaining).
|
||||
pub mix_secs: f32,
|
||||
/// Outgoing A gain at mix start — always 1.0 (carried for completeness).
|
||||
#[allow(dead_code)]
|
||||
pub outgoing_gain_start: f32,
|
||||
/// Outgoing A gain at mix end = 1 − linear_A(0); held after the fade.
|
||||
pub outgoing_gain_end: f32,
|
||||
/// Incoming B gain at mix start = 1 − linear_B(0).
|
||||
pub incoming_gain_start: f32,
|
||||
/// Incoming B gain at mix end — always 1.0.
|
||||
pub incoming_gain_end: f32,
|
||||
}
|
||||
|
||||
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `analysis_track_id`: Subsonic `song.id` from the UI — ties waveform/loudness
|
||||
@@ -75,6 +96,9 @@ pub async fn audio_play(
|
||||
// AutoDJ smooth skip: short outgoing fade when the user hits next/previous
|
||||
// while a track is playing. Optional; only honoured when `manual` is true.
|
||||
manual_autodj_blend: Option<bool>,
|
||||
// AutoDJ edge-mix: linear blend plan (mix length + gain endpoints). Present
|
||||
// only in AutoDJ mode with a valid plan; absent ⇒ equal-power sin/cos path.
|
||||
autodj_linear_mix: Option<AutodjLinearMixParams>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -263,20 +287,46 @@ pub async fn audio_play(
|
||||
0.0
|
||||
};
|
||||
|
||||
// AutoDJ edge-mix: when a linear blend plan is present (and crossfade is
|
||||
// active for this swap) it replaces the equal-power fade shape on BOTH sides
|
||||
// — B rises linearly from `incoming_gain_start`, A falls linearly to
|
||||
// `outgoing_gain_end` and holds. `actual_mix_secs` mirrors `actual_fade_secs`:
|
||||
// the planned mix length clamped to A's measured remaining audio.
|
||||
let autodj_mix = autodj_linear_mix.filter(|_| crossfade_enabled);
|
||||
let actual_mix_secs: f32 = match autodj_mix {
|
||||
Some(m) => {
|
||||
let mix = m.mix_secs.clamp(0.5, 12.0);
|
||||
let cur = state.current.lock().unwrap();
|
||||
let remaining = (cur.duration_secs - cur.position()) as f32;
|
||||
remaining.min(mix).clamp(0.1, mix)
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
let autodj_in: Option<(f32, f32)> = autodj_mix
|
||||
.map(|m| (m.incoming_gain_start.clamp(0.0, 1.0), m.incoming_gain_end.clamp(0.0, 1.0)));
|
||||
let outgoing_linear_end_gain: Option<f32> =
|
||||
autodj_mix.map(|m| m.outgoing_gain_end.clamp(0.0, 1.0));
|
||||
|
||||
// Fade-in duration for Track B:
|
||||
// crossfade → equal-power sin(t·π/2) over actual remaining time of Track A
|
||||
// hard cut → 5 ms micro-fade to suppress DC-offset click
|
||||
let fade_in_dur = if crossfade_enabled {
|
||||
// edge-mix → linear lerp over the planned mix length (A's remaining)
|
||||
// crossfade → equal-power sin(t·π/2) over actual remaining time of Track A
|
||||
// hard cut → 5 ms micro-fade to suppress DC-offset click
|
||||
let fade_in_dur = if autodj_mix.is_some() {
|
||||
Duration::from_secs_f32(actual_mix_secs)
|
||||
} else if crossfade_enabled {
|
||||
Duration::from_secs_f32(actual_fade_secs)
|
||||
} else {
|
||||
Duration::from_millis(5)
|
||||
};
|
||||
|
||||
// Outgoing (Track A) fade-out, decoupled from B's fade-in. Defaults to
|
||||
// `actual_fade_secs` (symmetric crossfade, today's behaviour); a `Some(0)`
|
||||
// override means A already fades out in the recording, so we leave it at
|
||||
// full engine gain (scenario A). Never longer than A's remaining audio.
|
||||
let outgoing_fade_secs: f32 = if crossfade_enabled {
|
||||
// Outgoing (Track A) fade-out, decoupled from B's fade-in. Edge-mix uses the
|
||||
// planned mix length with a linear fade-to-hold; otherwise defaults to
|
||||
// `actual_fade_secs` (symmetric crossfade) — a `Some(0)` override means A
|
||||
// already fades out in the recording, so we leave it at full engine gain
|
||||
// (scenario A). Never longer than A's remaining audio.
|
||||
let outgoing_fade_secs: f32 = if autodj_mix.is_some() {
|
||||
actual_mix_secs
|
||||
} else if crossfade_enabled {
|
||||
match outgoing_fade_secs_override {
|
||||
Some(v) => v.max(0.0).min(actual_fade_secs),
|
||||
None => actual_fade_secs,
|
||||
@@ -302,6 +352,7 @@ pub async fn audio_play(
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
autodj_in,
|
||||
},
|
||||
&state,
|
||||
&app,
|
||||
@@ -471,9 +522,12 @@ pub async fn audio_play(
|
||||
gain_linear,
|
||||
fadeout_trigger: built.fadeout_trigger,
|
||||
fadeout_samples: built.fadeout_samples,
|
||||
fadeout_linear: built.fadeout_linear,
|
||||
fadeout_end_gain: built.fadeout_end_gain,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
outgoing_fade_secs,
|
||||
outgoing_linear_end_gain,
|
||||
start_paused,
|
||||
});
|
||||
|
||||
@@ -693,6 +747,7 @@ pub async fn audio_chain_preload(
|
||||
target_rate,
|
||||
format_hint.as_deref(),
|
||||
hi_res_enabled,
|
||||
None, // gapless chain never uses the AutoDJ linear edge-mix
|
||||
).map_err(|e| e.to_string())?;
|
||||
let source = built.source;
|
||||
let duration_secs = built.duration_secs;
|
||||
|
||||
@@ -702,8 +702,15 @@ pub(crate) fn parse_gapless_info(data: &[u8]) -> GaplessInfo {
|
||||
GaplessInfo { delay_samples: delay, total_valid_samples: total_valid }
|
||||
}
|
||||
|
||||
// The incoming fade-in is type-erased through `DynSource` so the same stack type
|
||||
// covers both the equal-power (classic crossfade) and linear (AutoDJ edge-mix)
|
||||
// fade-in envelopes selected at build time.
|
||||
pub(crate) type BuiltSourceStack =
|
||||
PriorityBoostSource<CountingSource<NotifyingSource<TriggeredFadeOut<EqualPowerFadeIn<EqSource<DynSource>>>>>>;
|
||||
PriorityBoostSource<CountingSource<NotifyingSource<TriggeredFadeOut<DynSource>>>>;
|
||||
|
||||
/// Incoming linear fade-in endpoints for the AutoDJ edge-mix (track B):
|
||||
/// `(incoming_gain_start, incoming_gain_end)`. `None` ⇒ classic equal-power fade.
|
||||
pub(crate) type AutodjFadeIn = Option<(f32, f32)>;
|
||||
|
||||
/// Result of build_source: the fully-wrapped source plus metadata and control Arcs.
|
||||
pub(crate) struct BuiltSource {
|
||||
@@ -715,6 +722,30 @@ pub(crate) struct BuiltSource {
|
||||
pub(crate) fadeout_trigger: Arc<AtomicBool>,
|
||||
/// Total samples for the fade-out (set before triggering).
|
||||
pub(crate) fadeout_samples: Arc<AtomicU64>,
|
||||
/// AutoDJ edge-mix: set at handoff for a linear fade-to-hold (else cos→0).
|
||||
pub(crate) fadeout_linear: Arc<AtomicBool>,
|
||||
/// AutoDJ edge-mix: outgoing end gain (f32 bits) the linear fade holds at.
|
||||
pub(crate) fadeout_end_gain: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
/// Build the fade stage shared by both builders: type-erase the chosen fade-in
|
||||
/// envelope, then wrap in the (generalised) triggered fade-out.
|
||||
fn build_fade_stage(
|
||||
eq_src: EqSource<DynSource>,
|
||||
fade_in_dur: Duration,
|
||||
autodj_in: AutodjFadeIn,
|
||||
fadeout_trigger: Arc<AtomicBool>,
|
||||
fadeout_samples: Arc<AtomicU64>,
|
||||
fadeout_linear: Arc<AtomicBool>,
|
||||
fadeout_end_gain: Arc<AtomicU32>,
|
||||
) -> TriggeredFadeOut<DynSource> {
|
||||
let fade_in: DynSource = match autodj_in {
|
||||
Some((start, end)) => {
|
||||
DynSource::new(LinearGainEnvelopeIn::new(eq_src, fade_in_dur, start, end))
|
||||
}
|
||||
None => DynSource::new(EqualPowerFadeIn::new(eq_src, fade_in_dur)),
|
||||
};
|
||||
TriggeredFadeOut::new(fade_in, fadeout_trigger, fadeout_samples, fadeout_linear, fadeout_end_gain)
|
||||
}
|
||||
|
||||
/// Build a fully-prepared playback source:
|
||||
@@ -742,6 +773,7 @@ pub(crate) fn build_source(
|
||||
target_rate: u32,
|
||||
format_hint: Option<&str>,
|
||||
hi_res: bool,
|
||||
autodj_in: AutodjFadeIn,
|
||||
) -> Result<BuiltSource, String> {
|
||||
let gapless = parse_gapless_info(&data);
|
||||
|
||||
@@ -805,12 +837,21 @@ pub(crate) fn build_source(
|
||||
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
let fadeout_linear = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_end_gain = Arc::new(AtomicU32::new(0));
|
||||
|
||||
let rate_src = PlaybackRateSource::new(dyn_src, playback_rate.clone());
|
||||
let rate_dyn = DynSource::new(rate_src);
|
||||
let eq_src = EqSource::new(rate_dyn, eq_gains, eq_enabled, eq_pre_gain);
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
let fade_out = build_fade_stage(
|
||||
eq_src,
|
||||
fade_in_dur,
|
||||
autodj_in,
|
||||
fadeout_trigger.clone(),
|
||||
fadeout_samples.clone(),
|
||||
fadeout_linear.clone(),
|
||||
fadeout_end_gain.clone(),
|
||||
);
|
||||
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||||
let counting = CountingSource::new(notifying, sample_counter);
|
||||
let boosted = PriorityBoostSource::new(counting);
|
||||
@@ -822,6 +863,8 @@ pub(crate) fn build_source(
|
||||
output_channels: channels.get(),
|
||||
fadeout_trigger,
|
||||
fadeout_samples,
|
||||
fadeout_linear,
|
||||
fadeout_end_gain,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -841,6 +884,7 @@ pub(crate) fn build_streaming_source(
|
||||
sample_counter: Arc<AtomicU64>,
|
||||
target_rate: u32,
|
||||
count_gate: Option<Arc<AtomicBool>>,
|
||||
autodj_in: AutodjFadeIn,
|
||||
) -> Result<BuiltSource, String> {
|
||||
let sample_rate = decoder.sample_rate();
|
||||
let channels = decoder.channels();
|
||||
@@ -874,12 +918,21 @@ pub(crate) fn build_streaming_source(
|
||||
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
let fadeout_linear = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_end_gain = Arc::new(AtomicU32::new(0));
|
||||
|
||||
let rate_src = PlaybackRateSource::new(dyn_src, playback_rate.clone());
|
||||
let rate_dyn = DynSource::new(rate_src);
|
||||
let eq_src = EqSource::new(rate_dyn, eq_gains, eq_enabled, eq_pre_gain);
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
let fade_out = build_fade_stage(
|
||||
eq_src,
|
||||
fade_in_dur,
|
||||
autodj_in,
|
||||
fadeout_trigger.clone(),
|
||||
fadeout_samples.clone(),
|
||||
fadeout_linear.clone(),
|
||||
fadeout_end_gain.clone(),
|
||||
);
|
||||
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||||
let counting = match count_gate {
|
||||
Some(gate) => CountingSource::new_gated(notifying, sample_counter, gate),
|
||||
@@ -894,6 +947,8 @@ pub(crate) fn build_streaming_source(
|
||||
output_channels: channels.get(),
|
||||
fadeout_trigger,
|
||||
fadeout_samples,
|
||||
fadeout_linear,
|
||||
fadeout_end_gain,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1212,6 +1267,7 @@ mod build_source_tests {
|
||||
0,
|
||||
Some("wav"),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("build_source must succeed for a valid WAV");
|
||||
assert_eq!(built.output_channels, 1);
|
||||
@@ -1235,6 +1291,7 @@ mod build_source_tests {
|
||||
0,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
@@ -1256,6 +1313,7 @@ mod build_source_tests {
|
||||
sample_counter,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("build_streaming_source must succeed for a valid WAV decoder");
|
||||
assert_eq!(built.output_channels, 1);
|
||||
@@ -1279,6 +1337,7 @@ mod build_source_tests {
|
||||
48_000,
|
||||
Some("wav"),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("resampled build_source must succeed");
|
||||
assert_eq!(built.output_rate, 48_000);
|
||||
|
||||
@@ -162,6 +162,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
fade_in_dur: std::time::Duration::from_millis(5),
|
||||
hi_res_enabled,
|
||||
duration_hint: snap.duration_secs,
|
||||
autodj_in: None,
|
||||
},
|
||||
&engine,
|
||||
app,
|
||||
@@ -210,9 +211,12 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
gain_linear: snap.gain_linear,
|
||||
fadeout_trigger: ps.built.fadeout_trigger,
|
||||
fadeout_samples: ps.built.fadeout_samples,
|
||||
fadeout_linear: ps.built.fadeout_linear,
|
||||
fadeout_end_gain: ps.built.fadeout_end_gain,
|
||||
crossfade_enabled: false,
|
||||
actual_fade_secs: 0.0,
|
||||
outgoing_fade_secs: 0.0,
|
||||
outgoing_linear_end_gain: None,
|
||||
start_paused: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -141,6 +141,11 @@ pub struct AudioCurrent {
|
||||
pub fadeout_trigger: Option<Arc<AtomicBool>>,
|
||||
/// Crossfade: total fade samples (set before triggering).
|
||||
pub fadeout_samples: Option<Arc<AtomicU64>>,
|
||||
/// AutoDJ edge-mix: set true at handoff to fade this source linearly to
|
||||
/// `fadeout_end_gain` then hold (instead of the equal-power cos → 0).
|
||||
pub fadeout_linear: Option<Arc<AtomicBool>>,
|
||||
/// AutoDJ edge-mix: outgoing end gain (f32 bits) the linear fade holds at.
|
||||
pub fadeout_end_gain: Option<Arc<AtomicU32>>,
|
||||
}
|
||||
|
||||
impl AudioCurrent {
|
||||
@@ -464,6 +469,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
base_volume: 0.8,
|
||||
fadeout_trigger: None,
|
||||
fadeout_samples: None,
|
||||
fadeout_linear: None,
|
||||
fadeout_end_gain: None,
|
||||
})),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
http_client: Arc::new(RwLock::new(
|
||||
|
||||
@@ -364,6 +364,8 @@ mod tests {
|
||||
base_volume: 1.0,
|
||||
fadeout_trigger: None,
|
||||
fadeout_samples: None,
|
||||
fadeout_linear: None,
|
||||
fadeout_end_gain: None,
|
||||
};
|
||||
Self {
|
||||
gen: 1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Live internet-radio playback. Distinct from main track playback: no
|
||||
//! gapless chain, no seek, no replay-gain, no preload.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -136,6 +136,9 @@ pub async fn audio_play_radio(
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
// Radio never uses the AutoDJ linear edge-mix → fixed non-linear defaults.
|
||||
let fadeout_linear = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_end_gain = Arc::new(AtomicU32::new(0));
|
||||
state.samples_played.store(0, Ordering::Relaxed);
|
||||
|
||||
// Radio: no gapless trim, no ReplayGain, 5 ms fade-in to suppress click.
|
||||
@@ -143,7 +146,8 @@ pub async fn audio_play_radio(
|
||||
let eq_src = EqSource::new(dyn_src, state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(), state.eq_pre_gain.clone());
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, Duration::from_millis(5));
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone(),
|
||||
fadeout_linear.clone(), fadeout_end_gain.clone());
|
||||
let notifying = NotifyingSource::new(fade_out, done_flag.clone());
|
||||
let counting = CountingSource::new(notifying, state.samples_played.clone());
|
||||
let boosted = PriorityBoostSource::new(counting);
|
||||
@@ -167,6 +171,8 @@ pub async fn audio_play_radio(
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
cur.fadeout_trigger = Some(fadeout_trigger);
|
||||
cur.fadeout_samples = Some(fadeout_samples);
|
||||
cur.fadeout_linear = Some(fadeout_linear);
|
||||
cur.fadeout_end_gain = Some(fadeout_end_gain);
|
||||
}
|
||||
|
||||
*state.current_playback_url.lock().unwrap() = Some(url.clone());
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! download task arms playback. Split out of `play_input.rs` so source
|
||||
//! selection and source building stay focused on their own concerns.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -88,21 +88,32 @@ pub(crate) struct SinkSwapInputs {
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) fadeout_trigger: Arc<AtomicBool>,
|
||||
pub(crate) fadeout_samples: Arc<AtomicU64>,
|
||||
/// New track's linear-mix control Arcs (stored so the *next* swap can drive
|
||||
/// this track's outgoing fade); inert until a later handoff sets them.
|
||||
pub(crate) fadeout_linear: Arc<AtomicBool>,
|
||||
pub(crate) fadeout_end_gain: Arc<AtomicU32>,
|
||||
pub(crate) crossfade_enabled: bool,
|
||||
pub(crate) actual_fade_secs: f32,
|
||||
/// Track A fade-out length (decoupled from B's `actual_fade_secs` fade-in).
|
||||
/// `0` ⇒ don't fade A — it rides its own recorded fade-out (scenario A).
|
||||
pub(crate) outgoing_fade_secs: f32,
|
||||
/// AutoDJ edge-mix: when `Some(end_gain)`, fade outgoing A *linearly* to
|
||||
/// `end_gain` then hold (instead of the equal-power cos → 0). `None` = cos.
|
||||
pub(crate) outgoing_linear_end_gain: Option<f32>,
|
||||
pub(crate) start_paused: bool,
|
||||
}
|
||||
|
||||
/// Hand off the outgoing sink to a sample-level fade-out, then stop it after
|
||||
/// `cleanup_secs`. No-op when `fade_secs <= 0` (immediate stop).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handoff_old_sink_fade_out(
|
||||
state: &State<'_, AudioEngine>,
|
||||
old_sink: Option<Arc<rodio::Player>>,
|
||||
old_fadeout_trigger: Option<Arc<AtomicBool>>,
|
||||
old_fadeout_samples: Option<Arc<AtomicU64>>,
|
||||
old_fadeout_linear: Option<Arc<AtomicBool>>,
|
||||
old_fadeout_end_gain: Option<Arc<AtomicU32>>,
|
||||
linear_end_gain: Option<f32>,
|
||||
fade_secs: f32,
|
||||
cleanup_secs: f32,
|
||||
) {
|
||||
@@ -117,6 +128,15 @@ fn handoff_old_sink_fade_out(
|
||||
let ch = state.current_channels.load(Ordering::Relaxed);
|
||||
let fade_total = (fade_secs as f64 * rate as f64 * ch as f64) as u64;
|
||||
|
||||
// AutoDJ edge-mix: configure the outgoing source's linear fade-to-hold before
|
||||
// arming the trigger, so its first fade sample reads the right mode + gain.
|
||||
if let (Some(end_gain), Some(linear), Some(bits)) =
|
||||
(linear_end_gain, old_fadeout_linear, old_fadeout_end_gain)
|
||||
{
|
||||
bits.store(end_gain.clamp(0.0, 1.0).to_bits(), Ordering::SeqCst);
|
||||
linear.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
|
||||
samples.store(fade_total.max(1), Ordering::SeqCst);
|
||||
trigger.store(true, Ordering::SeqCst);
|
||||
@@ -145,17 +165,22 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
|
||||
gain_linear,
|
||||
fadeout_trigger: new_fadeout_trigger,
|
||||
fadeout_samples: new_fadeout_samples,
|
||||
fadeout_linear: new_fadeout_linear,
|
||||
fadeout_end_gain: new_fadeout_end_gain,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
outgoing_fade_secs,
|
||||
outgoing_linear_end_gain,
|
||||
start_paused,
|
||||
} = inputs;
|
||||
|
||||
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
|
||||
let (old_sink, old_fadeout_trigger, old_fadeout_samples, old_fadeout_linear, old_fadeout_end_gain) = {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let old = cur.sink.take();
|
||||
let old_fo_trigger = cur.fadeout_trigger.take();
|
||||
let old_fo_samples = cur.fadeout_samples.take();
|
||||
let old_fo_linear = cur.fadeout_linear.take();
|
||||
let old_fo_end_gain = cur.fadeout_end_gain.take();
|
||||
cur.sink = Some(sink.clone());
|
||||
cur.duration_secs = duration_secs;
|
||||
cur.seek_offset = 0.0;
|
||||
@@ -171,18 +196,25 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
cur.fadeout_trigger = Some(new_fadeout_trigger);
|
||||
cur.fadeout_samples = Some(new_fadeout_samples);
|
||||
(old, old_fo_trigger, old_fo_samples)
|
||||
cur.fadeout_linear = Some(new_fadeout_linear);
|
||||
cur.fadeout_end_gain = Some(new_fadeout_end_gain);
|
||||
(old, old_fo_trigger, old_fo_samples, old_fo_linear, old_fo_end_gain)
|
||||
};
|
||||
|
||||
if crossfade_enabled {
|
||||
if outgoing_fade_secs > 0.0 {
|
||||
// Scenario A (`outgoing_fade_secs == 0`): A keeps full engine gain;
|
||||
// still keep the old sink alive until B's fade-in window elapses.
|
||||
// AutoDJ edge-mix: `outgoing_linear_end_gain` drives a linear
|
||||
// fade-to-hold instead of the equal-power cos → 0.
|
||||
handoff_old_sink_fade_out(
|
||||
state,
|
||||
old_sink,
|
||||
old_fadeout_trigger,
|
||||
old_fadeout_samples,
|
||||
old_fadeout_linear,
|
||||
old_fadeout_end_gain,
|
||||
outgoing_linear_end_gain,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs.max(outgoing_fade_secs) + 0.5,
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ use tauri::{AppHandle, State};
|
||||
use super::analysis_dispatch::{
|
||||
prepare_playback_analysis, spawn_track_analysis_bytes, TrackAnalysisOrigin,
|
||||
};
|
||||
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
|
||||
use super::decode::{build_source, build_streaming_source, AutodjFadeIn, BuiltSource, SizedDecoder};
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::{fetch_data, resolve_playback_format_hint, same_playback_target};
|
||||
use super::play_input::PlayInput;
|
||||
@@ -34,6 +34,8 @@ pub(crate) struct BuildSourceArgs<'a> {
|
||||
pub fade_in_dur: Duration,
|
||||
pub hi_res_enabled: bool,
|
||||
pub duration_hint: f64,
|
||||
/// AutoDJ edge-mix incoming linear fade-in endpoints (None = equal-power).
|
||||
pub autodj_in: AutodjFadeIn,
|
||||
}
|
||||
|
||||
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
|
||||
@@ -184,6 +186,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
autodj_in,
|
||||
} = args;
|
||||
let media_hint = play_media_format_hint(&play_input);
|
||||
let effective_hint = resolve_playback_format_hint(
|
||||
@@ -204,6 +207,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
autodj_in,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -265,6 +269,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
autodj_in,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -300,6 +305,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
autodj_in,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -313,6 +319,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
/// Dispatch [`PlayInput`] → fully wrapped rodio source. For Bytes the full
|
||||
/// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming
|
||||
/// the streaming variant runs the decoder build on a blocking thread.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn build_source_from_play_input(
|
||||
play_input: PlayInput,
|
||||
state: &State<'_, AudioEngine>,
|
||||
@@ -321,6 +328,7 @@ async fn build_source_from_play_input(
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
duration_hint: f64,
|
||||
autodj_in: AutodjFadeIn,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
// Always 0 — no application-level resampling. Rodio handles conversion to
|
||||
// the output device rate internally; we let every track play at its native rate.
|
||||
@@ -340,6 +348,7 @@ async fn build_source_from_play_input(
|
||||
target_rate,
|
||||
format_hint,
|
||||
hi_res_enabled,
|
||||
autodj_in,
|
||||
),
|
||||
PlayInput::SeekableMedia {
|
||||
reader,
|
||||
@@ -371,6 +380,7 @@ async fn build_source_from_play_input(
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
None,
|
||||
autodj_in,
|
||||
)
|
||||
}
|
||||
PlayInput::Streaming { reader, format_hint: stream_hint } => {
|
||||
@@ -397,6 +407,7 @@ async fn build_source_from_play_input(
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
Some(state.stream_playback_armed.clone()),
|
||||
autodj_in,
|
||||
)
|
||||
}
|
||||
}?;
|
||||
|
||||
@@ -239,6 +239,77 @@ impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── LinearGainEnvelopeIn — AutoDJ edge-mix incoming equal-power fade-in ─────
|
||||
//
|
||||
// Incoming track B on the AutoDJ edge-mix path: gain rises via equal-power
|
||||
// power-lerp from `start_gain` (= 1 − linear_B(0), may be > 0 when B starts loud)
|
||||
// to `end_gain` (always 1.0) across the mix window, then holds `end_gain`:
|
||||
// g(t) = sqrt(start²·(1−t) + end²·t)
|
||||
// For a symmetric 0→1 fade this matches sin(t·π/2); for non-zero endpoints it
|
||||
// generalises scenario A/B while keeping g_A²+g_B² ≈ 1 when paired with the
|
||||
// outgoing power-lerp on Track A.
|
||||
|
||||
pub(crate) struct LinearGainEnvelopeIn<S: Source<Item = f32>> {
|
||||
inner: S,
|
||||
sample_count: u64,
|
||||
fade_samples: u64,
|
||||
start_gain: f32,
|
||||
end_gain: f32,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> LinearGainEnvelopeIn<S> {
|
||||
pub(crate) fn new(inner: S, fade_dur: Duration, start_gain: f32, end_gain: f32) -> Self {
|
||||
let sample_rate = inner.sample_rate();
|
||||
let channels = inner.channels().get() as u64;
|
||||
let fade_samples = if fade_dur.is_zero() {
|
||||
0
|
||||
} else {
|
||||
(fade_dur.as_secs_f64() * sample_rate.get() as f64 * channels as f64) as u64
|
||||
};
|
||||
Self {
|
||||
inner,
|
||||
sample_count: 0,
|
||||
fade_samples,
|
||||
start_gain: start_gain.clamp(0.0, 1.0),
|
||||
end_gain: end_gain.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Iterator for LinearGainEnvelopeIn<S> {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
let sample = self.inner.next()?;
|
||||
let gain = if self.fade_samples == 0 || self.sample_count >= self.fade_samples {
|
||||
self.end_gain
|
||||
} else {
|
||||
let t = self.sample_count as f32 / self.fade_samples as f32;
|
||||
(self.start_gain.powi(2) * (1.0 - t) + self.end_gain.powi(2) * t).sqrt()
|
||||
};
|
||||
self.sample_count += 1;
|
||||
Some((sample * gain).clamp(-1.0, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Source for LinearGainEnvelopeIn<S> {
|
||||
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
if self.sample_count == 0 {
|
||||
// Initial start-offset seek (B-head: skip leading silence). Keep the
|
||||
// envelope so B still rises in from its trimmed start.
|
||||
} else if pos.as_millis() < 100 {
|
||||
self.sample_count = 0;
|
||||
} else {
|
||||
// Mid-playback seek elsewhere → jump to the held end gain.
|
||||
self.sample_count = self.fade_samples;
|
||||
}
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TriggeredFadeOut — sample-level cos(t·π/2) fade-out triggered externally ─
|
||||
//
|
||||
// Every track source is wrapped with this. It passes through at unity gain
|
||||
@@ -255,20 +326,40 @@ pub(crate) struct TriggeredFadeOut<S: Source<Item = f32>> {
|
||||
inner: S,
|
||||
trigger: Arc<AtomicBool>,
|
||||
fade_total_samples: Arc<AtomicU64>,
|
||||
// AutoDJ edge-mix: when `linear` is set at trigger time, fade via equal-power
|
||||
// power-lerp from 1.0 to `end_gain` over the fade window, then **hold**
|
||||
// `end_gain` until the inner source exhausts (generalised scenario A — the
|
||||
// recording carries the outgoing track the rest of the way at a fixed engine
|
||||
// gain). When `linear` is false the classic equal-power `cos(t·π/2) → 0 →
|
||||
// None` path runs.
|
||||
linear: Arc<AtomicBool>,
|
||||
end_gain_bits: Arc<AtomicU32>,
|
||||
fade_progress: u64,
|
||||
fading: bool,
|
||||
cached_total: u64,
|
||||
cached_linear: bool,
|
||||
cached_end_gain: f32,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> TriggeredFadeOut<S> {
|
||||
pub(crate) fn new(inner: S, trigger: Arc<AtomicBool>, fade_total_samples: Arc<AtomicU64>) -> Self {
|
||||
pub(crate) fn new(
|
||||
inner: S,
|
||||
trigger: Arc<AtomicBool>,
|
||||
fade_total_samples: Arc<AtomicU64>,
|
||||
linear: Arc<AtomicBool>,
|
||||
end_gain_bits: Arc<AtomicU32>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
trigger,
|
||||
fade_total_samples,
|
||||
linear,
|
||||
end_gain_bits,
|
||||
fade_progress: 0,
|
||||
fading: false,
|
||||
cached_total: 0,
|
||||
cached_linear: false,
|
||||
cached_end_gain: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,17 +371,29 @@ impl<S: Source<Item = f32>> Iterator for TriggeredFadeOut<S> {
|
||||
if !self.fading && self.trigger.load(Ordering::Relaxed) {
|
||||
self.fading = true;
|
||||
self.cached_total = self.fade_total_samples.load(Ordering::Relaxed).max(1);
|
||||
self.cached_linear = self.linear.load(Ordering::Relaxed);
|
||||
self.cached_end_gain = f32::from_bits(self.end_gain_bits.load(Ordering::Relaxed)).clamp(0.0, 1.0);
|
||||
self.fade_progress = 0;
|
||||
}
|
||||
|
||||
if self.fading {
|
||||
if self.fade_progress >= self.cached_total {
|
||||
// Linear edge-mix with a non-zero end gain: hold that gain so the
|
||||
// outgoing recording keeps playing under the incoming track.
|
||||
if self.cached_linear && self.cached_end_gain > 0.001 {
|
||||
let sample = self.inner.next()?;
|
||||
return Some((sample * self.cached_end_gain).clamp(-1.0, 1.0));
|
||||
}
|
||||
// Fade complete — exhaust the source.
|
||||
return None;
|
||||
}
|
||||
let sample = self.inner.next()?;
|
||||
let t = self.fade_progress as f32 / self.cached_total as f32;
|
||||
let gain = (t * std::f32::consts::FRAC_PI_2).cos();
|
||||
let gain = if self.cached_linear {
|
||||
(1.0 * (1.0 - t) + self.cached_end_gain.powi(2) * t).sqrt()
|
||||
} else {
|
||||
(t * std::f32::consts::FRAC_PI_2).cos()
|
||||
};
|
||||
self.fade_progress += 1;
|
||||
Some((sample * gain).clamp(-1.0, 1.0))
|
||||
} else {
|
||||
@@ -558,3 +661,101 @@ mod counting_source_tests {
|
||||
assert_eq!(counter.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod edge_mix_tests {
|
||||
use super::*;
|
||||
|
||||
/// Constant 1.0 source: mono, 4 Hz → a 1-second fade spans exactly 4 samples.
|
||||
struct Ones(usize);
|
||||
impl Iterator for Ones {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
if self.0 == 0 {
|
||||
None
|
||||
} else {
|
||||
self.0 -= 1;
|
||||
Some(1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Source for Ones {
|
||||
fn current_span_len(&self) -> Option<usize> { Some(1) }
|
||||
fn channels(&self) -> rodio::ChannelCount { std::num::NonZero::new(1).unwrap() }
|
||||
fn sample_rate(&self) -> rodio::SampleRate { std::num::NonZero::new(4).unwrap() }
|
||||
fn total_duration(&self) -> Option<Duration> { None }
|
||||
}
|
||||
|
||||
fn end_gain_arc(g: f32) -> Arc<AtomicU32> {
|
||||
Arc::new(AtomicU32::new(g.to_bits()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_fade_in_lerps_then_holds_end_gain() {
|
||||
let out: Vec<f32> =
|
||||
LinearGainEnvelopeIn::new(Ones(8), Duration::from_secs(1), 0.25, 1.0).collect();
|
||||
assert!((out[0] - 0.25).abs() < 1e-4); // p=0 → start_gain
|
||||
// p=0.5 → sqrt(0.25²·0.5 + 1²·0.5)
|
||||
assert!((out[2] - 0.7289).abs() < 1e-3);
|
||||
assert!((out[4] - 1.0).abs() < 1e-4); // after fade → end_gain
|
||||
assert!((out[7] - 1.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggered_linear_fade_out_holds_nonzero_end_gain() {
|
||||
let src = TriggeredFadeOut::new(
|
||||
Ones(8),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicU64::new(4)),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
end_gain_arc(0.5),
|
||||
);
|
||||
let out: Vec<f32> = src.collect();
|
||||
assert!((out[0] - 1.0).abs() < 1e-4); // p=0 → outgoing_gain_start (1.0)
|
||||
// p=0.5 → sqrt(1·0.5 + 0.5²·0.5)
|
||||
assert!((out[2] - 0.7906).abs() < 1e-3);
|
||||
assert!((out[4] - 0.5).abs() < 1e-4); // held at end_gain
|
||||
assert!((out[7] - 0.5).abs() < 1e-4);
|
||||
assert_eq!(out.len(), 8); // not exhausted — A keeps playing under B
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggered_linear_fade_out_exhausts_when_end_gain_zero() {
|
||||
let src = TriggeredFadeOut::new(
|
||||
Ones(8),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicU64::new(4)),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
end_gain_arc(0.0),
|
||||
);
|
||||
let out: Vec<f32> = src.collect();
|
||||
assert_eq!(out.len(), 4); // fades 1→0 then returns None
|
||||
assert!((out[0] - 1.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggered_cos_fade_out_unchanged_when_not_linear() {
|
||||
let src = TriggeredFadeOut::new(
|
||||
Ones(8),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicU64::new(4)),
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
end_gain_arc(0.0),
|
||||
);
|
||||
let out: Vec<f32> = src.collect();
|
||||
assert_eq!(out.len(), 4); // cos → 0 then None
|
||||
assert!((out[0] - 1.0).abs() < 1e-4); // cos(0) = 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_mix_power_lerp_is_constant_power_for_symmetric_crossfade() {
|
||||
// g_A: 1→0, g_B: 0→1 via power-lerp → g_A² + g_B² = 1 at every sample.
|
||||
let fade_samples = 4u64;
|
||||
for i in 0..fade_samples {
|
||||
let t = i as f32 / fade_samples as f32;
|
||||
let g_a = (1.0 * (1.0 - t)).sqrt();
|
||||
let g_b = (0.0 * (1.0 - t) + 1.0 * t).sqrt();
|
||||
assert!((g_a.powi(2) + g_b.powi(2) - 1.0).abs() < 1e-4, "i={i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,8 @@ mod tests {
|
||||
base_volume: 0.8,
|
||||
fadeout_trigger: None,
|
||||
fadeout_samples: None,
|
||||
fadeout_linear: None,
|
||||
fadeout_end_gain: None,
|
||||
})),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
http_client: Arc::new(RwLock::new(reqwest::Client::new())),
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import React from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import {
|
||||
AUTODJ_MAX_TRANSITION_SEC_MAX,
|
||||
AUTODJ_MAX_TRANSITION_SEC_MIN,
|
||||
AUTODJ_MIN_TRANSITION_SEC_MAX,
|
||||
AUTODJ_MIN_TRANSITION_SEC_MIN,
|
||||
} from '../../../store/authStoreDefaults';
|
||||
import { useOrbitStore } from '../../../store/orbitStore';
|
||||
import {
|
||||
getTransitionMode,
|
||||
@@ -14,6 +20,56 @@ interface Props {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
interface BoundRowProps {
|
||||
label: string;
|
||||
autoLabel: string;
|
||||
unit: string;
|
||||
/** Stored value; `0` (or less) means Auto. */
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
/** Value applied when the user turns Auto off. */
|
||||
enabledDefault: number;
|
||||
disabled?: boolean;
|
||||
onChange: (next: number) => void;
|
||||
}
|
||||
|
||||
/** One AutoDJ transition bound: an Auto checkbox + a seconds input (disabled while Auto). */
|
||||
function TransitionBoundRow({
|
||||
label, autoLabel, unit, value, min, max, enabledDefault, disabled, onChange,
|
||||
}: BoundRowProps) {
|
||||
const isAuto = !(value > 0);
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', marginBottom: '0.4rem' }}>
|
||||
<span style={{ minWidth: 56, fontSize: 13, color: 'var(--text-secondary)' }}>{label}</span>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAuto}
|
||||
disabled={disabled}
|
||||
onChange={e => onChange(e.target.checked ? 0 : enabledDefault)}
|
||||
/>
|
||||
{autoLabel}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={0.5}
|
||||
value={isAuto ? '' : value}
|
||||
disabled={isAuto || disabled}
|
||||
placeholder="—"
|
||||
onChange={e => {
|
||||
const n = parseFloat(e.target.value);
|
||||
onChange(Number.isFinite(n) ? n : 0);
|
||||
}}
|
||||
style={{ width: 72 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{unit}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track-transition picker. Crossfade, AutoDJ and Gapless are mutually
|
||||
* exclusive — only one can be active — so they are presented as a single
|
||||
@@ -98,6 +154,36 @@ export function TrackTransitionsBlock({ t }: Props) {
|
||||
onChange={auth.setAutodjSmoothSkip}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.9rem' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 2 }}>
|
||||
{t('settings.autodjTransitionBounds')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.6rem' }}>
|
||||
{t('settings.autodjTransitionBoundsDesc')}
|
||||
</div>
|
||||
<TransitionBoundRow
|
||||
label={t('settings.autodjMinLabel')}
|
||||
autoLabel={t('settings.autodjAuto')}
|
||||
unit={t('settings.autodjSecondsUnit')}
|
||||
value={auth.autodjMinTransitionSec}
|
||||
min={AUTODJ_MIN_TRANSITION_SEC_MIN}
|
||||
max={AUTODJ_MIN_TRANSITION_SEC_MAX}
|
||||
enabledDefault={2}
|
||||
disabled={hostControlled}
|
||||
onChange={auth.setAutodjMinTransitionSec}
|
||||
/>
|
||||
<TransitionBoundRow
|
||||
label={t('settings.autodjMaxLabel')}
|
||||
autoLabel={t('settings.autodjAuto')}
|
||||
unit={t('settings.autodjSecondsUnit')}
|
||||
value={auth.autodjMaxTransitionSec}
|
||||
min={AUTODJ_MAX_TRANSITION_SEC_MIN}
|
||||
max={AUTODJ_MAX_TRANSITION_SEC_MAX}
|
||||
enabledDefault={8}
|
||||
disabled={hostControlled}
|
||||
onChange={auth.setAutodjMaxTransitionSec}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -239,6 +239,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
since: '1.43.0',
|
||||
contributions: [
|
||||
'WebView2 idle hooks when Tauri windows are hidden — Windows GPU and compositor mitigation (PR #273)',
|
||||
'AutoDJ edge-mix transition algorithm — waveform edge model and linear blend design (PR #1167)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -669,6 +669,12 @@ export const settings = {
|
||||
autoDjDesc: 'No fixed duration — AutoDJ follows the actual audio, blending real fade-outs and intros instead of a set number of seconds. For reliable results, enable the Hot playback cache.',
|
||||
autodjSmoothSkip: 'Smooth skip',
|
||||
autodjSmoothSkipDesc: 'When you skip to the next or previous track while playing, blend into it with the same AutoDJ rules (overlap length, intro trim) from your current position instead of an abrupt cut. Works best when the next track is already buffered.',
|
||||
autodjTransitionBounds: 'Transition length',
|
||||
autodjTransitionBoundsDesc: 'Optional limits on how long a blend can last. Leave on Auto to let the audio decide; set a value to clamp the minimum or maximum.',
|
||||
autodjMinLabel: 'Min',
|
||||
autodjMaxLabel: 'Max',
|
||||
autodjAuto: 'Auto',
|
||||
autodjSecondsUnit: 's',
|
||||
gapless: 'Gapless Playback',
|
||||
transitionsTitle: 'Track transitions',
|
||||
transitionsDesc: 'How consecutive tracks blend together. Only one mode can be active at a time.',
|
||||
|
||||
@@ -689,6 +689,12 @@ export const settings = {
|
||||
autoDjDesc: 'Без фиксированной длительности — AutoDJ подстраивается под само звучание, накладываясь на реальные затухания и вступления, а не на заданное число секунд. Для надёжной работы включите «Горячий кэш воспроизведения».',
|
||||
autodjSmoothSkip: 'Плавный пропуск',
|
||||
autodjSmoothSkipDesc: 'При переходе на следующий или предыдущий трек во время воспроизведения склеивает его по тем же правилам AutoDJ (длина наложения, обрезка вступления) с текущей позиции, а не резким обрывом. Надёжнее, когда следующий трек уже в буфере.',
|
||||
autodjTransitionBounds: 'Длина перехода',
|
||||
autodjTransitionBoundsDesc: 'Необязательные ограничения длительности перехода. Оставьте «Авто», чтобы длину выбирал сам звук, либо задайте значение, чтобы ограничить минимум или максимум.',
|
||||
autodjMinLabel: 'Мин.',
|
||||
autodjMaxLabel: 'Макс.',
|
||||
autodjAuto: 'Авто',
|
||||
autodjSecondsUnit: 'с',
|
||||
gapless: 'Без пауз между треками',
|
||||
transitionsTitle: 'Переходы между треками',
|
||||
transitionsDesc: 'Как соединяются идущие подряд треки. Одновременно может быть активен только один режим.',
|
||||
|
||||
@@ -94,7 +94,12 @@ import {
|
||||
} from '../utils/playback/autodjAutoAdvance';
|
||||
import { isInterruptHandoffPending } from '../utils/playback/autodjInterruptPrep';
|
||||
import { isCrossfadeNextReady, maybeCrossfadeBytePreload } from './crossfadePreload';
|
||||
import { armCrossfadeDynamicOverlap, getCrossfadeTransition } from './crossfadeTrimCache';
|
||||
import {
|
||||
armCrossfadeDynamicOverlap,
|
||||
armEdgeMix,
|
||||
getCrossfadeTransition,
|
||||
getEdgeMixPlan,
|
||||
} from './crossfadeTrimCache';
|
||||
import { armAutodjMixing } from './autodjTransitionUi';
|
||||
|
||||
// Silence-aware crossfade (A-tail): guards the early advance to once per play
|
||||
@@ -289,31 +294,16 @@ export function handleAudioProgress(
|
||||
: (store.repeatMode === 'all' && store.queueItems.length > 0 ? store.queueItems[0] : null);
|
||||
const nextTrackId = nextRef ? resolveQueueTrack(nextRef)?.id : undefined;
|
||||
if (nextTrackId) {
|
||||
const cf = clampCrossfadeSecs(crossfadeSecs);
|
||||
const plan = getCrossfadeTransition(nextTrackId);
|
||||
let contentOverlap: number;
|
||||
// Scenario A: does A carry its own recorded fade-out? If so we let it ride
|
||||
// at full engine gain (no double fade) and bring B up underneath.
|
||||
let aRidesOwnFade: boolean;
|
||||
if (plan && plan.overlapSec > 0) {
|
||||
contentOverlap = plan.overlapSec;
|
||||
aRidesOwnFade = plan.outgoingFadeSec <= 0.001;
|
||||
} else {
|
||||
// No next-track envelope (cold plan) → judge A from its own waveform.
|
||||
const aShape = analyzeBoundary(store.waveformBins, dur);
|
||||
contentOverlap = aShape.outroFadeSec;
|
||||
aRidesOwnFade = aShape.outroFadeSec >= 1.0;
|
||||
}
|
||||
if (shouldJsDriveAutodjTransition(curTrailSilenceSec, contentOverlap, cf, aRidesOwnFade)) {
|
||||
const edgePlan = getEdgeMixPlan(nextTrackId);
|
||||
if (edgePlan) {
|
||||
// AutoDJ edge-mix (§16): JS always drives the swap so the linear blend is
|
||||
// sample-aligned and readiness-gated; the engine's autonomous crossfade
|
||||
// timer is suppressed. The full plan (overlap + B head-trim + linear gain
|
||||
// curves) is armed for playTrack → `autodj_linear_mix`.
|
||||
autodjSuppressWant = true;
|
||||
const { overlapSec, outgoingFadeSec } = computeAutodjJsOverlap(contentOverlap, aRidesOwnFade);
|
||||
const overlapSec = edgePlan.transitionDur;
|
||||
const triggerAt = autodjJsTriggerAtSec(dur, curTrailSilenceSec, overlapSec);
|
||||
const gen = getPlayGeneration();
|
||||
// Readiness gate: only advance when B's audio is actually available (RAM
|
||||
// preload slot or local on disk). A cold stream can't sustain a stable
|
||||
// fade, so we leave the gen guard unset and re-check on later ticks — if
|
||||
// B readies before A ends we fade then; if never, A plays out (engine
|
||||
// timer suppressed) and the source-exhaustion end gives a clean cut.
|
||||
if (
|
||||
current_time >= triggerAt
|
||||
&& crossfadeTrimAdvanceGen !== gen
|
||||
@@ -324,11 +314,55 @@ export function handleAudioProgress(
|
||||
)
|
||||
) {
|
||||
crossfadeTrimAdvanceGen = gen;
|
||||
armCrossfadeDynamicOverlap(nextTrackId, overlapSec, outgoingFadeSec);
|
||||
armEdgeMix(nextTrackId, edgePlan);
|
||||
armAutodjMixing(overlapSec);
|
||||
store.next(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Cold/un-analysed B (no edge plan) → degrade to the equal-power engine
|
||||
// crossfade driven from A's own envelope (pre-edge-mix behaviour).
|
||||
const cf = clampCrossfadeSecs(crossfadeSecs);
|
||||
const plan = getCrossfadeTransition(nextTrackId);
|
||||
let contentOverlap: number;
|
||||
// Scenario A: does A carry its own recorded fade-out? If so we let it ride
|
||||
// at full engine gain (no double fade) and bring B up underneath.
|
||||
let aRidesOwnFade: boolean;
|
||||
if (plan && plan.overlapSec > 0) {
|
||||
contentOverlap = plan.overlapSec;
|
||||
aRidesOwnFade = plan.outgoingFadeSec <= 0.001;
|
||||
} else {
|
||||
// No next-track envelope (cold plan) → judge A from its own waveform.
|
||||
const aShape = analyzeBoundary(store.waveformBins, dur);
|
||||
contentOverlap = aShape.outroFadeSec;
|
||||
aRidesOwnFade = aShape.outroFadeSec >= 1.0;
|
||||
}
|
||||
if (shouldJsDriveAutodjTransition(curTrailSilenceSec, contentOverlap, cf, aRidesOwnFade)) {
|
||||
autodjSuppressWant = true;
|
||||
const { overlapSec, outgoingFadeSec } = computeAutodjJsOverlap(contentOverlap, aRidesOwnFade);
|
||||
const triggerAt = autodjJsTriggerAtSec(dur, curTrailSilenceSec, overlapSec);
|
||||
const gen = getPlayGeneration();
|
||||
// Readiness gate: only advance when B's audio is actually available (RAM
|
||||
// preload slot or local on disk). A cold stream can't sustain a stable
|
||||
// fade, so we leave the gen guard unset and re-check on later ticks — if
|
||||
// B readies before A ends we fade then; if never, A plays out (engine
|
||||
// timer suppressed) and the source-exhaustion end gives a clean cut.
|
||||
if (
|
||||
current_time >= triggerAt
|
||||
&& crossfadeTrimAdvanceGen !== gen
|
||||
&& isCrossfadeNextReady(
|
||||
nextTrackId,
|
||||
playbackProfileIdForRef(nextRef),
|
||||
playbackCacheKeyForRef(nextRef),
|
||||
)
|
||||
) {
|
||||
crossfadeTrimAdvanceGen = gen;
|
||||
armCrossfadeDynamicOverlap(nextTrackId, overlapSec, outgoingFadeSec);
|
||||
armAutodjMixing(overlapSec);
|
||||
store.next(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { clampStoredLoudnessPreAnalysisAttenuationRefDb } from '../utils/audio/loudnessPreAnalysisSlider';
|
||||
import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB } from './authStoreDefaults';
|
||||
import {
|
||||
AUTODJ_MAX_TRANSITION_SEC_MAX,
|
||||
AUTODJ_MAX_TRANSITION_SEC_MIN,
|
||||
AUTODJ_MIN_TRANSITION_SEC_MAX,
|
||||
AUTODJ_MIN_TRANSITION_SEC_MIN,
|
||||
DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
|
||||
} from './authStoreDefaults';
|
||||
import { clampAutodjTransitionSec } from './authStoreHelpers';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import type { AuthState } from './authStoreTypes';
|
||||
|
||||
@@ -29,6 +36,8 @@ export function createAudioSettingsActions(set: SetState): Pick<
|
||||
| 'setCrossfadeSecs'
|
||||
| 'setCrossfadeTrimSilence'
|
||||
| 'setAutodjSmoothSkip'
|
||||
| 'setAutodjMinTransitionSec'
|
||||
| 'setAutodjMaxTransitionSec'
|
||||
| 'setGaplessEnabled'
|
||||
| 'setEnableHiRes'
|
||||
| 'setAudioOutputDevice'
|
||||
@@ -71,6 +80,16 @@ export function createAudioSettingsActions(set: SetState): Pick<
|
||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||
setCrossfadeTrimSilence: (v) => set({ crossfadeTrimSilence: v }),
|
||||
setAutodjSmoothSkip: (v) => set({ autodjSmoothSkip: v }),
|
||||
setAutodjMinTransitionSec: (v) => set({
|
||||
autodjMinTransitionSec: clampAutodjTransitionSec(
|
||||
v, AUTODJ_MIN_TRANSITION_SEC_MIN, AUTODJ_MIN_TRANSITION_SEC_MAX,
|
||||
),
|
||||
}),
|
||||
setAutodjMaxTransitionSec: (v) => set({
|
||||
autodjMaxTransitionSec: clampAutodjTransitionSec(
|
||||
v, AUTODJ_MAX_TRANSITION_SEC_MIN, AUTODJ_MAX_TRANSITION_SEC_MAX,
|
||||
),
|
||||
}),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
setEnableHiRes: (v) => set({ enableHiRes: v }),
|
||||
setAudioOutputDevice: (v) => set({ audioOutputDevice: v }),
|
||||
|
||||
@@ -56,6 +56,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
crossfadeSecs: 3,
|
||||
crossfadeTrimSilence: false,
|
||||
autodjSmoothSkip: true,
|
||||
autodjMinTransitionSec: 0,
|
||||
autodjMaxTransitionSec: 0,
|
||||
gaplessEnabled: false,
|
||||
trackPreviewsEnabled: true,
|
||||
trackPreviewLocations: { ...DEFAULT_TRACK_PREVIEW_LOCATIONS },
|
||||
|
||||
@@ -46,3 +46,15 @@ export const RANDOM_MIX_SIZE_OPTIONS: readonly number[] = [50, 75, 100, 125, 150
|
||||
export const DEFAULT_LIBRARY_GRID_MAX_COLUMNS = 6;
|
||||
export const LIBRARY_GRID_MAX_COLUMNS_MIN = 4;
|
||||
export const LIBRARY_GRID_MAX_COLUMNS_MAX = 12;
|
||||
|
||||
// AutoDJ transition-length user bounds (Settings → Track transitions). `0` is the
|
||||
// "Auto" sentinel — the edge-mix algorithm uses its own content-derived span with
|
||||
// no user floor/ceiling. A non-zero value clamps `transition_dur` (and the edge
|
||||
// analysis window) to that many seconds. Min must stay ≤ max at use sites.
|
||||
export const AUTODJ_MIN_TRANSITION_SEC_MIN = 0.5;
|
||||
export const AUTODJ_MIN_TRANSITION_SEC_MAX = 10;
|
||||
export const AUTODJ_MAX_TRANSITION_SEC_MIN = 1;
|
||||
// Upper ceiling mirrors the engine's AutoDJ mix clamp in `audio_play`
|
||||
// (`mix_secs.clamp(0.5, 12.0)`) — keep them in lockstep so a configured max is
|
||||
// actually honoured end-to-end (no silent re-clamp in Rust).
|
||||
export const AUTODJ_MAX_TRANSITION_SEC_MAX = 12;
|
||||
|
||||
@@ -51,6 +51,18 @@ export function clampLibraryGridMaxColumns(v: unknown): number {
|
||||
return Math.max(LIBRARY_GRID_MAX_COLUMNS_MIN, Math.min(LIBRARY_GRID_MAX_COLUMNS_MAX, Math.round(n)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp an AutoDJ transition-length bound (seconds). A non-positive / non-finite
|
||||
* value (or explicit `0`) is the "Auto" sentinel and passes through as `0`; any
|
||||
* other value is snapped to a 0.5 s step inside `[lo, hi]`.
|
||||
*/
|
||||
export function clampAutodjTransitionSec(v: unknown, lo: number, hi: number): number {
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
if (!Number.isFinite(n) || n <= 0) return 0;
|
||||
const snapped = Math.round(n * 2) / 2;
|
||||
return Math.max(lo, Math.min(hi, snapped));
|
||||
}
|
||||
|
||||
export function clampSkipStarThreshold(v: number): number {
|
||||
if (!Number.isFinite(v)) return 3;
|
||||
return Math.max(1, Math.min(99, Math.round(v)));
|
||||
|
||||
@@ -3,8 +3,15 @@ import {
|
||||
LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS,
|
||||
clampStoredLoudnessPreAnalysisAttenuationRefDb,
|
||||
} from '../utils/audio/loudnessPreAnalysisSlider';
|
||||
import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB } from './authStoreDefaults';
|
||||
import {
|
||||
AUTODJ_MAX_TRANSITION_SEC_MAX,
|
||||
AUTODJ_MAX_TRANSITION_SEC_MIN,
|
||||
AUTODJ_MIN_TRANSITION_SEC_MAX,
|
||||
AUTODJ_MIN_TRANSITION_SEC_MIN,
|
||||
DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
|
||||
} from './authStoreDefaults';
|
||||
import {
|
||||
clampAutodjTransitionSec,
|
||||
clampMixFilterMinStars,
|
||||
clampRandomMixSize,
|
||||
clampLibraryGridMaxColumns,
|
||||
@@ -235,6 +242,14 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
|
||||
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
|
||||
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
|
||||
randomMixSize: clampRandomMixSize(state.randomMixSize as number),
|
||||
autodjMinTransitionSec: clampAutodjTransitionSec(
|
||||
(state as { autodjMinTransitionSec?: unknown }).autodjMinTransitionSec,
|
||||
AUTODJ_MIN_TRANSITION_SEC_MIN, AUTODJ_MIN_TRANSITION_SEC_MAX,
|
||||
),
|
||||
autodjMaxTransitionSec: clampAutodjTransitionSec(
|
||||
(state as { autodjMaxTransitionSec?: unknown }).autodjMaxTransitionSec,
|
||||
AUTODJ_MAX_TRANSITION_SEC_MIN, AUTODJ_MAX_TRANSITION_SEC_MAX,
|
||||
),
|
||||
libraryGridMaxColumns: clampLibraryGridMaxColumns(
|
||||
(state as { libraryGridMaxColumns?: unknown }).libraryGridMaxColumns,
|
||||
),
|
||||
|
||||
@@ -151,6 +151,13 @@ export interface AuthState {
|
||||
* playing (avoids an abrupt cut). Default on for new installs.
|
||||
*/
|
||||
autodjSmoothSkip: boolean;
|
||||
/**
|
||||
* AutoDJ transition-length bounds (seconds). `0` = Auto: the edge-mix algorithm
|
||||
* uses its own content-derived span with no user floor/ceiling. A non-zero value
|
||||
* clamps the analysed edge window + final `transition_dur`. Default Auto (0).
|
||||
*/
|
||||
autodjMinTransitionSec: number;
|
||||
autodjMaxTransitionSec: number;
|
||||
gaplessEnabled: boolean;
|
||||
/** Show inline Play+Preview buttons in tracklists. Default on per Q3. Master kill switch — when off, all locations are off. */
|
||||
trackPreviewsEnabled: boolean;
|
||||
@@ -372,6 +379,10 @@ export interface AuthState {
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
setCrossfadeTrimSilence: (v: boolean) => void;
|
||||
setAutodjSmoothSkip: (v: boolean) => void;
|
||||
/** Set the AutoDJ min transition length (seconds); `0` = Auto. */
|
||||
setAutodjMinTransitionSec: (v: number) => void;
|
||||
/** Set the AutoDJ max transition length (seconds); `0` = Auto. */
|
||||
setAutodjMaxTransitionSec: (v: number) => void;
|
||||
setGaplessEnabled: (v: boolean) => void;
|
||||
setTrackPreviewsEnabled: (v: boolean) => void;
|
||||
setTrackPreviewLocation: (location: TrackPreviewLocation, enabled: boolean) => void;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { computeWaveformSilence, planCrossfadeTransition } from '../utils/waveform/waveformSilence';
|
||||
import { computeWaveformSilence } from '../utils/waveform/waveformSilence';
|
||||
import { edgeDurationOptionsFromSettings, planEdgeMix } from '../utils/waveform/autodjEdgeMix';
|
||||
import { findLocalPlaybackUrl } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { playbackCacheKeyForRef } from '../utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||
@@ -9,7 +10,7 @@ import { useAuthStore } from './authStore';
|
||||
import {
|
||||
hasPlannedCrossfade,
|
||||
markPlannedCrossfade,
|
||||
setCrossfadeTransition,
|
||||
setEdgeMixPlan,
|
||||
} from './crossfadeTrimCache';
|
||||
import { getBytePreloadingId, setBytePreloadingId } from './gaplessPreloadState';
|
||||
import { refreshLoudnessForTrack } from './loudnessRefresh';
|
||||
@@ -121,6 +122,7 @@ export function maybeCrossfadeBytePreload(currentTime: number, dur: number): voi
|
||||
if (!(dur > 0)) return;
|
||||
const {
|
||||
gaplessEnabled, hotCacheEnabled, crossfadeEnabled, crossfadeSecs, crossfadeTrimSilence,
|
||||
autodjMinTransitionSec, autodjMaxTransitionSec,
|
||||
} = useAuthStore.getState();
|
||||
if (!crossfadeEnabled || gaplessEnabled) return;
|
||||
|
||||
@@ -169,24 +171,37 @@ export function maybeCrossfadeBytePreload(currentTime: number, dur: number): voi
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// B-head + dynamic overlap: plan the whole transition once (no store write) so
|
||||
// playTrack can start the incoming track past its dead head AND fade over a
|
||||
// content-adaptive overlap. Pairs the current track's envelope (already in the
|
||||
// store) with the next track's cached waveform; the alignment maths is cheap,
|
||||
// so it runs regardless of hot cache (which otherwise skips the byte
|
||||
// pre-download). Cold/un-analysed tracks fall back to a fixed overlap + no
|
||||
// head trim → today's behaviour.
|
||||
// AutoDJ edge-mix: plan the whole transition once (no store write) so playTrack
|
||||
// can start the incoming track past its dead head AND blend over the
|
||||
// content-derived linear edge curves. Pairs the current track's envelope
|
||||
// (already in the store) with the next track's cached waveform; the analysis is
|
||||
// cheap, so it runs regardless of hot cache (which otherwise skips the byte
|
||||
// pre-download). Cold/un-analysed tracks leave no plan → audioEventHandlers
|
||||
// degrades to the engine crossfade (§16.4).
|
||||
if (crossfadeTrimSilence && !hasPlannedCrossfade(nextTrack.id)) {
|
||||
markPlannedCrossfade(nextTrack.id);
|
||||
const planTrackId = nextTrack.id;
|
||||
const planDuration = nextTrack.duration;
|
||||
const curBins = store.waveformBins;
|
||||
const aContentEndSec = computeWaveformSilence(curBins, dur).contentEndSec;
|
||||
// User min/max transition bounds (Settings → Track transitions); `0` = Auto.
|
||||
const edgeOpts = edgeDurationOptionsFromSettings(autodjMinTransitionSec, autodjMaxTransitionSec);
|
||||
void fetchWaveformBins(planTrackId, serverId || null)
|
||||
.then(nextBins => {
|
||||
// Overlap is derived purely from the audio (fade-out / buildup); the
|
||||
// Edge curves + overlap derive purely from the audio (edge model); the
|
||||
// user's crossfadeSecs is intentionally not a factor in this mode.
|
||||
const plan = planCrossfadeTransition(curBins, dur, nextBins, planDuration);
|
||||
setCrossfadeTransition(planTrackId, plan);
|
||||
const bSilence = computeWaveformSilence(nextBins, planDuration);
|
||||
const edgePlan = planEdgeMix(
|
||||
curBins,
|
||||
dur,
|
||||
aContentEndSec,
|
||||
nextBins,
|
||||
planDuration,
|
||||
bSilence.contentStartSec,
|
||||
bSilence.contentEndSec,
|
||||
edgeOpts,
|
||||
);
|
||||
if (edgePlan) setEdgeMixPlan(planTrackId, edgePlan);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
@@ -11,11 +11,15 @@
|
||||
* per-transition playback data, not user state.
|
||||
*/
|
||||
import type { CrossfadeTransitionPlan } from '../utils/waveform/waveformSilence';
|
||||
import type { EdgeMixPlan } from '../utils/waveform/autodjEdgeMix';
|
||||
|
||||
export type { CrossfadeTransitionPlan } from '../utils/waveform/waveformSilence';
|
||||
export type { EdgeMixPlan } from '../utils/waveform/autodjEdgeMix';
|
||||
|
||||
/** trackId → planned transition for when this track starts under crossfade. */
|
||||
const planByTrackId = new Map<string, CrossfadeTransitionPlan>();
|
||||
/** trackId → planned AutoDJ edge-mix (linear blend) for when this track starts. */
|
||||
const edgePlanByTrackId = new Map<string, EdgeMixPlan>();
|
||||
/** trackIds we've already attempted a plan for (avoids per-tick refetch). */
|
||||
const plannedTrackIds = new Set<string>();
|
||||
|
||||
@@ -47,6 +51,19 @@ export function getCrossfadeTransition(trackId: string): CrossfadeTransitionPlan
|
||||
return planByTrackId.get(trackId) ?? null;
|
||||
}
|
||||
|
||||
/** Record the computed AutoDJ edge-mix plan for `trackId`. */
|
||||
export function setEdgeMixPlan(trackId: string, plan: EdgeMixPlan): void {
|
||||
if (!trackId) return;
|
||||
edgePlanByTrackId.set(trackId, plan);
|
||||
trim(edgePlanByTrackId);
|
||||
}
|
||||
|
||||
/** Read the cached AutoDJ edge-mix plan for `trackId` (null when none/unknown). */
|
||||
export function getEdgeMixPlan(trackId: string): EdgeMixPlan | null {
|
||||
if (!trackId) return null;
|
||||
return edgePlanByTrackId.get(trackId) ?? null;
|
||||
}
|
||||
|
||||
/** True once we've already attempted to plan a transition into `trackId`. */
|
||||
export function hasPlannedCrossfade(trackId: string): boolean {
|
||||
return plannedTrackIds.has(trackId);
|
||||
@@ -109,11 +126,43 @@ export function peekArmedCrossfadeDynamicOverlap(trackId: string): boolean {
|
||||
return !!trackId && armedOverlapTrackId === trackId && armedOverlapSec > 0;
|
||||
}
|
||||
|
||||
// ── One-shot AutoDJ edge-mix hand-off (A-tail advance → playTrack) ──────────────
|
||||
// AutoDJ edge-mix analogue of the dynamic-overlap handoff: the JS early advance
|
||||
// arms the full linear-mix plan for the incoming track; `playTrack` consumes it
|
||||
// to pass `autodj_linear_mix` to the engine. When this is armed it takes
|
||||
// precedence over the equal-power `crossfade_secs_override` path.
|
||||
let armedEdgeTrackId: string | null = null;
|
||||
let armedEdgePlan: EdgeMixPlan | null = null;
|
||||
|
||||
/** Arm the edge-mix plan JS positioned for the incoming `trackId`. */
|
||||
export function armEdgeMix(trackId: string, plan: EdgeMixPlan): void {
|
||||
if (!trackId) return;
|
||||
armedEdgeTrackId = trackId;
|
||||
armedEdgePlan = plan;
|
||||
}
|
||||
|
||||
/** Consume + clear the armed edge-mix plan for `trackId` (null when none/mismatched). */
|
||||
export function consumeEdgeMix(trackId: string): EdgeMixPlan | null {
|
||||
if (!trackId || armedEdgeTrackId !== trackId) return null;
|
||||
const plan = armedEdgePlan;
|
||||
armedEdgeTrackId = null;
|
||||
armedEdgePlan = null;
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** True when JS A-tail advance armed an edge-mix handoff for `trackId` (peek only). */
|
||||
export function peekArmedEdgeMix(trackId: string): boolean {
|
||||
return !!trackId && armedEdgeTrackId === trackId && armedEdgePlan !== null;
|
||||
}
|
||||
|
||||
/** Test/reset hook. */
|
||||
export function _resetCrossfadeTrimCacheForTest(): void {
|
||||
planByTrackId.clear();
|
||||
edgePlanByTrackId.clear();
|
||||
plannedTrackIds.clear();
|
||||
armedOverlapTrackId = null;
|
||||
armedOverlapSec = 0;
|
||||
armedOutgoingFadeSec = 0;
|
||||
armedEdgeTrackId = null;
|
||||
armedEdgePlan = null;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
computeAutodjManualBlendPlan,
|
||||
shouldAutodjInterruptBlend,
|
||||
} from '../utils/playback/autodjManualBlend';
|
||||
import type { CrossfadeTransitionPlan } from '../utils/waveform/waveformSilence';
|
||||
import type { EdgeMixPlan } from '../utils/waveform/autodjEdgeMix';
|
||||
import {
|
||||
armInterruptHandoff,
|
||||
clearInterruptHandoff,
|
||||
@@ -34,7 +34,12 @@ import {
|
||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { consumeCrossfadeDynamicOverlap, getCrossfadeTransition, peekArmedCrossfadeDynamicOverlap } from './crossfadeTrimCache';
|
||||
import {
|
||||
consumeCrossfadeDynamicOverlap,
|
||||
consumeEdgeMix,
|
||||
getCrossfadeTransition,
|
||||
peekArmedCrossfadeDynamicOverlap,
|
||||
} from './crossfadeTrimCache';
|
||||
import {
|
||||
bumpPlayGeneration,
|
||||
getPlayGeneration,
|
||||
@@ -424,7 +429,7 @@ export function runPlayTrack(
|
||||
);
|
||||
const replayGainPeak = isReplayGainActive() ? (scopedTrack.replayGainPeak ?? null) : null;
|
||||
|
||||
const invokeAudioPlay = (manualBlend: CrossfadeTransitionPlan | null) => {
|
||||
const invokeAudioPlay = (manualBlend: EdgeMixPlan | null) => {
|
||||
// Silence-aware crossfade (B-head + dynamic overlap): on a fresh auto-advance
|
||||
// under crossfade, start past this track's leading silence (always, from the
|
||||
// plan) and — only when the JS A-tail advance positioned this transition —
|
||||
@@ -438,20 +443,37 @@ export function runPlayTrack(
|
||||
&& initialTime <= 0.05;
|
||||
const useManualBlend = manualBlend !== null;
|
||||
|
||||
const crossfadePlan = useTrimAuto ? getCrossfadeTransition(scopedTrack.id) : null;
|
||||
const armedOverlap = useTrimAuto ? consumeCrossfadeDynamicOverlap(scopedTrack.id) : null;
|
||||
const crossfadeStartSecs = useManualBlend
|
||||
? manualBlend.bStartSec
|
||||
// AutoDJ edge-mix (§16): both manual skip (`planEdgeMixForSkip`) and the JS
|
||||
// A-tail auto-advance arm a full linear-blend plan. Either supersedes the
|
||||
// equal-power dynamic-overlap handoff — the engine runs `autodj_linear_mix`
|
||||
// instead of the cosine crossfade overrides. Manual skip takes priority.
|
||||
const armedEdge = useTrimAuto ? consumeEdgeMix(scopedTrack.id) : null;
|
||||
const edgePlan = manualBlend ?? armedEdge;
|
||||
const crossfadePlan = !edgePlan && useTrimAuto ? getCrossfadeTransition(scopedTrack.id) : null;
|
||||
const armedOverlap = !edgePlan && useTrimAuto ? consumeCrossfadeDynamicOverlap(scopedTrack.id) : null;
|
||||
const autodjLinearMix = edgePlan
|
||||
? {
|
||||
mix_secs: edgePlan.transitionDur,
|
||||
outgoing_gain_start: edgePlan.outgoingGainAtMixStart,
|
||||
outgoing_gain_end: edgePlan.outgoingGainAtMixEnd,
|
||||
incoming_gain_start: edgePlan.incomingGainAtMixStart,
|
||||
incoming_gain_end: edgePlan.incomingGainAtMixEnd,
|
||||
}
|
||||
: null;
|
||||
const crossfadeStartSecs = edgePlan
|
||||
? edgePlan.bStartSec
|
||||
: (crossfadePlan?.bStartSec ?? 0);
|
||||
const crossfadeSecsOverride = useManualBlend
|
||||
? manualBlend.overlapSec
|
||||
// Edge-mix carries its fade shape in `autodj_linear_mix`; the cosine
|
||||
// overrides only feed the degrade (no-plan) path.
|
||||
const crossfadeSecsOverride = edgePlan
|
||||
? null
|
||||
: (armedOverlap ? armedOverlap.overlapSec : null);
|
||||
const outgoingFadeSecsOverride = useManualBlend
|
||||
? manualBlend.outgoingFadeSec
|
||||
const outgoingFadeSecsOverride = edgePlan
|
||||
? null
|
||||
: (armedOverlap ? armedOverlap.outgoingFadeSec : null);
|
||||
|
||||
if (useManualBlend) {
|
||||
armAutodjMixing(manualBlend.overlapSec);
|
||||
if (edgePlan && edgePlan.transitionDur > 0) {
|
||||
armAutodjMixing(edgePlan.transitionDur);
|
||||
} else if (crossfadeSecsOverride != null && crossfadeSecsOverride > 0) {
|
||||
armAutodjMixing(crossfadeSecsOverride);
|
||||
} else if (manual) {
|
||||
@@ -477,6 +499,7 @@ export function runPlayTrack(
|
||||
crossfadeSecsOverride,
|
||||
outgoingFadeSecsOverride,
|
||||
manualAutodjBlend: useManualBlend ? true : null,
|
||||
autodjLinearMix,
|
||||
})
|
||||
.then(() => {
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
@@ -547,7 +570,7 @@ export function runPlayTrack(
|
||||
touchHotCacheOnPlayback(scopedTrack.id, playbackCacheSid);
|
||||
};
|
||||
|
||||
const startAudio = (manualBlend: CrossfadeTransitionPlan | null) => {
|
||||
const startAudio = (manualBlend: EdgeMixPlan | null) => {
|
||||
if (deferInterruptUi) applyInterruptHandoffUi();
|
||||
clearInterruptHandoff();
|
||||
invokeAudioPlay(manualBlend);
|
||||
@@ -584,13 +607,11 @@ export function runPlayTrack(
|
||||
scopedTrack.duration || 0,
|
||||
)
|
||||
: null;
|
||||
startAudio(blend
|
||||
? {
|
||||
...blend,
|
||||
// Prep fade already ducked A when we waited for a cold B.
|
||||
outgoingFadeSec: bReadyNow ? blend.outgoingFadeSec : 0,
|
||||
}
|
||||
: null);
|
||||
startAudio(blend && !bReadyNow
|
||||
// Cold B: the interrupt prep already ducked A, so the linear handoff
|
||||
// must not fade A again — hold it at full (`outgoing_gain_end = 1`).
|
||||
? { ...blend, outgoingGainAtMixEnd: 1 }
|
||||
: blend);
|
||||
} catch {
|
||||
if (getPlayGeneration() !== gen) {
|
||||
clearInterruptHandoff();
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { setTransitionMode } from './playbackTransition';
|
||||
import { computeAutodjManualBlendPlan, shouldAutodjInterruptBlend } from './autodjManualBlend';
|
||||
|
||||
/** Loud plateau with a short trailing silence (500 bins, 100 s). */
|
||||
function loudTrackBins(trailQuietBins = 8): number[] {
|
||||
const bins = Array<number>(500).fill(200);
|
||||
for (let i = 0; i < trailQuietBins; i++) bins[499 - i] = 8;
|
||||
return bins;
|
||||
/** Fully-loud track (500 bins, plays as 100 s) — no lead/trail silence. */
|
||||
function loudBins(): number[] {
|
||||
return Array<number>(500).fill(200);
|
||||
}
|
||||
|
||||
/** Loud track with quiet head then plateau. */
|
||||
function loudIntroBins(leadQuietBins = 6): number[] {
|
||||
const bins = Array<number>(500).fill(200);
|
||||
for (let i = 0; i < leadQuietBins; i++) bins[i] = 8;
|
||||
return bins;
|
||||
}
|
||||
beforeEach(() => {
|
||||
// Reset the user transition bounds to Auto so the default-span tests are
|
||||
// independent of bound-override tests (Vitest keeps store state across cases).
|
||||
useAuthStore.setState({ autodjMinTransitionSec: 0, autodjMaxTransitionSec: 0 });
|
||||
});
|
||||
|
||||
describe('shouldAutodjInterruptBlend', () => {
|
||||
it('is true while playing even when manual flag would be false', () => {
|
||||
@@ -32,37 +29,38 @@ describe('shouldAutodjInterruptBlend', () => {
|
||||
});
|
||||
|
||||
describe('computeAutodjManualBlendPlan', () => {
|
||||
it('clamps overlap to remaining audible tail when skipping mid-track', () => {
|
||||
const aBins = loudTrackBins();
|
||||
const bBins = loudIntroBins();
|
||||
const plan = computeAutodjManualBlendPlan(aBins, 100, 95, bBins, 100);
|
||||
it('returns an edge-mix plan for a mid-track skip with loud A and B', () => {
|
||||
const plan = computeAutodjManualBlendPlan(loudBins(), 100, 50, loudBins(), 100);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.overlapSec).toBeLessThanOrEqual(100 - 95 + 0.01);
|
||||
expect(plan!.overlapSec).toBeGreaterThanOrEqual(0.5);
|
||||
expect(plan!.bStartSec).toBeGreaterThan(0);
|
||||
expect(plan!.transitionDur).toBeGreaterThan(0);
|
||||
expect(plan!.outgoingGainAtMixStart).toBe(1);
|
||||
expect(plan!.incomingGainAtMixEnd).toBe(1);
|
||||
});
|
||||
|
||||
it('uses standard blend for hard loud→loud when enough tail remains', () => {
|
||||
const aBins = loudTrackBins(4);
|
||||
const bBins = loudIntroBins(4);
|
||||
const plan = computeAutodjManualBlendPlan(aBins, 100, 50, bBins, 100);
|
||||
it('fully ducks A (outgoing_gain_end = 0) on a mid-track loud skip', () => {
|
||||
// Skip lands well before A's outro zone → not scenario A → A must fade to 0.
|
||||
const plan = computeAutodjManualBlendPlan(loudBins(), 100, 50, loudBins(), 100);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.overlapSec).toBe(2);
|
||||
expect(plan!.outgoingFadeSec).toBe(2);
|
||||
expect(plan!.outgoingGainAtMixEnd).toBe(0);
|
||||
});
|
||||
|
||||
it('caps skip blend to 2s when B has a long quiet intro', () => {
|
||||
const aBins = loudTrackBins();
|
||||
const bBins = loudIntroBins(80);
|
||||
const plan = computeAutodjManualBlendPlan(aBins, 100, 40, bBins, 100);
|
||||
it('honours the user max transition bound', () => {
|
||||
useAuthStore.setState({ autodjMaxTransitionSec: 1 });
|
||||
const plan = computeAutodjManualBlendPlan(loudBins(), 100, 50, loudBins(), 100);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.overlapSec).toBe(2);
|
||||
expect(plan!.outgoingFadeSec).toBe(2);
|
||||
expect(plan!.transitionDur).toBeLessThanOrEqual(1 + 1e-6);
|
||||
});
|
||||
|
||||
it('returns null when almost no audible tail remains on A', () => {
|
||||
const aBins = loudTrackBins();
|
||||
const bBins = loudIntroBins();
|
||||
expect(computeAutodjManualBlendPlan(aBins, 100, 99.95, bBins, 100)).toBeNull();
|
||||
expect(computeAutodjManualBlendPlan(loudBins(), 100, 99.95, loudBins(), 100)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when a waveform is missing', () => {
|
||||
expect(computeAutodjManualBlendPlan(null, 100, 50, loudBins(), 100)).toBeNull();
|
||||
expect(computeAutodjManualBlendPlan(loudBins(), 100, 50, undefined, 100)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-positive durations', () => {
|
||||
expect(computeAutodjManualBlendPlan(loudBins(), 0, 50, loudBins(), 100)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
analyzeBoundary,
|
||||
planCrossfadeTransition,
|
||||
STANDARD_BLEND_SEC,
|
||||
type CrossfadeTransitionPlan,
|
||||
} from '../waveform/waveformSilence';
|
||||
edgeDurationOptionsFromSettings,
|
||||
planEdgeMixForSkip,
|
||||
type EdgeMixPlan,
|
||||
} from '../waveform/autodjEdgeMix';
|
||||
import { getTransitionMode } from './playbackTransition';
|
||||
|
||||
/** Same trust threshold as end-of-track scenario A in `waveformSilence.ts`. */
|
||||
const OWN_FADE_TRUST_SEC = 1.0;
|
||||
|
||||
/** Minimum audible tail on A required to attempt a manual blend. */
|
||||
const MIN_A_REMAINING_SEC = 0.15;
|
||||
|
||||
/**
|
||||
* Manual skip is a deliberate "next track now" — cap how long loud A lingers over a
|
||||
* quiet B intro. End-of-track AutoDJ keeps content-driven spans; scenario A unchanged.
|
||||
*/
|
||||
const MANUAL_SKIP_MAX_BLEND_SEC = STANDARD_BLEND_SEC;
|
||||
|
||||
/**
|
||||
* True when switching to a different track while audio is already playing should
|
||||
* use the AutoDJ interrupt blend (same rules as manual skip). Excludes JS
|
||||
@@ -42,10 +29,13 @@ export function shouldAutodjManualBlend(manual: boolean, wasPlaying: boolean): b
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the same transition planning as end-of-track AutoDJ, but clamp the
|
||||
* overlap to the audible tail remaining on A from `skipFromTimeSec` (mid-track
|
||||
* skip). Non–scenario-A skips are capped to ~2 s so loud A does not linger over
|
||||
* a quiet B intro. Scenario A only applies when the skip lands inside A's outro fade zone.
|
||||
* AutoDJ manual skip / interrupt blend plan (§10.6). Delegates to the edge-mix
|
||||
* planner with the mid-track skip position on A, so the same content-derived
|
||||
* linear blend used for end-of-track transitions applies from the current
|
||||
* position. Honours the user's AutoDJ min/max transition bounds (0 = Auto).
|
||||
*
|
||||
* Returns null when either waveform is missing or A has too little audible tail
|
||||
* left for a stable blend — the caller then falls back to a plain cut.
|
||||
*/
|
||||
export function computeAutodjManualBlendPlan(
|
||||
aBins: number[] | null | undefined,
|
||||
@@ -53,39 +43,12 @@ export function computeAutodjManualBlendPlan(
|
||||
skipFromTimeSec: number,
|
||||
bBins: number[] | null | undefined,
|
||||
bDurationSec: number,
|
||||
): CrossfadeTransitionPlan | null {
|
||||
): EdgeMixPlan | null {
|
||||
const aDur = Number.isFinite(aDurationSec) && aDurationSec > 0 ? aDurationSec : 0;
|
||||
const bDur = Number.isFinite(bDurationSec) && bDurationSec > 0 ? bDurationSec : 0;
|
||||
if (aDur <= 0 || bDur <= 0) return null;
|
||||
|
||||
const base = planCrossfadeTransition(aBins, aDur, bBins, bDur);
|
||||
if (!(base.overlapSec > 0)) return null;
|
||||
|
||||
const aShape = analyzeBoundary(aBins, aDur);
|
||||
const bShape = analyzeBoundary(bBins, bDur);
|
||||
const aRemaining = aShape.contentEndSec - Math.max(0, skipFromTimeSec);
|
||||
if (aRemaining < MIN_A_REMAINING_SEC) return null;
|
||||
|
||||
let overlap = Math.max(0.5, Math.min(12, base.overlapSec, aRemaining));
|
||||
const bPlayable = Math.max(0, bShape.contentEndSec - base.bStartSec);
|
||||
if (bPlayable > 0) overlap = Math.min(overlap, bPlayable * 0.9);
|
||||
|
||||
const inOutroZone =
|
||||
skipFromTimeSec >= aShape.contentEndSec - Math.max(aShape.outroFadeSec, 0.5);
|
||||
const aRidesOwnFade = inOutroZone
|
||||
&& aShape.outroFadeSec >= OWN_FADE_TRUST_SEC
|
||||
&& aShape.outroFadeSec >= bShape.introRiseSec;
|
||||
if (!aRidesOwnFade && overlap < STANDARD_BLEND_SEC) {
|
||||
overlap = Math.min(STANDARD_BLEND_SEC, aRemaining, bPlayable > 0 ? bPlayable * 0.9 : STANDARD_BLEND_SEC);
|
||||
}
|
||||
if (!aRidesOwnFade && overlap > MANUAL_SKIP_MAX_BLEND_SEC) {
|
||||
overlap = MANUAL_SKIP_MAX_BLEND_SEC;
|
||||
}
|
||||
|
||||
const outgoingFadeSec = aRidesOwnFade ? 0 : overlap;
|
||||
return {
|
||||
bStartSec: base.bStartSec,
|
||||
overlapSec: overlap,
|
||||
outgoingFadeSec,
|
||||
};
|
||||
const { autodjMinTransitionSec, autodjMaxTransitionSec } = useAuthStore.getState();
|
||||
const opts = edgeDurationOptionsFromSettings(autodjMinTransitionSec, autodjMaxTransitionSec);
|
||||
return planEdgeMixForSkip(aBins, aDur, skipFromTimeSec, bBins, bDur, opts);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
WAVEFORM_GAMMA,
|
||||
analyzeEdge,
|
||||
detectWaveformEncoding,
|
||||
normU8,
|
||||
planEdgeMix,
|
||||
planEdgeMixForSkip,
|
||||
unGammaToAmplitude,
|
||||
} from './autodjEdgeMix';
|
||||
|
||||
// ── Synthetic waveform helpers (PCM percentile path: bin = 8 + t^gamma * 247) ──
|
||||
const N = 500;
|
||||
const DUR = 240; // 4 min → sec_per_bin = 0.48
|
||||
|
||||
function pcmBinForT(t: number): number {
|
||||
const clamped = Math.max(0, Math.min(1, t));
|
||||
return Math.round(8 + Math.pow(clamped, WAVEFORM_GAMMA) * 247);
|
||||
}
|
||||
|
||||
/** Constant amplitude `t` across the whole track. */
|
||||
function constBins(t: number, n = N): number[] {
|
||||
return new Array(n).fill(pcmBinForT(t));
|
||||
}
|
||||
|
||||
/** Linear ramp in amplitude `t` from start (bin 0) to end (last bin). */
|
||||
function rampBins(tStart: number, tEnd: number, n = N): number[] {
|
||||
return Array.from({ length: n }, (_, i) => pcmBinForT(tStart + (tEnd - tStart) * (i / (n - 1))));
|
||||
}
|
||||
|
||||
/** Loud content with `padBins` of trim-silence (bin 8 ≤ cut) on the given side(s). */
|
||||
function paddedLoud(side: 'start' | 'end' | 'both', padBins = 10, n = N): number[] {
|
||||
const bins = new Array(n).fill(pcmBinForT(1));
|
||||
if (side === 'start' || side === 'both') for (let i = 0; i < padBins; i++) bins[i] = 8;
|
||||
if (side === 'end' || side === 'both') for (let i = 0; i < padBins; i++) bins[n - 1 - i] = 8;
|
||||
return bins;
|
||||
}
|
||||
|
||||
/** Loud body with a linear amplitude fade-out over the last `fadeSec` seconds. */
|
||||
function fadeOutBins(fadeSec: number, n = N): number[] {
|
||||
const fadeBins = Math.max(1, Math.round(fadeSec / (DUR / n)));
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const fromEnd = n - 1 - i;
|
||||
return fromEnd >= fadeBins ? pcmBinForT(1) : pcmBinForT(fromEnd / fadeBins);
|
||||
});
|
||||
}
|
||||
|
||||
/** Loud body with a linear amplitude fade-in over the first `riseSec` seconds. */
|
||||
function fadeInBins(riseSec: number, n = N): number[] {
|
||||
const riseBins = Math.max(1, Math.round(riseSec / (DUR / n)));
|
||||
return Array.from({ length: n }, (_, i) => (i >= riseBins ? pcmBinForT(1) : pcmBinForT(i / riseBins)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Track ending in loud stabs separated by quiet gaps (regression: contiguous loud
|
||||
* run at the content edge is short, but hard↔hard must still get STANDARD_BLEND).
|
||||
*/
|
||||
function endStabsBins(stabSec = 0.5, gapSec = 0.35, stabCount = 3, n = N): number[] {
|
||||
const secPerBin = DUR / n;
|
||||
const stabBins = Math.max(1, Math.round(stabSec / secPerBin));
|
||||
const gapBins = Math.max(1, Math.round(gapSec / secPerBin));
|
||||
const bins = new Array(n).fill(pcmBinForT(0.08));
|
||||
let i = n - 1;
|
||||
for (let s = 0; s < stabCount && i >= 0; s++) {
|
||||
for (let k = 0; k < stabBins && i >= 0; k++, i--) bins[i] = pcmBinForT(1);
|
||||
for (let k = 0; k < gapBins && i >= 0; k++, i--) bins[i] = pcmBinForT(0.08);
|
||||
}
|
||||
while (i >= 0) { bins[i] = pcmBinForT(1); i--; }
|
||||
return bins;
|
||||
}
|
||||
|
||||
describe('normU8 / encoding (§16.1 G1)', () => {
|
||||
it('maps the PCM percentile range 8…255 to [0,1]', () => {
|
||||
expect(normU8(8, 'pcm_u8')).toBeCloseTo(0, 6);
|
||||
expect(normU8(255, 'pcm_u8')).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('places the documented 0.9 reference between bins 230 and 231', () => {
|
||||
expect(normU8(230, 'pcm_u8')).toBeLessThan(0.9);
|
||||
expect(normU8(230, 'pcm_u8')).toBeCloseTo(0.899, 2);
|
||||
expect(normU8(231, 'pcm_u8')).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
it('maps the byte-envelope fallback range 0…255 to [0,1]', () => {
|
||||
expect(normU8(0, 'byte_envelope')).toBeCloseTo(0, 6);
|
||||
expect(normU8(255, 'byte_envelope')).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('detects encoding from the peak floor (min < 8 → byte-envelope)', () => {
|
||||
expect(detectWaveformEncoding([0, 128, 255, 64])).toBe('byte_envelope');
|
||||
expect(detectWaveformEncoding([8, 100, 255, 40])).toBe('pcm_u8');
|
||||
expect(detectWaveformEncoding([])).toBe('pcm_u8');
|
||||
});
|
||||
|
||||
it('un-gammas norm_u8 to linear amplitude t = norm^(1/gamma)', () => {
|
||||
// norm_u8 ≈ 0.9 ⇒ t ≈ 0.82 of the track's own range (§16.7).
|
||||
expect(unGammaToAmplitude(0.9)).toBeCloseTo(Math.pow(0.9, 1 / WAVEFORM_GAMMA), 6);
|
||||
expect(unGammaToAmplitude(0.9)).toBeCloseTo(0.82, 2);
|
||||
expect(unGammaToAmplitude(1)).toBeCloseTo(1, 6);
|
||||
expect(unGammaToAmplitude(0)).toBeCloseTo(0, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeEdge — duration & shape (§3)', () => {
|
||||
it('returns null for missing bins or invalid duration', () => {
|
||||
expect(analyzeEdge(null, DUR, 'end')).toBeNull();
|
||||
expect(analyzeEdge(undefined, DUR, 'start')).toBeNull();
|
||||
expect(analyzeEdge(constBins(1), 0, 'end')).toBeNull();
|
||||
expect(analyzeEdge(constBins(1), Number.NaN, 'end')).toBeNull();
|
||||
expect(analyzeEdge([1, 2, 3], DUR, 'end')).toBeNull(); // wrong length → coerce null
|
||||
});
|
||||
|
||||
it('loud-throughout end edge saturates to max_duration with y0 ≈ 1', () => {
|
||||
const edge = analyzeEdge(constBins(1), DUR, 'end');
|
||||
expect(edge).not.toBeNull();
|
||||
expect(edge!.shape.seconds).toBeCloseTo(12, 5); // max_duration
|
||||
expect(edge!.shape.y0).toBeCloseTo(1, 2);
|
||||
});
|
||||
|
||||
it('self-fading outro → end edge spans the fade (not min_duration), y0 ≈ 0 (scenario A)', () => {
|
||||
// The boundary sits in the quiet fade tail, so the loud-run walk would
|
||||
// collapse to min_duration and B would only come in once A is already
|
||||
// silent. The edge must instead span A's own fade so B rises underneath it.
|
||||
const edge = analyzeEdge(fadeOutBins(6), DUR, 'end');
|
||||
expect(edge).not.toBeNull();
|
||||
expect(edge!.shape.seconds).toBeGreaterThan(2);
|
||||
expect(edge!.shape.seconds).toBeLessThan(8);
|
||||
expect(edge!.shape.y0).toBeLessThan(0.25); // quiet at the very end → A rides its fade
|
||||
});
|
||||
|
||||
it('quiet fade-in intro → start edge spans the rise (not min_duration), y0 ≈ 0', () => {
|
||||
const edge = analyzeEdge(fadeInBins(6), DUR, 'start');
|
||||
expect(edge).not.toBeNull();
|
||||
expect(edge!.shape.seconds).toBeGreaterThan(2);
|
||||
expect(edge!.shape.seconds).toBeLessThan(8);
|
||||
expect(edge!.shape.y0).toBeLessThan(0.25);
|
||||
});
|
||||
|
||||
it('hard-start intro → long start edge with y0 ≈ 1', () => {
|
||||
const edge = analyzeEdge(constBins(1), DUR, 'start');
|
||||
expect(edge).not.toBeNull();
|
||||
expect(edge!.shape.seconds).toBeCloseTo(12, 5);
|
||||
expect(edge!.shape.y0).toBeCloseTo(1, 2);
|
||||
});
|
||||
|
||||
it('anchors at the content edge — trimmed lead/trail silence does not collapse the edge', () => {
|
||||
// Regression: walking from the raw physical bin stopped instantly in the
|
||||
// trimmed silence → every edge became min_duration → near-gapless overlaps.
|
||||
const startEdge = analyzeEdge(paddedLoud('start'), DUR, 'start');
|
||||
expect(startEdge!.shape.seconds).toBeGreaterThan(2);
|
||||
expect(startEdge!.shape.y0).toBeCloseTo(1, 1);
|
||||
|
||||
const endEdge = analyzeEdge(paddedLoud('end'), DUR, 'end');
|
||||
expect(endEdge!.shape.seconds).toBeGreaterThan(2);
|
||||
expect(endEdge!.shape.y0).toBeCloseTo(1, 1);
|
||||
});
|
||||
|
||||
it('classifies edge kind: hard / ramp / silent', () => {
|
||||
expect(analyzeEdge(constBins(1), DUR, 'end')!.kind).toBe('hard');
|
||||
expect(analyzeEdge(fadeOutBins(6), DUR, 'end')!.kind).toBe('ramp');
|
||||
expect(analyzeEdge(constBins(0), DUR, 'end')!.kind).toBe('silent');
|
||||
});
|
||||
|
||||
it('does not count trim-silence residue after capped lead trim in start edge span', () => {
|
||||
// 30 silent bins (~14 s raw) but playback trim cap → seek at bin ~10; bins 10–29
|
||||
// are still digital silence and must not inflate the edge / fade window.
|
||||
const bins = new Array(N).fill(pcmBinForT(1));
|
||||
for (let i = 0; i < 30; i++) bins[i] = 8;
|
||||
const edge = analyzeEdge(bins, DUR, 'start');
|
||||
expect(edge!.kind).toBe('hard');
|
||||
expect(edge!.shape.y0).toBeCloseTo(1, 1);
|
||||
expect(edge!.shape.seconds).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it('does not count trim-silence residue before capped trail trim in end edge span', () => {
|
||||
const bins = new Array(N).fill(pcmBinForT(1));
|
||||
for (let i = 0; i < 30; i++) bins[N - 1 - i] = 8;
|
||||
const edge = analyzeEdge(bins, DUR, 'end');
|
||||
expect(edge!.kind).toBe('hard');
|
||||
expect(edge!.shape.y0).toBeCloseTo(1, 1);
|
||||
expect(edge!.shape.seconds).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it('silence-only track → raw 0 → min_duration, y0 ≈ 0 (fallback threshold path)', () => {
|
||||
const edge = analyzeEdge(constBins(0), DUR, 'end');
|
||||
expect(edge).not.toBeNull();
|
||||
expect(edge!.shape.seconds).toBeCloseTo(0.5, 1);
|
||||
expect(edge!.shape.y0).toBeLessThan(0.05);
|
||||
});
|
||||
|
||||
it('always clamps shape endpoints to [0,1] (clamp-refit)', () => {
|
||||
for (const bins of [rampBins(0, 1), rampBins(1, 0), constBins(0.6), constBins(1)]) {
|
||||
for (const side of ['start', 'end'] as const) {
|
||||
const edge = analyzeEdge(bins, DUR, side);
|
||||
expect(edge!.shape.y0).toBeGreaterThanOrEqual(0);
|
||||
expect(edge!.shape.y0).toBeLessThanOrEqual(1);
|
||||
expect(edge!.shape.y1).toBeGreaterThanOrEqual(0);
|
||||
expect(edge!.shape.y1).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('planEdgeMix — end-of-track AutoDJ (§4)', () => {
|
||||
it('returns null when either waveform is missing (never partial)', () => {
|
||||
expect(planEdgeMix(null, DUR, DUR, constBins(1), DUR, 0, DUR)).toBeNull();
|
||||
expect(planEdgeMix(constBins(1), DUR, DUR, null, DUR, 0, DUR)).toBeNull();
|
||||
});
|
||||
|
||||
it('produces a decreasing g_A and increasing g_B with the §4.2 fixed endpoints', () => {
|
||||
const plan = planEdgeMix(constBins(1), DUR, DUR, constBins(1), DUR, 0, DUR);
|
||||
expect(plan).not.toBeNull();
|
||||
// Fixed endpoints required by the IPC contract.
|
||||
expect(plan!.outgoingGainAtMixStart).toBeCloseTo(1, 6);
|
||||
expect(plan!.incomingGainAtMixEnd).toBeCloseTo(1, 6);
|
||||
// g_A decreases over the mix; g_B increases.
|
||||
expect(plan!.outgoingGainAtMixStart).toBeGreaterThanOrEqual(plan!.outgoingGainAtMixEnd);
|
||||
expect(plan!.incomingGainAtMixStart).toBeLessThanOrEqual(plan!.incomingGainAtMixEnd);
|
||||
for (const g of [
|
||||
plan!.outgoingGainAtMixEnd,
|
||||
plan!.incomingGainAtMixStart,
|
||||
]) {
|
||||
expect(g).toBeGreaterThanOrEqual(0);
|
||||
expect(g).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('transition_dur = min(edges), spanning A’s fade so B rises underneath it', () => {
|
||||
// A self-fades over ~6 s (end edge ≈ the fade); B hard-starts (start edge ≈ 12 s).
|
||||
// transition_dur must follow A's fade, not collapse to min_duration.
|
||||
const plan = planEdgeMix(fadeOutBins(6), DUR, 239, constBins(1), DUR, 0, DUR);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.transitionDur).toBeGreaterThan(2);
|
||||
expect(plan!.transitionDur).toBeLessThan(8);
|
||||
expect(plan!.outgoingGainAtMixEnd).toBeGreaterThan(0.7); // A rides its own fade
|
||||
expect(plan!.bStartSec).toBe(0);
|
||||
});
|
||||
|
||||
it('scenario A: self-fading outro keeps outgoing gain high at mix end (not forced to 0)', () => {
|
||||
const fade = planEdgeMix(rampBins(1, 0), DUR, DUR, constBins(1), DUR, 0, DUR);
|
||||
expect(fade!.outgoingGainAtMixEnd).toBeGreaterThan(0.8);
|
||||
|
||||
// Hard loud cut → engine must fully duck A by mix end.
|
||||
const hard = planEdgeMix(constBins(1), DUR, DUR, constBins(1), DUR, 0, DUR);
|
||||
expect(hard!.outgoingGainAtMixEnd).toBeLessThan(0.05);
|
||||
});
|
||||
|
||||
it('two loud tracks padded with edge silence still blend over a real overlap (not gapless)', () => {
|
||||
// A ends loud (with trailing silence trimmed); B starts loud (leading silence
|
||||
// trimmed). Content bounds come from the silence layer; the blend must be a
|
||||
// multi-second musical overlap, not a min_duration cut.
|
||||
const plan = planEdgeMix(paddedLoud('end'), DUR, 235.2, paddedLoud('start'), DUR, 4.8, DUR);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.transitionDur).toBeGreaterThan(2);
|
||||
expect(plan!.outgoingGainAtMixEnd).toBeLessThan(0.05); // loud A fully ducks
|
||||
expect(plan!.incomingGainAtMixStart).toBeLessThan(0.05); // loud B rises from ~0
|
||||
});
|
||||
|
||||
it('hard↔hard stabs at A end still blend ≥ STANDARD_BLEND (not a near-cut)', () => {
|
||||
// Regression from user report: A ends in loud stabs with gaps; contiguous loud
|
||||
// run is ~1 s but the mix must feel like a real crossfade.
|
||||
const plan = planEdgeMix(endStabsBins(), DUR, 235.2, constBins(1), DUR, 4.8, DUR);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.transitionDur).toBeGreaterThanOrEqual(1.9);
|
||||
expect(plan!.outgoingGainAtMixEnd).toBeLessThan(0.05);
|
||||
expect(plan!.incomingGainAtMixStart).toBeLessThan(0.05);
|
||||
});
|
||||
|
||||
it('documents endpoint overlap: g_A(0) + g_B(0) can exceed 1 before equal-power curve (§10.5)', () => {
|
||||
// Loud outgoing A + quiet-intro B (linear_B(0) ≈ 0 → incoming_start ≈ 1).
|
||||
const plan = planEdgeMix(constBins(1), DUR, DUR, rampBins(0, 1), DUR, 0, DUR);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.incomingGainAtMixStart).toBeGreaterThan(0.8);
|
||||
const sumAtStart = plan!.outgoingGainAtMixStart + plan!.incomingGainAtMixStart;
|
||||
expect(sumAtStart).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planEdgeMixForSkip — manual mid-track skip (§10.6)', () => {
|
||||
it('mid-track loud skip fully ducks the outgoing track and caps the blend', () => {
|
||||
const plan = planEdgeMixForSkip(constBins(1), DUR, 60, constBins(1), DUR);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.outgoingGainAtMixEnd).toBe(0); // engine fades A out fully
|
||||
expect(plan!.transitionDur).toBeLessThanOrEqual(2.0); // MANUAL_SKIP_MAX_BLEND_SEC
|
||||
expect(plan!.transitionDur).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('skip inside a sustained outro lets A ride its own end-edge shape', () => {
|
||||
// Constant moderate level → end edge stays above the plateau-relative threshold
|
||||
// (long edge), y0 ≈ 0.6 → outgoing_gain_end ≈ 0.4.
|
||||
const plan = planEdgeMixForSkip(constBins(0.6), DUR, 238, constBins(1), DUR);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.outgoingGainAtMixEnd).toBeCloseTo(0.4, 1);
|
||||
});
|
||||
|
||||
it('returns null when too little of A remains from the skip point', () => {
|
||||
expect(planEdgeMixForSkip(constBins(1), DUR, 239.9, constBins(1), DUR)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when a waveform is missing', () => {
|
||||
expect(planEdgeMixForSkip(null, DUR, 60, constBins(1), DUR)).toBeNull();
|
||||
expect(planEdgeMixForSkip(constBins(1), DUR, 60, undefined, DUR)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* AutoDJ edge-mix — waveform-driven equal-power blend (algorithm by Ivan Pelipenko,
|
||||
* @peri4ko). Each track boundary ("edge") is analysed from the *existing* cached
|
||||
* waveform bins: an edge has a duration and a clamped linear envelope. At mix
|
||||
* time we derive effective gain endpoints for the outgoing and incoming track;
|
||||
* the engine applies equal-power power-lerp curves and sums the sinks.
|
||||
*
|
||||
* This module is the pure, testable core (no Tauri/store deps). It does NOT do
|
||||
* silence trimming — that stays in `waveformSilence.ts` and is an orthogonal
|
||||
* layer; we only consume its content bounds for feasibility caps.
|
||||
*
|
||||
* Waveform domain (important): the cached bins are NOT linear [0,1] amplitude.
|
||||
* `normalize_peak_bins` (psysonic-analysis) applies per-track percentile
|
||||
* (p5→8, p99→255) and a gamma of 0.52 before storage, so `normU8` is perceptual.
|
||||
* Edge math therefore un-gammas to a linear amplitude `t = normU8^(1/gamma)` and
|
||||
* uses a plateau-relative threshold rather than a fixed absolute 0.9. See the
|
||||
* task spec §16.1 / §16.7 for the full rationale.
|
||||
*/
|
||||
import { coerceWaveformBins } from './waveformParse';
|
||||
import { computeWaveformSilence, contentPlayBinRange, peakHalf } from './waveformSilence';
|
||||
|
||||
/** Gamma applied by `normalize_peak_bins` before storage (perceptual shaping). */
|
||||
export const WAVEFORM_GAMMA = 0.52;
|
||||
|
||||
/** PCM percentile path floors silence at this u8; byte-envelope can emit 0. */
|
||||
const PCM_FLOOR = 8;
|
||||
/** Bins at/below this peak value are trim-silence (must match waveformSilence cut). */
|
||||
const SILENCE_CUT = 12;
|
||||
|
||||
const DEFAULT_MIN_DURATION = 0.5;
|
||||
const DEFAULT_MAX_DURATION = 12.0;
|
||||
/** Threshold = `plateauFactor * plateau_t` (fraction of the track's own plateau). */
|
||||
const DEFAULT_PLATEAU_FACTOR = 0.8;
|
||||
/** Lower clamp on the plateau-relative threshold (and floor when plateau usable). */
|
||||
const DEFAULT_ABS_FLOOR_T = 0.3;
|
||||
/** Upper clamp on the plateau-relative threshold. */
|
||||
const THRESHOLD_T_CAP = 0.95;
|
||||
/** Absolute fallback (on `normU8`) when the plateau cannot be computed. */
|
||||
const DEFAULT_THRESHOLD_NORM_U8 = 0.9;
|
||||
/** Bins with `t` below this are treated as silence for the plateau percentile. */
|
||||
const PLATEAU_SILENCE_FLOOR_T = 0.05;
|
||||
/** Don't let the overlap eat more than this fraction of the shorter content window. */
|
||||
const SUSTAINABLE_FACTOR = 0.9;
|
||||
/** Manual mid-track skip: cap loud A lingering over a quiet B intro. */
|
||||
const MANUAL_SKIP_MAX_BLEND_SEC = 2.0;
|
||||
/** A's own outro fade must be at least this long to be trusted (scenario A). */
|
||||
const OWN_FADE_TRUST_SEC = 1.0;
|
||||
/**
|
||||
* Minimum pleasant blend when both edges are hard loud↔loud (ported from main
|
||||
* `planCrossfadeTransition` / `STANDARD_BLEND_SEC`).
|
||||
*/
|
||||
const STANDARD_BLEND_SEC = 2.0;
|
||||
|
||||
export type WaveformEncoding = 'pcm_u8' | 'byte_envelope';
|
||||
|
||||
/** How the content edge was classified during span measurement. */
|
||||
export type EdgeKind = 'hard' | 'ramp' | 'silent';
|
||||
|
||||
/** Edge linear shape after clamp-refit. Mix uses `y0` + `seconds` only (`y1` is debug). */
|
||||
export interface EdgeShape {
|
||||
/** Edge span in seconds (after clamp to [min,max]). */
|
||||
seconds: number;
|
||||
/** linear(0) — un-gamma'd amplitude at the physical edge, clamped to [0,1]. */
|
||||
y0: number;
|
||||
/** linear(seconds) — clamped to [0,1]; shape/debug only, not passed to the mix. */
|
||||
y1: number;
|
||||
}
|
||||
|
||||
export interface AnalyzedEdge {
|
||||
side: 'start' | 'end';
|
||||
/** `hard` = loud boundary (contiguous loud run); `ramp` = natural fade/rise; `silent` = degenerate. */
|
||||
kind: EdgeKind;
|
||||
shape: EdgeShape;
|
||||
}
|
||||
|
||||
/** Tunables; defaults locked in the task spec §16.7 G7. Sweepable for calibration. */
|
||||
export interface EdgeAnalysisOptions {
|
||||
minDuration?: number;
|
||||
maxDuration?: number;
|
||||
/** Un-gamma exponent base; defaults to {@link WAVEFORM_GAMMA}. */
|
||||
gamma?: number;
|
||||
/** Threshold = plateauFactor * plateau_t. */
|
||||
plateauFactor?: number;
|
||||
/** Floor/clamp for the plateau-relative threshold (in `t` space). */
|
||||
absFloorT?: number;
|
||||
/** Absolute fallback threshold on `normU8` when no plateau. */
|
||||
thresholdNormU8?: number;
|
||||
}
|
||||
|
||||
/** Per-transition plan handed to the engine (ephemeral, like CrossfadeTransitionPlan). */
|
||||
export interface EdgeMixPlan {
|
||||
/** Mix overlap length (seconds). */
|
||||
transitionDur: number;
|
||||
/** Always 1.0 — outgoing starts the mix at full engine gain. */
|
||||
outgoingGainAtMixStart: number;
|
||||
/** 1 - linear_A(0) — may stay > 0 (old not forced to silence; scenario A). */
|
||||
outgoingGainAtMixEnd: number;
|
||||
/** 1 - linear_B(0) — may be > 0 (new may not start from 0). */
|
||||
incomingGainAtMixStart: number;
|
||||
/** Always 1.0 — new must exit the mix at full engine gain. */
|
||||
incomingGainAtMixEnd: number;
|
||||
/** Silence-trim B seek (unchanged source). */
|
||||
bStartSec: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build {@link EdgeAnalysisOptions} duration bounds from the user's AutoDJ
|
||||
* transition-length settings. `0` (or any non-positive value) on either bound is
|
||||
* the "Auto" sentinel → that bound is left unset so the algorithm keeps its
|
||||
* built-in default. A set min that exceeds a set max is collapsed to the max so
|
||||
* the window never inverts.
|
||||
*/
|
||||
export function edgeDurationOptionsFromSettings(
|
||||
minTransitionSec: number,
|
||||
maxTransitionSec: number,
|
||||
): EdgeAnalysisOptions {
|
||||
const opts: EdgeAnalysisOptions = {};
|
||||
const min = Number.isFinite(minTransitionSec) && minTransitionSec > 0 ? minTransitionSec : undefined;
|
||||
const max = Number.isFinite(maxTransitionSec) && maxTransitionSec > 0 ? maxTransitionSec : undefined;
|
||||
if (max != null) opts.maxDuration = max;
|
||||
if (min != null) opts.minDuration = max != null ? Math.min(min, max) : min;
|
||||
return opts;
|
||||
}
|
||||
|
||||
function clamp01(x: number): number {
|
||||
return x < 0 ? 0 : x > 1 ? 1 : x;
|
||||
}
|
||||
|
||||
function clamp(x: number, lo: number, hi: number): number {
|
||||
return x < lo ? lo : x > hi ? hi : x;
|
||||
}
|
||||
|
||||
/** Detect waveform encoding from the peak half (G1). */
|
||||
export function detectWaveformEncoding(peak: number[]): WaveformEncoding {
|
||||
if (peak.length === 0) return 'pcm_u8';
|
||||
let min = peak[0];
|
||||
for (let i = 1; i < peak.length; i++) if (peak[i] < min) min = peak[i];
|
||||
return min < PCM_FLOOR ? 'byte_envelope' : 'pcm_u8';
|
||||
}
|
||||
|
||||
/** Map one bin to the stored (gamma'd, perceptual) [0,1] value (G1). */
|
||||
export function normU8(bin: number, encoding: WaveformEncoding): number {
|
||||
return encoding === 'byte_envelope'
|
||||
? clamp01(bin / 255)
|
||||
: clamp01((bin - PCM_FLOOR) / (255 - PCM_FLOOR));
|
||||
}
|
||||
|
||||
/** Un-gamma a stored value to linear percentile amplitude `t` (§16.7). */
|
||||
export function unGammaToAmplitude(normValue: number, gamma = WAVEFORM_GAMMA): number {
|
||||
return Math.pow(clamp01(normValue), 1 / gamma);
|
||||
}
|
||||
|
||||
function binToAmplitude(bin: number, encoding: WaveformEncoding, gamma: number): number {
|
||||
return unGammaToAmplitude(normU8(bin, encoding), gamma);
|
||||
}
|
||||
|
||||
/** 75th-percentile of above-floor amplitudes within the playable content window. */
|
||||
function plateauAmplitude(tValues: number[], peak: number[], startBin: number, endBin: number): number {
|
||||
const loud: number[] = [];
|
||||
for (let i = startBin; i < endBin; i++) {
|
||||
if (peak[i] > SILENCE_CUT && tValues[i] > PLATEAU_SILENCE_FLOOR_T) loud.push(tValues[i]);
|
||||
}
|
||||
if (loud.length === 0) return 0;
|
||||
loud.sort((a, b) => a - b);
|
||||
return loud[Math.min(loud.length - 1, Math.floor(loud.length * 0.75))];
|
||||
}
|
||||
|
||||
/** Least-squares line `y = a*t + b` over points (slope 0 for a single point). */
|
||||
function fitLine(points: { t: number; y: number }[]): { a: number; b: number } {
|
||||
const n = points.length;
|
||||
if (n <= 1) return { a: 0, b: n === 1 ? points[0].y : 0 };
|
||||
let sumT = 0;
|
||||
let sumY = 0;
|
||||
let sumTT = 0;
|
||||
let sumTY = 0;
|
||||
for (const p of points) {
|
||||
sumT += p.t;
|
||||
sumY += p.y;
|
||||
sumTT += p.t * p.t;
|
||||
sumTY += p.t * p.y;
|
||||
}
|
||||
const denom = n * sumTT - sumT * sumT;
|
||||
if (Math.abs(denom) < 1e-9) return { a: 0, b: sumY / n };
|
||||
const a = (n * sumTY - sumT * sumY) / denom;
|
||||
const b = (sumY - a * sumT) / n;
|
||||
return { a, b };
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse one edge of a track. `side='start'` walks inward from the content
|
||||
* start; `side='end'` walks backward from the content end (both from the
|
||||
* silence-trimmed boundary, not the raw file edge). The span is the adjacent
|
||||
* loud run for a hard edge, or the natural fade/rise envelope when the boundary
|
||||
* is below threshold. Returns null when bins are missing/invalid or duration is
|
||||
* non-positive.
|
||||
*/
|
||||
export function analyzeEdge(
|
||||
bins: number[] | null | undefined,
|
||||
durationSec: number,
|
||||
side: 'start' | 'end',
|
||||
opts: EdgeAnalysisOptions = {},
|
||||
): AnalyzedEdge | null {
|
||||
const coerced = coerceWaveformBins(bins);
|
||||
const dur = Number.isFinite(durationSec) && durationSec > 0 ? durationSec : 0;
|
||||
if (!coerced || dur <= 0) return null;
|
||||
|
||||
const peak = peakHalf(coerced);
|
||||
const n = peak.length;
|
||||
if (n === 0) return null;
|
||||
|
||||
const minDuration = Math.max(0.01, opts.minDuration ?? DEFAULT_MIN_DURATION);
|
||||
const maxDuration = Math.max(minDuration, opts.maxDuration ?? DEFAULT_MAX_DURATION);
|
||||
const gamma = opts.gamma && opts.gamma > 0 ? opts.gamma : WAVEFORM_GAMMA;
|
||||
const plateauFactor = opts.plateauFactor ?? DEFAULT_PLATEAU_FACTOR;
|
||||
const absFloorT = opts.absFloorT ?? DEFAULT_ABS_FLOOR_T;
|
||||
const thresholdNormU8 = opts.thresholdNormU8 ?? DEFAULT_THRESHOLD_NORM_U8;
|
||||
|
||||
const encoding = detectWaveformEncoding(peak);
|
||||
const tValues = peak.map(b => binToAmplitude(b, encoding, gamma));
|
||||
|
||||
// Plateau-relative threshold (un-gamma'd amplitude domain). Fallback to the
|
||||
// absolute normU8 threshold (also un-gamma'd) when no usable plateau exists.
|
||||
// Plateau is measured only inside the playable content window — not from trim
|
||||
// silence bins that would skew the threshold.
|
||||
const playRange = contentPlayBinRange(peak, dur);
|
||||
const playStartBin = playRange?.startBin ?? 0;
|
||||
const playEndBin = playRange?.endBin ?? n;
|
||||
|
||||
const plateau = plateauAmplitude(tValues, peak, playStartBin, playEndBin);
|
||||
const thresholdT =
|
||||
plateau > 0
|
||||
? clamp(plateauFactor * plateau, absFloorT, THRESHOLD_T_CAP)
|
||||
: unGammaToAmplitude(thresholdNormU8, gamma);
|
||||
|
||||
const secPerBin = dur / n;
|
||||
|
||||
// Anchor edge walks at the first/last *audible* bin inside the playback window.
|
||||
// `computeWaveformSilence` may seek past only part of a long digital tail (trim
|
||||
// cap); bins between the seek point and real music are still trim-silence and
|
||||
// must not inflate edge span or fade length.
|
||||
const isTrimSilence = (idx: number) =>
|
||||
idx < playStartBin || idx >= playEndBin || peak[idx] <= SILENCE_CUT;
|
||||
|
||||
let edgeBin: number;
|
||||
if (side === 'start') {
|
||||
edgeBin = playStartBin;
|
||||
while (edgeBin < playEndBin && isTrimSilence(edgeBin)) edgeBin++;
|
||||
} else {
|
||||
edgeBin = playEndBin - 1;
|
||||
while (edgeBin >= playStartBin && isTrimSilence(edgeBin)) edgeBin--;
|
||||
}
|
||||
if (edgeBin < playStartBin || edgeBin >= playEndBin) {
|
||||
return { side, kind: 'silent', shape: { seconds: minDuration, y0: 0, y1: 0 } };
|
||||
}
|
||||
|
||||
const step = side === 'start' ? 1 : -1;
|
||||
const past = (idx: number) =>
|
||||
side === 'start' ? idx >= playEndBin || isTrimSilence(idx) : idx < playStartBin || isTrimSilence(idx);
|
||||
|
||||
let runBins = 0;
|
||||
let kind: EdgeKind;
|
||||
const loudAt = (idx: number) => tValues[idx] >= thresholdT;
|
||||
if (loudAt(edgeBin)) {
|
||||
let i = edgeBin;
|
||||
while (!past(i) && loudAt(i)) { i += step; runBins++; }
|
||||
kind = 'hard';
|
||||
} else {
|
||||
// Walk the quiet envelope until it reaches the loud body. If it never does
|
||||
// (whole content below threshold — silent/degenerate track), there is no
|
||||
// edge to ride → fall back to the min_duration clamp.
|
||||
let i = edgeBin;
|
||||
while (!past(i) && !loudAt(i)) { i += step; runBins++; }
|
||||
if (past(i)) {
|
||||
runBins = 0;
|
||||
kind = 'silent';
|
||||
} else {
|
||||
kind = 'ramp';
|
||||
}
|
||||
}
|
||||
const rawSeconds = runBins * secPerBin;
|
||||
const edgeSeconds = clamp(rawSeconds, minDuration, maxDuration);
|
||||
|
||||
// Step 2 — collect samples over the (possibly forced) edge window, in edge
|
||||
// time (t = 0 at the content edge, increasing inward).
|
||||
const windowBins = clamp(Math.round(edgeSeconds / secPerBin), 1, n);
|
||||
const points: { t: number; y: number }[] = [];
|
||||
for (let k = 0; k < windowBins; k++) {
|
||||
const binIndex = side === 'start' ? edgeBin + k : edgeBin - k;
|
||||
if (binIndex < playStartBin || binIndex >= playEndBin || isTrimSilence(binIndex)) break;
|
||||
points.push({ t: k * secPerBin, y: tValues[binIndex] });
|
||||
}
|
||||
|
||||
// First fit (LSQ) → endpoints → clamp y to [0,1] → two-point refit.
|
||||
const { a, b } = fitLine(points);
|
||||
const y0 = clamp01(b);
|
||||
const y1 = clamp01(a * edgeSeconds + b);
|
||||
|
||||
return { side, kind, shape: { seconds: edgeSeconds, y0, y1 } };
|
||||
}
|
||||
|
||||
interface FinalizeInputs {
|
||||
aContentStartSec: number;
|
||||
aContentEndSec: number;
|
||||
bStartSec: number;
|
||||
bContentEndSec: number;
|
||||
/** Manual skip only: wall-clock on A where the mix begins. */
|
||||
mixStartA?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap the maintainer `min(edge)` overlap to the playable content window
|
||||
* (§16.2). Returns null only on the manual-skip path when A has too little
|
||||
* tail left. `edge.seconds` is never re-clamped here — only `transition_dur`.
|
||||
*/
|
||||
function finalizeTransitionDur(
|
||||
edgeA: AnalyzedEdge,
|
||||
edgeB: AnalyzedEdge,
|
||||
io: FinalizeInputs,
|
||||
minDuration: number,
|
||||
maxDuration: number,
|
||||
): number | null {
|
||||
const raw = Math.min(edgeA.shape.seconds, edgeB.shape.seconds);
|
||||
let td = clamp(raw, minDuration, maxDuration);
|
||||
|
||||
const aContentLen = Math.max(0, io.aContentEndSec - io.aContentStartSec);
|
||||
const bPlayable = Math.max(0, io.bContentEndSec - io.bStartSec);
|
||||
const sustainable = Math.min(aContentLen, bPlayable) * SUSTAINABLE_FACTOR;
|
||||
|
||||
// Hard loud↔loud: don't butt with a near-cut — blend at least STANDARD_BLEND_SEC
|
||||
// (same intent as main planCrossfadeTransition), capped by sustainability.
|
||||
if (edgeA.kind === 'hard' && edgeB.kind === 'hard') {
|
||||
const floor = Math.min(STANDARD_BLEND_SEC, sustainable, maxDuration);
|
||||
td = Math.max(td, floor);
|
||||
}
|
||||
|
||||
td = Math.min(td, sustainable);
|
||||
|
||||
if (io.mixStartA != null) {
|
||||
const aRemaining = Math.max(0, io.aContentEndSec - io.mixStartA);
|
||||
if (aRemaining < minDuration) return null;
|
||||
td = Math.min(td, aRemaining);
|
||||
}
|
||||
|
||||
return Math.max(minDuration, td);
|
||||
}
|
||||
|
||||
function resolveDurations(opts: EdgeAnalysisOptions): { minDuration: number; maxDuration: number } {
|
||||
const minDuration = Math.max(0.01, opts.minDuration ?? DEFAULT_MIN_DURATION);
|
||||
return { minDuration, maxDuration: Math.max(minDuration, opts.maxDuration ?? DEFAULT_MAX_DURATION) };
|
||||
}
|
||||
|
||||
/**
|
||||
* End-of-track AutoDJ plan: outgoing A end edge + incoming B start edge.
|
||||
* Returns null when either edge is unavailable (caller degrades to engine
|
||||
* crossfade — never a partial plan).
|
||||
*/
|
||||
export function planEdgeMix(
|
||||
aBins: number[] | null | undefined,
|
||||
aDurationSec: number,
|
||||
aContentEndSec: number,
|
||||
bBins: number[] | null | undefined,
|
||||
bDurationSec: number,
|
||||
bStartSec: number,
|
||||
bContentEndSec: number,
|
||||
opts: EdgeAnalysisOptions = {},
|
||||
): EdgeMixPlan | null {
|
||||
const edgeA = analyzeEdge(aBins, aDurationSec, 'end', opts);
|
||||
const edgeB = analyzeEdge(bBins, bDurationSec, 'start', opts);
|
||||
if (!edgeA || !edgeB) return null;
|
||||
|
||||
const { minDuration, maxDuration } = resolveDurations(opts);
|
||||
const aContentStartSec = computeWaveformSilence(aBins, aDurationSec).contentStartSec;
|
||||
|
||||
const transitionDur = finalizeTransitionDur(
|
||||
edgeA,
|
||||
edgeB,
|
||||
{ aContentStartSec, aContentEndSec, bStartSec, bContentEndSec },
|
||||
minDuration,
|
||||
maxDuration,
|
||||
);
|
||||
if (transitionDur == null) return null;
|
||||
|
||||
return {
|
||||
transitionDur,
|
||||
outgoingGainAtMixStart: 1,
|
||||
outgoingGainAtMixEnd: clamp01(1 - edgeA.shape.y0),
|
||||
incomingGainAtMixStart: clamp01(1 - edgeB.shape.y0),
|
||||
incomingGainAtMixEnd: 1,
|
||||
bStartSec: Math.max(0, bStartSec),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual skip / interrupt blend (§10.6). The track-end edge does not relate to
|
||||
* a mid-track skip point, so unless the skip lands inside A's own outro fade we
|
||||
* fully duck the outgoing (`outgoingGainAtMixEnd = 0`) and cap the overlap.
|
||||
* Returns null when A has too little audible tail left from `skipFromTimeSec`.
|
||||
*/
|
||||
export function planEdgeMixForSkip(
|
||||
aBins: number[] | null | undefined,
|
||||
aDurationSec: number,
|
||||
skipFromTimeSec: number,
|
||||
bBins: number[] | null | undefined,
|
||||
bDurationSec: number,
|
||||
opts: EdgeAnalysisOptions = {},
|
||||
): EdgeMixPlan | null {
|
||||
const edgeA = analyzeEdge(aBins, aDurationSec, 'end', opts);
|
||||
const edgeB = analyzeEdge(bBins, bDurationSec, 'start', opts);
|
||||
if (!edgeA || !edgeB) return null;
|
||||
|
||||
const { minDuration, maxDuration } = resolveDurations(opts);
|
||||
const aSilence = computeWaveformSilence(aBins, aDurationSec);
|
||||
const bSilence = computeWaveformSilence(bBins, bDurationSec);
|
||||
const contentEndA = aSilence.contentEndSec;
|
||||
const bStart = bSilence.contentStartSec;
|
||||
|
||||
const inOutroZone = skipFromTimeSec >= contentEndA - Math.max(edgeA.shape.seconds, 0.5);
|
||||
const aRidesOwnFade =
|
||||
inOutroZone &&
|
||||
edgeA.shape.seconds >= OWN_FADE_TRUST_SEC &&
|
||||
edgeA.shape.seconds >= edgeB.shape.seconds;
|
||||
|
||||
let transitionDur = finalizeTransitionDur(
|
||||
edgeA,
|
||||
edgeB,
|
||||
{
|
||||
aContentStartSec: aSilence.contentStartSec,
|
||||
aContentEndSec: contentEndA,
|
||||
bStartSec: bStart,
|
||||
bContentEndSec: bSilence.contentEndSec,
|
||||
mixStartA: Math.max(0, skipFromTimeSec),
|
||||
},
|
||||
minDuration,
|
||||
maxDuration,
|
||||
);
|
||||
if (transitionDur == null) return null;
|
||||
|
||||
if (!aRidesOwnFade) {
|
||||
transitionDur = Math.min(transitionDur, MANUAL_SKIP_MAX_BLEND_SEC);
|
||||
if (transitionDur < minDuration) return null;
|
||||
}
|
||||
|
||||
return {
|
||||
transitionDur,
|
||||
outgoingGainAtMixStart: 1,
|
||||
outgoingGainAtMixEnd: aRidesOwnFade ? clamp01(1 - edgeA.shape.y0) : 0,
|
||||
incomingGainAtMixStart: clamp01(1 - edgeB.shape.y0),
|
||||
incomingGainAtMixEnd: 1,
|
||||
bStartSec: Math.max(0, bStart),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { analyzeBoundary, computeWaveformSilence, planCrossfadeTransition } from './waveformSilence';
|
||||
import { analyzeBoundary, computeWaveformSilence, contentPlayBinRange, planCrossfadeTransition } from './waveformSilence';
|
||||
|
||||
/** Build a 500-bin peak curve: `lead` silent bins, a loud middle, `trail` silent bins. */
|
||||
function curve(lead: number, mid: number, trail: number, loud = 200, quiet = 4): number[] {
|
||||
@@ -61,6 +61,14 @@ describe('computeWaveformSilence', () => {
|
||||
expect(r.trailSilenceSec).toBe(8);
|
||||
});
|
||||
|
||||
it('contentPlayBinRange matches capped trim in bin space', () => {
|
||||
const bins = curve(20, 470, 10);
|
||||
const peak = bins; // single curve in tests
|
||||
const range = contentPlayBinRange(peak, 250)!;
|
||||
expect(range.startBin).toBe(10); // 5 s cap at 0.5 s/bin
|
||||
expect(range.endBin).toBe(490);
|
||||
});
|
||||
|
||||
it('never trims a fully-silent curve to nothing', () => {
|
||||
const bins = Array(500).fill(3);
|
||||
const r = computeWaveformSilence(bins, 120);
|
||||
|
||||
@@ -33,11 +33,47 @@ export interface WaveformSilenceOptions {
|
||||
const DEFAULT_SILENCE_CUT = 12;
|
||||
const DEFAULT_MAX_TRIM_SEC = 5;
|
||||
|
||||
/**
|
||||
* Playback content window `[startBin, endBin)` in bin indices — mirrors
|
||||
* {@link computeWaveformSilence} (trim cap included). Use this instead of
|
||||
* rounding `contentStartSec` / `contentEndSec` back to bins, which can drift
|
||||
* by ±1 bin and pull trim-silence into edge/fade math.
|
||||
*/
|
||||
export function contentPlayBinRange(
|
||||
peak: number[],
|
||||
durationSec: number,
|
||||
opts: WaveformSilenceOptions = {},
|
||||
): { startBin: number; endBin: number } | null {
|
||||
const dur = Number.isFinite(durationSec) && durationSec > 0 ? durationSec : 0;
|
||||
if (peak.length === 0 || dur <= 0) return null;
|
||||
|
||||
const cut = opts.cut ?? DEFAULT_SILENCE_CUT;
|
||||
const maxTrimSec = opts.maxTrimSec ?? DEFAULT_MAX_TRIM_SEC;
|
||||
const n = peak.length;
|
||||
const secPerBin = dur / n;
|
||||
|
||||
let anyLoud = false;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (peak[i] > cut) { anyLoud = true; break; }
|
||||
}
|
||||
if (!anyLoud) return null;
|
||||
|
||||
let leadBins = 0;
|
||||
while (leadBins < n && peak[leadBins] <= cut) leadBins++;
|
||||
let trailBins = 0;
|
||||
while (trailBins < n && peak[n - 1 - trailBins] <= cut) trailBins++;
|
||||
|
||||
const maxTrimBins = Math.max(0, Math.round(maxTrimSec / secPerBin));
|
||||
const startBin = Math.min(leadBins, maxTrimBins);
|
||||
const endBin = Math.max(startBin + 1, n - Math.min(trailBins, maxTrimBins));
|
||||
return { startBin, endBin };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dual-curve payload is peak ++ mean; use the peak half. Legacy single curve
|
||||
* (length === peak length) is used as-is.
|
||||
*/
|
||||
function peakHalf(bins: number[]): number[] {
|
||||
export function peakHalf(bins: number[]): number[] {
|
||||
return bins.length >= 1000 ? bins.slice(0, Math.floor(bins.length / 2)) : bins;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user