Add graph propagation for tract voices

This commit is contained in:
Meta Vens 2026-05-26 19:13:43 +01:00
commit 81aebb2e5e
8 changed files with 102 additions and 23 deletions

View file

@ -263,4 +263,7 @@ junction, boundary, and radiation roles can inhabit the same anatomical point.
The remaining limits are now parity and law depth rather than graph ownership:
connection laws beyond area/pressure/bypass need sharper physical semantics,
runtime morphology changes still require care around recompilation boundaries,
and PT-shaped `Voice.Tract` has not yet been demoted to graph sugar.
and the old PT-shaped `Voice.Tract` renderer remains as a baseline. A tract can
now opt into graph lowering with `propagation=graph`, which reuses the
PT-shaped authoring commands while routing audio through the typed acoustic
network.

View file

@ -181,8 +181,10 @@ single graph node, so a source can share an anatomical point with a junction or
boundary without disappearing.
The remaining implementation pressure is now narrower: broaden connection laws
with sharper physical semantics, demote PT-shaped `Voice.Tract` into graph
authoring sugar, and run the log-mel parity loop against the graph renderer.
with sharper physical semantics and run the log-mel parity loop against the
graph renderer. PT-shaped `tract` authoring can now opt into graph lowering via
`propagation=graph`; the legacy `propagation=waveguide` lane remains as the
old parity baseline until the graph renderer wins on evidence.
Authority map for the required rebuild:
@ -207,8 +209,8 @@ Authority map for the required rebuild:
Exact Pink Trombone timing still needs pressure, but the next backend cut is no
longer "add more substeps." The graph renderer now has fractional-delay clock
semantics; the remaining parity question is whether PT compatibility commands
can be lowered into the graph closely enough to beat the current weak
semantics and a `tract propagation=graph` entry point; the remaining parity
question is whether graph-authored PT compatibility can beat the current weak
`Voice.Tract` baseline.
## Cut Line

View file

