AquaSynth/src/AquaSynth.Core/PatchScript.cs
2026-06-01 04:44:11 +01:00

4391 lines
224 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Globalization;
namespace AquaSynth.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<ControlCurve> _controlCurves = [];
private readonly List<ControlSurface> _controlSurfaces = [];
private readonly Dictionary<string, ControlSurface> _controlSurfacesByPath = new(StringComparer.OrdinalIgnoreCase);
private readonly List<ControlSpline> _controlSplines = [];
private readonly Dictionary<string, ControlSpline> _controlSplinesByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<PhonemeGesture> _phonemeGestures = [];
private readonly List<GestureGroup> _gestureGroups = [];
private readonly List<ParameterBinding> _parameterBindings = [];
private readonly List<PatchLayer> _layers = [];
private readonly List<HarmonicBank> _harmonicBanks = [];
private readonly List<SpectralBank> _spectralBanks = [];
private readonly List<TractShape> _tractShapes = [];
private readonly Dictionary<string, TractShape> _tractShapesByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<GlottalSource> _glottalSources = [];
private readonly Dictionary<string, GlottalSource> _glottalSourcesByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<TractInjection> _tractInjections = [];
private readonly Dictionary<string, TractInjection> _tractInjectionsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<NasalBranch> _nasalBranches = [];
private readonly Dictionary<string, NasalBranch> _nasalBranchesByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<TractMotion> _tractMotions = [];
private readonly Dictionary<string, TractMotion> _tractMotionsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<AcousticAreaCurve> _acousticAreaCurves = [];
private readonly Dictionary<string, AcousticAreaCurve> _acousticAreaCurvesByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<AcousticPath> _acousticPaths = [];
private readonly Dictionary<string, AcousticPath> _acousticPathsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<AcousticSourcePort> _acousticSourcePorts = [];
private readonly Dictionary<string, AcousticSourcePort> _acousticSourcePortsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<AcousticBranch> _acousticBranches = [];
private readonly Dictionary<string, AcousticBranch> _acousticBranchesByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<AcousticRadiationPort> _acousticRadiationPorts = [];
private readonly Dictionary<string, AcousticRadiationPort> _acousticRadiationPortsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<AcousticTerminal> _acousticTerminals = [];
private readonly Dictionary<string, AcousticTerminal> _acousticTerminalsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<AcousticConnection> _acousticConnections = [];
private readonly Dictionary<string, AcousticConnection> _acousticConnectionsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<WaveClockPolicy> _waveClocks = [];
private readonly Dictionary<string, WaveClockPolicy> _waveClocksByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<AcousticPortNetwork> _acousticNetworks = [];
private readonly Dictionary<string, AcousticPortNetwork> _acousticNetworksByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<AreaFunction> _areaFunctions = [];
private readonly Dictionary<string, AreaFunction> _areaFunctionsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<WaveguidePath> _waveguidePaths = [];
private readonly Dictionary<string, WaveguidePath> _waveguidePathsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<SourcePort> _sourcePorts = [];
private readonly Dictionary<string, SourcePort> _sourcePortsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<ConstrictionContact> _constrictionContacts = [];
private readonly Dictionary<string, ConstrictionContact> _constrictionContactsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<BranchPort> _branchPorts = [];
private readonly Dictionary<string, BranchPort> _branchPortsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<RadiationLoad> _radiationLoads = [];
private readonly Dictionary<string, RadiationLoad> _radiationLoadsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<ProbeTimeline> _probeTimelines = [];
private readonly Dictionary<string, ProbeTimeline> _probeTimelinesByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<VocalNetwork> _vocalNetworks = [];
private readonly Dictionary<string, VocalNetwork> _vocalNetworksByName = new(StringComparer.OrdinalIgnoreCase);
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 || _spectralBanks.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;
private float BeatSeconds { get; set; } = 0.5f;
private int BeatsPerBar { get; set; } = 4;
public SynthPatch Build()
{
FlushPendingOperatorGraph();
return new SynthPatch
{
Voices = Voices,
Layers = _layers,
HarmonicBanks = _harmonicBanks,
SpectralBanks = _spectralBanks,
TractShapes = _tractShapes,
GlottalSources = _glottalSources,
TractInjections = _tractInjections,
NasalBranches = _nasalBranches,
TractMotions = _tractMotions,
AcousticAreaCurves = _acousticAreaCurves,
AcousticPaths = _acousticPaths,
AcousticSourcePorts = _acousticSourcePorts,
AcousticBranches = _acousticBranches,
AcousticRadiationPorts = _acousticRadiationPorts,
AcousticTerminals = _acousticTerminals,
AcousticConnections = _acousticConnections,
WaveClocks = _waveClocks,
AcousticNetworks = _acousticNetworks,
AreaFunctions = _areaFunctions,
WaveguidePaths = _waveguidePaths,
SourcePorts = _sourcePorts,
ConstrictionContacts = _constrictionContacts,
BranchPorts = _branchPorts,
RadiationLoads = _radiationLoads,
ProbeTimelines = _probeTimelines,
VocalNetworks = _vocalNetworks,
OperatorGraphs = _operatorGraphs,
Controls = _controls,
Parameters = _parameters,
ControlCurves = _controlCurves,
ControlSurfaces = _controlSurfaces,
ControlSplines = _controlSplines,
PhonemeGestures = _phonemeGestures,
GestureGroups = _gestureGroups,
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 "tract_shape":
FlushPendingOperatorGraph();
AddTractShape(fields, line);
break;
case "glottis":
FlushPendingOperatorGraph();
AddGlottalSource(fields, line);
break;
case "tract_injection":
FlushPendingOperatorGraph();
AddTractInjection(fields, line);
break;
case "nasal_branch":
FlushPendingOperatorGraph();
AddNasalBranch(fields, line);
break;
case "tract_motion":
FlushPendingOperatorGraph();
AddTractMotion(fields, line);
break;
case "area_curve":
FlushPendingOperatorGraph();
AddAcousticAreaCurve(fields, line);
break;
case "morphology":
FlushPendingOperatorGraph();
AddAreaFunction(fields, line);
break;
case "waveguide_path":
FlushPendingOperatorGraph();
AddWaveguidePath(fields, line);
break;
case "acoustic_path":
FlushPendingOperatorGraph();
AddAcousticPath(fields, line);
break;
case "source_port":
FlushPendingOperatorGraph();
AddAcousticSourcePort(fields, line);
break;
case "constriction_contact":
FlushPendingOperatorGraph();
AddConstrictionContact(fields, line);
break;
case "branch_port":
FlushPendingOperatorGraph();
AddBranchPort(fields, line);
break;
case "radiation_load":
FlushPendingOperatorGraph();
AddRadiationLoad(fields, line);
break;
case "probe_timeline":
FlushPendingOperatorGraph();
AddProbeTimeline(fields, line);
break;
case "branch":
FlushPendingOperatorGraph();
AddAcousticBranch(fields, line);
break;
case "radiation_port":
FlushPendingOperatorGraph();
AddAcousticRadiationPort(fields, line);
break;
case "terminal":
FlushPendingOperatorGraph();
AddAcousticTerminal(fields, line);
break;
case "connection":
FlushPendingOperatorGraph();
AddAcousticConnection(fields, line);
break;
case "wave_clock":
FlushPendingOperatorGraph();
AddWaveClock(fields, line);
break;
case "acoustic_network":
FlushPendingOperatorGraph();
AddAcousticNetwork(fields, line);
break;
case "vocal_network":
FlushPendingOperatorGraph();
AddVocalNetwork(fields, line);
break;
case "voice":
FlushPendingOperatorGraph();
Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), VoicePath(Voices.Count), line));
break;
case "texture":
FlushPendingOperatorGraph();
AddNoiseTexture(fields, line);
break;
case "acoustic_voice":
FlushPendingOperatorGraph();
Voices.Add(ParseAcousticVoice(ExpandVoiceFields(fields, line), VoicePath(Voices.Count), line));
break;
case "vocal_voice":
FlushPendingOperatorGraph();
Voices.Add(ParseVocalVoice(ExpandVoiceFields(fields, line), VoicePath(Voices.Count), line));
break;
case "tract":
FlushPendingOperatorGraph();
Voices.Add(ParseTractVoice(ExpandVoiceFields(fields, line), VoicePath(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 "control_surface":
FlushPendingOperatorGraph();
AddControlSurface(fields, line);
break;
case "curve":
FlushPendingOperatorGraph();
AddControlCurve(fields, line);
break;
case "pattern":
FlushPendingOperatorGraph();
AddPatternCurve(fields, line);
break;
case "scale":
FlushPendingOperatorGraph();
AddScaleCurve(fields, line);
break;
case "meter":
FlushPendingOperatorGraph();
AddMeter(fields, line);
break;
case "sequence":
FlushPendingOperatorGraph();
AddSequencePattern(fields, line);
break;
case "chords":
FlushPendingOperatorGraph();
AddChordProgression(fields, line);
break;
case "mix":
FlushPendingOperatorGraph();
AddMixAutomation(fields, line);
break;
case "drumkit":
FlushPendingOperatorGraph();
AddSubKickGrid(fields, line);
break;
case "dust_hat":
FlushPendingOperatorGraph();
AddDustHat(fields, line);
break;
case "bass_response":
FlushPendingOperatorGraph();
AddBassResponse(fields, line);
break;
case "additive_wash":
FlushPendingOperatorGraph();
AddAdditiveWash(fields, line);
break;
case "section_rise":
FlushPendingOperatorGraph();
AddSectionRise(fields, line);
break;
case "control_spline":
FlushPendingOperatorGraph();
AddControlSpline(fields, line);
break;
case "phoneme_gesture":
FlushPendingOperatorGraph();
AddPhonemeGesture(fields, line);
break;
case "gesture":
FlushPendingOperatorGraph();
if (HasAny(fields, "surface", "surface_path", "target"))
{
AddControlSpline(fields, line);
}
else
{
AddGestureGroup(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), VoicePath(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", "basefreq", "table_freq", "table_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 profile = ParsePadSpectrumProfile(fields, line);
var partials = ParseHarmonicPartials(
GetAny(fields, ["partials", "bank", "tones"], ""),
line);
if (partials.Count == 0)
{
throw new PatchScriptException(line, "spectrum needs at least one partial");
}
var treatmentFields = Without(fields,
"layer",
"root",
"base",
"basefreq",
"table_freq",
"table_frequency",
"spread",
"width",
"detune",
"pad_mode",
"zyn_mode",
"pad_bandwidth",
"zyn_bandwidth",
"bandwidth",
"pad_bwscale",
"zyn_bwscale",
"bwscale",
"pad_profile",
"zyn_profile",
"pad_position",
"zyn_position",
"partials",
"bank",
"tones");
treatmentFields["layer"] = layerName;
if (!HasAny(treatmentFields, "freq", "frequency", "f"))
{
treatmentFields["freq"] = F(rootFrequency);
}
var treatment = ParseVoice(
ExpandVoiceFields(treatmentFields, line),
SpectralPath(_spectralBanks.Count),
line);
_spectralBanks.Add(new SpectralBank(layerName, rootFrequency, spread, partials, treatment, profile));
}
private static string SpectralPath(int spectralIndex) => $"/spectral/{spectralIndex}";
private void AddTractShape(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_tractShapesByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate tract shape `{name}`");
}
var areaFunction = ParseTractAreaFunction(fields, line, 17);
var shape = new TractShape(name, areaFunction);
_tractShapes.Add(shape);
_tractShapesByName[name] = shape;
AddAcousticPathRecord(new AcousticPath(
name,
areaFunction,
GetBoundFloat(fields, line, 343, $"/acoustic/paths/{_acousticPaths.Count}/speed", "speed", "wave_speed", "propagation_speed"),
GetBoundFloat(fields, line, 0.999f, $"/acoustic/paths/{_acousticPaths.Count}/loss", "loss"),
LossModel: ParseAcousticLossModel(GetAny(fields, ["loss_model", "loss_kind"], "custom"), line)));
}
private void AddGlottalSource(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_glottalSourcesByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate glottis `{name}`");
}
var glottis = new GlottalSource(
name,
GetBoundFloat(fields, line, 0.72f, $"/glottis/{_glottalSources.Count}/intensity", "intensity", "pressure"),
GetBoundFloat(fields, line, 0.6f, $"/glottis/{_glottalSources.Count}/tenseness", "tenseness", "tense"),
GetBoundFloat(fields, line, 0.08f, $"/glottis/{_glottalSources.Count}/aspiration", "aspiration", "breath"),
GetBoundFloat(fields, line, 0.75f, $"/glottis/{_glottalSources.Count}/reflection", "reflection", "glottal_reflection", "gr"),
GetBoundFloat(fields, line, 0.42f, $"/glottis/{_glottalSources.Count}/skew", "skew", "open_phase"));
_glottalSources.Add(glottis);
_glottalSourcesByName[name] = glottis;
if (TryGetAny(fields, ["path"], out var path))
{
RequireAcousticPath(path, line);
AddAcousticSourcePortRecord(new AcousticSourcePort(
name,
path,
GetBoundFloat(fields, line, 0, $"/acoustic/sources/{_acousticSourcePorts.Count}/position", "position", "pos", "at"),
AcousticSourceKind.Glottal,
glottis.Intensity,
glottis.Tenseness,
glottis.Skew,
glottis.Aspiration,
Model: AcousticSourceModel.TissueValve,
Law: AcousticValveLaw.TwoMass,
ReservoirPressure: 1));
}
}
private void AddTractInjection(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_tractInjectionsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate tract injection `{name}`");
}
var injection = new TractInjection(
name,
GetBoundFloat(fields, line, 32, $"/tract_injections/{_tractInjections.Count}/position", "position", "index", "constriction_index", "ci"),
GetBoundFloat(fields, line, 1, $"/tract_injections/{_tractInjections.Count}/diameter", "diameter", "opening", "constriction_diameter", "cd"),
GetBoundFloat(fields, line, 0, $"/tract_injections/{_tractInjections.Count}/turbulence", "turbulence", "frication"),
GetBoundFloat(fields, line, 0, $"/tract_injections/{_tractInjections.Count}/burst", "burst", "transient"),
GetBoundFloat(fields, line, 1, $"/tract_injections/{_tractInjections.Count}/width", "width"));
_tractInjections.Add(injection);
_tractInjectionsByName[name] = injection;
if (TryGetAny(fields, ["path"], out var path))
{
RequireAcousticPath(path, line);
AddAcousticSourcePortRecord(new AcousticSourcePort(
name,
path,
injection.Position,
AcousticSourceKind.TurbulenceJet,
injection.Burst,
0,
injection.Diameter,
injection.Turbulence,
injection.Burst));
}
}
private void AddNasalBranch(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_nasalBranchesByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate nasal branch `{name}`");
}
var branch = new NasalBranch(
name,
ParseTractAreaFunction(fields, line, 12),
GetBoundInt(fields, line, 17, $"/nasal_branches/{_nasalBranches.Count}/junction", "junction", "junction_index", "at"),
GetBoundFloat(fields, line, 0.01f, $"/nasal_branches/{_nasalBranches.Count}/velum", "velum", "opening"),
GetBoundFloat(fields, line, -0.85f, $"/nasal_branches/{_nasalBranches.Count}/reflection", "reflection", "lip_reflection"),
GetBoundFloat(fields, line, 0.999f, $"/nasal_branches/{_nasalBranches.Count}/loss", "loss"));
_nasalBranches.Add(branch);
_nasalBranchesByName[name] = branch;
if (branch.AreaFunction is { } nasalArea)
{
AddAcousticPathRecord(new AcousticPath(
name,
nasalArea,
GetBoundFloat(fields, line, 343, $"/acoustic/paths/{_acousticPaths.Count}/speed", "speed", "wave_speed", "propagation_speed"),
branch.Loss));
}
AddAcousticBranchRecord(new AcousticBranch(
name,
GetAny(fields, ["from_path"], "oral"),
branch.JunctionIndex,
name,
0,
AcousticBranchKind.Nasal,
branch.Velum,
1,
true));
}
private void AddTractMotion(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_tractMotionsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate tract motion `{name}`");
}
var motion = new TractMotion(
name,
GetBoundFloat(fields, line, 18, $"/tract_motions/{_tractMotions.Count}/diameter_slew", "diameter_slew", "slew"),
GetBoundFloat(fields, line, 8, $"/tract_motions/{_tractMotions.Count}/shape_return", "shape_return", "return_slew"),
GetBoundFloat(fields, line, 24, $"/tract_motions/{_tractMotions.Count}/constriction_slew", "constriction_slew", "constriction"),
GetBoundFloat(fields, line, 16, $"/tract_motions/{_tractMotions.Count}/velum_slew", "velum_slew", "velum"),
GetBoundFloat(fields, line, 0.05f, $"/tract_motions/{_tractMotions.Count}/obstruction_threshold", "obstruction_threshold", "closure"));
_tractMotions.Add(motion);
_tractMotionsByName[name] = motion;
}
private void AddAcousticPath(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_acousticPathsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate acoustic path `{name}`");
}
var areaFunction = ParseTractAreaFunction(fields, line, 17);
var areaCurve = GetAny(fields, ["area_curve", "curve"], "");
var path = new AcousticPath(
name,
areaFunction,
GetBoundFloat(fields, line, 343, $"/acoustic/paths/{_acousticPaths.Count}/speed", "speed", "wave_speed", "propagation_speed"),
GetBoundFloat(fields, line, 0.999f, $"/acoustic/paths/{_acousticPaths.Count}/loss", "loss"),
ParseAcousticAreaControl(fields, line, _acousticPaths.Count),
areaCurve,
ParseAcousticLossModel(GetAny(fields, ["loss_model", "loss_kind"], "custom"), line));
AddAcousticPathRecord(path);
}
private void AddAcousticAreaCurve(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_acousticAreaCurvesByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate area curve `{name}`");
}
var curve = new AcousticAreaCurve(name, ParseTractAreaFunction(fields, line, 17));
_acousticAreaCurves.Add(curve);
_acousticAreaCurvesByName[name] = curve;
}
private void AddAreaFunction(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_areaFunctionsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate area function `{name}`");
}
var area = new AreaFunction(
name,
ParseTractAreaFunction(fields, line, 17),
ParseAcousticAreaControl(fields, line, _areaFunctions.Count),
GetBoundInt(fields, line, 8, $"/vocal/areas/{_areaFunctions.Count}/emit_sections", "emit_sections", "sections", "samples"));
_areaFunctions.Add(area);
_areaFunctionsByName[name] = area;
RegisterAreaFunctionSurfaces(_areaFunctions.Count - 1, area);
}
private void AddWaveguidePath(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_waveguidePathsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate waveguide path `{name}`");
}
var areaName = RequiredAny(fields, ["morphology", "area_function", "area", "shape"], line);
if (!_areaFunctionsByName.ContainsKey(areaName))
{
throw new PatchScriptException(line, $"unknown area function `{areaName}`");
}
var path = new WaveguidePath(
name,
areaName,
GetBoundFloat(fields, line, 343, $"/vocal/paths/{_waveguidePaths.Count}/speed", "speed", "wave_speed", "propagation_speed"),
ParseWaveClockDelayStrategy(GetAny(fields, ["strategy", "delay", "delay_strategy"], "thiran"), line),
GetBoundInt(fields, line, 1, $"/vocal/paths/{_waveguidePaths.Count}/order", "order", "fractional_order"),
GetBoundInt(fields, line, 4096, $"/vocal/paths/{_waveguidePaths.Count}/max_delay", "max_delay", "max_delay_samples"),
GetBoundFloat(fields, line, 0.999f, $"/vocal/paths/{_waveguidePaths.Count}/loss", "loss"));
_waveguidePaths.Add(path);
_waveguidePathsByName[name] = path;
RegisterNormalizedPrimitiveSurface($"/vocal/paths/{_waveguidePaths.Count - 1}/speed", name, "speed", path.PropagationSpeedMetersPerSecond, 100, 1000, "m/s");
RegisterNormalizedPrimitiveSurface($"/vocal/paths/{_waveguidePaths.Count - 1}/loss", name, "loss", path.Loss);
}
private static bool HasAcousticAreaControl(IReadOnlyDictionary<string, string> fields) =>
HasAny(
fields,
"area_control",
"morph",
"tongue_index",
"tongue",
"ti",
"tongue_diameter",
"td",
"constriction_index",
"ci",
"constriction_diameter",
"cd",
"lip",
"lip_opening",
"mouth");
private AcousticAreaControl? ParseAcousticAreaControl(IReadOnlyDictionary<string, string> fields, int line, int pathIndex)
{
if (!HasAcousticAreaControl(fields))
{
return null;
}
return new AcousticAreaControl(
GetBoundFloat(fields, line, 12.9f, $"/acoustic/paths/{pathIndex}/area/tongue_index", "tongue_index", "tongue", "ti"),
GetBoundFloat(fields, line, 2.43f, $"/acoustic/paths/{pathIndex}/area/tongue_diameter", "tongue_diameter", "td"),
GetBoundFloat(fields, line, 0.18f, $"/acoustic/paths/{pathIndex}/area/tongue_width", "tongue_width", "tw"),
GetBoundFloat(fields, line, 32, $"/acoustic/paths/{pathIndex}/area/constriction_index", "constriction_index", "ci"),
GetBoundFloat(fields, line, 1, $"/acoustic/paths/{pathIndex}/area/constriction_diameter", "constriction_diameter", "cd"),
GetBoundFloat(fields, line, 0.09f, $"/acoustic/paths/{pathIndex}/area/constriction_width", "constriction_width", "cw"),
GetBoundFloat(fields, line, 1.5f, $"/acoustic/paths/{pathIndex}/area/lip_opening", "lip", "lip_opening", "mouth"),
GetBoundFloat(fields, line, 0.04f, $"/acoustic/paths/{pathIndex}/area/lip_width", "lip_width", "lw"),
GetBoundFloat(fields, line, 1, $"/acoustic/paths/{pathIndex}/area/index_scale", "index_scale"));
}
private void AddAcousticSourcePort(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_waveguidePathsByName.ContainsKey(Required(fields, "path", line)))
{
AddPrimitiveSourcePort(fields, line);
return;
}
if (_acousticSourcePortsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate acoustic source port `{name}`");
}
var path = Required(fields, "path", line);
RequireAcousticPath(path, line);
var kind = ParseAcousticSourceKind(GetAny(fields, ["kind", "source_kind"], "glottal"), line);
var model = ParseAcousticSourceModel(GetAny(fields, ["model", "source_model"], ""), kind, line);
var port = new AcousticSourcePort(
name,
path,
GetBoundFloat(fields, line, 0, $"/acoustic/sources/{_acousticSourcePorts.Count}/position", "position", "pos", "at"),
kind,
GetBoundFloat(fields, line, 0.72f, $"/acoustic/sources/{_acousticSourcePorts.Count}/pressure", "pressure", "intensity"),
GetBoundFloat(fields, line, 0.6f, $"/acoustic/sources/{_acousticSourcePorts.Count}/tension", "tension", "tenseness", "tense"),
GetBoundFloat(fields, line, 0.5f, $"/acoustic/sources/{_acousticSourcePorts.Count}/opening", "opening", "open"),
GetBoundFloat(fields, line, 0.08f, $"/acoustic/sources/{_acousticSourcePorts.Count}/noise", "noise", "aspiration"),
GetBoundFloat(fields, line, 0, $"/acoustic/sources/{_acousticSourcePorts.Count}/transient", "transient", "burst"),
GetBoundFloat(fields, line, 1, $"/acoustic/sources/{_acousticSourcePorts.Count}/balance", "balance"),
TryGetAny(fields, ["active"], out var active) ? ParseBool(active, line) : true,
ParseAcousticSourcePositionControl(fields, line, _acousticSourcePorts.Count),
GetBoundFloat(fields, line, 0.35f, $"/acoustic/sources/{_acousticSourcePorts.Count}/impedance", "impedance", "load", "source_impedance"),
model,
GetBoundFloat(fields, line, 0.35f, $"/acoustic/sources/{_acousticSourcePorts.Count}/mass", "mass", "valve_mass"),
GetBoundFloat(fields, line, 0.18f, $"/acoustic/sources/{_acousticSourcePorts.Count}/damping", "damping", "damp"),
GetBoundFloat(fields, line, 0, $"/acoustic/sources/{_acousticSourcePorts.Count}/stiffness", "stiffness", "spring"),
GetBoundFloat(fields, line, 0.8f, $"/acoustic/sources/{_acousticSourcePorts.Count}/saturation", "saturation", "sat"),
1,
GetBoundFloat(fields, line, 1, $"/acoustic/sources/{_acousticSourcePorts.Count}/flow_scale", "flow_scale", "flow_gain", "source_gain", "drive"),
GetBoundFloat(fields, line, 0.12f, $"/acoustic/sources/{_acousticSourcePorts.Count}/tissue_loss", "tissue_loss", "viscous_loss", "tissue_damping"),
GetBoundFloat(fields, line, 0.35f, $"/acoustic/sources/{_acousticSourcePorts.Count}/aperture_shape", "aperture_shape", "aperture_law"),
GetBoundFloat(fields, line, 0.35f, $"/acoustic/sources/{_acousticSourcePorts.Count}/flow_loss", "flow_loss", "flow_resistance", "airflow_loss"),
GetBoundFloat(fields, line, 0.35f, $"/acoustic/sources/{_acousticSourcePorts.Count}/load_coupling", "load_coupling", "load_feedback"),
GetBoundFloat(fields, line, 0.02f, $"/acoustic/sources/{_acousticSourcePorts.Count}/rest_opening", "rest_opening", "rest_aperture"),
ParseAcousticValveLaw(GetAny(fields, ["law", "valve_law"], kind == AcousticSourceKind.Glottal ? "two_mass" : "one_mass"), line),
GetBoundFloat(fields, line, 0.20f, $"/acoustic/sources/{_acousticSourcePorts.Count}/upper_mass", "upper_mass"),
GetBoundFloat(fields, line, 0.35f, $"/acoustic/sources/{_acousticSourcePorts.Count}/lower_mass", "lower_mass"),
GetBoundFloat(fields, line, 0, $"/acoustic/sources/{_acousticSourcePorts.Count}/upper_stiffness", "upper_stiffness"),
GetBoundFloat(fields, line, 0, $"/acoustic/sources/{_acousticSourcePorts.Count}/lower_stiffness", "lower_stiffness"),
GetBoundFloat(fields, line, 0.08f, $"/acoustic/sources/{_acousticSourcePorts.Count}/coupling_stiffness", "coupling_stiffness", "coupling_spring"),
GetBoundFloat(fields, line, 0.6f, $"/acoustic/sources/{_acousticSourcePorts.Count}/collision_stiffness", "collision_stiffness"),
GetBoundFloat(fields, line, 0.15f, $"/acoustic/sources/{_acousticSourcePorts.Count}/collision_damping", "collision_damping"),
GetBoundFloat(fields, line, 0.18f, $"/acoustic/sources/{_acousticSourcePorts.Count}/vertical_phase", "vertical_phase"),
GetBoundFloat(fields, line, 1, $"/acoustic/sources/{_acousticSourcePorts.Count}/reservoir_pressure", "reservoir_pressure", "subglottal_pressure", "bronchial_pressure"),
GetBoundFloat(fields, line, 0, $"/acoustic/sources/{_acousticSourcePorts.Count}/downstream_pressure", "downstream_pressure", "supraglottal_pressure"));
AddAcousticSourcePortRecord(port);
AddAcousticTerminalRecord(new AcousticTerminal(
port.Name,
port.Path,
port.Position,
AcousticTerminalKind.Source,
port.Name));
}
private void AddPrimitiveSourcePort(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_sourcePortsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate source port `{name}`");
}
var path = Required(fields, "path", line);
if (!_waveguidePathsByName.ContainsKey(path))
{
throw new PatchScriptException(line, $"unknown waveguide path `{path}`");
}
var port = new SourcePort(
name,
path,
GetBoundFloat(fields, line, 0, $"/vocal/sources/{_sourcePorts.Count}/position", "position", "pos", "at"),
ParseAcousticSourceKind(GetAny(fields, ["kind", "source_kind"], "glottal"), line),
GetBoundFloat(fields, line, 0.72f, $"/vocal/sources/{_sourcePorts.Count}/pressure", "pressure", "intensity"),
GetBoundFloat(fields, line, 0.6f, $"/vocal/sources/{_sourcePorts.Count}/tension", "tension", "tenseness", "tense"),
GetBoundFloat(fields, line, 0.5f, $"/vocal/sources/{_sourcePorts.Count}/opening", "opening", "open"),
GetBoundFloat(fields, line, 0.08f, $"/vocal/sources/{_sourcePorts.Count}/noise", "noise", "aspiration"),
GetBoundFloat(fields, line, 0.35f, $"/vocal/sources/{_sourcePorts.Count}/impedance", "impedance", "load", "source_impedance"),
GetBoundFloat(fields, line, 0.02f, $"/vocal/sources/{_sourcePorts.Count}/flow_scale", "flow_scale", "scale"));
_sourcePorts.Add(port);
_sourcePortsByName[name] = port;
RegisterSourcePortSurfaces(_sourcePorts.Count - 1, port);
}
private void AddConstrictionContact(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_constrictionContactsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate constriction contact `{name}`");
}
var path = Required(fields, "path", line);
if (!_waveguidePathsByName.ContainsKey(path))
{
throw new PatchScriptException(line, $"unknown waveguide path `{path}`");
}
var contact = new ConstrictionContact(
name,
path,
GetBoundFloat(fields, line, 0.5f, $"/vocal/contacts/{_constrictionContacts.Count}/position", "position", "pos", "at"),
GetBoundFloat(fields, line, 1, $"/vocal/contacts/{_constrictionContacts.Count}/opening", "opening", "open"),
GetBoundFloat(fields, line, 0.35f, $"/vocal/contacts/{_constrictionContacts.Count}/resistance", "resistance", "flow_loss"),
GetBoundFloat(fields, line, 0, $"/vocal/contacts/{_constrictionContacts.Count}/stored_pressure", "stored_pressure", "pressure"),
GetBoundFloat(fields, line, 0, $"/vocal/contacts/{_constrictionContacts.Count}/release_flow", "release_flow", "release"));
_constrictionContacts.Add(contact);
_constrictionContactsByName[name] = contact;
RegisterContactSurfaces(_constrictionContacts.Count - 1, contact);
}
private void AddBranchPort(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_branchPortsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate branch port `{name}`");
}
var fromPath = RequiredAny(fields, ["from_path", "from"], line);
var toPath = RequiredAny(fields, ["to_path", "to"], line);
if (!_waveguidePathsByName.ContainsKey(fromPath)) throw new PatchScriptException(line, $"unknown waveguide path `{fromPath}`");
if (!_waveguidePathsByName.ContainsKey(toPath)) throw new PatchScriptException(line, $"unknown waveguide path `{toPath}`");
var branch = new BranchPort(
name,
fromPath,
GetBoundFloat(fields, line, 0.5f, $"/vocal/branches/{_branchPorts.Count}/from_position", "from_position", "from_pos", "at"),
toPath,
GetBoundFloat(fields, line, 0, $"/vocal/branches/{_branchPorts.Count}/to_position", "to_position", "to_pos"),
GetBoundFloat(fields, line, 0, $"/vocal/branches/{_branchPorts.Count}/opening", "opening", "open"),
GetBoundFloat(fields, line, 1, $"/vocal/branches/{_branchPorts.Count}/coupling", "coupling"));
_branchPorts.Add(branch);
_branchPortsByName[name] = branch;
RegisterBranchSurfaces(_branchPorts.Count - 1, branch);
}
private void AddRadiationLoad(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_radiationLoadsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate radiation load `{name}`");
}
var path = Required(fields, "path", line);
if (!_waveguidePathsByName.ContainsKey(path))
{
throw new PatchScriptException(line, $"unknown waveguide path `{path}`");
}
var load = new RadiationLoad(
name,
path,
GetBoundFloat(fields, line, 1, $"/vocal/radiation/{_radiationLoads.Count}/position", "position", "pos", "at"),
ParseAcousticRadiationKind(GetAny(fields, ["kind", "radiation_kind"], "lip"), line),
GetBoundFloat(fields, line, 1, $"/vocal/radiation/{_radiationLoads.Count}/aperture", "aperture", "opening", "open"),
GetBoundFloat(fields, line, -0.85f, $"/vocal/radiation/{_radiationLoads.Count}/reflection", "reflection"),
GetBoundFloat(fields, line, 0.35f, $"/vocal/radiation/{_radiationLoads.Count}/impedance", "impedance", "load"));
_radiationLoads.Add(load);
_radiationLoadsByName[name] = load;
RegisterRadiationSurfaces(_radiationLoads.Count - 1, load);
}
private void AddProbeTimeline(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_probeTimelinesByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate probe timeline `{name}`");
}
var timeline = new ProbeTimeline(
name,
ParseNameList(GetAny(fields, ["networks", "network"], "")),
GetBoundInt(fields, line, 64, $"/vocal/probes/{_probeTimelines.Count}/block_size", "block_size", "block"),
GetBoundInt(fields, line, 16, $"/vocal/probes/{_probeTimelines.Count}/blocks", "blocks"));
_probeTimelines.Add(timeline);
_probeTimelinesByName[name] = timeline;
}
private static bool HasAcousticSourcePositionControl(IReadOnlyDictionary<string, string> fields) =>
HasAny(fields, "position_control", "follow_position", "position_index", "source_index", "follow_index", "position_width", "position_index_scale");
private AcousticSourcePositionControl? ParseAcousticSourcePositionControl(IReadOnlyDictionary<string, string> fields, int line, int sourceIndex)
{
if (!HasAcousticSourcePositionControl(fields))
{
return null;
}
return new AcousticSourcePositionControl(
GetBoundFloat(fields, line, 0, $"/acoustic/sources/{sourceIndex}/position/index", "position_index", "source_index", "follow_index"),
GetBoundFloat(fields, line, 1, $"/acoustic/sources/{sourceIndex}/position/width", "position_width", "follow_width"),
GetBoundFloat(fields, line, 1, $"/acoustic/sources/{sourceIndex}/position/index_scale", "position_index_scale", "follow_index_scale"));
}
private void AddAcousticBranch(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_acousticBranchesByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate acoustic branch `{name}`");
}
var fromPath = GetAny(fields, ["from_path", "from"], "");
var toPath = GetAny(fields, ["to_path", "to"], "");
if (fromPath.Length == 0) throw new PatchScriptException(line, "branch missing `from_path`");
if (toPath.Length == 0) throw new PatchScriptException(line, "branch missing `to_path`");
RequireAcousticPath(fromPath, line);
RequireAcousticPath(toPath, line);
var branch = new AcousticBranch(
name,
fromPath,
GetBoundFloat(fields, line, 0, $"/acoustic/branches/{_acousticBranches.Count}/from_position", "from_position", "from_pos", "from_at", "at"),
toPath,
GetBoundFloat(fields, line, 0, $"/acoustic/branches/{_acousticBranches.Count}/to_position", "to_position", "to_pos", "to_at"),
ParseAcousticBranchKind(GetAny(fields, ["kind", "branch_kind"], "side"), line),
GetBoundFloat(fields, line, 0, $"/acoustic/branches/{_acousticBranches.Count}/opening", "opening", "open"),
GetBoundFloat(fields, line, 1, $"/acoustic/branches/{_acousticBranches.Count}/coupling", "coupling"),
TryGetAny(fields, ["passive"], out var passive) ? ParseBool(passive, line) : true);
AddAcousticBranchRecord(branch);
AddAcousticTerminalRecord(new AcousticTerminal(
BranchFromTerminalName(branch),
branch.FromPath,
branch.FromPosition,
AcousticTerminalKind.Junction,
branch.Name,
Math.Max(0, branch.Coupling)));
AddAcousticTerminalRecord(new AcousticTerminal(
BranchToTerminalName(branch),
branch.ToPath,
branch.ToPosition,
AcousticTerminalKind.Junction,
branch.Name,
Math.Max(0, branch.Opening)));
AddAcousticConnectionRecord(new AcousticConnection(
BranchConnectionName(branch),
[BranchFromTerminalName(branch), BranchToTerminalName(branch)],
AcousticConnectionLaw.AreaScattering,
branch.Coupling));
}
private void AddAcousticRadiationPort(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_acousticRadiationPortsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate acoustic radiation port `{name}`");
}
var path = Required(fields, "path", line);
RequireAcousticPath(path, line);
var port = new AcousticRadiationPort(
name,
path,
GetBoundFloat(fields, line, 1, $"/acoustic/radiation/{_acousticRadiationPorts.Count}/position", "position", "pos", "at"),
ParseAcousticRadiationKind(GetAny(fields, ["kind", "radiation_kind"], "lip"), line),
GetBoundFloat(fields, line, 1, $"/acoustic/radiation/{_acousticRadiationPorts.Count}/opening", "opening", "open"),
GetBoundFloat(fields, line, -0.85f, $"/acoustic/radiation/{_acousticRadiationPorts.Count}/reflection", "reflection"),
GetBoundFloat(fields, line, 1, $"/acoustic/radiation/{_acousticRadiationPorts.Count}/loss", "loss"),
ParseAcousticRadiationModel(GetAny(fields, ["model", "radiation_model"], "simple_reflection"), line));
AddAcousticRadiationPortRecord(port);
AddAcousticTerminalRecord(new AcousticTerminal(
port.Name,
port.Path,
port.Position,
AcousticTerminalKind.Radiation,
port.Name,
Math.Max(0, port.Opening),
port.Reflection));
}
private void AddAcousticTerminal(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_acousticTerminalsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate acoustic terminal `{name}`");
}
var path = Required(fields, "path", line);
RequireAcousticPath(path, line);
var terminal = new AcousticTerminal(
name,
path,
GetBoundFloat(fields, line, 0, $"/acoustic/terminals/{_acousticTerminals.Count}/position", "position", "pos", "at"),
ParseAcousticTerminalKind(GetAny(fields, ["kind", "terminal_kind", "type"], "junction"), line),
GetAny(fields, ["port", "ref"], ""),
GetBoundFloat(fields, line, 1, $"/acoustic/terminals/{_acousticTerminals.Count}/area_scale", "area_scale", "area", "admittance"),
GetBoundFloat(fields, line, 0, $"/acoustic/terminals/{_acousticTerminals.Count}/reflection", "reflection"));
AddAcousticTerminalRecord(terminal);
}
private void AddAcousticConnection(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_acousticConnectionsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate acoustic connection `{name}`");
}
var terminals = ParseNameList(RequiredAny(fields, ["terminals", "ports"], line));
if (terminals.Count < 2)
{
throw new PatchScriptException(line, "connection needs at least two terminals");
}
foreach (var terminal in terminals) RequireAcousticTerminal(terminal, line);
var connection = new AcousticConnection(
name,
terminals,
ParseAcousticConnectionLaw(GetAny(fields, ["law", "scatter", "mode"], "area_scatter"), line),
GetBoundFloat(fields, line, 1, $"/acoustic/connections/{_acousticConnections.Count}/coupling", "coupling"),
GetBoundFloat(fields, line, 1, $"/acoustic/connections/{_acousticConnections.Count}/loss", "loss"));
AddAcousticConnectionRecord(connection);
}
private void AddWaveClock(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_waveClocksByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate wave clock `{name}`");
}
var policy = new WaveClockPolicy(
name,
ParseWaveClockDelayStrategy(GetAny(fields, ["strategy", "delay", "mode"], "unit_grid"), line),
GetBoundInt(fields, line, 1, $"/acoustic/wave_clocks/{_waveClocks.Count}/order", "order", "fractional_order"),
GetBoundInt(fields, line, 2048, $"/acoustic/wave_clocks/{_waveClocks.Count}/max_delay", "max_delay", "max_delay_samples"),
GetBoundFloat(fields, line, 5, $"/acoustic/wave_clocks/{_waveClocks.Count}/smoothing_ms", "smoothing_ms", "smooth_ms"));
AddWaveClockRecord(policy);
}
private void AddAcousticNetwork(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_acousticNetworksByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate acoustic network `{name}`");
}
var primaryPath = Required(fields, "path", line);
RequireAcousticPath(primaryPath, line);
var waveClock = GetAny(fields, ["wave_clock", "clock"], "");
if (waveClock.Length > 0 && !_waveClocksByName.ContainsKey(waveClock))
{
throw new PatchScriptException(line, $"unknown wave clock `{waveClock}`");
}
var network = new AcousticPortNetwork(
name,
primaryPath,
waveClock,
ParseNameList(GetAny(fields, ["sources", "source_ports"], "")),
ParseNameList(GetAny(fields, ["branches"], "")),
ParseNameList(GetAny(fields, ["radiation", "radiation_ports"], "")),
ParseNameList(GetAny(fields, ["terminals"], "")),
ParseNameList(GetAny(fields, ["connections"], "")));
foreach (var sourceName in network.SourcePorts) RequireAcousticSourcePort(sourceName, line);
foreach (var branchName in network.Branches) RequireAcousticBranch(branchName, line);
foreach (var radiationName in network.RadiationPorts) RequireAcousticRadiationPort(radiationName, line);
foreach (var terminalName in network.Terminals) RequireAcousticTerminal(terminalName, line);
foreach (var connectionName in network.Connections) RequireAcousticConnection(connectionName, line);
AddAcousticNetworkRecord(ExpandNetworkGraphSugar(network));
}
private void AddVocalNetwork(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_vocalNetworksByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate vocal network `{name}`");
}
var paths = ParseNameList(RequiredAny(fields, ["paths", "path"], line));
var sources = ParseNameList(GetAny(fields, ["sources", "source_ports"], ""));
var contacts = ParseNameList(GetAny(fields, ["contacts", "constrictions"], ""));
var branches = ParseNameList(GetAny(fields, ["branches", "branch_ports"], ""));
var radiation = ParseNameList(GetAny(fields, ["radiation", "radiation_loads"], ""));
foreach (var path in paths)
{
if (!_waveguidePathsByName.ContainsKey(path)) throw new PatchScriptException(line, $"unknown waveguide path `{path}`");
}
foreach (var source in sources)
{
if (!_sourcePortsByName.ContainsKey(source)) throw new PatchScriptException(line, $"unknown source port `{source}`");
}
foreach (var contact in contacts)
{
if (!_constrictionContactsByName.ContainsKey(contact)) throw new PatchScriptException(line, $"unknown constriction contact `{contact}`");
}
foreach (var branch in branches)
{
if (!_branchPortsByName.ContainsKey(branch)) throw new PatchScriptException(line, $"unknown branch port `{branch}`");
}
foreach (var load in radiation)
{
if (!_radiationLoadsByName.ContainsKey(load)) throw new PatchScriptException(line, $"unknown radiation load `{load}`");
}
var probe = GetAny(fields, ["probe", "probe_timeline", "timeline"], "");
if (probe.Length > 0 && !_probeTimelinesByName.ContainsKey(probe))
{
throw new PatchScriptException(line, $"unknown probe timeline `{probe}`");
}
var network = new VocalNetwork(name, paths, sources, contacts, branches, radiation, probe);
_vocalNetworks.Add(network);
_vocalNetworksByName[name] = network;
}
private void AddAcousticPathRecord(AcousticPath path)
{
if (_acousticPathsByName.ContainsKey(path.Name)) return;
_acousticPaths.Add(path);
_acousticPathsByName[path.Name] = path;
}
private void AddAcousticSourcePortRecord(AcousticSourcePort port)
{
if (_acousticSourcePortsByName.ContainsKey(port.Name)) return;
_acousticSourcePorts.Add(port);
_acousticSourcePortsByName[port.Name] = port;
}
private void AddAcousticBranchRecord(AcousticBranch branch)
{
if (_acousticBranchesByName.ContainsKey(branch.Name)) return;
_acousticBranches.Add(branch);
_acousticBranchesByName[branch.Name] = branch;
}
private void AddAcousticRadiationPortRecord(AcousticRadiationPort port)
{
if (_acousticRadiationPortsByName.ContainsKey(port.Name)) return;
_acousticRadiationPorts.Add(port);
_acousticRadiationPortsByName[port.Name] = port;
}
private void AddAcousticTerminalRecord(AcousticTerminal terminal)
{
if (_acousticTerminalsByName.ContainsKey(terminal.Name)) return;
_acousticTerminals.Add(terminal);
_acousticTerminalsByName[terminal.Name] = terminal;
}
private void MirrorParameterBinding(
string sourceFieldPath,
string targetFieldPath,
ParameterBindingTransform transform = ParameterBindingTransform.Identity,
float scale = 1)
{
if (_parameterBindings.Any(binding => binding.FieldPath.Equals(targetFieldPath, StringComparison.OrdinalIgnoreCase)))
{
return;
}
var source = _parameterBindings.LastOrDefault(binding => binding.FieldPath.Equals(sourceFieldPath, StringComparison.OrdinalIgnoreCase));
if (source is null)
{
return;
}
_parameterBindings.Add(new ParameterBinding(targetFieldPath, source.ParameterPath, transform, scale));
}
private void RegisterControlSurface(
string path,
string fieldPath,
string owner,
string field,
float defaultValue,
float minValue,
float maxValue,
string label = "",
string unit = "",
string automationRate = "control")
{
if (!path.StartsWith('/'))
{
path = "/" + path;
}
if (_controlSurfacesByPath.ContainsKey(path))
{
return;
}
var normalized = Math.Clamp((defaultValue - minValue) / Math.Max(0.000001f, maxValue - minValue), 0, 1);
var surface = new ControlSurface(
path,
fieldPath,
owner,
field,
normalized,
minValue,
maxValue,
label.Length == 0 ? field : label,
unit,
automationRate);
_controlSurfaces.Add(surface);
_controlSurfacesByPath[path] = surface;
}
private void RegisterNormalizedPrimitiveSurface(
string fieldPath,
string owner,
string field,
float defaultValue,
float minValue = 0,
float maxValue = 1,
string unit = "") =>
RegisterControlSurface(fieldPath, fieldPath, owner, field, defaultValue, minValue, maxValue, field, unit);
private void RegisterSourcePortSurfaces(int index, SourcePort port)
{
var owner = $"/vocal/sources/{index}";
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "pressure"), port.Name, "pressure", port.Pressure);
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "tension"), port.Name, "tension", port.Tension);
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "opening"), port.Name, "opening", port.Opening);
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "noise"), port.Name, "noise", port.Noise);
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "impedance"), port.Name, "impedance", port.Impedance, 0.05f, 2);
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "flow_scale"), port.Name, "flow_scale", port.FlowScale, 0, 0.2f);
}
private void RegisterAreaFunctionSurfaces(int index, AreaFunction area)
{
if (area.Deformation is null)
{
return;
}
var owner = $"/vocal/areas/{index}/area";
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "tongue_index"), area.Name, "tongue_index", area.Deformation.TongueIndex, 0, Math.Max(1, area.Shape.Sections));
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "tongue_diameter"), area.Name, "tongue_diameter", area.Deformation.TongueDiameter, 0, 4, "cm");
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "constriction_index"), area.Name, "constriction_index", area.Deformation.ConstrictionIndex, 0, Math.Max(1, area.Shape.Sections));
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "constriction_diameter"), area.Name, "constriction_diameter", area.Deformation.ConstrictionDiameter, 0, 4, "cm");
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "lip_opening"), area.Name, "lip_opening", area.Deformation.LipOpening, 0, 4, "cm");
}
private void RegisterContactSurfaces(int index, ConstrictionContact contact)
{
var owner = $"/vocal/contacts/{index}";
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "opening"), contact.Name, "opening", contact.Opening);
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "resistance"), contact.Name, "resistance", contact.Resistance, 0, 2);
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "stored_pressure"), contact.Name, "stored_pressure", contact.StoredPressure, 0, 2);
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "release_flow"), contact.Name, "release_flow", contact.ReleaseFlow, 0, 2);
}
private void RegisterBranchSurfaces(int index, BranchPort branch)
{
var owner = $"/vocal/branches/{index}";
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "opening"), branch.Name, "opening", branch.Opening);
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "coupling"), branch.Name, "coupling", branch.Coupling);
}
private void RegisterRadiationSurfaces(int index, RadiationLoad load)
{
var owner = $"/vocal/radiation/{index}";
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "aperture"), load.Name, "aperture", load.Aperture);
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "reflection"), load.Name, "reflection", load.Reflection, -1, 1);
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "impedance"), load.Name, "impedance", load.Impedance, 0.05f, 2);
}
private void AddGeneratedControlSpline(
string name,
string surfacePath,
float initial,
float target,
float slewPerSecond,
ControlSplineInterpolation interpolation = ControlSplineInterpolation.Bezier)
{
if (!_controlSurfacesByPath.ContainsKey(surfacePath) || _controlSplinesByName.ContainsKey(name))
{
return;
}
var duration = Math.Clamp(Math.Abs(target - initial) / Math.Max(0.0001f, slewPerSecond), 0.005f, 0.25f);
var handle = duration * 0.35f;
var spline = new ControlSpline(
name,
surfacePath,
[
new ControlSplinePoint(0, Math.Clamp(initial, 0, 1), handle, Math.Clamp(initial, 0, 1)),
new ControlSplinePoint(duration, Math.Clamp(target, 0, 1), 0, 0, handle, Math.Clamp(target, 0, 1))
],
interpolation);
_controlSplines.Add(spline);
_controlSplinesByName[name] = spline;
}
private void AddAuthoredSpline(
string name,
string surfacePath,
float start,
float duration,
params (float time, float value)[] points)
{
if (!_controlSurfacesByPath.TryGetValue(surfacePath, out var surface))
{
return;
}
var splineName = UniqueSplineName(name);
var clamped = points.Length == 0
? [(0f, surface.DefaultNormalized), (1f, surface.DefaultNormalized)]
: points;
var splinePoints = clamped
.Select(point =>
new ControlSplinePoint(
start + Math.Clamp(point.time, 0, 1) * duration,
Math.Clamp(point.value, 0, 1),
duration * 0.25f,
Math.Clamp(point.value, 0, 1),
duration * 0.25f,
Math.Clamp(point.value, 0, 1)))
.ToArray();
var spline = new ControlSpline(splineName, surfacePath, splinePoints, ControlSplineInterpolation.Bezier);
_controlSplines.Add(spline);
_controlSplinesByName[spline.Name] = spline;
}
private string UniqueSplineName(string name)
{
var safe = SafeName(name);
if (!_controlSplinesByName.ContainsKey(safe))
{
return safe;
}
for (var i = 1; ; i++)
{
var candidate = $"{safe}_{i}";
if (!_controlSplinesByName.ContainsKey(candidate))
{
return candidate;
}
}
}
private void EmitPhonemeGestureSplines(string name, string descriptor, float start, float duration, float intensity)
{
var tokens = DescriptorTokens(descriptor);
var voiced = tokens.Contains("voiced") || tokens.Contains("vowel") || tokens.Contains("nasal") || tokens.Contains("approximant");
if (tokens.Contains("voiceless"))
{
voiced = false;
}
var place = PlaceTarget(tokens);
var constriction = ConstrictionTarget(tokens);
var pressure = Math.Clamp(0.45f + 0.30f * intensity, 0, 1);
AddAuthoredSpline($"{name}_source_pressure", "/vocal/sources/0/pressure", start, duration, (0, 0.4f), (0.2f, pressure), (1, pressure * 0.85f));
AddAuthoredSpline($"{name}_source_tension", "/vocal/sources/0/tension", start, duration, (0, voiced ? 0.55f : 0.18f), (1, voiced ? 0.62f : 0.20f));
AddAuthoredSpline($"{name}_source_opening", "/vocal/sources/0/opening", start, duration, (0, voiced ? 0.45f : 0.25f), (1, voiced ? 0.48f : 0.28f));
AddAuthoredSpline($"{name}_source_noise", "/vocal/sources/0/noise", start, duration, (0, tokens.Contains("fricative") ? 0.45f : 0.05f), (1, tokens.Contains("fricative") ? 0.55f : 0.05f));
if (tokens.Contains("vowel"))
{
EmitVowelGesture(name, tokens, start, duration, intensity);
return;
}
AddAuthoredSpline($"{name}_constriction_index", "/vocal/areas/0/area/constriction_index", start, duration, (0, place), (1, place));
AddAuthoredSpline($"{name}_constriction_diameter", "/vocal/areas/0/area/constriction_diameter", start, duration, (0, constriction), (0.5f, constriction), (1, Math.Min(0.65f, constriction + 0.2f)));
if (tokens.Contains("bilabial") || tokens.Contains("labiodental") || tokens.Contains("labial") || tokens.Contains("rounded"))
{
var lipTarget = tokens.Contains("approximant") ? 0.38f : tokens.Contains("fricative") ? 0.18f : 0.04f;
AddAuthoredSpline($"{name}_lip_area", "/vocal/areas/0/area/lip_opening", start, duration, (0, lipTarget), (0.7f, lipTarget), (1, 0.45f));
AddAuthoredSpline($"{name}_lip_radiation", "/vocal/radiation/0/aperture", start, duration, (0, lipTarget), (0.7f, lipTarget), (1, 0.75f));
}
if (tokens.Contains("nasal"))
{
AddAuthoredSpline($"{name}_velum", "/vocal/branches/0/opening", start, duration, (0, 0.7f), (0.8f, 0.7f), (1, 0.25f));
AddAuthoredSpline($"{name}_contact", "/vocal/contacts/0/opening", start, duration, (0, 0.03f), (0.8f, 0.03f), (1, 0.25f));
}
else if (tokens.Contains("plosive") || tokens.Contains("stop"))
{
AddAuthoredSpline($"{name}_contact", "/vocal/contacts/0/opening", start, duration, (0, 0.02f), (0.65f, 0.02f), (1, 0.9f));
AddAuthoredSpline($"{name}_stored_pressure", "/vocal/contacts/0/stored_pressure", start, duration, (0, 0.4f), (0.65f, 0.75f), (1, 0.05f));
AddAuthoredSpline($"{name}_velum", "/vocal/branches/0/opening", start, duration, (0, 0.01f), (1, 0.01f));
}
else if (tokens.Contains("fricative"))
{
AddAuthoredSpline($"{name}_contact", "/vocal/contacts/0/opening", start, duration, (0, 0.18f), (1, 0.22f));
AddAuthoredSpline($"{name}_contact_resistance", "/vocal/contacts/0/resistance", start, duration, (0, 0.75f), (1, 0.85f));
AddAuthoredSpline($"{name}_velum", "/vocal/branches/0/opening", start, duration, (0, 0.01f), (1, 0.01f));
}
else
{
AddAuthoredSpline($"{name}_contact", "/vocal/contacts/0/opening", start, duration, (0, 0.55f), (1, 0.65f));
}
}
private void EmitVowelGesture(string name, HashSet<string> tokens, float start, float duration, float intensity)
{
var frontness = tokens.Contains("front") ? 0.70f : tokens.Contains("back") ? 0.30f : 0.50f;
var openness = tokens.Contains("close") ? 0.30f : tokens.Contains("mid") ? 0.50f : tokens.Contains("open") ? 0.78f : 0.55f;
var rounding = tokens.Contains("rounded") ? 0.35f : tokens.Contains("unrounded") ? 0.72f : 0.60f;
AddAuthoredSpline($"{name}_tongue_index", "/vocal/areas/0/area/tongue_index", start, duration, (0, frontness), (1, frontness));
AddAuthoredSpline($"{name}_tongue_diameter", "/vocal/areas/0/area/tongue_diameter", start, duration, (0, openness), (1, openness));
AddAuthoredSpline($"{name}_lip_area", "/vocal/areas/0/area/lip_opening", start, duration, (0, rounding), (1, rounding));
AddAuthoredSpline($"{name}_lip_radiation", "/vocal/radiation/0/aperture", start, duration, (0, Math.Clamp(rounding + 0.1f * intensity, 0, 1)), (1, Math.Clamp(rounding + 0.1f * intensity, 0, 1)));
AddAuthoredSpline($"{name}_velum", "/vocal/branches/0/opening", start, duration, (0, 0.01f), (1, 0.01f));
}
private static string GestureDescriptor(string ipa, IReadOnlyDictionary<string, string> fields)
{
var explicitDescriptor = GetAny(fields, ["descriptor", "desc", "features"], "");
if (explicitDescriptor.Length > 0)
{
return explicitDescriptor;
}
var place = GetAny(fields, ["place"], "");
var manner = GetAny(fields, ["manner"], "");
var phonation = GetAny(fields, ["voice", "voicing", "phonation"], "");
if (place.Length + manner.Length + phonation.Length > 0)
{
return $"{phonation}_{place}_{manner}";
}
return ipa switch
{
"p" => "voiceless_bilabial_plosive",
"b" => "voiced_bilabial_plosive",
"m" => "voiced_bilabial_nasal",
"s" => "voiceless_alveolar_fricative",
"z" => "voiced_alveolar_fricative",
"ʃ" => "voiceless_postalveolar_fricative",
"ʒ" => "voiced_postalveolar_fricative",
"ʍ" => "voiceless_labial_velar_fricative",
"w" => "voiced_labial_velar_approximant",
"i" => "voiced_close_front_unrounded_vowel",
"u" => "voiced_close_back_rounded_vowel",
"a" or "ɑ" => "voiced_open_back_unrounded_vowel",
"æ" => "voiced_open_front_unrounded_vowel",
"ə" => "voiced_mid_central_unrounded_vowel",
_ => "voiced_alveolar_approximant"
};
}
private static HashSet<string> DescriptorTokens(string descriptor) =>
descriptor
.ToLowerInvariant()
.Replace('-', '_')
.Split(['_', ',', '+', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
private static float PlaceTarget(HashSet<string> tokens)
{
if (tokens.Contains("bilabial") || tokens.Contains("labiodental") || tokens.Contains("labial")) return 0.96f;
if (tokens.Contains("dental")) return 0.88f;
if (tokens.Contains("alveolar")) return 0.78f;
if (tokens.Contains("postalveolar") || tokens.Contains("palatoalveolar")) return 0.70f;
if (tokens.Contains("retroflex")) return 0.64f;
if (tokens.Contains("palatal")) return 0.56f;
if (tokens.Contains("velar")) return 0.44f;
if (tokens.Contains("uvular")) return 0.34f;
if (tokens.Contains("pharyngeal") || tokens.Contains("epiglottal")) return 0.20f;
if (tokens.Contains("glottal")) return 0.05f;
return 0.70f;
}
private static float ConstrictionTarget(HashSet<string> tokens)
{
if (tokens.Contains("plosive") || tokens.Contains("stop") || tokens.Contains("nasal")) return 0.03f;
if (tokens.Contains("fricative")) return 0.16f;
if (tokens.Contains("approximant")) return 0.48f;
if (tokens.Contains("trill") || tokens.Contains("tap") || tokens.Contains("flap")) return 0.24f;
return 0.55f;
}
private void AddAcousticConnectionRecord(AcousticConnection connection)
{
if (_acousticConnectionsByName.ContainsKey(connection.Name)) return;
_acousticConnections.Add(connection);
_acousticConnectionsByName[connection.Name] = connection;
}
private void AddWaveClockRecord(WaveClockPolicy policy)
{
if (_waveClocksByName.ContainsKey(policy.Name)) return;
_waveClocks.Add(policy);
_waveClocksByName[policy.Name] = policy;
}
private void AddAcousticNetworkRecord(AcousticPortNetwork network)
{
if (_acousticNetworksByName.ContainsKey(network.Name)) return;
_acousticNetworks.Add(network);
_acousticNetworksByName[network.Name] = network;
}
private AcousticPortNetwork ExpandNetworkGraphSugar(AcousticPortNetwork network)
{
var terminals = new List<string>(network.Terminals);
var connections = new List<string>(network.Connections);
foreach (var sourceName in network.SourcePorts)
{
AddUnique(terminals, sourceName);
}
foreach (var radiationName in network.RadiationPorts)
{
AddUnique(terminals, radiationName);
}
foreach (var branchName in network.Branches)
{
if (!_acousticBranchesByName.TryGetValue(branchName, out var branch))
{
continue;
}
AddUnique(terminals, BranchFromTerminalName(branch));
AddUnique(terminals, BranchToTerminalName(branch));
AddUnique(connections, BranchConnectionName(branch));
}
return network with
{
Terminals = terminals,
Connections = connections
};
}
private static void AddUnique(List<string> values, string value)
{
if (!values.Contains(value, StringComparer.OrdinalIgnoreCase))
{
values.Add(value);
}
}
private static string BranchFromTerminalName(AcousticBranch branch) => $"{branch.Name}_from";
private static string BranchToTerminalName(AcousticBranch branch) => $"{branch.Name}_to";
private static string BranchConnectionName(AcousticBranch branch) => $"{branch.Name}_connection";
private void RequireAcousticPath(string name, int line)
{
if (!_acousticPathsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"unknown acoustic path `{name}`");
}
}
private void RequireAcousticSourcePort(string name, int line)
{
if (!_acousticSourcePortsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"unknown acoustic source port `{name}`");
}
}
private void RequireAcousticBranch(string name, int line)
{
if (!_acousticBranchesByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"unknown acoustic branch `{name}`");
}
}
private void RequireAcousticRadiationPort(string name, int line)
{
if (!_acousticRadiationPortsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"unknown acoustic radiation port `{name}`");
}
}
private void RequireAcousticTerminal(string name, int line)
{
if (!_acousticTerminalsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"unknown acoustic terminal `{name}`");
}
}
private void RequireAcousticConnection(string name, int line)
{
if (!_acousticConnectionsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"unknown acoustic connection `{name}`");
}
}
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, string ownerPath, int line)
{
var waveform = TryGetAny(fields, ["wave", "w"], out var wave) ? ParseWaveform(wave, line) : Waveform.Sine;
var frequency = GetBoundFloat(fields, line, 440, OwnerField(ownerPath, "note/frequency"), "freq", "frequency", "f");
var envelopeSpec = TryGetAny(fields, ["env", "envelope"], out var envSpec)
? ParseEnvelopeSpec(envSpec, fields, line, ownerPath)
: null;
var gateSeconds = envelopeSpec?.GateSeconds ??
GetBoundFloat(fields, line, 0.1f, OwnerField(ownerPath, "note/gate"), "gate", "hold", "duration", "sustain", "s");
var sustainLevel = envelopeSpec?.Envelope.SustainLevel ??
GetBoundFloat(fields, line, 1, OwnerField(ownerPath, "env/sustain_level"), "sustain_level", "sl");
var gainScale = 1f;
if (TryGetAny(fields, ["punch", "pu"], out var punch))
{
gainScale = PunchGain(ParseBoundFloat(punch, line, 0, OwnerField(ownerPath, "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 formantFrames = TryGetAny(fields, ["vowels", "vowel_frames", "formant_frames"], out var vowelSpec)
? ParseFormantFrames(vowelSpec, 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, OwnerField(ownerPath, "arpeggio/delay")),
ParseBoundFloat(arpMult ?? throw new PatchScriptException(line, "arpeggio needs arp_mult"), line, 1, OwnerField(ownerPath, "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, OwnerField(ownerPath, "osc/duty"), "duty", "du"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "osc/phase"), "phase", "pa")),
Note = new Note(frequency, gateSeconds, noteSource),
Envelope = envelopeSpec?.Envelope ?? new Envelope(
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "env/attack"), "attack", "a"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "env/decay"), "env_decay", "ed"),
sustainLevel,
GetBoundFloat(fields, line, 0.1f, OwnerField(ownerPath, "env/release"), "release", "rel", "decay", "d")),
RateLevelEnvelope = envelopeSpec?.RateLevelEnvelope,
Pitch = new PitchMotion(
GetBoundFloat(fields, line, 20, OwnerField(ownerPath, "pitch/min_freq"), "min_freq", "min"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "pitch/ramp"), "pitch_ramp", "pr"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "pitch/delta"), "pitch_delta", "pd", "pitch_dramp", "pdr"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "pitch/vibrato"), "vibrato", "vi"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "pitch/vibrato_hz"), "vibrato_hz", "vh"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "pitch/vibrato_delay"), "vibrato_delay", "vd")),
Duty = new DutyMotion(GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "duty/ramp"), "duty_ramp", "dur")),
Filter = new Filter(
GetBoundFloat(fields, line, 1, OwnerField(ownerPath, "filter/lpf"), "lpf", "l"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/lpf_ramp"), "lpf_ramp", "lr"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/resonance"), "resonance", "res"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/lpf_q"), "lpf_q", "lpq"),
GetBoundInt(fields, line, 1, OwnerField(ownerPath, "filter/lpf_order"), "lpf_order", "lpfo"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/hpf"), "hpf", "h"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/hpf_ramp"), "hpf_ramp", "hr"),
GetBoundInt(fields, line, 1, OwnerField(ownerPath, "filter/hpf_order"), "hpf_order", "hpfo"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/bpf"), "bpf", "bp"),
GetBoundFloat(fields, line, 1, OwnerField(ownerPath, "filter/bpf_q"), "bpf_q", "bpq"),
GetBoundInt(fields, line, 1, OwnerField(ownerPath, "filter/bpf_order"), "bpf_order", "bpfo"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "filter/notch"), "notch", "nt"),
GetBoundFloat(fields, line, 1, OwnerField(ownerPath, "filter/notch_q"), "notch_q", "ntq"),
GetBoundInt(fields, line, 1, OwnerField(ownerPath, "filter/notch_order"), "notch_order", "nto"),
ParseFilterRateLevelEnvelope(fields, line, ownerPath, "lpf"),
ParseFilterRateLevelEnvelope(fields, line, ownerPath, "hpf")),
Phaser = new Phaser(
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "phaser/offset"), "phaser", "ph"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "phaser/ramp"), "phaser_ramp", "phr")),
Arpeggio = arpeggio,
Fm = new FrequencyModulation(
GetBoundFloat(fields, line, 1, OwnerField(ownerPath, "fm/ratio"), "fm", "fmr", "fm_ratio"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "fm/index"), "fm_index", "fmi"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "fm/decay"), "fm_decay", "fmd")),
Color = new VoiceColor(
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/noise"), "noise", "nz"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/drive"), "drive", "drv"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/fold"), "fold", "fl"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/tremolo"), "tremolo", "tr"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/tremolo_hz"), "tremolo_hz", "th"),
GetBoundFloat(fields, line, 0, OwnerField(ownerPath, "color/formant_mix"), "formant_mix", "fmix")),
Formants = formants,
FormantFrames = formantFrames,
FormantFrameRateHz = GetBoundFloat(fields, line, 0.5f, OwnerField(ownerPath, "color/vowel_rate"), "vowel_hz", "vowel_rate", "vowels_hz"),
Modulators = modulators,
Gain = GetBoundFloat(fields, line, 0.2f, OwnerField(ownerPath, "gain"), "gain", "g") * gainScale,
AcousticNetwork = ParseAcousticNetworkReference(fields, line),
VocalNetwork = ParseVocalNetworkReference(fields, line)
};
}
private Voice ParseAcousticVoice(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var voiceFields = new Dictionary<string, string>(fields, StringComparer.OrdinalIgnoreCase);
if (!HasAny(voiceFields, "wave", "w"))
{
voiceFields["wave"] = "saw";
}
if (!HasAny(voiceFields, "gain", "g"))
{
voiceFields["gain"] = "0.35";
}
if (!HasAny(voiceFields, "sustain", "s", "gate", "hold", "duration"))
{
voiceFields["sustain"] = "0.35";
}
var voice = ParseVoice(voiceFields, ownerPath, line);
if (voice.AcousticNetwork is null)
{
throw new PatchScriptException(line, "acoustic voice needs `network` or `acoustic_network`");
}
return voice;
}
private Voice ParseVocalVoice(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var voiceFields = new Dictionary<string, string>(fields, StringComparer.OrdinalIgnoreCase);
if (!HasAny(voiceFields, "wave", "w"))
{
voiceFields["wave"] = "saw";
}
if (!HasAny(voiceFields, "gain", "g"))
{
voiceFields["gain"] = "0.35";
}
if (!HasAny(voiceFields, "sustain", "s", "gate", "hold", "duration"))
{
voiceFields["sustain"] = "0.35";
}
var voice = ParseVoice(voiceFields, ownerPath, line);
if (voice.VocalNetwork is null)
{
throw new PatchScriptException(line, "vocal voice needs `network` or `vocal_network`");
}
return voice;
}
private Voice ParseTractVoice(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var voiceFields = new Dictionary<string, string>(fields, StringComparer.OrdinalIgnoreCase);
if (!HasAny(voiceFields, "wave", "w"))
{
voiceFields["wave"] = "saw";
}
if (!HasAny(voiceFields, "gain", "g"))
{
voiceFields["gain"] = "0.6";
}
if (!HasAny(voiceFields, "sustain", "s", "gate", "hold", "duration"))
{
voiceFields["sustain"] = "0.35";
}
if (!HasAny(voiceFields, "decay", "d", "release", "rel"))
{
voiceFields["decay"] = "0.12";
}
var voice = ParseVoice(voiceFields, ownerPath, line);
var areaFunction = ParseTractAreaFunctionReference(fields, line);
var glottis = ParseGlottalSourceReference(fields, ownerPath, line);
var injection = ParseTractInjectionReference(fields, ownerPath, line);
var nasal = ParseNasalBranchReference(fields, ownerPath, line);
var motion = ParseTractMotionReference(fields, ownerPath, line);
if (HasAny(fields, "waveguide_loss"))
{
throw new PatchScriptException(line, "`waveguide_loss` has been removed; use graph `loss`");
}
if (HasAny(fields, "substeps", "steps"))
{
throw new PatchScriptException(line, "`substeps` has been removed; graph timing is owned by path length and wave_clock");
}
var propagation = ParseTractPropagationMode(GetAny(fields, ["propagation", "model"], "graph"), line);
var originalSections = areaFunction?.Sections ?? 44;
var defaultSections = originalSections;
var sections = GetBoundInt(fields, line, defaultSections, OwnerField(ownerPath, "tract/sections"), "sections", "cells");
if (sections < 4)
{
throw new PatchScriptException(line, "tract sections must be at least 4");
}
areaFunction = areaFunction is not null && areaFunction.Sections != sections
? areaFunction.Resample(sections)
: areaFunction;
var indexScale = originalSections <= 0 ? 1 : sections / (float)originalSections;
var defaultNoseSections = nasal?.AreaFunction?.Sections ?? 28;
var noseSections = GetBoundInt(fields, line, defaultNoseSections, OwnerField(ownerPath, "tract/nose_sections"), "nose_sections", "nose_cells");
if (noseSections < 0)
{
throw new PatchScriptException(line, "tract nose_sections must be non-negative");
}
nasal = ResampleNasalBranch(nasal, noseSections, indexScale);
var tongueIndex = GetBoundFloat(fields, line, 12.9f, OwnerField(ownerPath, "tract/tongue_index"), "tongue_index", "tongue", "ti");
var constrictionIndex = GetBoundFloat(fields, line, injection?.Position ?? 32, OwnerField(ownerPath, "tract/constriction_index"), "constriction_index", "ci");
var tract = new VocalTract(
sections,
noseSections,
GetBoundFloat(fields, line, glottis?.Intensity ?? 0.72f, OwnerField(ownerPath, "tract/intensity"), "intensity", "pressure"),
GetBoundFloat(fields, line, glottis?.Tenseness ?? 0.6f, OwnerField(ownerPath, "tract/tenseness"), "tenseness", "tense"),
tongueIndex,
GetBoundFloat(fields, line, 2.43f, OwnerField(ownerPath, "tract/tongue_diameter"), "tongue_diameter", "td"),
GetBoundFloat(fields, line, 0.01f, OwnerField(ownerPath, "tract/velum"), "velum", "nose", "nasal"),
constrictionIndex,
GetBoundFloat(fields, line, injection?.Diameter ?? 1, OwnerField(ownerPath, "tract/constriction_diameter"), "constriction_diameter", "cd"),
GetBoundFloat(fields, line, injection?.Turbulence ?? 0, OwnerField(ownerPath, "tract/turbulence"), "turbulence", "frication"),
GetBoundFloat(fields, line, 1.5f, OwnerField(ownerPath, "tract/lip_opening"), "lip", "lip_opening", "mouth"),
GetBoundFloat(fields, line, glottis?.Reflection ?? 0.75f, OwnerField(ownerPath, "tract/glottal_reflection"), "glottal_reflection", "gr"),
GetBoundFloat(fields, line, -0.85f, OwnerField(ownerPath, "tract/lip_reflection"), "lip_reflection", "lr"),
areaFunction,
glottis,
injection,
nasal,
motion,
propagation,
GetBoundFloat(fields, line, 0.999f, OwnerField(ownerPath, "tract/loss"), "loss", "propagation_loss"),
indexScale);
var vocalNetwork = EnsureTractVocalNetwork(ownerPath, tract);
return voice with
{
Tract = tract with { VocalNetwork = vocalNetwork },
VocalNetwork = vocalNetwork
};
}
private VocalNetwork EnsureTractVocalNetwork(string ownerPath, VocalTract tract)
{
var prefix = ownerPath.Replace("/", "_", StringComparison.Ordinal).Trim('_');
var tractPath = OwnerField(ownerPath, "tract");
var baseOralArea = tract.AreaFunction ?? new TractAreaFunction([0.6f, 0.8f, 1.2f, 1.5f, 1.5f, 1.2f, 0.8f, 0.6f], 17);
var areaName = $"{prefix}_morphology";
var areaIndex = _areaFunctions.Count;
var area = new AreaFunction(
areaName,
baseOralArea,
new AcousticAreaControl(
tract.TongueIndex,
tract.TongueDiameter,
0.18f,
tract.ConstrictionIndex,
tract.ConstrictionDiameter,
0.09f,
tract.LipOpening,
0.04f,
tract.IndexScale),
Math.Min(10, Math.Max(4, tract.Sections / 4)));
if (!_areaFunctionsByName.ContainsKey(area.Name))
{
_areaFunctions.Add(area);
_areaFunctionsByName[area.Name] = area;
RegisterAreaFunctionSurfaces(areaIndex, area);
}
MirrorParameterBinding(OwnerField(tractPath, "tongue_index"), $"/vocal/areas/{areaIndex}/area/tongue_index");
MirrorParameterBinding(OwnerField(tractPath, "tongue_diameter"), $"/vocal/areas/{areaIndex}/area/tongue_diameter");
MirrorParameterBinding(OwnerField(tractPath, "constriction_index"), $"/vocal/areas/{areaIndex}/area/constriction_index");
MirrorParameterBinding(OwnerField(tractPath, "constriction_diameter"), $"/vocal/areas/{areaIndex}/area/constriction_diameter");
MirrorParameterBinding(OwnerField(tractPath, "lip_opening"), $"/vocal/areas/{areaIndex}/area/lip_opening");
var pathName = $"{prefix}_oral";
var pathIndex = _waveguidePaths.Count;
var path = new WaveguidePath(pathName, areaName, Loss: tract.PropagationLoss);
if (!_waveguidePathsByName.ContainsKey(path.Name))
{
_waveguidePaths.Add(path);
_waveguidePathsByName[path.Name] = path;
RegisterNormalizedPrimitiveSurface($"/vocal/paths/{pathIndex}/speed", path.Name, "speed", path.PropagationSpeedMetersPerSecond, 100, 1000, "m/s");
RegisterNormalizedPrimitiveSurface($"/vocal/paths/{pathIndex}/loss", path.Name, "loss", path.Loss);
}
MirrorParameterBinding(OwnerField(tractPath, "loss"), $"/vocal/paths/{pathIndex}/loss");
var sourceName = string.IsNullOrWhiteSpace(tract.Glottis?.Name) ? $"{prefix}_source" : $"{prefix}_{tract.Glottis!.Name}";
var sourceIndex = _sourcePorts.Count;
var source = new SourcePort(
sourceName,
pathName,
0,
AcousticSourceKind.Glottal,
tract.Intensity,
tract.Tenseness,
tract.Glottis?.Skew ?? 0.42f,
tract.Glottis?.Aspiration ?? 0.08f,
0.10f,
0.02f);
if (!_sourcePortsByName.ContainsKey(source.Name))
{
_sourcePorts.Add(source);
_sourcePortsByName[source.Name] = source;
RegisterSourcePortSurfaces(sourceIndex, source);
}
MirrorParameterBinding(OwnerField(tractPath, "intensity"), $"/vocal/sources/{sourceIndex}/pressure");
MirrorParameterBinding(OwnerField(tractPath, "tenseness"), $"/vocal/sources/{sourceIndex}/tension");
var sourceNames = new List<string> { sourceName };
var contactNames = new List<string>();
var branchNames = new List<string>();
var radiationNames = new List<string>();
if (tract.Injection is not null || tract.Turbulence > 0 || tract.ConstrictionDiameter < 1)
{
var contactName = $"{prefix}_constriction";
var contactIndex = _constrictionContacts.Count;
var contact = new ConstrictionContact(
contactName,
pathName,
NormalizeTractIndex(tract.ConstrictionIndex, tract.Sections),
Math.Clamp(tract.ConstrictionDiameter / 2.5f, 0, 1),
0.35f + Math.Max(0, tract.Turbulence) * 0.35f,
tract.Injection?.Burst ?? 0,
tract.Injection?.Burst ?? 0);
if (!_constrictionContactsByName.ContainsKey(contact.Name))
{
_constrictionContacts.Add(contact);
_constrictionContactsByName[contact.Name] = contact;
RegisterContactSurfaces(contactIndex, contact);
}
MirrorParameterBinding(OwnerField(tractPath, "constriction_diameter"), $"/vocal/contacts/{contactIndex}/opening");
MirrorParameterBinding(OwnerField(tractPath, "turbulence"), $"/vocal/contacts/{contactIndex}/resistance");
MirrorParameterBinding(OwnerField(tractPath, "injection/burst"), $"/vocal/contacts/{contactIndex}/stored_pressure");
contactNames.Add(contactName);
}
if (tract.Nasal is { AreaFunction: { } nasalArea } nasal)
{
var nasalAreaName = $"{prefix}_nasal_morphology";
if (!_areaFunctionsByName.ContainsKey(nasalAreaName))
{
_areaFunctions.Add(new AreaFunction(nasalAreaName, nasalArea, EmitSections: Math.Min(8, Math.Max(2, nasalArea.Sections / 3))));
_areaFunctionsByName[nasalAreaName] = _areaFunctions[^1];
}
var nasalPathName = $"{prefix}_nasal";
if (!_waveguidePathsByName.ContainsKey(nasalPathName))
{
_waveguidePaths.Add(new WaveguidePath(nasalPathName, nasalAreaName, Loss: nasal.Loss));
_waveguidePathsByName[nasalPathName] = _waveguidePaths[^1];
RegisterNormalizedPrimitiveSurface($"/vocal/paths/{_waveguidePaths.Count - 1}/speed", nasalPathName, "speed", _waveguidePaths[^1].PropagationSpeedMetersPerSecond, 100, 1000, "m/s");
RegisterNormalizedPrimitiveSurface($"/vocal/paths/{_waveguidePaths.Count - 1}/loss", nasalPathName, "loss", _waveguidePaths[^1].Loss);
}
var branchName = $"{prefix}_velopharynx";
var branchIndex = _branchPorts.Count;
if (!_branchPortsByName.ContainsKey(branchName))
{
_branchPorts.Add(new BranchPort(
branchName,
pathName,
NormalizeTractIndex(nasal.JunctionIndex, tract.Sections),
nasalPathName,
0,
nasal.Velum,
1));
_branchPortsByName[branchName] = _branchPorts[^1];
RegisterBranchSurfaces(branchIndex, _branchPorts[^1]);
}
if (tract.Motion is { } motion)
{
AddGeneratedControlSpline(
$"{branchName}_opening_motion",
$"/vocal/branches/{branchIndex}/opening",
0.01f,
Math.Clamp(nasal.Velum, 0, 1),
motion.VelumSlewPerSecond);
}
MirrorParameterBinding(OwnerField(tractPath, "velum"), $"/vocal/branches/{branchIndex}/opening");
branchNames.Add(branchName);
}
var radiationName = $"{prefix}_lip";
var radiationIndex = _radiationLoads.Count;
var radiation = new RadiationLoad(
radiationName,
pathName,
1,
AcousticRadiationKind.Lip,
tract.LipOpening,
tract.LipReflection,
0.28f);
if (!_radiationLoadsByName.ContainsKey(radiation.Name))
{
_radiationLoads.Add(radiation);
_radiationLoadsByName[radiation.Name] = radiation;
RegisterRadiationSurfaces(radiationIndex, radiation);
}
MirrorParameterBinding(OwnerField(tractPath, "lip_opening"), $"/vocal/radiation/{radiationIndex}/aperture");
MirrorParameterBinding(OwnerField(tractPath, "lip_reflection"), $"/vocal/radiation/{radiationIndex}/reflection");
radiationNames.Add(radiationName);
var probeName = $"{prefix}_flow";
if (!_probeTimelinesByName.ContainsKey(probeName))
{
_probeTimelines.Add(new ProbeTimeline(probeName, [$"{prefix}_network"]));
_probeTimelinesByName[probeName] = _probeTimelines[^1];
}
var network = new VocalNetwork(
$"{prefix}_network",
branchNames.Count == 0 ? [pathName] : [pathName, $"{prefix}_nasal"],
sourceNames,
contactNames,
branchNames,
radiationNames,
probeName);
if (!_vocalNetworksByName.ContainsKey(network.Name))
{
_vocalNetworks.Add(network);
_vocalNetworksByName[network.Name] = network;
}
return network;
}
private static NasalBranch? ResampleNasalBranch(NasalBranch? nasal, int noseSections, float oralIndexScale)
{
if (nasal is null)
{
return null;
}
var areaFunction = nasal.AreaFunction is { } shape && shape.Sections != noseSections && noseSections > 0
? shape.Resample(noseSections)
: nasal.AreaFunction;
return nasal with
{
AreaFunction = areaFunction,
JunctionIndex = Math.Max(0, (int)MathF.Round(nasal.JunctionIndex * oralIndexScale, MidpointRounding.AwayFromZero))
};
}
private static float NormalizeTractIndex(float index, int sections) =>
sections <= 0 ? 0 : Math.Clamp(index / Math.Max(1, sections - 1), 0, 1);
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 void AddControlSurface(IReadOnlyDictionary<string, string> fields, int line)
{
var fieldPath = RequiredAny(fields, ["field", "field_path", "target"], line);
if (!fieldPath.StartsWith('/'))
{
throw new PatchScriptException(line, "control surface field must start with `/`");
}
var path = GetAny(fields, ["path", "surface", "surface_path"], fieldPath);
var min = GetFloat(fields, line, 0, "min", "minimum");
var max = GetFloat(fields, line, 1, "max", "maximum");
if (max <= min)
{
throw new PatchScriptException(line, "control surface max must be greater than min");
}
var defaultValue = GetFloat(fields, line, min, "default", "value");
RegisterControlSurface(
path,
fieldPath,
GetAny(fields, ["owner"], OwnerNameFromFieldPath(fieldPath)),
GetAny(fields, ["control", "field_name"], FieldNameFromFieldPath(fieldPath)),
defaultValue,
min,
max,
GetAny(fields, ["label"], ""),
GetAny(fields, ["unit"], ""),
GetAny(fields, ["rate", "automation", "automation_rate"], "control"));
}
private void AddControlSpline(IReadOnlyDictionary<string, string> fields, int line)
{
var surfacePath = RequiredAny(fields, ["surface", "surface_path", "target"], line);
if (!surfacePath.StartsWith('/'))
{
throw new PatchScriptException(line, "control spline surface must start with `/`");
}
if (!_controlSurfacesByPath.ContainsKey(surfacePath))
{
throw new PatchScriptException(line, $"control spline targets unknown surface `{surfacePath}`");
}
var name = TryGetAny(fields, ["name", "n"], out var nameText)
? nameText
: surfacePath.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? "spline";
if (_controlSplinesByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate control spline `{name}`");
}
var points = ParseControlSplinePoints(RequiredAny(fields, ["points", "pts", "values"], line), line);
_controlSplines.Add(new ControlSpline(
name,
surfacePath,
points,
ParseControlSplineInterpolation(GetAny(fields, ["interp", "interpolation"], "bezier"), line),
TryGetAny(fields, ["loop"], out var loop) && ParseBool(loop, line),
TryGetAny(fields, ["enabled", "active"], out var enabled) ? ParseBool(enabled, line) : true));
_controlSplinesByName[name] = _controlSplines[^1];
}
private void AddPhonemeGesture(IReadOnlyDictionary<string, string> fields, int line)
{
var ipa = RequiredAny(fields, ["ipa", "symbol", "phone", "phoneme"], line);
var start = GetFloat(fields, line, 0, "start", "time", "at");
var duration = Math.Max(0.001f, GetFloat(fields, line, 0.12f, "dur", "duration", "length"));
var intensity = Math.Clamp(GetFloat(fields, line, 1, "intensity", "gain", "strength"), 0, 2);
var descriptor = GestureDescriptor(ipa, fields);
var name = TryGetAny(fields, ["name", "n"], out var nameText)
? nameText
: $"ipa_{SafeName(ipa)}_{_phonemeGestures.Count}";
_phonemeGestures.Add(new PhonemeGesture(name, ipa, descriptor, start, duration, intensity));
EmitPhonemeGestureSplines(name, descriptor, start, duration, intensity);
}
private void AddControlCurve(IReadOnlyDictionary<string, string> fields, int line)
{
var parameterPath = Required(fields, "path", line);
if (!parameterPath.StartsWith('/'))
{
throw new PatchScriptException(line, "control curve path must start with `/`");
}
if (_parameters.All(parameter => !parameter.Path.Equals(parameterPath, StringComparison.OrdinalIgnoreCase)))
{
throw new PatchScriptException(line, $"control curve targets unknown parameter `{parameterPath}`");
}
if (_controlCurves.Any(curve => curve.ParameterPath.Equals(parameterPath, StringComparison.OrdinalIgnoreCase)))
{
throw new PatchScriptException(line, $"duplicate control curve for `{parameterPath}`");
}
var name = TryGetAny(fields, ["name", "n"], out var nameText)
? nameText
: parameterPath.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? "curve";
var points = ParseControlCurvePoints(RequiredAny(fields, ["points", "pts", "values"], line), line);
_controlCurves.Add(new ControlCurve(
name,
parameterPath,
points,
ParseControlCurveMode(GetAny(fields, ["mode"], "blend"), line),
ParseControlCurveInterpolation(GetAny(fields, ["interp", "interpolation"], "linear"), line),
GetFloat(fields, line, 1, "depth", "amount", "mix"),
GetFloat(fields, line, 1, "time_scale", "speed"),
GetFloat(fields, line, 0, "offset", "time_offset"),
TryGetAny(fields, ["loop"], out var loop) && ParseBool(loop, line),
GetAny(fields, ["rate", "automation", "automation_rate"], "control")));
}
private void AddPatternCurve(IReadOnlyDictionary<string, string> fields, int line)
{
var parameterPath = Required(fields, "path", line);
var pattern = RequiredAny(fields, ["pattern", "pat", "steps"], line);
var stepSeconds = GetFloat(fields, line, GetFloat(fields, line, 0.25f, "beat"), "step", "step_seconds", "dt");
if (stepSeconds <= 0)
{
throw new PatchScriptException(line, "pattern step must be greater than zero");
}
var high = GetFloat(fields, line, 1, "high", "on", "hit");
var low = GetFloat(fields, line, 0, "low", "off", "rest");
var values = PatternValues(pattern, high, low, line);
EnsureParameter(parameterPath, low, Math.Min(low, high), Math.Max(low, high), GetFloat(fields, line, 0.001f, "step_size"), "", line);
AddControlCurve(SyntheticFields(
("name", GetAny(fields, ["name", "n"], SafeName(parameterPath.Trim('/')) + "_pattern")),
("path", parameterPath),
("points", PointsText(values.Select((value, index) => (index * stepSeconds, value)))),
("interp", "hold"),
("loop", GetAny(fields, ["loop"], "true")),
("mode", GetAny(fields, ["mode"], "blend")),
("depth", GetAny(fields, ["depth", "amount", "mix"], "1"))), line);
}
private void AddScaleCurve(IReadOnlyDictionary<string, string> fields, int line)
{
var parameterPath = Required(fields, "path", line);
var stepSeconds = GetFloat(fields, line, GetFloat(fields, line, BeatSeconds / 2, "beat"), "step", "step_seconds", "dt");
if (stepSeconds <= 0)
{
throw new PatchScriptException(line, "scale step must be greater than zero");
}
var frequencies = TryGetAny(fields, ["freqs", "frequencies", "notes"], out var frequenciesText)
? ParseLooseFloatList(frequenciesText, line)
: ScaleFrequencies(fields, line);
if (frequencies.Count == 0)
{
throw new PatchScriptException(line, "scale needs at least one frequency");
}
var degrees = TryGetAny(fields, ["degrees", "pattern", "pat", "steps"], out var degreeText)
? ParseLooseIntList(degreeText, line)
: Enumerable.Range(0, frequencies.Count).ToArray();
if (degrees.Count == 0)
{
throw new PatchScriptException(line, "scale needs at least one degree");
}
var values = degrees
.Select(degree => frequencies[Math.Clamp(degree, 0, frequencies.Count - 1)])
.ToArray();
var min = GetFloat(fields, line, frequencies.Min(), "min");
var max = GetFloat(fields, line, frequencies.Max(), "max");
EnsureParameter(parameterPath, values[0], min, max, GetFloat(fields, line, 0.01f, "step_size"), "Hz", line);
AddControlCurve(SyntheticFields(
("name", GetAny(fields, ["name", "n"], SafeName(parameterPath.Trim('/')) + "_scale")),
("path", parameterPath),
("points", PointsText(values.Select((value, index) => (index * stepSeconds, value)))),
("interp", "hold"),
("loop", GetAny(fields, ["loop"], "true")),
("mode", GetAny(fields, ["mode"], "blend")),
("depth", GetAny(fields, ["depth", "amount", "mix"], "1"))), line);
}
private void AddMeter(IReadOnlyDictionary<string, string> fields, int line)
{
var bpm = GetFloat(fields, line, 60f / BeatSeconds, "bpm", "tempo");
if (bpm <= 0)
{
throw new PatchScriptException(line, "meter bpm must be greater than zero");
}
BeatSeconds = 60f / bpm;
BeatsPerBar = Math.Max(1, GetInt(fields, line, BeatsPerBar, "beats", "beats_per_bar", "numerator"));
}
private void AddSequencePattern(IReadOnlyDictionary<string, string> fields, int line)
{
var name = SafeName(RequiredAny(fields, ["name", "n", "voice", "lane", "instrument"], line));
var path = GetAny(fields, ["path", "gate_path"], $"/seq/{name}");
var stepSeconds = GetMusicalStepSeconds(fields, line, BeatSeconds / 4, "step", "step_seconds", "dt");
AddPatternCurve(SyntheticFields(
("name", GetAny(fields, ["curve", "curve_name"], $"{name}_seq")),
("path", path),
("pattern", RequiredAny(fields, ["pattern", "pat", "steps"], line)),
("step", stepSeconds.ToString(CultureInfo.InvariantCulture)),
("high", GetAny(fields, ["high", "on", "hit", "gain"], "1")),
("low", GetAny(fields, ["low", "off", "rest"], "0")),
("loop", GetAny(fields, ["loop"], "true"))), line);
}
private void AddChordProgression(IReadOnlyDictionary<string, string> fields, int line)
{
var name = SafeName(GetAny(fields, ["name", "n"], "chords"));
var root = GetFloat(fields, line, 220, "root", "freq", "base");
var scaleIntervals = TryGetAny(fields, ["scale_intervals", "intervals"], out var scaleIntervalsText)
? ParseLooseIntList(scaleIntervalsText, line)
: ScaleIntervals(GetAny(fields, ["scale", "mode"], "minor"));
var progression = TryGetAny(fields, ["progression", "prog", "roots"], out var progressionText)
? ParseLooseIntList(progressionText, line)
: [0, 5, 3, 4];
var voicing = TryGetAny(fields, ["voicing", "degrees"], out var voicingText)
? ParseLooseIntList(voicingText, line)
: [0, 2, 4];
if (progression.Count == 0 || voicing.Count == 0)
{
throw new PatchScriptException(line, "chords need progression and voicing degrees");
}
var stepSeconds = GetMusicalStepSeconds(fields, line, BeatSeconds * BeatsPerBar, "step", "bar", "bar_seconds");
var paths = TryGetAny(fields, ["paths"], out var pathsText)
? ParseNameList(pathsText)
: voicing.Select((_, index) => $"/chords/{name}/{index}").ToArray();
if (paths.Count != voicing.Count)
{
throw new PatchScriptException(line, "chords paths count must match voicing count");
}
for (var voiceIndex = 0; voiceIndex < voicing.Count; voiceIndex++)
{
var values = progression
.Select(rootDegree => ChordToneFrequency(root, scaleIntervals, rootDegree, voicing[voiceIndex]))
.ToArray();
var path = paths[voiceIndex];
EnsureParameter(path, values[0], values.Min() * 0.5f, values.Max() * 2f, 0.01f, "Hz", line);
AddControlCurve(SyntheticFields(
("name", $"{name}_{voiceIndex}"),
("path", path),
("points", PointsText(values.Select((value, index) => (index * stepSeconds, value)))),
("interp", "hold"),
("loop", GetAny(fields, ["loop"], "true")),
("mode", GetAny(fields, ["curve_mode"], "blend"))), line);
}
}
private void AddMixAutomation(IReadOnlyDictionary<string, string> fields, int line)
{
var name = SafeName(RequiredAny(fields, ["name", "n", "lane", "voice", "bus"], line));
var path = GetAny(fields, ["path"], $"/mix/{name}/gain");
if (TryGetAny(fields, ["pattern", "pat", "steps"], out var pattern))
{
AddPatternCurve(SyntheticFields(
("name", $"{name}_mix"),
("path", path),
("pattern", pattern),
("step", GetMusicalStepSeconds(fields, line, BeatSeconds, "step", "step_seconds", "dt").ToString(CultureInfo.InvariantCulture)),
("high", GetAny(fields, ["high", "on"], GetAny(fields, ["gain", "g"], "1"))),
("low", GetAny(fields, ["low", "off"], "0")),
("loop", GetAny(fields, ["loop"], "true"))), line);
return;
}
var points = TryGetAny(fields, ["points", "pts", "values"], out var pointsText)
? pointsText
: $"0:{GetAny(fields, ["gain", "g", "default"], "1")}";
var values = ParseControlCurvePoints(points, line).Select(point => point.Value).ToArray();
EnsureParameter(path, values.Length == 0 ? 1 : values[0], 0, Math.Max(1, values.DefaultIfEmpty(1).Max() * 2), 0.001f, "", line);
AddControlCurve(SyntheticFields(
("name", $"{name}_mix"),
("path", path),
("points", points),
("interp", GetAny(fields, ["interp", "interpolation"], "linear")),
("loop", GetAny(fields, ["loop"], "false")),
("mode", GetAny(fields, ["mode"], "blend"))), line);
}
private void AddNoiseTexture(IReadOnlyDictionary<string, string> fields, int line)
{
var name = SafeName(GetAny(fields, ["name", "n"], $"texture_{Voices.Count}"));
var role = NoiseTextureRole(GetAny(fields, ["role", "kind", "type"], name));
var duration = GetAny(fields, ["sustain", "s", "gate", "hold", "duration"], "30");
var gainText = GetAny(fields, ["gain", "g"], role.Gain.ToString(CultureInfo.InvariantCulture));
var gatePath = GetAny(fields, ["path", "gate_path"], $"/textures/{name}/gate");
if (!gainText.StartsWith('@'))
{
var gainValue = Math.Clamp(ParseFloat(gainText, line), 0, 1);
if (TryGetAny(fields, ["pattern", "pat", "steps"], out var pattern))
{
AddPatternCurve(SyntheticFields(
("name", $"{name}_gate"),
("path", gatePath),
("pattern", pattern),
("step", GetAny(fields, ["step", "step_seconds", "dt"], role.StepSeconds.ToString(CultureInfo.InvariantCulture))),
("high", GetAny(fields, ["high", "on", "hit"], gainValue.ToString(CultureInfo.InvariantCulture))),
("low", GetAny(fields, ["low", "off", "rest"], (gainValue * role.RestScale).ToString(CultureInfo.InvariantCulture))),
("loop", GetAny(fields, ["loop"], "true"))), line);
}
else
{
EnsureParameter(gatePath, gainValue, 0, Math.Max(gainValue * 2, 0.001f), 0.001f, "", line);
AddControlCurve(SyntheticFields(
("name", $"{name}_gate_motion"),
("path", gatePath),
("points", TextureGatePoints(gainValue, role.MotionScale)),
("interp", "linear"),
("loop", "true")), line);
}
gainText = "@" + gatePath;
}
Voices.Add(ParseVoice(ExpandVoiceFields(SyntheticFields(
("wave", "noise"),
("freq", GetAny(fields, ["freq", "frequency"], role.FrequencyHz.ToString(CultureInfo.InvariantCulture))),
("gain", gainText),
("attack", GetAny(fields, ["attack", "a"], role.AttackSeconds.ToString(CultureInfo.InvariantCulture))),
("sustain", duration),
("decay", GetAny(fields, ["decay", "d", "release", "rel"], role.DecaySeconds.ToString(CultureInfo.InvariantCulture))),
("noise", GetAny(fields, ["noise", "nz"], "1")),
("hpf", GetAny(fields, ["hpf", "h"], role.HighPass.ToString(CultureInfo.InvariantCulture))),
("lpf", GetAny(fields, ["lpf", "l"], role.LowPass.ToString(CultureInfo.InvariantCulture))),
("bpf", GetAny(fields, ["bpf", "bp"], role.BandPass.ToString(CultureInfo.InvariantCulture))),
("bpf_q", GetAny(fields, ["bpf_q", "bpq"], role.BandPassQ.ToString(CultureInfo.InvariantCulture))),
("tremolo", GetAny(fields, ["tremolo", "tr"], role.TremoloDepth.ToString(CultureInfo.InvariantCulture))),
("tremolo_hz", GetAny(fields, ["tremolo_hz", "th"], role.TremoloHz.ToString(CultureInfo.InvariantCulture))),
("drive", GetAny(fields, ["drive"], role.Drive.ToString(CultureInfo.InvariantCulture)))), line), VoicePath(Voices.Count), line));
}
private void AddSubKickGrid(IReadOnlyDictionary<string, string> fields, int line)
{
var name = SafeName(GetAny(fields, ["name", "n"], "drums"));
var stepSeconds = GetMusicalStepSeconds(fields, line, BeatSeconds / 4, "step", "step_seconds", "dt");
var kickPath = GetAny(fields, ["kick_path"], $"/seq/{name}/kick");
var snarePath = GetAny(fields, ["snare_path"], $"/seq/{name}/snare");
var hatPath = GetAny(fields, ["hat_path"], $"/seq/{name}/hat");
var kickPattern = GetAny(fields, ["kick", "kick_pattern"], "x...x...x...x...");
var snarePattern = GetAny(fields, ["snare", "snare_pattern"], "....x.......x...");
var hatPattern = GetAny(fields, ["hat", "hat_pattern"], "x.x.x.x.x.x.x.x.");
AddPatternCurve(SyntheticFields(
("name", $"{name}_kick"),
("path", kickPath),
("pattern", kickPattern),
("step", F(stepSeconds)),
("high", GetAny(fields, ["kick_gain", "kg"], "1")),
("low", "0"),
("loop", GetAny(fields, ["loop"], "true"))), line);
AddPatternCurve(SyntheticFields(
("name", $"{name}_snare"),
("path", snarePath),
("pattern", snarePattern),
("step", F(stepSeconds)),
("high", GetAny(fields, ["snare_gain", "sg"], ".8")),
("low", "0"),
("loop", GetAny(fields, ["loop"], "true"))), line);
AddPatternCurve(SyntheticFields(
("name", $"{name}_hat"),
("path", hatPath),
("pattern", hatPattern),
("step", F(stepSeconds)),
("high", GetAny(fields, ["hat_gain", "hg"], ".18")),
("low", GetAny(fields, ["hat_rest", "hr"], ".02")),
("loop", GetAny(fields, ["loop"], "true"))), line);
Voices.Add(ParseVoice(ExpandVoiceFields(SyntheticFields(
("name", $"{name}_kick_body"),
("wave", "sine"),
("freq", GetAny(fields, ["kick_freq", "kf"], "54")),
("gain", "@" + kickPath),
("env", "ad"),
("attack", GetAny(fields, ["kick_attack"], ".002")),
("decay", GetAny(fields, ["kick_decay"], ".14")),
("lpf", GetAny(fields, ["kick_lpf"], ".74")),
("pitch_ramp", GetAny(fields, ["kick_pitch_ramp"], "-2.2"))), line), VoicePath(Voices.Count), line));
Voices.Add(ParseVoice(ExpandVoiceFields(SyntheticFields(
("name", $"{name}_kick_skin"),
("wave", "noise"),
("freq", GetAny(fields, ["kick_skin_freq"], "2600")),
("gain", "@" + kickPath),
("env", "ad"),
("attack", GetAny(fields, ["skin_attack"], ".001")),
("decay", GetAny(fields, ["kick_skin_decay"], ".035")),
("hpf", GetAny(fields, ["kick_skin_hpf"], ".55")),
("lpf", GetAny(fields, ["kick_skin_lpf"], ".35"))), line), VoicePath(Voices.Count), line));
Voices.Add(ParseVoice(ExpandVoiceFields(SyntheticFields(
("name", $"{name}_snare_body"),
("wave", "triangle"),
("freq", GetAny(fields, ["snare_freq", "sf"], "185")),
("gain", "@" + snarePath),
("env", "ad"),
("attack", GetAny(fields, ["snare_attack"], ".001")),
("decay", GetAny(fields, ["snare_decay"], ".11")),
("lpf", GetAny(fields, ["snare_lpf"], ".55"))), line), VoicePath(Voices.Count), line));
Voices.Add(ParseVoice(ExpandVoiceFields(SyntheticFields(
("name", $"{name}_snare_skin"),
("wave", "noise"),
("freq", GetAny(fields, ["snare_skin_freq"], "3400")),
("gain", "@" + snarePath),
("env", "ad"),
("attack", GetAny(fields, ["skin_attack"], ".001")),
("decay", GetAny(fields, ["snare_skin_decay"], ".08")),
("hpf", GetAny(fields, ["snare_skin_hpf"], ".62")),
("lpf", GetAny(fields, ["snare_skin_lpf"], ".42"))), line), VoicePath(Voices.Count), line));
Voices.Add(ParseVoice(ExpandVoiceFields(SyntheticFields(
("name", $"{name}_hat_tick"),
("wave", "noise"),
("freq", GetAny(fields, ["hat_freq", "hf"], "9200")),
("gain", "@" + hatPath),
("env", "ad"),
("attack", GetAny(fields, ["hat_attack"], ".001")),
("decay", GetAny(fields, ["hat_decay"], ".035")),
("hpf", GetAny(fields, ["hat_hpf"], ".88")),
("lpf", GetAny(fields, ["hat_lpf"], ".23"))), line), VoicePath(Voices.Count), line));
}
private void AddDustHat(IReadOnlyDictionary<string, string> fields, int line)
{
var merged = SyntheticFields(
("name", GetAny(fields, ["name", "n"], "dust_hat")),
("role", "dust"),
("pattern", RequiredAny(fields, ["pattern", "pat", "steps"], line)),
("step", GetAny(fields, ["step", "step_seconds", "dt"], F(BeatSeconds / 4))),
("gain", GetAny(fields, ["gain", "g"], ".05")),
("sustain", GetAny(fields, ["sustain", "duration", "hold"], "30")));
Merge(merged, Without(fields, "name", "n", "role", "kind", "type", "pattern", "pat", "steps", "step", "step_seconds", "dt", "gain", "g", "sustain", "duration", "hold"));
AddNoiseTexture(merged, line);
}
private void AddBassResponse(IReadOnlyDictionary<string, string> fields, int line)
{
var name = SafeName(GetAny(fields, ["name", "n"], "bass"));
var gatePath = GetAny(fields, ["gate_path"], $"/seq/{name}/gate");
var mixPath = GetAny(fields, ["mix_path", "path"], $"/mix/{name}/gain");
var chordName = SafeName(GetAny(fields, ["chords", "prog_name"], $"{name}_prog"));
var chordRootPath = GetAny(fields, ["root_path", "freq_path"], $"/chords/{chordName}/root");
AddSequencePattern(SyntheticFields(
("name", $"{name}_gate"),
("path", gatePath),
("pattern", GetAny(fields, ["pattern", "pat", "gate"], "x..x.x...x..xx..")),
("step", GetAny(fields, ["step", "step_seconds", "dt"], F(BeatSeconds / 4))),
("high", GetAny(fields, ["gate_high", "high"], ".6")),
("low", GetAny(fields, ["gate_low", "low"], ".05")),
("loop", GetAny(fields, ["loop"], "true"))), line);
AddChordProgression(SyntheticFields(
("name", chordName),
("root", GetAny(fields, ["root", "freq", "base"], "110")),
("scale", GetAny(fields, ["scale", "mode"], "minor")),
("progression", GetAny(fields, ["progression", "prog"], "0,5,3,4")),
("voicing", "0"),
("paths", chordRootPath),
("step", GetAny(fields, ["chord_step", "bar"], "bar"))), line);
AddMixAutomation(SyntheticFields(
("name", name),
("path", mixPath),
("points", GetAny(fields, ["scene", "points", "pts"], "0:.18,4:.25,8:.14,12:.3,16:.18"))), line);
Voices.Add(ParseVoice(ExpandVoiceFields(SyntheticFields(
("name", $"{name}_body"),
("wave", GetAny(fields, ["wave", "w"], "triangle")),
("freq", "@" + chordRootPath),
("gain", "@" + mixPath),
("attack", GetAny(fields, ["attack", "a"], ".002")),
("sustain", GetAny(fields, ["sustain", "s"], ".09")),
("decay", GetAny(fields, ["decay", "d"], ".22")),
("lpf", GetAny(fields, ["lpf"], ".34")),
("fm", GetAny(fields, ["fm"], "1.8")),
("fmi", GetAny(fields, ["fmi"], ".22")),
("tremolo_hz", GetAny(fields, ["tremolo_hz", "th"], F(1 / Math.Max(BeatSeconds, 0.001f)))),
("tremolo", GetAny(fields, ["tremolo", "tr"], ".08"))), line), VoicePath(Voices.Count), line));
Voices.Add(ParseVoice(ExpandVoiceFields(SyntheticFields(
("name", $"{name}_pulse"),
("wave", GetAny(fields, ["pulse_wave"], "sine")),
("freq", "@" + chordRootPath),
("gain", "@" + gatePath),
("env", "ad"),
("attack", GetAny(fields, ["pulse_attack"], ".001")),
("decay", GetAny(fields, ["pulse_decay"], ".09")),
("lpf", GetAny(fields, ["pulse_lpf"], ".4"))), line), VoicePath(Voices.Count), line));
}
private void AddAdditiveWash(IReadOnlyDictionary<string, string> fields, int line)
{
var name = SafeName(GetAny(fields, ["name", "n"], "wash"));
var gainPath = GetAny(fields, ["mix_path", "path"], $"/mix/{name}/gain");
AddMixAutomation(SyntheticFields(
("name", name),
("path", gainPath),
("points", GetAny(fields, ["scene", "points", "pts"], "0:.08,4:.18,8:.12,12:.24,16:.08"))), line);
if (!_layerDefaults.ContainsKey(name))
{
AddLayer(SyntheticFields(
("name", name),
("engine", GetAny(fields, ["engine", "e"], "add")),
("gain", "@" + gainPath),
("attack", GetAny(fields, ["attack", "a"], ".5")),
("sustain", GetAny(fields, ["sustain", "s"], "30")),
("decay", GetAny(fields, ["decay", "d"], "1.5"))), line);
}
AddHarmonicBank(SyntheticFields(
("layer", name),
("root", GetAny(fields, ["root", "freq", "base"], "220")),
("partials", GetAny(fields, ["partials", "bank"], "1:.12,2:.06,3:.035,5:.018")),
("wave", GetAny(fields, ["wave", "w"], "sine")),
("attack", GetAny(fields, ["attack", "a"], ".5")),
("sustain", GetAny(fields, ["sustain", "s"], "30")),
("decay", GetAny(fields, ["decay", "d"], "1.5")),
("lpf", GetAny(fields, ["lpf"], ".62"))), line);
}
private void AddSectionRise(IReadOnlyDictionary<string, string> fields, int line)
{
var name = SafeName(RequiredAny(fields, ["name", "n", "lane", "voice", "bus"], line));
var start = GetFloat(fields, line, 0, "start", "from");
var peak = GetFloat(fields, line, start + BeatSeconds * BeatsPerBar * 2, "peak", "at");
var end = GetFloat(fields, line, peak + BeatSeconds * BeatsPerBar * 2, "end", "to");
var low = GetFloat(fields, line, .08f, "low", "base");
var high = GetFloat(fields, line, .24f, "high", "top");
AddMixAutomation(SyntheticFields(
("name", name),
("path", GetAny(fields, ["path"], $"/mix/{name}/gain")),
("points", GetAny(fields, ["points", "pts"], $"{F(start)}:{F(low)},{F(peak)}:{F(high)},{F(end)}:{F(low)}")),
("interp", GetAny(fields, ["interp", "interpolation"], "linear")),
("loop", GetAny(fields, ["loop"], "false"))), line);
}
private void EnsureParameter(string path, float defaultValue, float min, float max, float step, string unit, int line)
{
if (_parameters.Any(parameter => parameter.Path.Equals(path, StringComparison.OrdinalIgnoreCase)))
{
return;
}
AddParameter(SyntheticFields(
("path", path),
("default", defaultValue.ToString(CultureInfo.InvariantCulture)),
("min", min.ToString(CultureInfo.InvariantCulture)),
("max", max.ToString(CultureInfo.InvariantCulture)),
("step", step.ToString(CultureInfo.InvariantCulture)),
("unit", unit)), line);
}
private void AddGestureGroup(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_gestureGroups.Any(group => group.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
{
throw new PatchScriptException(line, $"duplicate gesture group `{name}`");
}
var curveNames = ParseNameList(RequiredAny(fields, ["curves", "curve"], line));
if (curveNames.Count == 0)
{
throw new PatchScriptException(line, "gesture group needs at least one curve");
}
foreach (var curveName in curveNames)
{
if (_controlCurves.All(curve => !curve.Name.Equals(curveName, StringComparison.OrdinalIgnoreCase)))
{
throw new PatchScriptException(line, $"gesture group references unknown curve `{curveName}`");
}
}
_gestureGroups.Add(new GestureGroup(
name,
curveNames,
GetFloat(fields, line, 1, "depth", "amount", "mix"),
TryGetAny(fields, ["enabled", "active"], out var enabled) ? ParseBool(enabled, line) : true));
}
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 int GetBoundInt(
IReadOnlyDictionary<string, string> fields,
int line,
int fallback,
string fieldPath,
params string[] keys)
{
if (!TryGetAny(fields, keys, out var value))
{
return fallback;
}
if (value.StartsWith('@'))
{
throw new PatchScriptException(line, $"parameter binding is not supported for integer field `{fieldPath}`");
}
return ParseInt(value, line);
}
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 OwnerField(string ownerPath, string field) => $"{ownerPath}/{field}";
private static string OwnerNameFromFieldPath(string fieldPath)
{
var parts = fieldPath.Split('/', StringSplitOptions.RemoveEmptyEntries);
return parts.Length <= 1 ? fieldPath : string.Join("/", parts.Take(parts.Length - 1));
}
private static string FieldNameFromFieldPath(string fieldPath) =>
fieldPath.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? fieldPath;
private static string SafeName(string value)
{
var chars = value.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_').ToArray();
var safe = new string(chars).Trim('_');
return safe.Length == 0 ? "gesture" : safe;
}
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 == 1 => AdEnvelope(
GetBoundFloat(fields, line, 0, $"{fieldPath}/env/attack", "attack", "a"),
GetBoundFloat(fields, line, 0.1f, $"{fieldPath}/env/decay", "decay", "d", "env_decay", "ed")),
"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 TractAreaFunction? ParseTractAreaFunctionReference(IReadOnlyDictionary<string, string> fields, int line)
{
var hasInlineShape = HasAny(fields, "diameters", "diameter", "areas", "area");
var hasNamedShape = TryGetAny(fields, ["shape", "tract_shape", "area_function"], out var shapeName);
if (hasInlineShape && hasNamedShape)
{
throw new PatchScriptException(line, "tract cannot use both a named shape and inline diameters/areas");
}
if (hasInlineShape)
{
return ParseTractAreaFunction(fields, line, 17);
}
if (!hasNamedShape)
{
return null;
}
if (!_tractShapesByName.TryGetValue(shapeName, out var shape))
{
throw new PatchScriptException(line, $"unknown tract shape `{shapeName}`");
}
return shape.AreaFunction;
}
private AcousticPortNetwork? ParseAcousticNetworkReference(IReadOnlyDictionary<string, string> fields, int line)
{
if (!TryGetAny(fields, ["network", "acoustic_network"], out var networkName))
{
return null;
}
if (!_acousticNetworksByName.TryGetValue(networkName, out var network))
{
if (_vocalNetworksByName.ContainsKey(networkName))
{
return null;
}
throw new PatchScriptException(line, $"unknown acoustic network `{networkName}`");
}
return network;
}
private VocalNetwork? ParseVocalNetworkReference(IReadOnlyDictionary<string, string> fields, int line)
{
if (!TryGetAny(fields, ["network", "vocal_network"], out var networkName))
{
return null;
}
if (!_vocalNetworksByName.TryGetValue(networkName, out var network))
{
return null;
}
return network;
}
private GlottalSource? ParseGlottalSourceReference(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var hasInlineGlottis = HasAny(fields, "aspiration", "breath", "skew", "open_phase");
var hasNamedGlottis = TryGetAny(fields, ["glottis", "source"], out var glottisName);
if (!hasNamedGlottis && !hasInlineGlottis)
{
return null;
}
GlottalSource? namedGlottis = null;
if (hasNamedGlottis && !_glottalSourcesByName.TryGetValue(glottisName, out namedGlottis))
{
throw new PatchScriptException(line, $"unknown glottis `{glottisName}`");
}
var fallback = hasNamedGlottis ? namedGlottis! : new GlottalSource();
return new GlottalSource(
hasNamedGlottis ? glottisName : "",
GetBoundFloat(fields, line, fallback.Intensity, OwnerField(ownerPath, "tract/glottis/intensity"), "intensity", "pressure"),
GetBoundFloat(fields, line, fallback.Tenseness, OwnerField(ownerPath, "tract/glottis/tenseness"), "tenseness", "tense"),
GetBoundFloat(fields, line, fallback.Aspiration, OwnerField(ownerPath, "tract/glottis/aspiration"), "aspiration", "breath"),
GetBoundFloat(fields, line, fallback.Reflection, OwnerField(ownerPath, "tract/glottis/reflection"), "glottal_reflection", "gr"),
GetBoundFloat(fields, line, fallback.Skew, OwnerField(ownerPath, "tract/glottis/skew"), "skew", "open_phase"));
}
private TractInjection? ParseTractInjectionReference(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var hasInlineInjection = HasAny(fields, "burst", "transient", "width");
var hasNamedInjection = TryGetAny(fields, ["injection", "tract_injection"], out var injectionName);
if (!hasNamedInjection && !hasInlineInjection)
{
return null;
}
TractInjection? namedInjection = null;
if (hasNamedInjection && !_tractInjectionsByName.TryGetValue(injectionName, out namedInjection))
{
throw new PatchScriptException(line, $"unknown tract injection `{injectionName}`");
}
var fallback = hasNamedInjection ? namedInjection! : new TractInjection();
return new TractInjection(
hasNamedInjection ? injectionName : "",
GetBoundFloat(fields, line, fallback.Position, OwnerField(ownerPath, "tract/injection/position"), "position", "constriction_index", "ci"),
GetBoundFloat(fields, line, fallback.Diameter, OwnerField(ownerPath, "tract/injection/diameter"), "diameter", "constriction_diameter", "cd"),
GetBoundFloat(fields, line, fallback.Turbulence, OwnerField(ownerPath, "tract/injection/turbulence"), "turbulence", "frication"),
GetBoundFloat(fields, line, fallback.Burst, OwnerField(ownerPath, "tract/injection/burst"), "burst", "transient"),
GetBoundFloat(fields, line, fallback.Width, OwnerField(ownerPath, "tract/injection/width"), "width"));
}
private NasalBranch? ParseNasalBranchReference(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var hasInlineNasal = HasAny(fields, "nose_diameters", "nose_areas", "nose_junction");
var hasNamedNasal = TryGetAny(fields, ["nasal_branch", "nasal_shape"], out var nasalName);
if (!hasNamedNasal && !hasInlineNasal)
{
return null;
}
NasalBranch? namedNasal = null;
if (hasNamedNasal && !_nasalBranchesByName.TryGetValue(nasalName, out namedNasal))
{
throw new PatchScriptException(line, $"unknown nasal branch `{nasalName}`");
}
var fallback = hasNamedNasal ? namedNasal! : new NasalBranch(AreaFunction: new TractAreaFunction([0.01f, 0.6f, 1.2f, 1.6f], 12));
var areaFunction = fallback.AreaFunction;
var lengthCentimeters = GetFloat(fields, line, areaFunction?.LengthCentimeters ?? 12, "length_cm", "length_centimeters");
if (TryGetAny(fields, ["nose_diameters"], out var diameters))
{
areaFunction = new TractAreaFunction(ParseFloatList(diameters, line, "nose_diameters").ToArray(), lengthCentimeters);
}
if (TryGetAny(fields, ["nose_areas"], out var areas))
{
areaFunction = TractAreaFunction.FromAreas(ParseFloatList(areas, line, "nose_areas").ToArray(), lengthCentimeters);
}
return new NasalBranch(
hasNamedNasal ? nasalName : "",
areaFunction,
GetBoundInt(fields, line, fallback.JunctionIndex, OwnerField(ownerPath, "tract/nasal/junction"), "nose_junction", "junction", "junction_index"),
GetBoundFloat(fields, line, fallback.Velum, OwnerField(ownerPath, "tract/nasal/velum"), "velum", "nose", "nasal"),
GetBoundFloat(fields, line, fallback.Reflection, OwnerField(ownerPath, "tract/nasal/reflection"), "nose_reflection"),
GetBoundFloat(fields, line, fallback.Loss, OwnerField(ownerPath, "tract/nasal/loss"), "nose_loss"));
}
private TractMotion? ParseTractMotionReference(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var hasInlineMotion = HasAny(fields, "diameter_slew", "shape_return", "constriction_slew", "velum_slew", "obstruction_threshold");
var hasNamedMotion = TryGetAny(fields, ["motion", "tract_motion"], out var motionName);
if (!hasNamedMotion && !hasInlineMotion)
{
return null;
}
TractMotion? namedMotion = null;
if (hasNamedMotion && !_tractMotionsByName.TryGetValue(motionName, out namedMotion))
{
throw new PatchScriptException(line, $"unknown tract motion `{motionName}`");
}
var fallback = hasNamedMotion ? namedMotion! : new TractMotion();
return new TractMotion(
hasNamedMotion ? motionName : "",
GetBoundFloat(fields, line, fallback.DiameterSlewPerSecond, OwnerField(ownerPath, "tract/motion/diameter_slew"), "diameter_slew", "slew"),
GetBoundFloat(fields, line, fallback.ShapeReturnPerSecond, OwnerField(ownerPath, "tract/motion/shape_return"), "shape_return", "return_slew"),
GetBoundFloat(fields, line, fallback.ConstrictionSlewPerSecond, OwnerField(ownerPath, "tract/motion/constriction_slew"), "constriction_slew"),
GetBoundFloat(fields, line, fallback.VelumSlewPerSecond, OwnerField(ownerPath, "tract/motion/velum_slew"), "velum_slew"),
GetBoundFloat(fields, line, fallback.ObstructionThreshold, OwnerField(ownerPath, "tract/motion/obstruction_threshold"), "obstruction_threshold", "closure"));
}
private TractAreaFunction ParseTractAreaFunction(IReadOnlyDictionary<string, string> fields, int line, float defaultLengthCentimeters)
{
if (TryGetAny(fields, ["area_curve", "curve"], out var curveName))
{
if (!_acousticAreaCurvesByName.TryGetValue(curveName, out var curve))
{
throw new PatchScriptException(line, $"unknown area curve `{curveName}`");
}
return curve.AreaFunction;
}
var hasDiameters = TryGetAny(fields, ["diameters", "diameter"], out var diametersText);
var hasAreas = TryGetAny(fields, ["areas", "area"], out var areasText);
if (hasDiameters == hasAreas)
{
throw new PatchScriptException(line, "tract shape needs exactly one of diameters, areas, or area_curve");
}
var samples = ParseFloatList(hasDiameters ? diametersText! : areasText!, line, hasDiameters ? "diameters" : "areas");
if (samples.Count < 4)
{
throw new PatchScriptException(line, "tract shape needs at least four samples");
}
if (samples.Any(sample => sample < 0))
{
throw new PatchScriptException(line, "tract shape samples must be zero or greater");
}
var lengthCentimeters = GetFloat(fields, line, defaultLengthCentimeters, "length_cm", "length_centimeters");
if (lengthCentimeters <= 0)
{
throw new PatchScriptException(line, "tract shape length_cm must be greater than zero");
}
return hasDiameters
? new TractAreaFunction(samples.ToArray(), lengthCentimeters)
: TractAreaFunction.FromAreas(samples.ToArray(), lengthCentimeters);
}
private static PadSpectrumProfile ParsePadSpectrumProfile(IReadOnlyDictionary<string, string> fields, int line)
{
var hasPadProfileFields =
HasAny(fields,
"pad_mode", "zyn_mode",
"pad_bandwidth", "zyn_bandwidth", "bandwidth",
"pad_bwscale", "zyn_bwscale", "bwscale",
"pad_profile", "zyn_profile",
"pad_position", "zyn_position");
if (!hasPadProfileFields)
{
return PadSpectrumProfile.Generic;
}
var mode = TryGetAny(fields, ["pad_mode", "zyn_mode"], out var modeText)
? ParsePadSpectrumMode(modeText, line)
: PadSpectrumMode.Bandwidth;
var bandwidth = GetInt(fields, line, 500, "pad_bandwidth", "zyn_bandwidth", "bandwidth");
var bandwidthScale = GetInt(fields, line, 0, "pad_bwscale", "zyn_bwscale", "bwscale");
if (bandwidth < 0) throw new PatchScriptException(line, "PAD bandwidth must be zero or greater");
return new PadSpectrumProfile(
mode,
Math.Clamp(bandwidth, 0, 1000),
Math.Clamp(bandwidthScale, 0, 7),
TryGetAny(fields, ["pad_profile", "zyn_profile"], out var profileText)
? ParsePadHarmonicProfile(profileText, line)
: new PadHarmonicProfile(),
TryGetAny(fields, ["pad_position", "zyn_position"], out var positionText)
? ParsePadHarmonicPosition(positionText, line)
: new PadHarmonicPosition());
}
private static PadSpectrumMode ParsePadSpectrumMode(string value, int line) => value.ToLowerInvariant() switch
{
"generic" => PadSpectrumMode.Generic,
"bandwidth" or "zyn" or "zyn_bandwidth" => PadSpectrumMode.Bandwidth,
"discrete" or "other" => PadSpectrumMode.Discrete,
"continuous" => PadSpectrumMode.Continuous,
_ => throw new PatchScriptException(line, $"unknown PAD spectrum mode `{value}`")
};
private static PadHarmonicProfile ParsePadHarmonicProfile(string value, int line)
{
var pieces = value.Split(':', StringSplitOptions.TrimEntries);
if (pieces.Length != 12)
{
throw new PatchScriptException(line, "PAD harmonic profile needs 12 colon-separated fields");
}
return new PadHarmonicProfile(
ParseProfileBaseType(pieces[0], line),
ParseByteish(pieces[1], line),
ParseByteish(pieces[2], line),
ParseByteish(pieces[3], line),
ParseByteish(pieces[4], line),
ParseByteish(pieces[5], line),
ParseProfileAmplitudeType(pieces[6], line),
ParseProfileAmplitudeMode(pieces[7], line),
ParseByteish(pieces[8], line),
ParseByteish(pieces[9], line),
ParseBool(pieces[10], line),
ParseProfileHalf(pieces[11], line));
}
private static PadHarmonicPosition ParsePadHarmonicPosition(string value, int line)
{
var pieces = value.Split(':', StringSplitOptions.TrimEntries);
if (pieces.Length != 4)
{
throw new PatchScriptException(line, "PAD harmonic position needs 4 colon-separated fields");
}
return new PadHarmonicPosition(
ParseHarmonicPositionType(pieces[0], line),
ParseByteish(pieces[1], line, 255),
ParseByteish(pieces[2], line, 255),
ParseByteish(pieces[3], line, 255));
}
private static int ParseByteish(string value, int line, int max = 127)
{
var parsed = ParseInt(value, line);
if (parsed < 0 || parsed > max)
{
throw new PatchScriptException(line, $"PAD profile parameter `{value}` is outside 0..{max}");
}
return parsed;
}
private static PadProfileBaseType ParseProfileBaseType(string value, int line) => value.ToLowerInvariant() switch
{
"0" or "gauss" or "gaussian" => PadProfileBaseType.Gaussian,
"1" or "square" or "rect" or "rectangular" => PadProfileBaseType.Square,
"2" or "double" or "double_exp" or "doubleexponential" => PadProfileBaseType.DoubleExponential,
_ => throw new PatchScriptException(line, $"unknown PAD profile base type `{value}`")
};
private static PadProfileAmplitudeType ParseProfileAmplitudeType(string value, int line) => value.ToLowerInvariant() switch
{
"0" or "off" or "none" => PadProfileAmplitudeType.Off,
"1" or "gauss" or "gaussian" => PadProfileAmplitudeType.Gaussian,
"2" or "sine" => PadProfileAmplitudeType.Sine,
"3" or "flat" => PadProfileAmplitudeType.Flat,
_ => throw new PatchScriptException(line, $"unknown PAD profile amplitude type `{value}`")
};
private static PadProfileAmplitudeMode ParseProfileAmplitudeMode(string value, int line) => value.ToLowerInvariant() switch
{
"0" or "sum" => PadProfileAmplitudeMode.Sum,
"1" or "mult" or "multiply" => PadProfileAmplitudeMode.Mult,
"2" or "div1" => PadProfileAmplitudeMode.Div1,
"3" or "div2" => PadProfileAmplitudeMode.Div2,
_ => throw new PatchScriptException(line, $"unknown PAD profile amplitude mode `{value}`")
};
private static PadProfileHalf ParseProfileHalf(string value, int line) => value.ToLowerInvariant() switch
{
"0" or "full" => PadProfileHalf.Full,
"1" or "upper" => PadProfileHalf.Upper,
"2" or "lower" => PadProfileHalf.Lower,
_ => throw new PatchScriptException(line, $"unknown PAD profile half `{value}`")
};
private static PadHarmonicPositionType ParseHarmonicPositionType(string value, int line) => value.ToLowerInvariant() switch
{
"0" or "harmonic" => PadHarmonicPositionType.Harmonic,
"1" or "shiftu" or "shift_up" => PadHarmonicPositionType.ShiftUp,
"2" or "shiftl" or "shift_down" => PadHarmonicPositionType.ShiftDown,
"3" or "poweru" or "power_up" => PadHarmonicPositionType.PowerUp,
"4" or "powerl" or "power_down" => PadHarmonicPositionType.PowerDown,
"5" or "sine" => PadHarmonicPositionType.Sine,
"6" or "power" => PadHarmonicPositionType.Power,
"7" or "shift" => PadHarmonicPositionType.Shift,
_ => throw new PatchScriptException(line, $"unknown PAD harmonic position type `{value}`")
};
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],
TryGetAny(fields, ["start_level", "start"], out var startText)
? Math.Clamp(ParseFloat(startText, line), 0, 4f)
: 0);
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 RateLevelEnvelope? ParseFilterRateLevelEnvelope(IReadOnlyDictionary<string, string> fields, int line, string ownerPath, string prefix)
{
var envKeys = prefix == "lpf" ? new[] { "lpf_env", "filter_env" } : new[] { "hpf_env" };
if (!TryGetAny(fields, envKeys, out var env) || env is not ("rl" or "ratelevel"))
{
return null;
}
var ratesName = $"{prefix}_rates";
var levelsName = $"{prefix}_levels";
var curvesName = $"{prefix}_curves";
var curveName = $"{prefix}_curve";
var startName = $"{prefix}_start";
var rates = ParseFloatList(Required(fields, ratesName, line), line, ratesName);
var levels = ParseFloatList(Required(fields, levelsName, line), line, levelsName);
if (rates.Count != 4 || levels.Count != 4)
{
throw new PatchScriptException(line, "filter rate/level envelope needs four rates and four levels");
}
var curves = TryGetAny(fields, [curvesName, curveName], out var curveText)
? ParseCurveList(curveText, line)
: [RateLevelCurve.Linear, RateLevelCurve.Linear, RateLevelCurve.Linear, RateLevelCurve.Linear];
if (curves.Count != 4)
{
throw new PatchScriptException(line, "filter rate/level envelope needs four curves");
}
return new RateLevelEnvelope(
Math.Max(0, rates[0]), Math.Clamp(levels[0], -1, 1),
Math.Max(0, rates[1]), Math.Clamp(levels[1], -1, 1),
Math.Max(0, rates[2]), Math.Clamp(levels[2], -1, 1),
Math.Max(0, rates[3]), Math.Clamp(levels[3], -1, 1),
curves[0], curves[1], curves[2], curves[3],
TryGetAny(fields, prefix == "lpf" ? [startName, "filter_start"] : [startName], out var startText)
? Math.Clamp(ParseFloat(startText, line), -1, 1)
: 0);
}
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 float GetMusicalStepSeconds(IReadOnlyDictionary<string, string> fields, int line, float fallback, params string[] keys)
{
if (!TryGetAny(fields, keys, out var value))
{
return fallback;
}
if (value.Equals("bar", StringComparison.OrdinalIgnoreCase) || value.Equals("measure", StringComparison.OrdinalIgnoreCase))
{
return BeatSeconds * BeatsPerBar;
}
if (value.EndsWith("bar", StringComparison.OrdinalIgnoreCase))
{
return BeatSeconds * BeatsPerBar * ParseFloat(value[..^3], line);
}
if (value.EndsWith("beat", StringComparison.OrdinalIgnoreCase))
{
return BeatSeconds * ParseFloat(value[..^4], line);
}
return ParseFloat(value, line);
}
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<FormantFrame> ParseFormantFrames(string value, int line) =>
value.Split('|', StringSplitOptions.RemoveEmptyEntries)
.Select(frame => new FormantFrame(ParseFormants(frame, 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",
"tractshape" or "tract_shape" or "tract_area" => "tract_shape",
"morphology" or "area_function" or "areafunction" => "morphology",
"area_curve" or "areacurve" or "morphology_curve" or "morph_curve" => "area_curve",
"glottis" or "glottal" or "excitation" => "glottis",
"tract_injection" or "injection" or "frication" or "burst" => "tract_injection",
"nasal_branch" or "nose_branch" or "nasal" => "nasal_branch",
"tract_motion" or "shape_motion" or "slew" => "tract_motion",
"path" or "acoustic_path" or "morphology_path" => "acoustic_path",
"waveguide_path" or "waveguidepath" or "wg_path" => "waveguide_path",
"source_port" or "acoustic_source" or "port_source" => "source_port",
"constriction_contact" or "contact_port" or "contact" => "constriction_contact",
"branch_port" => "branch_port",
"radiation_load" or "load_radiation" => "radiation_load",
"probe_timeline" or "flow_probe" or "timeline_probe" => "probe_timeline",
"branch" or "acoustic_branch" => "branch",
"radiation_port" or "radiation" or "acoustic_radiation" => "radiation_port",
"terminal" or "term" or "acoustic_terminal" => "terminal",
"connect" or "connection" or "junction" or "acoustic_connection" => "connection",
"wave_clock" or "waveclock" or "clock" => "wave_clock",
"acoustic_network" or "port_network" or "network" => "acoustic_network",
"vocal_network" or "vocalnetwork" => "vocal_network",
"v" or "voice" => "voice",
"texture" or "noise_texture" or "scene_texture" or "dust" or "air" or "air_wash" or "codec_bed" => "texture",
"acoustic" or "acoustic_voice" or "av" => "acoustic_voice",
"vocal" or "vocal_voice" or "vv" => "vocal_voice",
"tract" or "vt" or "tractvoice" or "tract_voice" => "tract",
"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",
"control_surface" or "surface" or "controlsurface" => "control_surface",
"curve" or "control_curve" or "automation" => "curve",
"pattern" or "pat" or "seq" => "pattern",
"scale" or "notes" or "melody" => "scale",
"meter" or "time" or "time_signature" or "timesig" => "meter",
"sequence" or "voice_seq" or "lane" or "instrument_seq" or "instrument_sequence" => "sequence",
"chord" or "chords" or "progression" or "chord_progression" => "chords",
"mix" or "mixer" or "mix_bus" or "mixbus" => "mix",
"drumkit" or "drum_kit" or "sub_kick_grid" or "sub-kick-grid" => "drumkit",
"dust_hat" or "dust-hat" or "spectral_dust_hat" or "spectral-dust-hat" => "dust_hat",
"bass_response" or "bass-response" or "call_response_bass" or "call-response-bass" => "bass_response",
"additive_wash" or "additive-wash" or "additive_wash_bed" or "additive-wash-bed" or "wash_bed" => "additive_wash",
"section_rise" or "section-rise" or "mix_scene" or "mix-scene" => "section_rise",
"control_spline" or "spline" or "gesture_spline" or "gesturespline" => "control_spline",
"phoneme_gesture" or "phone_gesture" or "ipa_gesture" or "phoneme" or "phone" => "phoneme_gesture",
"gesture" or "gesture_group" or "gesturegroup" => "gesture",
"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 TractPropagationMode ParseTractPropagationMode(string value, int line) => value.ToLowerInvariant() switch
{
"graph" or "acoustic_graph" or "network" => TractPropagationMode.Graph,
"resonator" or "proxy" or "formant" or "waveguide" or "kl" or "kelly-lochbaum" or "kelly_lochbaum" or "tube" =>
throw new PatchScriptException(line, $"legacy tract propagation mode `{value}` has been removed; use `propagation=graph`"),
_ => throw new PatchScriptException(line, $"unknown tract propagation mode `{value}`")
};
private static AcousticSourceKind ParseAcousticSourceKind(string value, int line) => value.ToLowerInvariant() switch
{
"glottal" or "glottis" or "larynx" => AcousticSourceKind.Glottal,
"labial" or "labia" or "syrinx" => AcousticSourceKind.Labial,
"reed" => AcousticSourceKind.Reed,
"turbulence" or "turbulence_jet" or "jet" or "frication" => AcousticSourceKind.TurbulenceJet,
"click" => AcousticSourceKind.Click,
"synthetic" or "synth" or "alien" => AcousticSourceKind.Synthetic,
_ => throw new PatchScriptException(line, $"unknown acoustic source kind `{value}`")
};
private static AcousticSourceModel ParseAcousticSourceModel(string value, AcousticSourceKind kind, int line)
{
if (string.IsNullOrWhiteSpace(value))
{
return kind is AcousticSourceKind.Labial or AcousticSourceKind.Glottal
? AcousticSourceModel.TissueValve
: AcousticSourceModel.Default;
}
return value.ToLowerInvariant() switch
{
"default" or "auto" => kind is AcousticSourceKind.Labial or AcousticSourceKind.Glottal
? AcousticSourceModel.TissueValve
: AcousticSourceModel.Default,
"legacy" or "proxy" => AcousticSourceModel.Legacy,
"tissue_valve" or "tissue-valve" or "valve" or "labial_oscillator" or "labial-oscillator" => AcousticSourceModel.TissueValve,
_ => throw new PatchScriptException(line, $"unknown acoustic source model `{value}`")
};
}
private static AcousticValveLaw ParseAcousticValveLaw(string value, int line) => value.ToLowerInvariant() switch
{
"one" or "1" or "one_mass" or "one-mass" or "single_mass" or "single-mass" => AcousticValveLaw.OneMass,
"two" or "2" or "two_mass" or "two-mass" => AcousticValveLaw.TwoMass,
"body_cover" or "body-cover" or "cover" => AcousticValveLaw.BodyCover,
_ => throw new PatchScriptException(line, $"unknown acoustic valve law `{value}`")
};
private static AcousticBranchKind ParseAcousticBranchKind(string value, int line) => value.ToLowerInvariant() switch
{
"side" or "side_branch" => AcousticBranchKind.SideBranch,
"nasal" or "nose" => AcousticBranchKind.Nasal,
"bronchial" or "bronchus" => AcousticBranchKind.Bronchial,
"lateral" => AcousticBranchKind.Lateral,
"resonator" => AcousticBranchKind.Resonator,
_ => throw new PatchScriptException(line, $"unknown acoustic branch kind `{value}`")
};
private static AcousticRadiationKind ParseAcousticRadiationKind(string value, int line) => value.ToLowerInvariant() switch
{
"lip" or "mouth" => AcousticRadiationKind.Lip,
"nostril" or "nose" => AcousticRadiationKind.Nostril,
"beak" => AcousticRadiationKind.Beak,
"vent" => AcousticRadiationKind.Vent,
"membrane" => AcousticRadiationKind.Membrane,
_ => throw new PatchScriptException(line, $"unknown acoustic radiation kind `{value}`")
};
private static AcousticLossModel ParseAcousticLossModel(string value, int line) => value.ToLowerInvariant() switch
{
"custom" or "default" or "auto" => AcousticLossModel.Custom,
"none" or "ideal" => AcousticLossModel.None,
"viscous" or "viscosity" => AcousticLossModel.Viscous,
"birkholz_2024" or "birkholz-2024" or "birkholz" => AcousticLossModel.Birkholz2024,
"wall" or "wall_loss" or "wall-loss" => AcousticLossModel.Wall,
_ => throw new PatchScriptException(line, $"unknown acoustic loss model `{value}`")
};
private static AcousticRadiationModel ParseAcousticRadiationModel(string value, int line) => value.ToLowerInvariant() switch
{
"simple" or "simple_reflection" or "simple-reflection" or "reflection" => AcousticRadiationModel.SimpleReflection,
"lip_piston" or "lip-piston" or "piston" => AcousticRadiationModel.LipPiston,
"beak" => AcousticRadiationModel.Beak,
"nostril" or "nose" => AcousticRadiationModel.Nostril,
"wall" => AcousticRadiationModel.Wall,
_ => throw new PatchScriptException(line, $"unknown acoustic radiation model `{value}`")
};
private static AcousticTerminalKind ParseAcousticTerminalKind(string value, int line) => value.ToLowerInvariant() switch
{
"junction" or "node" or "scatter" => AcousticTerminalKind.Junction,
"source" or "excitation" => AcousticTerminalKind.Source,
"radiation" or "radiator" or "output" => AcousticTerminalKind.Radiation,
"contact" or "closure" or "obstruction" => AcousticTerminalKind.Contact,
"open" => AcousticTerminalKind.Open,
"closed" or "wall" => AcousticTerminalKind.Closed,
"probe" or "diagnostic" => AcousticTerminalKind.Probe,
_ => throw new PatchScriptException(line, $"unknown acoustic terminal kind `{value}`")
};
private static AcousticConnectionLaw ParseAcousticConnectionLaw(string value, int line) => value.ToLowerInvariant() switch
{
"area" or "area_scatter" or "area_scattering" or "scatter" => AcousticConnectionLaw.AreaScattering,
"pressure" or "pressure_continuity" => AcousticConnectionLaw.PressureContinuity,
"admittance" or "admittance_scatter" or "admittance_scattering" => AcousticConnectionLaw.AdmittanceScattering,
"lossy" or "loss" => AcousticConnectionLaw.Lossy,
"bypass" or "pass" => AcousticConnectionLaw.Bypass,
_ => throw new PatchScriptException(line, $"unknown acoustic connection law `{value}`")
};
private static WaveClockDelayStrategy ParseWaveClockDelayStrategy(string value, int line) => value.ToLowerInvariant() switch
{
"unit" or "unit_grid" or "grid" => WaveClockDelayStrategy.UnitGrid,
"half" or "half_sample" or "half_sample_grid" => WaveClockDelayStrategy.HalfSampleGrid,
"linear" or "fractional_linear" => WaveClockDelayStrategy.FractionalLinear,
"lagrange" or "fractional_lagrange" => WaveClockDelayStrategy.FractionalLagrange,
"thiran" or "allpass" or "fractional_thiran" => WaveClockDelayStrategy.FractionalThiran,
"crossfade" or "crossfaded" or "variable" or "crossfaded_variable" => WaveClockDelayStrategy.CrossfadedVariable,
_ => throw new PatchScriptException(line, $"unknown wave clock strategy `{value}`")
};
private static IReadOnlyList<string> ParseNameList(string value) =>
string.IsNullOrWhiteSpace(value)
? Array.Empty<string>()
: value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private static Dictionary<string, string> SyntheticFields(params (string Key, string Value)[] fields) =>
fields.ToDictionary(field => field.Key, field => field.Value, StringComparer.OrdinalIgnoreCase);
private static string TextureGatePoints(float gain, float motionScale)
{
var low = Math.Max(0, gain * Math.Max(0, 1 - motionScale));
var high = Math.Min(1, gain * (1 + motionScale));
return string.Join(",", new[]
{
(0f, gain),
(0.37f, high),
(0.91f, low),
(1.43f, gain * 0.85f),
(2.11f, high * 0.9f),
(3.0f, gain)
}.Select(point =>
point.Item1.ToString("0.######", CultureInfo.InvariantCulture) +
":" +
point.Item2.ToString("0.######", CultureInfo.InvariantCulture)));
}
private static NoiseTextureDefaults NoiseTextureRole(string role)
{
var normalized = role.ToLowerInvariant().Replace("_", "-", StringComparison.Ordinal);
if (normalized.Contains("dust", StringComparison.Ordinal) || normalized.Contains("hat", StringComparison.Ordinal))
{
return new(7600, 0.075f, 0.001f, 0.09f, 0.78f, 0.42f, 0.66f, 4.5f, 0.22f, 5.0f, 0.16f, 0.08f, 0.35f, 0.125f);
}
if (normalized.Contains("codec", StringComparison.Ordinal) || normalized.Contains("bit", StringComparison.Ordinal))
{
return new(4200, 0.045f, 0.015f, 0.55f, 0.38f, 0.34f, 0.44f, 5.5f, 0.18f, 1.7f, 0.20f, 0.18f, 0.45f, 0.25f);
}
if (normalized.Contains("room", StringComparison.Ordinal) || normalized.Contains("bed", StringComparison.Ordinal))
{
return new(1800, 0.04f, 0.05f, 0.8f, 0.14f, 0.32f, 0.24f, 0.9f, 0.20f, 0.23f, 0.05f, 0.35f, 0.55f, 0.5f);
}
if (normalized.Contains("tape", StringComparison.Ordinal) || normalized.Contains("vinyl", StringComparison.Ordinal))
{
return new(2600, 0.04f, 0.02f, 0.7f, 0.22f, 0.48f, 0.34f, 1.2f, 0.15f, 0.67f, 0.10f, 0.25f, 0.5f, 0.25f);
}
return new(5800, 0.045f, 0.02f, 0.55f, 0.58f, 0.48f, 0.56f, 1.8f, 0.24f, 0.31f, 0.08f, 0.24f, 0.5f, 0.25f);
}
private static string PointsText(IEnumerable<(float Time, float Value)> points) =>
string.Join(",", points.Select(point =>
point.Time.ToString("0.######", CultureInfo.InvariantCulture) +
":" +
point.Value.ToString("0.######", CultureInfo.InvariantCulture)));
private static IReadOnlyList<float> PatternValues(string pattern, float high, float low, int line)
{
var compact = pattern.Replace("\"", "", StringComparison.Ordinal).Replace("'", "", StringComparison.Ordinal);
var values = new List<float>();
foreach (var token in compact.Split([',', ' ', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
foreach (var character in token)
{
values.Add(character switch
{
'x' or 'X' or '1' or '*' => high,
'.' or '-' or '_' or '~' or '0' => low,
_ when char.IsDigit(character) => Math.Clamp((character - '0') / 9f, 0, 1) * (high - low) + low,
_ => throw new PatchScriptException(line, $"bad pattern step `{character}`")
});
}
}
if (values.Count == 0)
{
throw new PatchScriptException(line, "pattern needs at least one step");
}
return values;
}
private static IReadOnlyList<float> ScaleFrequencies(IReadOnlyDictionary<string, string> fields, int line)
{
var root = GetFloat(fields, line, 440, "root", "freq", "base");
var intervals = TryGetAny(fields, ["intervals"], out var intervalsText)
? ParseLooseIntList(intervalsText, line)
: ScaleIntervals(GetAny(fields, ["scale", "mode"], "minor-pentatonic"));
return intervals.Select(interval => root * MathF.Pow(2f, interval / 12f)).ToArray();
}
private static float ChordToneFrequency(float root, IReadOnlyList<int> scaleIntervals, int rootDegree, int voicingDegree)
{
var degree = rootDegree + voicingDegree;
var octave = MathF.Floor(degree / (float)scaleIntervals.Count);
var wrapped = ((degree % scaleIntervals.Count) + scaleIntervals.Count) % scaleIntervals.Count;
return root * MathF.Pow(2f, (scaleIntervals[wrapped] + octave * 12f) / 12f);
}
private static IReadOnlyList<int> ScaleIntervals(string scale) => scale.ToLowerInvariant() switch
{
"major" or "ionian" => [0, 2, 4, 5, 7, 9, 11, 12],
"minor" or "aeolian" => [0, 2, 3, 5, 7, 8, 10, 12],
"pentatonic" or "major-pentatonic" => [0, 2, 4, 7, 9, 12],
"minor-pentatonic" or "minor_pentatonic" => [0, 3, 5, 7, 10, 12],
"minor-pentatonic-plus-tritone" or "minor_pentatonic_plus_tritone" or "alien" => [0, 3, 5, 6, 7, 10, 12],
"chromatic" => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
_ => [0, 3, 5, 7, 10, 12]
};
private static IReadOnlyList<float> ParseLooseFloatList(string value, int line) =>
value.Split([',', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(item => ParseFloat(item, line))
.ToArray();
private static IReadOnlyList<int> ParseLooseIntList(string value, int line) =>
value.Split([',', '|', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(item => ParseInt(item, line))
.ToArray();
private static IReadOnlyList<ControlCurvePoint> ParseControlCurvePoints(string value, int line)
{
var points = value
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part =>
{
var pieces = part.Split(':', StringSplitOptions.TrimEntries);
if (pieces.Length != 2)
{
throw new PatchScriptException(line, $"bad control curve point `{part}`");
}
return new ControlCurvePoint(ParseFloat(pieces[0], line), ParseFloat(pieces[1], line));
})
.OrderBy(point => point.TimeSeconds)
.ToArray();
if (points.Length == 0)
{
throw new PatchScriptException(line, "control curve needs at least one point");
}
if (points[0].TimeSeconds < 0)
{
throw new PatchScriptException(line, "control curve times must be non-negative");
}
for (var i = 1; i < points.Length; i++)
{
if (Math.Abs(points[i].TimeSeconds - points[i - 1].TimeSeconds) < 0.000001f)
{
throw new PatchScriptException(line, "control curve point times must be unique");
}
}
return points;
}
private static IReadOnlyList<ControlSplinePoint> ParseControlSplinePoints(string value, int line)
{
var points = value
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part =>
{
var pieces = part.Split(':', StringSplitOptions.TrimEntries);
if (pieces.Length is not (2 or 4 or 6))
{
throw new PatchScriptException(line, $"bad control spline point `{part}`");
}
return new ControlSplinePoint(
ParseFloat(pieces[0], line),
ParseFloat(pieces[1], line),
pieces.Length >= 4 ? ParseFloat(pieces[2], line) : 0,
pieces.Length >= 4 ? ParseFloat(pieces[3], line) : 0,
pieces.Length >= 6 ? ParseFloat(pieces[4], line) : 0,
pieces.Length >= 6 ? ParseFloat(pieces[5], line) : 0);
})
.OrderBy(point => point.TimeSeconds)
.ToArray();
if (points.Length == 0)
{
throw new PatchScriptException(line, "control spline needs at least one point");
}
return points;
}
private static ControlCurveMode ParseControlCurveMode(string value, int line) => value.ToLowerInvariant() switch
{
"blend" or "replace" or "absolute" => ControlCurveMode.Blend,
"add" or "offset" or "relative" => ControlCurveMode.Add,
_ => throw new PatchScriptException(line, $"unknown control curve mode `{value}`")
};
private static ControlCurveInterpolation ParseControlCurveInterpolation(string value, int line) => value.ToLowerInvariant() switch
{
"linear" or "lin" => ControlCurveInterpolation.Linear,
"hold" or "step" or "sample_hold" => ControlCurveInterpolation.Hold,
"smooth" or "smoothstep" or "ease" or "eased" => ControlCurveInterpolation.Smooth,
_ => throw new PatchScriptException(line, $"unknown control curve interpolation `{value}`")
};
private static ControlSplineInterpolation ParseControlSplineInterpolation(string value, int line) => value.ToLowerInvariant() switch
{
"linear" or "lin" => ControlSplineInterpolation.Linear,
"hold" or "step" or "sample_hold" => ControlSplineInterpolation.Hold,
"bezier" or "cubic" or "spline" => ControlSplineInterpolation.Bezier,
_ => throw new PatchScriptException(line, $"unknown control spline interpolation `{value}`")
};
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 RequiredAny(IReadOnlyDictionary<string, string> fields, string[] keys, int line) =>
TryGetAny(fields, keys, out var value) ? value : throw new PatchScriptException(line, $"missing `{string.Join("|", keys)}`");
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 bool HasAny(IReadOnlyDictionary<string, string> fields, params string[] keys) =>
keys.Select(CanonicalField).Any(fields.ContainsKey);
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 int GetInt(IReadOnlyDictionary<string, string> fields, int line, int fallback, params string[] keys) =>
TryGetAny(fields, keys, out var value) ? ParseInt(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);
}
private sealed record NoiseTextureDefaults(
float FrequencyHz,
float Gain,
float AttackSeconds,
float DecaySeconds,
float HighPass,
float LowPass,
float BandPass,
float BandPassQ,
float TremoloDepth,
float TremoloHz,
float Drive,
float RestScale,
float MotionScale,
float StepSeconds);
}