1207 lines
58 KiB
C#
1207 lines
58 KiB
C#
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<string, Dictionary<string, string>> _templates = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly List<ControlLane> _controls = [];
|
|
private readonly List<OperatorGraph> _operatorGraphs = [];
|
|
private readonly List<PatchParameter> _parameters = [];
|
|
private readonly List<ParameterBinding> _parameterBindings = [];
|
|
private readonly List<PatchLayer> _layers = [];
|
|
private readonly List<HarmonicBank> _harmonicBanks = [];
|
|
private readonly List<SpectralBank> _spectralBanks = [];
|
|
private readonly Dictionary<string, string> _defaults = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, Dictionary<string, string>> _layerDefaults = new(StringComparer.OrdinalIgnoreCase);
|
|
private PendingOperatorGraph? _pendingOperatorGraph;
|
|
|
|
public List<Voice> Voices { get; } = [];
|
|
public List<OperatorGraph> 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<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)
|
|
};
|
|
Voices.Add(ParseVoice(ExpandVoiceFields(voiceFields, line), Voices.Count, line));
|
|
}
|
|
}
|
|
|
|
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}`");
|
|
}
|
|
|
|
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<string, string> sharedFields,
|
|
string layerName,
|
|
float frequency,
|
|
float gain,
|
|
int line)
|
|
{
|
|
var voiceFields = new Dictionary<string, string>(sharedFields, StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["layer"] = layerName,
|
|
["freq"] = F(frequency),
|
|
["gain"] = F(gain)
|
|
};
|
|
Voices.Add(ParseVoice(ExpandVoiceFields(voiceFields, line), Voices.Count, line));
|
|
}
|
|
|
|
private Dictionary<string, string> ExpandVoiceFields(Dictionary<string, string> fields, int line)
|
|
{
|
|
var expanded = new Dictionary<string, string>(_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<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;
|
|
}
|
|
|
|
private void ApplyPatch(IReadOnlyDictionary<string, string> 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<string, string> 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<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");
|
|
|
|
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<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"))));
|
|
}
|
|
|
|
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();
|
|
|
|
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<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,
|
|
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<string, string> 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<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);
|
|
graph.Edges.Add(new OperatorEdge(source, target, GetBoundFloat(fields, line, 1, $"{graph.Path}/routes/{source}>{target}/index", "index", "amount", "depth")));
|
|
}
|
|
|
|
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(),
|
|
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<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"], "")));
|
|
}
|
|
|
|
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;
|
|
|
|
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<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(
|
|
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<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();
|
|
|
|
private ParsedEnvelope ParseEnvelopeSpec(string value, IReadOnlyDictionary<string, string> 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<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 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)
|
|
{
|
|
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<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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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<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;
|
|
}
|
|
|
|
private sealed record PendingOperatorGraph(
|
|
int Line,
|
|
string Path,
|
|
string Name,
|
|
float FrequencyHz,
|
|
Note Note,
|
|
float Gain,
|
|
float VibratoDepth,
|
|
float VibratoHz,
|
|
float VibratoDelaySeconds)
|
|
{
|
|
public List<OperatorNode> Operators { get; } = [];
|
|
public List<OperatorEdge> Edges { get; } = [];
|
|
public List<int> 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<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();
|
|
|
|
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();
|
|
|
|
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<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;
|
|
}
|
|
|
|
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;
|
|
|
|
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<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);
|
|
}
|
|
}
|