ym2149_common/visualization.rs
1//! Shared visualization utilities for YM2149 oscilloscope and spectrum display.
2//!
3//! This module provides register-based visualization that works across all frontends
4//! (Bevy, CLI TUI) and all formats (YM, AKS, AY, SNDH). Unlike FFT-based analysis,
5//! this approach synthesizes waveforms directly from register state, ensuring
6//! visualization works even when digidrums or STE-DAC bypass the PSG.
7//!
8//! # Example
9//!
10//! ```ignore
11//! use ym2149_common::visualization::{WaveformSynthesizer, SpectrumAnalyzer};
12//! use ym2149_common::ChannelStates;
13//!
14//! let mut waveform = WaveformSynthesizer::new();
15//! let mut spectrum = SpectrumAnalyzer::new();
16//!
17//! // Update from register state each frame
18//! let channel_states = ChannelStates::from_registers(®isters);
19//! waveform.update(&channel_states);
20//! spectrum.update(&channel_states);
21//!
22//! // Get data for rendering
23//! let samples = waveform.get_samples();
24//! let bins = spectrum.get_bins();
25//! ```
26
27use crate::channel_state::ChannelStates;
28use std::collections::VecDeque;
29
30// ============================================================================
31// Constants
32// ============================================================================
33
34/// Maximum number of PSG chips supported.
35pub const MAX_PSG_COUNT: usize = 4;
36
37/// Maximum number of channels (4 PSGs × 3 channels).
38pub const MAX_CHANNEL_COUNT: usize = MAX_PSG_COUNT * 3;
39
40/// Number of samples to keep for waveform display.
41pub const WAVEFORM_SIZE: usize = 256;
42
43/// Sample rate for waveform generation (visual only, not audio).
44/// Uses 44.1kHz as a good balance between visual smoothness and performance.
45pub const VISUAL_SAMPLE_RATE: f32 = 44100.0;
46
47/// Number of samples to generate per frame update (~20ms at 50Hz).
48pub const SAMPLES_PER_UPDATE: usize = 64;
49
50/// Number of octaves covered by spectrum display.
51/// 8 octaves covers C1 (~33 Hz) to B8 (~8 kHz), full YM2149 range.
52pub const SPECTRUM_OCTAVES: usize = 8;
53
54/// Bins per octave (4 = minor-third resolution for compact display).
55pub const BINS_PER_OCTAVE: usize = 4;
56
57/// Number of spectrum bins (8 octaves × 4 bins per octave = 32 bins).
58pub const SPECTRUM_BINS: usize = SPECTRUM_OCTAVES * BINS_PER_OCTAVE;
59
60/// Decay factor for spectrum bars (0.85 = fast release, responsive visualization).
61pub const SPECTRUM_DECAY: f32 = 0.85;
62
63/// Base frequency for spectrum bins: C1 = 32.703 Hz (MIDI note 24).
64pub const SPECTRUM_BASE_FREQ: f32 = 32.703;
65
66// ============================================================================
67// Waveform Synthesizer
68// ============================================================================
69
70/// Synthesizes oscilloscope waveforms from YM2149 register state.
71///
72/// This generates per-channel waveforms based on the current register values,
73/// producing square waves for tone, pseudo-random noise, and envelope-accurate
74/// waveforms for buzz instruments.
75///
76/// Supports up to 4 PSGs (12 channels total) for multi-PSG configurations.
77#[derive(Clone)]
78pub struct WaveformSynthesizer {
79 /// Ring buffer for waveform samples (per channel, up to 12).
80 waveform: [VecDeque<f32>; MAX_CHANNEL_COUNT],
81 /// Phase accumulators for waveform generation (per channel).
82 phase: [f32; MAX_CHANNEL_COUNT],
83 /// Number of active PSGs.
84 psg_count: usize,
85}
86
87impl Default for WaveformSynthesizer {
88 fn default() -> Self {
89 Self::new()
90 }
91}
92
93impl WaveformSynthesizer {
94 /// Create a new waveform synthesizer.
95 #[must_use]
96 pub fn new() -> Self {
97 Self {
98 waveform: std::array::from_fn(|_| VecDeque::with_capacity(WAVEFORM_SIZE)),
99 phase: [0.0; MAX_CHANNEL_COUNT],
100 psg_count: 1,
101 }
102 }
103
104 /// Set the number of active PSGs (1-4).
105 pub fn set_psg_count(&mut self, count: usize) {
106 self.psg_count = count.clamp(1, MAX_PSG_COUNT);
107 }
108
109 /// Get the number of active PSGs.
110 #[must_use]
111 pub fn psg_count(&self) -> usize {
112 self.psg_count
113 }
114
115 /// Get the number of active channels.
116 #[must_use]
117 pub fn channel_count(&self) -> usize {
118 self.psg_count * 3
119 }
120
121 /// Update waveforms from YM2149 channel states (single PSG, for backward compatibility).
122 ///
123 /// Call this once per frame to generate new waveform samples based on
124 /// the current register state. This updates channels 0-2.
125 pub fn update(&mut self, channel_states: &ChannelStates) {
126 self.update_psg(0, channel_states);
127 }
128
129 /// Update waveforms for a specific PSG (0-3).
130 ///
131 /// Call this for each active PSG to update its 3 channels.
132 pub fn update_psg(&mut self, psg_index: usize, channel_states: &ChannelStates) {
133 if psg_index >= MAX_PSG_COUNT {
134 return;
135 }
136
137 let base_channel = psg_index * 3;
138
139 for (local_ch, ch_state) in channel_states.channels.iter().enumerate() {
140 let global_ch = base_channel + local_ch;
141 if global_ch >= MAX_CHANNEL_COUNT {
142 break;
143 }
144
145 // Get frequency and amplitude for this channel
146 let freq = ch_state.frequency_hz.unwrap_or(0.0);
147 let has_output =
148 ch_state.tone_enabled || ch_state.noise_enabled || ch_state.envelope_enabled;
149 let has_amplitude = ch_state.amplitude > 0 || ch_state.envelope_enabled;
150
151 let amplitude = if has_output && has_amplitude {
152 if ch_state.envelope_enabled {
153 1.0
154 } else {
155 ch_state.amplitude_normalized
156 }
157 } else {
158 0.0
159 };
160
161 // Calculate phase increment
162 let phase_increment = if freq > 0.0 {
163 freq / VISUAL_SAMPLE_RATE
164 } else {
165 0.0
166 };
167
168 // Get envelope shape for accurate waveform synthesis
169 let envelope_shape = channel_states.envelope.shape;
170
171 for _ in 0..SAMPLES_PER_UPDATE {
172 let sample =
173 self.synthesize_sample(ch_state, global_ch, amplitude, envelope_shape, freq);
174
175 // Add to waveform buffer
176 if self.waveform[global_ch].len() >= WAVEFORM_SIZE {
177 self.waveform[global_ch].pop_front();
178 }
179 self.waveform[global_ch].push_back(sample);
180
181 // Advance phase with proper wrapping (handles phase_increment > 1.0)
182 self.phase[global_ch] = (self.phase[global_ch] + phase_increment).fract();
183 }
184 }
185 }
186
187 /// Update waveforms from multiple PSG register banks.
188 ///
189 /// Call this once per frame with all PSG register states.
190 pub fn update_multi_psg(&mut self, register_banks: &[[u8; 16]], psg_count: usize) {
191 self.set_psg_count(psg_count);
192
193 for (psg_idx, registers) in register_banks.iter().enumerate().take(psg_count) {
194 let channel_states = ChannelStates::from_registers(registers);
195 self.update_psg(psg_idx, &channel_states);
196 }
197 }
198
199 /// Synthesize a single sample for a channel.
200 ///
201 /// Handles different YM2149 sound types:
202 /// - Pure tone: square wave
203 /// - Pure noise: LFSR-like noise
204 /// - Buzz/Envelope: envelope waveform (sawtooth, triangle, etc.)
205 /// - Sync-buzzer: envelope with tone frequency modulation
206 #[inline]
207 fn synthesize_sample(
208 &self,
209 ch_state: &crate::channel_state::ChannelState,
210 ch: usize,
211 amplitude: f32,
212 envelope_shape: u8,
213 freq: f32,
214 ) -> f32 {
215 let phase = self.phase[ch];
216
217 // Priority: Envelope/Buzz sounds take precedence for visualization
218 // because they have the most interesting waveform shape.
219 // Sync-buzzer: envelope_enabled + tone_period > 0 (freq used for pitch)
220 // Pure buzz: envelope_enabled + tone_period = 0 (envelope freq for pitch)
221 if ch_state.envelope_enabled && freq > 0.0 {
222 // Envelope/Buzz: synthesize based on actual shape register
223 // This includes sync-buzzer (tone+envelope) and pure buzz (envelope only)
224 self.synthesize_envelope_sample(envelope_shape, phase, amplitude)
225 } else if ch_state.tone_enabled && freq > 0.0 {
226 // Pure tone: square wave
227 if phase < 0.5 { amplitude } else { -amplitude }
228 } else if ch_state.noise_enabled {
229 // Noise: pseudo-random values scaled by amplitude
230 // Use LFSR-like behavior based on phase
231 let noise = (phase * 12345.0).sin() * 2.0 - 1.0;
232 noise * amplitude * 0.7
233 } else {
234 0.0
235 }
236 }
237
238 /// Synthesize envelope waveform based on YM2149 envelope shape.
239 ///
240 /// YM2149 envelope shapes (register 13, bits 0-3):
241 /// - 0x00-0x03: Decay (\\\___)
242 /// - 0x04-0x07: Attack (/____)
243 /// - 0x08: Sawtooth down (\\\\\\\\)
244 /// - 0x09: Decay one-shot (\\\___)
245 /// - 0x0A: Triangle (/\\/\\)
246 /// - 0x0B: Decay + hold high (\\¯¯¯)
247 /// - 0x0C: Sawtooth up (////)
248 /// - 0x0D: Attack + hold high (/¯¯¯)
249 /// - 0x0E: Triangle inverted (\\/\\/)
250 /// - 0x0F: Attack one-shot (/____)
251 #[inline]
252 fn synthesize_envelope_sample(&self, shape: u8, phase: f32, amplitude: f32) -> f32 {
253 let sample = match shape & 0x0F {
254 // Decay shapes: start high, go low
255 0x00..=0x03 | 0x09 => {
256 // Single decay: high to low
257 1.0 - phase * 2.0
258 }
259 // Attack shapes: start low, go high
260 0x04..=0x07 | 0x0F => {
261 // Single attack: low to high
262 phase * 2.0 - 1.0
263 }
264 // Sawtooth down: continuous decay
265 0x08 => {
266 // Repeating sawtooth down
267 1.0 - phase * 2.0
268 }
269 // Triangle: /\/\/\
270 0x0A => {
271 // Triangle wave
272 if phase < 0.5 {
273 phase * 4.0 - 1.0 // Rising: -1 to 1
274 } else {
275 3.0 - phase * 4.0 // Falling: 1 to -1
276 }
277 }
278 // Decay + hold high
279 0x0B => {
280 // Decay then hold at max
281 if phase < 0.5 { 1.0 - phase * 4.0 } else { 1.0 }
282 }
283 // Sawtooth up: continuous attack
284 0x0C => {
285 // Repeating sawtooth up
286 phase * 2.0 - 1.0
287 }
288 // Attack + hold high
289 0x0D => {
290 // Attack then hold at max
291 if phase < 0.5 { phase * 4.0 - 1.0 } else { 1.0 }
292 }
293 // Triangle inverted: \/\/\/
294 0x0E => {
295 // Inverted triangle wave
296 if phase < 0.5 {
297 1.0 - phase * 4.0 // Falling: 1 to -1
298 } else {
299 phase * 4.0 - 3.0 // Rising: -1 to 1
300 }
301 }
302 _ => 0.0,
303 };
304
305 sample * amplitude
306 }
307
308 /// Get waveform samples as a Vec for display (first 3 channels only for backward compat).
309 ///
310 /// Returns samples in the format `[amplitude_a, amplitude_b, amplitude_c]` per sample.
311 #[must_use]
312 pub fn get_samples(&self) -> Vec<[f32; 3]> {
313 let len = self.waveform[0]
314 .len()
315 .min(self.waveform[1].len())
316 .min(self.waveform[2].len());
317
318 (0..len)
319 .map(|i| {
320 [
321 self.waveform[0].get(i).copied().unwrap_or(0.0),
322 self.waveform[1].get(i).copied().unwrap_or(0.0),
323 self.waveform[2].get(i).copied().unwrap_or(0.0),
324 ]
325 })
326 .collect()
327 }
328
329 /// Get waveform for a specific channel (0-11 for multi-PSG).
330 #[must_use]
331 pub fn channel_waveform(&self, channel: usize) -> &VecDeque<f32> {
332 &self.waveform[channel.min(MAX_CHANNEL_COUNT - 1)]
333 }
334}
335
336// ============================================================================
337// Spectrum Analyzer
338// ============================================================================
339
340/// Map a frequency to a spectrum bin index (note-aligned, minor-third resolution).
341///
342/// Returns bin 0-31 based on position (3 semitones per bin):
343/// - Bin 0: C1 (32.7 Hz)
344/// - Bin 4: C2 (65.4 Hz)
345/// - Bin 12: C4 (262 Hz, middle C)
346/// - Bin 16: C5 (523 Hz)
347/// - Bin 28: C8 (4186 Hz)
348/// - Bin 31: A#8 (~7458 Hz)
349#[inline]
350#[must_use]
351pub fn freq_to_bin(freq: f32) -> usize {
352 if freq <= 0.0 {
353 return 0;
354 }
355 // Calculate semitones above C1
356 // bin = log2(freq / C1) * 12
357 let octaves_above_c1 = (freq / SPECTRUM_BASE_FREQ).log2();
358 let bin = (octaves_above_c1 * BINS_PER_OCTAVE as f32).round() as i32;
359 bin.clamp(0, (SPECTRUM_BINS - 1) as i32) as usize
360}
361
362/// Register-based spectrum analyzer.
363///
364/// Maps YM2149 channel frequencies to note-aligned spectrum bins,
365/// showing the actual notes being played rather than FFT analysis.
366///
367/// Supports up to 4 PSGs (12 channels total) for multi-PSG configurations.
368#[derive(Clone)]
369pub struct SpectrumAnalyzer {
370 /// Per-channel spectrum magnitudes (up to 12 channels).
371 spectrum: [[f32; SPECTRUM_BINS]; MAX_CHANNEL_COUNT],
372 /// Combined spectrum (max across all active channels).
373 combined: [f32; SPECTRUM_BINS],
374 /// Number of active PSGs.
375 psg_count: usize,
376}
377
378impl Default for SpectrumAnalyzer {
379 fn default() -> Self {
380 Self::new()
381 }
382}
383
384impl SpectrumAnalyzer {
385 /// Create a new spectrum analyzer.
386 #[must_use]
387 pub fn new() -> Self {
388 Self {
389 spectrum: [[0.0; SPECTRUM_BINS]; MAX_CHANNEL_COUNT],
390 combined: [0.0; SPECTRUM_BINS],
391 psg_count: 1,
392 }
393 }
394
395 /// Set the number of active PSGs (1-4).
396 pub fn set_psg_count(&mut self, count: usize) {
397 self.psg_count = count.clamp(1, MAX_PSG_COUNT);
398 }
399
400 /// Get the number of active PSGs.
401 #[must_use]
402 pub fn psg_count(&self) -> usize {
403 self.psg_count
404 }
405
406 /// Get the number of active channels.
407 #[must_use]
408 pub fn channel_count(&self) -> usize {
409 self.psg_count * 3
410 }
411
412 /// Update spectrum from YM2149 channel states (single PSG, for backward compatibility).
413 ///
414 /// Call this once per frame. Applies decay to previous values
415 /// and updates bins based on current frequencies. Updates channels 0-2.
416 pub fn update(&mut self, channel_states: &ChannelStates) {
417 self.update_psg(0, channel_states);
418 self.update_combined();
419 }
420
421 /// Update spectrum for a specific PSG (0-3).
422 ///
423 /// Call this for each active PSG to update its 3 channels.
424 pub fn update_psg(&mut self, psg_index: usize, channel_states: &ChannelStates) {
425 if psg_index >= MAX_PSG_COUNT {
426 return;
427 }
428
429 let base_channel = psg_index * 3;
430
431 for (local_ch, ch_state) in channel_states.channels.iter().enumerate() {
432 let global_ch = base_channel + local_ch;
433 if global_ch >= MAX_CHANNEL_COUNT {
434 break;
435 }
436
437 // Save previous value for decay
438 let prev = self.spectrum[global_ch];
439
440 // Reset current frame for this channel
441 self.spectrum[global_ch] = [0.0; SPECTRUM_BINS];
442
443 // Channel is active if it has amplitude AND some output enabled
444 let has_output =
445 ch_state.tone_enabled || ch_state.noise_enabled || ch_state.envelope_enabled;
446 let has_amplitude = ch_state.amplitude > 0 || ch_state.envelope_enabled;
447 let is_active = has_amplitude && has_output;
448
449 if is_active {
450 // For envelope mode, use full amplitude since envelope controls it dynamically
451 let magnitude = if ch_state.envelope_enabled {
452 1.0
453 } else {
454 ch_state.amplitude_normalized
455 };
456
457 // Handle tone frequency
458 if ch_state.tone_enabled
459 && let Some(freq) = ch_state.frequency_hz
460 && freq > 0.0
461 {
462 let bin = freq_to_bin(freq);
463 self.spectrum[global_ch][bin] = magnitude;
464 }
465
466 // Handle noise - spread across high frequency bins
467 if ch_state.noise_enabled {
468 self.add_noise_to_spectrum(global_ch, channel_states.noise.period, magnitude);
469 }
470
471 // Handle envelope/buzz instruments (including sync-buzzer)
472 if ch_state.envelope_enabled {
473 self.add_envelope_to_spectrum(global_ch, ch_state, channel_states, magnitude);
474 }
475 }
476
477 // Apply decay to all bins
478 for (bin, &prev_val) in prev.iter().enumerate() {
479 if self.spectrum[global_ch][bin] < prev_val {
480 self.spectrum[global_ch][bin] = prev_val * SPECTRUM_DECAY;
481 }
482 }
483 }
484 }
485
486 /// Update spectrum from multiple PSG register banks.
487 ///
488 /// Call this once per frame with all PSG register states.
489 pub fn update_multi_psg(&mut self, register_banks: &[[u8; 16]], psg_count: usize) {
490 self.set_psg_count(psg_count);
491
492 for (psg_idx, registers) in register_banks.iter().enumerate().take(psg_count) {
493 let channel_states = ChannelStates::from_registers(registers);
494 self.update_psg(psg_idx, &channel_states);
495 }
496
497 self.update_combined();
498 }
499
500 /// Update the combined spectrum from all active channels.
501 fn update_combined(&mut self) {
502 let channel_count = self.channel_count();
503 for (bin, combined) in self.combined.iter_mut().enumerate() {
504 *combined = (0..channel_count)
505 .map(|ch| self.spectrum[ch][bin])
506 .fold(0.0, f32::max);
507 }
508 }
509
510 /// Add noise contribution to spectrum.
511 fn add_noise_to_spectrum(&mut self, ch: usize, noise_period: u8, magnitude: f32) {
512 // Map noise period to bins: period 0 = high freq, period 31 = lower freq
513 let noise_center = if noise_period == 0 {
514 SPECTRUM_BINS - 2 // Very high frequency noise
515 } else {
516 let ratio = 1.0 - (noise_period as f32 / 31.0);
517 ((ratio * 0.6 + 0.3) * (SPECTRUM_BINS - 1) as f32) as usize
518 };
519
520 // Spread noise across a few adjacent bins for "fuzzy" look
521 let noise_mag = magnitude * 0.7;
522 for offset in 0..=2 {
523 let bin = (noise_center + offset).min(SPECTRUM_BINS - 1);
524 self.spectrum[ch][bin] =
525 self.spectrum[ch][bin].max(noise_mag * (1.0 - offset as f32 * 0.25));
526 }
527 }
528
529 /// Add envelope/buzz contribution to spectrum.
530 fn add_envelope_to_spectrum(
531 &mut self,
532 ch: usize,
533 ch_state: &crate::channel_state::ChannelState,
534 channel_states: &ChannelStates,
535 magnitude: f32,
536 ) {
537 // Sync-buzzer: tone_period sets the pitch, envelope provides the timbre
538 // For sync-buzzer: use tone frequency (even if tone is disabled in mixer)
539 // For pure buzz: fall back to envelope frequency
540 let buzz_freq = if ch_state.frequency_hz.is_some() && ch_state.tone_period > 0 {
541 ch_state.frequency_hz
542 } else {
543 channel_states.envelope.frequency_hz
544 };
545
546 if let Some(freq) = buzz_freq
547 && freq > 0.0
548 {
549 let bin = freq_to_bin(freq);
550 self.spectrum[ch][bin] = self.spectrum[ch][bin].max(magnitude);
551 }
552 }
553
554 /// Get combined spectrum bins (max across all channels).
555 #[must_use]
556 pub fn get_bins(&self) -> &[f32; SPECTRUM_BINS] {
557 &self.combined
558 }
559
560 /// Get spectrum for a specific channel (0-11 for multi-PSG).
561 #[must_use]
562 pub fn channel_spectrum(&self, channel: usize) -> &[f32; SPECTRUM_BINS] {
563 &self.spectrum[channel.min(MAX_CHANNEL_COUNT - 1)]
564 }
565
566 /// Get all per-channel spectrums (all 12 channels).
567 #[must_use]
568 pub fn all_channel_spectrums(&self) -> &[[f32; SPECTRUM_BINS]; MAX_CHANNEL_COUNT] {
569 &self.spectrum
570 }
571
572 /// Compute high frequency ratio (bins 8-15 vs total).
573 ///
574 /// Useful for badges indicating "bright" or "treble" content.
575 #[must_use]
576 pub fn high_freq_ratio(&self, channel: usize) -> f32 {
577 let ch = channel.min(2);
578 let total_energy: f32 = self.spectrum[ch].iter().sum();
579 let high_energy: f32 = self.spectrum[ch][8..].iter().sum();
580
581 if total_energy > 0.01 {
582 (high_energy / total_energy).clamp(0.0, 1.0)
583 } else {
584 0.0
585 }
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592
593 #[test]
594 fn test_freq_to_bin_c1() {
595 // C1 = 32.703 Hz should map to bin 0
596 assert_eq!(freq_to_bin(32.703), 0);
597 }
598
599 #[test]
600 fn test_freq_to_bin_a4() {
601 // A4 = 440 Hz is 3 octaves + 9 semitones above C1
602 // With 4 bins per octave: bin = log2(440/32.703) * 4 ≈ 15
603 // C1=0, C2=4, C3=8, C4=12, A4≈15
604 let bin = freq_to_bin(440.0);
605 assert!(
606 bin >= 14 && bin <= 16,
607 "A4 should be around bin 15, got {}",
608 bin
609 );
610 }
611
612 #[test]
613 fn test_freq_to_bin_bounds() {
614 assert_eq!(freq_to_bin(0.0), 0);
615 assert_eq!(freq_to_bin(-100.0), 0);
616 assert_eq!(freq_to_bin(20000.0), SPECTRUM_BINS - 1);
617 }
618
619 #[test]
620 fn test_waveform_phase_wrapping() {
621 let mut synth = WaveformSynthesizer::new();
622
623 // Create channel states with very high frequency
624 let mut regs = [0u8; 16];
625 regs[0] = 1; // Very low period = very high frequency
626 regs[7] = 0x3E; // Tone A enabled
627 regs[8] = 0x0F; // Max amplitude
628
629 let states = ChannelStates::from_registers(®s);
630 synth.update(&states);
631
632 // Phase should always be in [0, 1)
633 assert!(synth.phase[0] >= 0.0 && synth.phase[0] < 1.0);
634 }
635
636 #[test]
637 fn test_spectrum_decay() {
638 let mut analyzer = SpectrumAnalyzer::new();
639
640 // Set up a tone on channel A
641 let mut regs = [0u8; 16];
642 regs[0] = 0x1C;
643 regs[1] = 0x01; // Period 284 ≈ 440Hz
644 regs[7] = 0x3E;
645 regs[8] = 0x0F;
646
647 let states = ChannelStates::from_registers(®s);
648 analyzer.update(&states);
649
650 let initial_bin = freq_to_bin(440.0);
651 let initial_value = analyzer.spectrum[0][initial_bin];
652 assert!(initial_value > 0.0);
653
654 // Now update with silence
655 let silent_regs = [0u8; 16];
656 let silent_states = ChannelStates::from_registers(&silent_regs);
657 analyzer.update(&silent_states);
658
659 // Value should have decayed but not be zero
660 let decayed_value = analyzer.spectrum[0][initial_bin];
661 assert!(decayed_value > 0.0);
662 assert!(decayed_value < initial_value);
663 assert!((decayed_value - initial_value * SPECTRUM_DECAY).abs() < 0.01);
664 }
665}