From 5e1463fcb97eb8be5d656ac9d99088cba4682e96 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 29 May 2026 20:03:32 +0100 Subject: [PATCH] Add primitive vocal control surfaces and splines --- docs/acoustic-graph-proprioception.md | 36 ++- src/AquaSynth.Core/ControlSplineTimeline.cs | 190 ++++++++++++ src/AquaSynth.Core/FaustExport.cs | 126 ++++++++ src/AquaSynth.Core/Model.cs | 40 +++ src/AquaSynth.Core/PatchScript.cs | 284 +++++++++++++++++- src/AquaSynth.Core/ProbeTimelineReport.cs | 141 +++++++-- state/evidence.jsonl | 1 + state/map.yaml | 1 + state/memory.json | 8 +- state/scratch.md | 18 ++ state/spine.yaml | 1 + tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs | 68 +++++ 12 files changed, 884 insertions(+), 30 deletions(-) create mode 100644 src/AquaSynth.Core/ControlSplineTimeline.cs diff --git a/docs/acoustic-graph-proprioception.md b/docs/acoustic-graph-proprioception.md index 7abda6c..d456889 100644 --- a/docs/acoustic-graph-proprioception.md +++ b/docs/acoustic-graph-proprioception.md @@ -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///...`, 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. diff --git a/src/AquaSynth.Core/ControlSplineTimeline.cs b/src/AquaSynth.Core/ControlSplineTimeline.cs new file mode 100644 index 0000000..11f848a --- /dev/null +++ b/src/AquaSynth.Core/ControlSplineTimeline.cs @@ -0,0 +1,190 @@ +namespace AquaSynth.Dsl; + +public sealed class ControlSplineTimeline +{ + private readonly Dictionary> _pointsBySurface; + + public ControlSplineTimeline(IEnumerable 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 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 points, float timeSeconds) + { + if (points.Count == 1) + { + return Math.Clamp(points[0].Value, 0, 1); + } + + return EvaluateBezier(points, timeSeconds); + } + + private static float EvaluateHold(IReadOnlyList 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 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 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; + } +} diff --git a/src/AquaSynth.Core/FaustExport.cs b/src/AquaSynth.Core/FaustExport.cs index 6bbb87c..19807a5 100644 --- a/src/AquaSynth.Core/FaustExport.cs +++ b/src/AquaSynth.Core/FaustExport.cs @@ -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(); + 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 _parameterIndexes = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _bindings = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _surfaceExpressions = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _boundParameterPaths = new(StringComparer.OrdinalIgnoreCase); public ParameterMap(SynthPatch patch, List 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 { diff --git a/src/AquaSynth.Core/Model.cs b/src/AquaSynth.Core/Model.cs index 2a376b1..f6f6091 100644 --- a/src/AquaSynth.Core/Model.cs +++ b/src/AquaSynth.Core/Model.cs @@ -501,6 +501,44 @@ public sealed record ProbeTimeline(string Name, IReadOnlyList? Networks public IReadOnlyList Networks { get; init; } = Networks ?? Array.Empty(); } +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 Points, + ControlSplineInterpolation Interpolation = ControlSplineInterpolation.Bezier, + bool Loop = false, + bool Enabled = true) +{ + public IReadOnlyList Points { get; init; } = Points ?? Array.Empty(); +} + public sealed record VocalNetwork( string Name, IReadOnlyList? Paths = null, @@ -867,6 +905,8 @@ public sealed record SynthPatch public IReadOnlyList Controls { get; init; } = Array.Empty(); public IReadOnlyList Parameters { get; init; } = Array.Empty(); public IReadOnlyList ControlCurves { get; init; } = Array.Empty(); + public IReadOnlyList ControlSurfaces { get; init; } = Array.Empty(); + public IReadOnlyList ControlSplines { get; init; } = Array.Empty(); public IReadOnlyList GestureGroups { get; init; } = Array.Empty(); public IReadOnlyList ParameterBindings { get; init; } = Array.Empty(); public Playback Playback { get; init; } = new(); diff --git a/src/AquaSynth.Core/PatchScript.cs b/src/AquaSynth.Core/PatchScript.cs index b47baa9..6035821 100644 --- a/src/AquaSynth.Core/PatchScript.cs +++ b/src/AquaSynth.Core/PatchScript.cs @@ -34,6 +34,10 @@ public static class PatchScript private readonly List _operatorGraphs = []; private readonly List _parameters = []; private readonly List _controlCurves = []; + private readonly List _controlSurfaces = []; + private readonly Dictionary _controlSurfacesByPath = new(StringComparer.OrdinalIgnoreCase); + private readonly List _controlSplines = []; + private readonly Dictionary _controlSplinesByName = new(StringComparer.OrdinalIgnoreCase); private readonly List _gestureGroups = []; private readonly List _parameterBindings = []; private readonly List _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 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 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 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 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 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 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 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 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 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 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 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 fields, string key, int line) => fields.TryGetValue(key, out var value) ? value : throw new PatchScriptException(line, $"missing `{key}`"); diff --git a/src/AquaSynth.Core/ProbeTimelineReport.cs b/src/AquaSynth.Core/ProbeTimelineReport.cs index 7563de2..6306308 100644 --- a/src/AquaSynth.Core/ProbeTimelineReport.cs +++ b/src/AquaSynth.Core/ProbeTimelineReport.cs @@ -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(); + 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(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 sources, IReadOnlyDictionary contacts, IReadOnlyDictionary branches, - string pathName) + string pathName, + IReadOnlyDictionary surfacesByField, + IReadOnlyDictionary 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 surfacesByField, + IReadOnlyDictionary 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 surfacesByField, + IReadOnlyDictionary 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(IReadOnlyList items, T item) + where T : class + { + for (var i = 0; i < items.Count; i++) + { + if (ReferenceEquals(items[i], item) || EqualityComparer.Default.Equals(items[i], item)) + { + return i; + } + } + + return -1; + } private static string Escape(string value) => value.Contains(',') || value.Contains('"') ? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\"" : value; diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 603f06f..30d9f99 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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/`. 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"} diff --git a/state/map.yaml b/state/map.yaml index 5a6b6d0..42c8733 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -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 diff --git a/state/memory.json b/state/memory.json index 7d9a90e..11baafa 100644 --- a/state/memory.json +++ b/state/memory.json @@ -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" } } diff --git a/state/scratch.md b/state/scratch.md index 43e6762..c28af59 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -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 diff --git a/state/spine.yaml b/state/spine.yaml index 331664c..d1e6c1b 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -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/`. 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. diff --git a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs index 97f65d2..cc36e8c 100644 --- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs @@ -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() {