xmrs 0.14.0

Read, edit and serialize SoundTracker music with pleasure — MOD/XM/S3M/IT/DW import plus SID & OPL chip synthesis, no_std.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
//! Automation lanes: parameter curves through time.
//!
//! Each lane targets one DAW parameter (global volume, BPM, etc.)
//! and carries a typed [`LaneKind`] payload — `Points` for typed
//! `(tick, value)` curves, or a continuous-modulation kind
//! (`Lfo` / `Slide` / `Glide`) for the modulation classes extracted
//! upstream from `TrackImportUnit` by
//! [`crate::tracker::import::extract`].
//!
//! ```text
//! Module
//!  └─ automation: Vec<AutomationLane>
//!       ├─ target:  AutomationTarget   (what is being modulated)
//!       ├─ kind:    LaneKind { Points / Lfo / Slide / Glide }
//!       └─ enabled: bool
//! ```
//!
//! Lanes are the **authoritative source** for every continuous
//! modulation:
//!
//! - per-Track: Vibrato / Tremolo / Panbrello / Portamento /
//!   TonePortamento / VolumeSlide / ChannelVolumeSlide / PanningSlide.
//! - song-level: Bpm / BpmSlide / Speed / GlobalVolume /
//!   GlobalVolumeSlide.
//!
//! They are extracted upstream from `TrackImportUnit` by
//! [`crate::tracker::import::extract`] and consumed by the player
//! without any cell-side fallback.

use crate::core::fixed::fixed::{Q15, Q8_8};
use crate::core::fixed::units::PitchDelta;
use crate::core::pitch::Pitch;
use crate::core::waveform::Waveform;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};

/// What an [`AutomationLane`] controls.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AutomationTarget {
    /// Mix-bus master gain — drives `SongLevelEffect::Volume` and
    /// `SongLevelEffect::VolumeSlide`.
    GlobalVolume,
    /// Song-level BPM (32..=255). Drives `SongLevelEffect::Bpm` and
    /// `SongLevelEffect::BpmSlide`.
    Bpm,
    /// Song-level speed in ticks/row (1..=31). Drives
    /// `SongLevelEffect::Speed`.
    Speed,
    /// Per-channel volume (S3M Mxx), keyed on raw channel index.
    /// Kept for completeness; the per-Track equivalent is
    /// [`Self::TrackChannelVolume`].
    ChannelVolume(u8),
    /// Per-channel panning, keyed on raw channel index. Per-Track
    /// equivalent is [`Self::TrackPanning`].
    ChannelPanning(u8),
    /// Per-Track volume — receives Tremolo (LFO), VolumeSlide
    /// (Slide).
    TrackVolume(u32),
    /// Per-Track panning — receives Panbrello (LFO), PanningSlide
    /// (Slide).
    TrackPanning(u32),
    /// Per-Track pitch in semitones (Q8.8 delta) — receives Vibrato
    /// (LFO), Portamento (Slide), TonePortamento (Glide).
    TrackPitch(u32),
    /// Per-Track channel volume — receives ChannelVolumeSlide
    /// (Slide). Keyed on Track index (vs. `ChannelVolume(u8)` which
    /// is keyed on the raw channel byte).
    TrackChannelVolume(u32),
    /// RFC §3A — a parameter of an insert [`Device`] on a mixer
    /// channel's chain (RFC §3B). `track` is the mixer-channel index,
    /// `device` the slot in [`crate::core::module::Module::channel_inserts`]'s
    /// chain, `param` the device-specific parameter id (e.g. `0` =
    /// gain for [`Device::Gain`]). Driven by `Normalized` `Points`
    /// values. No tracker importer emits it ⇒ inert by default.
    ///
    /// [`Device`]: crate::core::daw::device::Device
    /// [`Device::Gain`]: crate::core::daw::device::Device::Gain
    DeviceParam { track: u32, device: u16, param: u16 },
}

