1use 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
27pub const VELOCITY_SPREAD: Decibels = Decibels::from_const(3.0);
30
31#[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#[derive(Clone, Copy, Debug, PartialEq)]
55pub struct Reading<T> {
56 pub value: T,
58 pub level: Level,
60}
61
62#[derive(Clone, Debug, Default, PartialEq)]
66#[non_exhaustive]
67pub struct PitchEnvelopeGroups {
68 pub pitch: Option<PitchValues>,
70 pub voices: Option<VoicesValues>,
72 pub envelope: Option<EnvelopeValues>,
74}
75
76#[derive(Clone, Debug, PartialEq)]
78pub struct PitchValues {
79 pub level: Level,
81 pub glides: Vec<GlideValue>,
83 pub drops: Vec<DropValue>,
85}
86
87#[derive(Clone, Copy, Debug, PartialEq)]
89pub struct GlideValue {
90 pub time: Seconds,
92 pub from_silence: bool,
94 pub level: Level,
96}
97
98#[derive(Clone, Copy, Debug, PartialEq)]
100pub struct DropValue {
101 pub df: Semitones,
103 pub tau: Seconds,
105 pub level: Level,
107}
108
109#[derive(Clone, Debug, PartialEq)]
111pub struct VoicesValues {
112 pub level: Level,
114 pub mono: bool,
116 pub max_polyphony: usize,
118 pub joins: Vec<JoinValue>,
120}
121
122#[derive(Clone, Copy, Debug, PartialEq)]
124pub struct JoinValue {
125 pub legato: Option<bool>,
127 pub level: Level,
129}
130
131#[derive(Clone, Debug, PartialEq)]
134pub struct EnvelopeValues {
135 pub level: Level,
137 pub attack: Option<Reading<Seconds>>,
139 pub attack_is_upper_bound: bool,
141 pub hold: Option<Reading<Seconds>>,
143 pub decay: Option<Reading<Seconds>>,
145 pub sustain: Option<Reading<Decibels>>,
147 pub release: Option<Reading<Seconds>>,
149 pub release_confidence: Option<Level>,
151 pub level_spread: Option<Decibels>,
153}
154
155impl PitchEnvelopeGroups {
156 #[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
215fn 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#[derive(Clone, Copy, Debug, PartialEq)]
225#[non_exhaustive]
226pub enum Want {
227 Mono(bool),
229 Legato(bool),
231 Voices(usize),
235 GlideTime(Seconds),
238 GlideFromSilence,
240 PitchDrop {
246 depth: Semitones,
248 tau: Seconds,
250 },
251 VelocityToLevel {
253 spread: Decibels,
256 },
257 Attack(Seconds),
259 Hold(Seconds),
261 Decay(Seconds),
263 Sustain(Decibels),
266 Release(Seconds),
268}
269
270impl Want {
271 #[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 #[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#[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#[derive(Clone, Copy, Debug, PartialEq)]
327pub struct Wanted {
328 pub want: Want,
330 pub confidence: Level,
332}
333
334#[derive(Clone, Debug, Default, PartialEq)]
336pub struct Plan {
337 pub wants: Vec<Wanted>,
339 pub unwritten: Vec<Unwritten>,
341}
342
343pub trait PitchEnvelopeControls {
345 fn edits(&self, wanted: &Wanted, ctx: &MapContext<'_>) -> Mapped;
353}
354
355#[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
383fn 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 (stored - spec.default).abs() <= 1e-9 * (spec.stored_max - spec.stored_min).abs()
395 })
396}
397
398#[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
424fn 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 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
466fn 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
505fn 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 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 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
554fn 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
574fn lowest(levels: impl Iterator<Item = Level>, group: Level) -> Option<Level> {
577 levels.min().map(|level| level.min(group))
578}
579
580fn 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 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 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 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 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 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 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 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 #[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 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 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}