xmrsplayer 0.11.1

XMrsPlayer is a safe no-std soundtracker music player
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
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
// Float-backend imports kept removed (Tour 6): no f32 methods
// remain in this file after the period→pitch fold.

use core::ops::Deref;

use crate::{
    state_auto_vibrato::StateAutoVibrato, state_envelope::StateEnvelope, state_filter::StateFilter,
    state_sample::StateSample,
};
use xmrs::fixed::fixed::Q15;
use xmrs::fixed::units::{Amp, EnvValue, Finetune, Panning, PitchDelta, SampleRate, Volume};
use xmrs::prelude::*;

impl<'a> Deref for StateInstrDefault<'a> {
    type Target = InstrDefault;
    fn deref(&self) -> &InstrDefault {
        self.instr
    }
}

/// An InstrDefault State
#[derive(Clone)]
pub struct StateInstrDefault<'a> {
    instr: &'a InstrDefault,
    pub num: usize,
    /// Output sample-rate, as integer Hz ([`SampleRate`]).
    /// Forwarded to [`StateSample::set_step`] (which divides
    /// frequency by it for the sample-clock step) and used by
    /// [`StateFilter`] for biquad coefficient sizing. The whole
    /// chain runs without `f32` from this point on.
    rate: SampleRate,
    period_helper: PeriodHelper,
    /// Sample state
    pub state_sample: Option<StateSample<'a>>,
    /// Index into `instr.sample` for the currently-selected sample.
    /// `None` when no sample is loaded. Parallels `state_sample`'s
    /// lifecycle but carries the numeric id; DCT::Sample matching
    /// reads this without having to cross-reference the `&Sample`
    /// pointer back to a slot index.
    pub current_sample_num: Option<usize>,
    /// Vibrato state
    pub state_vibrato: StateAutoVibrato<'a>,
    /// Volume Envelope state
    pub envelope_volume: StateEnvelope<'a>,
    /// Panning Envelope state
    pub envelope_panning: StateEnvelope<'a>,
    /// Pitch Envelope state. IT-specific: each envelope value equates
    /// to half a semitone (ITTECH), applied additively to the voice's
    /// current pitch. Default value 0.5 = centre (no offset) because
    /// the IT importer normalises the signed -32..+32 node magnitudes
    /// into 0..1 via `to_envelope_struct_signed`, with 0.5 = 0-offset.
    /// When `instr.voice.pitch_envelope_as_low_pass_filter` is set (IT's
    /// "use pitch envelope as filter" flag), the same envelope state
    /// is read by the filter engine instead — that path lands with
    /// the filter work in IT_ROADMAP §4.
    pub envelope_pitch: StateEnvelope<'a>,

    // Volume sustained?
    pub sustained: bool,
    /// Whether the voice is in its "fadeout" phase (schism's
    /// `CHN_NOTEFADE`). Independent from [`sustained`] because IT
    /// distinguishes two stages after a key-off:
    ///   1. release — sustain is gone but the envelope is still
    ///      walking through its release section. Fadeout has NOT
    ///      started yet; the voice plays at whatever level the
    ///      envelope dictates.
    ///   2. fadeout — either the envelope reached its last point
    ///      with no loop to wrap it back, or the instrument has no
    ///      envelope at all, or NNA = NoteFade was applied. The
    ///      `volume_fadeout` register starts decaying.
    ///
    /// Set by [`Self::envelopes`] when the volume envelope walks
    /// past its last point, by [`Self::key_off`] in the no-envelope
    /// / loop+fadeout branches (mirroring schism's `fx_key_off`),
    /// and by [`Self::start_fadeout`] for NNA NoteFade. Cleared by
    /// [`Self::envelopes_reset`] / [`Self::volume_reset`] on a fresh
    /// trigger.
    pub volume_fading_out: bool,
    /// Volume fadeout register, Q1.15 in `[0, 1]`. Was `f32`.
    /// Decremented by `voice.volume_fadeout` per tick once
    /// `volume_fading_out` is true; held at `Volume::FULL`
    /// otherwise.
    pub volume_fadeout: Volume,

    /// Current voice volume, Q1.15 in `[0, 1]`. Was `f32`.
    /// Initialised at note-trigger time from the sample's
    /// channel volume (see `volume_orig`); modulated
    /// thereafter by Vxx, tremolo, retrigger, etc.
    pub volume: Volume,
    /// Snapshot of [`Self::volume`] at the most recent
    /// note-trigger, used by `volume_reset`.
    volume_orig: Volume,

    /// Whether `Sample.volume` keeps acting as a static gain in
    /// `get_volume` (IT semantics — "GvL"), or whether it has
    /// already been consumed by the channel at trigger time
    /// (MOD/XM/S3M). Mirrors
    /// `PlaybackQuirks::sample_volume_is_static_gain` from
    /// the owning `Module` and is set by the constructor caller.
    sample_volume_is_static_gain: bool,

    /// Current panning, Q1.15 in `[0, 1]`. Was `f32`.
    pub panning: Panning,

    /// Per-voice resonant low-pass filter. Engaged at note-trigger
    /// time from the instrument's `initial_filter_cutoff` /
    /// `initial_filter_resonance` pair (each register's bit 7 is
    /// the enable flag); otherwise remains in pass-through. Applied
    /// on every output sample in `Iterator::next`.
    pub(crate) filter: StateFilter,
}

