1use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
2
3use crate::info::ParamInfo;
4use crate::sample::Float;
5use crate::smooth::{Smoother, SmoothingStyle};
6
7pub struct AtomicF64 {
9 bits: AtomicU64,
10}
11
12impl AtomicF64 {
13 pub fn new(value: f64) -> Self {
14 Self {
15 bits: AtomicU64::new(value.to_bits()),
16 }
17 }
18
19 #[inline]
20 pub fn load(&self) -> f64 {
21 f64::from_bits(self.bits.load(Ordering::Relaxed))
22 }
23
24 #[inline]
25 pub fn store(&self, value: f64) {
26 self.bits.store(value.to_bits(), Ordering::Relaxed);
27 }
28}
29
30pub struct FloatParam {
32 pub info: ParamInfo,
33 value: AtomicF64,
35 mod_amount: AtomicF64,
38 pub smoother: Smoother,
39}
40
41impl FloatParam {
42 #[must_use]
43 pub fn new(info: ParamInfo, smoothing: SmoothingStyle) -> Self {
44 let default = info.default_plain;
45 let (lo, hi) = (info.range.min(), info.range.max());
52 debug_assert!(
53 lo.is_finite() && hi.is_finite() && lo <= hi,
54 "FloatParam range bounds must be finite and ordered (min <= max); \
55 got [{lo}, {hi}] - check the `range = \"...\"` attribute"
56 );
57 debug_assert!(
64 default.is_finite() && default >= lo.min(hi) && default <= lo.max(hi),
65 "FloatParam default {default} is outside range [{lo}, {hi}] or non-finite"
66 );
67 let default = if default.is_finite() {
68 default.clamp(lo.min(hi), lo.max(hi))
69 } else {
70 lo.min(hi)
71 };
72 let smoother = Smoother::new(smoothing);
73 smoother.snap(default);
74 Self {
75 info,
76 value: AtomicF64::new(default),
77 mod_amount: AtomicF64::new(0.0),
78 smoother,
79 }
80 }
81
82 #[inline]
88 pub fn set_value(&self, v: f64) {
89 if !v.is_finite() {
90 return;
91 }
92 let (lo, hi) = (self.info.range.min(), self.info.range.max());
98 self.value.store(v.clamp(lo.min(hi), lo.max(hi)));
99 }
100
101 #[inline]
103 pub fn set_mod_amount(&self, amount: f64) {
104 let a = if amount.is_finite() { amount } else { 0.0 };
105 self.mod_amount.store(a);
106 }
107
108 #[inline]
110 #[must_use]
111 pub fn mod_amount(&self) -> f64 {
112 self.mod_amount.load()
113 }
114
115 #[inline]
117 #[must_use]
118 pub fn effective_target(&self) -> f64 {
119 let (a, b) = (self.info.range.min(), self.info.range.max());
120 let (lo, hi) = (a.min(b), a.max(b));
121 (self.value.load() + self.mod_amount.load()).clamp(lo, hi)
122 }
123
124 #[doc(hidden)]
127 #[inline]
128 pub fn raw_target(&self) -> f64 {
129 self.value.load()
130 }
131
132 #[doc(hidden)]
134 #[inline]
135 pub fn raw_smoothed_next(&self) -> f32 {
136 self.smoother.next(self.effective_target())
137 }
138
139 #[doc(hidden)]
141 #[inline]
142 pub fn raw_smoothed_current(&self) -> f32 {
143 self.smoother.current()
144 }
145
146 #[doc(hidden)]
148 #[inline]
149 pub fn raw_smoothed_next_into(&self, out: &mut [f32]) {
150 self.smoother.next_into(self.effective_target(), out);
151 }
152
153 #[doc(hidden)]
155 #[inline]
156 pub fn raw_smoothed_next_after(&self, n_samples: usize) -> f32 {
157 self.smoother.next_after(self.effective_target(), n_samples)
158 }
159
160 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
164 #[inline]
165 pub fn value_usize(&self) -> usize {
166 let v = self.value.load().round();
167 if v <= 0.0 { 0 } else { v as usize }
168 }
169
170 #[allow(clippy::cast_possible_truncation)]
173 #[inline]
174 pub fn value_i32(&self) -> i32 {
175 self.value.load().round() as i32
176 }
177
178 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
181 #[inline]
182 pub fn value_u8(&self) -> u8 {
183 let v = self.value.load().round();
184 if v <= 0.0 {
185 0
186 } else if v >= 255.0 {
187 255
188 } else {
189 v as u8
190 }
191 }
192
193 #[inline]
212 #[must_use]
213 pub fn is_smoothing(&self) -> bool {
214 !self.smoother.is_converged(self.effective_target())
215 }
216
217 pub fn id(&self) -> u32 {
219 self.info.id
220 }
221}
222
223pub trait FloatParamReadF32 {
241 #[must_use]
243 fn read(&self) -> f32;
244
245 fn read_into(&self, out: &mut [f32]);
261
262 #[must_use]
271 fn read_after(&self, n_samples: usize) -> f32;
272
273 #[must_use]
275 fn current(&self) -> f32;
276
277 #[must_use]
281 fn value(&self) -> f32;
282}
283
284pub trait FloatParamReadF64 {
287 #[must_use]
288 fn read(&self) -> f64;
289 fn read_into(&self, out: &mut [f64]);
292 #[must_use]
295 fn read_after(&self, n_samples: usize) -> f64;
296 #[must_use]
297 fn current(&self) -> f64;
298 #[must_use]
299 fn value(&self) -> f64;
300}
301
302impl FloatParamReadF32 for FloatParam {
303 #[inline]
304 fn read(&self) -> f32 {
305 self.raw_smoothed_next()
306 }
307
308 #[inline]
309 fn read_into(&self, out: &mut [f32]) {
310 self.raw_smoothed_next_into(out);
311 }
312
313 #[inline]
314 fn read_after(&self, n_samples: usize) -> f32 {
315 self.raw_smoothed_next_after(n_samples)
316 }
317
318 #[inline]
319 fn current(&self) -> f32 {
320 self.raw_smoothed_current()
321 }
322
323 #[inline]
324 fn value(&self) -> f32 {
325 f32::from_f64(self.raw_target())
326 }
327}
328
329impl FloatParamReadF64 for FloatParam {
330 #[inline]
331 fn read(&self) -> f64 {
332 f64::from(self.raw_smoothed_next())
333 }
334
335 #[inline]
336 fn read_into(&self, out: &mut [f64]) {
337 const SCRATCH: usize = 1024;
342 let mut scratch = [0.0_f32; SCRATCH];
343 let mut remaining = out;
344 while !remaining.is_empty() {
345 let take = remaining.len().min(SCRATCH);
346 self.raw_smoothed_next_into(&mut scratch[..take]);
347 for (dst, &src) in remaining[..take].iter_mut().zip(&scratch[..take]) {
348 *dst = f64::from(src);
349 }
350 remaining = &mut remaining[take..];
351 }
352 }
353
354 #[inline]
355 fn read_after(&self, n_samples: usize) -> f64 {
356 f64::from(self.raw_smoothed_next_after(n_samples))
357 }
358
359 #[inline]
360 fn current(&self) -> f64 {
361 f64::from(self.raw_smoothed_current())
362 }
363
364 #[inline]
365 fn value(&self) -> f64 {
366 self.raw_target()
367 }
368}
369
370pub struct BoolParam {
372 pub info: ParamInfo,
373 value: AtomicBool,
374}
375
376impl BoolParam {
377 #[must_use]
384 pub fn new(info: ParamInfo) -> Self {
385 let default = match info.default_plain {
386 0.0 => false,
387 1.0 => true,
388 other => panic!(
389 "BoolParam '{}' default {} must be exactly 0.0 (false) \
390 or 1.0 (true) - bool params have no halfway value",
391 info.name, other,
392 ),
393 };
394 Self {
395 info,
396 value: AtomicBool::new(default),
397 }
398 }
399
400 pub fn value(&self) -> bool {
401 self.value.load(Ordering::Relaxed)
402 }
403
404 pub fn set_value(&self, v: bool) {
405 self.value.store(v, Ordering::Relaxed);
406 }
407
408 pub fn id(&self) -> u32 {
409 self.info.id
410 }
411}
412
413pub struct IntParam {
415 pub info: ParamInfo,
416 value: AtomicI64,
417}
418
419impl IntParam {
420 #[allow(
434 clippy::float_cmp,
435 clippy::cast_possible_truncation,
436 clippy::cast_precision_loss
437 )]
438 #[must_use]
439 pub fn new(info: ParamInfo) -> Self {
440 let default = info.default_plain;
441 assert!(
442 default.is_finite(),
443 "IntParam '{}' default {} is not finite",
444 info.name,
445 default,
446 );
447 let truncated = default as i64;
448 assert!(
449 truncated as f64 == default,
450 "IntParam '{}' default {} doesn't round-trip through i64 \
451 - supply an integer-valued default in the derive attribute",
452 info.name,
453 default,
454 );
455 let (lo, hi) = (info.range.min() as i64, info.range.max() as i64);
456 assert!(
457 truncated >= lo && truncated <= hi,
458 "IntParam '{}' default {} is outside range [{}, {}]",
459 info.name,
460 truncated,
461 lo,
462 hi,
463 );
464 Self {
465 info,
466 value: AtomicI64::new(truncated),
467 }
468 }
469
470 pub fn value(&self) -> i64 {
471 self.value.load(Ordering::Relaxed)
472 }
473
474 #[allow(clippy::cast_precision_loss)]
477 #[inline]
478 pub fn value_f32(&self) -> f32 {
479 self.value.load(Ordering::Relaxed) as f32
480 }
481
482 #[allow(clippy::cast_precision_loss)]
484 #[inline]
485 pub fn value_f64(&self) -> f64 {
486 self.value.load(Ordering::Relaxed) as f64
487 }
488
489 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
492 #[inline]
493 pub fn value_usize(&self) -> usize {
494 let v = self.value.load(Ordering::Relaxed);
495 if v <= 0 { 0 } else { v as usize }
496 }
497
498 #[allow(clippy::cast_possible_truncation)]
500 #[inline]
501 pub fn value_i32(&self) -> i32 {
502 self.value
503 .load(Ordering::Relaxed)
504 .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32
505 }
506
507 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
509 #[inline]
510 pub fn value_u8(&self) -> u8 {
511 self.value.load(Ordering::Relaxed).clamp(0, 255) as u8
512 }
513
514 #[allow(clippy::cast_possible_truncation)]
520 pub fn set_value(&self, v: i64) {
521 let (lo, hi) = (self.info.range.min() as i64, self.info.range.max() as i64);
522 self.value
523 .store(v.clamp(lo.min(hi), lo.max(hi)), Ordering::Relaxed);
524 }
525
526 pub fn id(&self) -> u32 {
527 self.info.id
528 }
529}
530
531pub trait ParamEnum: crate::__private::Sealed + Clone + Copy + Send + Sync + 'static {
533 fn from_index(index: usize) -> Self;
534 fn to_index(&self) -> usize;
535 fn name(&self) -> &'static str;
536 fn variant_count() -> usize;
537 fn variant_names() -> &'static [&'static str];
538}
539
540pub struct EnumParam<E: ParamEnum> {
542 pub info: ParamInfo,
543 value: AtomicU32,
544 _phantom: std::marker::PhantomData<E>,
545}
546
547impl<E: ParamEnum> EnumParam<E> {
548 #[allow(
560 clippy::float_cmp,
561 clippy::cast_possible_truncation,
562 clippy::cast_sign_loss
563 )]
564 #[must_use]
565 pub fn new(info: ParamInfo) -> Self {
566 let default = info.default_plain;
567 let count = E::variant_count();
568 assert!(
569 default.is_finite(),
570 "EnumParam '{}' default {} is not finite",
571 info.name,
572 default,
573 );
574 assert!(
575 default >= 0.0,
576 "EnumParam '{}' default {} is negative; enum variants are \
577 0-indexed",
578 info.name,
579 default,
580 );
581 let idx = default as u32;
582 assert!(
583 f64::from(idx) == default,
584 "EnumParam '{}' default {} is non-integer; supply a 0-indexed \
585 variant index",
586 info.name,
587 default,
588 );
589 assert!(
590 (idx as usize) < count,
591 "EnumParam '{}' default {} is out of range; only {} variant(s) \
592 defined",
593 info.name,
594 idx,
595 count,
596 );
597 Self {
598 info,
599 value: AtomicU32::new(idx),
600 _phantom: std::marker::PhantomData,
601 }
602 }
603
604 pub fn value(&self) -> E {
605 #[allow(clippy::cast_possible_truncation)]
608 let idx = self.value.load(Ordering::Relaxed) as usize;
609 E::from_index(idx)
610 }
611
612 pub fn set_value(&self, v: E) {
613 #[allow(clippy::cast_possible_truncation)]
617 let idx = v.to_index() as u32;
618 self.value.store(idx, Ordering::Relaxed);
619 }
620
621 pub fn set_index(&self, idx: u32) {
622 #[allow(clippy::cast_possible_truncation)]
631 let max = (E::variant_count() as u32).saturating_sub(1);
632 self.value.store(idx.min(max), Ordering::Relaxed);
633 }
634
635 pub fn index(&self) -> u32 {
636 self.value.load(Ordering::Relaxed)
637 }
638
639 pub fn id(&self) -> u32 {
640 self.info.id
641 }
642
643 #[must_use]
650 pub fn format_by_index(value: f64) -> String {
651 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
654 let idx = value.round() as usize;
655 E::from_index(idx).name().to_string()
656 }
657}
658
659pub struct MeterSlot {
679 #[doc(hidden)]
680 pub id: u32,
681}
682
683impl MeterSlot {
684 #[must_use]
685 pub fn id(&self) -> u32 {
686 self.id
687 }
688}
689
690impl From<MeterSlot> for u32 {
691 fn from(m: MeterSlot) -> u32 {
692 m.id
693 }
694}
695
696impl From<&MeterSlot> for u32 {
697 fn from(m: &MeterSlot) -> u32 {
698 m.id
699 }
700}
701
702use std::sync::atomic::AtomicUsize;
707
708pub const DEFAULT_TAP_CAPACITY: usize = 4096;
712
713pub struct AudioTap {
752 buf: Box<[AtomicU32]>,
753 write: AtomicUsize,
755 read: AtomicUsize,
757}
758
759impl AudioTap {
760 #[must_use]
763 pub fn new(capacity: usize) -> Self {
764 let capacity = capacity.max(1);
765 Self {
766 buf: (0..capacity).map(|_| AtomicU32::new(0)).collect(),
767 write: AtomicUsize::new(0),
768 read: AtomicUsize::new(0),
769 }
770 }
771
772 #[must_use]
774 pub fn capacity(&self) -> usize {
775 self.buf.len()
776 }
777
778 pub fn push(&self, samples: &[f32]) {
781 let cap = self.buf.len();
782 let mut write = self.write.load(Ordering::Relaxed);
783 for &s in samples {
784 self.buf[write % cap].store(s.to_bits(), Ordering::Relaxed);
785 write = write.wrapping_add(1);
786 }
787 self.write.store(write, Ordering::Release);
788 }
789
790 #[must_use]
795 pub fn drain(&self) -> Vec<f32> {
796 let cap = self.buf.len();
797 let write = self.write.load(Ordering::Acquire);
798 let read = self.read.load(Ordering::Relaxed);
799 let available = write.wrapping_sub(read).min(cap);
800 let start = write.wrapping_sub(available);
801 let mut out = Vec::with_capacity(available);
802 for i in 0..available {
803 let idx = start.wrapping_add(i) % cap;
804 out.push(f32::from_bits(self.buf[idx].load(Ordering::Relaxed)));
805 }
806 self.read.store(write, Ordering::Release);
807 out
808 }
809}
810
811impl Default for AudioTap {
812 fn default() -> Self {
813 Self::new(DEFAULT_TAP_CAPACITY)
814 }
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820 use crate::info::{ParamFlags, ParamUnit, ParamValueKind};
821 use crate::range::ParamRange;
822
823 fn info(name: &'static str, range: ParamRange, default_plain: f64) -> ParamInfo {
824 ParamInfo {
825 id: 0,
826 name,
827 short_name: name,
828 group: "",
829 range,
830 default_plain,
831 flags: ParamFlags::AUTOMATABLE,
832 unit: ParamUnit::None,
833 kind: ParamValueKind::Float,
834 midi_map: None,
835 midi_channel: None,
836 }
837 }
838
839 #[derive(Clone, Copy)]
840 enum E4 {
841 A,
842 B,
843 C,
844 D,
845 }
846 impl crate::__private::Sealed for E4 {}
847 impl ParamEnum for E4 {
848 fn from_index(i: usize) -> Self {
849 match i {
850 0 => Self::A,
851 1 => Self::B,
852 2 => Self::C,
853 _ => Self::D,
854 }
855 }
856 fn to_index(&self) -> usize {
857 *self as usize
858 }
859 fn name(&self) -> &'static str {
860 match self {
861 Self::A => "A",
862 Self::B => "B",
863 Self::C => "C",
864 Self::D => "D",
865 }
866 }
867 fn variant_count() -> usize {
868 4
869 }
870 fn variant_names() -> &'static [&'static str] {
871 &["A", "B", "C", "D"]
872 }
873 }
874
875 #[test]
876 fn enum_param_accepts_in_range_default() {
877 let p: EnumParam<E4> = EnumParam::new(info("Mode", ParamRange::Enum { count: 4 }, 2.0));
878 assert_eq!(p.index(), 2);
879 }
880
881 #[test]
882 #[should_panic(expected = "negative")]
883 fn enum_param_rejects_negative_default() {
884 let _: EnumParam<E4> = EnumParam::new(info("Mode", ParamRange::Enum { count: 4 }, -1.0));
885 }
886
887 #[test]
888 fn enum_param_set_index_clamps_out_of_range() {
889 let p: EnumParam<E4> = EnumParam::new(info("Mode", ParamRange::Enum { count: 4 }, 0.0));
894 p.set_index(4);
895 assert_eq!(p.index(), 3, "out-of-range index clamps to last variant");
896 assert!(matches!(p.value(), E4::D));
897 p.set_index(1000);
898 assert_eq!(p.index(), 3);
899 }
900
901 #[test]
902 #[should_panic(expected = "out of range")]
903 fn enum_param_rejects_overflow_default() {
904 let _: EnumParam<E4> = EnumParam::new(info("Mode", ParamRange::Enum { count: 4 }, 99.0));
905 }
906
907 #[test]
908 #[should_panic(expected = "non-integer")]
909 fn enum_param_rejects_fractional_default() {
910 let _: EnumParam<E4> = EnumParam::new(info("Mode", ParamRange::Enum { count: 4 }, 1.5));
911 }
912
913 #[test]
914 fn int_param_accepts_negative_default() {
915 let p = IntParam::new(info("N", ParamRange::Discrete { min: -10, max: 10 }, -3.0));
916 assert_eq!(p.value(), -3);
917 }
918
919 #[test]
920 #[should_panic(expected = "round-trip")]
921 fn int_param_rejects_fractional_default() {
922 let _ = IntParam::new(info("N", ParamRange::Discrete { min: 0, max: 10 }, 1.5));
923 }
924
925 #[test]
926 #[should_panic(expected = "outside range")]
927 fn int_param_rejects_out_of_range_default() {
928 let _ = IntParam::new(info("N", ParamRange::Discrete { min: 0, max: 5 }, 10.0));
929 }
930
931 #[test]
932 fn int_param_set_value_clamps_to_range() {
933 let p = IntParam::new(info("N", ParamRange::Discrete { min: 0, max: 8 }, 0.0));
937 p.set_value(i64::MAX);
938 assert_eq!(p.value(), 8, "clamps above max");
939 p.set_value(-1000);
940 assert_eq!(p.value(), 0, "clamps below min");
941 p.set_value(5);
942 assert_eq!(p.value(), 5, "in-range value stored as-is");
943 }
944
945 fn float(min: f64, max: f64) -> FloatParam {
946 FloatParam::new(
947 info("Gain", ParamRange::Linear { min, max }, 0.0),
948 SmoothingStyle::None,
949 )
950 }
951
952 #[test]
953 #[allow(clippy::float_cmp)] fn float_set_value_drops_non_finite() {
955 let p = float(-60.0, 6.0);
956 p.set_value(-12.0);
957 p.set_value(f64::NAN);
958 assert_eq!(p.raw_target(), -12.0, "NaN write is dropped");
959 p.set_value(f64::INFINITY);
960 assert_eq!(p.raw_target(), -12.0, "infinite write is dropped");
961 }
962
963 #[test]
964 #[allow(clippy::float_cmp)] fn float_set_value_clamps_to_range() {
966 let p = float(-60.0, 6.0);
967 p.set_value(1e308);
968 assert_eq!(p.raw_target(), 6.0, "clamps above max");
969 p.set_value(-1e308);
970 assert_eq!(p.raw_target(), -60.0, "clamps below min");
971 }
972
973 #[cfg(debug_assertions)]
977 #[test]
978 #[should_panic(expected = "ordered")]
979 fn float_new_debug_asserts_misordered_range() {
980 let _ = FloatParam::new(
981 info(
982 "Bad",
983 ParamRange::Linear {
984 min: 6.0,
985 max: -60.0,
986 },
987 0.0,
988 ),
989 SmoothingStyle::None,
990 );
991 }
992
993 #[cfg(not(debug_assertions))]
997 #[test]
998 fn float_set_value_survives_misordered_range() {
999 let p = FloatParam::new(
1000 info(
1001 "Bad",
1002 ParamRange::Linear {
1003 min: 6.0,
1004 max: -60.0,
1005 },
1006 0.0,
1007 ),
1008 SmoothingStyle::None,
1009 );
1010 p.set_value(1000.0); let v = p.raw_target();
1012 assert!(
1013 (-60.0..=6.0).contains(&v),
1014 "clamped to the normalized interval"
1015 );
1016 }
1017
1018 #[test]
1019 fn audio_tap_push_drain_round_trip() {
1020 let tap = AudioTap::new(8);
1021 tap.push(&[1.0, 2.0, 3.0]);
1022 assert_eq!(tap.drain(), vec![1.0, 2.0, 3.0]);
1023 assert_eq!(tap.drain(), Vec::<f32>::new());
1025 }
1026
1027 #[test]
1028 fn audio_tap_multiple_pushes_before_drain() {
1029 let tap = AudioTap::new(8);
1030 tap.push(&[1.0, 2.0]);
1031 tap.push(&[3.0, 4.0]);
1032 assert_eq!(tap.drain(), vec![1.0, 2.0, 3.0, 4.0]);
1033 }
1034
1035 #[test]
1036 fn audio_tap_overflow_drops_oldest_never_blocks() {
1037 let tap = AudioTap::new(4);
1038 tap.push(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1040 assert_eq!(tap.drain(), vec![3.0, 4.0, 5.0, 6.0]);
1041 }
1042
1043 #[test]
1044 fn audio_tap_overflow_across_pushes_between_drains() {
1045 let tap = AudioTap::new(4);
1046 tap.push(&[1.0, 2.0, 3.0]);
1047 tap.push(&[4.0, 5.0, 6.0]);
1049 assert_eq!(tap.drain(), vec![3.0, 4.0, 5.0, 6.0]);
1051 }
1052
1053 #[test]
1054 fn audio_tap_default_uses_default_capacity() {
1055 assert_eq!(AudioTap::default().capacity(), DEFAULT_TAP_CAPACITY);
1056 }
1057
1058 #[test]
1059 fn audio_tap_zero_capacity_clamped_to_one() {
1060 let tap = AudioTap::new(0);
1061 assert_eq!(tap.capacity(), 1);
1062 tap.push(&[7.0, 8.0]);
1063 assert_eq!(
1064 tap.drain(),
1065 vec![8.0],
1066 "only the last sample survives a 1-slot ring"
1067 );
1068 }
1069
1070 #[test]
1071 fn float_mod_is_non_destructive() {
1072 let p = FloatParam::new(
1073 info("Gain", ParamRange::Linear { min: 0.0, max: 1.0 }, 0.5),
1074 SmoothingStyle::None,
1075 );
1076 assert!((p.raw_target() - 0.5).abs() < 1e-12);
1077 p.set_mod_amount(0.25);
1078 assert!((p.raw_target() - 0.5).abs() < 1e-12);
1079 assert!((p.effective_target() - 0.75).abs() < 1e-12);
1080 p.set_mod_amount(0.0);
1081 assert!((p.effective_target() - 0.5).abs() < 1e-12);
1082 }
1083
1084 #[test]
1085 fn float_mod_clamps_to_range() {
1086 let p = FloatParam::new(
1087 info("Gain", ParamRange::Linear { min: 0.0, max: 1.0 }, 0.5),
1088 SmoothingStyle::None,
1089 );
1090 p.set_value(0.9);
1091 p.set_mod_amount(0.5); assert!((p.effective_target() - 1.0).abs() < 1e-12);
1093 }
1094}