@ -183,7 +183,9 @@ public static class FaustEmitter
var dutyExpression = $"clip01({parameters.Expression(OwnerField(ownerPath, "osc/duty"), voice.Oscillator.Duty)} + {parameters.Expression(OwnerField(ownerPath, "duty/ramp"), voice.Duty.RampPerSecond)} * age + patch_mod_duty + {duty})";
var fmIndex = $"max(0.0, {parameters.Expression(OwnerField(ownerPath, "fm/index"), voice.Fm.Index)} + patch_mod_fm_index + {fmIndexMod}) * {FmDecay(parameters.Expression(OwnerField(ownerPath, "fm/decay"), voice.Fm.IndexDecaySeconds), voice.Fm.IndexDecaySeconds, parameters.IsBound(OwnerField(ownerPath, "fm/decay")))}";
var oscillator = oscillatorOverride ??
(voice.Tract is { } tract
(voice.Tract is { Propagation: TractPropagationMode.Graph } && voice.AcousticNetwork is { } tractGraph
? AcousticNetworkExpression(source, patch, tractGraph, ownerPath, name, frequency, parameters, warnings)
: voice.Tract is { } tract
? TractExpression(source, tract, ownerPath, name, frequency, noteGate, parameters)
: voice.AcousticNetwork is { } acousticNetwork
? AcousticNetworkExpression(source, patch, acousticNetwork, ownerPath, name, frequency, parameters, warnings)

View file

@ -380,7 +380,8 @@ public sealed record TractInjection(
public enum TractPropagationMode
{
Resonator,
Waveguide
Waveguide,
Graph
}
public sealed record NasalBranch(

View file

@ -1223,7 +1223,7 @@ public static class PatchScript
var radiationNames = new List<string>();
var sourceName = string.IsNullOrWhiteSpace(tract.Glottis?.Name) ? $"{prefix}_source" : $"{prefix}_{tract.Glottis!.Name}";
AddAcousticSourcePortRecord(new AcousticSourcePort(
var source = new AcousticSourcePort(
sourceName,
primaryPathName,
0,
@ -1231,21 +1231,25 @@ public static class PatchScript
tract.Intensity,
tract.Tenseness,
tract.Glottis?.Skew ?? 0.42f,
tract.Glottis?.Aspiration ?? 0.08f));
tract.Glottis?.Aspiration ?? 0.08f);
AddAcousticSourcePortRecord(source);
AddAcousticTerminalRecord(new AcousticTerminal(source.Name, source.Path, source.Position, AcousticTerminalKind.Source, source.Name));
sourceNames.Add(sourceName);
if (tract.Injection is { } injection)
{
var injectionName = string.IsNullOrWhiteSpace(injection.Name) ? $"{prefix}_injection" : $"{prefix}_{injection.Name}";
AddAcousticSourcePortRecord(new AcousticSourcePort(
var injectionSource = new AcousticSourcePort(
injectionName,
primaryPathName,
injection.Position,
NormalizeTractIndex(injection.Position, tract.Sections),
AcousticSourceKind.TurbulenceJet,
injection.Burst,
0,
injection.Diameter,
injection.Turbulence));
injection.Turbulence);
AddAcousticSourcePortRecord(injectionSource);
AddAcousticTerminalRecord(new AcousticTerminal(injectionSource.Name, injectionSource.Path, injectionSource.Position, AcousticTerminalKind.Source, injectionSource.Name));
sourceNames.Add(injectionName);
}
@ -1253,37 +1257,75 @@ public static class PatchScript
{
var nasalPathName = string.IsNullOrWhiteSpace(nasal.Name) ? $"{prefix}_nasal" : $"{prefix}_{nasal.Name}";
AddAcousticPathRecord(new AcousticPath(nasalPathName, nasalArea, 343, nasal.Loss));
AddAcousticBranchRecord(new AcousticBranch(
var branch = new AcousticBranch(
nasalPathName,
primaryPathName,
nasal.JunctionIndex,
NormalizeTractIndex(nasal.JunctionIndex, tract.Sections),
nasalPathName,
0,
AcousticBranchKind.Nasal,
nasal.Velum,
1,
true));
AddAcousticRadiationPortRecord(new AcousticRadiationPort(
true);
AddAcousticBranchRecord(branch);
AddAcousticTerminalRecord(new AcousticTerminal(
BranchFromTerminalName(branch),
branch.FromPath,
branch.FromPosition,
AcousticTerminalKind.Junction,
branch.Name,
Math.Max(0, branch.Coupling)));
AddAcousticTerminalRecord(new AcousticTerminal(
BranchToTerminalName(branch),
branch.ToPath,
branch.ToPosition,
AcousticTerminalKind.Junction,
branch.Name,
Math.Max(0, branch.Opening)));
AddAcousticConnectionRecord(new AcousticConnection(
BranchConnectionName(branch),
[BranchFromTerminalName(branch), BranchToTerminalName(branch)],
AcousticConnectionLaw.AreaScattering,
branch.Coupling));
var nasalRadiation = new AcousticRadiationPort(
$"{nasalPathName}_radiation",
nasalPathName,
1,
AcousticRadiationKind.Nostril,
nasal.Velum,
nasal.Reflection,
nasal.Loss));
nasal.Loss);
AddAcousticRadiationPortRecord(nasalRadiation);
AddAcousticTerminalRecord(new AcousticTerminal(
nasalRadiation.Name,
nasalRadiation.Path,
nasalRadiation.Position,
AcousticTerminalKind.Radiation,
nasalRadiation.Name,
Math.Max(0, nasalRadiation.Opening),
nasalRadiation.Reflection));
branchNames.Add(nasalPathName);
radiationNames.Add($"{nasalPathName}_radiation");
}
var lipName = $"{prefix}_lip";
AddAcousticRadiationPortRecord(new AcousticRadiationPort(
var lip = new AcousticRadiationPort(
lipName,
primaryPathName,
1,
AcousticRadiationKind.Lip,
tract.LipOpening,
tract.LipReflection,
1));
1);
AddAcousticRadiationPortRecord(lip);
AddAcousticTerminalRecord(new AcousticTerminal(
lip.Name,
lip.Path,
lip.Position,
AcousticTerminalKind.Radiation,
lip.Name,
Math.Max(0, lip.Opening),
lip.Reflection));
radiationNames.Add(lipName);
var clockName = $"{prefix}_clock";
@ -1291,6 +1333,8 @@ public static class PatchScript
clockName,
tract.Propagation == TractPropagationMode.Waveguide
? WaveClockDelayStrategy.UnitGrid
: tract.Propagation == TractPropagationMode.Graph
? WaveClockDelayStrategy.FractionalThiran
: WaveClockDelayStrategy.FractionalLinear));
var network = new AcousticPortNetwork(
@ -1300,10 +1344,14 @@ public static class PatchScript
sourceNames,
branchNames,
radiationNames);
AddAcousticNetworkRecord(network);
return network;
var graphNetwork = ExpandNetworkGraphSugar(network);
AddAcousticNetworkRecord(graphNetwork);
return graphNetwork;
}
private static float NormalizeTractIndex(float index, int sections) =>
sections <= 0 ? 0 : Math.Clamp(index / Math.Max(1, sections - 1), 0, 1);
private void AddModBus(IReadOnlyDictionary<string, string> fields, int line)
{
var name = GetAny(fields, ["name", "n"], "mod");
@ -2376,10 +2424,11 @@ public static class PatchScript
_ => target.ToString().ToLowerInvariant()
};
private static TractPropagationMode ParseTractPropagationMode(string value, int line) => value.ToLowerInvariant() switch
private static TractPropagationMode ParseTractPropagationMode(string value, int line) => value.ToLowerInvariant() switch
{
"resonator" or "proxy" or "formant" => TractPropagationMode.Resonator,
"waveguide" or "kl" or "kelly-lochbaum" or "kelly_lochbaum" or "tube" => TractPropagationMode.Waveguide,
"graph" or "acoustic_graph" or "network" => TractPropagationMode.Graph,
_ => throw new PatchScriptException(line, $"unknown tract propagation mode `{value}`")
};

View file

@ -150,3 +150,4 @@
{"timestamp":"2026-05-26T18:05:00+01:00","topic":"physical-voice-typed-path-graph","claim":"Aqua acoustic topology now uses a path graph with typed terminals and connections. `source_port`, `radiation_port`, and `branch` remain authoring sugar that populate terminal/connection records, while `AcousticPortNetwork` carries explicit graph terminal and connection names. This resolves the topology naming blocker; the remaining blocker is compiling that graph into bidirectional fractional-delay waveguide Faust.","evidence":"Added `AcousticTerminal`, `AcousticConnection`, terminal/connection enums, parser commands `terminal` and `connect`, branch/source/radiation graph sugar, and focused parser tests `ParserSupportsReusableAcousticPortNetworkPrimitives` and `ParserSupportsTypedAcousticPathGraph`; focused test command passed 4 tests with disabled build servers."}
{"timestamp":"2026-05-26T18:35:00+01:00","topic":"physical-voice-typed-graph-faust-lowering","claim":"Typed acoustic path graphs now compile to Faust bidirectional segment state when terminals are present. The lowering splits paths at terminals, creates right/left segment state, scatters connected terminals by area-weighted pressure continuity, injects source terminals, and sums radiation terminals. Remaining known limits are integer sample segment delays, fallback for co-located same-path terminals, and only first area-scattering law lowering.","evidence":"Added `AcousticGraphExpression` in `FaustExport`; focused tests `ParserSupportsReusableAcousticPortNetworkPrimitives`, `ParserSupportsTypedAcousticPathGraph`, `FaustCompilerValidatesAcousticPathGraphWhenInstalled`, `ParserSupportsPinkTromboneStyleTractVoice`, and `WaveguideTractDefaultsToAcousticUnitDelayGrid` passed; `dotnet build src/AquaSynth.Core/AquaSynth.Core.csproj --no-restore --disable-build-servers -p:UseSharedCompilation=false -p:BuildInParallel=false` passed."}
{"timestamp":"2026-05-26T19:10:00+01:00","topic":"physical-voice-graph-wave-clock-and-node-aggregation","claim":"Acoustic graph lowering now honors `wave_clock` delay strategy and aggregates co-located terminals into shared graph nodes. Sources can inject at connected nodes, and graph delay lowers through unit-grid, half-sample, linear fractional, Lagrange, or Thiran/allpass Faust primitives from physical segment length and `ma.SR`.","evidence":"Updated `AcousticGraphExpression` with wave-clock delay selection, same-position terminal node aggregation, connected-node source injection, and basic connection-law expression routing; focused tests `ParserSupportsReusableAcousticPortNetworkPrimitives`, `ParserSupportsTypedAcousticPathGraph`, `FaustCompilerValidatesAcousticPathGraphWhenInstalled`, `ParserSupportsPinkTromboneStyleTractVoice`, and `WaveguideTractDefaultsToAcousticUnitDelayGrid` passed; core build passed."}
{"timestamp":"2026-05-26T19:30:00+01:00","topic":"physical-voice-tract-graph-propagation","claim":"PT-shaped `tract` authoring can now opt into typed acoustic graph audio with `propagation=graph`. Generated tract acoustic networks now include typed terminals/connections, normalized tract-index positions, and a Thiran wave-clock default for graph propagation; legacy `propagation=waveguide` remains available as the old baseline.","evidence":"Added `TractPropagationMode.Graph`, parser alias `propagation=graph`, graph-first Faust routing for tract voices in graph mode, generated tract terminal/connection records, and focused test `TractVoiceCanLowerThroughAcousticGraph`; focused tests and core build passed."}

View file

@ -117,7 +117,7 @@ live_invariants:
- Physical voice modeling should generalize around continuous acoustic morphologies, acoustic port networks, source ports, branch/radiation ports, and wave-clock policies. A larynx, syrinx, or alien voice should differ by topology and source-port configuration, not by special-case modules. Faust `physmodels.lib`/`delays.lib` provide useful primitives (`pm.chain`, `pm.l2s`, `pm.waveguideFd*`, `de.fdelay*`, Thiran/allpass and Lagrange delays), but AquaSynth should still generate tract/branch scattering where live area controls and inspectable training parameters matter.
- The physical voice research packet now includes a practical implementation/researcher map (`research/physical-voice-modeling/PEOPLE.md`) and talk/transcript ledger (`research/physical-voice-modeling/talks/TALKS.md`). Birkholz/VocalTractLab pressures multi-level articulatory control and gestures; Story/TubeTalker pressures continuous parametric area functions and length/coupling; Smith pressures bidirectional delay-line scattering and fractional-delay waveguides; Fels/ArtiSynth pressures separation between biomechanics, acoustics, timeline controls, and diagnostics. These are reusable DSL semantics, not modules to clone.
- Physical voice implementation has started moving PT pressure into neutral acoustic owners: `AcousticPath`, `AcousticTerminal`, `AcousticConnection`, `AcousticSourcePort`, `AcousticBranch`, `AcousticRadiationPort`, `WaveClockPolicy`, and `AcousticPortNetwork`. Parser commands `path`, `terminal`, `connect`, `source_port`, `branch`, `radiation_port`, `wave_clock`, and `acoustic_network` now exist. Existing `tract_shape`, `glottis`, `tract_injection`, `nasal_branch`, and `tract` commands populate acoustic records as compatibility aliases while the current `Voice.Tract` Faust renderer still owns audio.
- `acoustic` / `acoustic_voice` can now render an `AcousticPortNetwork` through a compact Faust response proxy or, when typed terminals are present, a compiled bidirectional Faust segment graph. Graph lowering splits paths at terminal positions, aggregates co-located terminals into graph nodes, orders connection ports deterministically, scatters area-weighted/pressure/bypass connection groups, injects source terminals even at connected nodes, and sums radiation terminals. Segment delay follows physical segment length and the network `wave_clock` strategy through unit-grid, half-sample, linear fractional, Lagrange, or Thiran/allpass Faust delay primitives. Source, branch, radiation, terminal, and connection controls can bind through ordinary Aqua parameters into emitted Faust. Remaining pressure: demote PT-shaped `Voice.Tract` to graph sugar, deepen connection-law semantics, and run log-mel parity against the graph renderer. Do not expand the proxy into a second fake synthesizer.
- `acoustic` / `acoustic_voice` can now render an `AcousticPortNetwork` through a compact Faust response proxy or, when typed terminals are present, a compiled bidirectional Faust segment graph. Graph lowering splits paths at terminal positions, aggregates co-located terminals into graph nodes, orders connection ports deterministically, scatters area-weighted/pressure/bypass connection groups, injects source terminals even at connected nodes, and sums radiation terminals. Segment delay follows physical segment length and the network `wave_clock` strategy through unit-grid, half-sample, linear fractional, Lagrange, or Thiran/allpass Faust delay primitives. Source, branch, radiation, terminal, and connection controls can bind through ordinary Aqua parameters into emitted Faust. PT-shaped `tract` authoring can opt into graph audio with `propagation=graph`; legacy `propagation=waveguide` remains the comparison baseline. Remaining pressure: deepen connection-law semantics and run log-mel parity against the graph renderer. Do not expand the proxy into a second fake synthesizer.
- PT expressive parity should decompose into reusable Aqua primitives before audio golf: `tract_shape` owns continuous diameter/area geometry over normalized position, physical length (`length_cm`), interpolation/resampling, derived reflection coefficients, and per-cell acoustic delay; `tract` may resample that geometry to a chosen compiled `sections` grid without making authoring sample count anatomy; `glottis` owns excitation controls; `tract_injection` owns positioned frication/burst controls and now distributes frication pressure into waveguide section updates; `nasal_branch` owns a second diameter/area tube; `tract_motion` owns control slew and obstruction threshold; `propagation=waveguide` lowers tract voices into a Faust-friendly `wg_loop ~ si.bus(...)` feedback component with compiled right/left traveling-wave state, live per-section diameter/area/reflection fields, obstruction-history burst pressure, and legacy substep clock intent. Waveguide tracts now default to an acoustic unit-delay grid from physical length at the current 44.1 kHz target, so a 17 cm oral tract emits about 22 sections instead of treating PT's 44 half-sample cells as anatomy; `VocalTract.IndexScale` maps runtime PT-index controls onto that emitted grid. PT's 44 oral and 28 nasal cells are reference discretization, not Aqua morphology authority. `PinkTromboneParityFixtures` defines fixed Aqua DSL workouts, `PinkTromboneReferenceRenderer` renders deterministic PT-style fixture references, and `AudioComparison` includes log-mel cosine similarity. Current waveguide baseline is weak but measured: cosine about 0.61 open vowel, 0.63 front vowel, 0.54 nasal, 0.37 sibilant, and -0.08 closure-release. Remaining low-level gap is fractional-delay waveguide lowering whose clock derives from physical length, propagation speed, and sample rate so runtime morphology motion does not require recompilation; closure/release timing is still badly wrong.
- If dotnet builds hang in this workspace, stop orphan dotnet workers and retry with `--disable-build-servers -p:UseSharedCompilation=false -p:BuildInParallel=false`; plain parallel/shared builds can stall without useful diagnostics.
- eSpeak NG is an early parity reference for timing, IPA coverage, and log-mel pressure, not an anatomy oracle or final target.

View file

@ -258,6 +258,27 @@ public sealed class PatchScriptTests
Assert.Contains("patch_param_0", export.Source);
}
[Fact]
public void TractVoiceCanLowerThroughAcousticGraph()
{
var patch = PatchScript.Parse("""
tract_shape name=human length_cm=17 diameters=.6,.8,1.2,1.5,1.2,.8
tract shape=human propagation=graph freq=140 gain=.1 sustain=.2
""");
var voice = Assert.Single(patch.Voices);
Assert.Equal(TractPropagationMode.Graph, voice.Tract?.Propagation);
var network = Assert.Single(patch.AcousticNetworks);
Assert.NotEmpty(network.Terminals);
Assert.Contains("voices_0_source", network.Terminals);
Assert.Contains("voices_0_lip", network.Terminals);
var export = FaustEmitter.Emit(patch, new FaustExportOptions("tract_graph"));
Assert.Contains("acoustic_graph_radiated", export.Source);
Assert.Contains("de.fdelay1a", export.Source);
Assert.DoesNotContain("tract_lf", export.Source);
}
[Fact]
public void TractAreaFunctionOwnsContinuousMorphology()
{