impl<'a> StateInstrDefault<'a> {
    pub fn new(
        instr: &'a InstrDefault,
        num: usize,
        period_helper: PeriodHelper,
        rate: SampleRate,
        sample_volume_is_static_gain: bool,
    ) -> Self {
        let v = &instr.voice.vibrato;
        let ve = &instr.voice.volume_envelope;
        let pe = &instr.voice.pan_envelope;
        let pie = &instr.voice.pitch_envelope;
        // Q-format filter — takes integer Hz directly.
        let mut filter = StateFilter::new(rate.hz());
        filter.configure_from_it_registers(
            instr.voice.initial_filter_cutoff,
            instr.voice.initial_filter_resonance,
        );
        Self {
            instr,
            num,
            rate,
            period_helper: period_helper.clone(),
            state_sample: None,
            current_sample_num: None,
            state_vibrato: StateAutoVibrato::new(v, period_helper),
            envelope_volume: StateEnvelope::new(ve, EnvValue::VOLUME_DEFAULT),
            envelope_panning: StateEnvelope::new(pe, EnvValue::PAN_DEFAULT),
            envelope_pitch: StateEnvelope::new(pie, EnvValue::PAN_DEFAULT),
            sustained: true,
            volume_fading_out: false,
            volume_fadeout: Volume::FULL,
            volume: Volume::FULL,
            volume_orig: Volume::FULL,
            sample_volume_is_static_gain,
            panning: Panning::CENTER,
            filter,
        }
    }

    pub fn has_volume_envelope(&self) -> bool {
        self.envelope_volume.has_volume_envelope()
    }

    pub fn replace_instr(&mut self, instr: &'a InstrDefault) {
        self.instr = instr;
        // Filter registers live on the instrument header; reapply
        // them so a `replace_instr` (ghost-instrument path) picks
        // up the new sample's filter settings too.
        self.filter.configure_from_it_registers(
            instr.voice.initial_filter_cutoff,
            instr.voice.initial_filter_resonance,
        );
    }

    #[inline(always)]
    pub fn is_enabled(&self) -> bool {
        match &self.state_sample {
            Some(s) => s.is_enabled(),
            None => false,
        }
    }

    pub fn sample_reset(&mut self) {
        if let Some(s) = &mut self.state_sample {
            s.reset()
        }
        // Zero the filter's delay line alongside the sample cursor
        // so a fresh trigger doesn't hear the tail of the previous
        // note's filter ring-down as a click.
        self.filter.reset_history();
    }

    pub fn envelopes_reset(&mut self) {
        self.sustained = true;
        self.volume_fading_out = false;
        self.volume_fadeout = Volume::FULL;
        self.envelope_volume.reset();
        self.envelope_panning.reset();
        self.envelope_pitch.reset();
    }

    pub fn volume_reset(&mut self) {
        self.volume = self.volume_orig;
        self.volume_fadeout = Volume::FULL;
        self.volume_fading_out = false;
        self.sustained = true;
    }

    pub fn vibrato_reset(&mut self) {
        self.state_vibrato.reset();
    }

    pub fn cut_pitch(&mut self) {
        self.volume = Volume::SILENT;
    }

    pub fn key_off(&mut self) {
        /* Key Off — the user's "===" cell, or NNA NoteOff. Releases
        sustain (the envelope's sustain loop stops wrapping; the
        release section starts walking through). Whether fadeout
        ALSO starts now or waits for the envelope to finish its
        release is decided here, mirroring schism's `fx_key_off`
        (`effects.c:140-167`). */
        self.sustained = false;

        if let Some(ss) = &mut self.state_sample {
            ss.set_sustained(false);
        }

        let has_env = self.envelope_volume.has_volume_envelope();
        let env = &self.instr.voice.volume_envelope;

        if !has_env {
            // No volume envelope: schism takes the alternate render
            // path (`sndmix.c:1158-1164`) where CHN_NOTEFADE is the
            // sole control on whether the voice keeps playing. To
            // get the same behaviour we engage fadeout immediately.
            // Schism's `fx_key_off` line 142-144 sets CHN_NOTEFADE
            // for the same reason in the same case.
            self.volume_fading_out = true;
            if self.instr.voice.volume_fadeout == Volume::SILENT {
                // No envelope AND no fadeout amount: nothing keeps
                // the voice alive past key-off. Cut the pitch (silence
                // the sample) and force the fadeout register to zero
                // so `Voice::is_alive()` reports the voice as dead on
                // the next pool sweep.
                self.cut_pitch();
                self.volume_fadeout = Volume::SILENT;
            }
        } else if env.loop_enabled && self.instr.voice.volume_fadeout != Volume::SILENT {
            // Looped envelope with non-zero fadeout: schism's
            // `fx_key_off` line 166-167 also engages CHN_NOTEFADE
            // here. Without this trigger the looped envelope would
            // hold the voice forever after key-off; with it, fadeout
            // runs in parallel and the voice eventually dies.
            //
            // FT2/XM modules reach this branch by construction —
            // the XM importer emulates schism's `xm.c:703-712`
            // trick (loop pinned to last node, ENV_VOLLOOP forced
            // on) so any XM voice with a volume envelope takes this
            // path on key-off, matching FT2's "fadeout starts
            // immediately, in parallel with the release section"
            // behaviour.
            self.volume_fading_out = true;
        }
        // else: envelope has sustain and a real release section but
        // no loop. This is the IT-typical pad / lead shape. We
        // deliberately leave `volume_fading_out` unchanged; the
        // envelope tick in `envelopes()` will set it the moment the
        // counter walks past the last point. That's IT's "release
        // section plays out, THEN fade" semantics.
        //
        // The previous unconditional `volume_fadeout` decrement on
        // every `!sustained` tick — i.e. as soon as key-off fired —
        // is what made any IT pad/sustain "stop too early": the
        // fade was running while the release section was still
        // playing, so the voice went silent before the envelope
        // finished its decay.
    }

