Skip to main content

rill_lofi/
lofi_processor.rs

1use crate::config::{ClassicSystem, LofiConfig};
2use crate::dsp;
3use rill_core::prelude::*;
4
5/// Applies lo-fi audio effects (bitcrushing, sample-rate reduction, vintage noise,
6/// DAC emulation, and delay) to emulate classic digital audio systems.
7pub struct LofiProcessor<const BUF_SIZE: usize> {
8    state: NodeState<f32, BUF_SIZE>,
9    id: NodeId,
10    metadata: NodeMetadata,
11    inputs: Vec<Port<f32, BUF_SIZE>>,
12    outputs: Vec<Port<f32, BUF_SIZE>>,
13
14    config: LofiConfig,
15    delay_buffer: Vec<f32>,
16    delay_write_pos: usize,
17    last_sample: f32,
18    sample_hold_counter: usize,
19    reduction_factor: usize,
20}
21
22impl<const BUF_SIZE: usize> LofiProcessor<BUF_SIZE> {
23    /// Creates a new `LofiProcessor` with the given configuration.
24    pub fn new(config: LofiConfig) -> Self {
25        let buffer_size = match config.system {
26            ClassicSystem::Nes => 256,
27            ClassicSystem::Commodore64 => 512,
28            ClassicSystem::AkaiS900 => 4096,
29            ClassicSystem::FairlightCMI => 2048,
30            _ => 1024,
31        };
32
33        let metadata = Self::build_metadata(&config);
34        let id = NodeId(0);
35        let state = NodeState::new(44100.0);
36
37        let inputs = vec![Port::input(id, 0, "signal_in")];
38        let outputs = vec![Port::output(id, 0, "signal_out")];
39
40        Self {
41            state,
42            id,
43            metadata,
44            inputs,
45            outputs,
46            config,
47            delay_buffer: vec![0.0; buffer_size],
48            delay_write_pos: 0,
49            last_sample: 0.0,
50            sample_hold_counter: 0,
51            reduction_factor: 1,
52        }
53    }
54
55    /// Creates a new `LofiProcessor` configured for a specific classic system.
56    pub fn for_system(system: ClassicSystem) -> Self {
57        Self::new(LofiConfig::for_system(system))
58    }
59
60    /// Processes a single audio sample through the lo-fi effect chain.
61    pub fn process_sample(&mut self, input: f32) -> f32 {
62        let mut sample = input;
63
64        if self.config.enable_sr_reduction {
65            let target_sr = self.config.system.get_sample_rate();
66            self.reduction_factor =
67                dsp::quantization::calculate_reduction_factor(self.state.sample_rate, target_sr);
68        }
69
70        if self.config.enable_bitcrush {
71            let bit_depth = self.config.system.get_bit_depth();
72            sample = dsp::quantization::bitcrush(sample, bit_depth, true);
73        }
74
75        if self.config.enable_sr_reduction && self.reduction_factor > 1 {
76            sample = dsp::quantization::sample_rate_reduce(
77                sample,
78                self.reduction_factor,
79                &mut self.last_sample,
80                &mut self.sample_hold_counter,
81            );
82        }
83
84        if self.config.enable_noise {
85            sample = dsp::noise::system_noise(self.config.system, sample);
86        }
87
88        sample = dsp::dac_emulation::for_system(self.config.system, sample);
89
90        if !self.delay_buffer.is_empty() {
91            self.delay_buffer[self.delay_write_pos] = sample;
92            let read_pos = if self.delay_write_pos >= 256 {
93                self.delay_write_pos - 256
94            } else {
95                self.delay_buffer.len() + self.delay_write_pos - 256
96            };
97            let delayed = self.delay_buffer[read_pos];
98            sample = sample * 0.7 + delayed * 0.3;
99            self.delay_write_pos = (self.delay_write_pos + 1) % self.delay_buffer.len();
100        }
101
102        let wet = sample * self.config.dry_wet;
103        let dry = input * (1.0 - self.config.dry_wet);
104
105        let mut out = wet + dry;
106        out -= self.config.dc_offset;
107        out *= self.config.output_gain;
108        out = out.clamp(-self.config.output_ceiling, self.config.output_ceiling);
109        out
110    }
111
112    /// Resets the internal delay buffer to silence.
113    pub fn clear_delay_buffer(&mut self) {
114        self.delay_buffer.fill(0.0);
115        self.delay_write_pos = 0;
116    }
117
118    /// Returns the total sample count and current time in seconds.
119    pub fn stats(&self) -> (u64, f32) {
120        (
121            self.state.sample_pos,
122            self.state.current_time_seconds() as f32,
123        )
124    }
125
126    fn build_metadata(config: &LofiConfig) -> NodeMetadata {
127        let system_name = match config.system {
128            ClassicSystem::Nes => "NES Emulator",
129            ClassicSystem::Commodore64 => "Commodore 64 SID",
130            ClassicSystem::AkaiS900 => "Akai S900 Sampler",
131            ClassicSystem::FairlightCMI => "Fairlight CMI",
132            ClassicSystem::Custom { .. } => "Custom Lo-Fi",
133            _ => "Lo-Fi Processor",
134        };
135
136        let description = match config.system {
137            ClassicSystem::Nes => "Nintendo Entertainment System sound chip".to_string(),
138            ClassicSystem::Commodore64 => "Commodore 64 SID chip".to_string(),
139            ClassicSystem::AkaiS900 => "Akai S900 12-bit sampler".to_string(),
140            ClassicSystem::FairlightCMI => "Fairlight CMI (first digital sampler)".to_string(),
141            ClassicSystem::Custom {
142                bit_depth,
143                sample_rate,
144                ..
145            } => format!("Custom {}-bit at {} Hz", bit_depth, sample_rate),
146            _ => "vintage digital audio system".to_string(),
147        };
148
149        NodeMetadata {
150            name: system_name.to_string(),
151
152            type_name: None,
153            category: NodeCategory::Processor,
154            description,
155            author: "Rill Lo-Fi".to_string(),
156            version: "0.2.0".to_string(),
157            signal_inputs: 1,
158            signal_outputs: 1,
159            control_inputs: 0,
160            control_outputs: 0,
161            clock_inputs: 0,
162            clock_outputs: 0,
163            feedback_ports: 0,
164            parameters: vec![
165                ParamMetadata::new(
166                    "system",
167                    ParamType::Choice,
168                    ParamValue::Choice("NES".to_string()),
169                )
170                .with_description("Classic system to emulate")
171                .with_choices(vec![
172                    ("NES".to_string(), 0.0),
173                    ("Commodore64".to_string(), 1.0),
174                    ("AkaiS900".to_string(), 2.0),
175                    ("FairlightCMI".to_string(), 3.0),
176                    ("Custom".to_string(), 4.0),
177                ]),
178                ParamMetadata::new("bit_depth", ParamType::Int, ParamValue::Int(8))
179                    .with_description("Bit depth for quantization")
180                    .with_range(1.0, 16.0, 1.0)
181                    .with_unit("bits"),
182                ParamMetadata::new("dry_wet", ParamType::Float, ParamValue::Float(1.0))
183                    .with_description("Dry/wet mix")
184                    .with_range(0.0, 1.0, 0.01),
185                ParamMetadata::new("output_gain", ParamType::Float, ParamValue::Float(1.0))
186                    .with_description("Output gain")
187                    .with_range(0.0, 4.0, 0.1),
188                ParamMetadata::new("dc_offset", ParamType::Float, ParamValue::Float(0.0))
189                    .with_description("DC offset correction (subtracted after gain)")
190                    .with_range(-1.0, 1.0, 0.01),
191                ParamMetadata::new("output_ceiling", ParamType::Float, ParamValue::Float(1.0))
192                    .with_description("Hard clamp ceiling (±value)")
193                    .with_range(0.0, 1.0, 0.01),
194                ParamMetadata::new("enable_bitcrush", ParamType::Bool, ParamValue::Bool(true))
195                    .with_description("Enable bitcrushing"),
196                ParamMetadata::new(
197                    "enable_sr_reduction",
198                    ParamType::Bool,
199                    ParamValue::Bool(true),
200                )
201                .with_description("Enable sample rate reduction"),
202                ParamMetadata::new("enable_noise", ParamType::Bool, ParamValue::Bool(true))
203                    .with_description("Enable vintage noise"),
204            ],
205        }
206    }
207}
208
209impl<const BUF_SIZE: usize> Node<f32, BUF_SIZE> for LofiProcessor<BUF_SIZE> {
210    fn metadata(&self) -> NodeMetadata {
211        self.metadata.clone()
212    }
213
214    fn node_type_id(&self) -> NodeTypeId {
215        NodeTypeId::of::<Self>()
216    }
217
218    fn init(&mut self, sample_rate: f32) {
219        self.state = NodeState::new(sample_rate);
220        self.last_sample = 0.0;
221        self.sample_hold_counter = 0;
222        self.clear_delay_buffer();
223
224        if let ClassicSystem::Custom {
225            sample_rate: ref mut field_sr,
226            ..
227        } = self.config.system
228        {
229            *field_sr = sample_rate;
230        }
231
232        if self.config.enable_sr_reduction {
233            let target_sr = self.config.system.get_sample_rate();
234            self.reduction_factor =
235                dsp::quantization::calculate_reduction_factor(sample_rate, target_sr);
236        }
237    }
238
239    fn reset(&mut self) {
240        self.state.reset();
241        self.last_sample = 0.0;
242        self.sample_hold_counter = 0;
243        self.clear_delay_buffer();
244    }
245
246    fn get_parameter(&self, id: &ParameterId) -> Option<ParamValue> {
247        match id.as_str() {
248            "bit_depth" => Some(ParamValue::Int(self.config.system.get_bit_depth() as i32)),
249            "sample_rate" => Some(ParamValue::Float(self.config.system.get_sample_rate())),
250            "dry_wet" => Some(ParamValue::Float(self.config.dry_wet)),
251            "output_gain" => Some(ParamValue::Float(self.config.output_gain)),
252            "dc_offset" => Some(ParamValue::Float(self.config.dc_offset)),
253            "output_ceiling" => Some(ParamValue::Float(self.config.output_ceiling)),
254            "enable_bitcrush" => Some(ParamValue::Bool(self.config.enable_bitcrush)),
255            "enable_sr_reduction" => Some(ParamValue::Bool(self.config.enable_sr_reduction)),
256            "enable_noise" => Some(ParamValue::Bool(self.config.enable_noise)),
257            "system" => {
258                let name = match self.config.system {
259                    ClassicSystem::Nes => "NES",
260                    ClassicSystem::Commodore64 => "Commodore64",
261                    ClassicSystem::AkaiS900 => "AkaiS900",
262                    ClassicSystem::FairlightCMI => "FairlightCMI",
263                    ClassicSystem::Custom { .. } => "Custom",
264                    _ => "Unknown",
265                };
266                Some(ParamValue::Choice(name.to_string()))
267            }
268            _ => None,
269        }
270    }
271
272    fn set_parameter(&mut self, id: &ParameterId, value: ParamValue) -> ProcessResult<()> {
273        match id.as_str() {
274            "bit_depth" => {
275                if let ParamValue::Int(v) = value {
276                    if let ClassicSystem::Custom {
277                        ref mut bit_depth, ..
278                    } = self.config.system
279                    {
280                        *bit_depth = v as u8;
281                        return Ok(());
282                    }
283                }
284                Err(ProcessError::parameter(
285                    "Cannot change bit_depth of fixed system",
286                ))
287            }
288            "sample_rate" => {
289                if let ParamValue::Float(v) = value {
290                    if let ClassicSystem::Custom {
291                        ref mut sample_rate,
292                        ..
293                    } = self.config.system
294                    {
295                        *sample_rate = v.clamp(8000.0, 192000.0);
296                        return Ok(());
297                    }
298                }
299                Err(ProcessError::parameter(
300                    "Cannot change sample_rate of fixed system",
301                ))
302            }
303            "dry_wet" => {
304                if let ParamValue::Float(v) = value {
305                    self.config.dry_wet = v.clamp(0.0, 1.0);
306                    return Ok(());
307                }
308                Err(ProcessError::parameter("dry_wet must be a float"))
309            }
310            "output_gain" => {
311                if let ParamValue::Float(v) = value {
312                    self.config.output_gain = v.clamp(0.0, 4.0);
313                    return Ok(());
314                }
315                Err(ProcessError::parameter("output_gain must be a float"))
316            }
317            "dc_offset" => {
318                if let ParamValue::Float(v) = value {
319                    self.config.dc_offset = v.clamp(-1.0, 1.0);
320                    return Ok(());
321                }
322                Err(ProcessError::parameter("dc_offset must be a float"))
323            }
324            "output_ceiling" => {
325                if let ParamValue::Float(v) = value {
326                    self.config.output_ceiling = v.clamp(0.0, 1.0);
327                    return Ok(());
328                }
329                Err(ProcessError::parameter("output_ceiling must be a float"))
330            }
331            "enable_bitcrush" => {
332                if let ParamValue::Bool(v) = value {
333                    self.config.enable_bitcrush = v;
334                    return Ok(());
335                }
336                Err(ProcessError::parameter("enable_bitcrush must be a bool"))
337            }
338            "enable_sr_reduction" => {
339                if let ParamValue::Bool(v) = value {
340                    self.config.enable_sr_reduction = v;
341                    return Ok(());
342                }
343                Err(ProcessError::parameter(
344                    "enable_sr_reduction must be a bool",
345                ))
346            }
347            "enable_noise" => {
348                if let ParamValue::Bool(v) = value {
349                    self.config.enable_noise = v;
350                    return Ok(());
351                }
352                Err(ProcessError::parameter("enable_noise must be a bool"))
353            }
354            _ => Err(ProcessError::parameter(format!(
355                "Unknown parameter: {}",
356                id
357            ))),
358        }
359    }
360
361    fn id(&self) -> NodeId {
362        self.id
363    }
364
365    fn set_id(&mut self, id: NodeId) {
366        self.id = id;
367    }
368
369    fn input_port(&self, index: usize) -> Option<&Port<f32, BUF_SIZE>> {
370        self.inputs.get(index)
371    }
372
373    fn input_port_mut(&mut self, index: usize) -> Option<&mut Port<f32, BUF_SIZE>> {
374        self.inputs.get_mut(index)
375    }
376
377    fn output_port(&self, index: usize) -> Option<&Port<f32, BUF_SIZE>> {
378        self.outputs.get(index)
379    }
380
381    fn output_port_mut(&mut self, index: usize) -> Option<&mut Port<f32, BUF_SIZE>> {
382        self.outputs.get_mut(index)
383    }
384
385    fn control_port(&self, _index: usize) -> Option<&Port<f32, BUF_SIZE>> {
386        None
387    }
388
389    fn control_port_mut(&mut self, _index: usize) -> Option<&mut Port<f32, BUF_SIZE>> {
390        None
391    }
392
393    fn state(&self) -> &NodeState<f32, BUF_SIZE> {
394        &self.state
395    }
396
397    fn state_mut(&mut self) -> &mut NodeState<f32, BUF_SIZE> {
398        &mut self.state
399    }
400
401    fn num_signal_inputs(&self) -> usize {
402        1
403    }
404
405    fn num_signal_outputs(&self) -> usize {
406        1
407    }
408}
409
410impl<const BUF_SIZE: usize> Processor<f32, BUF_SIZE> for LofiProcessor<BUF_SIZE> {
411    fn process(
412        &mut self,
413        _ctx: &RenderContext,
414        signal_inputs: &[&[f32; BUF_SIZE]],
415        _control_inputs: &[f32],
416        _clock_inputs: &[RenderContext],
417        _feedback_inputs: &[&[f32; BUF_SIZE]],
418    ) -> ProcessResult<()> {
419        if signal_inputs.is_empty() {
420            return Ok(());
421        }
422
423        let input = signal_inputs[0];
424        for (i, sample) in input.iter().enumerate() {
425            self.outputs[0].write()[i] = self.process_sample(*sample);
426        }
427
428        Ok(())
429    }
430
431    fn latency(&self) -> usize {
432        0
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    fn build_param_id(name: &str) -> ParameterId {
441        ParameterId::new(name).unwrap()
442    }
443
444    fn approx_eq(a: f32, b: f32, eps: f32) -> bool {
445        (a - b).abs() < eps
446    }
447
448    #[test]
449    fn test_lofi_processor_process_basic() {
450        let mut processor = LofiProcessor::<64>::new(LofiConfig::default());
451
452        processor
453            .set_parameter(&build_param_id("enable_bitcrush"), ParamValue::Bool(false))
454            .unwrap();
455        processor
456            .set_parameter(
457                &build_param_id("enable_sr_reduction"),
458                ParamValue::Bool(false),
459            )
460            .unwrap();
461        processor
462            .set_parameter(&build_param_id("enable_noise"), ParamValue::Bool(false))
463            .unwrap();
464        processor
465            .set_parameter(&build_param_id("dry_wet"), ParamValue::Float(0.0))
466            .unwrap();
467        processor
468            .set_parameter(&build_param_id("output_gain"), ParamValue::Float(0.8))
469            .unwrap();
470        processor.init(44100.0);
471
472        let mut input = [0.0f32; 64];
473        for (i, slot) in input.iter_mut().enumerate() {
474            *slot = (i as f32 / 64.0 * std::f32::consts::TAU).sin() * 0.5;
475        }
476
477        let ctx = RenderContext::new(0, 64, 44100.0);
478        processor.process(&ctx, &[&input], &[], &[], &[]).unwrap();
479
480        let output = *processor.outputs[0].read();
481        for i in 0..64 {
482            let expected = input[i] * 0.8;
483            assert!(
484                approx_eq(output[i], expected, 0.001),
485                "Mismatch at {}: got {}, expected {}",
486                i,
487                output[i],
488                expected
489            );
490        }
491
492        let (_samples, _time) = processor.stats();
493    }
494
495    #[test]
496    fn test_lofi_processor_with_bitcrush() {
497        let mut processor = LofiProcessor::<64>::new(LofiConfig::default());
498
499        processor
500            .set_parameter(
501                &build_param_id("enable_sr_reduction"),
502                ParamValue::Bool(false),
503            )
504            .unwrap();
505        processor
506            .set_parameter(&build_param_id("enable_noise"), ParamValue::Bool(false))
507            .unwrap();
508        processor
509            .set_parameter(&build_param_id("enable_bitcrush"), ParamValue::Bool(true))
510            .unwrap();
511        processor
512            .set_parameter(&build_param_id("dry_wet"), ParamValue::Float(0.0))
513            .unwrap();
514        processor.init(44100.0);
515
516        let input = [0.5f32; 64];
517        let ctx = RenderContext::new(0, 64, 44100.0);
518        processor.process(&ctx, &[&input], &[], &[], &[]).unwrap();
519
520        let output = *processor.outputs[0].read();
521        for &sample in output.iter() {
522            assert!(
523                (0.49..=0.51).contains(&sample),
524                "Bitcrush should not radically change value 0.5"
525            );
526        }
527    }
528
529    #[test]
530    fn test_lofi_processor_dry_wet() {
531        let mut processor = LofiProcessor::<64>::new(LofiConfig::default());
532
533        processor
534            .set_parameter(&build_param_id("enable_bitcrush"), ParamValue::Bool(true))
535            .unwrap();
536        processor
537            .set_parameter(
538                &build_param_id("enable_sr_reduction"),
539                ParamValue::Bool(false),
540            )
541            .unwrap();
542        processor
543            .set_parameter(&build_param_id("enable_noise"), ParamValue::Bool(false))
544            .unwrap();
545        processor
546            .set_parameter(&build_param_id("dry_wet"), ParamValue::Float(0.0))
547            .unwrap();
548        processor.init(44100.0);
549
550        let input_val = 0.75f32;
551        let input = [input_val; 64];
552        let ctx = RenderContext::new(0, 64, 44100.0);
553        processor.process(&ctx, &[&input], &[], &[], &[]).unwrap();
554
555        let output = *processor.outputs[0].read();
556        assert!(
557            approx_eq(output[0], input_val, 0.001),
558            "With dry_wet=0, output should equal input"
559        );
560    }
561
562    #[test]
563    fn test_lofi_processor_clear_delay() {
564        let mut processor = LofiProcessor::<64>::new(LofiConfig::default());
565        processor
566            .set_parameter(&build_param_id("enable_bitcrush"), ParamValue::Bool(false))
567            .unwrap();
568        processor
569            .set_parameter(
570                &build_param_id("enable_sr_reduction"),
571                ParamValue::Bool(false),
572            )
573            .unwrap();
574        processor
575            .set_parameter(&build_param_id("enable_noise"), ParamValue::Bool(false))
576            .unwrap();
577        processor.clear_delay_buffer();
578        let input = [0.0f32; 64];
579        let ctx = RenderContext::new(0, 64, 44100.0);
580        processor.process(&ctx, &[&input], &[], &[], &[]).unwrap();
581        let output = *processor.outputs[0].read();
582        for &sample in output.iter() {
583            assert!(approx_eq(sample, 0.0, 0.001));
584        }
585    }
586
587    #[test]
588    fn test_lofi_processor_empty_input() {
589        let mut processor = LofiProcessor::<64>::new(LofiConfig::default());
590        let ctx = RenderContext::new(0, 64, 44100.0);
591        let result = processor.process(&ctx, &[], &[], &[], &[]);
592        assert!(result.is_ok());
593    }
594
595    #[test]
596    fn test_lofi_processor_parameter_validation() {
597        let mut processor = LofiProcessor::<64>::new(LofiConfig::default());
598
599        let result =
600            processor.set_parameter(&build_param_id("output_gain"), ParamValue::Float(-1.0));
601        assert!(result.is_ok());
602        let val = processor
603            .get_parameter(&build_param_id("output_gain"))
604            .unwrap();
605        assert_eq!(val.as_f32(), Some(0.0));
606
607        let result =
608            processor.set_parameter(&build_param_id("output_gain"), ParamValue::Float(10.0));
609        assert!(result.is_ok());
610        let val = processor
611            .get_parameter(&build_param_id("output_gain"))
612            .unwrap();
613        assert_eq!(val.as_f32(), Some(4.0));
614
615        let result =
616            processor.set_parameter(&build_param_id("unknown_param"), ParamValue::Float(0.5));
617        assert!(result.is_err());
618    }
619
620    #[test]
621    fn test_lofi_processor_metadata() {
622        let processor = LofiProcessor::<64>::new(LofiConfig::default());
623        let meta = processor.metadata();
624        assert_eq!(meta.signal_inputs, 1);
625        assert_eq!(meta.signal_outputs, 1);
626        assert_eq!(meta.category, NodeCategory::Processor);
627        assert!(!meta.name.is_empty());
628    }
629
630    #[test]
631    fn test_lofi_processor_for_system() {
632        let processor = LofiProcessor::<64>::for_system(ClassicSystem::Nes);
633        let meta = processor.metadata();
634        assert!(!meta.name.is_empty());
635        assert_eq!(meta.signal_inputs, 1);
636        assert_eq!(meta.signal_outputs, 1);
637        let default_gain = processor
638            .get_parameter(&build_param_id("output_gain"))
639            .unwrap();
640        assert_eq!(default_gain.as_f32(), Some(1.0));
641    }
642
643    #[test]
644    fn test_lofi_processor_init_reset() {
645        let mut processor = LofiProcessor::<64>::new(LofiConfig::default());
646        processor.init(48000.0);
647        assert!(approx_eq(processor.state.sample_rate, 48000.0, 0.001));
648
649        let input = [0.5f32; 64];
650        let ctx = RenderContext::new(0, 64, 44100.0);
651        processor.process(&ctx, &[&input], &[], &[], &[]).unwrap();
652
653        processor.reset();
654        assert_eq!(processor.state.sample_pos, 0);
655        assert_eq!(processor.state.blocks_processed, 0);
656    }
657
658    #[test]
659    fn test_dc_offset_removal() {
660        let config = LofiConfig {
661            enable_bitcrush: false,
662            enable_sr_reduction: false,
663            enable_noise: false,
664            dc_offset: 0.5,
665            dry_wet: 1.0,
666            output_gain: 1.0,
667            ..Default::default()
668        };
669        let mut processor = LofiProcessor::<1>::new(config);
670
671        // Feed constant 1.0 — with offset 0.5 applied, output should be reduced by ~0.5
672        // (exact value depends on DAC emulation, which always runs)
673        let s = processor.process_sample(1.0);
674        // Without offset: ~0.77 (dac * delay * gain). With offset 0.5: ~0.27
675        assert!(
676            s < 0.5,
677            "offset should reduce output below 0.5, got {:.3}",
678            s
679        );
680        assert!(
681            s > 0.0,
682            "positive input should stay positive after offset, got {:.3}",
683            s
684        );
685    }
686
687    #[test]
688    fn test_output_ceiling_clamp() {
689        let config = LofiConfig {
690            enable_bitcrush: false,
691            enable_sr_reduction: false,
692            enable_noise: false,
693            output_ceiling: 0.8,
694            dry_wet: 1.0,
695            output_gain: 2.0, // 1.0 * 2.0 = 2.0 → clamp to 0.8
696            ..Default::default()
697        };
698        let mut processor = LofiProcessor::<1>::new(config);
699
700        let sample = processor.process_sample(1.0);
701        assert!(sample <= 0.8, "should be ≤ 0.8, got {:.3}", sample);
702        assert!(sample >= -0.8, "should be ≥ -0.8, got {:.3}", sample);
703    }
704
705    #[test]
706    fn test_dc_offset_and_ceiling_combined() {
707        let config = LofiConfig {
708            enable_bitcrush: false,
709            enable_sr_reduction: false,
710            enable_noise: false,
711            dc_offset: 0.5,
712            output_gain: 2.0,
713            output_ceiling: 0.5,
714            dry_wet: 1.0,
715            ..Default::default()
716        };
717        let mut processor = LofiProcessor::<1>::new(config);
718
719        // Input 1.0 → max gain 2.0 + offset 0.5 should exceed ceiling 0.5 → clamped
720        let sample = processor.process_sample(1.0);
721        assert!(
722            sample.abs() <= 0.5,
723            "ceiling should clamp to ±0.5, got {:.3}",
724            sample
725        );
726    }
727}