/// Typed value of an [`AutomationPoint`], matched against the
/// lane's [`AutomationTarget`] at construction time.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutomationValue {
    /// For targets in `[0.0, 1.0]`: volumes, panning, etc.
    Normalized(Q15),
    /// For BPM (typical range 32..=255).
    Bpm(u16),
    /// For Speed (typical range 1..=31).
    Speed(u8),
    /// A signed **pitch offset in semitones** ([`PitchDelta`], Q8.8),
    /// for [`AutomationTarget::TrackPitch`] `Points` lanes. Unlike the
    /// relative modulations that also target `TrackPitch` — `Lfo`
    /// (vibrato), `Slide`/`Glide` (portamento) — this is an *absolute,
    /// held* pitch shift: the value latched by the latest point applies
    /// to every following tick (step semantics via
    /// [`AutomationLane::value_at`]), and the player folds it into the
    /// note's pitch each tick. It expresses a time-varying transpose
    /// (e.g. a tracker's global/channel transpose changing mid-note, or
    /// an arpeggio authored as an explicit pitch curve) that a per-note
    /// instrument pitch-envelope can't, because the curve lives on the
    /// channel/Track timeline, not the note. See
    /// `StateInstrDefault::update_frequency`.
    Pitch(PitchDelta),
}

impl AutomationValue {
    /// `true` when the value's variant matches `target`.
    pub fn is_compatible_with(&self, target: AutomationTarget) -> bool {
        matches!(
            (self, target),
            (
                AutomationValue::Normalized(_),
                AutomationTarget::GlobalVolume
            ) | (
                AutomationValue::Normalized(_),
                AutomationTarget::ChannelVolume(_)
            ) | (
                AutomationValue::Normalized(_),
                AutomationTarget::ChannelPanning(_)
            ) | (
                AutomationValue::Normalized(_),
                AutomationTarget::TrackVolume(_)
            ) | (
                AutomationValue::Normalized(_),
                AutomationTarget::TrackPanning(_)
            ) | (
                AutomationValue::Normalized(_),
                AutomationTarget::TrackPitch(_)
            ) | (
                AutomationValue::Normalized(_),
                AutomationTarget::TrackChannelVolume(_)
            ) | (AutomationValue::Pitch(_), AutomationTarget::TrackPitch(_))
                | (
                    // RFC §3A — insert-device params are normalized scalars.
                    AutomationValue::Normalized(_),
                    AutomationTarget::DeviceParam { .. }
                )
                | (AutomationValue::Bpm(_), AutomationTarget::Bpm)
                | (AutomationValue::Speed(_), AutomationTarget::Speed)
        )
    }
}

/// One typed point on a [`LaneKind::Points`] curve.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub struct AutomationPoint {
    pub tick: u32,
    pub value: AutomationValue,
}

/// LFO modulation events — vibrato / tremolo / panbrello. The
/// runtime keeps the phase state machine; events tell it when to
/// arm / update / clear that machine.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)]
pub enum LfoEvent {
    /// (Re-)initialise every LFO parameter. Emitted by
    /// `Vibrato{speed,depth}`, `Tremolo`, `Panbrello`, or by a
    /// fusion of a sub-effect (`VibratoSpeed` etc.) with the
    /// main effect on the same row.
    Set {
        tick: u32,
        speed: Q8_8,
        depth: Q15,
        waveform: Waveform,
        retrig: bool,
    },
    /// Update partial : only the depth changes. Emitted by a
    /// standalone `VibratoDepth` row under the XM
    /// `volcol_b_advances_vibrato` quirk.
    DepthOnly { tick: u32, depth: Q15 },
    /// Update partial : only the speed changes. Emitted by a
    /// standalone `VibratoSpeed`.
    SpeedOnly { tick: u32, speed: Q8_8 },
    /// Update partial : only the waveform shape (and the
    /// per-channel retrig-on-new-note flag) change. Emitted by a
    /// standalone `VibratoWaveform` / `TremoloWaveform` /
    /// `PanbrelloWaveform` row — i.e. a row that touches the LFO
    /// shape without also re-arming the modulation. The runtime's
    /// `WaveformState` is updated immediately; subsequent `Set`s on
    /// the same lane will carry the new waveform in their
    /// `Set::waveform` slot.
    WaveformOnly {
        tick: u32,
        waveform: Waveform,
        retrig: bool,
    },
    /// Explicit deactivation. Whether the phase resets or carries
    /// over depends on the `retrig` of the last `Set`.
    Clear { tick: u32 },
}