    /// Engage the fadeout register without releasing sustain.
    /// Mirrors schism's NNA_NOTEFADE branch (`effects.c:1791-1792`):
    /// the voice keeps wrapping in its sustain loop while the
    /// fadeout register decays it to silence. Distinct from
    /// [`Self::key_off`] (NNA_NOTEOFF), which releases sustain so
    /// the envelope walks through its release section.
    pub fn start_fadeout(&mut self) {
        self.volume_fading_out = true;
    }

    #[inline(always)]
    pub fn get_volume(&self) -> Volume {
        // Pure Q1.15 gain chain. Each multiplication is a
        // saturating Q15 mul (`Volume::scaled_by`); no float
        // anywhere.
        //
        //   IT:        final = fadeout × env × per_instrument × per_voice(GvL)
        //   MOD/XM/S3M: final = fadeout × env × per_instrument
        //
        // `EnvValue::applied_to` returns a `Volume` (the
        // envelope acts as a gain multiplier on the fadeout).
        // From there it's straight `Volume × Volume → Volume`.
        // `instr.voice.volume` (per-instrument scalar) is IT's
        // global-volume byte — full unity for non-IT formats.
        //
        // For IT, `self.volume` carries `Sample.volume` (GvL) as
        // a static gain that keeps scaling the voice on every
        // tick. For MOD/XM/S3M, `Sample.volume` is consumed at
        // trigger time as the channel's initial volume (see
        // `Channel::trigger_pitch`) and `self.volume` here would
        // be the same `Sample.volume` again — applying it would
        // square it. The `sample_volume_is_static_gain` flag,
        // forwarded from the module's
        // `PlaybackQuirks::sample_volume_is_static_gain`, selects
        // which path runs.
        let v = self
            .envelope_volume
            .value
            .applied_to(self.volume_fadeout)
            .scaled_by(self.instr.voice.volume);

        if self.sample_volume_is_static_gain {
            v.scaled_by(self.volume)
        } else {
            v
        }
    }

    /// The current sample's `default_note_volume` — IT's per-sample
    /// Vol register normalised to 0..1. For non-IT formats this is
    /// always 1.0 (identity). Returns 1.0 when no sample is currently
    /// selected.
    ///
    /// Used by the channel-level trigger path to apply Vol at note-
    /// on time on top of the always-applied `Sample.volume` (GvL).
    /// A V-column override later in effect processing replaces the
    /// resulting `self.volume` wholesale, cleanly superseding Vol
    /// without also discarding the GvL scaling.
    pub fn current_sample_default_note_volume(&self) -> Volume {
        self.current_sample_num
            .and_then(|n| self.instr.sample.get(n))
            .and_then(|s| s.as_ref())
            .map(|s| s.default_note_volume)
            .unwrap_or(Volume::FULL)
    }

    /// Instrument-level random volume variation (IT humanisation).
    /// IT stores this as 0..100 percent of the starting volume; the
    /// importer normalises to 0..1. Zero for non-IT formats.
    pub fn random_volume_variation(&self) -> Q15 {
        self.instr.voice.random_volume_variation
    }

    /// Instrument-level random pan variation (IT humanisation).
    /// Same range / normalisation as `random_volume_variation`.
    pub fn random_pan_variation(&self) -> Q15 {
        self.instr.voice.random_pan_variation
    }

    /// Instrument-level pitch-pan separation (IT). Positive values
    /// spread the pan as the played note moves away from
    /// `pitch_pan_center`. The importer normalises IT's signed
    /// -32..+32 byte to -1..+1. Zero for non-IT formats.
    pub fn pitch_pan_separation(&self) -> Q15 {
        self.instr.voice.pitch_pan_separation
    }

    /// The reference note for `pitch_pan_separation`, returned as a
    /// semitone index (0 = C0). Non-IT: `Pitch::C4` (= 48).
    pub fn pitch_pan_center_semitones(&self) -> i32 {
        self.instr.voice.pitch_pan_center as i32
    }

