Honor wave clocks in acoustic graph lowering

This commit is contained in:
Meta Vens 2026-05-26 19:09:14 +01:00
commit 6bc51fb287
6 changed files with 193 additions and 67 deletions

View file

@ -255,8 +255,12 @@ 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.
three-path trachea/oral/nasal graph. The next cut added wave-clock lowering and
same-node aggregation: segment delay now follows the network's `wave_clock`
strategy (`unit`, half-sample, linear fractional, Lagrange, or Thiran/allpass),
and co-located terminals on one path become a shared graph node so source,
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.

View file

@ -173,10 +173,16 @@ 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.
The graph compiler now also honors `wave_clock` delay strategy. Segment delay
comes from physical segment length, propagation speed, and `ma.SR`, then lowers
through unit-grid, half-sample, linear fractional, Lagrange, or Thiran/allpass
Faust delay primitives. Co-located terminals on one path now aggregate into a
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.
Authority map for the required rebuild:
@ -200,11 +206,10 @@ Authority map for the required rebuild:
`branch` can remain only as shorthand that emits those records.
Exact Pink Trombone timing still needs pressure, but the next backend cut is no
longer "add more substeps." The current Faust-friendly compromise chooses a
unit-delay scattering grid from physical length. The deeper target remains
fractional-delay waveguide lowering whose clock comes from physical length,
propagation speed, and sample rate while morphology moves through runtime
controls without recompiling the patch.
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
`Voice.Tract` baseline.
## Cut Line

View file