impl LfoEvent {
    #[inline]
    pub fn tick(&self) -> u32 {
        match *self {
            Self::Set { tick, .. } => tick,
            Self::DepthOnly { tick, .. } => tick,
            Self::SpeedOnly { tick, .. } => tick,
            Self::WaveformOnly { tick, .. } => tick,
            Self::Clear { tick } => tick,
        }
    }
}

/// Linear-slide modulation events — VolumeSlide / Portamento /
/// PanningSlide / ChannelVolumeSlide. The runtime accumulates `rate`
/// at every tick of the row that emitted the `Set`, then `Clear`
/// stops the slide.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)]
pub enum SlideEvent {
    Set { tick: u32, rate: Q15, fine: bool },
    Clear { tick: u32 },
}

impl SlideEvent {
    #[inline]
    pub fn tick(&self) -> u32 {
        match *self {
            Self::Set { tick, .. } => tick,
            Self::Clear { tick } => tick,
        }
    }
}

/// Glide-towards-target events — TonePortamento (`Gxx`). Each
/// `Set` rebinds the target pitch the runtime should slide toward.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)]
pub enum GlideEvent {
    Set { tick: u32, target: Pitch, rate: i16 },
    Clear { tick: u32 },
}

impl GlideEvent {
    #[inline]
    pub fn tick(&self) -> u32 {
        match *self {
            Self::Set { tick, .. } => tick,
            Self::Clear { tick } => tick,
        }
    }
}

/// The payload of an [`AutomationLane`]. One variant per modulation
/// class; the lane's `target` constrains which variants are
/// semantically valid.
///
/// - `Points` — typed-value curve, the editor-native form.
/// - `Lfo` / `Slide` / `Glide` — continuous-modulation payloads
///   extracted from `TrackImportUnit.effects` /
///   `TrackImportUnit.song_level` by
///   [`crate::tracker::import::extract`].
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum LaneKind {
    Points(Vec<AutomationPoint>),
    Lfo { events: Vec<LfoEvent> },
    Slide { events: Vec<SlideEvent> },
    Glide { events: Vec<GlideEvent> },
}

impl Default for LaneKind {
    fn default() -> Self {
        LaneKind::Points(Vec::new())
    }
}

/// Snapshot of an LFO state machine — what an [`LfoEvent`] stream
/// resolves to at a specific tick. `armed = false` corresponds to
/// "the LFO has been cleared (or never set)", in which case the
/// other fields are last-known values that should not be read.
///
/// Used by [`AutomationLane::lfo_state_at`] to reconstruct, from
/// the event stream alone, the active Vibrato / Tremolo / Panbrello
/// parameters at a given tick. The runtime player consumes it
/// directly — there is no cell fallback.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LfoState {
    pub armed: bool,
    pub speed: Q8_8,
    pub depth: Q15,
    pub waveform: Waveform,
    pub retrig: bool,
}

impl Default for LfoState {
    fn default() -> Self {
        Self {
            armed: false,
            speed: Q8_8::ZERO,
            depth: Q15::ZERO,
            waveform: Waveform::BipolarSine,
            retrig: true,
        }
    }
}

/// Snapshot of a linear-slide state machine. `armed = false`
/// corresponds to "no slide active at this tick".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SlideState {
    pub armed: bool,
    pub rate: Q15,
    pub fine: bool,
}

/// Snapshot of a glide-toward-target state machine. `armed = false`
/// = no glide active. When armed, `target` is the most recent
/// `Pitch` requested by a `Set` event and `rate` the period-unit
/// speed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GlideState {
    pub armed: bool,
    pub target: Pitch,
    pub rate: i16,
}

impl Default for GlideState {
    fn default() -> Self {
        Self {
            armed: false,
            target: Pitch::C5,
            rate: 0,
        }
    }
}