    /// Current pitch-envelope contribution, in semitones.
    ///
    /// The xmrs signed-normalised envelope stores the IT node
    /// magnitude (range −32..+32 half-semitones) as a 0..1 float
    /// with 0.5 = centre (no pitch change). Each envelope unit
    /// represents half a semitone per ITTECH, so the full range is
    /// ±32·½ = ±16 semitones. Mapping: offset = (value − 0.5) × 32.
    ///
    /// Returns 0 when either the envelope is disabled OR the
    /// instrument's "pitch envelope as low-pass filter" flag is
    /// set — in the latter case the envelope drives the filter
    /// cutoff rather than pitch, and the filter engine reads the
    /// envelope value directly (Phase 4 of IT_ROADMAP).
    /// Pitch envelope contribution as a [`PitchDelta`] (Q8.8
    /// semitones). Q-format throughout — no f32 round trip.
    ///
    /// Mapping: `EnvValue` is Q1.15 in `[0, 1]` with `0.5` as
    /// the neutral point. Offset in semitones is `(v - 0.5) ×
    /// 32`. In raw bits: `(v_q15 - 16384) × 32 / 128 =
    /// (v_q15 - 16384) / 4` lands in Q8.8 raw directly.
    /// Range: `±16` semitones.
    pub fn get_pitch_envelope_offset(&self) -> PitchDelta {
        if !self.instr.voice.pitch_envelope.enabled
            || !self.envelope_pitch.enabled
            || self.instr.voice.pitch_envelope_as_low_pass_filter
        {
            return PitchDelta::ZERO;
        }
        // Pitch envelope value is `EnvValue` ∈ `[0, ONE]`; centre
        // it to a signed `[-HALF, +HALF]` Q1.15 swing, then narrow
        // to Q8.8 (the `/ 4` scales `±0.5` cycle to `±0.125 cycle`
        // = `±32 semitones`, the XM pitch-envelope range).
        let centred = self.envelope_pitch.value.raw_q15() - Q15::HALF;
        PitchDelta::from_q8_8_i32_sat(centred.raw() as i32 / 4)
    }

    fn envelopes(&mut self) {
        // Volume envelope tick. Run BEFORE the fadeout decrement so
        // a tick that walks the envelope past its last point can
        // arm `volume_fading_out` for this same tick — matches the
        // ordering schism uses in `sndmix.c:1156-1157`
        // (`rn_increment_env_pos` then `rn_process_envelope`, with
        // the fadeout register decay happening immediately after as
        // part of the same processing pass).
        if self.instr.voice.volume_envelope.enabled {
            self.envelope_volume.tick(self.sustained);

            // Schism's `_process_envelope` (sndmix.c:493-499) sets
            // the volume envelope's `fade_flag` (i.e. CHN_NOTEFADE)
            // exactly when the counter has walked past the last
            // point. By construction the counter only gets there
            // when neither sustain hold nor a real loop has
            // wrapped it back — i.e. we're in the "release section
            // ran out" state. `is_past_last_point` captures that
            // condition directly.
            if self.envelope_volume.is_past_last_point() {
                self.volume_fading_out = true;
            }
        }

        // Fadeout decrement. Gated on `volume_fading_out`, NOT on
        // `!sustained`. The two diverge in the IT-typical case
        // where sustain has been released but the envelope is
        // still walking through its release section: at that point
        // the voice should play at envelope-controlled volume, not
        // get attenuated by a parallel fadeout. The fadeout only
        // kicks in once the envelope itself runs out — set above —
        // or when `key_off` / `start_fadeout` decided up-front
        // that the case warrants immediate fade.
        //
        // Schism's matching code is `rn_process_ins_fade`
        // (sndmix.c:326-345): only runs while CHN_NOTEFADE is set.
        if self.volume_fading_out {
            // Volume::faded_by saturates at zero, replacing the
            // previous `(fadeout - step).max(0.0)` pattern.
            self.volume_fadeout = self
                .volume_fadeout
                .faded_by(self.instr.voice.volume_fadeout.raw());
        }

        // Panning
        if self.instr.voice.pan_envelope.enabled {
            self.envelope_panning.tick(self.sustained);
        }
        // Pitch (also drives the low-pass filter when flagged —
        // see `get_pitch_envelope_offset_semitones`)
        if self.instr.voice.pitch_envelope.enabled {
            self.envelope_pitch.tick(self.sustained);

            // IT "pitch envelope as low-pass filter" routing.
            // When the instrument flag is set, the pitch envelope's
            // current value drives the filter cutoff instead of
            // modulating pitch. `get_pitch_envelope_offset_
            // semitones` already returns 0 in that mode so pitch
            // isn't double-modulated; here we complete the routing
            // by pushing the envelope value through to the filter.
            //
            // Schism's reference implementation
            // (`sndmix.c::rn_pitch_filter_envelope` +
            // `filters.c::setup_channel_filter`) treats the pitch
            // envelope as a *modulator* of the cutoff posed by the
            // instrument header, not a substitute:
            //
            //   modifier  = (env_value_signed - 32) * 8     // -256..+256
            //   cutoff    = base_cutoff * (modifier + 256) / 256
            //
            // In xmrs, `envelope_pitch.value` arrives already
            // normalised to 0..1 by `to_envelope_points_signed`,
            // with 0.5 corresponding to the IT-signed 0 (neutral).
            // Substituting:
            //
            //   modifier   = (value - 0.5) * 512
            //   new_cutoff = base * (modifier + 256) / 256
            //              = base * (value * 512) / 256
            //              = base * value * 2
            //
            // Cases:
            //   value = 0.0 → cutoff = 0       (filter fully closed)
            //   value = 0.5 → cutoff = base    (no change)
            //   value = 1.0 → cutoff = 2*base  (clamped at 127)
            //
            // The base cutoff comes from the instrument header byte
            // — the bottom 7 bits of `initial_filter_cutoff` (bit 7
            // is the enable flag and is consumed elsewhere). xmrs's
            // cutoff register is clamped to 0..=127 by
            // `set_cutoff_reg`, so the modulated value clamps there
            // even though schism allows 0..255. That's a property
            // of xmrs's filter coefficient table, unrelated to the
            // modulation logic.
            //
            // The previous code substituted `value * 127` as the
            // cutoff outright, which: (a) ignored the instrument's
            // base cutoff entirely, and (b) silenced any sample at
            // envelope-value 0.5 (true neutral) by setting cutoff
            // to ~63 regardless of intent.
            if self.instr.voice.pitch_envelope_as_low_pass_filter {
                // Q-format integer modulation:
                //   modulated = clamp(base × envelope_value × 2, 0, 127)
                // base ∈ 0..=127, envelope_value Q1.15 raw [0, 32767].
                //   `(base × env_q15 × 2) >> 15`
                // = `(base × env_q15) >> 14`
                // Saturate at 127.
                let base = (self.instr.voice.initial_filter_cutoff & 0x7F) as i32;
                let env_q15 = self.envelope_pitch.value.as_q15_i32().max(0);
                let modulated = ((base * env_q15) >> 14).min(127) as u8;
                self.filter.set_cutoff_reg(modulated);
            }
        }
    }

