AquaSynth/src/AquaSynth.Core/PatchScript.cs

1824 lines
93 KiB
C#
Raw Normal View History

2026-05-08 03:00:53 +01:00
using System.Globalization;
2026-05-16 05:49:44 +01:00
namespace AquaSynth.Dsl;
2026-05-08 03:00:53 +01:00
public sealed class PatchScriptException(int line, string message) : Exception($"line {line}: {message}")
{
public int Line { get; } = line;
}
public static class PatchScript
{
public static SynthPatch Parse(string script)
{
var compiler = new Compiler();
var line = 1;
2026-05-12 22:10:02 +01:00
foreach (var statement in PatchScriptStatements.Enumerate(script))
2026-05-08 03:00:53 +01:00
{
compiler.Apply(statement.Text, statement.Line);
line = statement.Line;
}
2026-05-13 11:47:39 +01:00
if (!compiler.HasOutput)
2026-05-08 03:00:53 +01:00
{
2026-05-13 01:25:05 +01:00
throw new PatchScriptException(line, "patch script produced no voices or operator graphs");
2026-05-08 03:00:53 +01:00
}
return compiler.Build();
}
private sealed class Compiler
{
private readonly Dictionary<string, Dictionary<string, string>> _templates = new(StringComparer.OrdinalIgnoreCase);
private readonly List<ControlLane> _controls = [];
2026-05-13 01:25:05 +01:00
private readonly List<OperatorGraph> _operatorGraphs = [];
2026-05-13 00:28:31 +01:00
private readonly List<PatchParameter> _parameters = [];
2026-05-13 00:38:23 +01:00
private readonly List<ParameterBinding> _parameterBindings = [];
2026-05-14 20:25:59 +01:00
private readonly List<PatchLayer> _layers = [];
2026-05-14 20:36:06 +01:00
private readonly List<HarmonicBank> _harmonicBanks = [];
2026-05-14 20:54:44 +01:00
private readonly List<SpectralBank> _spectralBanks = [];
private readonly List<TractShape> _tractShapes = [];
private readonly Dictionary<string, TractShape> _tractShapesByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<GlottalSource> _glottalSources = [];
private readonly Dictionary<string, GlottalSource> _glottalSourcesByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<TractInjection> _tractInjections = [];
private readonly Dictionary<string, TractInjection> _tractInjectionsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<NasalBranch> _nasalBranches = [];
private readonly Dictionary<string, NasalBranch> _nasalBranchesByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<TractMotion> _tractMotions = [];
private readonly Dictionary<string, TractMotion> _tractMotionsByName = new(StringComparer.OrdinalIgnoreCase);
2026-05-08 03:00:53 +01:00
private readonly Dictionary<string, string> _defaults = new(StringComparer.OrdinalIgnoreCase);
2026-05-14 20:25:59 +01:00
private readonly Dictionary<string, Dictionary<string, string>> _layerDefaults = new(StringComparer.OrdinalIgnoreCase);
2026-05-13 11:47:39 +01:00
private PendingOperatorGraph? _pendingOperatorGraph;
2026-05-08 03:00:53 +01:00
public List<Voice> Voices { get; } = [];
2026-05-13 01:25:05 +01:00
public List<OperatorGraph> OperatorGraphs => _operatorGraphs;
2026-05-14 21:11:20 +01:00
public bool HasOutput => Voices.Count > 0 || _spectralBanks.Count > 0 || _operatorGraphs.Count > 0 || _pendingOperatorGraph is not null;
2026-05-08 03:00:53 +01:00
private Repeat? Repeat { get; set; }
2026-05-13 16:10:08 +01:00
private Playback Playback { get; set; } = new();
2026-05-08 03:00:53 +01:00
private float Gain { get; set; } = 1;
private bool SoftClip { get; set; } = true;
2026-05-13 11:47:39 +01:00
public SynthPatch Build()
2026-05-08 03:00:53 +01:00
{
2026-05-13 11:47:39 +01:00
FlushPendingOperatorGraph();
return new SynthPatch
{
Voices = Voices,
2026-05-14 20:25:59 +01:00
Layers = _layers,
2026-05-14 20:36:06 +01:00
HarmonicBanks = _harmonicBanks,
2026-05-14 20:54:44 +01:00
SpectralBanks = _spectralBanks,
TractShapes = _tractShapes,
GlottalSources = _glottalSources,
TractInjections = _tractInjections,
NasalBranches = _nasalBranches,
TractMotions = _tractMotions,
2026-05-13 11:47:39 +01:00
OperatorGraphs = _operatorGraphs,
Controls = _controls,
Parameters = _parameters,
ParameterBindings = _parameterBindings,
2026-05-13 16:10:08 +01:00
Playback = Playback,
2026-05-13 11:47:39 +01:00
Repeat = Repeat,
Gain = Gain,
SoftClip = SoftClip
};
}
2026-05-08 03:00:53 +01:00
public void Apply(string statement, int line)
{
var parts = statement.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
2026-05-08 10:34:07 +01:00
var rawCommand = parts[0];
2026-05-08 03:00:53 +01:00
var command = CanonicalCommand(parts[0]);
var fields = ParseFields(parts.Skip(1), line);
2026-05-08 10:34:07 +01:00
if (SfxrParams.Named(rawCommand) is { } namedParams)
{
2026-05-13 11:47:39 +01:00
FlushPendingOperatorGraph();
2026-05-08 10:34:07 +01:00
AddSfxrPatch(ApplySfxrFields(namedParams, fields, line));
return;
}
2026-05-08 03:00:53 +01:00
switch (command)
{
case "patch":
2026-05-13 11:47:39 +01:00
FlushPendingOperatorGraph();
2026-05-08 03:00:53 +01:00
ApplyPatch(fields, line);
break;
case "defaults":
2026-05-13 11:47:39 +01:00
FlushPendingOperatorGraph();
2026-05-08 03:00:53 +01:00
Merge(_defaults, fields);
break;
case "template":
2026-05-13 11:47:39 +01:00
FlushPendingOperatorGraph();
2026-05-08 03:00:53 +01:00
var name = Required(fields, "name", line);
_templates[name] = Without(fields, "name");
break;
2026-05-14 20:25:59 +01:00
case "layer":
FlushPendingOperatorGraph();
AddLayer(fields, line);
break;
2026-05-14 20:36:06 +01:00
case "harmonics":
FlushPendingOperatorGraph();
AddHarmonicBank(fields, line);
break;
2026-05-14 20:54:44 +01:00
case "spectrum":
FlushPendingOperatorGraph();
AddSpectralBank(fields, line);
break;
case "tract_shape":
FlushPendingOperatorGraph();
AddTractShape(fields, line);
break;
case "glottis":
FlushPendingOperatorGraph();
AddGlottalSource(fields, line);
break;
case "tract_injection":
FlushPendingOperatorGraph();
AddTractInjection(fields, line);
break;
case "nasal_branch":
FlushPendingOperatorGraph();
AddNasalBranch(fields, line);
break;
case "tract_motion":
FlushPendingOperatorGraph();
AddTractMotion(fields, line);
break;
2026-05-08 03:00:53 +01:00
case "voice":
2026-05-13 11:47:39 +01:00
FlushPendingOperatorGraph();
2026-05-14 21:11:20 +01:00
Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), VoicePath(Voices.Count), line));
2026-05-08 03:00:53 +01:00
break;
2026-05-25 19:36:21 +01:00
case "tract":
FlushPendingOperatorGraph();
Voices.Add(ParseTractVoice(ExpandVoiceFields(fields, line), VoicePath(Voices.Count), line));
break;
2026-05-13 01:25:05 +01:00
case "opgraph":
2026-05-13 11:47:39 +01:00
StartOperatorGraph(fields, line);
break;
case "operator":
AddOperator(fields, line);
break;
case "route":
AddOperatorRoute(fields, line);
break;
case "carrier":
AddOperatorCarrier(fields, line);
2026-05-13 01:25:05 +01:00
break;
2026-05-08 03:00:53 +01:00
case "mod":
2026-05-13 11:47:39 +01:00
FlushPendingOperatorGraph();
2026-05-08 03:00:53 +01:00
AddModBus(fields, line);
break;
2026-05-08 10:21:32 +01:00
case "control":
2026-05-13 11:47:39 +01:00
FlushPendingOperatorGraph();
2026-05-08 10:21:32 +01:00
AddControlLane(fields, line);
break;
2026-05-13 00:28:31 +01:00
case "param":
2026-05-13 11:47:39 +01:00
FlushPendingOperatorGraph();
2026-05-13 00:28:31 +01:00
AddParameter(fields, line);
break;
2026-05-08 10:34:07 +01:00
case "sfxr":
2026-05-13 11:47:39 +01:00
FlushPendingOperatorGraph();
2026-05-08 10:34:07 +01:00
AddSfxrPatch(ParseSfxrCommand(fields, line));
break;
2026-05-08 03:00:53 +01:00
default:
throw new PatchScriptException(line, $"unknown command `{parts[0]}`");
}
}
2026-05-14 20:36:06 +01:00
private void AddHarmonicBank(IReadOnlyDictionary<string, string> fields, int line)
{
var layerName = Required(fields, "layer", line);
if (!_layerDefaults.ContainsKey(layerName))
{
throw new PatchScriptException(line, $"unknown layer `{layerName}`");
}
var rootFrequency = GetBoundFloat(fields, line, 440, $"/harmonics/{_harmonicBanks.Count}/root", "root", "base", "freq", "frequency");
if (rootFrequency <= 0)
{
throw new PatchScriptException(line, "harmonic root frequency must be greater than zero");
}
var partials = ParseHarmonicPartials(
GetAny(fields, ["partials", "bank", "tones", "drawbars"], ""),
line);
if (partials.Count == 0)
{
throw new PatchScriptException(line, "harmonics needs at least one partial");
}
_harmonicBanks.Add(new HarmonicBank(layerName, rootFrequency, partials));
var sharedFields = Without(fields,
"layer",
"root",
"base",
"freq",
"frequency",
"partials",
"bank",
"tones",
"drawbars",
"gain",
"g");
foreach (var partial in partials)
{
var voiceFields = new Dictionary<string, string>(sharedFields, StringComparer.OrdinalIgnoreCase)
{
["layer"] = layerName,
["freq"] = F(rootFrequency * partial.Ratio),
["gain"] = F(partial.Gain)
};
2026-05-14 21:11:20 +01:00
Voices.Add(ParseVoice(ExpandVoiceFields(voiceFields, line), VoicePath(Voices.Count), line));
2026-05-14 20:36:06 +01:00
}
}
2026-05-14 20:54:44 +01:00
private void AddSpectralBank(IReadOnlyDictionary<string, string> fields, int line)
{
var layerName = Required(fields, "layer", line);
if (!_layerDefaults.ContainsKey(layerName))
{
throw new PatchScriptException(line, $"unknown layer `{layerName}`");
}
2026-05-15 00:46:39 +01:00
var rootFrequency = GetFloat(fields, line, 440, "root", "base", "basefreq", "table_freq", "table_frequency");
2026-05-14 20:54:44 +01:00
if (rootFrequency <= 0)
{
throw new PatchScriptException(line, "spectral root frequency must be greater than zero");
}
var spread = GetFloat(fields, line, 0, "spread", "width", "detune");
if (spread is < 0 or >= 1)
{
throw new PatchScriptException(line, "spectral spread must be at least zero and less than one");
}
2026-05-15 09:10:40 +01:00
var profile = ParsePadSpectrumProfile(fields, line);
2026-05-14 20:54:44 +01:00
var partials = ParseHarmonicPartials(
GetAny(fields, ["partials", "bank", "tones"], ""),
line);
if (partials.Count == 0)
{
throw new PatchScriptException(line, "spectrum needs at least one partial");
}
2026-05-14 21:11:20 +01:00
var treatmentFields = Without(fields,
2026-05-14 20:54:44 +01:00
"layer",
"root",
"base",
2026-05-15 00:46:39 +01:00
"basefreq",
"table_freq",
"table_frequency",
2026-05-14 20:54:44 +01:00
"spread",
"width",
"detune",
2026-05-15 09:10:40 +01:00
"pad_mode",
"zyn_mode",
"pad_bandwidth",
"zyn_bandwidth",
"bandwidth",
"pad_bwscale",
"zyn_bwscale",
"bwscale",
"pad_profile",
"zyn_profile",
"pad_position",
"zyn_position",
2026-05-14 20:54:44 +01:00
"partials",
"bank",
2026-05-14 21:11:20 +01:00
"tones");
treatmentFields["layer"] = layerName;
2026-05-15 00:46:39 +01:00
if (!HasAny(treatmentFields, "freq", "frequency", "f"))
{
treatmentFields["freq"] = F(rootFrequency);
}
2026-05-14 21:11:20 +01:00
var treatment = ParseVoice(
ExpandVoiceFields(treatmentFields, line),
SpectralPath(_spectralBanks.Count),
line);
2026-05-15 09:10:40 +01:00
_spectralBanks.Add(new SpectralBank(layerName, rootFrequency, spread, partials, treatment, profile));
2026-05-14 20:54:44 +01:00
}
2026-05-14 21:11:20 +01:00
private static string SpectralPath(int spectralIndex) => $"/spectral/{spectralIndex}";
2026-05-14 20:54:44 +01:00
private void AddTractShape(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_tractShapesByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate tract shape `{name}`");
}
2026-05-26 11:21:21 +01:00
var areaFunction = ParseTractAreaFunction(fields, line, 17);
var shape = new TractShape(name, areaFunction);
_tractShapes.Add(shape);
_tractShapesByName[name] = shape;
}
private void AddGlottalSource(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_glottalSourcesByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate glottis `{name}`");
}
var glottis = new GlottalSource(
name,
GetBoundFloat(fields, line, 0.72f, $"/glottis/{_glottalSources.Count}/intensity", "intensity", "pressure"),
GetBoundFloat(fields, line, 0.6f, $"/glottis/{_glottalSources.Count}/tenseness", "tenseness", "tense"),
GetBoundFloat(fields, line, 0.08f, $"/glottis/{_glottalSources.Count}/aspiration", "aspiration", "breath"),
GetBoundFloat(fields, line, 0.75f, $"/glottis/{_glottalSources.Count}/reflection", "reflection", "glottal_reflection", "gr"),
GetBoundFloat(fields, line, 0.42f, $"/glottis/{_glottalSources.Count}/skew", "skew", "open_phase"));
_glottalSources.Add(glottis);
_glottalSourcesByName[name] = glottis;
}
private void AddTractInjection(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_tractInjectionsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate tract injection `{name}`");
}
var injection = new TractInjection(
name,
GetBoundFloat(fields, line, 32, $"/tract_injections/{_tractInjections.Count}/position", "position", "index", "constriction_index", "ci"),
GetBoundFloat(fields, line, 1, $"/tract_injections/{_tractInjections.Count}/diameter", "diameter", "opening", "constriction_diameter", "cd"),
GetBoundFloat(fields, line, 0, $"/tract_injections/{_tractInjections.Count}/turbulence", "turbulence", "frication"),
GetBoundFloat(fields, line, 0, $"/tract_injections/{_tractInjections.Count}/burst", "burst", "transient"),
GetBoundFloat(fields, line, 1, $"/tract_injections/{_tractInjections.Count}/width", "width"));
_tractInjections.Add(injection);
_tractInjectionsByName[name] = injection;
}
private void AddNasalBranch(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_nasalBranchesByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate nasal branch `{name}`");
}
var branch = new NasalBranch(
name,
2026-05-26 11:21:21 +01:00
ParseTractAreaFunction(fields, line, 12),
GetBoundInt(fields, line, 17, $"/nasal_branches/{_nasalBranches.Count}/junction", "junction", "junction_index", "at"),
GetBoundFloat(fields, line, 0.01f, $"/nasal_branches/{_nasalBranches.Count}/velum", "velum", "opening"),
GetBoundFloat(fields, line, -0.85f, $"/nasal_branches/{_nasalBranches.Count}/reflection", "reflection", "lip_reflection"),
GetBoundFloat(fields, line, 0.999f, $"/nasal_branches/{_nasalBranches.Count}/loss", "loss"));
_nasalBranches.Add(branch);
_nasalBranchesByName[name] = branch;
}
private void AddTractMotion(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_tractMotionsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate tract motion `{name}`");
}
var motion = new TractMotion(
name,
GetBoundFloat(fields, line, 18, $"/tract_motions/{_tractMotions.Count}/diameter_slew", "diameter_slew", "slew"),
GetBoundFloat(fields, line, 8, $"/tract_motions/{_tractMotions.Count}/shape_return", "shape_return", "return_slew"),
GetBoundFloat(fields, line, 24, $"/tract_motions/{_tractMotions.Count}/constriction_slew", "constriction_slew", "constriction"),
GetBoundFloat(fields, line, 16, $"/tract_motions/{_tractMotions.Count}/velum_slew", "velum_slew", "velum"),
GetBoundFloat(fields, line, 0.05f, $"/tract_motions/{_tractMotions.Count}/obstruction_threshold", "obstruction_threshold", "closure"));
_tractMotions.Add(motion);
_tractMotionsByName[name] = motion;
}
2026-05-08 03:00:53 +01:00
private Dictionary<string, string> ExpandVoiceFields(Dictionary<string, string> fields, int line)
{
var expanded = new Dictionary<string, string>(_defaults, StringComparer.OrdinalIgnoreCase);
2026-05-14 20:25:59 +01:00
if (TryGetAny(fields, ["layer"], out var layerName))
{
if (!_layerDefaults.TryGetValue(layerName, out var layer))
{
throw new PatchScriptException(line, $"unknown layer `{layerName}`");
}
Merge(expanded, layer);
}
2026-05-08 03:00:53 +01:00
if (TryGetAny(fields, ["use", "u"], out var templateName))
{
2026-05-08 10:34:07 +01:00
foreach (var name in templateName.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
2026-05-08 03:00:53 +01:00
{
2026-05-08 10:34:07 +01:00
if (!_templates.TryGetValue(name, out var template))
{
throw new PatchScriptException(line, $"unknown template `{name}`");
}
Merge(expanded, template);
2026-05-08 03:00:53 +01:00
}
}
Merge(expanded, Without(fields, "use", "u"));
return expanded;
}
2026-05-14 20:25:59 +01:00
private void AddLayer(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_layers.Any(layer => layer.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
{
throw new PatchScriptException(line, $"duplicate layer `{name}`");
}
var layer = new PatchLayer(
name,
GetAny(fields, ["engine", "e"], ""),
TryGetAny(fields, ["min_key", "key_min", "lo"], out var minKey) ? ParseInt(minKey, line) : null,
TryGetAny(fields, ["max_key", "key_max", "hi"], out var maxKey) ? ParseInt(maxKey, line) : null,
GetBoundFloat(fields, line, 1, $"/layers/{_layers.Count}/gain", "gain", "g"),
GetAny(fields, ["send", "effect", "fx"], ""));
if (layer is { MinKey: { } min, MaxKey: { } max } && min > max)
{
throw new PatchScriptException(line, "layer min_key must be less than or equal to max_key");
}
_layers.Add(layer);
_layerDefaults[name] = LayerVoiceFields(layer, Without(fields, "name", "engine", "e", "min_key", "key_min", "lo", "max_key", "key_max", "hi", "send", "effect", "fx"));
}
private static Dictionary<string, string> LayerVoiceFields(PatchLayer layer, IReadOnlyDictionary<string, string> fields)
{
var result = new Dictionary<string, string>(fields, StringComparer.OrdinalIgnoreCase)
{
["layer"] = layer.Name
};
if (!result.ContainsKey("gain"))
{
result["gain"] = F(layer.Gain);
}
return result;
}
2026-05-08 03:00:53 +01:00
private void ApplyPatch(IReadOnlyDictionary<string, string> fields, int line)
{
2026-05-13 00:38:23 +01:00
if (TryGetAny(fields, ["gain", "g"], out var gain)) Gain = ParseBoundFloat(gain, line, Gain, "/patch/gain");
2026-05-08 03:00:53 +01:00
if (TryGetAny(fields, ["soft_clip", "clip"], out var softClip)) SoftClip = ParseBool(softClip, line);
2026-05-13 16:10:08 +01:00
if (TryGetAny(fields, ["mode", "playback"], out var mode))
{
var playbackMode = ParsePlaybackMode(mode, line);
Playback = Playback with
{
Mode = playbackMode,
Midi = playbackMode == PlaybackMode.Poly || Playback.Midi
};
}
if (TryGetAny(fields, ["polyphony", "voices", "nvoices"], out var voices))
{
var count = ParseInt(voices, line);
if (count < 1) throw new PatchScriptException(line, "polyphony must be at least 1");
Playback = Playback with
{
Voices = count,
Mode = count > 1 ? PlaybackMode.Poly : Playback.Mode,
Midi = count > 1 || Playback.Midi
};
}
if (TryGetAny(fields, ["midi"], out var midi))
{
var enabled = ParseBool(midi, line);
Playback = Playback with
{
Midi = enabled,
Mode = enabled && Playback.Mode == PlaybackMode.OneShot ? PlaybackMode.Mono : Playback.Mode
};
}
if (TryGetAny(fields, ["note_freq", "note_frequency"], out var noteFreq)) Playback = Playback with { FrequencyHz = ParseFloat(noteFreq, line) };
if (TryGetAny(fields, ["note_gain", "velocity"], out var noteGain)) Playback = Playback with { Gain = ParseFloat(noteGain, line) };
2026-05-08 03:00:53 +01:00
if (TryGetAny(fields, ["repeat", "r", "rp"], out var repeat))
{
2026-05-13 00:38:23 +01:00
var interval = ParseBoundFloat(repeat, line, 0.1f, "/patch/repeat");
2026-05-08 03:00:53 +01:00
Repeat = interval > 0 ? new Repeat(interval) : null;
}
}
2026-05-14 21:11:20 +01:00
private Voice ParseVoice(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
2026-05-08 03:00:53 +01:00
{
var waveform = TryGetAny(fields, ["wave", "w"], out var wave) ? ParseWaveform(wave, line) : Waveform.Sine;
2026-05-14 21:11:20 +01:00
var frequency = GetBoundFloat(fields, line, 440, OwnerField(ownerPath, "note/frequency"), "freq", "frequency", "f");
2026-05-14 20:45:54 +01:00
var envelopeSpec = TryGetAny(fields, ["env", "envelope"], out var envSpec)
2026-05-14 21:11:20 +01:00
? ParseEnvelopeSpec(envSpec, fields, line, ownerPath)
2026-05-14 20:45:54 +01:00
: null;
var gateSeconds = envelopeSpec?.GateSeconds ??
2026-05-14 21:11:20 +01:00
GetBoundFloat(fields, line, 0.1f, OwnerField(ownerPath, "note/gate"), "gate", "hold", "duration", "sustain", "s");
2026-05-14 20:45:54 +01:00
var sustainLevel = envelopeSpec?.Envelope.SustainLevel ??
2026-05-14 21:11:20 +01:00
GetBoundFloat(fields, line, 1, OwnerField(ownerPath, "env/sustain_level"), "sustain_level", "sl");
2026-05-13 15:39:56 +01:00
var gainScale = 1f;
2026-05-13 12:31:43 +01:00
if (TryGetAny(fields, ["punch", "pu"], out var punch))
{
2026-05-14 21:11:20 +01:00
gainScale = PunchGain(ParseBoundFloat(punch, line, 0, OwnerField(ownerPath, "env/sustain_level")));
2026-05-13 15:39:56 +01:00
sustainLevel = 1 / gainScale;
2026-05-13 12:31:43 +01:00
}
var noteSource = ParseNoteSource(GetAny(fields, ["note_source", "source"], "oneshot"), line);
if (TryGetAny(fields, ["midi"], out var midi) && ParseBool(midi, line))
{
noteSource = NoteSource.Host;
2026-05-13 16:10:08 +01:00
Playback = Playback with
{
Midi = true,
Mode = Playback.Mode == PlaybackMode.OneShot ? PlaybackMode.Mono : Playback.Mode,
FrequencyHz = frequency
};
2026-05-13 12:31:43 +01:00
}
2026-05-08 03:00:53 +01:00
var formants = TryGetAny(fields, ["formants", "fs"], out var formantSpec)
? ParseFormants(formantSpec, line)
: [];
2026-05-17 20:34:52 +01:00
var formantFrames = TryGetAny(fields, ["vowels", "vowel_frames", "formant_frames"], out var vowelSpec)
? ParseFormantFrames(vowelSpec, line)
: [];
2026-05-08 03:00:53 +01:00
2026-05-08 10:21:32 +01:00
var modulators = TryGetAny(fields, ["mods", "m"], out var mods)
2026-05-08 03:00:53 +01:00
? ParseVoiceModulators(mods, line)
: [];
Arpeggio? arpeggio = null;
var hasArpDelay = TryGetAny(fields, ["arp_delay", "ad"], out var arpDelay);
var hasArpMult = TryGetAny(fields, ["arp_mult", "am"], out var arpMult);
if (hasArpDelay || hasArpMult)
{
arpeggio = new Arpeggio(
2026-05-14 21:11:20 +01:00
ParseBoundFloat(arpDelay ?? throw new PatchScriptException(line, "arpeggio needs arp_delay"), line, 0, OwnerField(ownerPath, "arpeggio/delay")),
ParseBoundFloat(arpMult ?? throw new PatchScriptException(line, "arpeggio needs arp_mult"), line, 1, OwnerField(ownerPath, "arpeggio/multiplier")));
2026-05-08 03:00:53 +01:00
}
return new Voice
{
2026-05-14 20:25:59 +01:00
Layer = TryGetAny(fields, ["layer"], out var layerName)
? _layers.First(layer => layer.Name.Equals(layerName, StringComparison.OrdinalIgnoreCase))
: null,
2026-05-08 03:00:53 +01:00
Oscillator = new Oscillator(
waveform,
2026-05-13 12:31:43 +01:00
frequency,
2026-05-14 21:11:20 +01:00
GetBoundFloat(fields, line, 0.5f, OwnerField(ownerPath, "osc/duty"), "duty", "du"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "osc/phase"), "phase", "pa")),
2026-05-13 12:31:43 +01:00
Note = new Note(frequency, gateSeconds, noteSource),
2026-05-14 20:45:54 +01:00
Envelope = envelopeSpec?.Envelope ?? new Envelope(
2026-05-14 21:11:20 +01:00
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "env/attack"), "attack", "a"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "env/decay"), "env_decay", "ed"),
2026-05-13 12:31:43 +01:00
sustainLevel,
2026-05-14 21:11:20 +01:00
GetBoundFloat(fields, line, 0.1f, OwnerField(ownerPath, "env/release"), "release", "rel", "decay", "d")),
2026-05-14 20:45:54 +01:00
RateLevelEnvelope = envelopeSpec?.RateLevelEnvelope,
2026-05-08 03:00:53 +01:00
Pitch = new PitchMotion(
2026-05-14 21:11:20 +01:00
GetBoundFloat(fields, line, 20, OwnerField(ownerPath, "pitch/min_freq"), "min_freq", "min"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "pitch/ramp"), "pitch_ramp", "pr"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "pitch/delta"), "pitch_delta", "pd", "pitch_dramp", "pdr"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "pitch/vibrato"), "vibrato", "vi"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "pitch/vibrato_hz"), "vibrato_hz", "vh"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "pitch/vibrato_delay"), "vibrato_delay", "vd")),
Duty = new DutyMotion(GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "duty/ramp"), "duty_ramp", "dur")),
2026-05-08 03:00:53 +01:00
Filter = new Filter(
2026-05-14 21:11:20 +01:00
GetBoundFloat(fields, line, 1, OwnerField(ownerPath, "filter/lpf"), "lpf", "l"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/lpf_ramp"), "lpf_ramp", "lr"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/resonance"), "resonance", "res"),
2026-05-15 14:18:37 +01:00
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/lpf_q"), "lpf_q", "lpq"),
2026-05-15 12:19:04 +01:00
GetBoundInt(fields, line, 1, OwnerField(ownerPath, "filter/lpf_order"), "lpf_order", "lpfo"),
2026-05-14 21:11:20 +01:00
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/hpf"), "hpf", "h"),
2026-05-15 13:24:22 +01:00
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/hpf_ramp"), "hpf_ramp", "hr"),
2026-05-15 16:43:36 +01:00
GetBoundInt(fields, line, 1, OwnerField(ownerPath, "filter/hpf_order"), "hpf_order", "hpfo"),
2026-05-16 10:32:28 +01:00
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/bpf"), "bpf", "bp"),
GetBoundFloat(fields, line, 1, OwnerField(ownerPath, "filter/bpf_q"), "bpf_q", "bpq"),
GetBoundInt(fields, line, 1, OwnerField(ownerPath, "filter/bpf_order"), "bpf_order", "bpfo"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/notch"), "notch", "nt"),
GetBoundFloat(fields, line, 1, OwnerField(ownerPath, "filter/notch_q"), "notch_q", "ntq"),
GetBoundInt(fields, line, 1, OwnerField(ownerPath, "filter/notch_order"), "notch_order", "nto"),
2026-05-15 16:43:36 +01:00
ParseFilterRateLevelEnvelope(fields, line, ownerPath, "lpf"),
ParseFilterRateLevelEnvelope(fields, line, ownerPath, "hpf")),
2026-05-08 03:00:53 +01:00
Phaser = new Phaser(
2026-05-14 21:11:20 +01:00
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "phaser/offset"), "phaser", "ph"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "phaser/ramp"), "phaser_ramp", "phr")),
2026-05-08 03:00:53 +01:00
Arpeggio = arpeggio,
Fm = new FrequencyModulation(
2026-05-14 21:11:20 +01:00
GetBoundFloat(fields, line, 1, OwnerField(ownerPath, "fm/ratio"), "fm", "fmr", "fm_ratio"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "fm/index"), "fm_index", "fmi"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "fm/decay"), "fm_decay", "fmd")),
2026-05-08 03:00:53 +01:00
Color = new VoiceColor(
2026-05-14 21:11:20 +01:00
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/noise"), "noise", "nz"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/drive"), "drive", "drv"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/fold"), "fold", "fl"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/tremolo"), "tremolo", "tr"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/tremolo_hz"), "tremolo_hz", "th"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/formant_mix"), "formant_mix", "fmix")),
2026-05-08 03:00:53 +01:00
Formants = formants,
2026-05-17 20:34:52 +01:00
FormantFrames = formantFrames,
FormantFrameRateHz = GetBoundFloat(fields, line, 0.5f, OwnerField(ownerPath, "color/vowel_rate"), "vowel_hz", "vowel_rate", "vowels_hz"),
2026-05-08 03:00:53 +01:00
Modulators = modulators,
2026-05-14 21:11:20 +01:00
Gain = GetBoundFloat(fields, line, 0.2f, OwnerField(ownerPath, "gain"), "gain", "g") * gainScale
2026-05-08 03:00:53 +01:00
};
}
2026-05-25 19:36:21 +01:00
private Voice ParseTractVoice(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var voiceFields = new Dictionary<string, string>(fields, StringComparer.OrdinalIgnoreCase);
if (!HasAny(voiceFields, "wave", "w"))
{
voiceFields["wave"] = "saw";
}
if (!HasAny(voiceFields, "gain", "g"))
{
voiceFields["gain"] = "0.6";
}
if (!HasAny(voiceFields, "sustain", "s", "gate", "hold", "duration"))
{
voiceFields["sustain"] = "0.35";
}
if (!HasAny(voiceFields, "decay", "d", "release", "rel"))
{
voiceFields["decay"] = "0.12";
}
var voice = ParseVoice(voiceFields, ownerPath, line);
var areaFunction = ParseTractAreaFunctionReference(fields, line);
var glottis = ParseGlottalSourceReference(fields, ownerPath, line);
var injection = ParseTractInjectionReference(fields, ownerPath, line);
var nasal = ParseNasalBranchReference(fields, ownerPath, line);
var motion = ParseTractMotionReference(fields, ownerPath, line);
var propagation = ParseTractPropagationMode(GetAny(fields, ["propagation", "model"], "resonator"), line);
var originalSections = areaFunction?.Sections ?? 44;
var defaultSections = propagation == TractPropagationMode.Waveguide && areaFunction is not null && !HasAny(fields, "sections", "cells")
? areaFunction.AcousticUnitDelaySections(44100, 343, 4)
: originalSections;
var sections = GetBoundInt(fields, line, defaultSections, OwnerField(ownerPath, "tract/sections"), "sections", "cells");
2026-05-25 19:36:21 +01:00
if (sections < 4)
{
throw new PatchScriptException(line, "tract sections must be at least 4");
}
2026-05-26 11:21:21 +01:00
areaFunction = areaFunction is not null && areaFunction.Sections != sections
? areaFunction.Resample(sections)
: areaFunction;
var indexScale = originalSections <= 0 ? 1 : sections / (float)originalSections;
var defaultNoseSections = propagation == TractPropagationMode.Waveguide && nasal?.AreaFunction is { } nasalArea && !HasAny(fields, "nose_sections", "nose_cells")
? nasalArea.AcousticUnitDelaySections(44100, 343, 1)
: nasal?.AreaFunction?.Sections ?? 28;
var noseSections = GetBoundInt(fields, line, defaultNoseSections, OwnerField(ownerPath, "tract/nose_sections"), "nose_sections", "nose_cells");
2026-05-25 19:36:21 +01:00
if (noseSections < 0)
{
throw new PatchScriptException(line, "tract nose_sections must be non-negative");
}
nasal = ResampleNasalBranch(nasal, noseSections, indexScale);
var tongueIndex = GetBoundFloat(fields, line, 12.9f, OwnerField(ownerPath, "tract/tongue_index"), "tongue_index", "tongue", "ti");
var constrictionIndex = GetBoundFloat(fields, line, injection?.Position ?? 32, OwnerField(ownerPath, "tract/constriction_index"), "constriction_index", "ci");
2026-05-25 19:36:21 +01:00
return voice with
{
Tract = new VocalTract(
sections,
noseSections,
GetBoundFloat(fields, line, glottis?.Intensity ?? 0.72f, OwnerField(ownerPath, "tract/intensity"), "intensity", "pressure"),
GetBoundFloat(fields, line, glottis?.Tenseness ?? 0.6f, OwnerField(ownerPath, "tract/tenseness"), "tenseness", "tense"),
tongueIndex,
2026-05-25 19:36:21 +01:00
GetBoundFloat(fields, line, 2.43f, OwnerField(ownerPath, "tract/tongue_diameter"), "tongue_diameter", "td"),
GetBoundFloat(fields, line, 0.01f, OwnerField(ownerPath, "tract/velum"), "velum", "nose", "nasal"),
constrictionIndex,
GetBoundFloat(fields, line, injection?.Diameter ?? 1, OwnerField(ownerPath, "tract/constriction_diameter"), "constriction_diameter", "cd"),
GetBoundFloat(fields, line, injection?.Turbulence ?? 0, OwnerField(ownerPath, "tract/turbulence"), "turbulence", "frication"),
2026-05-25 19:36:21 +01:00
GetBoundFloat(fields, line, 1.5f, OwnerField(ownerPath, "tract/lip_opening"), "lip", "lip_opening", "mouth"),
GetBoundFloat(fields, line, glottis?.Reflection ?? 0.75f, OwnerField(ownerPath, "tract/glottal_reflection"), "glottal_reflection", "gr"),
GetBoundFloat(fields, line, -0.85f, OwnerField(ownerPath, "tract/lip_reflection"), "lip_reflection", "lr"),
areaFunction,
glottis,
injection,
nasal,
motion,
propagation,
GetBoundFloat(fields, line, 0.999f, OwnerField(ownerPath, "tract/waveguide_loss"), "waveguide_loss", "loss"),
GetBoundInt(fields, line, 1, OwnerField(ownerPath, "tract/substeps"), "substeps", "steps"),
indexScale)
};
}
private static NasalBranch? ResampleNasalBranch(NasalBranch? nasal, int noseSections, float oralIndexScale)
{
if (nasal is null)
{
return null;
}
var areaFunction = nasal.AreaFunction is { } shape && shape.Sections != noseSections && noseSections > 0
? shape.Resample(noseSections)
: nasal.AreaFunction;
return nasal with
{
AreaFunction = areaFunction,
JunctionIndex = Math.Max(0, (int)MathF.Round(nasal.JunctionIndex * oralIndexScale, MidpointRounding.AwayFromZero))
2026-05-25 19:36:21 +01:00
};
}
2026-05-08 03:00:53 +01:00
private void AddModBus(IReadOnlyDictionary<string, string> fields, int line)
{
var name = GetAny(fields, ["name", "n"], "mod");
var wave = TryGetAny(fields, ["wave", "w"], out var waveform)
? ParseModWaveform(waveform, line)
: ModWaveform.Sine;
var hz = GetFloat(fields, line, 1, "hz", "rate");
var phase = GetFloat(fields, line, 0, "phase");
2026-05-08 10:21:32 +01:00
if (TryGetAny(fields, ["to", "targets"], out var routeSpec))
{
foreach (var route in ParseRoutes(routeSpec, line))
{
_controls.Add(new ControlLane(
$"{name}_{TargetSuffix(route.Target)}",
new Modulator(route.Target, wave, hz, route.Depth, phase)));
}
}
2026-05-08 03:00:53 +01:00
foreach (var (key, target) in ModTargets)
{
if (!TryGetAny(fields, key, out var depthText)) continue;
var depth = ParseFloat(depthText, line);
_controls.Add(new ControlLane($"{name}_{key[0]}", new Modulator(target, wave, hz, depth, phase)));
}
}
2026-05-08 10:21:32 +01:00
private void AddControlLane(IReadOnlyDictionary<string, string> fields, int line)
{
var name = GetAny(fields, ["name", "n"], "control");
if (!TryGetAny(fields, ["target", "t"], out var targetText))
{
throw new PatchScriptException(line, "control lane needs target");
}
var wave = TryGetAny(fields, ["wave", "w"], out var waveform)
? ParseModWaveform(waveform, line)
: ModWaveform.Sine;
_controls.Add(new ControlLane(
name,
new Modulator(
ParseModTarget(targetText, line),
wave,
GetFloat(fields, line, 1, "hz", "rate"),
GetFloat(fields, line, 0, "depth", "d", "decay"),
GetFloat(fields, line, 0, "phase", "ph"),
GetFloat(fields, line, 0, "bias", "b"))));
}
2026-05-08 10:34:07 +01:00
2026-05-13 01:25:05 +01:00
private void AddOperatorGraph(IReadOnlyDictionary<string, string> fields, int line)
{
var graphIndex = _operatorGraphs.Count;
var graphPath = $"/opgraphs/{graphIndex}";
var operators = ParseOperatorNodes(Required(fields, "ops", line), line);
var edges = TryGetAny(fields, ["edges", "e"], out var edgeSpec)
? ParseOperatorEdges(edgeSpec, line)
: [];
var carriers = TryGetAny(fields, ["carriers", "c"], out var carrierSpec)
? ParseOperatorIds(carrierSpec, line)
: operators.Select(op => op.Id).ToList();
2026-05-13 11:47:39 +01:00
AddValidatedOperatorGraph(line, new OperatorGraph(
2026-05-14 19:27:04 +01:00
Name: GetAny(fields, ["name", "n"], $"opgraph{graphIndex}"),
FrequencyHz: GetBoundFloat(fields, line, 440, $"{graphPath}/freq", "freq", "frequency", "f"),
Operators: operators,
Edges: edges,
Carriers: carriers,
Note: new Note(
2026-05-13 12:31:43 +01:00
GetBoundFloat(fields, line, 440, $"{graphPath}/note/frequency", "freq", "frequency", "f"),
GetBoundFloat(fields, line, 0.1f, $"{graphPath}/note/gate", "gate", "hold", "duration"),
ParseNoteSource(GetAny(fields, ["note_source", "source"], "oneshot"), line)),
2026-05-14 19:27:04 +01:00
Gain: GetBoundFloat(fields, line, 0.2f, $"{graphPath}/gain", "gain", "g"),
VibratoDepth: GetBoundFloat(fields, line, 0, $"{graphPath}/pitch/vibrato", "vibrato", "vib"),
VibratoHz: GetBoundFloat(fields, line, 0, $"{graphPath}/pitch/vibrato_hz", "vibrato_hz", "vib_hz"),
VibratoDelaySeconds: GetBoundFloat(fields, line, 0, $"{graphPath}/pitch/vibrato_delay", "vibrato_delay", "vib_delay")));
2026-05-13 11:47:39 +01:00
}
private void StartOperatorGraph(IReadOnlyDictionary<string, string> fields, int line)
{
FlushPendingOperatorGraph();
if (fields.ContainsKey("ops"))
{
AddOperatorGraph(fields, line);
return;
}
var graphIndex = _operatorGraphs.Count;
var graphPath = $"/opgraphs/{graphIndex}";
_pendingOperatorGraph = new PendingOperatorGraph(
line,
2026-05-13 16:50:43 +01:00
graphPath,
2026-05-13 11:47:39 +01:00
GetAny(fields, ["name", "n"], $"opgraph{graphIndex}"),
GetBoundFloat(fields, line, 440, $"{graphPath}/freq", "freq", "frequency", "f"),
2026-05-13 12:31:43 +01:00
new Note(
GetBoundFloat(fields, line, 440, $"{graphPath}/note/frequency", "freq", "frequency", "f"),
GetBoundFloat(fields, line, 0.1f, $"{graphPath}/note/gate", "gate", "hold", "duration"),
ParseNoteSource(GetAny(fields, ["note_source", "source"], "oneshot"), line)),
2026-05-14 19:27:04 +01:00
GetBoundFloat(fields, line, 0.2f, $"{graphPath}/gain", "gain", "g"),
GetBoundFloat(fields, line, 0, $"{graphPath}/pitch/vibrato", "vibrato", "vib"),
GetBoundFloat(fields, line, 0, $"{graphPath}/pitch/vibrato_hz", "vibrato_hz", "vib_hz"),
GetBoundFloat(fields, line, 0, $"{graphPath}/pitch/vibrato_delay", "vibrato_delay", "vib_delay"));
2026-05-13 11:47:39 +01:00
}
private void AddOperator(IReadOnlyDictionary<string, string> fields, int line)
{
var graph = RequiredPendingOperatorGraph(line);
var id = ParseOperatorId(Required(fields, "name", line), line);
2026-05-13 16:50:43 +01:00
var operatorPath = $"{graph.Path}/operators/{id}";
2026-05-13 12:31:43 +01:00
var envelopeSpec = TryGetAny(fields, ["env", "envelope"], out var envSpec)
2026-05-13 19:28:07 +01:00
? ParseEnvelopeSpec(envSpec, fields, line, operatorPath)
2026-05-13 12:31:43 +01:00
: new ParsedEnvelope(
new Envelope(
2026-05-13 16:50:43 +01:00
GetBoundFloat(fields, line, 0, $"{operatorPath}/env/attack", "attack", "a"),
GetBoundFloat(fields, line, 0, $"{operatorPath}/env/decay", "env_decay", "ed"),
GetBoundFloat(fields, line, 1, $"{operatorPath}/env/sustain_level", "sustain_level", "sl"),
GetBoundFloat(fields, line, 0.1f, $"{operatorPath}/env/release", "release", "rel", "decay", "d")),
GetBoundFloat(fields, line, graph.Note.GateSeconds, $"{operatorPath}/note/gate", "gate", "hold", "duration"));
2026-05-13 11:47:39 +01:00
graph.Operators.Add(new OperatorNode(
2026-05-14 19:27:04 +01:00
Id: id,
Ratio: GetBoundFloat(fields, line, 1, $"{operatorPath}/ratio", "ratio", "r"),
Level: GetBoundFloat(fields, line, 1, $"{operatorPath}/level", "level", "l"),
Feedback: GetBoundFloat(fields, line, 0, $"{operatorPath}/feedback", "feedback", "fb"),
Note: graph.Note with { GateSeconds = envelopeSpec.GateSeconds },
Envelope: envelopeSpec.Envelope,
RateLevelEnvelope: envelopeSpec.RateLevelEnvelope));
2026-05-13 11:47:39 +01:00
}
private void AddOperatorRoute(IReadOnlyDictionary<string, string> fields, int line)
{
var graph = RequiredPendingOperatorGraph(line);
var source = ParseOperatorId(Required(fields, "from", line), line);
var target = ParseOperatorId(Required(fields, "to", line), line);
2026-05-13 16:50:43 +01:00
graph.Edges.Add(new OperatorEdge(source, target, GetBoundFloat(fields, line, 1, $"{graph.Path}/routes/{source}>{target}/index", "index", "amount", "depth")));
2026-05-13 11:47:39 +01:00
}
private void AddOperatorCarrier(IReadOnlyDictionary<string, string> fields, int line)
{
var graph = RequiredPendingOperatorGraph(line);
graph.Carriers.Add(ParseOperatorId(Required(fields, "name", line), line));
}
private PendingOperatorGraph RequiredPendingOperatorGraph(int line) =>
_pendingOperatorGraph ?? throw new PatchScriptException(line, "operator graph command needs an active opgraph");
private void FlushPendingOperatorGraph()
{
if (_pendingOperatorGraph is null) return;
var graph = _pendingOperatorGraph;
_pendingOperatorGraph = null;
AddValidatedOperatorGraph(graph.Line, new OperatorGraph(
graph.Name,
graph.FrequencyHz,
graph.Operators,
graph.Edges,
graph.Carriers.Count > 0 ? graph.Carriers : graph.Operators.Select(op => op.Id).ToList(),
2026-05-13 12:31:43 +01:00
graph.Note,
2026-05-14 19:27:04 +01:00
graph.Gain,
graph.VibratoDepth,
graph.VibratoHz,
graph.VibratoDelaySeconds));
2026-05-13 11:47:39 +01:00
}
private void AddValidatedOperatorGraph(int line, OperatorGraph graph)
{
if (graph.Operators.Count == 0)
{
throw new PatchScriptException(line, "operator graph needs at least one operator");
}
var operatorIds = graph.Operators.Select(op => op.Id).ToHashSet();
foreach (var edge in graph.Edges)
2026-05-13 01:25:05 +01:00
{
if (!operatorIds.Contains(edge.SourceId) || !operatorIds.Contains(edge.TargetId))
{
throw new PatchScriptException(line, $"operator edge `{edge.SourceId}>{edge.TargetId}` references an unknown operator");
}
}
2026-05-13 11:47:39 +01:00
foreach (var carrier in graph.Carriers)
2026-05-13 01:25:05 +01:00
{
if (!operatorIds.Contains(carrier))
{
throw new PatchScriptException(line, $"carrier `{carrier}` references an unknown operator");
}
}
2026-05-13 11:47:39 +01:00
_operatorGraphs.Add(graph);
2026-05-13 01:25:05 +01:00
}
2026-05-13 00:28:31 +01:00
private void AddParameter(IReadOnlyDictionary<string, string> fields, int line)
{
var path = Required(fields, "path", line);
if (!path.StartsWith('/'))
{
throw new PatchScriptException(line, "parameter path must start with `/`");
}
if (_parameters.Any(parameter => parameter.Path.Equals(path, StringComparison.OrdinalIgnoreCase)))
{
throw new PatchScriptException(line, $"duplicate parameter path `{path}`");
}
var label = TryGetAny(fields, ["label", "name", "n"], out var labelText)
? labelText
: path.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? path;
var min = GetFloat(fields, line, 0, "min");
var max = GetFloat(fields, line, 1, "max");
var step = GetFloat(fields, line, 0.001f, "step");
var fallbackDefault = Math.Clamp(0.5f, Math.Min(min, max), Math.Max(min, max));
var defaultValue = GetFloat(fields, line, fallbackDefault, "default", "value", "v");
if (max < min)
{
throw new PatchScriptException(line, "parameter max must be greater than or equal to min");
}
if (step < 0)
{
throw new PatchScriptException(line, "parameter step must be non-negative");
}
if (defaultValue < min || defaultValue > max)
{
throw new PatchScriptException(line, "parameter default must be inside min/max");
}
_parameters.Add(new PatchParameter(
path,
label,
defaultValue,
min,
max,
step,
GetAny(fields, ["unit"], ""),
GetAny(fields, ["rate", "automation", "automation_rate"], "control"),
GetAny(fields, ["notes", "note"], "")));
}
2026-05-13 00:38:23 +01:00
private float GetBoundFloat(
IReadOnlyDictionary<string, string> fields,
int line,
float fallback,
string fieldPath,
params string[] keys) =>
TryGetAny(fields, keys, out var value) ? ParseBoundFloat(value, line, fallback, fieldPath) : fallback;
2026-05-15 12:19:04 +01:00
private int GetBoundInt(
IReadOnlyDictionary<string, string> fields,
int line,
int fallback,
string fieldPath,
params string[] keys)
{
if (!TryGetAny(fields, keys, out var value))
{
return fallback;
}
if (value.StartsWith('@'))
{
throw new PatchScriptException(line, $"parameter binding is not supported for integer field `{fieldPath}`");
}
return ParseInt(value, line);
}
2026-05-13 00:38:23 +01:00
private float ParseBoundFloat(string value, int line, float fallback, string fieldPath)
{
if (!value.StartsWith('@'))
{
return ParseFloat(value, line);
}
var parameterPath = value[1..];
if (!parameterPath.StartsWith('/'))
{
throw new PatchScriptException(line, "parameter reference must use `@/path`");
}
var parameter = _parameters.FirstOrDefault(candidate => candidate.Path.Equals(parameterPath, StringComparison.OrdinalIgnoreCase));
if (parameter is null)
{
throw new PatchScriptException(line, $"unknown parameter `{parameterPath}`");
}
if (_parameterBindings.Any(binding => binding.FieldPath.Equals(fieldPath, StringComparison.OrdinalIgnoreCase)))
{
throw new PatchScriptException(line, $"duplicate parameter binding for `{fieldPath}`");
}
_parameterBindings.Add(new ParameterBinding(fieldPath, parameter.Path));
return parameter.Default;
}
2026-05-14 20:45:54 +01:00
private static string VoicePath(int voiceIndex) => $"/voices/{voiceIndex}";
2026-05-14 21:11:20 +01:00
private static string OwnerField(string ownerPath, string field) => $"{ownerPath}/{field}";
2026-05-13 00:38:23 +01:00
2026-05-13 01:25:05 +01:00
private static List<OperatorNode> ParseOperatorNodes(string value, int line) =>
value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part =>
{
var pieces = part.Split(':');
if (pieces.Length is < 3 or > 6)
{
throw new PatchScriptException(line, $"bad operator `{part}`");
}
return new OperatorNode(
2026-05-14 19:27:04 +01:00
Id: ParseInt(pieces[0], line),
Ratio: ParseFloat(pieces[1], line),
Level: ParseFloat(pieces[2], line),
Feedback: pieces.Length >= 4 ? ParseFloat(pieces[3], line) : 0,
Note: pieces.Length >= 6 ? new Note(GateSeconds: ParseFloat(pieces[4], line)) : new Note(),
Envelope: pieces.Length >= 6 ? new Envelope(ReleaseSeconds: ParseFloat(pieces[5], line)) : new Envelope());
2026-05-13 01:25:05 +01:00
})
.ToList();
private static List<OperatorEdge> ParseOperatorEdges(string value, int line) =>
value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part =>
{
var pieces = part.Split(':');
if (pieces.Length is < 1 or > 2)
{
throw new PatchScriptException(line, $"bad operator edge `{part}`");
}
var nodes = pieces[0].Split('>');
if (nodes.Length != 2)
{
throw new PatchScriptException(line, $"bad operator edge `{part}`");
}
return new OperatorEdge(
ParseInt(nodes[0], line),
ParseInt(nodes[1], line),
pieces.Length == 2 ? ParseFloat(pieces[1], line) : 1);
})
.ToList();
private static List<int> ParseOperatorIds(string value, int line) =>
value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part => ParseInt(part, line))
.ToList();
2026-05-13 19:28:07 +01:00
private ParsedEnvelope ParseEnvelopeSpec(string value, IReadOnlyDictionary<string, string> fields, int line, string fieldPath)
2026-05-13 11:47:39 +01:00
{
var pieces = value.Split(':');
return pieces[0].ToLowerInvariant() switch
{
2026-05-13 12:31:43 +01:00
"ad" when pieces.Length == 3 => AdEnvelope(
2026-05-13 16:50:43 +01:00
ParseBoundFloat(pieces[1], line, 0, $"{fieldPath}/env/attack"),
ParseBoundFloat(pieces[2], line, 0.1f, $"{fieldPath}/env/decay")),
2026-05-13 12:31:43 +01:00
"adsr" when pieces.Length == 5 => new ParsedEnvelope(
new Envelope(
2026-05-13 16:50:43 +01:00
ParseBoundFloat(pieces[1], line, 0, $"{fieldPath}/env/attack"),
ParseBoundFloat(pieces[2], line, 0, $"{fieldPath}/env/decay"),
ParseBoundFloat(pieces[3], line, 1, $"{fieldPath}/env/sustain_level"),
ParseBoundFloat(pieces[4], line, 0.1f, $"{fieldPath}/env/release")),
2026-05-13 19:28:07 +01:00
GateSeconds(fields, line, 0.1f, fieldPath)),
"rl" or "ratelevel" when pieces.Length == 1 => ParseRateLevelEnvelope(fields, line, fieldPath),
"rl" or "ratelevel" when pieces.Length == 9 => ParseRateLevelEnvelope(pieces, fields, line, fieldPath),
2026-05-13 11:47:39 +01:00
_ => throw new PatchScriptException(line, $"bad envelope `{value}`")
};
}
2026-05-13 12:31:43 +01:00
private static ParsedEnvelope AdEnvelope(float attackSeconds, float decaySeconds) =>
new(new Envelope(attackSeconds, decaySeconds, 0, 0), attackSeconds + decaySeconds);
2026-05-14 20:36:06 +01:00
private static IReadOnlyList<HarmonicPartial> ParseHarmonicPartials(string value, int line)
{
if (string.IsNullOrWhiteSpace(value)) return [];
return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part =>
{
var pieces = part.Split(':', 2);
if (pieces.Length != 2) throw new PatchScriptException(line, $"bad harmonic partial `{part}`");
var ratio = ParseFloat(pieces[0], line);
var gain = ParseFloat(pieces[1], line);
if (ratio <= 0) throw new PatchScriptException(line, "harmonic partial ratio must be greater than zero");
if (gain < 0) throw new PatchScriptException(line, "harmonic partial gain must be zero or greater");
return new HarmonicPartial(ratio, gain);
})
.ToArray();
}
private TractAreaFunction? ParseTractAreaFunctionReference(IReadOnlyDictionary<string, string> fields, int line)
{
var hasInlineShape = HasAny(fields, "diameters", "diameter", "areas", "area");
var hasNamedShape = TryGetAny(fields, ["shape", "tract_shape", "area_function"], out var shapeName);
if (hasInlineShape && hasNamedShape)
{
throw new PatchScriptException(line, "tract cannot use both a named shape and inline diameters/areas");
}
if (hasInlineShape)
{
2026-05-26 11:21:21 +01:00
return ParseTractAreaFunction(fields, line, 17);
}
if (!hasNamedShape)
{
return null;
}
if (!_tractShapesByName.TryGetValue(shapeName, out var shape))
{
throw new PatchScriptException(line, $"unknown tract shape `{shapeName}`");
}
return shape.AreaFunction;
}
private GlottalSource? ParseGlottalSourceReference(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var hasInlineGlottis = HasAny(fields, "aspiration", "breath", "skew", "open_phase");
var hasNamedGlottis = TryGetAny(fields, ["glottis", "source"], out var glottisName);
if (!hasNamedGlottis && !hasInlineGlottis)
{
return null;
}
GlottalSource? namedGlottis = null;
if (hasNamedGlottis && !_glottalSourcesByName.TryGetValue(glottisName, out namedGlottis))
{
throw new PatchScriptException(line, $"unknown glottis `{glottisName}`");
}
var fallback = hasNamedGlottis ? namedGlottis! : new GlottalSource();
return new GlottalSource(
hasNamedGlottis ? glottisName : "",
GetBoundFloat(fields, line, fallback.Intensity, OwnerField(ownerPath, "tract/glottis/intensity"), "intensity", "pressure"),
GetBoundFloat(fields, line, fallback.Tenseness, OwnerField(ownerPath, "tract/glottis/tenseness"), "tenseness", "tense"),
GetBoundFloat(fields, line, fallback.Aspiration, OwnerField(ownerPath, "tract/glottis/aspiration"), "aspiration", "breath"),
GetBoundFloat(fields, line, fallback.Reflection, OwnerField(ownerPath, "tract/glottis/reflection"), "glottal_reflection", "gr"),
GetBoundFloat(fields, line, fallback.Skew, OwnerField(ownerPath, "tract/glottis/skew"), "skew", "open_phase"));
}
private TractInjection? ParseTractInjectionReference(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var hasInlineInjection = HasAny(fields, "burst", "transient", "width");
var hasNamedInjection = TryGetAny(fields, ["injection", "tract_injection"], out var injectionName);
if (!hasNamedInjection && !hasInlineInjection)
{
return null;
}
TractInjection? namedInjection = null;
if (hasNamedInjection && !_tractInjectionsByName.TryGetValue(injectionName, out namedInjection))
{
throw new PatchScriptException(line, $"unknown tract injection `{injectionName}`");
}
var fallback = hasNamedInjection ? namedInjection! : new TractInjection();
return new TractInjection(
hasNamedInjection ? injectionName : "",
GetBoundFloat(fields, line, fallback.Position, OwnerField(ownerPath, "tract/injection/position"), "position", "constriction_index", "ci"),
GetBoundFloat(fields, line, fallback.Diameter, OwnerField(ownerPath, "tract/injection/diameter"), "diameter", "constriction_diameter", "cd"),
GetBoundFloat(fields, line, fallback.Turbulence, OwnerField(ownerPath, "tract/injection/turbulence"), "turbulence", "frication"),
GetBoundFloat(fields, line, fallback.Burst, OwnerField(ownerPath, "tract/injection/burst"), "burst", "transient"),
GetBoundFloat(fields, line, fallback.Width, OwnerField(ownerPath, "tract/injection/width"), "width"));
}
private NasalBranch? ParseNasalBranchReference(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var hasInlineNasal = HasAny(fields, "nose_diameters", "nose_areas", "nose_junction");
var hasNamedNasal = TryGetAny(fields, ["nasal_branch", "nasal_shape"], out var nasalName);
if (!hasNamedNasal && !hasInlineNasal)
{
return null;
}
NasalBranch? namedNasal = null;
if (hasNamedNasal && !_nasalBranchesByName.TryGetValue(nasalName, out namedNasal))
{
throw new PatchScriptException(line, $"unknown nasal branch `{nasalName}`");
}
2026-05-26 11:21:21 +01:00
var fallback = hasNamedNasal ? namedNasal! : new NasalBranch(AreaFunction: new TractAreaFunction([0.01f, 0.6f, 1.2f, 1.6f], 12));
var areaFunction = fallback.AreaFunction;
2026-05-26 11:21:21 +01:00
var lengthCentimeters = GetFloat(fields, line, areaFunction?.LengthCentimeters ?? 12, "length_cm", "length_centimeters");
if (TryGetAny(fields, ["nose_diameters"], out var diameters))
{
2026-05-26 11:21:21 +01:00
areaFunction = new TractAreaFunction(ParseFloatList(diameters, line, "nose_diameters").ToArray(), lengthCentimeters);
}
if (TryGetAny(fields, ["nose_areas"], out var areas))
{
2026-05-26 11:21:21 +01:00
areaFunction = TractAreaFunction.FromAreas(ParseFloatList(areas, line, "nose_areas").ToArray(), lengthCentimeters);
}
return new NasalBranch(
hasNamedNasal ? nasalName : "",
areaFunction,
GetBoundInt(fields, line, fallback.JunctionIndex, OwnerField(ownerPath, "tract/nasal/junction"), "nose_junction", "junction", "junction_index"),
GetBoundFloat(fields, line, fallback.Velum, OwnerField(ownerPath, "tract/nasal/velum"), "velum", "nose", "nasal"),
GetBoundFloat(fields, line, fallback.Reflection, OwnerField(ownerPath, "tract/nasal/reflection"), "nose_reflection"),
GetBoundFloat(fields, line, fallback.Loss, OwnerField(ownerPath, "tract/nasal/loss"), "nose_loss"));
}
private TractMotion? ParseTractMotionReference(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var hasInlineMotion = HasAny(fields, "diameter_slew", "shape_return", "constriction_slew", "velum_slew", "obstruction_threshold");
var hasNamedMotion = TryGetAny(fields, ["motion", "tract_motion"], out var motionName);
if (!hasNamedMotion && !hasInlineMotion)
{
return null;
}
TractMotion? namedMotion = null;
if (hasNamedMotion && !_tractMotionsByName.TryGetValue(motionName, out namedMotion))
{
throw new PatchScriptException(line, $"unknown tract motion `{motionName}`");
}
var fallback = hasNamedMotion ? namedMotion! : new TractMotion();
return new TractMotion(
hasNamedMotion ? motionName : "",
GetBoundFloat(fields, line, fallback.DiameterSlewPerSecond, OwnerField(ownerPath, "tract/motion/diameter_slew"), "diameter_slew", "slew"),
GetBoundFloat(fields, line, fallback.ShapeReturnPerSecond, OwnerField(ownerPath, "tract/motion/shape_return"), "shape_return", "return_slew"),
GetBoundFloat(fields, line, fallback.ConstrictionSlewPerSecond, OwnerField(ownerPath, "tract/motion/constriction_slew"), "constriction_slew"),
GetBoundFloat(fields, line, fallback.VelumSlewPerSecond, OwnerField(ownerPath, "tract/motion/velum_slew"), "velum_slew"),
GetBoundFloat(fields, line, fallback.ObstructionThreshold, OwnerField(ownerPath, "tract/motion/obstruction_threshold"), "obstruction_threshold", "closure"));
}
2026-05-26 11:21:21 +01:00
private static TractAreaFunction ParseTractAreaFunction(IReadOnlyDictionary<string, string> fields, int line, float defaultLengthCentimeters)
{
var hasDiameters = TryGetAny(fields, ["diameters", "diameter"], out var diametersText);
var hasAreas = TryGetAny(fields, ["areas", "area"], out var areasText);
if (hasDiameters == hasAreas)
{
throw new PatchScriptException(line, "tract shape needs exactly one of diameters or areas");
}
var samples = ParseFloatList(hasDiameters ? diametersText! : areasText!, line, hasDiameters ? "diameters" : "areas");
if (samples.Count < 4)
{
throw new PatchScriptException(line, "tract shape needs at least four samples");
}
if (samples.Any(sample => sample < 0))
{
throw new PatchScriptException(line, "tract shape samples must be zero or greater");
}
2026-05-26 11:21:21 +01:00
var lengthCentimeters = GetFloat(fields, line, defaultLengthCentimeters, "length_cm", "length_centimeters");
if (lengthCentimeters <= 0)
{
throw new PatchScriptException(line, "tract shape length_cm must be greater than zero");
}
return hasDiameters
2026-05-26 11:21:21 +01:00
? new TractAreaFunction(samples.ToArray(), lengthCentimeters)
: TractAreaFunction.FromAreas(samples.ToArray(), lengthCentimeters);
}
2026-05-15 09:10:40 +01:00
private static PadSpectrumProfile ParsePadSpectrumProfile(IReadOnlyDictionary<string, string> fields, int line)
{
2026-05-16 02:02:58 +01:00
var hasPadProfileFields =
2026-05-15 09:10:40 +01:00
HasAny(fields,
"pad_mode", "zyn_mode",
"pad_bandwidth", "zyn_bandwidth", "bandwidth",
"pad_bwscale", "zyn_bwscale", "bwscale",
"pad_profile", "zyn_profile",
"pad_position", "zyn_position");
2026-05-16 02:02:58 +01:00
if (!hasPadProfileFields)
2026-05-15 09:10:40 +01:00
{
return PadSpectrumProfile.Generic;
}
var mode = TryGetAny(fields, ["pad_mode", "zyn_mode"], out var modeText)
? ParsePadSpectrumMode(modeText, line)
2026-05-16 02:02:58 +01:00
: PadSpectrumMode.Bandwidth;
2026-05-15 09:10:40 +01:00
var bandwidth = GetInt(fields, line, 500, "pad_bandwidth", "zyn_bandwidth", "bandwidth");
var bandwidthScale = GetInt(fields, line, 0, "pad_bwscale", "zyn_bwscale", "bwscale");
if (bandwidth < 0) throw new PatchScriptException(line, "PAD bandwidth must be zero or greater");
return new PadSpectrumProfile(
mode,
Math.Clamp(bandwidth, 0, 1000),
Math.Clamp(bandwidthScale, 0, 7),
TryGetAny(fields, ["pad_profile", "zyn_profile"], out var profileText)
2026-05-16 02:02:58 +01:00
? ParsePadHarmonicProfile(profileText, line)
: new PadHarmonicProfile(),
2026-05-15 09:10:40 +01:00
TryGetAny(fields, ["pad_position", "zyn_position"], out var positionText)
2026-05-16 02:02:58 +01:00
? ParsePadHarmonicPosition(positionText, line)
: new PadHarmonicPosition());
2026-05-15 09:10:40 +01:00
}
private static PadSpectrumMode ParsePadSpectrumMode(string value, int line) => value.ToLowerInvariant() switch
{
"generic" => PadSpectrumMode.Generic,
2026-05-16 02:02:58 +01:00
"bandwidth" or "zyn" or "zyn_bandwidth" => PadSpectrumMode.Bandwidth,
"discrete" or "other" => PadSpectrumMode.Discrete,
"continuous" => PadSpectrumMode.Continuous,
2026-05-15 09:10:40 +01:00
_ => throw new PatchScriptException(line, $"unknown PAD spectrum mode `{value}`")
};
2026-05-16 02:02:58 +01:00
private static PadHarmonicProfile ParsePadHarmonicProfile(string value, int line)
2026-05-15 09:10:40 +01:00
{
var pieces = value.Split(':', StringSplitOptions.TrimEntries);
if (pieces.Length != 12)
{
2026-05-16 02:02:58 +01:00
throw new PatchScriptException(line, "PAD harmonic profile needs 12 colon-separated fields");
2026-05-15 09:10:40 +01:00
}
2026-05-16 02:02:58 +01:00
return new PadHarmonicProfile(
2026-05-15 09:10:40 +01:00
ParseProfileBaseType(pieces[0], line),
ParseByteish(pieces[1], line),
ParseByteish(pieces[2], line),
ParseByteish(pieces[3], line),
ParseByteish(pieces[4], line),
ParseByteish(pieces[5], line),
ParseProfileAmplitudeType(pieces[6], line),
ParseProfileAmplitudeMode(pieces[7], line),
ParseByteish(pieces[8], line),
ParseByteish(pieces[9], line),
ParseBool(pieces[10], line),
ParseProfileHalf(pieces[11], line));
}
2026-05-16 02:02:58 +01:00
private static PadHarmonicPosition ParsePadHarmonicPosition(string value, int line)
2026-05-15 09:10:40 +01:00
{
var pieces = value.Split(':', StringSplitOptions.TrimEntries);
if (pieces.Length != 4)
{
2026-05-16 02:02:58 +01:00
throw new PatchScriptException(line, "PAD harmonic position needs 4 colon-separated fields");
2026-05-15 09:10:40 +01:00
}
2026-05-16 02:02:58 +01:00
return new PadHarmonicPosition(
2026-05-15 09:10:40 +01:00
ParseHarmonicPositionType(pieces[0], line),
ParseByteish(pieces[1], line, 255),
ParseByteish(pieces[2], line, 255),
ParseByteish(pieces[3], line, 255));
}
private static int ParseByteish(string value, int line, int max = 127)
{
var parsed = ParseInt(value, line);
if (parsed < 0 || parsed > max)
{
2026-05-16 02:02:58 +01:00
throw new PatchScriptException(line, $"PAD profile parameter `{value}` is outside 0..{max}");
2026-05-15 09:10:40 +01:00
}
return parsed;
}
2026-05-16 02:02:58 +01:00
private static PadProfileBaseType ParseProfileBaseType(string value, int line) => value.ToLowerInvariant() switch
2026-05-15 09:10:40 +01:00
{
2026-05-16 02:02:58 +01:00
"0" or "gauss" or "gaussian" => PadProfileBaseType.Gaussian,
"1" or "square" or "rect" or "rectangular" => PadProfileBaseType.Square,
"2" or "double" or "double_exp" or "doubleexponential" => PadProfileBaseType.DoubleExponential,
_ => throw new PatchScriptException(line, $"unknown PAD profile base type `{value}`")
2026-05-15 09:10:40 +01:00
};
2026-05-16 02:02:58 +01:00
private static PadProfileAmplitudeType ParseProfileAmplitudeType(string value, int line) => value.ToLowerInvariant() switch
2026-05-15 09:10:40 +01:00
{
2026-05-16 02:02:58 +01:00
"0" or "off" or "none" => PadProfileAmplitudeType.Off,
"1" or "gauss" or "gaussian" => PadProfileAmplitudeType.Gaussian,
"2" or "sine" => PadProfileAmplitudeType.Sine,
"3" or "flat" => PadProfileAmplitudeType.Flat,
_ => throw new PatchScriptException(line, $"unknown PAD profile amplitude type `{value}`")
2026-05-15 09:10:40 +01:00
};
2026-05-16 02:02:58 +01:00
private static PadProfileAmplitudeMode ParseProfileAmplitudeMode(string value, int line) => value.ToLowerInvariant() switch
2026-05-15 09:10:40 +01:00
{
2026-05-16 02:02:58 +01:00
"0" or "sum" => PadProfileAmplitudeMode.Sum,
"1" or "mult" or "multiply" => PadProfileAmplitudeMode.Mult,
"2" or "div1" => PadProfileAmplitudeMode.Div1,
"3" or "div2" => PadProfileAmplitudeMode.Div2,
_ => throw new PatchScriptException(line, $"unknown PAD profile amplitude mode `{value}`")
2026-05-15 09:10:40 +01:00
};
2026-05-16 02:02:58 +01:00
private static PadProfileHalf ParseProfileHalf(string value, int line) => value.ToLowerInvariant() switch
2026-05-15 09:10:40 +01:00
{
2026-05-16 02:02:58 +01:00
"0" or "full" => PadProfileHalf.Full,
"1" or "upper" => PadProfileHalf.Upper,
"2" or "lower" => PadProfileHalf.Lower,
_ => throw new PatchScriptException(line, $"unknown PAD profile half `{value}`")
2026-05-15 09:10:40 +01:00
};
2026-05-16 02:02:58 +01:00
private static PadHarmonicPositionType ParseHarmonicPositionType(string value, int line) => value.ToLowerInvariant() switch
{
"0" or "harmonic" => PadHarmonicPositionType.Harmonic,
"1" or "shiftu" or "shift_up" => PadHarmonicPositionType.ShiftUp,
"2" or "shiftl" or "shift_down" => PadHarmonicPositionType.ShiftDown,
"3" or "poweru" or "power_up" => PadHarmonicPositionType.PowerUp,
"4" or "powerl" or "power_down" => PadHarmonicPositionType.PowerDown,
"5" or "sine" => PadHarmonicPositionType.Sine,
"6" or "power" => PadHarmonicPositionType.Power,
"7" or "shift" => PadHarmonicPositionType.Shift,
_ => throw new PatchScriptException(line, $"unknown PAD harmonic position type `{value}`")
2026-05-15 09:10:40 +01:00
};
2026-05-13 19:28:07 +01:00
private ParsedEnvelope ParseRateLevelEnvelope(IReadOnlyDictionary<string, string> fields, int line, string fieldPath)
{
var rates = ParseFloatList(Required(fields, "rates", line), line, "rates");
var levels = ParseFloatList(Required(fields, "levels", line), line, "levels");
if (rates.Count != 4 || levels.Count != 4)
{
throw new PatchScriptException(line, "rate/level envelope needs four rates and four levels");
}
return RateLevelParsedEnvelope(rates, levels, fields, line, fieldPath);
}
private ParsedEnvelope ParseRateLevelEnvelope(string[] pieces, IReadOnlyDictionary<string, string> fields, int line, string fieldPath)
{
var rates = new[] { pieces[1], pieces[3], pieces[5], pieces[7] }
.Select(part => ParseFloat(part, line))
.ToArray();
var levels = new[] { pieces[2], pieces[4], pieces[6], pieces[8] }
.Select(part => ParseFloat(part, line))
.ToArray();
return RateLevelParsedEnvelope(rates, levels, fields, line, fieldPath);
}
private ParsedEnvelope RateLevelParsedEnvelope(IReadOnlyList<float> rates, IReadOnlyList<float> levels, IReadOnlyDictionary<string, string> fields, int line, string fieldPath)
{
2026-05-14 13:31:15 +01:00
var curves = TryGetAny(fields, ["curves", "curve"], out var curveText)
? ParseCurveList(curveText, line)
: [RateLevelCurve.Linear, RateLevelCurve.Linear, RateLevelCurve.Linear, RateLevelCurve.Linear];
if (curves.Count != 4)
{
throw new PatchScriptException(line, "rate/level envelope needs four curves");
}
2026-05-13 19:28:07 +01:00
var envelope = new RateLevelEnvelope(
2026-05-14 13:31:15 +01:00
Math.Max(0, rates[0]), Math.Clamp(levels[0], 0, 4f),
Math.Max(0, rates[1]), Math.Clamp(levels[1], 0, 4f),
Math.Max(0, rates[2]), Math.Clamp(levels[2], 0, 4f),
Math.Max(0, rates[3]), Math.Clamp(levels[3], 0, 4f),
2026-05-15 13:24:22 +01:00
curves[0], curves[1], curves[2], curves[3],
TryGetAny(fields, ["start_level", "start"], out var startText)
? Math.Clamp(ParseFloat(startText, line), 0, 4f)
: 0);
2026-05-13 19:28:07 +01:00
var defaultGate = Math.Max(envelope.Rate1Seconds + envelope.Rate2Seconds + envelope.Rate3Seconds, 0.02f);
return new ParsedEnvelope(
new Envelope(envelope.Rate1Seconds, envelope.Rate2Seconds + envelope.Rate3Seconds, envelope.Level3, envelope.Rate4Seconds),
GateSeconds(fields, line, defaultGate, fieldPath),
envelope);
}
2026-05-15 16:43:36 +01:00
private RateLevelEnvelope? ParseFilterRateLevelEnvelope(IReadOnlyDictionary<string, string> fields, int line, string ownerPath, string prefix)
2026-05-15 13:24:22 +01:00
{
2026-05-15 16:43:36 +01:00
var envKeys = prefix == "lpf" ? new[] { "lpf_env", "filter_env" } : new[] { "hpf_env" };
if (!TryGetAny(fields, envKeys, out var env) || env is not ("rl" or "ratelevel"))
2026-05-15 13:24:22 +01:00
{
return null;
}
2026-05-15 16:43:36 +01:00
var ratesName = $"{prefix}_rates";
var levelsName = $"{prefix}_levels";
var curvesName = $"{prefix}_curves";
var curveName = $"{prefix}_curve";
var startName = $"{prefix}_start";
var rates = ParseFloatList(Required(fields, ratesName, line), line, ratesName);
var levels = ParseFloatList(Required(fields, levelsName, line), line, levelsName);
2026-05-15 13:24:22 +01:00
if (rates.Count != 4 || levels.Count != 4)
{
throw new PatchScriptException(line, "filter rate/level envelope needs four rates and four levels");
}
2026-05-15 16:43:36 +01:00
var curves = TryGetAny(fields, [curvesName, curveName], out var curveText)
2026-05-15 13:24:22 +01:00
? ParseCurveList(curveText, line)
: [RateLevelCurve.Linear, RateLevelCurve.Linear, RateLevelCurve.Linear, RateLevelCurve.Linear];
if (curves.Count != 4)
{
throw new PatchScriptException(line, "filter rate/level envelope needs four curves");
}
return new RateLevelEnvelope(
Math.Max(0, rates[0]), Math.Clamp(levels[0], -1, 1),
Math.Max(0, rates[1]), Math.Clamp(levels[1], -1, 1),
Math.Max(0, rates[2]), Math.Clamp(levels[2], -1, 1),
Math.Max(0, rates[3]), Math.Clamp(levels[3], -1, 1),
curves[0], curves[1], curves[2], curves[3],
2026-05-15 16:43:36 +01:00
TryGetAny(fields, prefix == "lpf" ? [startName, "filter_start"] : [startName], out var startText)
2026-05-15 13:24:22 +01:00
? Math.Clamp(ParseFloat(startText, line), -1, 1)
: 0);
}
2026-05-14 13:31:15 +01:00
private static IReadOnlyList<RateLevelCurve> ParseCurveList(string value, int line)
{
var parts = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0)
{
throw new PatchScriptException(line, "curves list cannot be empty");
}
return parts.Select(part => part.ToLowerInvariant() switch
{
"lin" or "linear" => RateLevelCurve.Linear,
"exp" or "exponential" => RateLevelCurve.Exponential,
_ => throw new PatchScriptException(line, $"unknown rate/level curve `{part}`")
}).ToArray();
}
2026-05-13 19:28:07 +01:00
private float GateSeconds(IReadOnlyDictionary<string, string> fields, int line, float defaultValue, string fieldPath) =>
GetBoundFloat(fields, line, defaultValue, $"{fieldPath}/note/gate", "gate", "hold", "duration");
private static IReadOnlyList<float> ParseFloatList(string value, int line, string fieldName)
{
var parts = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0)
{
throw new PatchScriptException(line, $"{fieldName} list cannot be empty");
}
return parts.Select(part => ParseFloat(part, line)).ToArray();
}
2026-05-13 11:47:39 +01:00
private static int ParseOperatorId(string value, int line)
{
var normalized = value.StartsWith("op", StringComparison.OrdinalIgnoreCase)
? value[2..]
: value;
return ParseInt(normalized, line);
}
2026-05-08 10:34:07 +01:00
private void AddSfxrPatch(SfxrParams parameters)
{
var mapped = parameters.ToPatch();
Voices.AddRange(mapped.Voices);
Repeat = mapped.Repeat;
Gain *= mapped.Gain;
}
private static SfxrParams ParseSfxrCommand(IReadOnlyDictionary<string, string> fields, int line)
{
var parameters = TryGetAny(fields, ["preset", "p"], out var preset)
? SfxrParams.Named(preset) ?? throw new PatchScriptException(line, $"unknown sfxr preset `{preset}`")
: new SfxrParams();
return ApplySfxrFields(parameters, fields, line);
}
private static SfxrParams ApplySfxrFields(SfxrParams parameters, IReadOnlyDictionary<string, string> fields, int line)
{
if (TryGetAny(fields, ["mutate_seed", "ms"], out var seedText))
{
if (!ulong.TryParse(seedText, out var seed))
{
throw new PatchScriptException(line, "mutate_seed must be an integer");
}
var amount = GetFloat(fields, line, 0.05f, "mutate", "m");
parameters = parameters.Mutate(seed, amount);
}
if (TryGetAny(fields, ["wave", "w"], out var wave)) parameters = parameters with { WaveType = ParseWaveform(wave, line) };
if (TryGetAny(fields, ["base", "b"], out var baseFreq)) parameters = parameters with { BaseFreq = Math.Clamp(ParseFloat(baseFreq, line), 0, 1) };
if (TryGetAny(fields, ["limit", "lim"], out var limit)) parameters = parameters with { FreqLimit = Math.Clamp(ParseFloat(limit, line), 0, 1) };
if (TryGetAny(fields, ["ramp", "r"], out var ramp)) parameters = parameters with { FreqRamp = Math.Clamp(ParseFloat(ramp, line), -1, 1) };
if (TryGetAny(fields, ["dramp", "dr"], out var dramp)) parameters = parameters with { FreqDramp = Math.Clamp(ParseFloat(dramp, line), -1, 1) };
if (TryGetAny(fields, ["duty", "du"], out var duty)) parameters = parameters with { Duty = Math.Clamp(ParseFloat(duty, line), 0, 1) };
if (TryGetAny(fields, ["duty_ramp", "dur"], out var dutyRamp)) parameters = parameters with { DutyRamp = Math.Clamp(ParseFloat(dutyRamp, line), -1, 1) };
if (TryGetAny(fields, ["vib", "vi"], out var vib)) parameters = parameters with { VibStrength = Math.Clamp(ParseFloat(vib, line), 0, 1) };
if (TryGetAny(fields, ["vib_speed", "vs"], out var vibSpeed)) parameters = parameters with { VibSpeed = Math.Clamp(ParseFloat(vibSpeed, line), 0, 1) };
if (TryGetAny(fields, ["vib_delay", "vd"], out var vibDelay)) parameters = parameters with { VibDelay = Math.Clamp(ParseFloat(vibDelay, line), 0, 1) };
if (TryGetAny(fields, ["attack", "a"], out var attack)) parameters = parameters with { EnvAttack = Math.Clamp(ParseFloat(attack, line), 0, 1) };
if (TryGetAny(fields, ["sustain", "s"], out var sustain)) parameters = parameters with { EnvSustain = Math.Clamp(ParseFloat(sustain, line), 0, 1) };
if (TryGetAny(fields, ["decay", "d"], out var decay)) parameters = parameters with { EnvDecay = Math.Clamp(ParseFloat(decay, line), 0, 1) };
if (TryGetAny(fields, ["punch", "pu"], out var punch)) parameters = parameters with { EnvPunch = Math.Clamp(ParseFloat(punch, line), -1, 1) };
if (TryGetAny(fields, ["resonance", "res"], out var resonance)) parameters = parameters with { LpfResonance = Math.Clamp(ParseFloat(resonance, line), 0, 1) };
if (TryGetAny(fields, ["lpf"], out var lpf)) parameters = parameters with { LpfFreq = Math.Clamp(ParseFloat(lpf, line), 0, 1) };
if (TryGetAny(fields, ["lpf_ramp", "lpfr"], out var lpfRamp)) parameters = parameters with { LpfRamp = Math.Clamp(ParseFloat(lpfRamp, line), -1, 1) };
if (TryGetAny(fields, ["hpf"], out var hpf)) parameters = parameters with { HpfFreq = Math.Clamp(ParseFloat(hpf, line), 0, 1) };
if (TryGetAny(fields, ["hpf_ramp", "hpfr"], out var hpfRamp)) parameters = parameters with { HpfRamp = Math.Clamp(ParseFloat(hpfRamp, line), -1, 1) };
if (TryGetAny(fields, ["phaser", "ph"], out var phaser)) parameters = parameters with { PhaOffset = Math.Clamp(ParseFloat(phaser, line), -1, 1) };
if (TryGetAny(fields, ["phaser_ramp", "phr"], out var phaserRamp)) parameters = parameters with { PhaRamp = Math.Clamp(ParseFloat(phaserRamp, line), -1, 1) };
if (TryGetAny(fields, ["repeat", "rep"], out var repeat)) parameters = parameters with { RepeatSpeed = Math.Clamp(ParseFloat(repeat, line), 0, 1) };
if (TryGetAny(fields, ["arp"], out var arp)) parameters = parameters with { ArpSpeed = Math.Clamp(ParseFloat(arp, line), 0, 1) };
if (TryGetAny(fields, ["arp_mod", "am"], out var arpMod)) parameters = parameters with { ArpMod = Math.Clamp(ParseFloat(arpMod, line), -1, 1) };
return parameters;
}
2026-05-13 11:47:39 +01:00
private sealed record PendingOperatorGraph(
int Line,
2026-05-13 16:50:43 +01:00
string Path,
2026-05-13 11:47:39 +01:00
string Name,
float FrequencyHz,
2026-05-13 12:31:43 +01:00
Note Note,
2026-05-14 19:27:04 +01:00
float Gain,
float VibratoDepth,
float VibratoHz,
float VibratoDelaySeconds)
2026-05-13 11:47:39 +01:00
{
public List<OperatorNode> Operators { get; } = [];
public List<OperatorEdge> Edges { get; } = [];
public List<int> Carriers { get; } = [];
}
2026-05-13 12:31:43 +01:00
2026-05-13 19:28:07 +01:00
private sealed record ParsedEnvelope(Envelope Envelope, float GateSeconds, RateLevelEnvelope? RateLevelEnvelope = null);
2026-05-08 03:00:53 +01:00
}
private static readonly (string[] Keys, ModTarget Target)[] ModTargets =
[
(["gain", "g"], ModTarget.Gain),
(["pitch", "p"], ModTarget.Pitch),
(["duty", "du"], ModTarget.Duty),
(["lpf", "l"], ModTarget.LowPass),
(["hpf", "h"], ModTarget.HighPass),
(["noise", "nz"], ModTarget.Noise),
(["drive", "drv"], ModTarget.Drive),
(["fold", "fl"], ModTarget.Fold),
2026-05-08 10:21:32 +01:00
(["formant_mix", "fmix", "formant"], ModTarget.FormantMix),
2026-05-08 03:00:53 +01:00
(["fm_index", "fmi"], ModTarget.FmIndex)
];
private static Dictionary<string, string> ParseFields(IEnumerable<string> tokens, int line)
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var token in tokens)
{
var split = token.Split('=', 2);
if (split.Length != 2 || split[0].Length == 0)
{
throw new PatchScriptException(line, $"bad field `{token}`");
}
fields[CanonicalField(split[0])] = split[1];
}
return fields;
}
private static List<Formant> ParseFormants(string value, int line) =>
value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(part =>
{
var pieces = part.Split(':');
if (pieces.Length != 3) throw new PatchScriptException(line, $"bad formant `{part}`");
return new Formant(ParseFloat(pieces[0], line), ParseFloat(pieces[1], line), ParseFloat(pieces[2], line));
})
.ToList();
2026-05-17 20:34:52 +01:00
private static List<FormantFrame> ParseFormantFrames(string value, int line) =>
value.Split('|', StringSplitOptions.RemoveEmptyEntries)
.Select(frame => new FormantFrame(ParseFormants(frame, line)))
.ToList();
2026-05-08 03:00:53 +01:00
private static List<Modulator> ParseVoiceModulators(string value, int line) =>
value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(part =>
{
var pieces = part.Split(':');
if (pieces.Length != 4) throw new PatchScriptException(line, $"bad modulator `{part}`");
return new Modulator(
ParseModTarget(pieces[0], line),
ParseModWaveform(pieces[1], line),
ParseFloat(pieces[2], line),
ParseFloat(pieces[3], line));
})
.ToList();
2026-05-08 10:21:32 +01:00
private static IEnumerable<(ModTarget Target, float Depth)> ParseRoutes(string value, int line) =>
value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(part =>
{
var pieces = part.Split(':');
if (pieces.Length != 2) throw new PatchScriptException(line, $"bad route `{part}`");
return (ParseModTarget(pieces[0], line), ParseFloat(pieces[1], line));
});
2026-05-13 11:47:39 +01:00
private static string CanonicalCommand(string command) => command.ToLowerInvariant() switch
2026-05-08 03:00:53 +01:00
{
2026-05-13 16:10:08 +01:00
"p" or "patch" or "instrument" => "patch",
2026-05-08 10:21:32 +01:00
"d" or "default" or "defaults" => "defaults",
2026-05-08 03:00:53 +01:00
"def" or "t" or "template" => "template",
2026-05-14 20:25:59 +01:00
"layer" or "kit" => "layer",
2026-05-14 20:36:06 +01:00
"harmonics" or "partials" or "drawbars" => "harmonics",
2026-05-14 20:54:44 +01:00
"spectrum" or "spectral" or "padsource" or "pad_source" => "spectrum",
"tractshape" or "tract_shape" or "area_function" or "tract_area" => "tract_shape",
"glottis" or "glottal" or "excitation" => "glottis",
"tract_injection" or "injection" or "frication" or "burst" => "tract_injection",
"nasal_branch" or "nose_branch" or "nasal" => "nasal_branch",
"tract_motion" or "shape_motion" or "slew" => "tract_motion",
2026-05-08 03:00:53 +01:00
"v" or "voice" => "voice",
2026-05-25 19:36:21 +01:00
"tract" or "vt" or "tractvoice" or "tract_voice" => "tract",
2026-05-13 01:25:05 +01:00
"opgraph" or "ops" or "operators" => "opgraph",
2026-05-13 11:47:39 +01:00
"operator" or "op" => "operator",
"route" or "edge" => "route",
"carrier" or "out" => "carrier",
2026-05-08 03:00:53 +01:00
"mod" or "wob" or "wobble" or "bus" => "mod",
2026-05-08 10:21:32 +01:00
"lfo" or "control" => "control",
2026-05-13 00:28:31 +01:00
"param" or "parameter" => "param",
2026-05-08 10:34:07 +01:00
"s" or "sfxr" => "sfxr",
2026-05-08 03:00:53 +01:00
_ => command
};
private static string CanonicalField(string field) => field.ToLowerInvariant() switch
{
"n" => "name",
"u" => "use",
"w" => "wave",
"f" => "freq",
"g" => "gain",
"a" => "attack",
"s" => "sustain",
"d" => "decay",
2026-05-13 11:47:39 +01:00
"from" or "src" => "from",
"to" or "dst" => "to",
2026-05-08 03:00:53 +01:00
_ => field
};
private static Waveform ParseWaveform(string value, int line) => value.ToLowerInvariant() switch
{
"sin" or "sine" => Waveform.Sine,
"sq" or "square" => Waveform.Square,
"saw" or "sawtooth" => Waveform.Sawtooth,
"tri" or "triangle" => Waveform.Triangle,
"n" or "noise" => Waveform.Noise,
_ => throw new PatchScriptException(line, $"unknown waveform `{value}`")
};
private static ModWaveform ParseModWaveform(string value, int line) => value.ToLowerInvariant() switch
{
"sin" or "sine" => ModWaveform.Sine,
"tri" or "triangle" => ModWaveform.Triangle,
"sq" or "square" => ModWaveform.Square,
"hold" or "sample_hold" => ModWaveform.SampleHold,
_ => throw new PatchScriptException(line, $"unknown mod waveform `{value}`")
};
2026-05-13 12:31:43 +01:00
private static NoteSource ParseNoteSource(string value, int line) => value.ToLowerInvariant() switch
{
"oneshot" or "one_shot" or "trigger" or "fixed" => NoteSource.OneShot,
"host" or "midi" or "gate" => NoteSource.Host,
_ => throw new PatchScriptException(line, $"unknown note source `{value}`")
};
2026-05-13 16:10:08 +01:00
private static PlaybackMode ParsePlaybackMode(string value, int line) => value.ToLowerInvariant() switch
{
"oneshot" or "one_shot" or "trigger" or "fixed" => PlaybackMode.OneShot,
"mono" or "monophonic" or "host" => PlaybackMode.Mono,
"poly" or "polyphonic" or "midi" => PlaybackMode.Poly,
_ => throw new PatchScriptException(line, $"unknown playback mode `{value}`")
};
2026-05-08 03:00:53 +01:00
private static ModTarget ParseModTarget(string value, int line)
{
foreach (var (keys, target) in ModTargets)
{
2026-05-08 10:21:32 +01:00
if (keys.Contains(value, StringComparer.OrdinalIgnoreCase) ||
keys.Select(CanonicalField).Contains(CanonicalField(value), StringComparer.OrdinalIgnoreCase))
{
return target;
}
2026-05-08 03:00:53 +01:00
}
if (value.Equals("formant", StringComparison.OrdinalIgnoreCase)) return ModTarget.FormantMix;
throw new PatchScriptException(line, $"unknown mod target `{value}`");
}
2026-05-08 10:21:32 +01:00
private static string TargetSuffix(ModTarget target) => target switch
{
ModTarget.Gain => "gain",
ModTarget.Pitch => "pitch",
ModTarget.Duty => "duty",
ModTarget.LowPass => "lpf",
ModTarget.HighPass => "hpf",
ModTarget.Noise => "noise",
ModTarget.Drive => "drive",
ModTarget.Fold => "fold",
ModTarget.FormantMix => "formant_mix",
ModTarget.FmIndex => "fm_index",
_ => target.ToString().ToLowerInvariant()
};
private static TractPropagationMode ParseTractPropagationMode(string value, int line) => value.ToLowerInvariant() switch
{
"resonator" or "proxy" or "formant" => TractPropagationMode.Resonator,
"waveguide" or "kl" or "kelly-lochbaum" or "kelly_lochbaum" or "tube" => TractPropagationMode.Waveguide,
_ => throw new PatchScriptException(line, $"unknown tract propagation mode `{value}`")
};
2026-05-08 03:00:53 +01:00
private static string Required(IReadOnlyDictionary<string, string> fields, string key, int line) =>
fields.TryGetValue(key, out var value) ? value : throw new PatchScriptException(line, $"missing `{key}`");
private static string GetAny(IReadOnlyDictionary<string, string> fields, string[] keys, string fallback) =>
TryGetAny(fields, keys, out var value) ? value : fallback;
private static bool TryGetAny(IReadOnlyDictionary<string, string> fields, string[] keys, out string value)
{
foreach (var key in keys.Select(CanonicalField))
{
if (fields.TryGetValue(key, out value!)) return true;
}
value = "";
return false;
}
2026-05-15 00:46:39 +01:00
private static bool HasAny(IReadOnlyDictionary<string, string> fields, params string[] keys) =>
keys.Select(CanonicalField).Any(fields.ContainsKey);
2026-05-08 03:00:53 +01:00
private static float GetFloat(IReadOnlyDictionary<string, string> fields, int line, float fallback, params string[] keys) =>
TryGetAny(fields, keys, out var value) ? ParseFloat(value, line) : fallback;
2026-05-15 09:10:40 +01:00
private static int GetInt(IReadOnlyDictionary<string, string> fields, int line, int fallback, params string[] keys) =>
TryGetAny(fields, keys, out var value) ? ParseInt(value, line) : fallback;
2026-05-08 03:00:53 +01:00
private static float ParseFloat(string value, int line) =>
float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
? parsed
: throw new PatchScriptException(line, $"bad number `{value}`");
2026-05-14 20:25:59 +01:00
private static string F(float value) =>
value.ToString("0.######", CultureInfo.InvariantCulture);
2026-05-13 15:39:56 +01:00
private static float PunchGain(float value) => 1 + Math.Max(0, Math.Clamp(value, -1, 1));
2026-05-13 01:25:05 +01:00
private static int ParseInt(string value, int line) =>
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: throw new PatchScriptException(line, $"bad integer `{value}`");
2026-05-08 03:00:53 +01:00
private static bool ParseBool(string value, int line) => value.ToLowerInvariant() switch
{
"true" or "1" or "yes" or "on" => true,
"false" or "0" or "no" or "off" => false,
_ => throw new PatchScriptException(line, $"bad bool `{value}`")
};
private static void Merge(IDictionary<string, string> target, IReadOnlyDictionary<string, string> source)
{
foreach (var (key, value) in source) target[key] = value;
}
private static Dictionary<string, string> Without(IReadOnlyDictionary<string, string> fields, params string[] keys)
{
var excluded = keys.Select(CanonicalField).ToHashSet(StringComparer.OrdinalIgnoreCase);
return fields.Where(pair => !excluded.Contains(pair.Key))
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
}
}