1use std::collections::HashMap;
23use std::error::Error;
24use std::fmt;
25
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29use crate::preset::format::{RoutePolarity, Stored};
30use crate::preset::registry::{Law, ParamKind, ParamRegistry, ParamSpec, Physical, UnitKind};
31use crate::synth::Synth;
32use crate::units::{Bipolar, Percent, Semitones};
33
34#[derive(Clone, Debug, PartialEq, Serialize)]
37pub struct EditList {
38 pub synth: Synth,
40 pub edits: Vec<Edit>,
42 pub ambiguities: Vec<Decision>,
44}
45
46#[derive(Clone, Debug, PartialEq)]
48pub struct Edit {
49 pub record: Record,
51 pub reason: Option<String>,
54}
55
56#[derive(Clone, Debug, PartialEq)]
58pub enum Record {
59 Param {
61 param: String,
63 value: ParamValue,
65 },
66 Route(RouteRecord),
68 Menu {
70 menu: String,
72 choice: String,
74 },
75 Raw {
78 key: String,
80 value: Stored,
82 },
83}
84
85#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
87#[serde(untagged)]
88pub enum ParamValue {
89 Flag(bool),
91 Number(f64),
94 Text(String),
96}
97
98impl fmt::Display for ParamValue {
99 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100 match self {
101 Self::Flag(flag) => write!(f, "{flag}"),
102 Self::Number(number) => write!(f, "{number}"),
103 Self::Text(text) => write!(f, "{text:?}"),
104 }
105 }
106}
107
108#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct RouteRecord {
112 pub source: String,
114 pub dest: String,
116 pub amount: String,
119 #[serde(default)]
121 pub polarity: Option<PolarityText>,
122}
123
124#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum PolarityText {
128 Unipolar,
130 Bipolar,
132}
133
134impl From<PolarityText> for RoutePolarity {
135 fn from(text: PolarityText) -> Self {
136 match text {
137 PolarityText::Unipolar => Self::Unipolar,
138 PolarityText::Bipolar => Self::Bipolar,
139 }
140 }
141}
142
143#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct Decision {
147 pub id: String,
149 pub choice: String,
151 pub reason: String,
153}
154
155#[derive(Clone, Debug, PartialEq, Eq)]
157pub struct EditError {
158 pub place: String,
160 pub cause: String,
162 pub fix: String,
164}
165
166impl fmt::Display for EditError {
167 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168 write!(f, "{}: {}. {}", self.place, self.cause, self.fix)
169 }
170}
171
172impl Error for EditError {}
173
174#[derive(Clone, Debug, PartialEq, Eq)]
176pub struct EditErrors(pub Vec<EditError>);
177
178impl fmt::Display for EditErrors {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 write!(f, "the edit list has {} problem(s):", self.0.len())?;
181 for error in &self.0 {
182 write!(f, "\n {error}")?;
183 }
184 Ok(())
185 }
186}
187
188impl Error for EditErrors {}
189
190fn place(index: usize, name: &str) -> String {
191 format!("edit {} ({name})", index + 1)
192}
193
194const MAX_PROBLEMS: usize = 50;
197
198fn stopped() -> EditError {
200 EditError {
201 place: "the edit list".to_owned(),
202 cause: format!("the check stopped after {MAX_PROBLEMS} problems"),
203 fix: "Fix these and run the command again".to_owned(),
204 }
205}
206
207#[derive(Deserialize)]
208#[serde(deny_unknown_fields)]
209struct ParamData {
210 param: String,
211 value: ParamValue,
212 #[serde(default)]
213 reason: Option<String>,
214 #[serde(default, rename = "uncalibrated")]
217 _uncalibrated: Option<bool>,
218 #[serde(default, rename = "confidence")]
219 _confidence: Option<crate::confidence::Level>,
220}
221
222#[derive(Deserialize)]
223#[serde(deny_unknown_fields)]
224struct RouteData {
225 route: RouteRecord,
226 #[serde(default)]
227 reason: Option<String>,
228 #[serde(default, rename = "uncalibrated")]
231 _uncalibrated: Option<bool>,
232 #[serde(default, rename = "confidence")]
233 _confidence: Option<crate::confidence::Level>,
234}
235
236#[derive(Deserialize)]
237#[serde(deny_unknown_fields)]
238struct MenuData {
239 menu: String,
240 choice: String,
241 #[serde(default)]
242 reason: Option<String>,
243}
244
245#[derive(Deserialize)]
246#[serde(deny_unknown_fields)]
247struct RawData {
248 raw: RawValue,
249 #[serde(default)]
250 reason: Option<String>,
251}
252
253#[derive(Deserialize)]
254#[serde(deny_unknown_fields)]
255struct RawValue {
256 key: String,
257 value: Value,
258}
259
260#[derive(Deserialize)]
261#[serde(deny_unknown_fields)]
262struct ListData {
263 synth: Synth,
264 edits: Vec<Value>,
265 #[serde(default)]
266 ambiguities: Vec<Decision>,
267}
268
269#[derive(Clone, Copy, Debug, PartialEq, Eq)]
271pub enum Path {
272 Agent,
274 Report,
276}
277
278pub fn parse(text: &str, path: Path) -> Result<EditList, EditErrors> {
287 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
288 let data: ListData = serde_json::from_str(text).map_err(|error| {
289 EditErrors(vec![EditError {
290 place: "the edit list".to_owned(),
291 cause: format!("it does not read: {error}"),
292 fix: "Write one JSON object with `synth`, `edits` and an optional `ambiguities` list"
293 .to_owned(),
294 }])
295 })?;
296 let mut edits = Vec::new();
297 let mut errors = Vec::new();
298 for (index, value) in data.edits.into_iter().enumerate() {
299 if errors.len() >= MAX_PROBLEMS {
300 errors.push(stopped());
301 break;
302 }
303 match parse_edit(&value) {
304 Ok(edit) => {
305 if path == Path::Agent && edit.reason.as_deref().is_none_or(|r| r.trim().is_empty())
306 {
307 errors.push(EditError {
308 place: place(index, edit.record.name()),
309 cause: "the edit has no reason".to_owned(),
310 fix: "Add a `reason` that says what was heard or measured".to_owned(),
311 });
312 }
313 edits.push(edit);
314 }
315 Err((name, cause, fix)) => errors.push(EditError {
316 place: place(index, &name),
317 cause,
318 fix,
319 }),
320 }
321 }
322 if errors.is_empty() {
323 Ok(EditList {
324 synth: data.synth,
325 edits,
326 ambiguities: data.ambiguities,
327 })
328 } else {
329 Err(EditErrors(errors))
330 }
331}
332
333type RecordError = (String, String, String);
334
335fn parse_edit(value: &Value) -> Result<Edit, RecordError> {
336 let kinds = ["param", "route", "menu", "raw"];
337 let Value::Object(map) = value else {
338 return Err((
339 "?".to_owned(),
340 format!("the edit is {value}, not an object"),
341 "Write an object with one of `param`, `route`, `menu` or `raw`".to_owned(),
342 ));
343 };
344 let found: Vec<&str> = kinds
345 .into_iter()
346 .filter(|kind| map.contains_key(*kind))
347 .collect();
348 let name = map
349 .get("param")
350 .or_else(|| map.get("menu"))
351 .and_then(Value::as_str)
352 .or_else(|| {
353 map.get("raw")
354 .and_then(|raw| raw.get("key"))
355 .and_then(Value::as_str)
356 })
357 .unwrap_or_else(|| found.first().copied().unwrap_or("?"))
358 .to_owned();
359 let invalid = |error: serde_json::Error, example: &str| {
360 (
361 name.clone(),
362 format!("the record does not read: {error}"),
363 format!("Write it as {example}"),
364 )
365 };
366 match found.as_slice() {
367 ["param"] => serde_json::from_value::<ParamData>(value.clone())
368 .map(|data| Edit {
369 record: Record::Param {
370 param: data.param,
371 value: data.value,
372 },
373 reason: data.reason,
374 })
375 .map_err(|error| invalid(error, r#"{"param": "<key>", "value": "410 Hz", "reason": "..."}"#)),
376 ["route"] => serde_json::from_value::<RouteData>(value.clone())
377 .map(|data| Edit {
378 record: Record::Route(data.route),
379 reason: data.reason,
380 })
381 .map_err(|error| {
382 invalid(
383 error,
384 r#"{"route": {"source": "lfo_1", "dest": "<key>", "amount": "25%", "polarity": "bipolar"}, "reason": "..."}"#,
385 )
386 }),
387 ["menu"] => serde_json::from_value::<MenuData>(value.clone())
388 .map(|data| Edit {
389 record: Record::Menu {
390 menu: data.menu,
391 choice: data.choice,
392 },
393 reason: data.reason,
394 })
395 .map_err(|error| invalid(error, r#"{"menu": "<key>", "choice": "<label>", "reason": "..."}"#)),
396 ["raw"] => {
397 let data = serde_json::from_value::<RawData>(value.clone())
398 .map_err(|error| invalid(error, r#"{"raw": {"key": "<key>", "value": 1.0}, "reason": "..."}"#))?;
399 let stored = stored_of(&data.raw.value).ok_or_else(|| {
400 (
401 name.clone(),
402 format!("the raw value {} is not a number, a flag or a text", data.raw.value),
403 "Write the value as the file stores it".to_owned(),
404 )
405 })?;
406 Ok(Edit {
407 record: Record::Raw {
408 key: data.raw.key,
409 value: stored,
410 },
411 reason: data.reason,
412 })
413 }
414 [] => Err((
415 name,
416 "the edit has no record kind".to_owned(),
417 "Add one of `param`, `route`, `menu` or `raw`".to_owned(),
418 )),
419 _ => Err((
420 name,
421 format!("the edit mixes record kinds: {}", found.join(", ")),
422 "Write one record kind per edit".to_owned(),
423 )),
424 }
425}
426
427fn stored_of(value: &Value) -> Option<Stored> {
428 match value {
429 Value::Bool(flag) => Some(Stored::Flag(*flag)),
430 Value::String(text) => Some(Stored::Text(text.clone())),
431 Value::Number(number) => match (number.as_i64(), number.as_f64()) {
432 (Some(int), _) if !number.is_f64() => Some(Stored::Int(int)),
433 (_, Some(float)) => Some(Stored::Float(float)),
434 _ => None,
435 },
436 Value::Null | Value::Array(_) | Value::Object(_) => None,
437 }
438}
439
440impl Serialize for Edit {
441 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
442 let mut map = serde_json::Map::new();
443 match &self.record {
444 Record::Param { param, value: v } => {
445 map.insert("param".into(), Value::String(param.clone()));
446 map.insert(
447 "value".into(),
448 serde_json::to_value(v).map_err(serde::ser::Error::custom)?,
449 );
450 }
451 Record::Route(route) => {
452 map.insert(
453 "route".into(),
454 serde_json::to_value(route).map_err(serde::ser::Error::custom)?,
455 );
456 }
457 Record::Menu { menu, choice } => {
458 map.insert("menu".into(), Value::String(menu.clone()));
459 map.insert("choice".into(), Value::String(choice.clone()));
460 }
461 Record::Raw { key, value: v } => {
462 let raw = match v {
463 Stored::Float(number) => serde_json::json!(number),
464 Stored::Int(number) => serde_json::json!(number),
465 Stored::Flag(flag) => Value::Bool(*flag),
466 Stored::Text(text) => Value::String(text.clone()),
467 };
468 map.insert("raw".into(), serde_json::json!({"key": key, "value": raw}));
469 }
470 }
471 if let Some(reason) = &self.reason {
472 map.insert("reason".into(), Value::String(reason.clone()));
473 }
474 map.serialize(serializer)
475 }
476}
477
478impl Record {
479 #[must_use]
481 pub fn name(&self) -> &str {
482 match self {
483 Self::Param { param, .. } => param,
484 Self::Route(route) => &route.dest,
485 Self::Menu { menu, .. } => menu,
486 Self::Raw { key, .. } => key,
487 }
488 }
489}
490
491#[derive(Clone, Debug, PartialEq)]
493pub enum Resolved {
494 Param {
496 key: String,
498 stored: f64,
500 uncalibrated: bool,
502 reason: Option<String>,
504 },
505 Route {
507 source: String,
509 destination: String,
511 amount: f64,
513 polarity: Option<RoutePolarity>,
515 reason: Option<String>,
517 },
518 Raw {
520 key: String,
522 value: Stored,
524 reason: Option<String>,
526 },
527}
528
529pub fn resolve(list: &EditList, registry: &dyn ParamRegistry) -> Result<Vec<Resolved>, EditErrors> {
541 if list.synth != registry.synth() {
542 return Err(EditErrors(vec![EditError {
543 place: "the edit list".to_owned(),
544 cause: format!(
545 "it is written for {}, but the target is {}",
546 list.synth.name(),
547 registry.synth().name()
548 ),
549 fix: format!(
550 "Pass --synth {}, or write the list for {}",
551 list.synth,
552 registry.synth().name()
553 ),
554 }]));
555 }
556 let mut resolved = Vec::new();
557 let mut errors = Vec::new();
558 let mut first: HashMap<(String, String), usize> = HashMap::new();
561 for (index, edit) in list.edits.iter().enumerate() {
562 if errors.len() >= MAX_PROBLEMS {
563 errors.push(stopped());
564 break;
565 }
566 let name = edit.record.name();
567 match resolve_record(&edit.record, registry) {
568 Ok(mut item) => {
569 match &mut item {
570 Resolved::Param { reason, .. }
571 | Resolved::Route { reason, .. }
572 | Resolved::Raw { reason, .. } => reason.clone_from(&edit.reason),
573 }
574 let (target, what) = match &item {
575 Resolved::Param { key, .. } | Resolved::Raw { key, .. } => {
576 ((key.clone(), String::new()), format!("{key} again"))
577 }
578 Resolved::Route {
579 source,
580 destination,
581 ..
582 } => (
583 (source.clone(), destination.clone()),
584 format!("the route {source} -> {destination} again"),
585 ),
586 };
587 if let Some(earlier) = first.get(&target) {
588 errors.push(EditError {
589 place: place(index, name),
590 cause: format!("it edits {what}, after edit {}", earlier + 1),
591 fix: "Keep one of the two edits; the second would override the first"
592 .to_owned(),
593 });
594 continue;
595 }
596 first.insert(target, index);
597 resolved.push(item);
598 }
599 Err((cause, fix)) => errors.push(EditError {
600 place: place(index, name),
601 cause,
602 fix,
603 }),
604 }
605 }
606 if errors.is_empty() {
607 Ok(resolved)
608 } else {
609 Err(EditErrors(errors))
610 }
611}
612
613type Problem = (String, String);
614
615fn spec_of<'a>(registry: &'a dyn ParamRegistry, key: &str) -> Result<&'a ParamSpec, Problem> {
616 let spec = registry.get(key).ok_or_else(|| {
617 let close = registry.close_names(key, 10);
618 (
619 format!("{key:?} is not a control of {}", registry.synth().name()),
620 if close.is_empty() {
621 "Check the key against the synth reference".to_owned()
622 } else {
623 format!("Close names: {}", close.join(", "))
624 },
625 )
626 })?;
627 if spec.legacy {
628 return Err((
629 format!("{key} is a legacy control that no module of the synth creates"),
630 "Leave it out; writers never create legacy controls".to_owned(),
631 ));
632 }
633 Ok(spec)
634}
635
636pub(crate) fn resolve_record(
637 record: &Record,
638 registry: &dyn ParamRegistry,
639) -> Result<Resolved, Problem> {
640 match record {
641 Record::Param { param, value } => {
642 let spec = spec_of(registry, param)?;
643 let stored = param_stored(spec, value)?;
644 Ok(Resolved::Param {
645 key: spec.key.clone(),
646 stored,
647 uncalibrated: spec.provenance == crate::preset::registry::Provenance::Interim,
648 reason: None,
649 })
650 }
651 Record::Menu { menu, choice } => {
652 let spec = spec_of(registry, menu)?;
653 if spec.kind != ParamKind::Choice {
654 return Err((
655 format!("{menu} is not a menu"),
656 "Use a `param` record for it".to_owned(),
657 ));
658 }
659 Ok(Resolved::Param {
660 key: spec.key.clone(),
661 stored: choice_stored(spec, choice)?,
662 uncalibrated: false,
663 reason: None,
664 })
665 }
666 Record::Route(route) => {
667 let spec = spec_of(registry, &route.dest)?;
668 Ok(Resolved::Route {
669 source: route.source.clone(),
670 destination: spec.key.clone(),
671 amount: route_amount(spec, &route.amount)?,
672 polarity: route.polarity.map(RoutePolarity::from),
673 reason: None,
674 })
675 }
676 Record::Raw { key, value } => {
677 if registry.get(key).is_some() {
678 return Err((
679 format!("{key} is a registry control, not a raw key"),
680 "Use a `param` or `menu` record for it".to_owned(),
681 ));
682 }
683 Ok(Resolved::Raw {
684 key: key.clone(),
685 value: value.clone(),
686 reason: None,
687 })
688 }
689 }
690}
691
692fn example(unit: UnitKind) -> &'static str {
694 match unit {
695 UnitKind::Hz => "\"1.2 kHz\"",
696 UnitKind::Seconds => "\"800 ms\"",
697 UnitKind::Semitones => "\"+12 st\"",
698 UnitKind::Cents => "\"7 ct\"",
699 UnitKind::Octaves => "\"-1 oct\"",
700 UnitKind::Decibels => "\"-6 dB\"",
701 UnitKind::Normalized => "a number from 0 to 1",
702 UnitKind::MidiPitch => "\"45 midi\"",
703 UnitKind::PercentUnipolar => "\"25%\"",
704 UnitKind::PercentBipolar => "\"-40%\"",
705 }
706}
707
708fn param_stored(spec: &ParamSpec, value: &ParamValue) -> Result<f64, Problem> {
709 let key = &spec.key;
710 match (spec.kind, spec.unit(), value) {
711 (ParamKind::Choice, _, ParamValue::Text(label)) => choice_stored(spec, label),
712 (ParamKind::Choice, _, ParamValue::Number(number)) if spec.accepts(*number) => Ok(*number),
714 (ParamKind::Choice, _, _) => Err((
715 format!("{key} is a menu, and {value} is not one of its labels or indices"),
716 format!(
717 "Write one of: {}, or a whole stored index from {}",
718 spec.choices.join(", "),
719 spec.range_text()
720 ),
721 )),
722 (ParamKind::Flag, _, ParamValue::Flag(flag)) => Ok(if *flag { 1.0 } else { 0.0 }),
723 (ParamKind::Flag, _, _) => Err((
724 format!("{key} is a switch, and {value} is not true or false"),
725 "Write true or false".to_owned(),
726 )),
727 (_, Some(unit), ParamValue::Text(text)) => {
728 let physical = Physical::parse(unit, text).map_err(|error| {
729 let example = example(unit);
730 (
731 format!("{key}: {}", error.reason()),
732 error.hint().map_or_else(
733 || format!("Write a value such as {example}"),
734 |hint| format!("{hint}, such as {example}"),
735 ),
736 )
737 })?;
738 spec.stored(&physical).map_err(|error| {
739 (
740 error.to_string(),
741 format!("The control takes {}", unit.type_name()),
742 )
743 })
744 }
745 (_, Some(unit), _) => Err((
746 format!("{key} takes a unit string, not {value}"),
747 format!("Write a value with its unit, such as {}", example(unit)),
748 )),
749 (_, None, ParamValue::Number(number)) => {
750 if spec.accepts(*number) {
751 return Ok(*number);
752 }
753 let whole = spec.kind != ParamKind::Float;
754 if whole && (spec.stored_min..=spec.stored_max).contains(number) {
755 return Err((
756 format!("{key}: {number} is not a whole number"),
757 format!("Use a whole number; the nearest is {:?}", number.round()),
758 ));
759 }
760 let nearest = number.clamp(spec.stored_min, spec.stored_max);
761 let nearest = if whole { nearest.round() } else { nearest };
762 Err((
763 format!(
764 "{key}: {number} is outside the stored range {}",
765 spec.range_text()
766 ),
767 format!(
768 "Use a {} from {}; the nearest is {nearest:?}",
769 if whole { "whole number" } else { "number" },
770 spec.range_text()
771 ),
772 ))
773 }
774 (_, None, _) => Err((
775 format!("{key} has no physical unit, and {value} is not a number"),
776 format!(
777 "Write the stored value as a bare number from {}",
778 spec.range_text()
779 ),
780 )),
781 }
782}
783
784fn choice_stored(spec: &ParamSpec, label: &str) -> Result<f64, Problem> {
785 let matches: Vec<usize> = spec
786 .choices
787 .iter()
788 .enumerate()
789 .filter(|(_, choice)| *choice == label)
790 .map(|(index, _)| index)
791 .collect();
792 match matches.as_slice() {
793 [index] => Ok(spec.stored_min + crate::cast::usize_to_f64(*index)),
794 [] => Err((
795 format!("{label:?} is not a choice of {}", spec.key),
796 format!("Write one of: {}", spec.choices.join(", ")),
797 )),
798 _ => Err((
799 format!("{label:?} names several choices of {}", spec.key),
800 "Write the stored index as a `param` record; the labels repeat".to_owned(),
801 )),
802 }
803}
804
805pub(crate) fn stored_in_semitones(spec: &ParamSpec) -> bool {
808 match spec.law.as_ref().map(|law| (&law.law, law.unit)) {
809 Some((Law::Exp2 { rate, .. }, UnitKind::Hz)) => (rate * 12.0 - 1.0).abs() < 1e-12,
810 Some((Law::Affine { scale, offset }, UnitKind::Semitones)) => {
811 (scale - 1.0).abs() < 1e-12 && offset.abs() < 1e-12
812 }
813 _ => false,
814 }
815}
816
817fn route_amount(spec: &ParamSpec, text: &str) -> Result<f64, Problem> {
818 let span = spec.stored_max - spec.stored_min;
819 let semitones_ok = stored_in_semitones(spec);
820 let fix = if semitones_ok {
821 "Write the depth as a percent of the range, such as \"25%\", or in semitones, such as \"+12 st\""
822 } else {
823 "Write the depth as a percent of the destination's range, such as \"25%\""
824 };
825 let beyond = || {
826 (
827 format!(
828 "route amount {text:?} is beyond the full range of {}",
829 spec.key
830 ),
831 format!(
832 "Keep it within -100% to 100%{}",
833 if semitones_ok {
834 format!(", or ±{span} st")
835 } else {
836 String::new()
837 }
838 ),
839 )
840 };
841 let amount = match text.parse::<Percent<Bipolar>>() {
842 Ok(percent) => percent.get() / 100.0,
843 Err(error) if error.source().is_some() => return Err(beyond()),
846 Err(_) => match (semitones_ok, text.parse::<Semitones>()) {
847 (true, Ok(semitones)) => semitones.get() / span,
848 _ => {
849 return Err((
850 format!("route amount {text:?} into {} does not read", spec.key),
851 fix.to_owned(),
852 ));
853 }
854 },
855 };
856 if (-1.0..=1.0).contains(&amount) {
857 Ok(amount)
858 } else {
859 Err(beyond())
860 }
861}
862
863#[cfg(test)]
864mod tests {
865 use super::*;
866 use crate::preset::registry::registry_for;
867 use approx::assert_relative_eq;
868
869 fn vital() -> &'static dyn ParamRegistry {
870 registry_for(Synth::Vital).unwrap()
871 }
872
873 fn list(edits: &str) -> String {
874 format!(r#"{{"synth":"vital","edits":[{edits}]}}"#)
875 }
876
877 #[test]
878 fn a_report_record_keeps_its_mapper_fields_out_of_the_edit() {
879 let report = list(
880 r#"{"param":"portamento_time","value":"0.12 s","reason":"pitch","uncalibrated":false,"confidence":"high"},
881 {"route":{"source":"env_2","dest":"osc_1_transpose","amount":"7 st","polarity":"unipolar"},"reason":"pitch","uncalibrated":true,"confidence":"low"}"#,
882 );
883 let parsed = parse(&report, Path::Report).unwrap();
884 let plain = list(
885 r#"{"param":"portamento_time","value":"0.12 s","reason":"pitch"},
886 {"route":{"source":"env_2","dest":"osc_1_transpose","amount":"7 st","polarity":"unipolar"},"reason":"pitch"}"#,
887 );
888 assert_eq!(parsed, parse(&plain, Path::Report).unwrap());
889 let wrong = list(r#"{"param":"portamento_time","value":"0.12 s","confidence":"huge"}"#);
890 assert!(parse(&wrong, Path::Report).is_err());
891 }
892
893 fn resolve_one(edit: &str) -> Result<Resolved, String> {
894 let parsed = parse(&list(edit), Path::Agent).map_err(|e| e.to_string())?;
895 resolve(&parsed, vital())
896 .map(|mut all| all.remove(0))
897 .map_err(|e| e.to_string())
898 }
899
900 fn stored(edit: &str) -> f64 {
901 match resolve_one(edit).unwrap() {
902 Resolved::Param { stored, .. } => stored,
903 other => panic!("{other:?}"),
904 }
905 }
906
907 #[test]
908 fn each_unit_family_parses_to_its_stored_value() {
909 assert_relative_eq!(
911 stored(r#"{"param":"filter_1_cutoff","value":"1.2 kHz","reason":"r"}"#),
912 12.0 * (1200.0f64 / 8.175_798_915_6).log2(),
913 max_relative = 1e-12
914 );
915 assert_relative_eq!(
917 stored(r#"{"param":"env_1_attack","value":"800 ms","reason":"r"}"#),
918 0.8f64.powf(0.25),
919 max_relative = 1e-12
920 );
921 assert_relative_eq!(
923 stored(r#"{"param":"osc_1_pan","value":"-40%","reason":"r"}"#),
924 -0.4,
925 max_relative = 1e-12
926 );
927 assert_relative_eq!(
929 stored(r#"{"param":"osc_1_level","value":"-6 dB","reason":"r"}"#),
930 10f64.powf(-6.0 / 40.0),
931 max_relative = 1e-12
932 );
933 assert_relative_eq!(
934 stored(r#"{"param":"osc_1_transpose","value":"+12 st","reason":"r"}"#),
935 12.0,
936 max_relative = 1e-12
937 );
938 assert_relative_eq!(
939 stored(r#"{"param":"osc_1_tune","value":"7 ct","reason":"r"}"#),
940 0.07,
941 max_relative = 1e-12
942 );
943 assert_relative_eq!(
944 stored(r#"{"param":"osc_1_unison_voices","value":5,"reason":"r"}"#),
945 5.0,
946 max_relative = 1e-12
947 );
948 assert_relative_eq!(
949 stored(r#"{"param":"filter_1_on","value":true,"reason":"r"}"#),
950 1.0,
951 max_relative = 1e-12
952 );
953 assert_relative_eq!(
954 stored(r#"{"param":"lfo_1_tempo","value":"1/8","reason":"r"}"#),
955 9.0,
956 max_relative = 1e-12
957 );
958 assert_relative_eq!(
959 stored(r#"{"menu":"filter_1_model","choice":"Ladder","reason":"r"}"#),
960 2.0,
961 max_relative = 1e-12
962 );
963 assert_relative_eq!(
964 stored(r#"{"param":"env_1_sustain","value":0.5,"reason":"r"}"#),
965 0.5,
966 max_relative = 1e-12
967 );
968 }
969
970 #[test]
971 fn the_five_failures_name_the_parameter_cause_and_fix() {
972 let bare =
973 resolve_one(r#"{"param":"filter_1_cutoff","value":410,"reason":"r"}"#).unwrap_err();
974 assert!(
975 bare.contains("filter_1_cutoff takes a unit string") && bare.contains("\"1.2 kHz\""),
976 "{bare}"
977 );
978 let family =
979 resolve_one(r#"{"param":"env_1_attack","value":"5 Hz","reason":"r"}"#).unwrap_err();
980 assert!(
981 family.contains("env_1_attack: cannot read \"5 Hz\" as Seconds")
982 && family.contains("s, ms"),
983 "{family}"
984 );
985 let unknown =
986 resolve_one(r#"{"param":"filter_1_cuttoff","value":"5 Hz","reason":"r"}"#).unwrap_err();
987 assert!(
988 unknown.contains("Close names: filter_1_cutoff"),
989 "{unknown}"
990 );
991 let range = resolve_one(r#"{"param":"filter_1_cutoff","value":"30 kHz","reason":"r"}"#)
992 .unwrap_err();
993 assert!(
996 range.contains(
997 "filter_1_cutoff: 30000 Hz is outside 12.978271799303903 Hz to 21096.16364231088 Hz. Use the nearest, 21096.16364231088 Hz"
998 ) && !range.contains("stored")
999 && !range.contains("136"),
1000 "{range}"
1001 );
1002 let reason = parse(
1003 &list(r#"{"param":"filter_1_cutoff","value":"1 kHz"}"#),
1004 Path::Agent,
1005 )
1006 .unwrap_err()
1007 .to_string();
1008 assert!(
1009 reason.contains("edit 1 (filter_1_cutoff): the edit has no reason"),
1010 "{reason}"
1011 );
1012 assert!(
1013 parse(
1014 &list(r#"{"param":"filter_1_cutoff","value":"1 kHz"}"#),
1015 Path::Report
1016 )
1017 .is_ok()
1018 );
1019 }
1020
1021 #[test]
1022 fn a_wrong_suffix_lists_the_accepted_ones() {
1023 let error =
1024 resolve_one(r#"{"param":"filter_1_cutoff","value":"1 GHz","reason":"r"}"#).unwrap_err();
1025 assert!(error.contains("Hz, kHz"), "{error}");
1026 let text = list(r#"{"param":"filter_1_cutoff","value":"1 GHz","reason":"r"}"#);
1027 let errors = resolve(&parse(&text, Path::Agent).unwrap(), vital()).unwrap_err();
1028 assert_eq!(
1029 errors.0[0].cause,
1030 "filter_1_cutoff: cannot read \"1 GHz\" as Hz: unknown suffix \"GHz\""
1031 );
1032 assert!(
1033 errors.0[0]
1034 .fix
1035 .starts_with("Write a number followed by one of: Hz, kHz, such as"),
1036 "{}",
1037 errors.0[0].fix
1038 );
1039 }
1040
1041 #[test]
1042 fn a_route_depth_in_semitones_divides_by_the_destination_span() {
1043 match resolve_one(
1046 r#"{"route":{"source":"lfo_1","dest":"filter_1_cutoff","amount":"+64 st"},"reason":"r"}"#,
1047 )
1048 .unwrap()
1049 {
1050 Resolved::Route { amount, .. } => assert_eq!(amount, 0.5),
1051 other => panic!("{other:?}"),
1052 }
1053 }
1054
1055 #[test]
1056 fn a_percent_route_depth_past_the_full_range_names_the_range() {
1057 for amount in ["150%", "-150%"] {
1058 for dest in ["filter_1_cutoff", "osc_1_level"] {
1059 let error = resolve_one(&format!(
1060 r#"{{"route":{{"source":"lfo_1","dest":"{dest}","amount":"{amount}"}},"reason":"r"}}"#
1061 ))
1062 .unwrap_err();
1063 assert!(
1064 error.contains(&format!(
1065 "route amount \"{amount}\" is beyond the full range of {dest}. Keep it within -100% to 100%"
1066 )),
1067 "{error}"
1068 );
1069 }
1070 }
1071 let error = resolve_one(
1072 r#"{"route":{"source":"lfo_1","dest":"osc_1_level","amount":"lots"},"reason":"r"}"#,
1073 )
1074 .unwrap_err();
1075 assert!(error.contains("does not read"), "{error}");
1076 }
1077
1078 #[test]
1079 fn a_second_edit_of_one_target_names_the_first() {
1080 for (edits, needle) in [
1081 (
1082 r#"{"param":"filter_1_cutoff","value":"400 Hz","reason":"r"},{"param":"filter_1_cutoff","value":"400 Hz","reason":"r"}"#,
1083 "edit 2 (filter_1_cutoff): it edits filter_1_cutoff again, after edit 1",
1084 ),
1085 (
1086 r#"{"param":"filter_1_cutoff","value":"400 Hz","reason":"r"},{"param":"env_1_attack","value":"1 s","reason":"r"},{"param":"filter_1_cutoff","value":"900 Hz","reason":"r"}"#,
1087 "edit 3 (filter_1_cutoff): it edits filter_1_cutoff again, after edit 1",
1088 ),
1089 (
1090 r#"{"menu":"filter_1_model","choice":"Ladder","reason":"r"},{"param":"filter_1_model","value":1,"reason":"r"}"#,
1091 "edit 2 (filter_1_model): it edits filter_1_model again, after edit 1",
1092 ),
1093 (
1094 r#"{"raw":{"key":"k","value":1},"reason":"r"},{"raw":{"key":"k","value":2},"reason":"r"}"#,
1095 "edit 2 (k): it edits k again, after edit 1",
1096 ),
1097 (
1098 r#"{"route":{"source":"lfo_1","dest":"filter_1_cutoff","amount":"25%"},"reason":"r"},{"route":{"source":"lfo_1","dest":"filter_1_cutoff","amount":"50%"},"reason":"r"}"#,
1099 "edit 2 (filter_1_cutoff): it edits the route lfo_1 -> filter_1_cutoff again, after edit 1",
1100 ),
1101 ] {
1102 let errors = resolve(&parse(&list(edits), Path::Agent).unwrap(), vital())
1103 .unwrap_err()
1104 .to_string();
1105 assert!(errors.contains(needle), "{needle}\n{errors}");
1106 assert!(errors.contains("has 1 problem(s)"), "{errors}");
1107 }
1108 let text = list(
1111 r#"{"param":"filter_1_cutoff","value":"400 Hz","reason":"r"},{"route":{"source":"lfo_1","dest":"filter_1_cutoff","amount":"25%"},"reason":"r"},{"route":{"source":"lfo_2","dest":"filter_1_cutoff","amount":"25%"},"reason":"r"}"#,
1112 );
1113 assert_eq!(
1114 resolve(&parse(&text, Path::Agent).unwrap(), vital())
1115 .unwrap()
1116 .len(),
1117 3
1118 );
1119 }
1120
1121 #[test]
1122 fn a_list_with_a_byte_order_mark_reads() {
1123 let text = list(r#"{"param":"filter_1_cutoff","value":"400 Hz","reason":"r"}"#);
1124 assert_eq!(
1125 parse(&format!("\u{feff}{text}"), Path::Agent).unwrap(),
1126 parse(&text, Path::Agent).unwrap()
1127 );
1128 }
1129
1130 #[test]
1131 fn a_list_of_many_bad_edits_stops_at_fifty_problems() {
1132 let bad = vec![r#"{"param":"a","value":1,"reason":"r"}"#; 200].join(",");
1133 let errors = resolve(&parse(&list(&bad), Path::Agent).unwrap(), vital()).unwrap_err();
1134 assert_eq!(errors.0.len(), MAX_PROBLEMS + 1);
1135 assert_eq!(errors.0[MAX_PROBLEMS], stopped());
1136 let unreadable = vec![r#"{"param":"a"}"#; 200].join(",");
1137 let errors = parse(&list(&unreadable), Path::Agent).unwrap_err();
1138 assert_eq!(errors.0.len(), MAX_PROBLEMS + 1);
1139 assert_eq!(errors.0[MAX_PROBLEMS], stopped());
1140 let fifty = vec![r#"{"param":"a","value":1,"reason":"r"}"#; 50].join(",");
1142 let errors = resolve(&parse(&list(&fifty), Path::Agent).unwrap(), vital()).unwrap_err();
1143 assert_eq!(errors.0.len(), MAX_PROBLEMS);
1144 }
1145
1146 #[test]
1147 fn every_problem_is_reported_not_only_the_first() {
1148 let text = list(
1149 r#"{"param":"nope","value":"1 Hz","reason":"r"},{"param":"filter_1_cutoff","value":"1 Hz","reason":"r"},{"menu":"filter_1_model","choice":"Moog","reason":"r"}"#,
1150 );
1151 let errors = resolve(&parse(&text, Path::Agent).unwrap(), vital()).unwrap_err();
1152 assert_eq!(errors.0.len(), 3);
1153 assert!(errors.0[2].fix.contains("Analog, Dirty, Ladder"));
1154 }
1155
1156 #[test]
1157 fn records_reject_unknown_fields_and_mixed_kinds() {
1158 for (edit, needle) in [
1159 (
1160 r#"{"param":"filter_1_cutoff","value":"1 kHz","reason":"r","why":"x"}"#,
1161 "unknown field",
1162 ),
1163 (
1164 r#"{"param":"filter_1_cutoff","menu":"x","reason":"r"}"#,
1165 "mixes record kinds",
1166 ),
1167 (r#"{"reason":"r"}"#, "no record kind"),
1168 ("5", "not an object"),
1169 (
1170 r#"{"raw":{"key":"k","value":null},"reason":"r"}"#,
1171 "not a number",
1172 ),
1173 ] {
1174 let error = parse(&list(edit), Path::Agent).unwrap_err().to_string();
1175 assert!(error.contains(needle), "{needle}: {error}");
1176 }
1177 assert!(parse(r#"{"synth":"vital","edits":[],"extra":1}"#, Path::Agent).is_err());
1178 assert!(parse(r#"{"edits":[]}"#, Path::Agent).is_err());
1179 }
1180
1181 #[test]
1182 fn route_menu_and_raw_records_parse_and_round_trip() {
1183 let text = list(
1184 r#"{"route":{"source":"lfo_1","dest":"filter_1_cutoff","amount":"+32 st","polarity":"unipolar"},"reason":"wobble"},{"menu":"filter_1_model","choice":"Ladder","reason":"warm"},{"raw":{"key":"custom_warps_count","value":2},"reason":"newer build"}"#,
1185 );
1186 let parsed = parse(&text, Path::Agent).unwrap();
1187 let resolved = resolve(&parsed, vital()).unwrap();
1188 assert_eq!(
1189 resolved[0],
1190 Resolved::Route {
1191 source: "lfo_1".into(),
1192 destination: "filter_1_cutoff".into(),
1193 amount: 0.25,
1194 polarity: Some(RoutePolarity::Unipolar),
1195 reason: Some("wobble".into()),
1196 }
1197 );
1198 assert_eq!(
1199 resolved[2],
1200 Resolved::Raw {
1201 key: "custom_warps_count".into(),
1202 value: Stored::Int(2),
1203 reason: Some("newer build".into()),
1204 }
1205 );
1206 let percent = resolve_one(
1207 r#"{"route":{"source":"env_2","dest":"osc_1_level","amount":"-50%"},"reason":"r"}"#,
1208 )
1209 .unwrap();
1210 assert!(
1211 matches!(percent, Resolved::Route { amount, polarity: None, .. } if (amount + 0.5).abs() < 1e-12)
1212 );
1213 let semis = resolve_one(
1214 r#"{"route":{"source":"env_2","dest":"osc_1_level","amount":"12 st"},"reason":"r"}"#,
1215 )
1216 .unwrap_err();
1217 assert!(semis.contains("percent of the destination"), "{semis}");
1218 let raw_known =
1219 resolve_one(r#"{"raw":{"key":"filter_1_cutoff","value":2},"reason":"r"}"#).unwrap_err();
1220 assert!(raw_known.contains("registry control"), "{raw_known}");
1221 }
1222
1223 #[test]
1224 fn every_record_kind_round_trips_through_json() {
1225 let text = list(
1226 r#"{"param":"filter_1_cutoff","value":"410 Hz","reason":"a"},{"param":"filter_1_on","value":true,"reason":"b"},{"param":"osc_1_unison_voices","value":5.0,"reason":"c"},{"route":{"source":"lfo_1","dest":"filter_1_cutoff","amount":"25%","polarity":"bipolar"},"reason":"d"},{"route":{"source":"env_2","dest":"osc_1_level","amount":"-10%"},"reason":"e"},{"menu":"filter_1_model","choice":"Ladder","reason":"f"},{"raw":{"key":"k","value":2},"reason":"g"},{"raw":{"key":"k2","value":0.5},"reason":"h"}"#,
1227 );
1228 let parsed = parse(&text, Path::Agent).unwrap();
1229 let again = parse(&serde_json::to_string(&parsed).unwrap(), Path::Agent).unwrap();
1230 assert_eq!(parsed, again);
1231 }
1232
1233 #[test]
1234 fn a_list_for_another_synth_is_rejected() {
1235 let parsed = parse(r#"{"synth":"serum2","edits":[]}"#, Path::Agent).unwrap();
1236 let error = resolve(&parsed, vital()).unwrap_err().to_string();
1237 assert!(
1238 error.contains("written for Serum 2, but the target is Vital"),
1239 "{error}"
1240 );
1241 }
1242
1243 #[test]
1244 fn legacy_controls_and_repeated_labels_are_refused() {
1245 let legacy = resolve_one(r#"{"param":"sub_on","value":true,"reason":"r"}"#).unwrap_err();
1246 assert!(legacy.contains("legacy control"), "{legacy}");
1247 let repeated =
1248 resolve_one(r#"{"menu":"osc_1_distortion_type","choice":"FM <- Osc","reason":"r"}"#)
1249 .unwrap_err();
1250 assert!(repeated.contains("several choices"), "{repeated}");
1251 }
1252
1253 #[test]
1254 fn a_stored_index_selects_a_repeated_label() {
1255 let spec = vital().get("osc_1_distortion_type").unwrap();
1256 let index = spec
1257 .choices
1258 .iter()
1259 .position(|label| label == "FM <- Osc")
1260 .unwrap();
1261 let stored = spec.stored_min + crate::cast::usize_to_f64(index);
1262 let resolved = resolve_one(&format!(
1263 r#"{{"param":"osc_1_distortion_type","value":{stored},"reason":"r"}}"#
1264 ))
1265 .unwrap();
1266 assert!(
1267 matches!(resolved, Resolved::Param { stored: got, .. } if got.to_bits() == stored.to_bits())
1268 );
1269 let outside = resolve_one(r#"{"param":"osc_1_distortion_type","value":99,"reason":"r"}"#)
1270 .unwrap_err();
1271 assert!(outside.contains("whole stored index"), "{outside}");
1272 }
1273
1274 #[test]
1275 fn a_count_between_steps_is_not_whole() {
1276 let error =
1277 resolve_one(r#"{"param":"osc_1_unison_voices","value":5.5,"reason":"r"}"#).unwrap_err();
1278 assert!(error.contains("5.5 is not a whole number"), "{error}");
1279 assert!(error.contains("the nearest is 6.0"), "{error}");
1280 let error =
1281 resolve_one(r#"{"param":"osc_1_unison_voices","value":20,"reason":"r"}"#).unwrap_err();
1282 assert!(error.contains("outside the stored range"), "{error}");
1283 }
1284
1285 #[test]
1286 fn uncalibrated_follows_the_registry() {
1287 let resolved =
1288 resolve_one(r#"{"param":"filter_1_resonance","value":"50%","reason":"r"}"#).unwrap();
1289 assert!(matches!(
1290 resolved,
1291 Resolved::Param {
1292 uncalibrated: true,
1293 ..
1294 }
1295 ));
1296 let resolved =
1297 resolve_one(r#"{"param":"filter_1_cutoff","value":"500 Hz","reason":"r"}"#).unwrap();
1298 assert!(matches!(
1299 resolved,
1300 Resolved::Param {
1301 uncalibrated: false,
1302 ..
1303 }
1304 ));
1305 }
1306
1307 #[test]
1308 fn decisions_parse_strictly() {
1309 let text = r#"{"synth":"vital","edits":[],"ambiguities":[{"id":"a","choice":"both","reason":"keep both"}]}"#;
1310 assert_eq!(parse(text, Path::Agent).unwrap().ambiguities.len(), 1);
1311 let bad = r#"{"synth":"vital","edits":[],"ambiguities":[{"id":"a","choice":"both"}]}"#;
1312 assert!(parse(bad, Path::Agent).is_err());
1313 }
1314}