    pub fn get_finetuned_pitch(&self) -> PitchDelta {
        match &self.state_sample {
            Some(s) if s.is_enabled() => s.get_finetuned_pitch(),
            _ => PitchDelta::ZERO,
        }
    }

    pub fn set_finetune(&mut self, finetune: Finetune) {
        if let Some(s) = &mut self.state_sample {
            if s.is_enabled() {
                s.set_finetune(finetune);
            }
        }
    }

    /// Recompute the per-sample frequency from the channel's
    /// current period and the LFO / arpeggio / pitch-envelope
    /// contributions. Q-typed inputs:
    ///
    /// * `period` — current channel period.
    /// * `arp_pitch` — arpeggio offset (semitones).
    /// * `vibrato` — vibrato modulation from the channel's main
    ///   vibrato LFO (semitones).
    /// * `semitone` — XM glissando: round the base pitch to the
    ///   nearest integer semitone before adding modulation.
    ///
    /// Pitch envelope and instrument auto-vibrato are read from
    /// `self` directly (not parameters) — they're voice-internal
    /// state.
    pub fn update_frequency(
        &mut self,
        period: xmrs::fixed::units::Period,
        arp_pitch: PitchDelta,
        vibrato: PitchDelta,
        semitone: bool,
    ) {
        let pitch_env_offset = self.get_pitch_envelope_offset();
        let vibrato_mod = self.state_vibrato.current_modulation;
        if self.state_sample.is_none() {
            return;
        }

        // Sum the four pitch deltas. Each `PitchDelta` is Q8.8
        // bounded to `±i16::MAX/256 ≈ ±128 semitones`; their
        // saturating `+` keeps the result inside that range
        // (and in practice each term is `± 32 semitones max`,
        // so we never get near saturation).
        let delta = arp_pitch + pitch_env_offset + vibrato + vibrato_mod;
        let delta_q88: i32 = delta.as_q8_8_i32();

        // Period → base pitch (Q8.8 semitones). Both branches
        // are now integer-only:
        //   - Linear: exact `(7680 - period) << 2` Q8.8 raw.
        //   - Amiga: binary-search through the Amiga period
        //     table (1/8-semitone grid, Q8.8 raw step 32).
        let pitch_q = match self.period_helper.freq_type {
            FrequencyType::LinearFrequencies => {
                // EXACT: pitch_semitones = (7680 - period) / 64.
                // In Q8.8 raw: `(7680 - period) << 2` because
                // (1/64) in Q8.8 raw = 256/64 = 4.
                let mut pitch_q88 = (7680_i32 - period.raw() as i32) * 4;

                // FT2 glissando snap: round to nearest integer
                // semitone (Q8.8 raw multiple of 256).
                if semitone {
                    pitch_q88 = round_to_nearest_semitone_q88(pitch_q88);
                }

                // FT2 arpeggio quirk: clamp base note to ≤ 95
                // (Q8.8 raw `95 * 256 = 24320`) if arpeggio or
                // pitch envelope is active.
                if self.period_helper.legacy_arpeggio_clamp_enabled()
                    && (arp_pitch + pitch_env_offset) != PitchDelta::ZERO
                {
                    pitch_q88 = pitch_q88.min(95 * 256);
                }

                // Saturating sum + Q-format encode (negative
                // pitch is meaningless, hence `.max(0)`; the i16
                // ceiling is handled by `from_q8_8_i32_sat`).
                let final_q88 = pitch_q88.saturating_add(delta_q88).max(0);
                xmrs::fixed::units::Pitch::from_q8_8_i32_sat(final_q88)
            }
            FrequencyType::AmigaFrequencies => {
                // Q-format Amiga period→pitch lookup. Uses the
                // 1/8-semitone (Q8.8 raw step = 32) grid that
                // matches the Amiga period table itself; pitch
                // slides between table entries map to the
                // nearest unit (12.5 cent precision) — same
                // resolution any FT2-era tracker offers on its
                // pitch sliders. No log2 / no f32.
                let mut pitch_q88 =
                    xmrs::fixed::tables::amiga_period_to_pitch(period).as_q8_8_i32();

                if semitone {
                    pitch_q88 = round_to_nearest_semitone_q88(pitch_q88);
                }

                if self.period_helper.legacy_arpeggio_clamp_enabled()
                    && (arp_pitch + pitch_env_offset) != PitchDelta::ZERO
                {
                    pitch_q88 = pitch_q88.min(95 * 256);
                }

                let final_q88 = pitch_q88.saturating_add(delta_q88).max(0);
                xmrs::fixed::units::Pitch::from_q8_8_i32_sat(final_q88)
            }
        };

        // Q-format end-to-end: Pitch → Period → Frequency → step.
        let period_q = self.period_helper.note_to_period(pitch_q);
        let f = self.period_helper.period_to_frequency(period_q);

        if let Some(s) = &mut self.state_sample {
            s.set_step(f);
        }
    }
}

