Lower acoustic path graphs to Faust

This commit is contained in:
Meta Vens 2026-05-26 18:56:32 +01:00
commit 249f3058b1
6 changed files with 368 additions and 19 deletions

View file

@ -251,9 +251,12 @@ connection law. `source_port`, `radiation_port`, and `branch` remain ergonomic
commands, but they populate typed graph records rather than becoming the graph
law themselves.
The next implementation blocker is graph compilation. The compiler must split
paths at terminal positions, order the ports of each connection, assign
fractional-delay state from physical path length and wave-clock policy, inject
sources at the declared terminals, and terminate radiation ports with physical
reflection/output behavior. Expanding the response proxy before that would
create a second PT-shaped instrument wearing generic names.
The first graph compiler cut now exists. It splits paths at terminal positions,
creates bidirectional segment state in Faust, scatters connected terminals by
area-weighted pressure continuity, injects declared source ports at source
terminals, and radiates declared radiation ports. Faust validation passes for a
three-path trachea/oral/nasal graph. The remaining limits are named rather than
hidden: segment delays are currently integer sample delays at the 44.1 kHz
target, co-located terminals fall back to the response proxy until same-node
aggregation is explicit, and the connection laws are compiled only as the first
area-scattering form.

View file

@ -167,10 +167,16 @@ terminals. Authoring sugar can hide some ceremony later, but the compiler-facing
shape is geometry plus terminals plus connections. `AcousticBranch` is no
longer the future topology law; it is a side-branch shorthand over the graph.
The remaining implementation blocker is graph lowering, not topology naming.
The compiler must turn the typed graph into bidirectional waveguide Faust with
deterministic segment splitting, port ordering, scattering law selection,
source injection, radiation termination, and fractional-delay state.
The first graph lowering cut now compiles typed graphs into bidirectional Faust
segment state. It splits each path at terminal positions, orders connection
ports deterministically, scatters connection groups with area-weighted pressure
continuity, injects source terminals, and sums radiation terminals. Faust
validation passes on a three-path trachea/oral/nasal graph.
The remaining implementation pressure is now narrower: replace integer segment
delays with wave-clock-driven fractional delays, add explicit same-node
terminal aggregation for co-located sources/junctions, and broaden connection
laws beyond the first area-scattering form.
Authority map for the required rebuild:
@ -189,8 +195,8 @@ Authority map for the required rebuild:
- Shared paths: direct DSL authoring, PT compatibility commands, future
syrinx/reed/alien voices, and training/playground parameter bindings must all
commit through the same acoustic graph.
- Deletion line: do not expand the proxy into a second fake synthesizer. The
next implementation compiles `AcousticTerminal`/`AcousticConnection` records;
- Deletion line: do not expand the proxy into a second fake synthesizer.
`AcousticTerminal`/`AcousticConnection` records now own graph lowering;
`branch` can remain only as shorthand that emits those records.
Exact Pink Trombone timing still needs pressure, but the next backend cut is no

View file

