Skip to main content

sound_to_patch/preset/
mapper.rs

1//! Turns measured layer values into edits for one synth.
2//!
3//! Estimators report values in unit types and never round to what a synth
4//! offers. A [`SynthMapper`] picks the nearest edit the synth can make and
5//! lists what it cannot reach in `unrepresentable`, with the residual.
6//! [`fit_param`] is the one helper every mapper shares: it keeps keys in
7//! the registry, values in range and output order stable.
8//!
9//! [`fit_flag`], [`fit_stored`] and [`fit_route`] do the same for a switch,
10//! a control without a unit and a modulation route. [`pitch_envelope`]
11//! holds the synth-neutral flow of the pitch and envelope groups. The
12//! timbre and motion inputs are empty until their estimator epics add
13//! their group fields.
14
15use 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/// The timbre groups of the layer report.
34#[derive(Clone, Debug, Default, PartialEq)]
35#[non_exhaustive]
36pub struct TimbreGroups {}
37
38/// The motion and effect groups of the layer report.
39#[derive(Clone, Debug, Default, PartialEq)]
40#[non_exhaustive]
41pub struct MotionFxGroups {}
42
43/// What a mapper may know about its synth.
44#[derive(Clone, Copy, Debug)]
45pub struct MapContext<'a> {
46    /// The registry of the target.
47    pub registry: &'a dyn ParamRegistry,
48    /// The structure of the target.
49    pub capabilities: &'a SynthCapabilities,
50}
51
52/// Maps layer groups to the edits of one synth.
53pub trait SynthMapper: Send + Sync {
54    /// The synth it maps to.
55    fn synth(&self) -> Synth;
56
57    /// Edits for the pitch and envelope groups.
58    fn pitch_envelope(&self, input: &PitchEnvelopeGroups, ctx: &MapContext<'_>) -> Mapped;
59
60    /// Edits for the timbre groups.
61    fn timbre(&self, input: &TimbreGroups, ctx: &MapContext<'_>) -> Mapped;
62
63    /// Edits for the motion and effect groups.
64    fn motion_fx(&self, input: &MotionFxGroups, ctx: &MapContext<'_>) -> Mapped;
65}
66
67/// The output of a mapper.
68#[derive(Clone, Debug, Default, PartialEq)]
69pub struct Mapped {
70    /// The edits, sorted by registry key, then by route slot.
71    pub edits: Vec<SuggestedEdit>,
72    /// What the synth cannot reach, with the nearest value it can.
73    pub unrepresentable: Vec<Unrepresentable>,
74    /// What the mapper leaves unwritten on purpose, with the reason.
75    pub unwritten: Vec<Unwritten>,
76}
77
78impl Mapped {
79    /// Appends the edits and lists of another output.
80    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/// One edit a mapper suggests.
88#[derive(Clone, Debug, PartialEq)]
89pub struct SuggestedEdit {
90    /// The edit-list record, with the layer name as its reason.
91    pub edit: Edit,
92    /// The registry law is interim; the explanation reads its tuning note.
93    pub uncalibrated: bool,
94    /// The level of the layer group, capped.
95    pub confidence: Level,
96}
97
98/// Why a wanted value has no exact edit.
99#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum UnrepresentableReason {
102    /// Above the largest value the control reaches.
103    AboveRange,
104    /// Below the smallest value the control reaches.
105    BelowRange,
106    /// Between the choices or steps the control offers.
107    NoSuchChoice,
108    /// The synth has no such control.
109    NotInSynth,
110    /// Every slot is taken.
111    SlotLimit,
112}
113
114/// A number in a unit, or a choice name.
115#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
116#[serde(untagged)]
117pub enum Amount {
118    /// A number and its unit suffix, or `count` for an integer entry.
119    Value {
120        /// The number.
121        value: f64,
122        /// The unit suffix.
123        unit: String,
124    },
125    /// A choice name.
126    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/// A wanted value the synth cannot reach exactly.
139#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct Unrepresentable {
142    /// The layer field name, such as `filter1_cutoff_hz`.
143    pub field: String,
144    /// What the layer measured.
145    pub wanted: Amount,
146    /// The nearest value the synth reaches, or `None` when it has nothing.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub nearest: Option<Amount>,
149    /// Why.
150    pub reason: UnrepresentableReason,
151}
152
153impl Unrepresentable {
154    /// The signed difference `nearest - wanted` for numbers in one unit.
155    #[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/// Why a mapper leaves a measured field unwritten.
174#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum UnwrittenReason {
177    /// The field has no estimate, or a low one.
178    LowConfidence,
179    /// The field is an upper bound, such as an attack faster than the track
180    /// can tell.
181    UpperBound,
182    /// The report cannot separate the value from what hides it, such as a
183    /// release under a heavy tail.
184    NotIdentifiable,
185    /// The curve stays at its default: no law maps the measured shape yet.
186    /// The explanation says "curve not set; tune by ear".
187    CurveNotSet,
188    /// The control exists, but no probe has shown what it does.
189    Unprobed,
190}
191
192/// A measured field that the mapper does not write.
193#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct Unwritten {
196    /// The path of the field in the group's `values`, with a dot into a
197    /// list's records, such as `attack_s` or `glides.curve`. A curve entry
198    /// names the stage whose curve stays at its default.
199    pub field: String,
200    /// Why.
201    pub reason: UnwrittenReason,
202}
203
204impl Unwritten {
205    /// An entry for a field.
206    #[must_use]
207    pub fn new(field: &str, reason: UnwrittenReason) -> Self {
208        Self {
209            field: field.to_owned(),
210            reason,
211        }
212    }
213}
214
215/// The suffix of a unit kind in the E1 grammar.
216fn 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/// The nearest edit of one control to a wanted physical value.
231///
232/// The value goes through the law of the entry. Outside the range it
233/// lands on the nearest bound, and a `Count` or `Choice` entry takes the
234/// nearest whole step; either way the miss is listed with the residual. A
235/// key outside the registry, a legacy key or a unit that differs gives no
236/// edit and a `not_in_synth` entry.
237#[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    // The law's round trip adds float noise, such as 0.11999999999999998 s
268    // for 0.12 s: an exact fit writes the wanted value, a miss the shortest
269    // text that stores the same value to the bit.
270    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        // The physical range is the span between the values at both stored
286        // bounds, so a falling law needs no special case.
287        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
303/// The value at a stored value with the fewest significant digits that
304/// still store exactly that value, so a bound stays inside the range.
305fn 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/// A switch set on or off.
321///
322/// A key outside the registry, a legacy key or a key that is not a switch
323/// gives no edit and a `not_in_synth` entry.
324#[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/// The nearest edit of a control without a unit to a wanted stored value,
351/// such as a voice count or a level the adapter's own law computed.
352///
353/// Outside the range the value lands on the nearest bound, and a `Count`
354/// takes the nearest whole number; either way the miss is listed with the
355/// residual. A key outside the registry, a legacy key, a key with a unit or
356/// a switch or menu gives no edit and a `not_in_synth` entry.
357#[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/// A route of a depth in semitones into a destination stored in
410/// semitones, such as an envelope into oscillator pitch.
411///
412/// A depth beyond the destination's stored span lands on the span and the
413/// rest is listed. A route amount is an interim law until the calibration
414/// set measures it, so the edit is always `uncalibrated`. A destination
415/// outside the registry, a legacy one or one not stored in semitones gives
416/// no edit and a `not_in_synth` entry; a zero depth gives nothing.
417#[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
465/// An output of one edit.
466fn 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
480/// An output of no edit and one `not_in_synth` entry.
481fn 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
493/// The side of a range a wanted value misses on, or a step between.
494fn 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
504/// The stored value nearest to a wanted physical value: through the law
505/// when it has an inverse there, else the bound on the near side, then the
506/// nearest whole step for a whole kind.
507fn 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            // The wanted value is outside the law's domain, such as 0 Hz:
516            // take the bound whose physical value is nearer.
517            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
544/// Sorts edits by registry key, then by route slot, as every mapper must.
545pub 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
551/// The mapper of a synth.
552///
553/// # Errors
554///
555/// [`SynthError::NotBuilt`] for a synth outside [`Synth::READY`].
556pub 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    /// The registry keys with a unit, for the property test.
575    fn unit_keys() -> Vec<&'static ParamSpec> {
576        vital()
577            .iter()
578            .filter(|spec| spec.unit().is_some() && !spec.legacy)
579            .collect()
580    }
581
582    /// Resolves the edits of a mapper output through the edit-list path,
583    /// as `preset apply` would.
584    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            // Rule 1: the key exists, the value resolves inside the range.
606            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            // Rules 2 and 5: a value outside the range or between steps is
613            // listed with the nearest value.
614            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        // Top of the range: 8.1757989156 * 2^(136 / 12) Hz.
640        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        // The table falls from 6 dB at stored 0 to -30 dB at stored 1.
660        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(&registry, "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        // A clamped time prints the bound: 2^4 s.
752        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        // A destination stored in cents takes a percent only.
854        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}