// log2 / pitch_from_semitones_f32_clamped helpers removed
// (Tour 6): both period→pitch branches in `update_frequency`
// now use Q-format LUTs from `xmrs::fixed::tables`. The
// `update_frequency` body is integer-only end-to-end.

impl<'a> StateInstrDefault<'a> {
    /// Resolve the *output* pitch for a given input note, applying
    /// the instrument's keyboard remap if any.
    ///
    /// IT drum-kit instruments wire each input key to an absolute
    /// output note via `note_for_pitch`. When the entry is `Some`,
    /// the sample is played at that output pitch regardless of which
    /// key the user pressed. When it's `None` (the identity case),
    /// or for any other format that doesn't populate the array, the
    /// played pitch is the input note itself.
    ///
    /// Used by the channel at trigger time to compute the frequency
    /// the sample should play at, while the input note is preserved
    /// for DCT / NNA / portamento-target comparisons.
    /// Resolves the IT keyboard-table remap for a given input pitch.
    /// When the instrument's drum-kit table maps `input` to a
    /// different output note, returns that output. Otherwise returns
    /// `input` unchanged.
    ///
    /// Used by the channel at trigger time to compute the frequency
    /// the sample should play at, while the input note is preserved
    /// for DCT / NNA / portamento-target comparisons.
    pub fn played_pitch_for(&self, input: Pitch) -> Pitch {
        match self.instr.keyboard.note_for_pitch[input.value() as usize] {
            Some(out) => Pitch::try_from(out).unwrap_or(input),
            None => input,
        }
    }

    pub fn set_pitch(&mut self, note: Pitch) -> bool {
        if let Some(num) = self.instr.keyboard.sample_for_pitch[note.value() as usize] {
            return self.select_sample(num);
        }
        false
    }

    fn select_sample(&mut self, num: usize) -> bool {
        if num < self.instr.sample.len() {
            if let Some(sample) = &self.instr.sample[num] {
                let state_sample = StateSample::new(sample, self.rate);
                // IT's pan cascade: sample override → instrument
                // default_pan → channel initial pan. The IT
                // importer stores exactly `0.5` in Sample.panning
                // when the sample's own bit-7-"use-pan" flag is
                // clear (i.e., "not set, defer to instrument"). We
                // can't distinguish that from an author's explicit
                // centre-pan, but treating exact-0.5 as "unset" is
                // the right heuristic — authors rarely place a
                // deliberate centre override, and the fallback to
                // `instr.voice.default_pan` is what the module expects.
                let sample_pan = state_sample.get_panning();
                self.panning = if sample_pan == Panning::CENTER {
                    // IT semantics: exact-centre = "unset, defer
                    // to instrument default_pan".
                    self.instr.voice.default_pan
                } else {
                    sample_pan
                };
                // ChannelVolume and Volume are both Q1.15 in
                // `[0, 1]`. The newtype distinction reflects
                // semantics (always-applied vs running per-voice
                // gain), not range. Sharing the raw Q15 is
                // lossless.
                self.volume = Volume::from_q15(state_sample.get_volume().raw());
                self.volume_orig = self.volume;
                self.state_sample = Some(state_sample);
                self.current_sample_num = Some(num);
                return true;
            }
        }
        self.state_sample = None;
        self.current_sample_num = None;
        self.panning = Panning::CENTER;
        self.volume = Volume::SILENT;
        false
    }

    pub fn tick(&mut self) {
        self.envelopes();
        self.state_vibrato.tick(self.sustained);
    }
}

impl<'a> Iterator for StateInstrDefault<'a> {
    type Item = (Amp, Amp);

