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

@ -169,6 +169,12 @@ Create or document the target primitives before adding another acoustic law:
- `BranchPort`: side-port admittance and coupling.
- `RadiationLoad`: boundary reflection, aperture, impedance, and radiated flow.
- `ProbeTimeline`: dev/test-only sampling of the primitive state above.
- `ControlSurface`: the public normalized control for a primitive field. The
primitive owns the physical law; the surface owns the runtime knob and maps
`0..1` into the primitive field's physical range.
- `ControlSpline`: the gesture timeline over a `ControlSurface`. It owns
future-time motion, including linear, hold, and Bezier segments. Primitives do
not hide local slew.
If an existing record cannot be mapped onto one of these owners, it should be
deleted, demoted to authoring sugar, or kept only as a compatibility shim that
@ -250,9 +256,35 @@ PT/SndKit comparison report:
The next implementation pass should harden the primitives further:
- decide where gesture slew lives: inside primitive ports or in a separate
gesture/timeline owner;
- harden articulator/area spline ownership now that velopharynx motion lowers
through `ControlSpline` instead of primitive-local smoothing;
- add optional VTL/ArtiSynth adapters only as artifact comparators, not default
CI dependencies;
- keep `mama`, `papa`, and `thrombosis` as downstream witnesses until primitive
flow evidence improves.
## Gesture Surface Cut
The separate gesture/timeline owner is now the live direction:
- Primitive vocal lowering auto-publishes normalized `ControlSurface` records
for path speed/loss, area deformation fields, source pressure/tension/opening,
contact opening/resistance/storage, branch opening/coupling, and radiation
aperture/reflection/impedance.
- `control_surface` can declare an explicit surface, and `control_spline`
or `gesture surface=...` can attach linear, hold, or Bezier point timelines.
- Faust lowering emits normalized surface controls and spline point controls
under `/splines/<name>/<point>/...`, then maps the effective normalized value
back into the primitive's physical field.
- Explicit old `@/param` field bindings remain compatibility base controls;
spline deltas apply on top of that base instead of bypassing it.
- `tract_motion` now adapts velopharynx opening into a branch `ControlSpline`.
A generated contact-opening spline was tested and cut because PT evidence
showed it invented an open-then-close transient; contact closure must come
from articulator/area control, not a compensating contact-port story.
Latest focused evidence: `PatchScriptTests` pass 90/90, and
`PinkTrombonePrimitiveTimelineComparisonWritesArtifacts` writes artifact
`artifacts/parity/primitive-vocal-timeline/20260529T185955965`, where
`closure-release` returns to `0.155925` mean absolute primitive timeline error
after removing the bogus contact spline.

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;

View file

@ -231,3 +231,4 @@
{"date":"2026-05-29","area":"primitive-vocal-reference-timeline","summary":"PT/SndKit local reference internals are now observable through `PinkTromboneReferenceRenderer.RenderTimeline`, exposing oral/nasal path waves and energy, velopharynx branch reflections/admittance, obstruction contact state, and lip radiation flow/output from the renderer's own arrays. `PrimitiveReferenceReport.ComparePinkTromboneTimeline` role-normalizes Aqua primitive timeline samples against PT timeline samples. PatchScriptTests pass 88/88.","evidence":"src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs; src/AquaSynth.Core/PrimitiveReferenceReport.cs; tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs; docs/acoustic-graph-proprioception.md"}
{"date":"2026-05-29","area":"primitive-vocal-artifacts","summary":"Added a PT fixture-suite primitive timeline artifact test. Each fixture writes Aqua primitive timeline CSV, PT internal timeline CSV, role-normalized comparison CSV, and a summary under `artifacts/parity/primitive-vocal-timeline/<timestamp>`. The focused artifact test passes without native Faust or external reference binaries.","evidence":"tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs; src/AquaSynth.Core/PinkTromboneReferenceRenderer.cs; src/AquaSynth.Core/PrimitiveReferenceReport.cs; docs/acoustic-graph-proprioception.md"}
{"date":"2026-05-29","area":"primitive-vocal-law-correction","summary":"Used primitive timeline artifacts to make the first non-fork physical-law correction. `SourcePort` now owns `FlowScale` defaulting to `.02`; primitive radiation reflection now uses declared `RadiationLoad.Reflection` directly; `ProbeTimelineReport` radiation flow now depends on outgoing path wave like Faust. The focused primitive/artifact tests and PatchScriptTests pass. New artifact `artifacts/parity/primitive-vocal-timeline/20260529T155613895` reduces fixture-suite mean absolute timeline error from roughly `1.2-1.3` to `0.15-0.27`. Remaining drift points to branch/contact motion ownership.","evidence":"src/AquaSynth.Core/Model.cs; src/AquaSynth.Core/PatchScript.cs; src/AquaSynth.Core/FaustExport.cs; src/AquaSynth.Core/ProbeTimelineReport.cs; artifacts/parity/primitive-vocal-timeline/20260529T155613895; tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs; tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs"}
{"date":"2026-05-29","area":"primitive-vocal-gesture-surfaces","summary":"Gesture motion now has a separate owner. `ControlSurface` exposes normalized primitive controls, `ControlSpline` owns linear/hold/Bezier future-time motion, Faust emits surface and spline point controls, and `ProbeTimelineReport` evaluates spline-driven primitive fields before audio parity. `tract_motion` adapts velopharynx opening into a branch spline. A generated contact-opening spline was tested and cut because PT timeline evidence showed it invented an open-then-close transient; contact closure must come from articulator/area ownership, not primitive-local smoothing. PatchScriptTests pass 90/90 and primitive timeline artifact `20260529T185955965` keeps closure-release mean absolute timeline error at `0.155925`.","evidence":"src/AquaSynth.Core/Model.cs; src/AquaSynth.Core/ControlSplineTimeline.cs; src/AquaSynth.Core/PatchScript.cs; src/AquaSynth.Core/FaustExport.cs; src/AquaSynth.Core/ProbeTimelineReport.cs; tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs; artifacts/parity/primitive-vocal-timeline/20260529T185955965; docs/acoustic-graph-proprioception.md"}