@ -398,12 +398,23 @@ public static class FaustEmitter
var sources = patch.AcousticSourcePorts.ToDictionary(port => port.Name, StringComparer.OrdinalIgnoreCase);
var branches = patch.AcousticBranches.ToDictionary(branch => branch.Name, StringComparer.OrdinalIgnoreCase);
var radiation = patch.AcousticRadiationPorts.ToDictionary(port => port.Name, StringComparer.OrdinalIgnoreCase);
var terminals = patch.AcousticTerminals.ToDictionary(terminal => terminal.Name, StringComparer.OrdinalIgnoreCase);
var connections = patch.AcousticConnections.ToDictionary(connection => connection.Name, StringComparer.OrdinalIgnoreCase);
if (!paths.TryGetValue(network.PrimaryPath, out var primaryPath))
{
warnings.Add($"{name}: acoustic network `{network.Name}` has unknown primary path `{network.PrimaryPath}`");
return "0.0";
}
if (network.Terminals.Count > 0)
{
var graph = AcousticGraphExpression(source, patch, network, name, frequency, parameters, warnings, paths, sources, radiation, terminals, connections);
if (graph.Length > 0)
{
return graph;
}
}
var sourceExpressions = new List<string>();
foreach (var sourceName in network.SourcePorts)
{
@ -491,6 +502,238 @@ public static class FaustEmitter
return $"{name}_acoustic_radiated";
}
private static string AcousticGraphExpression(
StringBuilder source,
SynthPatch patch,
AcousticPortNetwork network,
string name,
string frequency,
ParameterMap parameters,
List<string> warnings,
IReadOnlyDictionary<string, AcousticPath> paths,
IReadOnlyDictionary<string, AcousticSourcePort> sources,
IReadOnlyDictionary<string, AcousticRadiationPort> radiation,
IReadOnlyDictionary<string, AcousticTerminal> terminals,
IReadOnlyDictionary<string, AcousticConnection> connections)
{
var networkTerminals = new List<AcousticTerminal>();
foreach (var terminalName in network.Terminals)
{
if (terminals.TryGetValue(terminalName, out var terminal))
{
networkTerminals.Add(terminal);
}
else
{
warnings.Add($"{name}: acoustic network `{network.Name}` has unknown terminal `{terminalName}`");
}
}
if (networkTerminals.Count < 2)
{
warnings.Add($"{name}: acoustic graph `{network.Name}` needs at least two valid terminals; using response proxy");
return "";
}
var terminalIndexes = networkTerminals
.Select((terminal, index) => (terminal.Name, index))
.ToDictionary(item => item.Name, item => item.index, StringComparer.OrdinalIgnoreCase);
var segments = new List<AcousticGraphSegment>();
foreach (var group in networkTerminals.GroupBy(terminal => terminal.Path, StringComparer.OrdinalIgnoreCase))
{
if (!paths.TryGetValue(group.Key, out var path))
{
warnings.Add($"{name}: acoustic graph `{network.Name}` terminal path `{group.Key}` is unknown");
continue;
}
var ordered = group
.OrderBy(terminal => Math.Clamp(terminal.Position, 0, 1))
.ThenBy(terminal => terminal.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var duplicatePosition = ordered
.GroupBy(terminal => MathF.Round(Math.Clamp(terminal.Position, 0, 1), 4))
.FirstOrDefault(positionGroup => positionGroup.Count() > 1);
if (duplicatePosition is not null)
{
warnings.Add($"{name}: acoustic graph `{network.Name}` has co-located terminals on path `{group.Key}`; using response proxy until same-node terminal aggregation is implemented");
return "";
}
for (var i = 0; i < ordered.Count - 1; i++)
{
if (Math.Abs(ordered[i + 1].Position - ordered[i].Position) < 0.0001f)
{
continue;
}
segments.Add(new AcousticGraphSegment(
segments.Count,
path,
ordered[i],
ordered[i + 1],
Math.Clamp(ordered[i].Position, 0, 1),
Math.Clamp(ordered[i + 1].Position, 0, 1)));
}
}
if (segments.Count == 0)
{
warnings.Add($"{name}: acoustic graph `{network.Name}` produced no path segments; using response proxy");
return "";
}
var incident = networkTerminals.ToDictionary(terminal => terminal.Name, _ => new List<AcousticGraphPort>(), StringComparer.OrdinalIgnoreCase);
foreach (var segment in segments)
{
incident[segment.Start.Name].Add(new AcousticGraphPort(segment, true));
incident[segment.End.Name].Add(new AcousticGraphPort(segment, false));
}
var terminalConnection = new Dictionary<string, AcousticConnection>(StringComparer.OrdinalIgnoreCase);
foreach (var connectionName in network.Connections)
{
if (!connections.TryGetValue(connectionName, out var connection))
{
warnings.Add($"{name}: acoustic network `{network.Name}` has unknown connection `{connectionName}`");
continue;
}
foreach (var terminalName in connection.Terminals)
{
if (terminalIndexes.ContainsKey(terminalName))
{
terminalConnection[terminalName] = connection;
}
}
}
source.AppendLine($"{name}_graph_drive = 1.0;");
foreach (var terminal in networkTerminals)
{
var terminalIndex = AcousticTerminalIndex(patch, terminal);
var terminalPath = $"/acoustic/terminals/{terminalIndex}";
source.AppendLine($"{name}_graph_terminal_area_{SafeIdentifier(terminal.Name)} = max(0.000001, {F(TerminalArea(paths, terminal))} * max(0.0, {parameters.Expression(OwnerField(terminalPath, "area_scale"), terminal.AreaScale)}));");
source.AppendLine($"{name}_graph_terminal_reflection_{SafeIdentifier(terminal.Name)} = {parameters.Expression(OwnerField(terminalPath, "reflection"), terminal.Reflection)};");
}
foreach (var connectionName in network.Connections)
{
if (!connections.TryGetValue(connectionName, out var connection))
{
continue;
}
var connectionIndex = AcousticConnectionIndex(patch, connection);
var connectionPath = $"/acoustic/connections/{connectionIndex}";
source.AppendLine($"{name}_graph_connection_coupling_{SafeIdentifier(connection.Name)} = clip01({parameters.Expression(OwnerField(connectionPath, "coupling"), connection.Coupling)});");
source.AppendLine($"{name}_graph_connection_loss_{SafeIdentifier(connection.Name)} = clip01({parameters.Expression(OwnerField(connectionPath, "loss"), connection.Loss)});");
}
foreach (var sourceName in network.SourcePorts)
{
if (sources.TryGetValue(sourceName, out var port))
{
source.AppendLine($"{name}_graph_source_{SafeIdentifier(sourceName)} = {AcousticSourceExpression(patch, port, frequency, parameters)};");
}
}
var stateCount = segments.Count * 2;
var stateInputs = segments.Select(segment => $"r{segment.Index}")
.Concat(segments.Select(segment => $"l{segment.Index}"))
.ToList();
var nextStates = new List<string>(stateCount);
source.AppendLine($"{name}_graph(x) = {name}_graph_loop ~ si.bus({stateCount}) : (si.block({stateCount}), _) with {{");
source.AppendLine($" {name}_graph_loop({string.Join(", ", stateInputs)}) = {string.Join(", ", NextStatesPlaceholder(stateCount))}, {name}_graph_out with {{");
foreach (var segment in segments)
{
source.AppendLine($" {name}_graph_in_{segment.Index}_start = l{segment.Index};");
source.AppendLine($" {name}_graph_in_{segment.Index}_end = r{segment.Index};");
}
var connectionGroups = network.Connections
.Select(connectionName => connections.TryGetValue(connectionName, out var connection) ? connection : null)
.Where(connection => connection is not null)
.Cast<AcousticConnection>()
.ToList();
foreach (var connection in connectionGroups)
{
var connectionTerminals = connection.Terminals
.Where(terminalIndexes.ContainsKey)
.Select(terminalName => terminals[terminalName])
.ToList();
var ports = connectionTerminals
.SelectMany(terminal => incident[terminal.Name].Select(port => (terminal, port)))
.ToList();
if (ports.Count < 2)
{
continue;
}
var safeConnection = SafeIdentifier(connection.Name);
var weightedIncoming = ports
.Select(item => $"{name}_graph_terminal_area_{SafeIdentifier(item.terminal.Name)} * {GraphIncoming(name, item.port)}");
var areaSum = ports.Select(item => $"{name}_graph_terminal_area_{SafeIdentifier(item.terminal.Name)}");
source.AppendLine($" {name}_graph_connection_pressure_{safeConnection} = 2.0 * ({string.Join(" + ", weightedIncoming)}) / max(0.000001, {string.Join(" + ", areaSum)});");
foreach (var (terminal, port) in ports)
{
var incoming = GraphIncoming(name, port);
var outgoing = GraphOutgoing(name, port);
source.AppendLine($" {outgoing} = (({name}_graph_connection_pressure_{safeConnection} - {incoming}) * {name}_graph_connection_coupling_{safeConnection} + {incoming} * (1.0 - {name}_graph_connection_coupling_{safeConnection})) * {name}_graph_connection_loss_{safeConnection};");
}
}
var radiated = new List<string>();
foreach (var terminal in networkTerminals)
{
var ports = incident[terminal.Name];
if (ports.Count == 0)
{
continue;
}
if (terminalConnection.ContainsKey(terminal.Name))
{
continue;
}
var safeTerminal = SafeIdentifier(terminal.Name);
var sourceInjection = terminal.Kind == AcousticTerminalKind.Source && sources.ContainsKey(terminal.Port.Length == 0 ? terminal.Name : terminal.Port)
? $"{name}_graph_source_{SafeIdentifier(terminal.Port.Length == 0 ? terminal.Name : terminal.Port)}"
: "0.0";
var reflection = $"{name}_graph_terminal_reflection_{safeTerminal}";
foreach (var port in ports)
{
var outgoing = GraphOutgoing(name, port);
var incoming = GraphIncoming(name, port);
source.AppendLine($" {outgoing} = {incoming} * {reflection} + ({sourceInjection}) / {F(Math.Max(1, ports.Count))};");
}
if (terminal.Kind == AcousticTerminalKind.Radiation && radiation.ContainsKey(terminal.Port.Length == 0 ? terminal.Name : terminal.Port))
{
var radiationPort = radiation[terminal.Port.Length == 0 ? terminal.Name : terminal.Port];
var radiationIndex = AcousticRadiationPortIndex(patch, radiationPort);
var radiationPath = $"/acoustic/radiation/{radiationIndex}";
var opening = $"max(0.0, {parameters.Expression(OwnerField(radiationPath, "opening"), radiationPort.Opening)})";
var loss = $"clip01({parameters.Expression(OwnerField(radiationPath, "loss"), radiationPort.Loss)})";
radiated.Add($"(({string.Join(" + ", ports.Select(port => GraphIncoming(name, port)))}) * {opening} * {loss})");
}
}
foreach (var segment in segments)
{
var delaySamples = Math.Max(1, (int)MathF.Round((segment.EndPosition - segment.StartPosition) * segment.Path.AreaFunction.LengthMeters / segment.Path.PropagationSpeedMetersPerSecond * 44100));
var loss = F(Math.Clamp(segment.Path.Loss, 0, 1));
source.AppendLine($" {name}_graph_next_r{segment.Index} = {GraphOutgoing(name, new AcousticGraphPort(segment, true))} : de.delay({delaySamples}, {delaySamples}) * {loss};");
source.AppendLine($" {name}_graph_next_l{segment.Index} = {GraphOutgoing(name, new AcousticGraphPort(segment, false))} : de.delay({delaySamples}, {delaySamples}) * {loss};");
nextStates.Add($"{name}_graph_next_r{segment.Index}");
}
nextStates.AddRange(segments.Select(segment => $"{name}_graph_next_l{segment.Index}"));
source.Replace(string.Join(", ", NextStatesPlaceholder(stateCount)), string.Join(", ", nextStates));
source.AppendLine($" {name}_graph_out = {(radiated.Count == 0 ? "0.0" : string.Join(" + ", radiated))};");
source.AppendLine(" };");
source.AppendLine("};");
source.AppendLine($"{name}_acoustic_graph_radiated = {name}_graph({name}_graph_drive);");
return $"{name}_acoustic_graph_radiated";
}
private static string AcousticSourceExpression(
SynthPatch patch,
AcousticSourcePort port,
@ -1057,6 +1300,44 @@ public static class FaustEmitter
: 0;
}
private static float TerminalArea(IReadOnlyDictionary<string, AcousticPath> paths, AcousticTerminal terminal)
{
if (!paths.TryGetValue(terminal.Path, out var path))
{
return 1;
}
var diameter = path.AreaFunction.DiameterAt(Math.Clamp(terminal.Position, 0, 1));
return Math.Max(0.000001f, diameter * diameter);
}
private static string GraphIncoming(string voiceName, AcousticGraphPort port) =>
port.AtStart
? $"{voiceName}_graph_in_{port.Segment.Index}_start"
: $"{voiceName}_graph_in_{port.Segment.Index}_end";
private static string GraphOutgoing(string voiceName, AcousticGraphPort port) =>
port.AtStart
? $"{voiceName}_graph_out_{port.Segment.Index}_start"
: $"{voiceName}_graph_out_{port.Segment.Index}_end";
private static IEnumerable<string> NextStatesPlaceholder(int count) =>
Enumerable.Range(0, count).Select(i => $"__graph_next_state_{i}__");
private static int AcousticConnectionIndex(SynthPatch patch, AcousticConnection connection)
{
for (var i = 0; i < patch.AcousticConnections.Count; i++)
{
if (ReferenceEquals(patch.AcousticConnections[i], connection) ||
patch.AcousticConnections[i].Name.Equals(connection.Name, StringComparison.OrdinalIgnoreCase))
{
return i;
}
}
return 0;
}
private static int AcousticBranchIndex(SynthPatch patch, AcousticBranch branch)
{
for (var i = 0; i < patch.AcousticBranches.Count; i++)
@ -1085,6 +1366,20 @@ public static class FaustEmitter
return 0;
}
private static int AcousticTerminalIndex(SynthPatch patch, AcousticTerminal terminal)
{
for (var i = 0; i < patch.AcousticTerminals.Count; i++)
{
if (ReferenceEquals(patch.AcousticTerminals[i], terminal) ||
patch.AcousticTerminals[i].Name.Equals(terminal.Name, StringComparison.OrdinalIgnoreCase))
{
return i;
}
}
return 0;
}
private static int AcousticSourcePortIndex(SynthPatch patch, AcousticSourcePort port)
{
for (var i = 0; i < patch.AcousticSourcePorts.Count; i++)
@ -1112,6 +1407,16 @@ public static class FaustEmitter
private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\"");
private sealed record AcousticGraphSegment(
int Index,
AcousticPath Path,
AcousticTerminal Start,
AcousticTerminal End,
float StartPosition,
float EndPosition);
private sealed record AcousticGraphPort(AcousticGraphSegment Segment, bool AtStart);
private sealed class ParameterMap
{
private readonly Dictionary<string, int> _parameterIndexes = new(StringComparer.OrdinalIgnoreCase);

View file

@ -148,3 +148,4 @@
{"timestamp":"2026-05-26T16:25:00+01:00","topic":"physical-voice-acoustic-port-model","claim":"AquaSynth now has first-cut neutral acoustic voice records and parser commands for paths, source ports, branches, radiation ports, wave clocks, and acoustic port networks. PT-shaped tract commands populate those records as compatibility aliases, while current Faust audio still renders through `Voice.Tract`. This creates the intended migration path: move lowering authority toward acoustic networks without forking a syrinx/bird/PT mode.","evidence":"Added `AcousticPath`, `AcousticSourcePort`, `AcousticBranch`, `AcousticRadiationPort`, `WaveClockPolicy`, `AcousticPortNetwork`; parser commands `path`, `source_port`, `branch`, `radiation_port`, `wave_clock`, `acoustic_network`; focused tests `ParserSupportsReusableAcousticPortNetworkPrimitives`, `ParserSupportsPinkTromboneStyleTractVoice`, and `WaveguideTractDefaultsToAcousticUnitDelayGrid` passed."}
{"timestamp":"2026-05-26T17:35:00+01:00","topic":"physical-voice-acoustic-network-proxy","claim":"Acoustic port networks are now audible through an `acoustic` / `acoustic_voice` Faust response proxy. The proxy derives modal source/body/branch/radiation expressions from declared acoustic records, so paired labial/syrinx-like networks can render without a special mode. Source, branch, and radiation controls bind through ordinary Aqua parameters into emitted Faust. This is a named proxy lane, not final waveguide truth; full lowering is blocked on explicit acoustic junction ownership.","evidence":"Added acoustic voice parsing and `AcousticNetworkExpression` Faust lowering; focused tests `ParserSupportsReusableAcousticPortNetworkPrimitives`, `ParserSupportsPinkTromboneStyleTractVoice`, and `WaveguideTractDefaultsToAcousticUnitDelayGrid` passed with `--disable-build-servers -p:UseSharedCompilation=false -p:BuildInParallel=false`; serial `dotnet build src/AquaSynth.Core/AquaSynth.Core.csproj --no-restore --disable-build-servers -p:UseSharedCompilation=false -p:BuildInParallel=false` passed."}
{"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."}

View file

@ -1,6 +1,6 @@
project: AquaSynth
spine_version: 1
updated: 2026-05-26T18:05:00+01:00
updated: 2026-05-26T18:35:00+01:00
prime_directive: >
Build coherent synth machinery. References exist to reveal missing structure,
@ -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 derived from source ports, primary path length/area summaries, branches, and radiation ports. Source, branch, and radiation controls can bind through ordinary Aqua parameters into emitted Faust. This is deliberately not the final waveguide truth renderer; it is the audible topology/proxy lane that lets syrinx-like and alien port networks exist before `Voice.Tract` is fully demoted. The topology decision is a path graph with typed terminals: paths own geometry, terminals own path positions and roles, connections own scattering groups and laws, and `branch` is only sugar. The current architectural blocker is graph compilation: split paths at terminals, order connection ports, assign fractional-delay state, inject sources, and terminate radiation in Faust. Do not expand the proxy into a second fake synthesizer to dodge that decision.
- `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, orders connection ports deterministically, scatters area-weighted connection groups, injects source terminals, and sums radiation terminals. Source, branch, radiation, terminal, and connection controls can bind through ordinary Aqua parameters into emitted Faust. Remaining pressure: segment delays are integer sample delays at the 44.1 kHz target, co-located terminals fall back to the response proxy until same-node aggregation is explicit, and connection laws beyond first area scattering still need real lowering. Do not expand the proxy into a second fake synthesizer to dodge those decisions.
- 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

@ -170,7 +170,7 @@ public sealed class PatchScriptTests
path name=trachea length_cm=12 diameters=.4,.7,1,1
path name=oral length_cm=17 diameters=.6,1.1,1.6,1.2,.8
source_port name=left_labium path=trachea kind=labial position=.1 pressure=@/voice/left_pressure tension=.55 opening=.4 noise=.02
source_port name=right_labium path=trachea kind=labial position=.1 pressure=.65 tension=.5 opening=.45 noise=.03 balance=.8
source_port name=right_labium path=trachea kind=labial position=.12 pressure=.65 tension=.5 opening=.45 noise=.03 balance=.8
branch name=throat from_path=trachea from_position=.9 to_path=oral kind=bronchial opening=@/voice/throat_opening coupling=.7
radiation_port name=mouth path=oral kind=lip position=1 opening=@/voice/mouth_opening reflection=-.82
wave_clock name=continuous strategy=thiran order=1 max_delay=4096 smoothing_ms=3
@ -205,10 +205,10 @@ public sealed class PatchScriptTests
Assert.Equal(network, voice.AcousticNetwork);
var export = FaustEmitter.Emit(patch, new FaustExportOptions("syrinxish"));
Assert.Contains("acoustic_source", export.Source);
Assert.Contains("acoustic_body", export.Source);
Assert.Contains("branch_throat", export.Source);
Assert.Contains("acoustic_radiated", export.Source);
Assert.Contains("acoustic_graph_radiated", export.Source);
Assert.Contains("graph_loop", export.Source);
Assert.Contains("graph_connection_pressure_throat_connection", export.Source);
Assert.Contains("graph_terminal_area_throat_from", export.Source);
Assert.Contains("patch_param_0", export.Source);
Assert.Contains("patch_param_1", export.Source);
Assert.Contains("patch_param_2", export.Source);
@ -244,6 +244,13 @@ public sealed class PatchScriptTests
Assert.Equal(["trachea_top", "oral_back", "nasal_gate"], connection.Terminals);
Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/terminals/5/area_scale");
Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/connections/0/coupling");
var export = FaustEmitter.Emit(patch, new FaustExportOptions("humanish"));
Assert.Contains("acoustic_graph_radiated", export.Source);
Assert.Contains("graph_connection_pressure_velopharynx", export.Source);
Assert.Contains("graph_next_r", export.Source);
Assert.Contains("graph_next_l", export.Source);
Assert.Contains("patch_param_0", export.Source);
}
[Fact]
@ -1147,6 +1154,33 @@ public sealed class PatchScriptTests
Assert.True(validation.Success, validation.Stderr);
}
[Fact]
public async Task FaustCompilerValidatesAcousticPathGraphWhenInstalled()
{
var export = FaustEmitter.EmitScript("""
path name=trachea length_cm=12 diameters=.4,.7,1,1
path name=oral length_cm=17 diameters=.6,1.1,1.6,1.2,.8
path name=nasal length_cm=10 diameters=.05,.3,.8,.5
source_port name=folds path=trachea kind=glottal position=0 pressure=.7 tension=.55 opening=.45
radiation_port name=mouth path=oral kind=lip position=1 opening=1.4 reflection=-.82
radiation_port name=nostril path=nasal kind=nostril position=1 opening=.5 reflection=-.45
terminal name=trachea_top path=trachea position=1 kind=junction area_scale=1
terminal name=oral_back path=oral position=0 kind=junction area_scale=1
terminal name=nasal_gate path=nasal position=0 kind=junction area_scale=.18
connect name=velopharynx terminals=trachea_top,oral_back,nasal_gate law=area_scatter coupling=.18
acoustic_network name=humanish path=oral sources=folds radiation=mouth,nostril terminals=trachea_top,oral_back,nasal_gate connections=velopharynx
acoustic network=humanish freq=130 gain=.1
""", new FaustExportOptions("acoustic_graph_validation"));
var validation = await FaustCompiler.ValidateAsync(export.Source);
if (validation is null)
{
return;
}
Assert.True(validation.Success, validation.Stderr);
}
[Fact]
public async Task FaustCompilerValidatesSpectralBankWhenInstalled()
{