    fn next(&mut self) -> Option<Self::Item> {
        if !self.is_enabled() {
            return None;
        }
        // Voice is silent and awaiting reap — its fadeout register
        // has reached zero (DCA NoteCut on the live voice, S70 past-
        // cut on a now-promoted ghost, or the natural end of an IT
        // fadeout). Mirrors the same gate in `Voice::next` (the
        // ghost path) and matches schism's per-render kill check at
        // `sndmix.c:1112-1118`. Without this, a DCA-cut live voice
        // would keep paying the full sample / filter / lerp work
        // every output sample until it's replaced by a fresh trigger
        // — a bounded waste (one such voice per channel at most),
        // but a nicely free win that aligns with the ghost path's
        // semantics.
        if self.volume_fading_out && self.volume_fadeout.raw() == Q15::ZERO {
            return None;
        }
        let raw = match &mut self.state_sample {
            Some(s) => s.next(),
            None => None,
        };
        // Filter is Q-format end-to-end: `process` takes
        // `(Amp, Amp)` and returns `(Amp, Amp)` directly. The
        // never-engaged short-circuit lives inside `process`
        // itself (`was_ever_enabled` check) — no `f32` touched
        // anywhere in the audio chain. For XM / MOD / S3M and
        // IT samples without filter bit-7 set, the function
        // returns its input unchanged.
        raw.map(|(l, r)| self.filter.process(l, r))
    }
}

