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
use serde::{Deserialize, Serialize};
// Float math backend (only needed when `std` is disabled).
// Priority: std > libm > micromath.
#[cfg(all(not(feature = "std"), not(feature = "libm"), feature = "micromath"))]
#[allow(unused_imports)]
use micromath::F32Ext;
#[cfg(all(not(feature = "std"), feature = "libm"))]
#[allow(unused_imports)]
use num_traits::float::Float;
/// Historical Frequencies to load old data. Default is Linear.
#[derive(Default, Serialize, Deserialize, Clone, Copy, Debug)]
pub enum FrequencyType {
AmigaFrequencies,
#[default]
LinearFrequencies,
}
/// Single-entry memoization of the most recent
/// `all_to_frequency` call. The dominant access pattern is a held
/// note producing the same key across every tick of a row; one slot
/// captures that case at 100% hit rate, with zero book-keeping. Any
/// change in period, arpeggio offset, finetune (including vibrato
/// modulation), or glissando mode just overwrites the entry.
#[derive(Clone, Copy)]
struct LastCall {
key: (f32, f32, f32, bool),
value: f32,
}
#[derive(Clone)]
pub struct PeriodHelper {
pub freq_type: FrequencyType,
/// Behaviour knob: when `true`, `adjust_period` clamps the base
/// note to ≤ 95 under active arpeggio so the period lookup stays
/// inside FT2's 0–95 note range (matches `ft2_replayer.c`
/// arpeggio's `period2NotePeriod`). Off by default — driven from
/// `module.quirks.ft2_arpeggio_note_clamp` at construction.
ft2_arpeggio_note_clamp: bool,
last_call: Option<LastCall>,
}
impl Default for PeriodHelper {
fn default() -> Self {
Self {
freq_type: FrequencyType::LinearFrequencies,
ft2_arpeggio_note_clamp: false,
last_call: None,
}
}
}
impl PeriodHelper {
/// historical amiga module sample frequency (Paula chipset related)
pub const C4_FREQ: f32 = 8363.0;
pub fn new(freq_type: FrequencyType, ft2_arpeggio_note_clamp: bool) -> Self {
Self {
freq_type,
ft2_arpeggio_note_clamp,
last_call: None,
}
}
// ==== Linear
/// return period
#[inline(always)]
fn linear_pitch_to_period(note: f32) -> f32 {
// 10.0: number of octaves
// 12.0: halftones
// 16.0: number of finetune steps
// 4.0: finetune resolution
10.0 * 12.0 * 16.0 * 4.0 - note * 16.0 * 4.0
}
/// return note
#[inline(always)]
fn linear_period_to_pitch(period: f32) -> f32 {
(10.0 * 12.0 * 16.0 * 4.0 - period) / (16.0 * 4.0)
}
/// return frequency
#[inline(always)]
fn linear_period_to_frequency(period: f32) -> f32 {
// 8363.0 is historical amiga module sample frequency (Paula chipset related)
// 6: octave center
// 12: halftones
// 64: period resolution (16.0 * 4.0)
// 16.0: number of finetune steps
// 4.0: finetune step resolution
Self::C4_FREQ * (2.0f32).powf((6.0 * 12.0 * 16.0 * 4.0 - period) / (12.0 * 16.0 * 4.0))
}
/// return period
#[inline(always)]
fn linear_frequency_to_period(freq: f32) -> f32 {
(6.0 * 12.0 * 16.0 * 4.0) - (12.0 * 16.0 * 4.0) * (freq / Self::C4_FREQ).log2()
}
// ==== Amiga
/// return period
#[inline(always)]
fn amiga_pitch_to_period(note: f32) -> f32 {
/* found using scipy.optimize.curve_fit */
6848.0 * (-0.0578 * note).exp() + 0.2782
}
/// return note
#[inline(always)]
fn amiga_period_to_pitch(period: f32) -> f32 {
-f32::ln((period - 0.2782) / 6848.0) / 0.0578
}
/// return frequency
#[inline(always)]
fn amiga_period_to_frequency(period: f32) -> f32 {
if period == 0.0 {
0.0
} else {
// 7159090.5 / (period * 2.0) // NTSC
7093789.2 / (period * 2.0) // PAL
}
}
/// return period
#[inline(always)]
fn amiga_frequency_to_period(freq: f32) -> f32 {
if freq == 0.0 {
0.0
} else {
// 7159090.5 / (freq * 2.0) // NTSC
7093789.2 / (freq * 2.0) // PAL
}
}
// ==== Generic
pub fn note_to_period(&self, note: f32) -> f32 {
match self.freq_type {
FrequencyType::LinearFrequencies => Self::linear_pitch_to_period(note),
FrequencyType::AmigaFrequencies => Self::amiga_pitch_to_period(note),
}
}
pub fn period_to_pitch(&self, period: f32) -> f32 {
match self.freq_type {
FrequencyType::LinearFrequencies => Self::linear_period_to_pitch(period),
FrequencyType::AmigaFrequencies => Self::amiga_period_to_pitch(period),
}
.max(0.0) // Remove < 0.0 and NaN numbers
}
pub fn period_to_frequency(&self, period: f32) -> f32 {
match self.freq_type {
FrequencyType::LinearFrequencies => Self::linear_period_to_frequency(period),
FrequencyType::AmigaFrequencies => Self::amiga_period_to_frequency(period),
}
}
pub fn frequency_to_period(&self, freq: f32) -> f32 {
match self.freq_type {
FrequencyType::LinearFrequencies => Self::linear_frequency_to_period(freq),
FrequencyType::AmigaFrequencies => Self::amiga_frequency_to_period(freq),
}
}
/// returns C-4 frequency from relative note and finetune
pub fn relative_pitch_to_c4freq(&self, relative_pitch: f32, finetune: f32) -> Option<f32> {
const NOTE_C4: f32 = 4.0 * 12.0;
const NOTE_B9: f32 = 10.0 * 12.0 - 1.0;
let note = NOTE_C4 + relative_pitch;
if !(0.0..=NOTE_B9).contains(¬e) {
return None;
}
let c4_period = self.note_to_period(note + finetune);
Some(self.period_to_frequency(c4_period))
}
/// return relative note and finetune
pub fn c4freq_to_relative_pitch(&self, freq: f32) -> (i8, f32) {
const NOTE_C4: f32 = 4.0 * 12.0;
let period = self.frequency_to_period(freq);
let note = self.period_to_pitch(period);
let note_ceil = note.ceil();
let relative_pitch = note_ceil - NOTE_C4;
let finetune = note - note_ceil;
(relative_pitch as i8, finetune)
}
//-----------------------------------------------------
/// new adjust period to arpeggio and finetune delta
pub fn adjust_period(&self, period: f32, arp_pitch: f32, finetune: f32, semitone: bool) -> f32 {
let note_orig: f32 = self.period_to_pitch(period);
let note = if semitone {
note_orig.round()
} else {
note_orig
};
if self.ft2_arpeggio_note_clamp && arp_pitch != 0.0 {
// FT2-canonical note clamp when arpeggio is active: FT2's
// arpeggio period lookup walks a 0..95 note range
// (`period2NotePeriod` in ft2_replayer.c), so any base note
// whose perturbed index would land at 96 or above is snapped
// back to 95. Without this, the arpeggio on very high notes
// can compute a negative index and return a nonsense period.
let mut note = note;
if note.ceil() >= 95.0 {
note = 95.0;
}
self.note_to_period(note + arp_pitch + finetune)
} else {
self.note_to_period(note + arp_pitch + finetune)
}
}
/// Do all the work in one pass.
pub fn all_to_frequency(
&self,
period: f32,
arp_pitch: f32,
finetune: f32,
semitone: bool,
) -> f32 {
let period_adjusted = self.adjust_period(period, arp_pitch, finetune, semitone);
self.period_to_frequency(period_adjusted)
}
/// Same as [`Self::all_to_frequency`] with single-entry
/// memoization. The dominant call pattern is a held note
/// re-evaluating the same key every tick of a row, so a single
/// slot captures ~95% of calls at zero book-keeping cost. Any
/// change in the key (vibrato, slide, arpeggio, instrument
/// swap) just overwrites the slot — no LRU, no scan.
pub fn all_to_frequency_cached(
&mut self,
period: f32,
arp_pitch: f32,
finetune: f32,
semitone: bool,
) -> f32 {
let key = (period, arp_pitch, finetune, semitone);
if let Some(last) = self.last_call {
if last.key == key {
return last.value;
}
}
let value = self.all_to_frequency(period, arp_pitch, finetune, semitone);
self.last_call = Some(LastCall { key, value });
value
}
}