Expose public gesture timeline API

This commit is contained in:
Meta Vens 2026-05-29 20:21:16 +01:00
commit 1bf220027d
9 changed files with 198 additions and 9 deletions

View file

@ -278,6 +278,15 @@ The separate gesture/timeline owner is now the live direction:
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.
- `ControlSurfaceCatalog.FromPatch(patch)` is the public gesture discovery API.
Hosts can query surfaces by normalized path/owner/field/range, create a
`ControlSplineTimeline`, edit future points, and call `ControlValuesAt(t)` to
stream ordinary surface controls without recompiling. This is the runtime path
for clients that want to author their own gestures.
- `AquaSynthCompiledPatch.ControlSurfaces` carries the same catalog through the
native Faust compile path, and `AquaSynthCompiledPatch.ControlValuesAt(...)`
filters timeline output to the controls actually exported by that compiled
DSP. The semantic catalog remains richer than optimized Faust UI zones.
- `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

View file

@ -1,12 +1,63 @@
namespace AquaSynth.Dsl;
public sealed record ControlSurfaceValue(string SurfacePath, float NormalizedValue);
public sealed class ControlSurfaceCatalog
{
private readonly Dictionary<string, ControlSurface> _surfacesByPath;
public ControlSurfaceCatalog(IEnumerable<ControlSurface> surfaces, IEnumerable<ControlSpline>? splines = null)
{
Surfaces = surfaces.OrderBy(surface => surface.Path, StringComparer.OrdinalIgnoreCase).ToArray();
Splines = (splines ?? Array.Empty<ControlSpline>())
.OrderBy(spline => spline.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
_surfacesByPath = Surfaces.ToDictionary(surface => surface.Path, StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyList<ControlSurface> Surfaces { get; }
public IReadOnlyList<ControlSpline> Splines { get; }
public IReadOnlyList<string> SurfacePaths => Surfaces.Select(surface => surface.Path).ToArray();
public static ControlSurfaceCatalog FromPatch(SynthPatch patch) =>
new(patch.ControlSurfaces, patch.ControlSplines);
public bool TryGetSurface(string path, out ControlSurface surface) =>
_surfacesByPath.TryGetValue(path, out surface!);
public ControlSurface Surface(string path) =>
TryGetSurface(path, out var surface)
? surface
: throw new KeyNotFoundException($"unknown control surface `{path}`");
public ControlSplineTimeline CreateTimeline(bool includePatchSplines = true) =>
new(this, includePatchSplines ? Splines : Array.Empty<ControlSpline>());
public IReadOnlyDictionary<string, float> Defaults() =>
Surfaces.ToDictionary(surface => surface.Path, surface => surface.DefaultNormalized, StringComparer.OrdinalIgnoreCase);
}
public sealed class ControlSplineTimeline
{
private readonly ControlSurfaceCatalog? catalog;
private readonly Dictionary<string, List<ControlSplinePoint>> _pointsBySurface;
public ControlSplineTimeline(IEnumerable<ControlSpline> splines)
{
_pointsBySurface = splines
catalog = null;
_pointsBySurface = BuildPointsBySurface(splines);
}
public ControlSplineTimeline(ControlSurfaceCatalog catalog, IEnumerable<ControlSpline>? splines = null)
{
this.catalog = catalog;
_pointsBySurface = BuildPointsBySurface(splines ?? Array.Empty<ControlSpline>());
}
private static Dictionary<string, List<ControlSplinePoint>> BuildPointsBySurface(IEnumerable<ControlSpline> splines) =>
splines
.Where(spline => spline.Enabled)
.GroupBy(spline => spline.SurfacePath, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
@ -16,12 +67,18 @@ public sealed class ControlSplineTimeline
.OrderBy(point => point.TimeSeconds)
.ToList(),
StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyList<string> SurfacePaths => _pointsBySurface.Keys.ToArray();
public void SetFuturePoint(string surfacePath, float timeSeconds, float normalizedValue, float nowSeconds) =>
SetFuturePoint(surfacePath, new ControlSplinePoint(timeSeconds, normalizedValue), nowSeconds);
public void SetFuturePoint(string surfacePath, ControlSplinePoint point, float nowSeconds)
{
if (catalog is not null && !catalog.TryGetSurface(surfacePath, out _))
{
throw new KeyNotFoundException($"unknown control surface `{surfacePath}`");
}
if (point.TimeSeconds < nowSeconds)
{
throw new ArgumentOutOfRangeException(nameof(point), "future spline edits must not rewrite past control points");
@ -56,6 +113,31 @@ public sealed class ControlSplineTimeline
return Evaluate(points, timeSeconds);
}
public IReadOnlyDictionary<string, float> ControlValuesAt(float timeSeconds)
{
if (catalog is null)
{
return _pointsBySurface.ToDictionary(
pair => pair.Key,
pair => Evaluate(pair.Value, timeSeconds),
StringComparer.OrdinalIgnoreCase);
}
var controls = new Dictionary<string, float>(StringComparer.OrdinalIgnoreCase);
foreach (var surface in catalog.Surfaces)
{
controls[surface.Path] = ValueAt(surface.Path, timeSeconds, surface.DefaultNormalized);
}
return controls;
}
public IReadOnlyList<ControlSurfaceValue> SurfaceValuesAt(float timeSeconds) =>
ControlValuesAt(timeSeconds)
.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase)
.Select(pair => new ControlSurfaceValue(pair.Key, pair.Value))
.ToArray();
public static float Evaluate(ControlSpline spline, float timeSeconds, float fallback = 0)
{
if (!spline.Enabled || spline.Points.Count == 0)

View file

@ -48,13 +48,15 @@ public sealed class AquaSynthCompiledPatch : IDisposable
IntPtr factory,
AquaSynthNativeManifest manifest,
IReadOnlyList<string> controlPaths,
IReadOnlyList<string> probePaths)
IReadOnlyList<string> probePaths,
ControlSurfaceCatalog? controlSurfaceCatalog = null)
{
this.toolchain = toolchain;
this.factory = factory;
Manifest = manifest;
ControlPaths = controlPaths;
ProbePaths = probePaths;
ControlSurfaces = controlSurfaceCatalog ?? new ControlSurfaceCatalog([]);
}
public AquaSynthNativeManifest Manifest { get; }
@ -63,6 +65,16 @@ public sealed class AquaSynthCompiledPatch : IDisposable
public IReadOnlyList<string> ProbePaths { get; }
public ControlSurfaceCatalog ControlSurfaces { get; }
public IReadOnlyDictionary<string, float> ControlValuesAt(ControlSplineTimeline timeline, float timeSeconds)
{
var exported = ControlPaths.ToHashSet(StringComparer.OrdinalIgnoreCase);
return timeline.ControlValuesAt(timeSeconds)
.Where(pair => exported.Contains(pair.Key) || exported.Contains(pair.Key.TrimStart('/')))
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
}
public float[] Render(float gain = 1.0f) => Render(null, gain);
public AquaSynthStreamingPatch CreateStreamingPatch()
@ -501,7 +513,7 @@ public sealed class AquaSynthNativeCompiler : IDisposable
var export = FaustEmitter.Emit(patch, new FaustExportOptions(faustName));
var duration = EstimateDuration(patch, options);
var sourcePath = WriteDspSource(identity.Id, export.Source, options.DspSourceDirectory);
return CompileSource(identity, faustName, export.Source, duration, sourcePath);
return CompileSource(identity, faustName, export.Source, duration, sourcePath, ControlSurfaceCatalog.FromPatch(patch));
}
public AquaSynthCompiledPatch CompileSource(
@ -509,7 +521,8 @@ public sealed class AquaSynthNativeCompiler : IDisposable
string faustName,
string source,
float durationSeconds,
string? dspSourcePath = null)
string? dspSourcePath = null,
ControlSurfaceCatalog? controlSurfaceCatalog = null)
{
ObjectDisposedException.ThrowIf(disposed, this);
var stopwatch = Stopwatch.StartNew();
@ -566,7 +579,7 @@ public sealed class AquaSynthNativeCompiler : IDisposable
toolchain.Version,
toolchain.Home,
dspSourcePath);
return new AquaSynthCompiledPatch(toolchain, factory, manifest, controlPaths, probePaths);
return new AquaSynthCompiledPatch(toolchain, factory, manifest, controlPaths, probePaths, controlSurfaceCatalog);
}
finally
{

View file

@ -232,3 +232,4 @@
{"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"}
{"date":"2026-05-29","area":"public-gesture-api","summary":"User-authored gestures now have a public runtime API. `ControlSurfaceCatalog.FromPatch(patch)` exposes normalized surface metadata; `ControlSplineTimeline` lets hosts edit future control points and produce per-time surface control dictionaries; `AquaSynthCompiledPatch.ControlSurfaces` carries the catalog through native Faust compilation; `AquaSynthCompiledPatch.ControlValuesAt(timeline,t)` filters semantic timeline values to the controls actually exported by optimized Faust DSP. This makes custom client gestures first-class without recompiling or relying on PT adapters. PatchScriptTests pass 91/91 and NativeFaustRuntimeTests pass 5/5.","evidence":"src/AquaSynth.Core/ControlSplineTimeline.cs; src/AquaSynth.Faust/NativeFaustRuntime.cs; tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs; tests/AquaSynth.Dsl.Tests/NativeFaustRuntimeTests.cs; docs/acoustic-graph-proprioception.md"}

View file

@ -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. 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."
"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. Public clients author gestures through ControlSurfaceCatalog plus ControlSplineTimeline and stream surface control dictionaries; higher-level gesture templates are only spline emitters."
},
"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 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"
"result": "Public gesture API cut passes PatchScriptTests 91/91 and NativeFaustRuntimeTests 5/5. ControlSurfaceCatalog.FromPatch exposes normalized surface metadata; ControlSplineTimeline supports future point edits and ControlValuesAt(t) surface dictionaries; AquaSynthCompiledPatch carries ControlSurfaces through native Faust compilation and filters timeline values to exported optimized DSP controls. Earlier full unfiltered runs hit the five-minute wall after leaving orphan dotnet workers; focused suites are the current reliable verification path.",
"commit": "pending public gesture API"
}
}

View file

@ -2,6 +2,15 @@
Current slice:
- Public gesture API pass implemented. `ControlSurfaceCatalog.FromPatch(patch)`
is the host-facing discovery surface for normalized controls; hosts can create
`ControlSplineTimeline`, edit future points, and call `ControlValuesAt(t)` to
stream ordinary surface controls into Faust without recompiling. Native Faust
compiled patches now expose `AquaSynthCompiledPatch.ControlSurfaces` and a
safe `ControlValuesAt(timeline,t)` helper that filters the semantic timeline
to UI zones actually exported by the optimized DSP. Verification:
`PatchScriptTests` pass 91/91 and `NativeFaustRuntimeTests` pass 5/5.
- 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.

View file

@ -140,6 +140,7 @@ live_invariants:
- `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`.
- Public gesture authoring API is now explicit. `ControlSurfaceCatalog.FromPatch(patch)` exposes normalized surface metadata for hosts; `ControlSplineTimeline` lets clients edit future points and emit `ControlValuesAt(t)` as ordinary surface-control dictionaries. Native Faust compiled patches carry `ControlSurfaces` and expose `ControlValuesAt(timeline,t)` to filter semantic timeline values to controls exported by the optimized DSP. This is the runtime path for user-authored gestures without recompilation. Verification: `PatchScriptTests` pass 91/91 and `NativeFaustRuntimeTests` pass 5/5.
- 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

@ -133,4 +133,45 @@ public sealed class NativeFaustRuntimeTests
Assert.Contains(stream.SnapshotProbes(), pair => pair.Key == "debug/level" && pair.Value >= 0.0f);
}
}
[Fact]
public void NativeFaustCompiledPatchExposesPrimitiveControlSurfaceCatalogWhenToolchainIsAvailable()
{
const string script = """
morphology name=oral length_cm=17 diameters=.6,.8,1.2,1.5,.9
waveguide_path name=oral_path morphology=oral loss=.998
source_port name=folds path=oral_path pressure=.7 tension=.55 opening=.45 noise=.05 impedance=.3
radiation_load name=mouth path=oral_path aperture=.8 reflection=-.82 impedance=.28
vocal_network name=voice paths=oral_path sources=folds radiation=mouth
vocal network=voice freq=150 gain=.2 sustain=.05 decay=.04
""";
using var compiler = new AquaSynthPatchCompiler();
if (!compiler.TryCompileScript(new AquaSynthCompileIdentity("native_surface_smoke", "native_surface_smoke", script), out var patch, out var error))
{
if (error?.Contains("Faust toolchain not found", StringComparison.OrdinalIgnoreCase) == true ||
error?.Contains("Faust DLL not found", StringComparison.OrdinalIgnoreCase) == true)
{
return;
}
Assert.Fail($"AquaSynth native Faust surface compile failed: {error}");
}
using (patch)
{
Assert.Contains("/vocal/sources/0/pressure", patch!.ControlSurfaces.SurfacePaths);
Assert.Contains("vocal/sources/0/pressure", patch.ControlPaths);
var timeline = patch.ControlSurfaces.CreateTimeline(includePatchSplines: false);
timeline.SetFuturePoint("/vocal/sources/0/pressure", timeSeconds: .01f, normalizedValue: .9f, nowSeconds: 0);
var controls = patch.ControlValuesAt(timeline, .01f);
var samples = patch.Render(controls);
Assert.Contains("/vocal/sources/0/pressure", controls.Keys);
Assert.DoesNotContain("/vocal/radiation/0/reflection", controls.Keys);
Assert.True(samples.Length > 1024);
Assert.Contains(samples, sample => MathF.Abs(sample) > 0.0001f);
}
}
}

View file

@ -374,6 +374,39 @@ public sealed class PatchScriptTests
Assert.InRange(last.Value, .34f, .36f);
}
[Fact]
public void ControlSurfaceCatalogIsThePublicGestureAuthoringApi()
{
var patch = PatchScript.Parse("""
morphology name=oral length_cm=17 diameters=.6,.8,1.2,1.6,.9
waveguide_path name=oral_path morphology=oral loss=.998
source_port name=folds path=oral_path pressure=.7 tension=.55 opening=.45 noise=.05 impedance=.3
radiation_load name=mouth path=oral_path aperture=.8 reflection=-.82 impedance=.28
vocal_network name=voice paths=oral_path sources=folds radiation=mouth
vocal network=voice freq=150 gain=.2 sustain=.2
""");
var catalog = ControlSurfaceCatalog.FromPatch(patch);
Assert.Contains("/vocal/sources/0/pressure", catalog.SurfacePaths);
Assert.Contains("/vocal/radiation/0/aperture", catalog.SurfacePaths);
var pressure = catalog.Surface("/vocal/sources/0/pressure");
Assert.Equal("folds", pressure.Owner);
Assert.Equal("pressure", pressure.Field);
Assert.InRange(pressure.DefaultNormalized, .69f, .71f);
var timeline = catalog.CreateTimeline(includePatchSplines: false);
timeline.SetFuturePoint("/vocal/sources/0/pressure", timeSeconds: .016f, normalizedValue: .9f, nowSeconds: .0f);
timeline.SetFuturePoint("/vocal/radiation/0/aperture", timeSeconds: .016f, normalizedValue: .4f, nowSeconds: .0f);
var controls = timeline.ControlValuesAt(.016f);
Assert.InRange(controls["/vocal/sources/0/pressure"], .89f, .91f);
Assert.InRange(controls["/vocal/radiation/0/aperture"], .39f, .41f);
Assert.Contains(controls, pair => pair.Key == "/vocal/sources/0/tension");
Assert.Throws<KeyNotFoundException>(() =>
timeline.SetFuturePoint("/vocal/nope", timeSeconds: .02f, normalizedValue: .5f, nowSeconds: .0f));
}
[Fact]
public void PrimitiveProbeTimelineReportsFlowBeforeAudioParity()
{