@ -400,6 +400,7 @@ public static class FaustEmitter
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);
var waveClocks = patch.WaveClocks.ToDictionary(clock => clock.Name, StringComparer.OrdinalIgnoreCase);
if (!paths.TryGetValue(network.PrimaryPath, out var primaryPath))
{
warnings.Add($"{name}: acoustic network `{network.Name}` has unknown primary path `{network.PrimaryPath}`");
@ -408,7 +409,7 @@ public static class FaustEmitter
if (network.Terminals.Count > 0)
{
var graph = AcousticGraphExpression(source, patch, network, name, frequency, parameters, warnings, paths, sources, radiation, terminals, connections);
var graph = AcousticGraphExpression(source, patch, network, name, frequency, parameters, warnings, paths, sources, radiation, terminals, connections, waveClocks);
if (graph.Length > 0)
{
return graph;
@ -514,8 +515,10 @@ public static class FaustEmitter
IReadOnlyDictionary<string, AcousticSourcePort> sources,
IReadOnlyDictionary<string, AcousticRadiationPort> radiation,
IReadOnlyDictionary<string, AcousticTerminal> terminals,
IReadOnlyDictionary<string, AcousticConnection> connections)
IReadOnlyDictionary<string, AcousticConnection> connections,
IReadOnlyDictionary<string, WaveClockPolicy> waveClocks)
{
var waveClock = ResolveWaveClock(network, waveClocks, warnings, name);
var networkTerminals = new List<AcousticTerminal>();
foreach (var terminalName in network.Terminals)
{
@ -537,6 +540,8 @@ public static class FaustEmitter
var terminalIndexes = networkTerminals
.Select((terminal, index) => (terminal.Name, index))
.ToDictionary(item => item.Name, item => item.index, StringComparer.OrdinalIgnoreCase);
var terminalNodes = new Dictionary<string, AcousticGraphNode>(StringComparer.OrdinalIgnoreCase);
var graphNodes = new List<AcousticGraphNode>();
var segments = new List<AcousticGraphSegment>();
foreach (var group in networkTerminals.GroupBy(terminal => terminal.Path, StringComparer.OrdinalIgnoreCase))
{
@ -547,17 +552,27 @@ public static class FaustEmitter
}
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 "";
}
.OrderBy(positionGroup => positionGroup.Key)
.ThenBy(positionGroup => positionGroup.Select(terminal => terminal.Name).Order(StringComparer.OrdinalIgnoreCase).First())
.Select(positionGroup =>
{
var nodeTerminals = positionGroup
.OrderBy(terminal => terminal.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var node = new AcousticGraphNode(
string.Join("_", nodeTerminals.Select(terminal => SafeIdentifier(terminal.Name))),
group.Key,
positionGroup.Key,
nodeTerminals);
graphNodes.Add(node);
foreach (var terminal in nodeTerminals)
{
terminalNodes[terminal.Name] = node;
}
return node;
})
.ToList();
for (var i = 0; i < ordered.Count - 1; i++)
{
@ -571,8 +586,8 @@ public static class FaustEmitter
path,
ordered[i],
ordered[i + 1],
Math.Clamp(ordered[i].Position, 0, 1),
Math.Clamp(ordered[i + 1].Position, 0, 1)));
ordered[i].Position,
ordered[i + 1].Position));
}
}
@ -582,7 +597,7 @@ public static class FaustEmitter
return "";
}
var incident = networkTerminals.ToDictionary(terminal => terminal.Name, _ => new List<AcousticGraphPort>(), StringComparer.OrdinalIgnoreCase);
var incident = graphNodes.ToDictionary(node => node.Name, _ => new List<AcousticGraphPort>(), StringComparer.OrdinalIgnoreCase);
foreach (var segment in segments)
{
incident[segment.Start.Name].Add(new AcousticGraphPort(segment, true));
@ -590,6 +605,7 @@ public static class FaustEmitter
}
var terminalConnection = new Dictionary<string, AcousticConnection>(StringComparer.OrdinalIgnoreCase);
var nodeConnection = new Dictionary<string, AcousticConnection>(StringComparer.OrdinalIgnoreCase);
foreach (var connectionName in network.Connections)
{
if (!connections.TryGetValue(connectionName, out var connection))
@ -602,6 +618,10 @@ public static class FaustEmitter
if (terminalIndexes.ContainsKey(terminalName))
{
terminalConnection[terminalName] = connection;
if (terminalNodes.TryGetValue(terminalName, out var node))
{
nodeConnection[node.Name] = connection;
}
}
}
}
@ -614,6 +634,11 @@ public static class FaustEmitter
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 node in graphNodes)
{
source.AppendLine($"{name}_graph_node_area_{node.Name} = max(0.000001, {string.Join(" + ", node.Terminals.Select(terminal => $"{name}_graph_terminal_area_{SafeIdentifier(terminal.Name)}"))});");
source.AppendLine($"{name}_graph_node_reflection_{node.Name} = ({string.Join(" + ", node.Terminals.Select(terminal => $"{name}_graph_terminal_reflection_{SafeIdentifier(terminal.Name)}"))}) / {F(Math.Max(1, node.Terminals.Count))};");
}
foreach (var connectionName in network.Connections)
{
@ -656,12 +681,13 @@ public static class FaustEmitter
.ToList();
foreach (var connection in connectionGroups)
{
var connectionTerminals = connection.Terminals
.Where(terminalIndexes.ContainsKey)
.Select(terminalName => terminals[terminalName])
var connectionNodes = connection.Terminals
.Where(terminalNodes.ContainsKey)
.Select(terminalName => terminalNodes[terminalName])
.DistinctBy(node => node.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var ports = connectionTerminals
.SelectMany(terminal => incident[terminal.Name].Select(port => (terminal, port)))
var ports = connectionNodes
.SelectMany(node => incident[node.Name].Select(port => (node, port)))
.ToList();
if (ports.Count < 2)
{
@ -669,36 +695,38 @@ public static class FaustEmitter
}
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 connectionPressure = ConnectionPressureExpression(name, connection, ports);
source.AppendLine($" {name}_graph_connection_pressure_{safeConnection} = {connectionPressure};");
foreach (var (node, 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 sourceInjection = NodeSourceExpression(name, node, sources);
var bypassInput = ports.Count == 1
? incoming
: $"(({string.Join(" + ", ports.Where(item => item.port != port).Select(item => GraphIncoming(name, item.port)))}) / {F(ports.Count - 1)})";
var scattered = connection.Law == AcousticConnectionLaw.Bypass
? bypassInput
: $"({name}_graph_connection_pressure_{safeConnection} - {incoming})";
source.AppendLine($" {outgoing} = (({scattered}) * {name}_graph_connection_coupling_{safeConnection} + {incoming} * (1.0 - {name}_graph_connection_coupling_{safeConnection}) + ({sourceInjection}) / {F(Math.Max(1, incident[node.Name].Count))}) * {name}_graph_connection_loss_{safeConnection};");
}
}
var radiated = new List<string>();
foreach (var terminal in networkTerminals)
foreach (var node in graphNodes)
{
var ports = incident[terminal.Name];
var ports = incident[node.Name];
if (ports.Count == 0)
{
continue;
}
if (terminalConnection.ContainsKey(terminal.Name))
if (nodeConnection.ContainsKey(node.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}";
var sourceInjection = NodeSourceExpression(name, node, sources);
var reflection = $"{name}_graph_node_reflection_{node.Name}";
foreach (var port in ports)
{
var outgoing = GraphOutgoing(name, port);
@ -706,7 +734,7 @@ public static class FaustEmitter
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))
foreach (var terminal in node.Terminals.Where(terminal => 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);
@ -719,10 +747,12 @@ public static class FaustEmitter
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};");
var delayExpression = SegmentDelayExpression(segment);
var maxDelay = SegmentMaxDelay(segment, waveClock);
source.AppendLine($" {name}_graph_delay_{segment.Index} = {delayExpression};");
source.AppendLine($" {name}_graph_next_r{segment.Index} = {GraphOutgoing(name, new AcousticGraphPort(segment, true))} : {WaveClockDelayExpression(waveClock, maxDelay, $"{name}_graph_delay_{segment.Index}")} * {loss};");
source.AppendLine($" {name}_graph_next_l{segment.Index} = {GraphOutgoing(name, new AcousticGraphPort(segment, false))} : {WaveClockDelayExpression(waveClock, maxDelay, $"{name}_graph_delay_{segment.Index}")} * {loss};");
nextStates.Add($"{name}_graph_next_r{segment.Index}");
}
nextStates.AddRange(segments.Select(segment => $"{name}_graph_next_l{segment.Index}"));
@ -1311,6 +1341,80 @@ public static class FaustEmitter
return Math.Max(0.000001f, diameter * diameter);
}
private static WaveClockPolicy ResolveWaveClock(
AcousticPortNetwork network,
IReadOnlyDictionary<string, WaveClockPolicy> waveClocks,
List<string> warnings,
string voiceName)
{
if (network.WaveClock.Length == 0)
{
return new WaveClockPolicy("__default", WaveClockDelayStrategy.FractionalLinear, 1, 4096, 0);
}
if (waveClocks.TryGetValue(network.WaveClock, out var waveClock))
{
return waveClock;
}
warnings.Add($"{voiceName}: acoustic network `{network.Name}` references missing wave clock `{network.WaveClock}`; using fractional linear delay");
return new WaveClockPolicy("__missing", WaveClockDelayStrategy.FractionalLinear, 1, 4096, 0);
}
private static string SegmentDelayExpression(AcousticGraphSegment segment)
{
var segmentLengthMeters = Math.Max(0.000001f, (segment.EndPosition - segment.StartPosition) * segment.Path.AreaFunction.LengthMeters);
return $"max(1.0, {F(segmentLengthMeters / segment.Path.PropagationSpeedMetersPerSecond)} * ma.SR)";
}
private static int SegmentMaxDelay(AcousticGraphSegment segment, WaveClockPolicy waveClock)
{
var maxAt44k = (int)MathF.Ceiling(Math.Max(1, (segment.EndPosition - segment.StartPosition) * segment.Path.AreaFunction.LengthMeters / segment.Path.PropagationSpeedMetersPerSecond * 44100) + 4);
return Math.Max(maxAt44k, Math.Max(1, waveClock.MaxDelaySamples));
}
private static string WaveClockDelayExpression(WaveClockPolicy waveClock, int maxDelay, string delayExpression)
{
var delay = $"min({F(maxDelay - 1)}, max(1.0, {delayExpression}))";
return waveClock.Strategy switch
{
WaveClockDelayStrategy.UnitGrid => $"de.delay({maxDelay}, int({delay}))",
WaveClockDelayStrategy.HalfSampleGrid => $"de.fdelay({maxDelay}, round({delay} * 2.0) * 0.5)",
WaveClockDelayStrategy.FractionalLagrange => $"de.fdelayltv({Math.Clamp(waveClock.FractionalOrder, 1, 5)}, {maxDelay}, {delay})",
WaveClockDelayStrategy.FractionalThiran => $"de.fdelay{Math.Clamp(waveClock.FractionalOrder, 1, 4)}a({maxDelay}, {delay})",
WaveClockDelayStrategy.CrossfadedVariable => $"de.fdelayltv({Math.Clamp(waveClock.FractionalOrder, 1, 5)}, {maxDelay}, {delay})",
_ => $"de.fdelay({maxDelay}, {delay})"
};
}
private static string ConnectionPressureExpression(
string voiceName,
AcousticConnection connection,
IReadOnlyList<(AcousticGraphNode node, AcousticGraphPort port)> ports)
{
if (connection.Law == AcousticConnectionLaw.PressureContinuity)
{
return $"2.0 * ({string.Join(" + ", ports.Select(item => GraphIncoming(voiceName, item.port)))}) / {F(Math.Max(1, ports.Count))}";
}
var weightedIncoming = ports
.Select(item => $"{voiceName}_graph_node_area_{item.node.Name} * {GraphIncoming(voiceName, item.port)}");
var areaSum = ports.Select(item => $"{voiceName}_graph_node_area_{item.node.Name}");
return $"2.0 * ({string.Join(" + ", weightedIncoming)}) / max(0.000001, {string.Join(" + ", areaSum)})";
}
private static string NodeSourceExpression(
string voiceName,
AcousticGraphNode node,
IReadOnlyDictionary<string, AcousticSourcePort> sources)
{
var sourceTerms = node.Terminals
.Where(terminal => terminal.Kind == AcousticTerminalKind.Source && sources.ContainsKey(terminal.Port.Length == 0 ? terminal.Name : terminal.Port))
.Select(terminal => $"{voiceName}_graph_source_{SafeIdentifier(terminal.Port.Length == 0 ? terminal.Name : terminal.Port)}")
.ToList();
return sourceTerms.Count == 0 ? "0.0" : string.Join(" + ", sourceTerms);
}
private static string GraphIncoming(string voiceName, AcousticGraphPort port) =>
port.AtStart
? $"{voiceName}_graph_in_{port.Segment.Index}_start"
@ -1410,11 +1514,17 @@ public static class FaustEmitter
private sealed record AcousticGraphSegment(
int Index,
AcousticPath Path,
AcousticTerminal Start,
AcousticTerminal End,
AcousticGraphNode Start,
AcousticGraphNode End,
float StartPosition,
float EndPosition);
private sealed record AcousticGraphNode(
string Name,
string Path,
float Position,
IReadOnlyList<AcousticTerminal> Terminals);
private sealed record AcousticGraphPort(AcousticGraphSegment Segment, bool AtStart);
private sealed class ParameterMap

View file

@ -149,3 +149,4 @@
{"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."}
{"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."}

View file

@ -1,6 +1,6 @@
project: AquaSynth
spine_version: 1
updated: 2026-05-26T18:35:00+01:00
updated: 2026-05-26T19:10: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 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.
- `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.
- 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=.12 pressure=.65 tension=.5 opening=.45 noise=.03 balance=.8
source_port name=right_labium path=trachea kind=labial position=.1 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
@ -209,6 +209,9 @@ public sealed class PatchScriptTests
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("graph_node_area_left_labium_right_labium", export.Source);
Assert.Contains("graph_source_right_labium", export.Source);
Assert.Contains("de.fdelay1a", export.Source);
Assert.Contains("patch_param_0", export.Source);
Assert.Contains("patch_param_1", export.Source);
Assert.Contains("patch_param_2", export.Source);
@ -222,34 +225,36 @@ 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
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
source_port name=folds path=trachea kind=glottal position=1 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=trachea_bottom path=trachea position=0 kind=closed reflection=.75
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=@/voice/velopharynx
connect name=velopharynx terminals=trachea_top,oral_back,nasal_gate law=area_scatter coupling=@/voice/velopharynx
acoustic_network name=humanish path=oral sources=folds radiation=mouth,nostril terminals=trachea_top,oral_back,nasal_gate connections=velopharynx
connect name=velopharynx terminals=folds,oral_back,nasal_gate law=area_scatter coupling=@/voice/velopharynx
acoustic_network name=humanish path=oral sources=folds radiation=mouth,nostril terminals=trachea_bottom,oral_back,nasal_gate connections=velopharynx
acoustic network=humanish freq=130 gain=.1
""");
var network = Assert.Single(patch.AcousticNetworks);
Assert.Equal(["folds"], network.SourcePorts);
Assert.Equal(["mouth", "nostril"], network.RadiationPorts);
Assert.Equal(["trachea_top", "oral_back", "nasal_gate", "folds", "mouth", "nostril"], network.Terminals);
Assert.Equal(["trachea_bottom", "oral_back", "nasal_gate", "folds", "mouth", "nostril"], network.Terminals);
Assert.Equal(["velopharynx"], network.Connections);
var connection = Assert.Single(patch.AcousticConnections);
Assert.Equal(AcousticConnectionLaw.AreaScattering, connection.Law);
Assert.Equal(["trachea_top", "oral_back", "nasal_gate"], connection.Terminals);
Assert.Equal(["folds", "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_source_folds", export.Source);
Assert.Contains("graph_next_r", export.Source);
Assert.Contains("graph_next_l", export.Source);
Assert.Contains("de.fdelay", export.Source);
Assert.Contains("patch_param_0", export.Source);
}
@ -1161,14 +1166,15 @@ 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
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
source_port name=folds path=trachea kind=glottal position=1 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=trachea_bottom path=trachea position=0 kind=closed reflection=.75
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
connect name=velopharynx terminals=folds,oral_back,nasal_gate law=area_scatter coupling=.18
wave_clock name=continuous strategy=thiran order=1 max_delay=4096 smoothing_ms=3
acoustic_network name=humanish path=oral wave_clock=continuous sources=folds radiation=mouth,nostril terminals=trachea_bottom,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);