View file

@ -56,6 +56,7 @@ modules:
primitive_vocal_model: src/AquaSynth.Core/Model.cs
primitive_vocal_lowering: src/AquaSynth.Core/FaustExport.cs
primitive_vocal_timeline: src/AquaSynth.Core/ProbeTimelineReport.cs
primitive_control_spline_timeline: src/AquaSynth.Core/ControlSplineTimeline.cs
primitive_vocal_reference_report: src/AquaSynth.Core/PrimitiveReferenceReport.cs
acoustic_graph_missing_pieces_audit: docs/acoustic-graph-missing-pieces-audit.md
acoustic_graph_proprioception: docs/acoustic-graph-proprioception.md

View file

@ -51,7 +51,7 @@
"generated DSP artifact manifests for engine hosting",
"live patch compilation, native Faust factory lifetime, and offline sample-buffer render proof for engine hosts",
"phonetic intent, articulatory plan, morphology capability, and constraint-report contracts for vocal tract synthesis",
"Pink Trombone fixture path through primitive vocal DSL records: AreaFunction, WaveguidePath, SourcePort, ConstrictionContact, BranchPort, RadiationLoad, ProbeTimeline, and VocalNetwork; tract_shape, glottis, tract_injection, nasal_branch, and tract are adapters, not DSP authority",
"Pink Trombone fixture path through primitive vocal DSL records: AreaFunction, WaveguidePath, SourcePort, ConstrictionContact, BranchPort, RadiationLoad, ProbeTimeline, ControlSurface, ControlSpline, and VocalNetwork; tract_shape, glottis, tract_injection, nasal_branch, tract_motion, and tract are adapters, not DSP authority",
"utterance parity curriculum research and docs",
"patch script parser",
"SFXR atoms and parameter mapping",
@ -91,7 +91,7 @@
"Surge XT for modern hybrid synth modulation",
"Cardinal/VCV-style patches for modular cable/rate semantics"
],
"rule": "For speech, add schema fields and learned-control lanes only when the parity harness has a loss surface that can exercise them. For reference synths, add DSL abstractions only when rebuilds prove the missing invariant. Parsing alone is inventory, not evidence of expressivity. Pink Trombone should be decomposed into reusable low-level Aqua primitives before audio golf, not copied as a bespoke clone or inflated into PT-shaped modules. After the 2026-05-29 proprioception correction, collapse graph-vocal lowering into a small set of instrumented physical primitives before more utterance golf."
"rule": "For speech, add schema fields and learned-control lanes only when the parity harness has a loss surface that can exercise them. For reference synths, add DSL abstractions only when rebuilds prove the missing invariant. Parsing alone is inventory, not evidence of expressivity. Pink Trombone should be decomposed into reusable low-level Aqua primitives before audio golf, not copied as a bespoke clone or inflated into PT-shaped modules. After the 2026-05-29 proprioception correction, collapse graph-vocal lowering into a small set of instrumented physical primitives before more utterance golf. Gesture motion belongs in ControlSurface/ControlSpline timeline ownership, not primitive-local smoothing; cut any generated contact/port spline that invents motion not supported by primitive reference timelines."
},
"ipa_vocal_tract_path": {
"status": "This is now the active project center. Roadmap and research notes align the curriculum around structured utterance metadata -> learned utterance embedding -> learned synth automation -> vocal tract/envelope/LFO/filter/spectral controls -> rendered audio loss. The first Core contracts still defend the boundary: PhoneticIntent is the Weksa/language-owned handoff, ArticulatoryPlan is the AquaSynth realization layer, VocalTractMorphology declares anatomy capabilities, ArticulatoryConstraintReport is the host-visible veto surface, and VocalTractPlanResult is the tiny end-to-end proof artifact for host branching. IPA is an authoring input only: tokenization and feature mapping lower into language phonology profiles, timed articulatory gestures, morphology-specific tract/excitation curves, and Faust DSP. The staged support plan starts with tokenization and vowels, then core pulmonic consonants, expanded human IPA, airstream/phonation, and Weksa/alien morphologies. Optional eSpeak NG tests provide the first generated speech workout when ESPEAK_NG or espeak-ng is installed; eSpeak is intelligibility/timing/log-mel pressure, not anatomy truth. AquaSynth owns the learned utterance embedding encoder and synth driver: speech text embeddings plus prosody/emphasis hints plus Ghostlight/Epiphany-shaped character state vectors compress into utterance embeddings, and phonetic features plus that embedding feed a packed-buffer native C# backprop/Adam mapper that predicts tract, AM/FM, LFO, filter, and mel spectral-envelope controls. SpeechBackpropagationPipeline chains the two models: synth-driver target loss returns gradients with respect to its input, slices the utterance-embedding gradient, and updates the utterance encoder from downstream speech loss. The generated ground-truth training fixture renders eSpeak syllables, extracts log-mel evidence, and trains that chained pipeline toward eSpeak-seeded mel/control targets. CultMesh speech training now has payload manifests, peer/lease admission receipts, worker assignment, result collection, and central checkpoint application; workers do not own model updates. Real remote artifact chunk transfer and process supervision remain CultMesh infrastructure surfaces, not AquaSynth workaround territory.",
@ -121,8 +121,8 @@
},
"last_verified": {
"command": "dotnet test tests\\AquaSynth.Dsl.Tests\\AquaSynth.Dsl.Tests.csproj --no-restore --disable-build-servers -p:UseSharedCompilation=false -p:BuildInParallel=false --filter \"FullyQualifiedName~PatchScriptTests\" -v minimal",
"result": "Primitive vocal telemetry/reference cut passes PatchScriptTests 88/88 and the focused PT primitive timeline artifact test. Path timelines now include wave/passivity fields, PT/SndKit static primitive comparison is covered, the local PT renderer exposes internal primitive timeline samples, and per-fixture comparison artifacts are written under artifacts/parity/primitive-vocal-timeline. First comparison-driven law correction added SourcePort.FlowScale, direct RadiationLoad.Reflection ownership, and wave-dependent timeline radiation flow. Earlier full unfiltered run hit the five-minute wall after leaving orphan dotnet workers; focused suites are the current reliable verification path.",
"commit": "pending primitive vocal teardown"
"result": "Primitive vocal gesture owner cut passes PatchScriptTests 90/90 plus the focused PT primitive timeline artifact test. ControlSurface exposes normalized primitive controls, ControlSpline owns editable future gesture timelines, Faust emits spline point controls and maps effective normalized values back to physical primitive fields, and tract_motion adapts velopharynx motion into a branch spline. Artifact artifacts/parity/primitive-vocal-timeline/20260529T185955965 reports closure-release mean absolute primitive timeline error 0.155925 after cutting a generated contact-opening spline that PT evidence rejected. Earlier full unfiltered runs hit the five-minute wall after leaving orphan dotnet workers; focused suites are the current reliable verification path.",
"commit": "pending primitive control surfaces"
}
}