/// Round a Q8.8 pitch to the nearest integer semitone (a Q8.8
/// raw multiple of `256`), half-away-from-zero. Used by the
/// FT2 glissando snap on both linear and Amiga period flavours.
const fn round_to_nearest_semitone_q88(pitch_q88: i32) -> i32 {
    if pitch_q88 >= 0 {
        ((pitch_q88 + 128) / 256) * 256
    } else {
        -(((-pitch_q88) + 128) / 256) * 256
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use alloc::vec;
    // `xmrs::prelude::*` is already in scope through the file-
    // level `use` at the top, re-exported here by `super::*`.

    fn make_instr_with_remap(remap: &[(usize, u8)]) -> InstrDefault {
        let mut instr = InstrDefault::default();
        for (input, output) in remap {
            instr.keyboard.note_for_pitch[*input] = Some(*output);
        }
        instr
    }

    fn state_for(instr: &InstrDefault) -> StateInstrDefault<'_> {
        // Use linear-frequency period helper; played_pitch_for
        // doesn't actually consult the period helper, but
        // StateInstrDefault::new requires one.
        let ph = PeriodHelper::new(FrequencyType::LinearFrequencies, false);
        // `true` mirrors the historical default and keeps these
        // pitch-only tests unaffected by the volume routing change.
        StateInstrDefault::new(instr, 0, ph, SampleRate::from_hz(44100), true)
    }

    #[test]
    fn played_pitch_identity_when_no_remap() {
        // XM/MOD/S3M-style instruments: every note_for_pitch entry
        // is None. played_pitch_for must echo the input untouched.
        let instr = InstrDefault::default();
        let state = state_for(&instr);
        for raw in 0u8..=119 {
            if let Ok(p) = Pitch::try_from(raw) {
                assert_eq!(state.played_pitch_for(p), p);
            }
        }
    }

    #[test]
    fn played_pitch_applies_remap() {
        // Drum-kit style: input D-5 (62) is wired to output C-5
        // (60, transposed down 2 semitones); input E-5 (64) is
        // wired to output G-5 (67).
        let instr = make_instr_with_remap(&[(62, 60), (64, 67)]);
        let state = state_for(&instr);

        assert_eq!(
            state.played_pitch_for(Pitch::D5),
            Pitch::C5,
            "input D-5 transposes to output C-5"
        );
        assert_eq!(
            state.played_pitch_for(Pitch::E5),
            Pitch::G5,
            "input E-5 transposes to output G-5"
        );
        // Untouched keys: identity.
        assert_eq!(state.played_pitch_for(Pitch::C5), Pitch::C5);
        assert_eq!(state.played_pitch_for(Pitch::F5), Pitch::F5);
    }

    // -------- regression tests: voices stopping too early in IT --------
    //
    // The 0.10.x player started decrementing `volume_fadeout` the
    // instant `sustained` flipped to false (i.e. on every key-off),
    // which is wrong for IT instruments with a release section: the
    // fade was running in parallel with the release segment, so
    // pads / sustains went silent before the envelope had finished
    // its decay. Schism's policy (`fx_key_off`, `_process_envelope`,
    // `rn_process_ins_fade`) is to gate fadeout decay on a separate
    // CHN_NOTEFADE flag, set on key-off only in specific cases
    // (no envelope, looped envelope with fadeout > 0) and otherwise
    // deferred until the volume envelope walks past its last point.
    //
    // The xmrsplayer equivalent is `volume_fading_out`, exercised
    // by the tests below.

    /// Build an envelope in the IT-typical pad/sustain shape:
    /// rises from 0 → 1 over 10 ticks, sustains at frame 10, then
    /// decays linearly to 0 at frame 50. No loop, just sustain +
    /// release.
    fn pad_envelope() -> Envelope {
        Envelope {
            enabled: true,
            point: vec![
                EnvelopePoint {
                    frame: 0,
                    value: EnvValue::default(),
                },
                EnvelopePoint {
                    frame: 10,
                    value: EnvValue::VOLUME_DEFAULT,
                },
                EnvelopePoint {
                    frame: 50,
                    value: EnvValue::default(),
                },
            ],
            sustain_enabled: true,
            sustain_start_point: 1,
            sustain_end_point: 1,
            loop_enabled: false,
            loop_start_point: 0,
            loop_end_point: 0,
        }
    }

    fn instr_with_envelope(env: Envelope, fadeout: Volume) -> InstrDefault {
        let mut instr = InstrDefault::default();
        instr.voice.volume_envelope = env;
        instr.voice.volume_fadeout = fadeout;
        instr
    }

    #[test]
    fn it_pad_release_section_plays_before_fadeout() {
        // The bug: after key-off on an IT pad (sustain + release, no
        // loop), `volume_fadeout` was decrementing every tick as the
        // envelope walked through its release section. Voices died
        // before the envelope's natural decay finished.
        //
        // The fix: `volume_fading_out` (CHN_NOTEFADE-equivalent)
        // stays false through the release, so `volume_fadeout` is
        // held at 1.0. It only flips true after the envelope has
        // walked past its last point — at which point the fadeout
        // register starts decaying.
        let instr = instr_with_envelope(pad_envelope(), Volume::from_ratio(1, 100));
        let mut state = state_for(&instr);
        assert!(state.sustained);
        assert!(!state.volume_fading_out);
        assert_eq!(state.volume_fadeout, Volume::FULL);

        state.key_off();
        // Sustain released, but fading not yet armed: envelope has
        // a real release section to play through first.
        assert!(!state.sustained, "key-off must release sustain");
        assert!(
            !state.volume_fading_out,
            "fading must NOT engage on key-off when the envelope has a release section"
        );

        // Tick the envelope from sustain (counter starts at 0 in
        // a fresh state) toward the last point at frame 50. All
        // through that walk, the fadeout register must stay at 1.0
        // — the release section is still playing.
        for _ in 0..49 {
            state.tick();
            assert_eq!(
                state.volume_fadeout,
                Volume::FULL,
                "fadeout must not decay while the release section is still playing"
            );
            assert!(
                !state.volume_fading_out,
                "fading must not arm before the envelope has walked past its last point"
            );
        }

        // Push past the last point. The exact tick at which
        // `is_past_last_point` flips depends on the inner branch
        // ordering of `StateEnvelope::tick`, but a handful of
        // extra ticks comfortably brackets it.
        for _ in 0..5 {
            state.tick();
        }
        assert!(
            state.volume_fading_out,
            "envelope walking past its last point must arm fadeout"
        );

        // From now on, fadeout decays. The next tick brings the
        // register strictly below its previous value.
        let pre = state.volume_fadeout;
        state.tick();
        assert!(
            state.volume_fadeout.raw() < pre.raw(),
            "fadeout register must decrement once the envelope is past its end"
        );
    }

    #[test]
    fn no_envelope_fades_immediately_on_key_off() {
        // No volume envelope at all (typical MOD/S3M sample, or an
        // IT instrument without an envelope): schism's `fx_key_off`
        // sets CHN_NOTEFADE right away because the envelope isn't
        // there to defer the decision.
        let instr = instr_with_envelope(Envelope::default(), Volume::from_ratio(1, 100));
        let mut state = state_for(&instr);
        state.key_off();
        assert!(
            state.volume_fading_out,
            "key-off on a no-envelope instrument must engage fadeout immediately"
        );
        // And the fadeout register must actually start moving.
        let pre = state.volume_fadeout;
        state.tick();
        assert!(state.volume_fadeout.raw() < pre.raw());
    }

    #[test]
    fn looped_envelope_with_fadeout_fades_immediately_on_key_off() {
        // Envelope with a loop AND a non-zero fadeout: schism's
        // `fx_key_off` last branch (`effects.c:166-167`) engages
        // CHN_NOTEFADE right away — without it, the looped envelope
        // would hold the voice forever past key-off.
        //
        // FT2/XM modules reach this branch for *every* enveloped
        // voice: the XM importer pins a degenerate loop on the
        // last point so that this exact branch fires, matching
        // FT2's "fadeout is parallel with the release section"
        // semantics.
        let mut env = pad_envelope();
        env.loop_enabled = true;
        env.loop_start_point = 2; // last point
        env.loop_end_point = 2;
        let instr = instr_with_envelope(env, Volume::from_ratio(1, 100));
        let mut state = state_for(&instr);
        state.key_off();
        assert!(
            state.volume_fading_out,
            "key-off on a looped envelope with non-zero fadeout must engage fadeout immediately"
        );
    }

    #[test]
    fn start_fadeout_does_not_release_sustain() {
        // NNA NoteFade / DCA NoteFadeOut / S72 / TrackEffect::
        // NoteFadeOut all funnel into `start_fadeout`. Schism's
        // matching code only sets CHN_NOTEFADE — sustain stays
        // held. The previous implementation went through `key_off`
        // here, conflating fade with note-off and making the
        // envelope walk into its release section instead of
        // continuing to wrap in the sustain loop.
        let instr = instr_with_envelope(pad_envelope(), Volume::from_ratio(1, 100));
        let mut state = state_for(&instr);
        assert!(state.sustained);
        state.start_fadeout();
        assert!(state.sustained, "start_fadeout must NOT release sustain");
        assert!(state.volume_fading_out, "start_fadeout must engage fadeout");
        // And fadeout starts decaying on the next tick.
        let pre = state.volume_fadeout;
        state.tick();
        assert!(state.volume_fadeout.raw() < pre.raw());
    }
}