1use std::fmt;
16
17use serde::{Deserialize, Serialize};
18
19use crate::confidence::Level;
20use crate::preset::edits::{
21 Edit, ParamValue, PolarityText, Record, RouteRecord, stored_in_semitones,
22};
23use crate::preset::registry::{
24 ParamKind, ParamRegistry, ParamSpec, Physical, Provenance, UnitKind,
25};
26use crate::synth::{Synth, SynthCapabilities, SynthError};
27use crate::units::Semitones;
28
29pub mod pitch_envelope;
30
31pub use pitch_envelope::PitchEnvelopeGroups;
32
33#[derive(Clone, Debug, Default, PartialEq)]
35#[non_exhaustive]
36pub struct TimbreGroups {}
37
38#[derive(Clone, Debug, Default, PartialEq)]
40#[non_exhaustive]
41pub struct MotionFxGroups {}
42
43#[derive(Clone, Copy, Debug)]
45pub struct MapContext<'a> {
46 pub registry: &'a dyn ParamRegistry,
48 pub capabilities: &'a SynthCapabilities,
50}
51
52pub trait SynthMapper: Send + Sync {
54 fn synth(&self) -> Synth;
56
57 fn pitch_envelope(&self, input: &PitchEnvelopeGroups, ctx: &MapContext<'_>) -> Mapped;
59
60 fn timbre(&self, input: &TimbreGroups, ctx: &MapContext<'_>) -> Mapped;
62
63 fn motion_fx(&self, input: &MotionFxGroups, ctx: &MapContext<'_>) -> Mapped;
65}
66
67#[derive(Clone, Debug, Default, PartialEq)]
69pub struct Mapped {
70 pub edits: Vec<SuggestedEdit>,
72 pub unrepresentable: Vec<Unrepresentable>,
74 pub unwritten: Vec<Unwritten>,
76}
77
78impl Mapped {
79 pub fn extend(&mut self, other: Self) {
81 self.edits.extend(other.edits);
82 self.unrepresentable.extend(other.unrepresentable);
83 self.unwritten.extend(other.unwritten);
84 }
85}
86
87#[derive(Clone, Debug, PartialEq)]
89pub struct SuggestedEdit {
90 pub edit: Edit,
92 pub uncalibrated: bool,
94 pub confidence: Level,
96}
97
98#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum UnrepresentableReason {
102 AboveRange,
104 BelowRange,
106 NoSuchChoice,
108 NotInSynth,
110 SlotLimit,
112}
113
114#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
116#[serde(untagged)]
117pub enum Amount {
118 Value {
120 value: f64,
122 unit: String,
124 },
125 Choice(String),
127}
128
129impl fmt::Display for Amount {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 match self {
132 Self::Value { value, unit } => write!(f, "{value} {unit}"),
133 Self::Choice(name) => f.write_str(name),
134 }
135 }
136}
137
138#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct Unrepresentable {
142 pub field: String,
144 pub wanted: Amount,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub nearest: Option<Amount>,
149 pub reason: UnrepresentableReason,
151}
152
153impl Unrepresentable {
154 #[must_use]
156 pub fn residual(&self) -> Option<f64> {
157 match (&self.wanted, &self.nearest) {
158 (
159 Amount::Value {
160 value: wanted,
161 unit,
162 },
163 Some(Amount::Value {
164 value: nearest,
165 unit: nearest_unit,
166 }),
167 ) if unit == nearest_unit => Some(nearest - wanted),
168 _ => None,
169 }
170 }
171}
172
173#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum UnwrittenReason {
177 LowConfidence,
179 UpperBound,
182 NotIdentifiable,
185 CurveNotSet,
188 Unprobed,
190}
191
192#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct Unwritten {
196 pub field: String,
200 pub reason: UnwrittenReason,
202}
203
204impl Unwritten {
205 #[must_use]
207 pub fn new(field: &str, reason: UnwrittenReason) -> Self {
208 Self {
209 field: field.to_owned(),
210 reason,
211 }
212 }
213}
214
215fn suffix(unit: UnitKind) -> &'static str {
217 match unit {
218 UnitKind::Hz => "Hz",
219 UnitKind::Seconds => "s",
220 UnitKind::Semitones => "st",
221 UnitKind::Cents => "ct",
222 UnitKind::Octaves => "oct",
223 UnitKind::Decibels => "dB",
224 UnitKind::Normalized => "",
225 UnitKind::MidiPitch => "midi",
226 UnitKind::PercentUnipolar | UnitKind::PercentBipolar => "%",
227 }
228}
229
230#[must_use]
238pub fn fit_param(
239 registry: &dyn ParamRegistry,
240 field: &str,
241 key: &str,
242 wanted: Physical,
243 reason: &str,
244 confidence: Level,
245) -> Mapped {
246 let unit = wanted.kind();
247 let wanted_amount = Amount::Value {
248 value: wanted.get(),
249 unit: suffix(unit).to_owned(),
250 };
251 let missing = || not_in_synth(field, wanted_amount.clone());
252 let Some(spec) = registry
253 .get(key)
254 .filter(|spec| !spec.legacy && spec.unit() == Some(unit))
255 else {
256 return missing();
257 };
258 let Some(stored) = nearest_stored(spec, wanted.get()) else {
259 return missing();
260 };
261 let Ok(physical) = spec.physical(stored) else {
262 return missing();
263 };
264 let exact = spec
265 .stored(&wanted)
266 .is_ok_and(|exact| exact.to_bits() == stored.to_bits());
267 let text = if exact {
271 wanted
272 } else {
273 shortest(spec, physical, stored)
274 };
275 let mut mapped = one_edit(
276 Record::Param {
277 param: spec.key.clone(),
278 value: ParamValue::Text(text.to_string()),
279 },
280 reason,
281 spec.provenance == Provenance::Interim,
282 confidence,
283 );
284 if !exact {
285 let ends = [spec.stored_min, spec.stored_max]
288 .map(|bound| spec.physical(bound).map_or(physical.get(), Physical::get));
289 let reason = miss_side(wanted.get(), ends[0].min(ends[1]), ends[0].max(ends[1]));
290 mapped.unrepresentable.push(Unrepresentable {
291 field: field.to_owned(),
292 wanted: wanted_amount,
293 nearest: Some(Amount::Value {
294 value: physical.get(),
295 unit: suffix(unit).to_owned(),
296 }),
297 reason,
298 });
299 }
300 mapped
301}
302
303fn shortest(spec: &ParamSpec, physical: Physical, stored: f64) -> Physical {
306 (0..17)
307 .filter_map(|digits| {
308 let value = format!("{:.digits$e}", physical.get())
309 .parse::<f64>()
310 .ok()?;
311 Physical::new(physical.kind(), value).ok()
312 })
313 .find(|short| {
314 spec.stored(short)
315 .is_ok_and(|back| back.to_bits() == stored.to_bits())
316 })
317 .unwrap_or(physical)
318}
319
320#[must_use]
325pub fn fit_flag(
326 registry: &dyn ParamRegistry,
327 field: &str,
328 key: &str,
329 on: bool,
330 reason: &str,
331 confidence: Level,
332) -> Mapped {
333 match registry
334 .get(key)
335 .filter(|spec| !spec.legacy && spec.kind == ParamKind::Flag)
336 {
337 Some(spec) => one_edit(
338 Record::Param {
339 param: spec.key.clone(),
340 value: ParamValue::Flag(on),
341 },
342 reason,
343 spec.provenance == Provenance::Interim,
344 confidence,
345 ),
346 None => not_in_synth(field, Amount::Choice(on.to_string())),
347 }
348}
349
350#[must_use]
358pub fn fit_stored(
359 registry: &dyn ParamRegistry,
360 field: &str,
361 key: &str,
362 wanted: f64,
363 reason: &str,
364 confidence: Level,
365) -> Mapped {
366 let spec = registry.get(key).filter(|spec| {
367 !spec.legacy
368 && spec.unit().is_none()
369 && matches!(spec.kind, ParamKind::Float | ParamKind::Count)
370 });
371 let unit = if spec.is_some_and(|spec| spec.kind == ParamKind::Count) {
372 "count"
373 } else {
374 ""
375 };
376 let amount = |value| Amount::Value {
377 value,
378 unit: unit.to_owned(),
379 };
380 let Some(spec) = spec else {
381 return not_in_synth(field, amount(wanted));
382 };
383 let clamped = wanted.clamp(spec.stored_min, spec.stored_max);
384 let stored = if spec.kind == ParamKind::Count {
385 clamped.round().clamp(spec.stored_min, spec.stored_max)
386 } else {
387 clamped
388 };
389 let mut mapped = one_edit(
390 Record::Param {
391 param: spec.key.clone(),
392 value: ParamValue::Number(stored),
393 },
394 reason,
395 spec.provenance == Provenance::Interim,
396 confidence,
397 );
398 if stored.to_bits() != wanted.to_bits() {
399 mapped.unrepresentable.push(Unrepresentable {
400 field: field.to_owned(),
401 wanted: amount(wanted),
402 nearest: Some(amount(stored)),
403 reason: miss_side(wanted, spec.stored_min, spec.stored_max),
404 });
405 }
406 mapped
407}
408
409#[must_use]
418pub fn fit_route(
419 registry: &dyn ParamRegistry,
420 field: &str,
421 (source, dest): (&str, &str),
422 depth: Semitones,
423 polarity: Option<PolarityText>,
424 reason: &str,
425 confidence: Level,
426) -> Mapped {
427 let amount = |value| Amount::Value {
428 value,
429 unit: suffix(UnitKind::Semitones).to_owned(),
430 };
431 let wanted = depth.get();
432 let Some(spec) = registry
433 .get(dest)
434 .filter(|spec| !spec.legacy && stored_in_semitones(spec))
435 else {
436 return not_in_synth(field, amount(wanted));
437 };
438 if wanted == 0.0 {
439 return Mapped::default();
440 }
441 let span = spec.stored_max - spec.stored_min;
442 let nearest = wanted.clamp(-span, span);
443 let mut mapped = one_edit(
444 Record::Route(RouteRecord {
445 source: source.to_owned(),
446 dest: spec.key.clone(),
447 amount: Semitones::clamped(nearest).to_string(),
448 polarity,
449 }),
450 reason,
451 true,
452 confidence,
453 );
454 if nearest.to_bits() != wanted.to_bits() {
455 mapped.unrepresentable.push(Unrepresentable {
456 field: field.to_owned(),
457 wanted: amount(wanted),
458 nearest: Some(amount(nearest)),
459 reason: miss_side(wanted, -span, span),
460 });
461 }
462 mapped
463}
464
465fn one_edit(record: Record, reason: &str, uncalibrated: bool, confidence: Level) -> Mapped {
467 Mapped {
468 edits: vec![SuggestedEdit {
469 edit: Edit {
470 record,
471 reason: Some(reason.to_owned()),
472 },
473 uncalibrated,
474 confidence,
475 }],
476 ..Mapped::default()
477 }
478}
479
480fn not_in_synth(field: &str, wanted: Amount) -> Mapped {
482 Mapped {
483 unrepresentable: vec![Unrepresentable {
484 field: field.to_owned(),
485 wanted,
486 nearest: None,
487 reason: UnrepresentableReason::NotInSynth,
488 }],
489 ..Mapped::default()
490 }
491}
492
493fn miss_side(wanted: f64, low: f64, high: f64) -> UnrepresentableReason {
495 if wanted > high {
496 UnrepresentableReason::AboveRange
497 } else if wanted < low {
498 UnrepresentableReason::BelowRange
499 } else {
500 UnrepresentableReason::NoSuchChoice
501 }
502}
503
504fn nearest_stored(spec: &ParamSpec, wanted: f64) -> Option<f64> {
508 let law = &spec.law.as_ref()?.law;
509 let low = spec.physical(spec.stored_min).ok().map(Physical::get);
510 let high = spec.physical(spec.stored_max).ok().map(Physical::get);
511 let raw = crate::preset::registry::ParamLaw::to_stored(law, wanted).ok();
512 let stored = match raw {
513 Some(stored) => stored.clamp(spec.stored_min, spec.stored_max),
514 None => {
515 match (low, high) {
518 (Some(low), Some(high)) if (wanted - low).abs() <= (wanted - high).abs() => {
519 spec.stored_min
520 }
521 (Some(_) | None, Some(_)) => spec.stored_max,
522 _ => spec.stored_min,
523 }
524 }
525 };
526 if spec.kind == ParamKind::Float {
527 return Some(stored);
528 }
529 let (down, up) = (
530 stored.floor().max(spec.stored_min),
531 stored.ceil().min(spec.stored_max),
532 );
533 let distance = |step: f64| {
534 spec.physical(step)
535 .map_or(f64::INFINITY, |value| (value.get() - wanted).abs())
536 };
537 Some(if distance(up) < distance(down) {
538 up
539 } else {
540 down
541 })
542}
543
544pub fn sort_edits(mapped: &mut Mapped) {
546 mapped
547 .edits
548 .sort_by(|a, b| a.edit.record.name().cmp(b.edit.record.name()));
549}
550
551pub fn mapper_for(synth: Synth) -> Result<&'static dyn SynthMapper, SynthError> {
557 match synth.ready()? {
558 Synth::Vital => Ok(&crate::preset::vital::MAPPER),
559 Synth::Serum2 => Err(SynthError::NotBuilt(synth)),
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566 use crate::preset::edits::{EditList, Path, Resolved, parse, resolve};
567 use crate::preset::registry::registry_for;
568 use proptest::prelude::*;
569
570 fn vital() -> &'static dyn ParamRegistry {
571 registry_for(Synth::Vital).unwrap()
572 }
573
574 fn unit_keys() -> Vec<&'static ParamSpec> {
576 vital()
577 .iter()
578 .filter(|spec| spec.unit().is_some() && !spec.legacy)
579 .collect()
580 }
581
582 fn resolved(mapped: &Mapped) -> Vec<Resolved> {
585 let list = EditList {
586 synth: Synth::Vital,
587 edits: mapped.edits.iter().map(|edit| edit.edit.clone()).collect(),
588 ambiguities: Vec::new(),
589 };
590 let text = serde_json::to_string(&list).unwrap();
591 resolve(&parse(&text, Path::Agent).unwrap(), vital()).unwrap()
592 }
593
594 proptest! {
595 #[test]
596 fn fitted_edits_stay_in_range_and_list_every_miss(index in 0usize..10_000, t in -0.5..1.5f64) {
597 let keys = unit_keys();
598 let spec = keys[index % keys.len()];
599 let Ok(low) = spec.physical(spec.stored_min).map(Physical::get) else { return Ok(()) };
600 let high = spec.physical(spec.stored_max).unwrap().get();
601 let value = low + t * (high - low);
602 let Ok(wanted) = Physical::new(spec.unit().unwrap(), value) else { return Ok(()) };
603 let mut mapped = fit_param(vital(), "field", &spec.key, wanted, "layer", Level::Medium);
604 sort_edits(&mut mapped);
605 prop_assert_eq!(mapped.edits.len(), 1);
607 for item in resolved(&mapped) {
608 let Resolved::Param { key, stored, .. } = item else { panic!("not a param") };
609 prop_assert_eq!(&key, &spec.key);
610 prop_assert!(spec.accepts(stored), "{} {}", key, stored);
611 }
612 let inside = (0.0..=1.0).contains(&t);
615 let whole = spec.kind != ParamKind::Float;
616 if !inside {
617 prop_assert_eq!(mapped.unrepresentable.len(), 1, "{} at {}", spec.key, value);
618 prop_assert!(mapped.unrepresentable[0].residual().is_some());
619 } else if !whole {
620 prop_assert!(mapped.unrepresentable.len() <= 1);
621 }
622 }
623 }
624
625 #[test]
626 fn a_cutoff_above_the_range_is_written_at_the_top_and_listed() {
627 let wanted = Physical::new(UnitKind::Hz, 25_000.0).unwrap();
628 let mapped = fit_param(
629 vital(),
630 "filter1_cutoff_hz",
631 "filter_1_cutoff",
632 wanted,
633 "filter",
634 Level::High,
635 );
636 assert_eq!(mapped.unrepresentable.len(), 1);
637 let miss = &mapped.unrepresentable[0];
638 assert_eq!(miss.reason, UnrepresentableReason::AboveRange);
639 let top = 8.175_798_915_6 * (136.0f64 / 12.0).exp2();
641 assert!((miss.residual().unwrap() - (top - 25_000.0)).abs() < 1e-6);
642 let item = resolved(&mapped).remove(0);
643 assert!(matches!(item, Resolved::Param { stored, .. } if (stored - 136.0).abs() < 1e-12));
644 }
645
646 #[test]
647 fn a_falling_law_names_the_side_it_misses() {
648 use crate::preset::registry::Registry;
649 use crate::preset::registry::tests::{law_cases, spec};
650 let (_, law, min, max, unit) = law_cases()
651 .into_iter()
652 .find(|(name, ..)| *name == "falling table")
653 .unwrap();
654 let registry = Registry::new(
655 crate::synth::Synth::Vital,
656 vec![spec("level", law, min, max, unit)],
657 )
658 .unwrap();
659 for (wanted, side) in [
661 (10.0, UnrepresentableReason::AboveRange),
662 (-40.0, UnrepresentableReason::BelowRange),
663 ] {
664 let wanted = Physical::new(UnitKind::Decibels, wanted).unwrap();
665 let mapped = fit_param(®istry, "level", "level", wanted, "level", Level::Low);
666 assert_eq!(mapped.unrepresentable[0].reason, side);
667 }
668 }
669
670 #[test]
671 fn a_zero_hz_cutoff_lands_on_the_bottom() {
672 let wanted = Physical::new(UnitKind::Hz, 0.0).unwrap();
673 let mapped = fit_param(
674 vital(),
675 "f",
676 "filter_1_cutoff",
677 wanted,
678 "filter",
679 Level::Low,
680 );
681 assert_eq!(
682 mapped.unrepresentable[0].reason,
683 UnrepresentableReason::BelowRange
684 );
685 }
686
687 #[test]
688 fn a_count_takes_the_nearest_step_and_lists_the_rest() {
689 let wanted = Physical::new(UnitKind::Semitones, 12.4).unwrap();
690 let mapped = fit_param(
691 vital(),
692 "osc1_transpose_st",
693 "osc_1_transpose",
694 wanted,
695 "pitch",
696 Level::High,
697 );
698 assert_eq!(
699 mapped.unrepresentable[0].reason,
700 UnrepresentableReason::NoSuchChoice
701 );
702 assert!((mapped.unrepresentable[0].residual().unwrap() + 0.4).abs() < 1e-9);
703 let exact = Physical::new(UnitKind::Semitones, 12.0).unwrap();
704 let mapped = fit_param(vital(), "f", "osc_1_transpose", exact, "pitch", Level::High);
705 assert_eq!(mapped.unrepresentable, Vec::new());
706 }
707
708 #[test]
709 fn a_missing_control_gives_no_edit() {
710 let wanted = Physical::new(UnitKind::Hz, 100.0).unwrap();
711 for key in ["no_such_key", "osc_1_level", "sub_level"] {
712 let mapped = fit_param(vital(), "f", key, wanted, "x", Level::High);
713 assert_eq!(mapped.edits, Vec::new(), "{key}");
714 assert_eq!(
715 mapped.unrepresentable[0].reason,
716 UnrepresentableReason::NotInSynth
717 );
718 }
719 }
720
721 #[test]
722 fn interim_controls_are_uncalibrated() {
723 let wanted = Physical::new(UnitKind::PercentUnipolar, 40.0).unwrap();
724 let mapped = fit_param(
725 vital(),
726 "f",
727 "filter_1_resonance",
728 wanted,
729 "filter",
730 Level::High,
731 );
732 assert!(mapped.edits[0].uncalibrated);
733 }
734
735 #[test]
736 fn a_fitted_time_prints_without_float_noise() {
737 let text = |key: &str, seconds: f64| {
738 let wanted = Physical::new(UnitKind::Seconds, seconds).unwrap();
739 let mapped = fit_param(vital(), "f", key, wanted, "x", Level::High);
740 match &mapped.edits[0].edit.record {
741 Record::Param {
742 value: ParamValue::Text(text),
743 ..
744 } => text.clone(),
745 other => panic!("{other:?}"),
746 }
747 };
748 assert_eq!(text("portamento_time", 0.12), "0.12 s");
749 assert_eq!(text("env_1_attack", 0.02), "0.02 s");
750 assert_eq!(text("env_1_decay", 0.2), "0.2 s");
751 assert_eq!(text("portamento_time", 20.0), "16 s");
753 }
754
755 #[test]
756 fn a_count_lands_on_a_whole_step_inside_the_range() {
757 let mapped = fit_stored(
758 vital(),
759 "max_polyphony",
760 "polyphony",
761 40.0,
762 "voices",
763 Level::High,
764 );
765 let miss = &mapped.unrepresentable[0];
766 assert_eq!(miss.reason, UnrepresentableReason::AboveRange);
767 assert_eq!(
768 miss.nearest,
769 Some(Amount::Value {
770 value: 32.0,
771 unit: "count".to_owned()
772 })
773 );
774 assert!(
775 matches!(resolved(&mapped)[0], Resolved::Param { stored, .. } if (stored - 32.0).abs() < 1e-12)
776 );
777 let between = fit_stored(vital(), "f", "polyphony", 3.4, "voices", Level::High);
778 assert_eq!(
779 between.unrepresentable[0].reason,
780 UnrepresentableReason::NoSuchChoice
781 );
782 assert!(
783 matches!(resolved(&between)[0], Resolved::Param { stored, .. } if (stored - 3.0).abs() < 1e-12)
784 );
785 let exact = fit_stored(vital(), "f", "polyphony", 3.0, "voices", Level::High);
786 assert_eq!(exact.unrepresentable, Vec::new());
787 for key in ["portamento_time", "legato", "no_such_key"] {
788 let mapped = fit_stored(vital(), "f", key, 1.0, "x", Level::High);
789 assert_eq!(mapped.edits, Vec::new(), "{key}");
790 assert_eq!(
791 mapped.unrepresentable[0].reason,
792 UnrepresentableReason::NotInSynth
793 );
794 }
795 }
796
797 #[test]
798 fn a_flag_needs_a_switch() {
799 let mapped = fit_flag(
800 vital(),
801 "joins.legato",
802 "legato",
803 true,
804 "voices",
805 Level::Medium,
806 );
807 assert!(
808 matches!(resolved(&mapped)[0], Resolved::Param { stored, .. } if (stored - 1.0).abs() < 1e-12)
809 );
810 assert_eq!(mapped.edits[0].confidence, Level::Medium);
811 let wrong = fit_flag(vital(), "f", "polyphony", true, "voices", Level::High);
812 assert_eq!(wrong.edits, Vec::new());
813 assert_eq!(
814 wrong.unrepresentable[0].reason,
815 UnrepresentableReason::NotInSynth
816 );
817 }
818
819 #[test]
820 fn a_route_in_semitones_clamps_at_the_span() {
821 let depth = |st: f64| Semitones::new(st).unwrap();
822 let route = |st: f64, dest: &str| {
823 fit_route(
824 vital(),
825 "drops.df_st",
826 ("env_2", dest),
827 depth(st),
828 Some(PolarityText::Unipolar),
829 "pitch",
830 Level::High,
831 )
832 };
833 let amount = |mapped: &Mapped| match resolved(mapped).remove(0) {
834 Resolved::Route { amount, .. } => amount,
835 other => panic!("{other:?}"),
836 };
837 let seven = route(7.0, "osc_1_transpose");
838 assert!(seven.edits[0].uncalibrated);
839 assert!((amount(&seven) - 7.0 / 96.0).abs() < 1e-9);
840 assert_eq!(seven.unrepresentable, Vec::new());
841 let deep = route(100.0, "osc_1_transpose");
842 assert!((amount(&deep) - 1.0).abs() < 1e-12);
843 assert_eq!(
844 deep.unrepresentable[0].reason,
845 UnrepresentableReason::AboveRange
846 );
847 assert!((deep.unrepresentable[0].residual().unwrap() + 4.0).abs() < 1e-9);
848 let rise = route(-100.0, "osc_1_transpose");
849 assert_eq!(
850 rise.unrepresentable[0].reason,
851 UnrepresentableReason::BelowRange
852 );
853 let cents = route(1.0, "osc_1_tune");
855 assert_eq!(cents.edits, Vec::new());
856 assert_eq!(
857 cents.unrepresentable[0].reason,
858 UnrepresentableReason::NotInSynth
859 );
860 assert_eq!(route(0.0, "osc_1_transpose"), Mapped::default());
861 }
862
863 #[test]
864 fn unrepresentable_round_trips_strictly() {
865 let text = r#"{"field":"filter1_cutoff_hz","wanted":{"value":25000.0,"unit":"Hz"},"nearest":{"value":21096.2,"unit":"Hz"},"reason":"above_range"}"#;
866 let entry: Unrepresentable = serde_json::from_str(text).unwrap();
867 assert_eq!(serde_json::to_string(&entry).unwrap(), text);
868 let choice = r#"{"field":"filter1_type","wanted":"ladder","nearest":"Ladder","reason":"no_such_choice"}"#;
869 let entry: Unrepresentable = serde_json::from_str(choice).unwrap();
870 assert_eq!(entry.residual(), None);
871 assert!(
872 serde_json::from_str::<Unrepresentable>(&text.replace("above_range", "too_high"))
873 .is_err()
874 );
875 let extra = r#"{"field":"f","wanted":"a","reason":"not_in_synth","x":1}"#;
876 assert!(serde_json::from_str::<Unrepresentable>(extra).is_err());
877 }
878
879 #[test]
880 fn serum2_has_no_mapper_yet() {
881 assert!(matches!(
882 mapper_for(Synth::Serum2),
883 Err(SynthError::NotBuilt(Synth::Serum2))
884 ));
885 }
886}