Skip to main content

sound_to_patch/preset/mapper/
pitch_envelope.rs

1//! The synth-neutral flow from the pitch, voices and envelope groups to
2//! suggested edits.
3//!
4//! [`plan`] reads the groups and decides what a patch should hold: the
5//! voicing, the glide, the pitch drop, the velocity path and the amplitude
6//! envelope, each in physical units with its confidence. It leaves out a
7//! low-confidence or flagged field and lists it in `unwritten`. An adapter
8//! implements [`PitchEnvelopeControls`], which turns one [`Wanted`] into
9//! the edits of its synth through its own keys and laws. [`map`] runs the
10//! plan through an adapter, drops each value equal to its default and
11//! sorts the edits.
12//!
13//! The laws are interim until the calibration set (epic #11) measures
14//! them. No law maps the fitted glide curve or the envelope contour, so the
15//! curves stay at their defaults and `unwritten` says "curve not set".
16
17use crate::confidence::{Estimate, Level, Spread};
18use crate::dsp::{Stats, Summary};
19use crate::estimate::envelope::{EnvelopeLayer, HOLD_MIN};
20use crate::estimate::pitch::PitchLayer;
21use crate::estimate::voices::VoicesLayer;
22use crate::preset::edits::{Record, Resolved, resolve_record};
23use crate::preset::mapper::{MapContext, Mapped, Unwritten, UnwrittenReason, sort_edits};
24use crate::preset::registry::ParamRegistry;
25use crate::units::{Decibels, Seconds, Semitones};
26
27/// The spread of the full-band levels of the pooled notes above which
28/// velocity gets a path to the level.
29pub const VELOCITY_SPREAD: Decibels = Decibels::from_const(3.0);
30
31/// Whether velocity gets a path to the level: the level spread has a value,
32/// read over at least three pooled notes, and exceeds [`VELOCITY_SPREAD`].
33///
34/// The writer of the final `notes.mid` reads this rule too: a velocity per
35/// note from its full-band level when it holds, one constant velocity
36/// otherwise. The spread itself is always low confidence, so the
37/// rule does not gate on its level.
38///
39/// ```
40/// use sound_to_patch::preset::mapper::pitch_envelope::velocity_reaches_level;
41/// use sound_to_patch::units::Decibels;
42///
43/// assert!(velocity_reaches_level(Some(Decibels::new(4.0)?)));
44/// assert!(!velocity_reaches_level(Some(Decibels::new(3.0)?)));
45/// assert!(!velocity_reaches_level(None));
46/// # Ok::<(), sound_to_patch::units::RangeError>(())
47/// ```
48#[must_use]
49pub fn velocity_reaches_level(level_spread: Option<Decibels>) -> bool {
50    level_spread.is_some_and(|spread| spread.get() > VELOCITY_SPREAD.get())
51}
52
53/// A measured value and how far to trust it.
54#[derive(Clone, Copy, Debug, PartialEq)]
55pub struct Reading<T> {
56    /// The value.
57    pub value: T,
58    /// Its confidence.
59    pub level: Level,
60}
61
62/// The pitch, voices and envelope groups of the layer report, as the
63/// mapper reads them. Each group is `None` when the report holds no
64/// values for it.
65#[derive(Clone, Debug, Default, PartialEq)]
66#[non_exhaustive]
67pub struct PitchEnvelopeGroups {
68    /// The `pitch` group.
69    pub pitch: Option<PitchValues>,
70    /// The `voices` group.
71    pub voices: Option<VoicesValues>,
72    /// The `envelope` group.
73    pub envelope: Option<EnvelopeValues>,
74}
75
76/// The values of the `pitch` group that the mapper reads.
77#[derive(Clone, Debug, PartialEq)]
78pub struct PitchValues {
79    /// The level of the group.
80    pub level: Level,
81    /// `glides`.
82    pub glides: Vec<GlideValue>,
83    /// `drops`.
84    pub drops: Vec<DropValue>,
85}
86
87/// A glide record.
88#[derive(Clone, Copy, Debug, PartialEq)]
89pub struct GlideValue {
90    /// `time_s`: the full 0 to 100 % time.
91    pub time: Seconds,
92    /// `from_silence`.
93    pub from_silence: bool,
94    /// The record's level.
95    pub level: Level,
96}
97
98/// A pitch-drop record.
99#[derive(Clone, Copy, Debug, PartialEq)]
100pub struct DropValue {
101    /// `df_st`: the start pitch above the settled pitch.
102    pub df: Semitones,
103    /// `tau_s`: the time constant.
104    pub tau: Seconds,
105    /// The record's level.
106    pub level: Level,
107}
108
109/// The values of the `voices` group that the mapper reads.
110#[derive(Clone, Debug, PartialEq)]
111pub struct VoicesValues {
112    /// The level of the group.
113    pub level: Level,
114    /// `mono`.
115    pub mono: bool,
116    /// `max_polyphony`.
117    pub max_polyphony: usize,
118    /// `joins`.
119    pub joins: Vec<JoinValue>,
120}
121
122/// A join record.
123#[derive(Clone, Copy, Debug, PartialEq)]
124pub struct JoinValue {
125    /// `legato`: the join shows no new attack; `None` without a verdict.
126    pub legato: Option<bool>,
127    /// The record's level.
128    pub level: Level,
129}
130
131/// The values of the `envelope` group that the mapper reads. A statistic
132/// is its median, `None` when it has no value.
133#[derive(Clone, Debug, PartialEq)]
134pub struct EnvelopeValues {
135    /// The level of the group.
136    pub level: Level,
137    /// `attack_s`: the full 0 to 100 % time.
138    pub attack: Option<Reading<Seconds>>,
139    /// `attack_is_upper_bound`.
140    pub attack_is_upper_bound: bool,
141    /// `hold_s`.
142    pub hold: Option<Reading<Seconds>>,
143    /// `decay_s`: from the peak to the sustain level.
144    pub decay: Option<Reading<Seconds>>,
145    /// `sustain_db`: the held level from the peak.
146    pub sustain: Option<Reading<Decibels>>,
147    /// `release_s`: the linear release after the note-off knee.
148    pub release: Option<Reading<Seconds>>,
149    /// `release_confidence`.
150    pub release_confidence: Option<Level>,
151    /// `level_spread_db`.
152    pub level_spread: Option<Decibels>,
153}
154
155impl PitchEnvelopeGroups {
156    /// The groups of the three layers, with the values `layers.json` holds.
157    #[must_use]
158    pub fn from_layers(pitch: &PitchLayer, voices: &VoicesLayer, envelope: &EnvelopeLayer) -> Self {
159        Self {
160            pitch: pitch.confidence.level().map(|level| PitchValues {
161                level,
162                glides: pitch
163                    .glides
164                    .iter()
165                    .filter_map(|glide| {
166                        Some(GlideValue {
167                            time: glide.value.time,
168                            from_silence: glide.value.from_silence,
169                            level: glide.confidence.level()?,
170                        })
171                    })
172                    .collect(),
173                drops: pitch
174                    .drops
175                    .iter()
176                    .filter_map(|drop| {
177                        Some(DropValue {
178                            df: drop.value.df()?,
179                            tau: drop.value.tau,
180                            level: drop.confidence.level()?,
181                        })
182                    })
183                    .collect(),
184            }),
185            voices: voices.confidence.level().map(|level| VoicesValues {
186                level,
187                mono: voices.mono,
188                max_polyphony: voices.max_polyphony,
189                joins: voices
190                    .joins
191                    .iter()
192                    .filter_map(|join| {
193                        Some(JoinValue {
194                            legato: join.legato,
195                            level: join.confidence.level()?,
196                        })
197                    })
198                    .collect(),
199            }),
200            envelope: envelope.confidence.level().map(|level| EnvelopeValues {
201                level,
202                attack: pooled(&envelope.attack),
203                attack_is_upper_bound: envelope.flags.attack_is_upper_bound,
204                hold: pooled(&envelope.hold),
205                decay: pooled(&envelope.decay),
206                sustain: pooled(&envelope.sustain),
207                release: pooled(&envelope.release),
208                release_confidence: envelope.release_confidence,
209                level_spread: envelope.level_spread.value().copied(),
210            }),
211        }
212    }
213}
214
215/// The median of a pooled statistic and its level.
216fn pooled<T: Copy, S: Spread>(estimate: &Estimate<Stats<T>, S>) -> Option<Reading<T>> {
217    Some(Reading {
218        value: estimate.value()?.median(),
219        level: estimate.confidence().level()?,
220    })
221}
222
223/// One setting the groups ask of a synth, in physical units.
224#[derive(Clone, Copy, Debug, PartialEq)]
225#[non_exhaustive]
226pub enum Want {
227    /// One voice at a time, or more.
228    Mono(bool),
229    /// A held note slides into the next without a new attack.
230    Legato(bool),
231    /// The count of voices: the most notes at once, times 1 unison voice
232    /// and 1 oscillator. The unison mapper sets the final value (#121,
233    /// #186).
234    Voices(usize),
235    /// The full 0 to 100 % glide time. The adapter clamps it to the synth's
236    /// maximum and lists the rest.
237    GlideTime(Seconds),
238    /// A note glides even after silence.
239    GlideFromSilence,
240    /// A unipolar envelope into oscillator pitch: the start pitch `depth`
241    /// above the settled pitch, falling with time constant `tau`. The depth
242    /// law and the envelope are the adapter's interim proposals. A miss of
243    /// the depth names `drops.df_st`, a miss of the envelope time
244    /// `drops.tau_s`.
245    PitchDrop {
246        /// The drop in semitones.
247        depth: Semitones,
248        /// The time constant.
249        tau: Seconds,
250    },
251    /// A path from velocity to the level, with the spread it should cover.
252    VelocityToLevel {
253        /// The 90th minus the 10th percentile of the full-band levels of
254        /// the pooled notes.
255        spread: Decibels,
256    },
257    /// The amplitude attack, 0 to 100 %.
258    Attack(Seconds),
259    /// The amplitude hold.
260    Hold(Seconds),
261    /// The amplitude decay, from the peak to the sustain level.
262    Decay(Seconds),
263    /// The sustain level from the peak. The adapter's law turns it into its
264    /// stored value.
265    Sustain(Decibels),
266    /// The amplitude release after the note-off.
267    Release(Seconds),
268}
269
270impl Want {
271    /// The path of the field it comes from, in its group's `values`.
272    #[must_use]
273    pub fn field(&self) -> &'static str {
274        match self {
275            Self::Mono(_) => "mono",
276            Self::Legato(_) => "joins.legato",
277            Self::Voices(_) => "max_polyphony",
278            Self::GlideTime(_) => "glides.time_s",
279            Self::GlideFromSilence => "glides.from_silence",
280            Self::PitchDrop { .. } => "drops.df_st",
281            Self::VelocityToLevel { .. } => "level_spread_db",
282            Self::Attack(_) => "attack_s",
283            Self::Hold(_) => "hold_s",
284            Self::Decay(_) => "decay_s",
285            Self::Sustain(_) => "sustain_db",
286            Self::Release(_) => "release_s",
287        }
288    }
289
290    /// The layer group it comes from, which is the reason of its edits.
291    #[must_use]
292    pub fn group(&self) -> &'static str {
293        match self {
294            Self::Mono(_) | Self::Legato(_) | Self::Voices(_) => "voices",
295            Self::GlideTime(_) | Self::GlideFromSilence | Self::PitchDrop { .. } => "pitch",
296            Self::VelocityToLevel { .. }
297            | Self::Attack(_)
298            | Self::Hold(_)
299            | Self::Decay(_)
300            | Self::Sustain(_)
301            | Self::Release(_) => "envelope",
302        }
303    }
304}
305
306/// The layer group of a field that a setting, a miss or an `unwritten`
307/// entry names, such as `pitch` for `glides.time_s`; `None` for a field no
308/// setting reads.
309#[must_use]
310pub fn group_of(field: &str) -> Option<&'static str> {
311    match field {
312        "mono" | "joins.legato" | "max_polyphony" => Some("voices"),
313        "glides.time_s"
314        | "glides.from_silence"
315        | "glides.curve"
316        | "drops.df_st"
317        | "drops.tau_s" => Some("pitch"),
318        "attack_s" | "hold_s" | "decay_s" | "sustain_db" | "release_s" | "level_spread_db" => {
319            Some("envelope")
320        }
321        _ => None,
322    }
323}
324
325/// A setting and the confidence its edits carry.
326#[derive(Clone, Copy, Debug, PartialEq)]
327pub struct Wanted {
328    /// The setting.
329    pub want: Want,
330    /// The lowest level of the records behind it, capped by its group.
331    pub confidence: Level,
332}
333
334/// What the groups ask of a synth, and what they leave unwritten.
335#[derive(Clone, Debug, Default, PartialEq)]
336pub struct Plan {
337    /// The settings, in the order of the groups.
338    pub wants: Vec<Wanted>,
339    /// The measured fields left out, with the reason.
340    pub unwritten: Vec<Unwritten>,
341}
342
343/// The controls and laws of one synth for the pitch and envelope groups.
344pub trait PitchEnvelopeControls {
345    /// The edits of one setting: `fit_param` and its siblings with the
346    /// synth's keys and `wanted.want.field()` as the field. [`map`] sets
347    /// each edit's reason to `wanted.want.group()`. The edits are `param`
348    /// and `route` records only, the two kinds the `layers.json` schema
349    /// takes. A setting the synth has no control for gives a `not_in_synth`
350    /// entry; one whose effect is unprobed gives an `unprobed` entry in
351    /// `unwritten`.
352    fn edits(&self, wanted: &Wanted, ctx: &MapContext<'_>) -> Mapped;
353}
354
355/// The edits of the groups through one adapter: the plan, then each
356/// setting through `controls`. A parameter whose value equals its default
357/// gives no edit. The edits are sorted by key.
358#[must_use]
359pub fn map(
360    groups: &PitchEnvelopeGroups,
361    ctx: &MapContext<'_>,
362    controls: &dyn PitchEnvelopeControls,
363) -> Mapped {
364    let plan = plan(groups);
365    let mut mapped = Mapped {
366        unwritten: plan.unwritten,
367        ..Mapped::default()
368    };
369    for wanted in &plan.wants {
370        let mut edits = controls.edits(wanted, ctx);
371        for edit in &mut edits.edits {
372            edit.edit.reason = Some(wanted.want.group().to_owned());
373        }
374        mapped.extend(edits);
375    }
376    mapped
377        .edits
378        .retain(|edit| !equals_default(&edit.edit.record, ctx.registry));
379    sort_edits(&mut mapped);
380    mapped
381}
382
383/// Whether a parameter record resolves to its default stored value.
384fn equals_default(record: &Record, registry: &dyn ParamRegistry) -> bool {
385    if !matches!(record, Record::Param { .. } | Record::Menu { .. }) {
386        return false;
387    }
388    let Ok(Resolved::Param { key, stored, .. }) = resolve_record(record, registry) else {
389        return false;
390    };
391    registry.get(&key).is_some_and(|spec| {
392        // The law's round trip through a unit string can move a value by a
393        // few ulps, so a value that close to the default counts as it.
394        (stored - spec.default).abs() <= 1e-9 * (spec.stored_max - spec.stored_min).abs()
395    })
396}
397
398/// The settings the groups ask for and the fields they leave unwritten.
399#[must_use]
400pub fn plan(groups: &PitchEnvelopeGroups) -> Plan {
401    let mut plan = Plan::default();
402    if let Some(voices) = &groups.voices {
403        plan_voices(voices, &mut plan);
404    }
405    if let Some(pitch) = &groups.pitch {
406        plan_pitch(pitch, &mut plan);
407    }
408    if let Some(envelope) = &groups.envelope {
409        plan_envelope(envelope, &mut plan);
410    }
411    plan
412}
413
414impl Plan {
415    fn want(&mut self, want: Want, confidence: Level) {
416        self.wants.push(Wanted { want, confidence });
417    }
418
419    fn skip(&mut self, field: &str, reason: UnwrittenReason) {
420        self.unwritten.push(Unwritten::new(field, reason));
421    }
422}
423
424/// Mono or poly, legato and the voice count. A poly stem writes legato off.
425/// Each needs a verdict of medium confidence or higher (owner decision on
426/// #73): a low verdict writes nothing, and `unwritten` lists the field so
427/// the explanation can say to set it by ear.
428fn plan_voices(voices: &VoicesValues, plan: &mut Plan) {
429    let group = voices.level;
430    if group == Level::Low {
431        for field in ["mono", "joins.legato", "max_polyphony"] {
432            plan.skip(field, UnwrittenReason::LowConfidence);
433        }
434        return;
435    }
436    if !voices.mono {
437        plan.want(Want::Mono(false), group);
438        plan.want(Want::Legato(false), group);
439        plan.want(Want::Voices(voices.max_polyphony), group);
440        return;
441    }
442    plan.want(Want::Mono(true), group);
443    let verdicts: Vec<(bool, Level)> = voices
444        .joins
445        .iter()
446        .filter_map(|join| Some((join.legato?, join.level.min(group))))
447        .filter(|(_, level)| *level > Level::Low)
448        .collect();
449    // Legato on when a confident join reads legato, off when every
450    // confident join retriggers: a synth's legato acts only where notes
451    // overlap, so one legato join needs it on.
452    let on: Vec<Level> = verdicts
453        .iter()
454        .filter(|(legato, _)| *legato)
455        .map(|(_, level)| *level)
456        .collect();
457    if let Some(level) = on.iter().min() {
458        plan.want(Want::Legato(true), *level);
459    } else if let Some(level) = verdicts.iter().map(|(_, level)| *level).min() {
460        plan.want(Want::Legato(false), level);
461    } else if !voices.joins.is_empty() {
462        plan.skip("joins.legato", UnwrittenReason::LowConfidence);
463    }
464}
465
466/// The glide time and always-glide from the confident glides, and the pitch
467/// drop from the confident drops.
468fn plan_pitch(pitch: &PitchValues, plan: &mut Plan) {
469    let group = pitch.level;
470    let glides: Vec<&GlideValue> = pitch
471        .glides
472        .iter()
473        .filter(|glide| glide.level.min(group) > Level::Low)
474        .collect();
475    if let Some(level) = lowest(glides.iter().map(|glide| glide.level), group) {
476        let time = median(glides.iter().map(|glide| glide.time.get()));
477        plan.want(Want::GlideTime(Seconds::clamped(time)), level);
478        plan.skip("glides.curve", UnwrittenReason::CurveNotSet);
479        if glides.iter().any(|glide| glide.from_silence) {
480            plan.want(Want::GlideFromSilence, level);
481        }
482    } else if !pitch.glides.is_empty() {
483        plan.skip("glides.time_s", UnwrittenReason::LowConfidence);
484    }
485    let drops: Vec<&DropValue> = pitch
486        .drops
487        .iter()
488        .filter(|drop| drop.level.min(group) > Level::Low)
489        .collect();
490    if let Some(level) = lowest(drops.iter().map(|drop| drop.level), group) {
491        let depth = median(drops.iter().map(|drop| drop.df.get()));
492        let tau = median(drops.iter().map(|drop| drop.tau.get()));
493        plan.want(
494            Want::PitchDrop {
495                depth: Semitones::clamped(depth),
496                tau: Seconds::clamped(tau),
497            },
498            level,
499        );
500    } else if !pitch.drops.is_empty() {
501        plan.skip("drops.df_st", UnwrittenReason::LowConfidence);
502    }
503}
504
505/// The amplitude stages and the velocity path. Tail and pump have no
506/// envelope key: they belong to the space and modulation layers.
507fn plan_envelope(envelope: &EnvelopeValues, plan: &mut Plan) {
508    let group = envelope.level;
509    if envelope.attack_is_upper_bound {
510        plan.skip("attack_s", UnwrittenReason::UpperBound);
511    } else {
512        stage(
513            plan,
514            envelope.attack,
515            group,
516            ("attack_s", Want::Attack),
517            true,
518        );
519    }
520    // Only a plateau over HOLD_MIN is a hold; the estimator reads a
521    // shorter one as 0.
522    match envelope.hold {
523        Some(hold) if hold.value <= HOLD_MIN => {}
524        hold => stage(plan, hold, group, ("hold_s", Want::Hold), false),
525    }
526    stage(plan, envelope.decay, group, ("decay_s", Want::Decay), true);
527    match envelope
528        .sustain
529        .filter(|sustain| sustain.level.min(group) > Level::Low)
530    {
531        Some(sustain) => plan.want(Want::Sustain(sustain.value), sustain.level.min(group)),
532        None => plan.skip("sustain_db", UnwrittenReason::LowConfidence),
533    }
534    // A release under a heavy tail or with a large residual has no value or
535    // a low release confidence: the report cannot tell it.
536    let identifiable = envelope
537        .release_confidence
538        .is_some_and(|level| level > Level::Low);
539    match (envelope.release, identifiable) {
540        (release @ Some(_), true) => {
541            stage(plan, release, group, ("release_s", Want::Release), true);
542        }
543        _ => plan.skip("release_s", UnwrittenReason::NotIdentifiable),
544    }
545    match envelope.level_spread {
546        Some(spread) if velocity_reaches_level(Some(spread)) => {
547            plan.want(Want::VelocityToLevel { spread }, Level::Low);
548        }
549        Some(_) => {}
550        None => plan.skip("level_spread_db", UnwrittenReason::LowConfidence),
551    }
552}
553
554/// A timed stage: its setting when confident, else a low-confidence entry;
555/// a stage with a curve also lists the curve as not set.
556fn stage(
557    plan: &mut Plan,
558    reading: Option<Reading<Seconds>>,
559    group: Level,
560    (field, want): (&str, fn(Seconds) -> Want),
561    curve: bool,
562) {
563    match reading.filter(|reading| reading.level.min(group) > Level::Low) {
564        Some(reading) => {
565            plan.want(want(reading.value), reading.level.min(group));
566            if curve {
567                plan.skip(field, UnwrittenReason::CurveNotSet);
568            }
569        }
570        None => plan.skip(field, UnwrittenReason::LowConfidence),
571    }
572}
573
574/// The lowest of some levels capped by the group, or `None` when there are
575/// none.
576fn lowest(levels: impl Iterator<Item = Level>, group: Level) -> Option<Level> {
577    levels.min().map(|level| level.min(group))
578}
579
580/// The median of a non-empty list.
581fn median(values: impl Iterator<Item = f64>) -> f64 {
582    let values: Vec<f64> = values.collect();
583    Summary::of(&values).map_or(0.0, Summary::median)
584}
585
586#[cfg(test)]
587mod tests {
588    use std::sync::LazyLock;
589
590    use super::*;
591    use crate::cast::usize_to_f64;
592    use crate::preset::edits::{EditList, ParamValue, Path, PolarityText, parse, resolve};
593    use crate::preset::mapper::{
594        Amount, UnrepresentableReason, fit_flag, fit_param, fit_route, fit_stored,
595    };
596    use crate::preset::registry::tests::spec;
597    use crate::preset::registry::{
598        Law, ParamKind, ParamSpec, Physical, Provenance, Registry, UnitKind,
599    };
600    use crate::synth::Synth;
601
602    const LINEAR: Law = Law::Affine {
603        scale: 1.0,
604        offset: 0.0,
605    };
606
607    fn seconds(key: &str, max: f64, default: f64) -> ParamSpec {
608        ParamSpec {
609            default,
610            ..spec(key, LINEAR, 0.0, max, UnitKind::Seconds)
611        }
612    }
613
614    fn flag(key: &str) -> ParamSpec {
615        ParamSpec {
616            kind: ParamKind::Flag,
617            law: None,
618            ..spec(key, LINEAR, 0.0, 1.0, UnitKind::Seconds)
619        }
620    }
621
622    fn interim(spec: ParamSpec) -> ParamSpec {
623        ParamSpec {
624            provenance: Provenance::Interim,
625            tuning_note: Some(format!("tune {} by ear", spec.key)),
626            ..spec
627        }
628    }
629
630    /// A synth with one control per setting. Glide time, sustain, velocity
631    /// and the drop envelope are interim, as on a synth with no measured
632    /// law for them.
633    static TEST_REGISTRY: LazyLock<Registry> = LazyLock::new(|| {
634        Registry::new(
635            Synth::Vital,
636            vec![
637                flag("mono"),
638                flag("legato"),
639                flag("glide_always"),
640                ParamSpec {
641                    kind: ParamKind::Count,
642                    law: None,
643                    default: 8.0,
644                    ..spec("voices", LINEAR, 1.0, 16.0, UnitKind::Seconds)
645                },
646                interim(seconds("glide_time", 10.0, 0.0)),
647                interim(ParamSpec {
648                    law: None,
649                    ..spec("glide_curve", LINEAR, -1.0, 1.0, UnitKind::Seconds)
650                }),
651                seconds("attack", 20.0, 0.001),
652                seconds("hold", 20.0, 0.0),
653                seconds("decay", 20.0, 1.0),
654                interim(ParamSpec {
655                    law: None,
656                    default: 1.0,
657                    ..spec("sustain", LINEAR, 0.0, 1.0, UnitKind::Seconds)
658                }),
659                seconds("release", 20.0, 0.05),
660                interim(spec(
661                    "velocity_amount",
662                    Law::Affine {
663                        scale: 100.0,
664                        offset: 0.0,
665                    },
666                    0.0,
667                    1.0,
668                    UnitKind::PercentUnipolar,
669                )),
670                ParamSpec {
671                    kind: ParamKind::Count,
672                    default: 0.0,
673                    ..spec("pitch_transpose", LINEAR, -24.0, 24.0, UnitKind::Semitones)
674                },
675                interim(seconds("drop_decay", 20.0, 1.0)),
676            ],
677        )
678        .unwrap()
679    });
680
681    /// The test synth's laws: the interim laws this issue names. Sustain is
682    /// linear amplitude, velocity gets 50 %, the drop envelope decays over
683    /// three time constants.
684    struct TestControls;
685
686    impl PitchEnvelopeControls for TestControls {
687        fn edits(&self, wanted: &Wanted, ctx: &MapContext<'_>) -> Mapped {
688            let (registry, field) = (ctx.registry, wanted.want.field());
689            let (reason, level) = (wanted.want.group(), wanted.confidence);
690            let time = |key: &str, value: Seconds| {
691                fit_param(
692                    registry,
693                    field,
694                    key,
695                    Physical::Seconds(value),
696                    reason,
697                    level,
698                )
699            };
700            match wanted.want {
701                Want::Mono(on) => fit_flag(registry, field, "mono", on, reason, level),
702                Want::Legato(on) => fit_flag(registry, field, "legato", on, reason, level),
703                Want::Voices(count) => fit_stored(
704                    registry,
705                    field,
706                    "voices",
707                    usize_to_f64(count),
708                    reason,
709                    level,
710                ),
711                Want::GlideTime(value) => time("glide_time", value),
712                Want::GlideFromSilence => {
713                    fit_flag(registry, field, "glide_always", true, reason, level)
714                }
715                Want::PitchDrop { depth, tau } => {
716                    let mut mapped = fit_route(
717                        registry,
718                        field,
719                        ("env_2", "pitch_transpose"),
720                        depth,
721                        Some(PolarityText::Unipolar),
722                        reason,
723                        level,
724                    );
725                    mapped.extend(fit_param(
726                        registry,
727                        "drops.tau_s",
728                        "drop_decay",
729                        Physical::Seconds(Seconds::clamped(3.0 * tau.get())),
730                        reason,
731                        level,
732                    ));
733                    mapped
734                }
735                Want::VelocityToLevel { .. } => fit_param(
736                    registry,
737                    field,
738                    "velocity_amount",
739                    Physical::new(UnitKind::PercentUnipolar, 50.0).unwrap(),
740                    reason,
741                    level,
742                ),
743                Want::Attack(value) => time("attack", value),
744                Want::Hold(value) => time("hold", value),
745                Want::Decay(value) => time("decay", value),
746                Want::Release(value) => time("release", value),
747                Want::Sustain(db) => fit_stored(
748                    registry,
749                    field,
750                    "sustain",
751                    10f64.powf(db.get() / 20.0),
752                    reason,
753                    level,
754                ),
755            }
756        }
757    }
758
759    /// The keys an adapter writes each setting to, for the cases below.
760    struct Keys {
761        mono: &'static str,
762        voices: &'static str,
763        glide_time: &'static str,
764        glide_curve: &'static str,
765        glide_always: &'static str,
766        drop_dest: &'static str,
767        drop_decay: &'static str,
768        velocity: &'static str,
769        attack: &'static str,
770        sustain: &'static str,
771        release: &'static str,
772    }
773
774    struct Adapter {
775        controls: &'static (dyn PitchEnvelopeControls + Sync),
776        registry: &'static Registry,
777        keys: Keys,
778    }
779
780    /// Every adapter the cases run on. An adapter task adds its synth here.
781    fn adapters() -> Vec<Adapter> {
782        vec![Adapter {
783            controls: &TestControls,
784            registry: &TEST_REGISTRY,
785            keys: Keys {
786                mono: "mono",
787                voices: "voices",
788                glide_time: "glide_time",
789                glide_curve: "glide_curve",
790                glide_always: "glide_always",
791                drop_dest: "pitch_transpose",
792                drop_decay: "drop_decay",
793                velocity: "velocity_amount",
794                attack: "attack",
795                sustain: "sustain",
796                release: "release",
797            },
798        }]
799    }
800
801    impl Adapter {
802        fn run(&self, groups: &PitchEnvelopeGroups) -> Mapped {
803            let ctx = MapContext {
804                registry: self.registry,
805                capabilities: &crate::preset::vital::CAPABILITIES,
806            };
807            let mapped = map(groups, &ctx, self.controls);
808            for miss in &mapped.unrepresentable {
809                assert!(
810                    group_of(&miss.field).is_some(),
811                    "{} has no group",
812                    miss.field
813                );
814            }
815            mapped
816        }
817
818        /// The edits resolved through the edit-list path, as `preset
819        /// apply` reads them.
820        fn resolved(&self, mapped: &Mapped) -> Vec<Resolved> {
821            let list = EditList {
822                synth: self.registry.synth(),
823                edits: mapped.edits.iter().map(|edit| edit.edit.clone()).collect(),
824                ambiguities: Vec::new(),
825            };
826            let text = serde_json::to_string(&list).unwrap();
827            resolve(&parse(&text, Path::Agent).unwrap(), self.registry).unwrap()
828        }
829
830        fn stored(&self, mapped: &Mapped, key: &str) -> Option<f64> {
831            self.resolved(mapped)
832                .into_iter()
833                .find_map(|item| match item {
834                    Resolved::Param {
835                        key: name, stored, ..
836                    } if name == key => Some(stored),
837                    _ => None,
838                })
839        }
840    }
841
842    fn written(mapped: &Mapped, key: &str) -> bool {
843        mapped
844            .edits
845            .iter()
846            .any(|edit| edit.edit.record.name() == key)
847    }
848
849    fn skipped(mapped: &Mapped, field: &str, reason: UnwrittenReason) -> bool {
850        mapped.unwritten.contains(&Unwritten::new(field, reason))
851    }
852
853    fn s(value: f64) -> Seconds {
854        Seconds::clamped(value)
855    }
856
857    #[expect(
858        clippy::unnecessary_wraps,
859        reason = "a reading of the groups is optional"
860    )]
861    fn high<T>(value: T) -> Option<Reading<T>> {
862        Some(Reading {
863            value,
864            level: Level::High,
865        })
866    }
867
868    fn glide(time: f64, from_silence: bool, level: Level) -> GlideValue {
869        GlideValue {
870            time: s(time),
871            from_silence,
872            level,
873        }
874    }
875
876    fn mono_line(glides: Vec<GlideValue>, drops: Vec<DropValue>) -> PitchEnvelopeGroups {
877        PitchEnvelopeGroups {
878            pitch: Some(PitchValues {
879                level: Level::High,
880                glides,
881                drops,
882            }),
883            voices: Some(VoicesValues {
884                level: Level::High,
885                mono: true,
886                max_polyphony: 1,
887                joins: Vec::new(),
888            }),
889            envelope: None,
890        }
891    }
892
893    fn envelope() -> EnvelopeValues {
894        EnvelopeValues {
895            level: Level::High,
896            attack: high(s(0.02)),
897            attack_is_upper_bound: false,
898            hold: high(s(0.0)),
899            decay: high(s(0.3)),
900            sustain: high(Decibels::clamped(-6.0)),
901            release: high(s(0.2)),
902            release_confidence: Some(Level::Medium),
903            level_spread: Some(Decibels::clamped(1.0)),
904        }
905    }
906
907    fn with_envelope(envelope: EnvelopeValues) -> PitchEnvelopeGroups {
908        PitchEnvelopeGroups {
909            envelope: Some(envelope),
910            ..PitchEnvelopeGroups::default()
911        }
912    }
913
914    #[test]
915    fn a_mono_bass_with_one_glide_sets_mono_and_the_glide_time() {
916        for adapter in adapters() {
917            let keys = &adapter.keys;
918            let mapped = adapter.run(&mono_line(vec![glide(0.12, false, Level::High)], vec![]));
919            assert_eq!(adapter.stored(&mapped, keys.mono), Some(1.0));
920            let time = adapter.stored(&mapped, keys.glide_time).unwrap();
921            let spec = adapter.registry.get(keys.glide_time).unwrap();
922            assert!((spec.physical(time).unwrap().get() - 0.12).abs() < 1e-9);
923            assert!(!written(&mapped, keys.voices));
924            assert!(!written(&mapped, keys.glide_curve));
925            assert!(skipped(
926                &mapped,
927                "glides.curve",
928                UnwrittenReason::CurveNotSet
929            ));
930            assert!(
931                mapped.unrepresentable.is_empty(),
932                "{:?}",
933                mapped.unrepresentable
934            );
935        }
936    }
937
938    #[test]
939    fn a_glide_beyond_the_maximum_clamps_and_is_listed() {
940        for adapter in adapters() {
941            let keys = &adapter.keys;
942            let spec = adapter.registry.get(keys.glide_time).unwrap();
943            let top = spec.physical(spec.stored_max).unwrap().get();
944            let mapped = adapter.run(&mono_line(
945                vec![glide(top + 10.0, false, Level::High)],
946                vec![],
947            ));
948            assert_eq!(
949                adapter.stored(&mapped, keys.glide_time),
950                Some(spec.stored_max)
951            );
952            let miss = &mapped.unrepresentable[0];
953            assert_eq!(miss.field, "glides.time_s");
954            assert_eq!(miss.reason, UnrepresentableReason::AboveRange);
955            assert!((miss.residual().unwrap() + 10.0).abs() < 1e-9);
956        }
957    }
958
959    #[test]
960    fn a_glide_from_silence_sets_always_glide() {
961        for adapter in adapters() {
962            let keys = &adapter.keys;
963            let mapped = adapter.run(&mono_line(
964                vec![
965                    glide(0.1, false, Level::High),
966                    glide(0.2, true, Level::Medium),
967                ],
968                vec![],
969            ));
970            assert_eq!(adapter.stored(&mapped, keys.glide_always), Some(1.0));
971            let without = adapter.run(&mono_line(vec![glide(0.1, false, Level::High)], vec![]));
972            assert!(!written(&without, keys.glide_always));
973        }
974    }
975
976    #[test]
977    fn the_glide_time_is_the_median_of_the_confident_glides() {
978        let groups = mono_line(
979            vec![
980                glide(0.1, false, Level::High),
981                glide(0.3, false, Level::Medium),
982                glide(0.2, false, Level::High),
983                glide(5.0, true, Level::Low),
984            ],
985            vec![],
986        );
987        let wants = plan(&groups).wants;
988        assert!(wants.contains(&Wanted {
989            want: Want::GlideTime(s(0.2)),
990            confidence: Level::Medium,
991        }));
992        // The low glide from silence counts for nothing.
993        assert!(!wants.iter().any(|w| w.want == Want::GlideFromSilence));
994        let low = plan(&mono_line(vec![glide(0.1, false, Level::Low)], vec![]));
995        assert!(
996            !low.wants
997                .iter()
998                .any(|w| matches!(w.want, Want::GlideTime(_)))
999        );
1000        assert!(low.unwritten.contains(&Unwritten::new(
1001            "glides.time_s",
1002            UnwrittenReason::LowConfidence
1003        )));
1004    }
1005
1006    #[test]
1007    fn a_poly_report_counts_its_voices() {
1008        for adapter in adapters() {
1009            let groups = PitchEnvelopeGroups {
1010                voices: Some(VoicesValues {
1011                    level: Level::High,
1012                    mono: false,
1013                    max_polyphony: 3,
1014                    joins: Vec::new(),
1015                }),
1016                ..PitchEnvelopeGroups::default()
1017            };
1018            let mapped = adapter.run(&groups);
1019            assert_eq!(adapter.stored(&mapped, adapter.keys.voices), Some(3.0));
1020            assert!(
1021                plan(&groups)
1022                    .wants
1023                    .iter()
1024                    .any(|w| w.want == Want::Legato(false))
1025            );
1026        }
1027    }
1028
1029    #[test]
1030    fn legato_follows_the_confident_joins() {
1031        let joins = |verdicts: &[(Option<bool>, Level)]| {
1032            plan(&PitchEnvelopeGroups {
1033                voices: Some(VoicesValues {
1034                    level: Level::High,
1035                    mono: true,
1036                    max_polyphony: 1,
1037                    joins: verdicts
1038                        .iter()
1039                        .map(|&(legato, level)| JoinValue { legato, level })
1040                        .collect(),
1041                }),
1042                ..PitchEnvelopeGroups::default()
1043            })
1044        };
1045        let legato = |plan: &Plan| {
1046            plan.wants.iter().find_map(|w| match w.want {
1047                Want::Legato(on) => Some(on),
1048                _ => None,
1049            })
1050        };
1051        let mixed = joins(&[(Some(false), Level::High), (Some(true), Level::Medium)]);
1052        assert_eq!(legato(&mixed), Some(true));
1053        let retrigger = joins(&[(Some(false), Level::High), (Some(true), Level::Low)]);
1054        assert_eq!(legato(&retrigger), Some(false));
1055        let unknown = joins(&[(None, Level::High), (Some(true), Level::Low)]);
1056        assert_eq!(legato(&unknown), None);
1057        assert!(unknown.unwritten.contains(&Unwritten::new(
1058            "joins.legato",
1059            UnwrittenReason::LowConfidence
1060        )));
1061        assert_eq!(legato(&joins(&[])), None);
1062    }
1063
1064    #[test]
1065    fn a_pitch_drop_routes_an_envelope_to_pitch() {
1066        for adapter in adapters() {
1067            let keys = &adapter.keys;
1068            let drop = |df: f64| DropValue {
1069                df: Semitones::clamped(df),
1070                tau: s(0.03),
1071                level: Level::High,
1072            };
1073            let mapped = adapter.run(&mono_line(vec![], vec![drop(7.0)]));
1074            let route = mapped
1075                .edits
1076                .iter()
1077                .find(|edit| matches!(&edit.edit.record, Record::Route(r) if r.dest == keys.drop_dest))
1078                .unwrap();
1079            assert!(route.uncalibrated);
1080            let Record::Route(record) = &route.edit.record else {
1081                unreachable!()
1082            };
1083            assert_eq!(record.polarity, Some(PolarityText::Unipolar));
1084            let dest = adapter.registry.get(keys.drop_dest).unwrap();
1085            let span = dest.stored_max - dest.stored_min;
1086            let amount = adapter
1087                .resolved(&mapped)
1088                .into_iter()
1089                .find_map(|item| match item {
1090                    Resolved::Route { amount, .. } => Some(amount),
1091                    _ => None,
1092                })
1093                .unwrap();
1094            assert!((amount - 7.0 / span).abs() < 1e-9);
1095            let decay = mapped
1096                .edits
1097                .iter()
1098                .find(|edit| edit.edit.record.name() == keys.drop_decay)
1099                .unwrap();
1100            assert!(decay.uncalibrated);
1101            assert!(
1102                adapter
1103                    .registry
1104                    .get(keys.drop_decay)
1105                    .unwrap()
1106                    .tuning_note
1107                    .is_some()
1108            );
1109            assert_eq!(mapped.unrepresentable, Vec::new());
1110
1111            let slow = adapter.run(&mono_line(
1112                vec![],
1113                vec![DropValue {
1114                    tau: s(100.0),
1115                    ..drop(7.0)
1116                }],
1117            ));
1118            let miss = &slow.unrepresentable[0];
1119            assert_eq!(miss.field, "drops.tau_s");
1120            assert_eq!(group_of(&miss.field), Some("pitch"));
1121            assert!(matches!(&miss.wanted, Amount::Value { unit, .. } if unit == "s"));
1122
1123            let deep = adapter.run(&mono_line(vec![], vec![drop(span + 4.0)]));
1124            let miss = &deep.unrepresentable[0];
1125            assert_eq!(miss.field, "drops.df_st");
1126            assert_eq!(miss.reason, UnrepresentableReason::AboveRange);
1127            assert!((miss.residual().unwrap() + 4.0).abs() < 1e-9);
1128        }
1129    }
1130
1131    #[test]
1132    fn velocity_reaches_the_level_above_3_db_only() {
1133        assert!(!velocity_reaches_level(None));
1134        assert!(!velocity_reaches_level(Some(Decibels::clamped(2.0))));
1135        assert!(!velocity_reaches_level(Some(Decibels::clamped(3.0))));
1136        assert!(velocity_reaches_level(Some(Decibels::clamped(3.01))));
1137        for adapter in adapters() {
1138            let spread = |db: f64| {
1139                adapter.run(&with_envelope(EnvelopeValues {
1140                    level_spread: Some(Decibels::clamped(db)),
1141                    ..envelope()
1142                }))
1143            };
1144            let wide = spread(4.0);
1145            let edit = wide
1146                .edits
1147                .iter()
1148                .find(|edit| edit.edit.record.name() == adapter.keys.velocity)
1149                .unwrap();
1150            assert_eq!(edit.confidence, Level::Low);
1151            assert!(!written(&spread(2.0), adapter.keys.velocity));
1152        }
1153    }
1154
1155    #[test]
1156    fn a_value_equal_to_its_default_gives_no_edit() {
1157        for adapter in adapters() {
1158            let keys = &adapter.keys;
1159            let default_of = |key: &str| {
1160                let spec = adapter.registry.get(key).unwrap();
1161                spec.physical(spec.default).unwrap().get()
1162            };
1163            let mapped = adapter.run(&with_envelope(EnvelopeValues {
1164                attack: high(s(default_of(keys.attack))),
1165                sustain: high(Decibels::clamped(0.0)),
1166                ..envelope()
1167            }));
1168            assert!(!written(&mapped, keys.attack));
1169            assert!(!written(&mapped, keys.sustain));
1170            assert!(written(&mapped, keys.release));
1171        }
1172    }
1173
1174    #[test]
1175    fn low_and_flagged_fields_give_no_edit() {
1176        for adapter in adapters() {
1177            let keys = &adapter.keys;
1178            let low = Some(Reading {
1179                value: s(0.05),
1180                level: Level::Low,
1181            });
1182            let mapped = adapter.run(&with_envelope(EnvelopeValues {
1183                attack: low,
1184                decay: None,
1185                ..envelope()
1186            }));
1187            assert!(!written(&mapped, keys.attack));
1188            assert!(skipped(&mapped, "attack_s", UnwrittenReason::LowConfidence));
1189            assert!(skipped(&mapped, "decay_s", UnwrittenReason::LowConfidence));
1190
1191            let bound = adapter.run(&with_envelope(EnvelopeValues {
1192                attack_is_upper_bound: true,
1193                ..envelope()
1194            }));
1195            assert!(!written(&bound, keys.attack));
1196            assert!(skipped(&bound, "attack_s", UnwrittenReason::UpperBound));
1197
1198            // A tail heavier than 0.5 leaves no release and a low release
1199            // confidence.
1200            let tail = adapter.run(&with_envelope(EnvelopeValues {
1201                release: None,
1202                release_confidence: Some(Level::Low),
1203                ..envelope()
1204            }));
1205            assert!(!written(&tail, keys.release));
1206            assert!(skipped(
1207                &tail,
1208                "release_s",
1209                UnwrittenReason::NotIdentifiable
1210            ));
1211            let unsure = adapter.run(&with_envelope(EnvelopeValues {
1212                release_confidence: Some(Level::Low),
1213                ..envelope()
1214            }));
1215            assert!(!written(&unsure, keys.release));
1216        }
1217    }
1218
1219    #[test]
1220    fn the_group_level_caps_its_records() {
1221        let pitch = |group: Level| {
1222            plan(&PitchEnvelopeGroups {
1223                pitch: Some(PitchValues {
1224                    level: group,
1225                    glides: vec![glide(0.1, true, Level::High)],
1226                    drops: vec![DropValue {
1227                        df: Semitones::clamped(5.0),
1228                        tau: s(0.02),
1229                        level: Level::High,
1230                    }],
1231                }),
1232                ..PitchEnvelopeGroups::default()
1233            })
1234        };
1235        let low = pitch(Level::Low);
1236        assert_eq!(low.wants, Vec::new());
1237        assert_eq!(
1238            low.unwritten,
1239            vec![
1240                Unwritten::new("glides.time_s", UnwrittenReason::LowConfidence),
1241                Unwritten::new("drops.df_st", UnwrittenReason::LowConfidence),
1242            ]
1243        );
1244        let medium = pitch(Level::Medium);
1245        assert_eq!(medium.wants.len(), 3);
1246        assert!(medium.wants.iter().all(|w| w.confidence == Level::Medium));
1247
1248        let envelope_at = |group: Level| {
1249            plan(&with_envelope(EnvelopeValues {
1250                level: group,
1251                ..envelope()
1252            }))
1253        };
1254        let low = envelope_at(Level::Low);
1255        assert_eq!(low.wants, Vec::new());
1256        for field in ["attack_s", "decay_s", "sustain_db", "release_s"] {
1257            assert!(
1258                low.unwritten
1259                    .contains(&Unwritten::new(field, UnwrittenReason::LowConfidence)),
1260                "{field}"
1261            );
1262        }
1263        let medium = envelope_at(Level::Medium);
1264        assert_ne!(medium.wants.len(), 0);
1265        assert!(medium.wants.iter().all(|w| w.confidence == Level::Medium));
1266
1267        let low_sustain = plan(&with_envelope(EnvelopeValues {
1268            sustain: Some(Reading {
1269                value: Decibels::clamped(-6.0),
1270                level: Level::Low,
1271            }),
1272            ..envelope()
1273        }));
1274        assert!(
1275            !low_sustain
1276                .wants
1277                .iter()
1278                .any(|w| matches!(w.want, Want::Sustain(_)))
1279        );
1280        assert!(low_sustain.unwritten.contains(&Unwritten::new(
1281            "sustain_db",
1282            UnwrittenReason::LowConfidence
1283        )));
1284    }
1285
1286    #[test]
1287    fn a_hold_needs_a_plateau() {
1288        let hold = |value: f64| {
1289            plan(&with_envelope(EnvelopeValues {
1290                hold: high(s(value)),
1291                ..envelope()
1292            }))
1293            .wants
1294            .iter()
1295            .any(|w| matches!(w.want, Want::Hold(_)))
1296        };
1297        assert!(!hold(0.0));
1298        assert!(!hold(0.009));
1299        assert!(!hold(0.01));
1300        assert!(hold(0.011));
1301        assert!(hold(0.05));
1302    }
1303
1304    #[test]
1305    fn every_edit_has_a_unit_a_range_and_a_reason() {
1306        for adapter in adapters() {
1307            let groups = PitchEnvelopeGroups {
1308                envelope: Some(EnvelopeValues {
1309                    hold: high(s(0.02)),
1310                    level_spread: Some(Decibels::clamped(6.0)),
1311                    ..envelope()
1312                }),
1313                ..mono_line(
1314                    vec![glide(0.12, true, Level::High)],
1315                    vec![DropValue {
1316                        df: Semitones::clamped(12.0),
1317                        tau: s(0.02),
1318                        level: Level::Medium,
1319                    }],
1320                )
1321            };
1322            let mapped = adapter.run(&groups);
1323            assert!(mapped.edits.len() >= 10, "{:?}", mapped.edits);
1324            let names: Vec<&str> = mapped.edits.iter().map(|e| e.edit.record.name()).collect();
1325            let mut sorted = names.clone();
1326            sorted.sort_unstable();
1327            assert_eq!(names, sorted);
1328            for edit in &mapped.edits {
1329                let reason = edit.edit.reason.as_deref().unwrap();
1330                assert!(["pitch", "voices", "envelope"].contains(&reason));
1331                let spec = adapter.registry.get(edit.edit.record.name()).unwrap();
1332                if let Record::Param { value, .. } = &edit.edit.record
1333                    && spec.unit().is_some()
1334                {
1335                    assert!(matches!(value, ParamValue::Text(_)), "{value:?}");
1336                }
1337                if spec.provenance == Provenance::Interim {
1338                    assert!(edit.uncalibrated, "{}", spec.key);
1339                    assert!(spec.tuning_note.is_some());
1340                }
1341            }
1342            for item in adapter.resolved(&mapped) {
1343                if let Resolved::Param { key, stored, .. } = item {
1344                    assert!(adapter.registry.get(&key).unwrap().accepts(stored));
1345                }
1346            }
1347        }
1348    }
1349
1350    #[test]
1351    fn every_field_has_its_group() {
1352        let (t, db) = (s(0.0), Decibels::clamped(0.0));
1353        for want in [
1354            Want::Mono(false),
1355            Want::Legato(false),
1356            Want::Voices(1),
1357            Want::GlideTime(t),
1358            Want::GlideFromSilence,
1359            Want::PitchDrop {
1360                depth: Semitones::clamped(0.0),
1361                tau: t,
1362            },
1363            Want::VelocityToLevel { spread: db },
1364            Want::Attack(t),
1365            Want::Hold(t),
1366            Want::Decay(t),
1367            Want::Sustain(db),
1368            Want::Release(t),
1369        ] {
1370            assert_eq!(group_of(want.field()), Some(want.group()), "{want:?}");
1371        }
1372        for (field, group) in [("glides.curve", "pitch"), ("drops.tau_s", "pitch")] {
1373            assert_eq!(group_of(field), Some(group));
1374        }
1375        assert_eq!(group_of("vibrato.rate_hz"), None);
1376    }
1377
1378    /// Owner decision on #73: mono and legato need a voices verdict of
1379    /// medium or higher; a low verdict writes nothing and lists the field.
1380    #[test]
1381    fn voicing_needs_a_medium_verdict() {
1382        let voices = |level: Level, mono: bool, legato: Level| {
1383            plan(&PitchEnvelopeGroups {
1384                voices: Some(VoicesValues {
1385                    level,
1386                    mono,
1387                    max_polyphony: if mono { 1 } else { 4 },
1388                    joins: vec![JoinValue {
1389                        legato: Some(true),
1390                        level: legato,
1391                    }],
1392                }),
1393                ..PitchEnvelopeGroups::default()
1394            })
1395        };
1396        let wants = |plan: &Plan| plan.wants.iter().map(|w| w.want).collect::<Vec<_>>();
1397        // A low line lists every voicing field, read mono or not, so the
1398        // explanation can say to set legato by ear.
1399        let every = ["mono", "joins.legato", "max_polyphony"]
1400            .map(|field| Unwritten::new(field, UnwrittenReason::LowConfidence))
1401            .to_vec();
1402        for mono in [true, false] {
1403            let low = voices(Level::Low, mono, Level::High);
1404            assert_eq!(wants(&low), Vec::new());
1405            assert_eq!(low.unwritten, every);
1406        }
1407        assert_eq!(
1408            wants(&voices(Level::Medium, true, Level::Medium)),
1409            vec![Want::Mono(true), Want::Legato(true)]
1410        );
1411        // A medium line whose only legato verdict is low writes mono and
1412        // leaves legato to the ear.
1413        let unsure = voices(Level::Medium, true, Level::Low);
1414        assert_eq!(wants(&unsure), vec![Want::Mono(true)]);
1415        assert_eq!(
1416            unsure.unwritten,
1417            vec![Unwritten::new(
1418                "joins.legato",
1419                UnwrittenReason::LowConfidence
1420            )]
1421        );
1422    }
1423
1424    #[test]
1425    fn empty_groups_map_to_nothing() {
1426        for adapter in adapters() {
1427            assert_eq!(
1428                adapter.run(&PitchEnvelopeGroups::default()),
1429                Mapped::default()
1430            );
1431        }
1432    }
1433}