Add primitive vocal control surfaces and splines

This commit is contained in:
Meta Vens 2026-05-29 20:03:32 +01:00
commit 5e1463fcb9
12 changed files with 884 additions and 30 deletions

View file

@ -0,0 +1,190 @@
namespace AquaSynth.Dsl;
public sealed class ControlSplineTimeline
{
private readonly Dictionary<string, List<ControlSplinePoint>> _pointsBySurface;
public ControlSplineTimeline(IEnumerable<ControlSpline> splines)
{
_pointsBySurface = splines
.Where(spline => spline.Enabled)
.GroupBy(spline => spline.SurfacePath, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group => group
.SelectMany(spline => spline.Points)
.OrderBy(point => point.TimeSeconds)
.ToList(),
StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyList<string> SurfacePaths => _pointsBySurface.Keys.ToArray();
public void SetFuturePoint(string surfacePath, ControlSplinePoint point, float nowSeconds)
{
if (point.TimeSeconds < nowSeconds)
{
throw new ArgumentOutOfRangeException(nameof(point), "future spline edits must not rewrite past control points");
}
if (!_pointsBySurface.TryGetValue(surfacePath, out var points))
{
points = [];
_pointsBySurface[surfacePath] = points;
}
var existing = points.FindIndex(candidate => MathF.Abs(candidate.TimeSeconds - point.TimeSeconds) <= 0.000001f);
if (existing >= 0)
{
points[existing] = point;
}
else
{
points.Add(point);
}
points.Sort((left, right) => left.TimeSeconds.CompareTo(right.TimeSeconds));
}
public float ValueAt(string surfacePath, float timeSeconds, float fallback = 0)
{
if (!_pointsBySurface.TryGetValue(surfacePath, out var points) || points.Count == 0)
{
return fallback;
}
return Evaluate(points, timeSeconds);
}
public static float Evaluate(ControlSpline spline, float timeSeconds, float fallback = 0)
{
if (!spline.Enabled || spline.Points.Count == 0)
{
return fallback;
}
var ordered = spline.Points.OrderBy(point => point.TimeSeconds).ToArray();
if (spline.Loop && ordered.Length > 1)
{
var duration = MathF.Max(0.000001f, ordered[^1].TimeSeconds - ordered[0].TimeSeconds);
timeSeconds = ordered[0].TimeSeconds + PositiveModulo(timeSeconds - ordered[0].TimeSeconds, duration);
}
return spline.Interpolation switch
{
ControlSplineInterpolation.Hold => EvaluateHold(ordered, timeSeconds),
ControlSplineInterpolation.Linear => EvaluateLinear(ordered, timeSeconds),
_ => EvaluateBezier(ordered, timeSeconds)
};
}
private static float Evaluate(IReadOnlyList<ControlSplinePoint> points, float timeSeconds)
{
if (points.Count == 1)
{
return Math.Clamp(points[0].Value, 0, 1);
}
return EvaluateBezier(points, timeSeconds);
}
private static float EvaluateHold(IReadOnlyList<ControlSplinePoint> points, float timeSeconds)
{
var current = points[0];
foreach (var point in points)
{
if (point.TimeSeconds > timeSeconds)
{
break;
}
current = point;
}
return Math.Clamp(current.Value, 0, 1);
}
private static float EvaluateLinear(IReadOnlyList<ControlSplinePoint> points, float timeSeconds)
{
if (timeSeconds <= points[0].TimeSeconds) return Math.Clamp(points[0].Value, 0, 1);
for (var i = 0; i < points.Count - 1; i++)
{
var from = points[i];
var to = points[i + 1];
if (timeSeconds > to.TimeSeconds)
{
continue;
}
var segmentDuration = MathF.Max(0.000001f, to.TimeSeconds - from.TimeSeconds);
var t = Math.Clamp((timeSeconds - from.TimeSeconds) / segmentDuration, 0, 1);
return Math.Clamp(from.Value + (to.Value - from.Value) * t, 0, 1);
}
return Math.Clamp(points[^1].Value, 0, 1);
}
private static float EvaluateBezier(IReadOnlyList<ControlSplinePoint> points, float timeSeconds)
{
if (timeSeconds <= points[0].TimeSeconds) return Math.Clamp(points[0].Value, 0, 1);
for (var i = 0; i < points.Count - 1; i++)
{
var from = points[i];
var to = points[i + 1];
if (timeSeconds > to.TimeSeconds)
{
continue;
}
var t = SolveBezierTime(from, to, timeSeconds);
var value = Cubic(
from.Value,
from.OutValue == 0 ? from.Value : from.OutValue,
to.InValue == 0 ? to.Value : to.InValue,
to.Value,
t);
return Math.Clamp(value, 0, 1);
}
return Math.Clamp(points[^1].Value, 0, 1);
}
private static float SolveBezierTime(ControlSplinePoint from, ControlSplinePoint to, float timeSeconds)
{
var c0 = from.TimeSeconds;
var c1 = from.TimeSeconds + MathF.Max(0, from.OutTimeOffsetSeconds);
var c2 = to.TimeSeconds - MathF.Max(0, to.InTimeOffsetSeconds);
var c3 = to.TimeSeconds;
var low = 0f;
var high = 1f;
var t = Math.Clamp((timeSeconds - from.TimeSeconds) / MathF.Max(0.000001f, to.TimeSeconds - from.TimeSeconds), 0, 1);
for (var i = 0; i < 10; i++)
{
var estimate = Cubic(c0, c1, c2, c3, t);
if (estimate < timeSeconds)
{
low = t;
}
else
{
high = t;
}
t = (low + high) * 0.5f;
}
return t;
}
private static float Cubic(float p0, float p1, float p2, float p3, float t)
{
var u = 1 - t;
return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
}
private static float PositiveModulo(float value, float modulus)
{
var result = value % modulus;
return result < 0 ? result + modulus : result;
}
}

View file

@ -43,6 +43,8 @@ public static class FaustEmitter
source.AppendLine("release_start(a,d,g) = max(g, a + d);");
source.AppendLine("oneshot_adsr(a,d,s,r,g) = select2(age < a, select2(age < a + d, select2(age < release_start(a,d,g), select2(age < release_start(a,d,g) + r, 0.0, s * (1.0 - (age - release_start(a,d,g)) / max(0.0001, r))), s), 1.0 - (1.0 - s) * ((age - a) / max(0.0001, d))), age / max(0.0001, a));");
source.AppendLine("seg(t,t0,d,a,b) = a + (b - a) * clip01((t - t0) / max(0.0001, d));");
source.AppendLine("bez(t,a,b,c,d) = (1.0 - t) * (1.0 - t) * (1.0 - t) * a + 3.0 * (1.0 - t) * (1.0 - t) * t * b + 3.0 * (1.0 - t) * t * t * c + t * t * t * d;");
source.AppendLine("seg_bez(t,t0,d,a,b,c,e) = bez(clip01((t - t0) / max(0.0001, d)), a, b, c, e);");
source.AppendLine("smooth01(x) = clip01(x) * clip01(x) * (3.0 - 2.0 * clip01(x));");
source.AppendLine("seg_smooth(t,t0,d,a,b) = a + (b - a) * smooth01((t - t0) / max(0.0001, d));");
source.AppendLine("seg_exp(t,t0,d,a,b) = exp(log(max(0.00001, a)) + (log(max(0.00001, b)) - log(max(0.00001, a))) * clip01((t - t0) / max(0.0001, d)));");
@ -69,6 +71,12 @@ public static class FaustEmitter
source.AppendLine();
}
EmitControlSurfaces(source, patch, parameters);
if (patch.ControlSurfaces.Count > 0)
{
source.AppendLine();
}
foreach (var (target, name) in ModTargets)
{
source.AppendLine($"patch_mod_{name} = {ModExpressionForTarget(patch.Controls, target)};");
@ -134,6 +142,97 @@ public static class FaustEmitter
}
}
private static void EmitControlSurfaces(StringBuilder source, SynthPatch patch, ParameterMap parameters)
{
for (var i = 0; i < patch.ControlSurfaces.Count; i++)
{
var surface = patch.ControlSurfaces[i];
var id = ControlSurfaceIdentifier(i);
var baseId = $"{id}_base";
var normalized = $"{id}_normalized";
var physical = $"{id}_physical";
var publicControl = $"hslider(\"{Escape(surface.Path)}\", {F(surface.DefaultNormalized)}, 0, 1, 0.001) : si.smoo";
var boundControl = parameters.TryBindingExpression(surface.FieldPath, out var bindingExpression)
? $"clip01(({bindingExpression} - {F(surface.MinValue)}) / max(0.000001, {F(surface.MaxValue - surface.MinValue)}))"
: publicControl;
source.AppendLine($"{baseId} = {boundControl};");
var splines = patch.ControlSplines
.Where(spline => spline.Enabled && spline.SurfacePath.Equals(surface.Path, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (splines.Length == 0)
{
source.AppendLine($"{normalized} = clip01({baseId});");
}
else
{
var terms = new List<string>();
for (var splineIndex = 0; splineIndex < splines.Length; splineIndex++)
{
var spline = splines[splineIndex];
var splineId = $"{id}_spline_{splineIndex}";
EmitControlSpline(source, spline, splineId, surface.DefaultNormalized);
var depth = $"{splineId}_depth";
source.AppendLine($"{depth} = hslider(\"/splines/{Escape(spline.Name)}/depth\", 1, 0, 1, 0.001) : si.smoo;");
terms.Add($"(({splineId}_value - {F(surface.DefaultNormalized)}) * {depth})");
}
source.AppendLine($"{normalized} = clip01({baseId} + {string.Join(" + ", terms)});");
}
source.AppendLine($"{physical} = {F(surface.MinValue)} + ({F(surface.MaxValue - surface.MinValue)}) * {normalized};");
parameters.BindSurface(surface.FieldPath, physical);
}
}
private static void EmitControlSpline(StringBuilder source, ControlSpline spline, string splineId, float fallback)
{
var points = spline.Points.OrderBy(point => point.TimeSeconds).ToArray();
if (points.Length == 0)
{
source.AppendLine($"{splineId}_value = {F(fallback)};");
return;
}
for (var i = 0; i < points.Length; i++)
{
var point = points[i];
source.AppendLine($"{splineId}_p{i}_time = hslider(\"/splines/{Escape(spline.Name)}/{i}/time\", {F(point.TimeSeconds)}, 0, 120, 0.001);");
source.AppendLine($"{splineId}_p{i}_value = hslider(\"/splines/{Escape(spline.Name)}/{i}/value\", {F(Math.Clamp(point.Value, 0, 1))}, 0, 1, 0.001) : si.smoo;");
source.AppendLine($"{splineId}_p{i}_out_time = hslider(\"/splines/{Escape(spline.Name)}/{i}/out_time\", {F(Math.Max(0, point.OutTimeOffsetSeconds))}, 0, 10, 0.001);");
source.AppendLine($"{splineId}_p{i}_out_value = hslider(\"/splines/{Escape(spline.Name)}/{i}/out_value\", {F(point.OutValue == 0 ? point.Value : Math.Clamp(point.OutValue, 0, 1))}, 0, 1, 0.001) : si.smoo;");
source.AppendLine($"{splineId}_p{i}_in_time = hslider(\"/splines/{Escape(spline.Name)}/{i}/in_time\", {F(Math.Max(0, point.InTimeOffsetSeconds))}, 0, 10, 0.001);");
source.AppendLine($"{splineId}_p{i}_in_value = hslider(\"/splines/{Escape(spline.Name)}/{i}/in_value\", {F(point.InValue == 0 ? point.Value : Math.Clamp(point.InValue, 0, 1))}, 0, 1, 0.001) : si.smoo;");
}
if (points.Length == 1)
{
source.AppendLine($"{splineId}_value = {splineId}_p0_value;");
return;
}
var lastPoint = points.Length - 1;
var lastTime = Math.Max(points[^1].TimeSeconds, 0.0001f);
var splineTime = spline.Loop
? $"wrap01(age / {F(lastTime)}) * {F(lastTime)}"
: "age";
var expression = $"{splineId}_p{lastPoint}_value";
for (var i = points.Length - 2; i >= 0; i--)
{
var from = $"{splineId}_p{i}";
var to = $"{splineId}_p{i + 1}";
var segment = spline.Interpolation switch
{
ControlSplineInterpolation.Hold => $"{from}_value",
ControlSplineInterpolation.Linear => $"seg({splineTime}, {from}_time, max(0.0001, {to}_time - {from}_time), {from}_value, {to}_value)",
_ => $"seg_bez({splineTime}, {from}_time, max(0.0001, {to}_time - {from}_time), {from}_value, {from}_out_value, {to}_in_value, {to}_value)"
};
expression = $"select2({splineTime} < {to}_time, {expression}, {segment})";
}
source.AppendLine($"{splineId}_value = clip01({expression});");
}
private static void EmitControlCurve(StringBuilder source, ControlCurve curve, PatchParameter parameter, string curveId)
{
var points = curve.Points
@ -1483,6 +1582,8 @@ public static class FaustEmitter
private static string ParameterIdentifier(int index) => $"patch_param_{index}";
private static string ControlSurfaceIdentifier(int index) => $"control_surface_{index}";
private static string VoicePath(int voiceIndex) => $"/voices/{voiceIndex}";
private static string SpectralPath(int spectralIndex) => $"/spectral/{spectralIndex}";
@ -2356,6 +2457,7 @@ public static class FaustEmitter
{
private readonly Dictionary<string, int> _parameterIndexes = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, ParameterBinding> _bindings = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _surfaceExpressions = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _boundParameterPaths = new(StringComparer.OrdinalIgnoreCase);
public ParameterMap(SynthPatch patch, List<string> warnings)
@ -2388,13 +2490,37 @@ public static class FaustEmitter
public bool IsBound(string fieldPath) => _bindings.ContainsKey(fieldPath);
public void BindSurface(string fieldPath, string expression) => _surfaceExpressions[fieldPath] = expression;
public bool TryBindingExpression(string fieldPath, out string expression)
{
if (!_bindings.TryGetValue(fieldPath, out var binding))
{
expression = "";
return false;
}
expression = BindingExpression(binding);
return true;
}
public string Expression(string fieldPath, float fallback)
{
if (_surfaceExpressions.TryGetValue(fieldPath, out var surfaceExpression))
{
return surfaceExpression;
}
if (!_bindings.TryGetValue(fieldPath, out var binding))
{
return F(fallback);
}
return BindingExpression(binding);
}
private string BindingExpression(ParameterBinding binding)
{
var parameter = ParameterIdentifier(_parameterIndexes[binding.ParameterPath]);
var transformed = binding.Transform switch
{

View file

@ -501,6 +501,44 @@ public sealed record ProbeTimeline(string Name, IReadOnlyList<string>? Networks
public IReadOnlyList<string> Networks { get; init; } = Networks ?? Array.Empty<string>();
}
public sealed record ControlSurface(
string Path,
string FieldPath,
string Owner,
string Field,
float DefaultNormalized,
float MinValue,
float MaxValue,
string Label = "",
string Unit = "",
string AutomationRate = "control");
public enum ControlSplineInterpolation
{
Linear,
Hold,
Bezier
}
public sealed record ControlSplinePoint(
float TimeSeconds,
float Value,
float OutTimeOffsetSeconds = 0,
float OutValue = 0,
float InTimeOffsetSeconds = 0,
float InValue = 0);
public sealed record ControlSpline(
string Name,
string SurfacePath,
IReadOnlyList<ControlSplinePoint> Points,
ControlSplineInterpolation Interpolation = ControlSplineInterpolation.Bezier,
bool Loop = false,
bool Enabled = true)
{
public IReadOnlyList<ControlSplinePoint> Points { get; init; } = Points ?? Array.Empty<ControlSplinePoint>();
}
public sealed record VocalNetwork(
string Name,
IReadOnlyList<string>? Paths = null,
@ -867,6 +905,8 @@ public sealed record SynthPatch
public IReadOnlyList<ControlLane> Controls { get; init; } = Array.Empty<ControlLane>();
public IReadOnlyList<PatchParameter> Parameters { get; init; } = Array.Empty<PatchParameter>();
public IReadOnlyList<ControlCurve> ControlCurves { get; init; } = Array.Empty<ControlCurve>();
public IReadOnlyList<ControlSurface> ControlSurfaces { get; init; } = Array.Empty<ControlSurface>();
public IReadOnlyList<ControlSpline> ControlSplines { get; init; } = Array.Empty<ControlSpline>();
public IReadOnlyList<GestureGroup> GestureGroups { get; init; } = Array.Empty<GestureGroup>();
public IReadOnlyList<ParameterBinding> ParameterBindings { get; init; } = Array.Empty<ParameterBinding>();
public Playback Playback { get; init; } = new();

View file

@ -34,6 +34,10 @@ public static class PatchScript
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<GestureGroup> _gestureGroups = [];
private readonly List<ParameterBinding> _parameterBindings = [];
private readonly List<PatchLayer> _layers = [];
@ -130,6 +134,8 @@ public static class PatchScript
Controls = _controls,
Parameters = _parameters,
ControlCurves = _controlCurves,
ControlSurfaces = _controlSurfaces,
ControlSplines = _controlSplines,
GestureGroups = _gestureGroups,
ParameterBindings = _parameterBindings,
Playback = Playback,
@ -303,13 +309,28 @@ public static class PatchScript
FlushPendingOperatorGraph();
AddParameter(fields, line);
break;
case "control_surface":
FlushPendingOperatorGraph();
AddControlSurface(fields, line);
break;
case "curve":
FlushPendingOperatorGraph();
AddControlCurve(fields, line);
break;
case "control_spline":
FlushPendingOperatorGraph();
AddControlSpline(fields, line);
break;
case "gesture":
FlushPendingOperatorGraph();
AddGestureGroup(fields, line);
if (HasAny(fields, "surface", "surface_path", "target"))
{
AddControlSpline(fields, line);
}
else
{
AddGestureGroup(fields, line);
}
break;
case "sfxr":
FlushPendingOperatorGraph();
@ -629,6 +650,7 @@ public static class PatchScript
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)
@ -655,6 +677,8 @@ public static class PatchScript
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) =>
@ -785,6 +809,7 @@ public static class PatchScript
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)
@ -811,6 +836,7 @@ public static class PatchScript
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)
@ -835,6 +861,7 @@ public static class PatchScript
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)
@ -861,6 +888,7 @@ public static class PatchScript
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)
@ -1167,6 +1195,131 @@ public static class PatchScript
_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 AddAcousticConnectionRecord(AcousticConnection connection)
{
if (_acousticConnectionsByName.ContainsKey(connection.Name)) return;
@ -1665,6 +1818,7 @@ public static class PatchScript
{
_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");
@ -1679,6 +1833,8 @@ public static class PatchScript
{
_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");
@ -1699,6 +1855,7 @@ public static class PatchScript
{
_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");
@ -1724,6 +1881,7 @@ public static class PatchScript
{
_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");
@ -1744,6 +1902,8 @@ public static class PatchScript
{
_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;
@ -1758,6 +1918,16 @@ public static class PatchScript
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);
@ -1777,6 +1947,7 @@ public static class PatchScript
{
_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");
@ -2061,6 +2232,67 @@ public static class PatchScript
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 AddControlCurve(IReadOnlyDictionary<string, string> fields, int line)
{
var parameterPath = Required(fields, "path", line);
@ -2178,6 +2410,15 @@ public static class PatchScript
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 List<OperatorNode> ParseOperatorNodes(string value, int line) =>
value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part =>
@ -2915,7 +3156,9 @@ public static class PatchScript
"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",
"control_spline" or "spline" or "gesture_spline" or "gesturespline" => "control_spline",
"gesture" or "gesture_group" or "gesturegroup" => "gesture",
"s" or "sfxr" => "sfxr",
_ => command
@ -3159,6 +3402,37 @@ public static class PatchScript
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,
@ -3174,6 +3448,14 @@ public static class PatchScript
_ => 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}`");

View file

@ -16,25 +16,35 @@ public static class ProbeTimelineReport
var contacts = patch.ConstrictionContacts.ToDictionary(contact => contact.Name, StringComparer.OrdinalIgnoreCase);
var branches = patch.BranchPorts.ToDictionary(branch => branch.Name, StringComparer.OrdinalIgnoreCase);
var radiation = patch.RadiationLoads.ToDictionary(load => load.Name, StringComparer.OrdinalIgnoreCase);
var surfacesByField = patch.ControlSurfaces.ToDictionary(surface => surface.FieldPath, StringComparer.OrdinalIgnoreCase);
var splinesBySurface = patch.ControlSplines
.Where(spline => spline.Enabled)
.GroupBy(spline => spline.SurfacePath, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase);
var samples = new List<ProbeTimelineSample>();
var blockSize = patch.ProbeTimelines.FirstOrDefault(probe => probe.Name.Equals(network.ProbeTimeline, StringComparison.OrdinalIgnoreCase))?.BlockSize ?? 64;
for (var block = 0; block < Math.Max(1, blocks); block++)
{
var timeSeconds = block * blockSize / 48000f;
var outgoingByPath = new Dictionary<string, float>(StringComparer.OrdinalIgnoreCase);
foreach (var pathName in network.Paths)
{
var path = paths[pathName];
var area = areas[path.AreaFunction];
var pathArea = area.Shape.Areas.Count == 0 ? 0 : area.Shape.Areas.Average();
var delay = area.Shape.LengthMeters / Math.Max(1, path.PropagationSpeedMetersPerSecond) * 48000;
var incoming = PathIncomingFlow(network, sources, contacts, branches, path.Name);
var outgoing = incoming * Math.Clamp(path.Loss, 0, 1);
var pathIndex = PathIndex(patch.WaveguidePaths, path);
var speed = SurfaceValue(surfacesByField, splinesBySurface, $"/vocal/paths/{pathIndex}/speed", path.PropagationSpeedMetersPerSecond, timeSeconds);
var loss = SurfaceValue(surfacesByField, splinesBySurface, $"/vocal/paths/{pathIndex}/loss", path.Loss, timeSeconds);
var delay = area.Shape.LengthMeters / Math.Max(1, speed) * 48000;
var incoming = PathIncomingFlow(patch, network, sources, contacts, branches, path.Name, surfacesByField, splinesBySurface, timeSeconds);
var outgoing = incoming * Math.Clamp(loss, 0, 1);
outgoingByPath[path.Name] = outgoing;
var energyIn = pathArea * incoming * incoming;
var energyOut = pathArea * outgoing * outgoing;
Add(samples, block, $"path:{path.Name}", "area", pathArea);
Add(samples, block, $"path:{path.Name}", "delay_samples", delay);
Add(samples, block, $"path:{path.Name}", "loss", path.Loss);
Add(samples, block, $"path:{path.Name}", "loss", loss);
Add(samples, block, $"path:{path.Name}", "incoming_wave", incoming);
Add(samples, block, $"path:{path.Name}", "outgoing_wave", outgoing);
Add(samples, block, $"path:{path.Name}", "energy_in", energyIn);
@ -45,27 +55,40 @@ public static class ProbeTimelineReport
foreach (var sourceName in network.Sources)
{
var source = sources[sourceName];
var flow = SourceFlow(source);
Add(samples, block, $"source:{source.Name}", "load_pressure", source.Pressure * source.Impedance);
Add(samples, block, $"source:{source.Name}", "flow_scale", source.FlowScale);
var sourceIndex = PathIndex(patch.SourcePorts, source);
var owner = $"/vocal/sources/{sourceIndex}";
var pressure = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/pressure", source.Pressure, timeSeconds);
var impedance = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/impedance", source.Impedance, timeSeconds);
var flowScale = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/flow_scale", source.FlowScale, timeSeconds);
var flow = SourceFlow(patch, source, surfacesByField, splinesBySurface, timeSeconds);
Add(samples, block, $"source:{source.Name}", "load_pressure", pressure * impedance);
Add(samples, block, $"source:{source.Name}", "flow_scale", flowScale);
Add(samples, block, $"source:{source.Name}", "flow", flow);
}
foreach (var contactName in network.Contacts)
{
var contact = contacts[contactName];
var opening = Math.Clamp(contact.Opening, 0, 1);
var reservoir = contact.StoredPressure + (1 - opening) * contact.Resistance;
var contactIndex = PathIndex(patch.ConstrictionContacts, contact);
var owner = $"/vocal/contacts/{contactIndex}";
var opening = Math.Clamp(SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/opening", contact.Opening, timeSeconds), 0, 1);
var resistance = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/resistance", contact.Resistance, timeSeconds);
var stored = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/stored_pressure", contact.StoredPressure, timeSeconds);
var reservoir = stored + (1 - opening) * resistance;
Add(samples, block, $"contact:{contact.Name}", "opening", opening);
Add(samples, block, $"contact:{contact.Name}", "resistance", contact.Resistance);
Add(samples, block, $"contact:{contact.Name}", "resistance", resistance);
Add(samples, block, $"contact:{contact.Name}", "reservoir", reservoir);
Add(samples, block, $"contact:{contact.Name}", "released_flow", reservoir * opening * (1 - Math.Min(0.95f, contact.Resistance * 0.5f)));
Add(samples, block, $"contact:{contact.Name}", "released_flow", reservoir * opening * (1 - Math.Min(0.95f, resistance * 0.5f)));
}
foreach (var branchName in network.Branches)
{
var branch = branches[branchName];
var admittance = Math.Clamp(branch.Opening, 0, 1) * Math.Clamp(branch.Coupling, 0, 1);
var branchIndex = PathIndex(patch.BranchPorts, branch);
var owner = $"/vocal/branches/{branchIndex}";
var opening = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/opening", branch.Opening, timeSeconds);
var coupling = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/coupling", branch.Coupling, timeSeconds);
var admittance = Math.Clamp(opening, 0, 1) * Math.Clamp(coupling, 0, 1);
Add(samples, block, $"branch:{branch.Name}", "admittance", admittance);
Add(samples, block, $"branch:{branch.Name}", "exchanged_flow", -admittance * 0.05f);
}
@ -73,10 +96,14 @@ public static class ProbeTimelineReport
foreach (var loadName in network.Radiation)
{
var load = radiation[loadName];
var aperture = Math.Clamp(load.Aperture, 0, 1);
var radiationIndex = PathIndex(patch.RadiationLoads, load);
var owner = $"/vocal/radiation/{radiationIndex}";
var aperture = Math.Clamp(SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/aperture", load.Aperture, timeSeconds), 0, 1);
var impedance = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/impedance", load.Impedance, timeSeconds);
var reflection = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/reflection", load.Reflection, timeSeconds);
var pathWave = outgoingByPath.GetValueOrDefault(load.Path, 0);
var flow = pathWave * aperture / Math.Max(0.05f, load.Impedance);
Add(samples, block, $"radiation:{load.Name}", "reflection", load.Reflection);
var flow = pathWave * aperture / Math.Max(0.05f, impedance);
Add(samples, block, $"radiation:{load.Name}", "reflection", reflection);
Add(samples, block, $"radiation:{load.Name}", "boundary_flow", flow);
Add(samples, block, $"radiation:{load.Name}", "flow", flow);
Add(samples, block, $"radiation:{load.Name}", "output", flow);
@ -108,34 +135,102 @@ public static class ProbeTimelineReport
samples.Add(new ProbeTimelineSample(block, primitive, signal, value));
private static float PathIncomingFlow(
SynthPatch patch,
VocalNetwork network,
IReadOnlyDictionary<string, SourcePort> sources,
IReadOnlyDictionary<string, ConstrictionContact> contacts,
IReadOnlyDictionary<string, BranchPort> branches,
string pathName)
string pathName,
IReadOnlyDictionary<string, ControlSurface> surfacesByField,
IReadOnlyDictionary<string, ControlSpline[]> splinesBySurface,
float timeSeconds)
{
var sourceFlow = network.Sources
.Select(name => sources[name])
.Where(source => source.Path.Equals(pathName, StringComparison.OrdinalIgnoreCase))
.Sum(SourceFlow);
.Sum(source => SourceFlow(patch, source, surfacesByField, splinesBySurface, timeSeconds));
var contactFlow = network.Contacts
.Select(name => contacts[name])
.Where(contact => contact.Path.Equals(pathName, StringComparison.OrdinalIgnoreCase))
.Sum(contact =>
{
var opening = Math.Clamp(contact.Opening, 0, 1);
var reservoir = contact.StoredPressure + (1 - opening) * contact.Resistance;
return reservoir * opening * (1 - Math.Min(0.95f, contact.Resistance * 0.5f));
var contactIndex = PathIndex(patch.ConstrictionContacts, contact);
var owner = $"/vocal/contacts/{contactIndex}";
var opening = Math.Clamp(SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/opening", contact.Opening, timeSeconds), 0, 1);
var resistance = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/resistance", contact.Resistance, timeSeconds);
var stored = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/stored_pressure", contact.StoredPressure, timeSeconds);
var reservoir = stored + (1 - opening) * resistance;
return reservoir * opening * (1 - Math.Min(0.95f, resistance * 0.5f));
});
var branchFlow = network.Branches
.Select(name => branches[name])
.Where(branch => branch.FromPath.Equals(pathName, StringComparison.OrdinalIgnoreCase))
.Sum(branch => -Math.Clamp(branch.Opening, 0, 1) * Math.Clamp(branch.Coupling, 0, 1) * 0.05f);
.Sum(branch =>
{
var branchIndex = PathIndex(patch.BranchPorts, branch);
var owner = $"/vocal/branches/{branchIndex}";
var opening = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/opening", branch.Opening, timeSeconds);
var coupling = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/coupling", branch.Coupling, timeSeconds);
return -Math.Clamp(opening, 0, 1) * Math.Clamp(coupling, 0, 1) * 0.05f;
});
return sourceFlow + contactFlow + branchFlow;
}
private static float SourceFlow(SourcePort source) =>
source.FlowScale * MathF.Tanh(source.Pressure * Math.Max(0, source.Opening) * (0.5f + source.Tension) / Math.Max(0.05f, source.Impedance));
private static float SourceFlow(
SynthPatch patch,
SourcePort source,
IReadOnlyDictionary<string, ControlSurface> surfacesByField,
IReadOnlyDictionary<string, ControlSpline[]> splinesBySurface,
float timeSeconds)
{
var sourceIndex = PathIndex(patch.SourcePorts, source);
var owner = $"/vocal/sources/{sourceIndex}";
var pressure = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/pressure", source.Pressure, timeSeconds);
var opening = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/opening", source.Opening, timeSeconds);
var tension = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/tension", source.Tension, timeSeconds);
var impedance = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/impedance", source.Impedance, timeSeconds);
var flowScale = SurfaceValue(surfacesByField, splinesBySurface, $"{owner}/flow_scale", source.FlowScale, timeSeconds);
return flowScale * MathF.Tanh(pressure * Math.Max(0, opening) * (0.5f + tension) / Math.Max(0.05f, impedance));
}
private static float SurfaceValue(
IReadOnlyDictionary<string, ControlSurface> surfacesByField,
IReadOnlyDictionary<string, ControlSpline[]> splinesBySurface,
string fieldPath,
float fallback,
float timeSeconds)
{
if (!surfacesByField.TryGetValue(fieldPath, out var surface))
{
return fallback;
}
var normalized = surface.DefaultNormalized;
if (splinesBySurface.TryGetValue(surface.Path, out var splines))
{
foreach (var spline in splines)
{
normalized += ControlSplineTimeline.Evaluate(spline, timeSeconds, surface.DefaultNormalized) - surface.DefaultNormalized;
}
}
normalized = Math.Clamp(normalized, 0, 1);
return surface.MinValue + (surface.MaxValue - surface.MinValue) * normalized;
}
private static int PathIndex<T>(IReadOnlyList<T> items, T item)
where T : class
{
for (var i = 0; i < items.Count; i++)
{
if (ReferenceEquals(items[i], item) || EqualityComparer<T>.Default.Equals(items[i], item))
{
return i;
}
}
return -1;
}
private static string Escape(string value) =>
value.Contains(',') || value.Contains('"') ? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\"" : value;