using System.Globalization; namespace AquariumSynth.Dsl; 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; foreach (var statement in PatchScriptStatements.Enumerate(script)) { compiler.Apply(statement.Text, statement.Line); line = statement.Line; } if (!compiler.HasOutput) { throw new PatchScriptException(line, "patch script produced no voices or operator graphs"); } return compiler.Build(); } private sealed class Compiler { private readonly Dictionary> _templates = new(StringComparer.OrdinalIgnoreCase); private readonly List _controls = []; private readonly List _operatorGraphs = []; private readonly List _parameters = []; private readonly List _parameterBindings = []; private readonly List _layers = []; private readonly List _harmonicBanks = []; private readonly List _spectralBanks = []; private readonly Dictionary _defaults = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _layerDefaults = new(StringComparer.OrdinalIgnoreCase); private PendingOperatorGraph? _pendingOperatorGraph; public List Voices { get; } = []; public List OperatorGraphs => _operatorGraphs; public bool HasOutput => Voices.Count > 0 || _operatorGraphs.Count > 0 || _pendingOperatorGraph is not null; private Repeat? Repeat { get; set; } private Playback Playback { get; set; } = new(); private float Gain { get; set; } = 1; private bool SoftClip { get; set; } = true; public SynthPatch Build() { FlushPendingOperatorGraph(); return new SynthPatch { Voices = Voices, Layers = _layers, HarmonicBanks = _harmonicBanks, SpectralBanks = _spectralBanks, OperatorGraphs = _operatorGraphs, Controls = _controls, Parameters = _parameters, ParameterBindings = _parameterBindings, Playback = Playback, Repeat = Repeat, Gain = Gain, SoftClip = SoftClip }; } public void Apply(string statement, int line) { var parts = statement.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); var rawCommand = parts[0]; var command = CanonicalCommand(parts[0]); var fields = ParseFields(parts.Skip(1), line); if (SfxrParams.Named(rawCommand) is { } namedParams) { FlushPendingOperatorGraph(); AddSfxrPatch(ApplySfxrFields(namedParams, fields, line)); return; } switch (command) { case "patch": FlushPendingOperatorGraph(); ApplyPatch(fields, line); break; case "defaults": FlushPendingOperatorGraph(); Merge(_defaults, fields); break; case "template": FlushPendingOperatorGraph(); var name = Required(fields, "name", line); _templates[name] = Without(fields, "name"); break; case "layer": FlushPendingOperatorGraph(); AddLayer(fields, line); break; case "harmonics": FlushPendingOperatorGraph(); AddHarmonicBank(fields, line); break; case "spectrum": FlushPendingOperatorGraph(); AddSpectralBank(fields, line); break; case "voice": FlushPendingOperatorGraph(); Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), Voices.Count, line)); break; case "opgraph": StartOperatorGraph(fields, line); break; case "operator": AddOperator(fields, line); break; case "route": AddOperatorRoute(fields, line); break; case "carrier": AddOperatorCarrier(fields, line); break; case "mod": FlushPendingOperatorGraph(); AddModBus(fields, line); break; case "control": FlushPendingOperatorGraph(); AddControlLane(fields, line); break; case "param": FlushPendingOperatorGraph(); AddParameter(fields, line); break; case "sfxr": FlushPendingOperatorGraph(); AddSfxrPatch(ParseSfxrCommand(fields, line)); break; default: throw new PatchScriptException(line, $"unknown command `{parts[0]}`"); } } private void AddHarmonicBank(IReadOnlyDictionary 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(sharedFields, StringComparer.OrdinalIgnoreCase) { ["layer"] = layerName, ["freq"] = F(rootFrequency * partial.Ratio), ["gain"] = F(partial.Gain) }; Voices.Add(ParseVoice(ExpandVoiceFields(voiceFields, line), Voices.Count, line)); } } private void AddSpectralBank(IReadOnlyDictionary fields, int line) { var layerName = Required(fields, "layer", line); if (!_layerDefaults.ContainsKey(layerName)) { throw new PatchScriptException(line, $"unknown layer `{layerName}`"); } var rootFrequency = GetFloat(fields, line, 440, "root", "base", "freq", "frequency"); 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"); } var partials = ParseHarmonicPartials( GetAny(fields, ["partials", "bank", "tones"], ""), line); if (partials.Count == 0) { throw new PatchScriptException(line, "spectrum needs at least one partial"); } _spectralBanks.Add(new SpectralBank(layerName, rootFrequency, spread, partials)); var sharedFields = Without(fields, "layer", "root", "base", "freq", "frequency", "spread", "width", "detune", "partials", "bank", "tones", "gain", "g"); foreach (var partial in partials) { if (spread == 0) { AddSpectralVoice(sharedFields, layerName, rootFrequency * partial.Ratio, partial.Gain, line); continue; } AddSpectralVoice(sharedFields, layerName, rootFrequency * partial.Ratio * (1 - spread), partial.Gain * 0.5f, line); AddSpectralVoice(sharedFields, layerName, rootFrequency * partial.Ratio * (1 + spread), partial.Gain * 0.5f, line); } } private void AddSpectralVoice( IReadOnlyDictionary sharedFields, string layerName, float frequency, float gain, int line) { var voiceFields = new Dictionary(sharedFields, StringComparer.OrdinalIgnoreCase) { ["layer"] = layerName, ["freq"] = F(frequency), ["gain"] = F(gain) }; Voices.Add(ParseVoice(ExpandVoiceFields(voiceFields, line), Voices.Count, line)); } private Dictionary ExpandVoiceFields(Dictionary fields, int line) { var expanded = new Dictionary(_defaults, StringComparer.OrdinalIgnoreCase); if (TryGetAny(fields, ["layer"], out var layerName)) { if (!_layerDefaults.TryGetValue(layerName, out var layer)) { throw new PatchScriptException(line, $"unknown layer `{layerName}`"); } Merge(expanded, layer); } if (TryGetAny(fields, ["use", "u"], out var templateName)) { foreach (var name in templateName.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { if (!_templates.TryGetValue(name, out var template)) { throw new PatchScriptException(line, $"unknown template `{name}`"); } Merge(expanded, template); } } Merge(expanded, Without(fields, "use", "u")); return expanded; } private void AddLayer(IReadOnlyDictionary 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 LayerVoiceFields(PatchLayer layer, IReadOnlyDictionary fields) { var result = new Dictionary(fields, StringComparer.OrdinalIgnoreCase) { ["layer"] = layer.Name }; if (!result.ContainsKey("gain")) { result["gain"] = F(layer.Gain); } return result; } private void ApplyPatch(IReadOnlyDictionary fields, int line) { if (TryGetAny(fields, ["gain", "g"], out var gain)) Gain = ParseBoundFloat(gain, line, Gain, "/patch/gain"); if (TryGetAny(fields, ["soft_clip", "clip"], out var softClip)) SoftClip = ParseBool(softClip, line); 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) }; if (TryGetAny(fields, ["repeat", "r", "rp"], out var repeat)) { var interval = ParseBoundFloat(repeat, line, 0.1f, "/patch/repeat"); Repeat = interval > 0 ? new Repeat(interval) : null; } } private Voice ParseVoice(IReadOnlyDictionary fields, int voiceIndex, int line) { var waveform = TryGetAny(fields, ["wave", "w"], out var wave) ? ParseWaveform(wave, line) : Waveform.Sine; var frequency = GetBoundFloat(fields, line, 440, VoiceField(voiceIndex, "note/frequency"), "freq", "frequency", "f"); var envelopeSpec = TryGetAny(fields, ["env", "envelope"], out var envSpec) ? ParseEnvelopeSpec(envSpec, fields, line, VoicePath(voiceIndex)) : null; var gateSeconds = envelopeSpec?.GateSeconds ?? GetBoundFloat(fields, line, 0.1f, VoiceField(voiceIndex, "note/gate"), "gate", "hold", "duration", "sustain", "s"); var sustainLevel = envelopeSpec?.Envelope.SustainLevel ?? GetBoundFloat(fields, line, 1, VoiceField(voiceIndex, "env/sustain_level"), "sustain_level", "sl"); var gainScale = 1f; if (TryGetAny(fields, ["punch", "pu"], out var punch)) { gainScale = PunchGain(ParseBoundFloat(punch, line, 0, VoiceField(voiceIndex, "env/sustain_level"))); sustainLevel = 1 / gainScale; } var noteSource = ParseNoteSource(GetAny(fields, ["note_source", "source"], "oneshot"), line); if (TryGetAny(fields, ["midi"], out var midi) && ParseBool(midi, line)) { noteSource = NoteSource.Host; Playback = Playback with { Midi = true, Mode = Playback.Mode == PlaybackMode.OneShot ? PlaybackMode.Mono : Playback.Mode, FrequencyHz = frequency }; } var formants = TryGetAny(fields, ["formants", "fs"], out var formantSpec) ? ParseFormants(formantSpec, line) : []; var modulators = TryGetAny(fields, ["mods", "m"], out var mods) ? 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( ParseBoundFloat(arpDelay ?? throw new PatchScriptException(line, "arpeggio needs arp_delay"), line, 0, VoiceField(voiceIndex, "arpeggio/delay")), ParseBoundFloat(arpMult ?? throw new PatchScriptException(line, "arpeggio needs arp_mult"), line, 1, VoiceField(voiceIndex, "arpeggio/multiplier"))); } return new Voice { Layer = TryGetAny(fields, ["layer"], out var layerName) ? _layers.First(layer => layer.Name.Equals(layerName, StringComparison.OrdinalIgnoreCase)) : null, Oscillator = new Oscillator( waveform, frequency, GetBoundFloat(fields, line, 0.5f, VoiceField(voiceIndex, "osc/duty"), "duty", "du"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "osc/phase"), "phase", "pa")), Note = new Note(frequency, gateSeconds, noteSource), Envelope = envelopeSpec?.Envelope ?? new Envelope( GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "env/attack"), "attack", "a"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "env/decay"), "env_decay", "ed"), sustainLevel, GetBoundFloat(fields, line, 0.1f, VoiceField(voiceIndex, "env/release"), "release", "rel", "decay", "d")), RateLevelEnvelope = envelopeSpec?.RateLevelEnvelope, Pitch = new PitchMotion( GetBoundFloat(fields, line, 20, VoiceField(voiceIndex, "pitch/min_freq"), "min_freq", "min"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "pitch/ramp"), "pitch_ramp", "pr"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "pitch/delta"), "pitch_delta", "pd", "pitch_dramp", "pdr"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "pitch/vibrato"), "vibrato", "vi"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "pitch/vibrato_hz"), "vibrato_hz", "vh"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "pitch/vibrato_delay"), "vibrato_delay", "vd")), Duty = new DutyMotion(GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "duty/ramp"), "duty_ramp", "dur")), Filter = new Filter( GetBoundFloat(fields, line, 1, VoiceField(voiceIndex, "filter/lpf"), "lpf", "l"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "filter/lpf_ramp"), "lpf_ramp", "lr"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "filter/resonance"), "resonance", "res"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "filter/hpf"), "hpf", "h"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "filter/hpf_ramp"), "hpf_ramp", "hr")), Phaser = new Phaser( GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "phaser/offset"), "phaser", "ph"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "phaser/ramp"), "phaser_ramp", "phr")), Arpeggio = arpeggio, Fm = new FrequencyModulation( GetBoundFloat(fields, line, 1, VoiceField(voiceIndex, "fm/ratio"), "fm", "fmr", "fm_ratio"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "fm/index"), "fm_index", "fmi"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "fm/decay"), "fm_decay", "fmd")), Color = new VoiceColor( GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "color/noise"), "noise", "nz"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "color/drive"), "drive", "drv"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "color/fold"), "fold", "fl"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "color/tremolo"), "tremolo", "tr"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "color/tremolo_hz"), "tremolo_hz", "th"), GetBoundFloat(fields, line, 0, VoiceField(voiceIndex, "color/formant_mix"), "formant_mix", "fmix")), Formants = formants, Modulators = modulators, Gain = GetBoundFloat(fields, line, 0.2f, VoiceField(voiceIndex, "gain"), "gain", "g") * gainScale }; } private void AddModBus(IReadOnlyDictionary 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"); 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))); } } 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))); } } private void AddControlLane(IReadOnlyDictionary 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")))); } private void AddOperatorGraph(IReadOnlyDictionary 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(); AddValidatedOperatorGraph(line, new OperatorGraph( 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( 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)), 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"))); } private void StartOperatorGraph(IReadOnlyDictionary fields, int line) { FlushPendingOperatorGraph(); if (fields.ContainsKey("ops")) { AddOperatorGraph(fields, line); return; } var graphIndex = _operatorGraphs.Count; var graphPath = $"/opgraphs/{graphIndex}"; _pendingOperatorGraph = new PendingOperatorGraph( line, graphPath, GetAny(fields, ["name", "n"], $"opgraph{graphIndex}"), GetBoundFloat(fields, line, 440, $"{graphPath}/freq", "freq", "frequency", "f"), 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)), 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")); } private void AddOperator(IReadOnlyDictionary fields, int line) { var graph = RequiredPendingOperatorGraph(line); var id = ParseOperatorId(Required(fields, "name", line), line); var operatorPath = $"{graph.Path}/operators/{id}"; var envelopeSpec = TryGetAny(fields, ["env", "envelope"], out var envSpec) ? ParseEnvelopeSpec(envSpec, fields, line, operatorPath) : new ParsedEnvelope( new Envelope( 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")); graph.Operators.Add(new OperatorNode( 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)); } private void AddOperatorRoute(IReadOnlyDictionary fields, int line) { var graph = RequiredPendingOperatorGraph(line); var source = ParseOperatorId(Required(fields, "from", line), line); var target = ParseOperatorId(Required(fields, "to", line), line); graph.Edges.Add(new OperatorEdge(source, target, GetBoundFloat(fields, line, 1, $"{graph.Path}/routes/{source}>{target}/index", "index", "amount", "depth"))); } private void AddOperatorCarrier(IReadOnlyDictionary 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(), graph.Note, graph.Gain, graph.VibratoDepth, graph.VibratoHz, graph.VibratoDelaySeconds)); } 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) { if (!operatorIds.Contains(edge.SourceId) || !operatorIds.Contains(edge.TargetId)) { throw new PatchScriptException(line, $"operator edge `{edge.SourceId}>{edge.TargetId}` references an unknown operator"); } } foreach (var carrier in graph.Carriers) { if (!operatorIds.Contains(carrier)) { throw new PatchScriptException(line, $"carrier `{carrier}` references an unknown operator"); } } _operatorGraphs.Add(graph); } private void AddParameter(IReadOnlyDictionary 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"], ""))); } private float GetBoundFloat( IReadOnlyDictionary fields, int line, float fallback, string fieldPath, params string[] keys) => TryGetAny(fields, keys, out var value) ? ParseBoundFloat(value, line, fallback, fieldPath) : fallback; 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; } private static string VoicePath(int voiceIndex) => $"/voices/{voiceIndex}"; private static string VoiceField(int voiceIndex, string field) => $"/voices/{voiceIndex}/{field}"; private static List 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( 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()); }) .ToList(); private static List 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 ParseOperatorIds(string value, int line) => value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(part => ParseInt(part, line)) .ToList(); private ParsedEnvelope ParseEnvelopeSpec(string value, IReadOnlyDictionary fields, int line, string fieldPath) { var pieces = value.Split(':'); return pieces[0].ToLowerInvariant() switch { "ad" when pieces.Length == 3 => AdEnvelope( ParseBoundFloat(pieces[1], line, 0, $"{fieldPath}/env/attack"), ParseBoundFloat(pieces[2], line, 0.1f, $"{fieldPath}/env/decay")), "adsr" when pieces.Length == 5 => new ParsedEnvelope( new Envelope( 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")), 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), _ => throw new PatchScriptException(line, $"bad envelope `{value}`") }; } private static ParsedEnvelope AdEnvelope(float attackSeconds, float decaySeconds) => new(new Envelope(attackSeconds, decaySeconds, 0, 0), attackSeconds + decaySeconds); private static IReadOnlyList 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 ParsedEnvelope ParseRateLevelEnvelope(IReadOnlyDictionary 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 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 rates, IReadOnlyList levels, IReadOnlyDictionary fields, int line, string fieldPath) { 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"); } var envelope = new RateLevelEnvelope( 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), curves[0], curves[1], curves[2], curves[3]); 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); } private static IReadOnlyList 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(); } private float GateSeconds(IReadOnlyDictionary fields, int line, float defaultValue, string fieldPath) => GetBoundFloat(fields, line, defaultValue, $"{fieldPath}/note/gate", "gate", "hold", "duration"); private static IReadOnlyList 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(); } private static int ParseOperatorId(string value, int line) { var normalized = value.StartsWith("op", StringComparison.OrdinalIgnoreCase) ? value[2..] : value; return ParseInt(normalized, line); } private void AddSfxrPatch(SfxrParams parameters) { var mapped = parameters.ToPatch(); Voices.AddRange(mapped.Voices); Repeat = mapped.Repeat; Gain *= mapped.Gain; } private static SfxrParams ParseSfxrCommand(IReadOnlyDictionary 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 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; } private sealed record PendingOperatorGraph( int Line, string Path, string Name, float FrequencyHz, Note Note, float Gain, float VibratoDepth, float VibratoHz, float VibratoDelaySeconds) { public List Operators { get; } = []; public List Edges { get; } = []; public List Carriers { get; } = []; } private sealed record ParsedEnvelope(Envelope Envelope, float GateSeconds, RateLevelEnvelope? RateLevelEnvelope = null); } 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), (["formant_mix", "fmix", "formant"], ModTarget.FormantMix), (["fm_index", "fmi"], ModTarget.FmIndex) ]; private static Dictionary ParseFields(IEnumerable tokens, int line) { var fields = new Dictionary(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 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(); private static List 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(); 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)); }); private static string CanonicalCommand(string command) => command.ToLowerInvariant() switch { "p" or "patch" or "instrument" => "patch", "d" or "default" or "defaults" => "defaults", "def" or "t" or "template" => "template", "layer" or "kit" => "layer", "harmonics" or "partials" or "drawbars" => "harmonics", "spectrum" or "spectral" or "padsource" or "pad_source" => "spectrum", "v" or "voice" => "voice", "opgraph" or "ops" or "operators" => "opgraph", "operator" or "op" => "operator", "route" or "edge" => "route", "carrier" or "out" => "carrier", "mod" or "wob" or "wobble" or "bus" => "mod", "lfo" or "control" => "control", "param" or "parameter" => "param", "s" or "sfxr" => "sfxr", _ => 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", "from" or "src" => "from", "to" or "dst" => "to", _ => 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}`") }; 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}`") }; 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}`") }; private static ModTarget ParseModTarget(string value, int line) { foreach (var (keys, target) in ModTargets) { if (keys.Contains(value, StringComparer.OrdinalIgnoreCase) || keys.Select(CanonicalField).Contains(CanonicalField(value), StringComparer.OrdinalIgnoreCase)) { return target; } } if (value.Equals("formant", StringComparison.OrdinalIgnoreCase)) return ModTarget.FormantMix; throw new PatchScriptException(line, $"unknown mod target `{value}`"); } 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 string Required(IReadOnlyDictionary fields, string key, int line) => fields.TryGetValue(key, out var value) ? value : throw new PatchScriptException(line, $"missing `{key}`"); private static string GetAny(IReadOnlyDictionary fields, string[] keys, string fallback) => TryGetAny(fields, keys, out var value) ? value : fallback; private static bool TryGetAny(IReadOnlyDictionary fields, string[] keys, out string value) { foreach (var key in keys.Select(CanonicalField)) { if (fields.TryGetValue(key, out value!)) return true; } value = ""; return false; } private static float GetFloat(IReadOnlyDictionary fields, int line, float fallback, params string[] keys) => TryGetAny(fields, keys, out var value) ? ParseFloat(value, line) : fallback; 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}`"); private static string F(float value) => value.ToString("0.######", CultureInfo.InvariantCulture); private static float PunchGain(float value) => 1 + Math.Max(0, Math.Clamp(value, -1, 1)); 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}`"); 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 target, IReadOnlyDictionary source) { foreach (var (key, value) in source) target[key] = value; } private static Dictionary Without(IReadOnlyDictionary 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); } }