View file

@ -2,6 +2,24 @@
Current slice:
- Implemented the gesture/timeline owner cut for primitive vocals.
`ControlSurface` now exposes normalized runtime controls for primitive fields,
and `ControlSpline` owns linear/hold/Bezier motion over those surfaces.
Primitive Faust lowering maps effective normalized surface values back into
physical fields; explicit old `@/param` bindings remain base controls so PT
fixture controls are not bypassed. `ControlSplineTimeline` supports future
point edits for realtime clients.
- `tract_motion` now adapts velopharynx motion into a branch-opening
`ControlSpline`. A generated contact-opening spline was tested and deleted:
PT timeline evidence showed the obstruction was already closed at block zero,
so an invented open-then-close contact story was a compensator. Contact
closure should come from articulator/area ownership, not local contact slew.
- Verification for this cut: `PatchScriptTests` pass 90/90. Focused primitive
timeline artifact test passes and writes
`artifacts/parity/primitive-vocal-timeline/20260529T185955965`; summary:
open-vowel 0.267624, front-vowel 0.246432, nasal-vowel 0.223792,
bilabial-nasal-ma 0.226266, sibilant 0.177032, closure-release 0.155925.
- First primitive vocal teardown cut implemented. `AreaFunction`,
`WaveguidePath`, `SourcePort`, `ConstrictionContact`, `BranchPort`,
`RadiationLoad`, `ProbeTimeline`, and `VocalNetwork` are model records with

