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(&registers);
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(&regs);
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(&regs);
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}