From 6bc51fb287ad7f6684877fea99fec1a0fd4e0179 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 26 May 2026 19:09:14 +0100 Subject: [PATCH] Honor wave clocks in acoustic graph lowering --- docs/physical-voice-modeling-research.md | 14 +- docs/pink-trombone-dsl-parity-plan.md | 23 ++- src/AquaSynth.Core/FaustExport.cs | 190 ++++++++++++++---- state/evidence.jsonl | 1 + state/spine.yaml | 4 +- tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs | 28 ++- 6 files changed, 193 insertions(+), 67 deletions(-) diff --git a/docs/physical-voice-modeling-research.md b/docs/physical-voice-modeling-research.md index 372604a..1ed7f3d 100644 --- a/docs/physical-voice-modeling-research.md +++ b/docs/physical-voice-modeling-research.md @@ -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. diff --git a/docs/pink-trombone-dsl-parity-plan.md b/docs/pink-trombone-dsl-parity-plan.md index b2a4b0c..a558ff2 100644 --- a/docs/pink-trombone-dsl-parity-plan.md +++ b/docs/pink-trombone-dsl-parity-plan.md @@ -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 diff --git a/src/AquaSynth.Core/FaustExport.cs b/src/AquaSynth.Core/FaustExport.cs index 39c5c86..4b55c0e 100644 --- a/src/AquaSynth.Core/FaustExport.cs +++ b/src/AquaSynth.Core/FaustExport.cs @@ -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 sources, IReadOnlyDictionary radiation, IReadOnlyDictionary terminals, - IReadOnlyDictionary connections) + IReadOnlyDictionary connections, + IReadOnlyDictionary waveClocks) { + var waveClock = ResolveWaveClock(network, waveClocks, warnings, name); var networkTerminals = new List(); 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(StringComparer.OrdinalIgnoreCase); + var graphNodes = new List(); var segments = new List(); 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(), StringComparer.OrdinalIgnoreCase); + var incident = graphNodes.ToDictionary(node => node.Name, _ => new List(), 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(StringComparer.OrdinalIgnoreCase); + var nodeConnection = new Dictionary(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(); - 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 waveClocks, + List 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 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 Terminals); + private sealed record AcousticGraphPort(AcousticGraphSegment Segment, bool AtStart); private sealed class ParameterMap diff --git a/state/evidence.jsonl b/state/evidence.jsonl index aa1f642..4ff8a2d 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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."} diff --git a/state/spine.yaml b/state/spine.yaml index 5283d06..304fb0a 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -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. diff --git a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs index 77c5e69..7cfdf9b 100644 --- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs @@ -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);