View file

@ -139,6 +139,7 @@ live_invariants:
- `PinkTromboneReferenceRenderer.RenderTimeline` now exposes PT internal oral/nasal path, branch, contact, and radiation samples from the renderer's own diameter/wave/reflection/transient arrays. `PrimitiveReferenceReport.ComparePinkTromboneTimeline` role-normalizes Aqua primitive samples against PT timeline samples for path/branch/contact/radiation signals. This is the first local reference-internal comparison surface for the primitive rebuild; next pressure is fixture-suite artifact reporting and then tightening laws where the timeline drift points.
- `PinkTrombonePrimitiveTimelineComparisonWritesArtifacts` writes per-fixture Aqua primitive timelines, PT internal timelines, comparison CSVs, and a summary under `artifacts/parity/primitive-vocal-timeline/<timestamp>`. This makes primitive drift inspectable across the PT fixture suite without native Faust or external VTL/ArtiSynth dependencies. Use these artifacts to choose the next physical-law correction.
- First comparison-driven primitive law correction: `SourcePort` owns `FlowScale` (default `.02`) so pressure/tension/opening no longer emit hidden unit-scale waves; `RadiationLoad.Reflection` owns reflection directly and aperture no longer rewrites that coefficient; `ProbeTimelineReport` radiation flow now uses path outgoing wave like Faust instead of reporting `aperture / impedance`. Artifact `artifacts/parity/primitive-vocal-timeline/20260529T155613895` drops fixture-suite mean absolute timeline error from roughly `1.2-1.3` to `0.15-0.27`. Remaining large drift is branch/contact dynamics, which needs an owner decision about gesture slew versus primitive-local smoothing.
- Gesture motion ownership is now separate from acoustic primitives. `ControlSurface` exposes normalized runtime controls for primitive fields, and `ControlSpline` owns future-time linear/hold/Bezier gesture motion over those surfaces. Faust lowering emits surface and spline point controls, maps effective normalized values back to physical primitive fields, and keeps old `@/param` bindings as compatibility base controls. `tract_motion` adapts velopharynx opening into a branch spline. A generated contact-opening spline was tested and cut because PT timeline evidence showed it invented an open-then-close transient; contact closure must come from articulator/area ownership. Focused verification: `PatchScriptTests` pass 90/90 and primitive timeline artifact `artifacts/parity/primitive-vocal-timeline/20260529T185955965` keeps `closure-release` at mean absolute error `0.155925`.
- TurbulenceJet now includes a release-timed burst-noise component in addition to the deterministic pressure pulse, but a stronger trial barely moved papa; the source is not the main remaining articulation bottleneck. Graph radiation now mixes 30% raw boundary flow with 70% high-passed flow after local admittance weighting, down from the over-bright 15/85 blend. Latest radiation/source artifact under `artifacts/parity/pink-trombone-utterance-logmel/20260527T174236049`: mama/papa/thrombosis cosine 0.6578/0.7692/0.5585, rmsRatio 3.0006/2.6153/2.3488, articulation 0.1437/0.1173/0.1191. Static `closure-release` under `artifacts/parity/pink-trombone-logmel/20260527T174139661` has rmsRatio 0.9900 and speechBandRatio 0.0842. This is still not speaking; next pressure is true tract resonance/passive scattering and pressure storage, not more burst gain.
- Same-path two-port area discontinuities now emit `graph_area_energy_in_*` and `graph_area_energy_out_*` probes alongside the three-port branch `graph_connection_energy_*` probes. Use these to inspect passivity and formant-body failures at the scatter sites rather than judging only final output loudness.
- `FaustExportOptions.DebugProbeUi` wraps graph probe signals in Faust `vbargraph` UI controls under `/debug/...` without changing audio output arity. Native streaming Faust patches now separate writable controls from read-only probe zones and can read/snapshot probe values after `ProcessBlock`; use this for acoustic graph passivity/radiation inspection, leave it off for normal parity renders.

View file