/// Unified read view of one event in a lane, regardless of kind.
/// Used by [`AutomationLane::events_in`] so the runtime can iterate
/// every kind through the same interface.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LaneEvent<'a> {
    Point(&'a AutomationPoint),
    Lfo(&'a LfoEvent),
    Slide(&'a SlideEvent),
    Glide(&'a GlideEvent),
}

impl<'a> LaneEvent<'a> {
    #[inline]
    pub fn tick(&self) -> u32 {
        match self {
            Self::Point(p) => p.tick,
            Self::Lfo(e) => e.tick(),
            Self::Slide(e) => e.tick(),
            Self::Glide(e) => e.tick(),
        }
    }
}

/// One automation curve: a target parameter, a payload of either
/// typed `(tick, value)` points or a stream of continuous-modulation
/// events, and an enable flag.
///
/// Lanes are stored in [`crate::core::module::Module::automation`]
/// and looked up at runtime by [`Module::lane_for`] /
/// [`Module::lanes_for`]. The player reads the active lane each
/// tick to drive the corresponding parameter; lanes are also the
/// editor's single source of truth for continuous effects (Vibrato
/// / Tremolo / Panbrello, slides, glides, song-level Bpm / Speed /
/// GlobalVolume).
///
/// Lane events / points are kept sorted by tick. The edit API and
/// the importer-side [`crate::tracker::import::extract`] pipeline
/// both preserve this invariant; consumers can rely on it for
/// binary search and incremental playback.
///
/// [`Module::lane_for`]: crate::core::module::Module::lane_for
/// [`Module::lanes_for`]: crate::core::module::Module::lanes_for
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AutomationLane {
    /// Which parameter this curve drives. See [`AutomationTarget`]
    /// for the full list (song-wide `Bpm` / `Speed` / `GlobalVolume`
    /// vs. per-Track `TrackPitch(n)` / `TrackVolume(n)` /
    /// `TrackPanning(n)` / `TrackChannelVolume(n)`).
    pub target: AutomationTarget,
    /// Payload — one variant per modulation class. Events inside
    /// each variant are sorted by tick (ascending); the API
    /// maintains this invariant.
    pub kind: LaneKind,
    /// Mute switch. When `false`, the player skips this lane —
    /// useful from the editor for A/B comparisons without
    /// destroying the curve.
    pub enabled: bool,
    /// RFC §2B — whether this lane's modulation drives only the
    /// track's **live** voice ([`ModScope::Track`], the default and
    /// the only value every tracker importer emits) or **every**
    /// voice of the track, ghosts included ([`ModScope::Voice`], the
    /// DAW / MPE path). `#[serde(default)]` so older serialised
    /// modules round-trip to `Track`.
    #[serde(default)]
    pub scope: ModScope,
    /// Which sub-song this lane belongs to. Automation lives in the
    /// **per-sub-song** tick space (every sub-song's timeline restarts
    /// at tick 0), so a lane is only consulted while its own sub-song
    /// plays — otherwise sub-song N's curve would alias into sub-song
    /// 0's identical tick window. `#[serde(default)]` → older modules
    /// and single-song imports round-trip to sub-song `0`.
    #[serde(default)]
    pub song: u16,
}

/// RFC §2B — modulation scope: which voices of a track an
/// [`AutomationLane`] drives.
///
/// `Track` (the default) reproduces the historical tracker behaviour
/// — the channel's modulation reaches only the **live** voice, while
/// detached NNA ghosts keep their frozen gain/pitch. `Voice` routes
/// the same per-tick modulation bus to **every** voice on the track
/// (live + ghosts), each folding it onto its own base — the
/// per-voice / MPE path that RFC §2C feeds with per-note expression.
#[derive(Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ModScope {
    #[default]
    Track,
    Voice,
}

impl AutomationLane {
    /// Build an empty [`LaneKind::Points`] lane for `target`. The
    /// `edit.rs` API surface uses this as the constructor for
    /// editor-authored automation curves.
    pub fn new(target: AutomationTarget) -> Self {
        Self {
            target,
            kind: LaneKind::default(),
            enabled: true,
            scope: ModScope::Track,
            song: 0,
        }
    }

    /// Build an empty lane of the given kind for `target`. Used by
    /// the extractor to seed Lfo / Slide / Glide lanes.
    pub fn new_with_kind(target: AutomationTarget, kind: LaneKind) -> Self {
        Self {
            target,
            kind,
            enabled: true,
            scope: ModScope::Track,
            song: 0,
        }
    }

    /// Builder: tag this lane with the sub-song it was extracted from.
    /// The importers stamp this so the player only consults a lane
    /// while its own sub-song is playing.
    pub fn with_song(mut self, song: u16) -> Self {
        self.song = song;
        self
    }

    /// Read-only accessor onto a Points lane's points.
    /// `None` for any other [`LaneKind`].
    #[inline]
    pub fn points(&self) -> Option<&[AutomationPoint]> {
        if let LaneKind::Points(p) = &self.kind {
            Some(p)
        } else {
            None
        }
    }

    /// Mutable accessor onto a Points lane's points.
    /// `None` for any other [`LaneKind`].
    #[inline]
    pub fn points_mut(&mut self) -> Option<&mut Vec<AutomationPoint>> {
        if let LaneKind::Points(p) = &mut self.kind {
            Some(p)
        } else {
            None
        }
    }

    /// Insert a point, keeping the Points-payload sorted by tick.
    /// If a point already exists at the same tick it is replaced.
    /// Panics if the lane is not a [`LaneKind::Points`].
    pub fn insert_point(&mut self, point: AutomationPoint) {
        let points = self
            .points_mut()
            .expect("insert_point called on non-Points lane");
        match points.binary_search_by_key(&point.tick, |p| p.tick) {
            Ok(idx) => points[idx] = point,
            Err(idx) => points.insert(idx, point),
        }
    }

    /// Iterate every event of the lane whose `tick ∈ [start_tick,
    /// end_tick)`, sorted ascending by tick. Used by the runtime to
    /// advance its state machines tick-by-tick.
    ///
    /// Allocates a `Vec` of references for the half-open window —
    /// the caller is expected to consume it before the next call;
    /// the runtime typically reads a single-tick window and so
    /// pays for ≤ 1-2 elements per call in practice.
    pub fn events_in(&self, start_tick: u32, end_tick: u32) -> alloc::vec::Vec<LaneEvent<'_>> {
        let in_window = |t: u32| t >= start_tick && t < end_tick;
        let mut out: alloc::vec::Vec<LaneEvent<'_>> = alloc::vec::Vec::new();
        match &self.kind {
            LaneKind::Points(points) => out.extend(
                points
                    .iter()
                    .filter(|p| in_window(p.tick))
                    .map(LaneEvent::Point),
            ),
            LaneKind::Lfo { events } => out.extend(
                events
                    .iter()
                    .filter(|e| in_window(e.tick()))
                    .map(LaneEvent::Lfo),
            ),
            LaneKind::Slide { events } => out.extend(
                events
                    .iter()
                    .filter(|e| in_window(e.tick()))
                    .map(LaneEvent::Slide),
            ),
            LaneKind::Glide { events } => out.extend(
                events
                    .iter()
                    .filter(|e| in_window(e.tick()))
                    .map(LaneEvent::Glide),
            ),
        }
        out
    }

    /// For [`LaneKind::Points`] only: returns the value at `tick`
    /// — the latest point whose `tick <= tick`, or `None` if no
    /// point precedes it. Returns `None` for non-Points kinds.
    pub fn value_at(&self, tick: u32) -> Option<AutomationValue> {
        let points = self.points()?;
        if points.is_empty() {
            return None;
        }
        // points are sorted ascending by tick.
        let idx = match points.binary_search_by_key(&tick, |p| p.tick) {
            Ok(i) => i,
            Err(0) => return None,
            Err(i) => i - 1,
        };
        Some(points[idx].value)
    }

    /// Replay a [`LaneKind::Lfo`] event stream up to (and including)
    /// `tick`, returning the resulting [`LfoState`] — i.e. what an
    /// LFO state machine fed by this lane would be holding at that
    /// tick. Returns `None` for any non-Lfo lane.
    ///
    /// `tick` is inclusive: events emitted *at* `tick` count.
    pub fn lfo_state_at(&self, tick: u32) -> Option<LfoState> {
        let events = if let LaneKind::Lfo { events } = &self.kind {
            events
        } else {
            return None;
        };
        let mut s = LfoState::default();
        for e in events {
            if e.tick() > tick {
                break;
            }
            match *e {
                LfoEvent::Set {
                    speed,
                    depth,
                    waveform,
                    retrig,
                    ..
                } => {
                    s.armed = true;
                    s.speed = speed;
                    s.depth = depth;
                    s.waveform = waveform;
                    s.retrig = retrig;
                }
                LfoEvent::DepthOnly { depth, .. } => {
                    s.armed = true;
                    s.depth = depth;
                }
                LfoEvent::SpeedOnly { speed, .. } => {
                    s.armed = true;
                    s.speed = speed;
                }
                LfoEvent::WaveformOnly {
                    waveform, retrig, ..
                } => {
                    // Updates the shape without touching `armed`,
                    // `speed`, or `depth`. Matches the runtime's
                    // cell-driven dispatch where `*Waveform` rows
                    // mutate `effect_*.data.waveform` immediately
                    // without re-arming the LFO.
                    s.waveform = waveform;
                    s.retrig = retrig;
                }
                LfoEvent::Clear { .. } => {
                    s.armed = false;
                }
            }
        }
        Some(s)
    }

    /// Replay a [`LaneKind::Slide`] event stream up to `tick` and
    /// return the resulting [`SlideState`]. `None` for non-Slide
    /// lanes.
    pub fn slide_state_at(&self, tick: u32) -> Option<SlideState> {
        let events = if let LaneKind::Slide { events } = &self.kind {
            events
        } else {
            return None;
        };
        let mut s = SlideState::default();
        for e in events {
            if e.tick() > tick {
                break;
            }
            match *e {
                SlideEvent::Set { rate, fine, .. } => {
                    s.armed = true;
                    s.rate = rate;
                    s.fine = fine;
                }
                SlideEvent::Clear { .. } => {
                    s.armed = false;
                }
            }
        }
        Some(s)
    }

    /// Replay a [`LaneKind::Glide`] event stream up to `tick` and
    /// return the resulting [`GlideState`]. `None` for non-Glide
    /// lanes.
    pub fn glide_state_at(&self, tick: u32) -> Option<GlideState> {
        let events = if let LaneKind::Glide { events } = &self.kind {
            events
        } else {
            return None;
        };
        let mut s = GlideState::default();
        for e in events {
            if e.tick() > tick {
                break;
            }
            match *e {
                GlideEvent::Set { target, rate, .. } => {
                    s.armed = true;
                    s.target = target;
                    s.rate = rate;
                }
                GlideEvent::Clear { .. } => {
                    s.armed = false;
                }
            }
        }
        Some(s)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use alloc::vec;

    fn pts_lane() -> AutomationLane {
        let mut lane = AutomationLane::new(AutomationTarget::Bpm);
        lane.insert_point(AutomationPoint {
            tick: 0,
            value: AutomationValue::Bpm(125),
        });
        lane.insert_point(AutomationPoint {
            tick: 96,
            value: AutomationValue::Bpm(140),
        });
        lane.insert_point(AutomationPoint {
            tick: 192,
            value: AutomationValue::Bpm(100),
        });
        lane
    }

    #[test]
    fn value_at_returns_latest_point() {
        let lane = pts_lane();
        assert_eq!(lane.value_at(0), Some(AutomationValue::Bpm(125)));
        assert_eq!(lane.value_at(50), Some(AutomationValue::Bpm(125)));
        assert_eq!(lane.value_at(96), Some(AutomationValue::Bpm(140)));
        assert_eq!(lane.value_at(150), Some(AutomationValue::Bpm(140)));
        assert_eq!(lane.value_at(192), Some(AutomationValue::Bpm(100)));
        assert_eq!(lane.value_at(1000), Some(AutomationValue::Bpm(100)));
    }

    #[test]
    fn value_at_returns_none_before_first_point() {
        let mut lane = AutomationLane::new(AutomationTarget::Bpm);
        lane.insert_point(AutomationPoint {
            tick: 100,
            value: AutomationValue::Bpm(120),
        });
        assert_eq!(lane.value_at(0), None);
        assert_eq!(lane.value_at(99), None);
        assert_eq!(lane.value_at(100), Some(AutomationValue::Bpm(120)));
    }

    #[test]
    fn value_at_is_none_for_non_points_kind() {
        let lane = AutomationLane::new_with_kind(
            AutomationTarget::TrackPitch(0),
            LaneKind::Lfo { events: vec![] },
        );
        assert_eq!(lane.value_at(0), None);
    }

    #[test]
    fn events_in_filters_window_for_points() {
        let lane = pts_lane();
        let evs: Vec<u32> = lane.events_in(50, 200).iter().map(|e| e.tick()).collect();
        assert_eq!(evs, vec![96, 192]);
    }

    #[test]
    fn events_in_filters_window_for_lfo() {
        let lane = AutomationLane::new_with_kind(
            AutomationTarget::TrackPitch(0),
            LaneKind::Lfo {
                events: vec![
                    LfoEvent::Set {
                        tick: 0,
                        speed: Q8_8::from_int(3),
                        depth: Q15::from_ratio(1, 4),
                        waveform: Waveform::BipolarSine,
                        retrig: true,
                    },
                    LfoEvent::Clear { tick: 24 },
                ],
            },
        );
        let evs: Vec<u32> = lane.events_in(0, 48).iter().map(|e| e.tick()).collect();
        assert_eq!(evs, vec![0, 24]);
        let evs: Vec<u32> = lane.events_in(0, 24).iter().map(|e| e.tick()).collect();
        assert_eq!(evs, vec![0]); // end is exclusive
    }

    #[test]
    fn new_targets_are_compatible_with_normalized_values() {
        let v = AutomationValue::Normalized(Q15::ZERO);
        assert!(v.is_compatible_with(AutomationTarget::TrackPitch(7)));
        assert!(v.is_compatible_with(AutomationTarget::TrackChannelVolume(3)));
    }

    #[test]
    fn pitch_value_is_track_pitch_only_and_holds() {
        use crate::core::fixed::units::PitchDelta;
        let v = AutomationValue::Pitch(PitchDelta::from_semitones(12));
        // `Pitch` is for `TrackPitch` only.
        assert!(v.is_compatible_with(AutomationTarget::TrackPitch(0)));
        assert!(!v.is_compatible_with(AutomationTarget::Bpm));
        assert!(!v.is_compatible_with(AutomationTarget::GlobalVolume));
        assert!(!v.is_compatible_with(AutomationTarget::TrackVolume(0)));
        // A `Points` lane carries it with step/hold semantics.
        let mut lane = AutomationLane::new(AutomationTarget::TrackPitch(0));
        lane.insert_point(AutomationPoint { tick: 0, value: v });
        lane.insert_point(AutomationPoint {
            tick: 64,
            value: AutomationValue::Pitch(PitchDelta::from_semitones(-5)),
        });
        assert_eq!(lane.value_at(10), Some(v)); // held from tick 0
        assert_eq!(
            lane.value_at(64),
            Some(AutomationValue::Pitch(PitchDelta::from_semitones(-5)))
        );
        assert_eq!(
            lane.value_at(1000),
            Some(AutomationValue::Pitch(PitchDelta::from_semitones(-5)))
        );
    }

    // ----- Lane state replay -----------------------------------------------

    fn lfo_lane(events: Vec<LfoEvent>) -> AutomationLane {
        AutomationLane::new_with_kind(AutomationTarget::TrackPitch(0), LaneKind::Lfo { events })
    }

    #[test]
    fn lfo_state_at_pre_first_event_is_default() {
        let lane = lfo_lane(vec![LfoEvent::Set {
            tick: 100,
            speed: Q8_8::from_int(3),
            depth: Q15::from_ratio(1, 4),
            waveform: Waveform::BipolarSquare,
            retrig: false,
        }]);
        let s = lane.lfo_state_at(0).expect("lfo lane");
        assert!(!s.armed);
        // Default values: speed=0, depth=0, waveform=Sine, retrig=true.
        assert_eq!(s.speed, Q8_8::ZERO);
        assert_eq!(s.depth, Q15::ZERO);
        assert_eq!(s.waveform, Waveform::BipolarSine);
        assert!(s.retrig);
    }

    #[test]
    fn lfo_state_at_set_arms_and_carries_params() {
        let lane = lfo_lane(vec![LfoEvent::Set {
            tick: 100,
            speed: Q8_8::from_int(3),
            depth: Q15::from_ratio(1, 4),
            waveform: Waveform::BipolarSquare,
            retrig: false,
        }]);
        let s = lane.lfo_state_at(100).unwrap();
        assert!(s.armed);
        assert_eq!(s.speed, Q8_8::from_int(3));
        assert_eq!(s.depth, Q15::from_ratio(1, 4));
        assert_eq!(s.waveform, Waveform::BipolarSquare);
        assert!(!s.retrig);
        // Past the Set, before any further event — state holds.
        let s2 = lane.lfo_state_at(1000).unwrap();
        assert!(s2.armed);
        assert_eq!(s2, s);
    }

    #[test]
    fn lfo_state_at_clear_disarms_but_keeps_params() {
        let lane = lfo_lane(vec![
            LfoEvent::Set {
                tick: 0,
                speed: Q8_8::from_int(3),
                depth: Q15::from_ratio(1, 4),
                waveform: Waveform::BipolarSine,
                retrig: true,
            },
            LfoEvent::Clear { tick: 12 },
        ]);
        let s = lane.lfo_state_at(11).unwrap();
        assert!(s.armed);
        let s = lane.lfo_state_at(12).unwrap();
        assert!(!s.armed, "Clear should disarm at its tick");
        // Params survive the clear (last-known wins) — the runtime
        // can re-arm without re-providing them via a fresh Set.
        assert_eq!(s.speed, Q8_8::from_int(3));
        assert_eq!(s.depth, Q15::from_ratio(1, 4));
    }

    #[test]
    fn lfo_state_at_depth_only_updates_only_depth() {
        let lane = lfo_lane(vec![
            LfoEvent::Set {
                tick: 0,
                speed: Q8_8::from_int(3),
                depth: Q15::from_ratio(1, 4),
                waveform: Waveform::BipolarSine,
                retrig: true,
            },
            LfoEvent::DepthOnly {
                tick: 6,
                depth: Q15::from_ratio(1, 2),
            },
        ]);
        let s = lane.lfo_state_at(6).unwrap();
        assert!(s.armed);
        assert_eq!(s.speed, Q8_8::from_int(3));
        assert_eq!(s.depth, Q15::from_ratio(1, 2));
    }

    #[test]
    fn lfo_state_at_speed_only_updates_only_speed() {
        let lane = lfo_lane(vec![
            LfoEvent::Set {
                tick: 0,
                speed: Q8_8::from_int(3),
                depth: Q15::from_ratio(1, 4),
                waveform: Waveform::BipolarSine,
                retrig: true,
            },
            LfoEvent::SpeedOnly {
                tick: 6,
                speed: Q8_8::from_int(7),
            },
        ]);
        let s = lane.lfo_state_at(6).unwrap();
        assert_eq!(s.speed, Q8_8::from_int(7));
        assert_eq!(s.depth, Q15::from_ratio(1, 4));
    }

    #[test]
    fn lfo_state_at_returns_none_for_non_lfo_kind() {
        let lane = AutomationLane::new(AutomationTarget::Bpm);
        assert!(lane.lfo_state_at(0).is_none());
    }

    fn slide_lane(events: Vec<SlideEvent>) -> AutomationLane {
        AutomationLane::new_with_kind(AutomationTarget::TrackVolume(0), LaneKind::Slide { events })
    }

    #[test]
    fn slide_state_replays_set_and_clear() {
        let lane = slide_lane(vec![
            SlideEvent::Set {
                tick: 0,
                rate: Q15::from_raw(5),
                fine: false,
            },
            SlideEvent::Clear { tick: 12 },
        ]);
        let s = lane.slide_state_at(0).unwrap();
        assert!(s.armed && s.rate == Q15::from_raw(5) && !s.fine);
        let s = lane.slide_state_at(12).unwrap();
        assert!(!s.armed);
        // Pre-first-event = default (not armed).
        let s = lane.slide_state_at(0).unwrap();
        assert!(s.armed); // Set was at tick 0
    }

    fn glide_lane(events: Vec<GlideEvent>) -> AutomationLane {
        AutomationLane::new_with_kind(AutomationTarget::TrackPitch(0), LaneKind::Glide { events })
    }

    #[test]
    fn glide_state_replays_set_retarget_clear() {
        let lane = glide_lane(vec![
            GlideEvent::Set {
                tick: 0,
                target: Pitch::C5,
                rate: 5,
            },
            GlideEvent::Set {
                tick: 6,
                target: Pitch::E5,
                rate: 5,
            },
            GlideEvent::Clear { tick: 12 },
        ]);
        let s = lane.glide_state_at(0).unwrap();
        assert!(s.armed && s.target == Pitch::C5);
        let s = lane.glide_state_at(6).unwrap();
        assert!(s.armed && s.target == Pitch::E5);
        let s = lane.glide_state_at(12).unwrap();
        assert!(!s.armed);
    }
}