1use crate::config::{ClassicSystem, LofiConfig};
2use crate::dsp;
3use rill_core::prelude::*;
4
5pub 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 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 pub fn for_system(system: ClassicSystem) -> Self {
57 Self::new(LofiConfig::for_system(system))
58 }
59
60 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 pub fn clear_delay_buffer(&mut self) {
114 self.delay_buffer.fill(0.0);
115 self.delay_write_pos = 0;
116 }
117
118 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 let s = processor.process_sample(1.0);
674 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, ..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 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}