@ -306,6 +306,74 @@ public sealed class PatchScriptTests
Assert.DoesNotContain("graph_terminal_area", export.Source);
}
[Fact]
public void PrimitiveVocalGraphExposesNormalizedControlSurfacesAndSplineGestures()
{
var patch = PatchScript.Parse("""
morphology name=oral length_cm=17 diameters=.6,.9,1.2,1.4,1.1,.8
morphology name=nasal length_cm=12 diameters=.05,.35,.6,.8
waveguide_path name=oral_path morphology=oral loss=.998
waveguide_path name=nasal_path morphology=nasal loss=.997
source_port name=folds path=oral_path pressure=.7 tension=.55 opening=.45 noise=.05 impedance=.3
branch_port name=velopharynx from=oral_path from_position=.45 to=nasal_path opening=.01 coupling=1
constriction_contact name=labial path=oral_path position=.92 opening=.05 resistance=.8 stored_pressure=.4
radiation_load name=mouth path=oral_path aperture=.8 reflection=-.82 impedance=.28
probe_timeline name=flow network=voice blocks=4 block_size=64
vocal_network name=voice paths=oral_path,nasal_path sources=folds contacts=labial branches=velopharynx radiation=mouth probe=flow
control_spline name=velum_open surface=/vocal/branches/0/opening interp=bezier points=0:.01:.02:.01,0.08:.75:.02:.75
gesture name=labial_release surface=/vocal/contacts/0/opening interp=linear points=0:.05,0.04:.9
vocal network=voice freq=150 gain=.2 sustain=.2
""");
Assert.Contains(patch.ControlSurfaces, surface =>
surface.Path == "/vocal/branches/0/opening" &&
surface.FieldPath == "/vocal/branches/0/opening" &&
surface.DefaultNormalized < .02f);
Assert.Contains(patch.ControlSurfaces, surface => surface.Path == "/vocal/contacts/0/opening");
Assert.Contains(patch.ControlSurfaces, surface => surface.Path == "/vocal/radiation/0/aperture");
Assert.Equal(2, patch.ControlSplines.Count);
var timeline = new ControlSplineTimeline(patch.ControlSplines);
timeline.SetFuturePoint("/vocal/branches/0/opening", new ControlSplinePoint(.12f, .2f), nowSeconds: .1f);
Assert.InRange(timeline.ValueAt("/vocal/branches/0/opening", .12f), .19f, .21f);
var early = ProbeTimelineReport.Build(patch, "voice", blocks: 1)
.Single(sample => sample.Primitive == "branch:velopharynx" && sample.Signal == "admittance")
.Value;
var late = ProbeTimelineReport.Build(patch, "voice", blocks: 80)
.Last(sample => sample.Primitive == "branch:velopharynx" && sample.Signal == "admittance")
.Value;
Assert.True(late > early, $"expected spline gesture to open velopharynx, early={early} late={late}");
var export = FaustEmitter.Emit(patch, new FaustExportOptions("primitive_spline"));
Assert.Contains("control_surface_0", export.Source);
Assert.Contains("/vocal/branches/0/opening", export.Source);
Assert.Contains("/splines/velum_open/0/value", export.Source);
Assert.Contains("seg_bez", export.Source);
}
[Fact]
public void TractMotionAdapterEmitsPrimitiveControlSplinesInsteadOfLocalSlew()
{
var patch = PatchScript.Parse("""
tract_shape name=human length_cm=17 diameters=.6,.8,1.2,1.6,1.3,.9
nasal_branch name=nose length_cm=12 junction=3 velum=.35 diameters=.01,.35,.6,.8
tract_motion name=motion velum_slew=16 constriction_slew=24
tract shape=human nasal_branch=nose motion=motion propagation=graph sections=6 velum=.35 freq=140 sustain=.2
""");
var branchSurface = Assert.Single(patch.ControlSurfaces, surface => surface.Path == "/vocal/branches/0/opening");
Assert.Equal("/vocal/branches/0/opening", branchSurface.FieldPath);
var branchMotion = Assert.Single(patch.ControlSplines, spline => spline.SurfacePath == "/vocal/branches/0/opening");
Assert.Contains("velopharynx", branchMotion.Name);
Assert.Equal(ControlSplineInterpolation.Bezier, branchMotion.Interpolation);
var first = Assert.Single(branchMotion.Points, point => point.TimeSeconds == 0);
var last = branchMotion.Points.OrderBy(point => point.TimeSeconds).Last();
Assert.InRange(first.Value, .009f, .011f);
Assert.InRange(last.Value, .34f, .36f);
}
[Fact]
public void PrimitiveProbeTimelineReportsFlowBeforeAudioParity()
{