Browse / github.com/storytold/soundcraft / patch
Tempo follow tick tracks
Tempo follow tick tracks When the tempo map changes, clips on tick-timebase tracks now keep their musical positions instead of their sample positions. - MIDI clips and audio on Elastic Audio tracks keep their length in ticks. Audio is re-stretched from the unwarped original, so dragging a tempo doesn't degrade it. - Varispeed tracks change speed. - Other audio moves but keeps its length in samples. - Covers `event.tempo`, `tempo_scale`, `tempo_constant`, `tempo_linear`, `tempo_parabolic`, `tempo_s_curve` and `tempo_stretch`. Implementation: new `cmd/tempo_follow.rs`; `warp_clip` factored out of `clip.elastic_propert
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- nirinaRakotoniary (imported from pull request #785, open)
- Base
60087e0f46032118bf309ed9ea161a9b4a872713- Size
- +296 −46 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
88c8bb5048c6f72fd9eb0bb0
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd.rs crates/engine/src/cmd/clip.rs crates/engine/src/cmd/event.rs crates/engine/src/cmd/event_more.rs crates/engine/src/cmd/tempo_follow.rs crates/time/src/tempo.rs
@@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **2026-10-19:** Tick-timebase tracks follow tempo changes: clips keep their musical positions (MIDI and Elastic Audio keep their length in ticks, re-stretched from the unwarped original; Varispeed changes speed) for every tempo command - **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change. - **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change. - **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.@@ -176,6 +177,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---|+| 2026-10-19 | trivial | Progress log: tick-timebase tracks follow tempo changes | | 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged | | 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged | | 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |@@ -30,6 +30,7 @@ pub(crate) mod sampler; mod setup_more; mod surround; mod tab;+mod tempo_follow; mod track; mod track_more; mod transport;@@ -197,41 +197,65 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> { let s = e.session_mut(); let mut rendered = 0; for id in &ids {- let Some((t, c)) = s.find_clip(*id).map(|(t, c)| (t, c.clone())) else { continue };- let elastic = s.track(t).and_then(|x| x.elastic.clone()).filter(|a| a != "varispeed");- // Start from the unwarped original if this clip was warped before.- let key = |k: &str| format!("elastic.{}.{k}", id.0);- let orig = warp_original(s, *id);- let mut base = c.clone();- if let Some((src, off, len)) = orig {- base.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };- base.length = len.max(1);- base.stretch = 1.0;+ if warp_clip(s, *id, |_| ratio, false)? {+ rendered += 1; }- if elastic.is_none() || !base.is_audio() {- if let Some(cm) = s.find_clip_mut(*id) {- cm.stretch = ratio;- }- continue;+ }+ Ok(json!({"ratio": ratio, "rendered": rendered}))+}++/// Warp one clip. `ratio_for` gets the unwarped clip and returns the stretch ratio to apply to it.+/// Elastic tracks render the stretched audio and return `true`; other tracks only set the+/// varispeed ratio. With `in_place` the clip keeps its neighbours (`place_clip` would overwrite+/// whatever the longer clip now covers).+pub(super) fn warp_clip(+ s: &mut soundcraft_model::Session,+ id: ClipId,+ ratio_for: impl FnOnce(&soundcraft_model::Clip) -> f64,+ in_place: bool,+) -> Result<bool> {+ let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { return Ok(false) };+ let elastic = s.track(t).and_then(|x| x.elastic.clone()).filter(|a| a != "varispeed");+ // Start from the unwarped original if this clip was warped before.+ let key = |k: &str| format!("elastic.{}.{k}", id.0);+ let orig = warp_original(s, id);+ let mut base = c.clone();+ if let Some((src, off, len)) = orig {+ base.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };+ base.length = len.max(1);+ base.stretch = 1.0;+ }+ let ratio = ratio_for(&base).clamp(0.05, 20.0);+ if elastic.is_none() || !base.is_audio() {+ if let Some(cm) = s.find_clip_mut(id) {+ cm.stretch = ratio; }- let out_len = soundcraft_time::to_samples(base.length as f64 * ratio).max(1);- let Some(src) = super::more_util::render_stretched(s, "clip.elastic_properties", &base, out_len)? else { continue };- if orig.is_none()- && let soundcraft_model::ClipContent::Audio { source, offset } = base.content- {- s.edit.values.insert(key("src"), source.0 as f64);- s.edit.values.insert(key("off"), offset as f64);- s.edit.values.insert(key("len"), base.length as f64);+ return Ok(false);+ }+ let out_len = soundcraft_time::to_samples(base.length as f64 * ratio).max(1);+ let Some(src) = super::more_util::render_stretched(s, "clip.elastic_properties", &base, out_len)? else { return Ok(false) };+ if orig.is_none()+ && let soundcraft_model::ClipContent::Audio { source, offset } = base.content+ {+ s.edit.values.insert(key("src"), source.0 as f64);+ s.edit.values.insert(key("off"), offset as f64);+ s.edit.values.insert(key("len"), base.length as f64);+ }+ s.edit.values.insert(key("ratio"), ratio);+ let mut nc = base;+ nc.content = soundcraft_model::ClipContent::Audio { source: src, offset: 0 };+ nc.length = out_len;+ nc.stretch = 1.0;+ if in_place {+ nc.fade_in.len = nc.fade_in.len.min(out_len);+ nc.fade_out.len = nc.fade_out.len.min(out_len);+ if let Some(cm) = s.find_clip_mut(id) {+ *cm = nc; }- s.edit.values.insert(key("ratio"), ratio);- let mut nc = base;- nc.content = soundcraft_model::ClipContent::Audio { source: src, offset: 0 };- nc.length = out_len;- nc.stretch = 1.0;- super::more_util::replace_clip(s, t, *id, nc);- rendered += 1;+ } else {+ super::more_util::replace_clip(s, t, id, nc); }- Ok(json!({"ratio": ratio, "rendered": rendered}))+ Ok(true) } fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {@@ -1,6 +1,7 @@ //! Event menu: time/tempo operations, MIDI operations, markers. use super::{CommandSpec, always, bad, clip_ids_param, f32_or, f64_or, has_selection, has_tracks, i64_or, position_param, range_param, str_param}; +use super::tempo_follow::following_tempo; use crate::{Engine, Result, cmd}; use serde_json::{Value, json}; use soundcraft_midi::ops::{DurationOp, QuantizeOptions, VelocityOp};@@ -9,20 +10,25 @@ use soundcraft_time::TICKS_PER_QUARTER; pub fn specs() -> Vec<CommandSpec> { vec![- cmd!("event.tempo", "Tempo Change", [], None, "{bpm, at?: position (default session start)}", always, tempo),- cmd!("event.tempo_remove", "Remove Tempo Change", [], None, "{at}", always, |e, p| {+ cmd!("event.tempo", "Tempo Change", [], None, "{bpm, at?: position (default session start)}", always, |e, p| following_tempo(e, |e| tempo(+ e, p+ ))),+ cmd!("event.tempo_remove", "Remove Tempo Change", [], None, "{at}", always, |e, p| following_tempo(e, |e| { let at = position_param(e, "event.tempo_remove", p, "at")?.unwrap_or(0); let s = e.session_mut(); let tick = s.tempo.samples_to_ticks(at, s.sample_rate); Ok(json!({"removed": s.tempo.remove_tempo(tick)}))- }),+ })), cmd!("event.meter", "Change Meter...", ["Event", "Time Operations"], None, "{numerator, denominator, at?: position}", always, meter),- cmd!("event.tempo_scale", "Scale...", ["Event", "Tempo Operations"], None, "{factor}", always, |e, p| {+ cmd!("event.tempo_scale", "Scale...", ["Event", "Tempo Operations"], None, "{factor}", always, |e, p| following_tempo(e, |e| { let f = f64_or(p, "factor", 1.0); e.session_mut().tempo.scale_tempos(f).map_err(|err| bad("event.tempo_scale", err.to_string()))?; Ok(json!({}))- }),- cmd!("event.tempo_constant", "Constant...", ["Event", "Tempo Operations"], None, "{bpm, start?, end?}", always, tempo_constant),+ })),+ cmd!("event.tempo_constant", "Constant...", ["Event", "Tempo Operations"], None, "{bpm, start?, end?}", always, |e, p| following_tempo(+ e,+ |e| tempo_constant(e, p)+ )), cmd!("event.insert_time", "Insert Time...", ["Event", "Time Operations"], None, "{start?, length | end}", has_tracks, insert_time), cmd!("event.cut_time", "Cut Time...", ["Event", "Time Operations"], None, "{start?, end?}", has_tracks, cut_time), cmd!(@@ -8,6 +8,7 @@ use super::{ }; use super::more_util::{store_value, track_key, values_with_prefix};+use super::tempo_follow::following_tempo; use crate::{Engine, Result, cmd}; use serde_json::{Value, json}; use soundcraft_midi::Note;@@ -49,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> { None, "{start?, end?, start_bpm?, end_bpm, resolution?: '1/8'|ticks}", always,- |e, p| tempo_curve(e, p, Curve::Linear, "event.tempo_linear")+ |e, p| following_tempo(e, |e| tempo_curve(e, p, Curve::Linear, "event.tempo_linear")) ), cmd!( "event.tempo_parabolic",@@ -58,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> { None, "{start?, end?, start_bpm?, end_bpm, curvature?: -1..1 (0.5), resolution?}", always,- |e, p| tempo_curve(e, p, Curve::Parabolic, "event.tempo_parabolic")+ |e, p| following_tempo(e, |e| tempo_curve(e, p, Curve::Parabolic, "event.tempo_parabolic")) ), cmd!( "event.tempo_s_curve",@@ -67,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> { None, "{start?, end?, start_bpm?, end_bpm, resolution?}", always,- |e, p| tempo_curve(e, p, Curve::Sigmoid, "event.tempo_s_curve")+ |e, p| following_tempo(e, |e| tempo_curve(e, p, Curve::Sigmoid, "event.tempo_s_curve")) ), cmd!( "event.tempo_stretch",@@ -76,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> { None, "{start?, end?, factor? | length?: new length} — scales the tempo inside the selection so it lasts `factor` times as long", always,- tempo_stretch+ |e, p| following_tempo(e, |e| tempo_stretch(e, p)) ), cmd!( "event.select_split_notes",@@ -133,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> { None, "{tracks?, start?, end?, sensitivity?: 0.5, separate?: false, set_tempo?: false}", has_range,- beat_detective+ |e, p| following_tempo(e, |e| beat_detective(e, p)) ), cmd!( "event.identify_beat",@@ -142,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> { Some("Cmd+I"), "{start?, end?, bars?: 1, beats?: 0} — sets the tempo so the selection lasts that many bars", has_range,- identify_beat+ |e, p| following_tempo(e, |e| identify_beat(e, p)) ), cmd!( "event.retrospective_record",new file mode 100644@@ -0,0 +1,210 @@+//! Tick-timebase tracks follow the tempo map: when the tempo changes, their clips keep their+//! musical positions instead of their sample positions.++use super::*;+use soundcraft_model::{ClipContent, Session};+use soundcraft_time::TempoMap;++/// Run a tempo edit, then re-anchor the clips on tick-timebase tracks to the new tempo map.+pub(super) fn following_tempo(e: &mut Engine, edit: impl FnOnce(&mut Engine) -> Result<Value>) -> Result<Value> {+ let old = e.session().tempo.clone();+ let out = edit(e)?;+ follow_tempo(e.session_mut(), &old)?;+ Ok(out)+}++/// Move the clips of every tick-timebase track from where `old` put them to where the session's+/// tempo map puts the same musical positions. MIDI clips and audio on Elastic Audio tracks also+/// keep their length in ticks: audio is re-stretched to fit, and Varispeed tracks change speed.+/// Audio on other tracks keeps its length in samples.+pub(super) fn follow_tempo(s: &mut Session, old: &TempoMap) -> Result<()> {+ if *old == s.tempo {+ return Ok(());+ }+ let sr = s.sample_rate;+ let new = s.tempo.clone();+ // (clip, new length) for audio that must be stretched once every clip has moved.+ let mut stretch: Vec<(ClipId, Samples, bool)> = Vec::new();+ for tr in s.tracks.iter_mut().filter(|t| t.ticks_timebase && t.kind.has_playlist()) {+ let algorithm = tr.elastic.clone();+ for pl in &mut tr.playlists {+ for c in pl.clips.iter_mut().filter(|c| !c.time_locked) {+ let t0 = old.samples_to_ticks_f(c.start, sr);+ let t1 = old.samples_to_ticks_f(c.start.saturating_add(c.length), sr);+ let start = new.ticks_f_to_samples(t0, sr).max(0);+ let length = (new.ticks_f_to_samples(t1, sr) - start).max(1);+ c.start = start;+ match (&c.content, algorithm.as_deref()) {+ (ClipContent::Midi { .. }, _) => c.length = length,+ (ClipContent::Audio { .. }, Some(alg)) if length != c.length => stretch.push((c.id, length, alg == "varispeed")),+ _ => {}+ }+ }+ pl.sort();+ }+ }+ for (id, length, varispeed) in stretch {+ // Audio that cannot be rendered (its source is offline) changes speed instead, so the clip+ // still ends where the next musical position begins. A stretch the renderer refuses fails+ // the command, which restores the document.+ if varispeed || !super::clip::warp_clip(s, id, |base| length as f64 / base.length as f64, true)? {+ change_speed(s, id, length);+ }+ }+ Ok(())+}++/// Give a clip a new length by changing its speed, without rendering.+fn change_speed(s: &mut Session, id: ClipId, length: Samples) {+ if let Some(c) = s.find_clip_mut(id) {+ c.stretch = if c.stretch.is_finite() && c.stretch > 0.0 { c.stretch } else { 1.0 } * length as f64 / c.length.max(1) as f64;+ c.length = length;+ c.fade_in.len = c.fade_in.len.min(length);+ c.fade_out.len = c.fade_out.len.min(length);+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ use serde_json::json;+ use soundcraft_model::{ChannelFormat, SourceId, TrackKind};++ const SOURCE_LEN: usize = 96_000;++ #[test]+ fn tick_audio_clips_follow_a_slower_tempo() {+ let (mut e, _, ids) = engine_with_clips(Some("rhythmic"));+ let before: Vec<(Samples, Samples)> = ids.iter().map(|id| span(&e, *id)).collect();+ e.execute("event.tempo", &json!({"bpm": 60.0})).unwrap();+ for (id, (start, length)) in ids.iter().zip(before) {+ let (new_start, new_length) = span(&e, *id);+ // 120 → 60 bpm: everything takes twice as long.+ assert!((new_start - start * 2).abs() <= 2, "start {start} became {new_start}");+ assert!((new_length - length * 2).abs() <= 2, "length {length} became {new_length}");+ }+ }++ #[test]+ fn elastic_clips_do_not_overlap_after_a_tempo_change() {+ let (mut e, _, ids) = engine_with_clips(Some("polyphonic"));+ e.execute("event.tempo", &json!({"bpm": 54.0})).unwrap();+ let (a_start, a_len) = span(&e, ids[0]);+ let (b_start, _) = span(&e, ids[1]);+ assert!(a_start + a_len <= b_start + 2, "first clip ends at {} after the second starts at {b_start}", a_start + a_len);+ assert_eq!(e.session().find_clip(ids[1]).unwrap().1.start, b_start);+ }++ #[test]+ fn varispeed_clips_change_speed_with_the_tempo() {+ let (mut e, _, ids) = engine_with_clips(Some("varispeed"));+ let (_, length) = span(&e, ids[0]);+ e.execute("event.tempo", &json!({"bpm": 60.0})).unwrap();+ let c = e.session().find_clip(ids[0]).unwrap().1.clone();+ assert!((c.length - length * 2).abs() <= 2);+ assert!((c.stretch - 2.0).abs() < 1e-3, "stretch {}", c.stretch);+ }++ #[test]+ fn tick_clips_without_elastic_audio_move_but_keep_their_length() {+ let (mut e, _, ids) = engine_with_clips(None);+ let (start, length) = span(&e, ids[1]);+ e.execute("event.tempo", &json!({"bpm": 60.0})).unwrap();+ let (new_start, new_length) = span(&e, ids[1]);+ assert!((new_start - start * 2).abs() <= 2);+ assert_eq!(new_length, length);+ }++ #[test]+ fn sample_timebase_tracks_stay_put() {+ let (mut e, t, ids) = engine_with_clips(Some("rhythmic"));+ e.session_mut().track_mut(t).unwrap().ticks_timebase = false;+ let before = span(&e, ids[1]);+ e.execute("event.tempo", &json!({"bpm": 60.0})).unwrap();+ assert_eq!(span(&e, ids[1]), before);+ }++ #[test]+ fn a_dragged_tempo_does_not_degrade_the_audio_each_step() {+ let (mut e, _, ids) = engine_with_clips(Some("rhythmic"));+ for bpm in [118.0, 110.0, 90.0, 70.0, 54.0] {+ e.execute("event.tempo", &json!({"bpm": bpm})).unwrap();+ }+ let (_, length) = span(&e, ids[0]);+ let expected = (SOURCE_LEN as f64 / 2.0 * 120.0 / 54.0).round() as Samples;+ assert!((length - expected).abs() <= 4, "length {length}, expected {expected}");+ // Every step stretched the original, not the previous render.+ e.execute("clip.remove_warp", &json!({"clip": ids[0].0})).unwrap();+ assert_eq!(span(&e, ids[0]).1, SOURCE_LEN as Samples / 2);+ }++ #[test]+ fn removing_a_tempo_change_brings_the_clips_back() {+ let (mut e, _, ids) = engine_with_clips(Some("rhythmic"));+ let before: Vec<(Samples, Samples)> = ids.iter().map(|id| span(&e, *id)).collect();+ e.execute("event.tempo", &json!({"bpm": 60.0, "at": 12_000})).unwrap();+ assert_ne!(span(&e, ids[1]), before[1]);+ e.execute("event.tempo_remove", &json!({"at": 12_000})).unwrap();+ for (id, (start, length)) in ids.iter().zip(before) {+ let (new_start, new_length) = span(&e, *id);+ assert!((new_start - start).abs() <= 2, "start {start} became {new_start}");+ assert!((new_length - length).abs() <= 2, "length {length} became {new_length}");+ }+ }++ #[test]+ fn identifying_the_beat_moves_the_clips_with_the_new_tempo() {+ let (mut e, _, ids) = engine_with_clips(None);+ let (start, _) = span(&e, ids[1]);+ e.execute("event.identify_beat", &json!({"start": 0, "end": 192_000, "bars": 1})).unwrap();+ // One bar over four seconds is 60 bpm, half the demo's 120.+ assert!((span(&e, ids[1]).0 - start * 2).abs() <= 2);+ }++ #[test]+ fn a_stretch_that_cannot_render_refuses_the_tempo_change() {+ let (mut e, _, ids) = engine_with_clips(Some("rhythmic"));+ let (tempo, before) = (e.session().tempo.clone(), ids.iter().map(|id| span(&e, *id)).collect::<Vec<_>>());+ // 120 → 5 bpm would stretch the audio 24 times, past what the renderer supports.+ assert!(e.execute("event.tempo", &json!({"bpm": 5.0})).is_err());+ assert!(e.session().tempo == tempo);+ assert_eq!(ids.iter().map(|id| span(&e, *id)).collect::<Vec<_>>(), before);+ }++ #[test]+ fn offline_audio_follows_the_tempo_without_overlapping() {+ let (mut e, _, ids) = engine_with_clips(Some("rhythmic"));+ let source = e.session().pool.ids().next().unwrap();+ e.session_mut().pool.remove(source);+ e.execute("event.tempo", &json!({"bpm": 240.0})).unwrap();+ let (a_start, a_len) = span(&e, ids[0]);+ let (b_start, _) = span(&e, ids[1]);+ assert!(a_start + a_len <= b_start + 2, "first clip ends at {} after the second starts at {b_start}", a_start + a_len);+ }++ fn span(e: &Engine, id: ClipId) -> (Samples, Samples) {+ let c = e.session().find_clip(id).unwrap().1;+ (c.start, c.length)+ }++ /// Two back-to-back half-second clips (at 120 bpm: one bar) on a tick-timebase audio track.+ fn engine_with_clips(algorithm: Option<&str>) -> (Engine, TrackId, Vec<ClipId>) {+ let mut s = Session::default();+ let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);+ let tone: Vec<f32> = (0..SOURCE_LEN).map(|i| (i as f32 * 0.05).sin() * 0.4).collect();+ let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };+ let src: SourceId = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);+ let half = SOURCE_LEN as Samples / 2;+ let mut ids = Vec::new();+ for i in 0..2 {+ let id = s.new_clip_id();+ crate::edit::place_clip(&mut s, t, soundcraft_model::Clip::audio(id, "tone", src, i * half, i * half, half));+ ids.push(id);+ }+ if let Some(tr) = s.track_mut(t) {+ tr.ticks_timebase = true;+ tr.elastic = algorithm.map(str::to_string);+ }+ (Engine::new(s), t, ids)+ }+}@@ -200,22 +200,28 @@ impl TempoMap { /// Ticks → samples (exact for constant-tempo segments). pub fn tick_to_samples(&self, tick: i64, sr: SampleRate) -> Samples {+ to_samples(self.tick_to_samples_f(tick as f64, sr))+ }++ /// Fractional ticks → samples. Clips keep sub-tick positions so a tempo change moves them+ /// without rounding each one to the tick grid.+ pub fn ticks_f_to_samples(&self, tick: f64, sr: SampleRate) -> Samples { to_samples(self.tick_to_samples_f(tick, sr)) } - fn tick_to_samples_f(&self, tick: i64, sr: SampleRate) -> f64 {+ fn tick_to_samples_f(&self, tick: f64, sr: SampleRate) -> f64 { let mut acc = 0.0; let mut seg_tick = 0i64; let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm); for e in self.tempos.iter().skip(1) {- if e.tick >= tick {+ if e.tick as f64 >= tick { break; } acc += (e.tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr); seg_tick = e.tick; bpm = e.bpm; }- acc + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr)+ acc + (tick - seg_tick as f64) * Self::samples_per_tick(bpm, sr) } /// Samples → ticks (fractional).