Add typed acoustic path graph

This commit is contained in:
Meta Vens 2026-05-26 18:36:06 +01:00
commit ad39a89b89
7 changed files with 327 additions and 35 deletions

View file

@ -243,11 +243,17 @@ is to make `AcousticPortNetwork` own the waveguide renderer directly, at which
point `Voice.Tract` should become a larynx-shaped authoring convenience over
the same network rather than a separate audio authority.
That next cut is currently blocked on junction semantics, not on a missing
filter primitive. The model needs an explicit owner for N-port scattering,
deterministic port ordering, source injection, radiation termination, and
fractional-delay state. The existing `AcousticBranch` record is enough to say
"this tube attaches to that tube"; it does not yet say whether the attachment is
a two-port graft, a three-port nasal-style junction, or a general graph node
with pressure/volume-velocity conservation. Expanding the response proxy before
that decision would create a second PT-shaped instrument wearing generic names.
The topology decision is now a path graph with typed terminals. `AcousticPath`
owns tube geometry. `AcousticTerminal` owns a named position on a path and its
role (`junction`, `source`, `radiation`, boundary, or diagnostic probe).
`AcousticConnection` owns the set of terminals that scatter together and the
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.

View file

@ -143,50 +143,55 @@ delay is about 0.5 samples per cell. That makes PT's two tract updates per
output sample look like a discretization strategy for a half-sample cell grid,
not a reusable Aqua language concept.
The acoustic-port cut has also started. Aqua now has neutral model records for
`AcousticPath`, `AcousticSourcePort`, `AcousticBranch`,
`AcousticRadiationPort`, `WaveClockPolicy`, and `AcousticPortNetwork`.
Parser commands `path`, `source_port`, `branch`, `radiation_port`,
The acoustic graph cut has also started. Aqua now has neutral model records for
`AcousticPath`, `AcousticTerminal`, `AcousticConnection`,
`AcousticSourcePort`, `AcousticBranch`, `AcousticRadiationPort`,
`WaveClockPolicy`, and `AcousticPortNetwork`. Parser commands `path`,
`terminal`, `connect`, `source_port`, `branch`, `radiation_port`,
`wave_clock`, and `acoustic_network` can express a larynx, paired labial
syrinx-like sources, or stranger source/topology combinations without adding a
species mode. Existing PT-shaped commands now populate those acoustic records
as compatibility aliases: they no longer have to be the only authority surface
for the next renderer. A first `acoustic` voice command now lowers an
syrinx-like sources, a nasal/oral junction, or stranger source/topology
combinations without adding a species mode. Existing PT-shaped commands now
populate those acoustic records as compatibility aliases: source ports and
radiation ports create typed terminals, while `branch` creates branch endpoint
terminals plus a connection. A first `acoustic` voice command now lowers an
`AcousticPortNetwork` to an audible Faust response proxy, so non-larynx
topologies can be heard before the full waveguide network owns them. Source,
branch, and radiation fields can be bound to parameters and survive into the
emitted Faust, which keeps playground/training knobs attached to declared
acoustic owners instead of dead UI state.
### Current architectural blocker: junction ownership
### Graph ownership decision
The next cut is blocked on a real topology choice. `AcousticBranch` currently
names a secondary path attached between two paths, which is enough for the
response proxy and for PT-style nasal compatibility. It is not enough to lower
an arbitrary physical network into bidirectional waveguide Faust without
smuggling Pink Trombone assumptions back in.
The topology choice is now explicit: Aqua will use a path graph with typed
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.
Authority map for the required rebuild:
- Owner: the acoustic network lowering must own an ordered scattering graph,
not `Voice.Tract` and not the response proxy.
- Inputs: continuous paths, source ports, branch/radiation ports, wave-clock
policy, and live parameter bindings.
- Inputs: continuous paths, typed terminals, connections, source/radiation
ports, branch sugar, wave-clock policy, and live parameter bindings.
- Outputs: Faust state for delay-line segments, source injection points,
junction scattering, radiation, and diagnostics.
- Derived state: `tract`/`nasal_branch` become larynx/PT-shaped authoring
conveniences over the same acoustic graph; the response proxy remains a
preview renderer only.
- Forbidden writers: `Voice.Tract` must stop being a parallel audio authority
once graph lowering exists; branch/radiation controls must not be repaired by
a separate playground model.
once graph lowering exists; branch/radiation/source controls must not be
repaired by a separate playground model.
- 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 needs either explicit `AcousticJunction` semantics or a
stronger definition that makes every `branch` a concrete N-port scattering
junction with deterministic port ordering and fractional-delay state.
next implementation compiles `AcousticTerminal`/`AcousticConnection` records;
`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

View file

@ -261,6 +261,25 @@ public enum WaveClockDelayStrategy
CrossfadedVariable
}
public enum AcousticTerminalKind
{
Junction,
Source,
Radiation,
Open,
Closed,
Probe
}
public enum AcousticConnectionLaw
{
AreaScattering,
PressureContinuity,
AdmittanceScattering,
Lossy,
Bypass
}
public sealed record AcousticPath(
string Name,
TractAreaFunction AreaFunction,
@ -299,6 +318,25 @@ public sealed record AcousticRadiationPort(
float Reflection = -0.85f,
float Loss = 1);
public sealed record AcousticTerminal(
string Name,
string Path,
float Position,
AcousticTerminalKind Kind = AcousticTerminalKind.Junction,
string Port = "",
float AreaScale = 1,
float Reflection = 0);
public sealed record AcousticConnection(
string Name,
IReadOnlyList<string>? Terminals = null,
AcousticConnectionLaw Law = AcousticConnectionLaw.AreaScattering,
float Coupling = 1,
float Loss = 1)
{
public IReadOnlyList<string> Terminals { get; init; } = Terminals ?? Array.Empty<string>();
}
public sealed record WaveClockPolicy(
string Name,
WaveClockDelayStrategy Strategy = WaveClockDelayStrategy.UnitGrid,
@ -312,11 +350,15 @@ public sealed record AcousticPortNetwork(
string WaveClock = "",
IReadOnlyList<string>? SourcePorts = null,
IReadOnlyList<string>? Branches = null,
IReadOnlyList<string>? RadiationPorts = null)
IReadOnlyList<string>? RadiationPorts = null,
IReadOnlyList<string>? Terminals = null,
IReadOnlyList<string>? Connections = null)
{
public IReadOnlyList<string> SourcePorts { get; init; } = SourcePorts ?? Array.Empty<string>();
public IReadOnlyList<string> Branches { get; init; } = Branches ?? Array.Empty<string>();
public IReadOnlyList<string> RadiationPorts { get; init; } = RadiationPorts ?? Array.Empty<string>();
public IReadOnlyList<string> Terminals { get; init; } = Terminals ?? Array.Empty<string>();
public IReadOnlyList<string> Connections { get; init; } = Connections ?? Array.Empty<string>();
}
public sealed record GlottalSource(
@ -603,6 +645,8 @@ public sealed record SynthPatch
public IReadOnlyList<AcousticSourcePort> AcousticSourcePorts { get; init; } = Array.Empty<AcousticSourcePort>();
public IReadOnlyList<AcousticBranch> AcousticBranches { get; init; } = Array.Empty<AcousticBranch>();
public IReadOnlyList<AcousticRadiationPort> AcousticRadiationPorts { get; init; } = Array.Empty<AcousticRadiationPort>();
public IReadOnlyList<AcousticTerminal> AcousticTerminals { get; init; } = Array.Empty<AcousticTerminal>();
public IReadOnlyList<AcousticConnection> AcousticConnections { get; init; } = Array.Empty<AcousticConnection>();
public IReadOnlyList<WaveClockPolicy> WaveClocks { get; init; } = Array.Empty<WaveClockPolicy>();
public IReadOnlyList<AcousticPortNetwork> AcousticNetworks { get; init; } = Array.Empty<AcousticPortNetwork>();
public IReadOnlyList<OperatorGraph> OperatorGraphs { get; init; } = Array.Empty<OperatorGraph>();

View file

@ -54,6 +54,10 @@ public static class PatchScript
private readonly Dictionary<string, AcousticBranch> _acousticBranchesByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<AcousticRadiationPort> _acousticRadiationPorts = [];
private readonly Dictionary<string, AcousticRadiationPort> _acousticRadiationPortsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<AcousticTerminal> _acousticTerminals = [];
private readonly Dictionary<string, AcousticTerminal> _acousticTerminalsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<AcousticConnection> _acousticConnections = [];
private readonly Dictionary<string, AcousticConnection> _acousticConnectionsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<WaveClockPolicy> _waveClocks = [];
private readonly Dictionary<string, WaveClockPolicy> _waveClocksByName = new(StringComparer.OrdinalIgnoreCase);
private readonly List<AcousticPortNetwork> _acousticNetworks = [];
@ -88,6 +92,8 @@ public static class PatchScript
AcousticSourcePorts = _acousticSourcePorts,
AcousticBranches = _acousticBranches,
AcousticRadiationPorts = _acousticRadiationPorts,
AcousticTerminals = _acousticTerminals,
AcousticConnections = _acousticConnections,
WaveClocks = _waveClocks,
AcousticNetworks = _acousticNetworks,
OperatorGraphs = _operatorGraphs,
@ -177,6 +183,14 @@ public static class PatchScript
FlushPendingOperatorGraph();
AddAcousticRadiationPort(fields, line);
break;
case "terminal":
FlushPendingOperatorGraph();
AddAcousticTerminal(fields, line);
break;
case "connection":
FlushPendingOperatorGraph();
AddAcousticConnection(fields, line);
break;
case "wave_clock":
FlushPendingOperatorGraph();
AddWaveClock(fields, line);
@ -523,6 +537,12 @@ public static class PatchScript
GetBoundFloat(fields, line, 1, $"/acoustic/sources/{_acousticSourcePorts.Count}/balance", "balance"),
TryGetAny(fields, ["active"], out var active) ? ParseBool(active, line) : true);
AddAcousticSourcePortRecord(port);
AddAcousticTerminalRecord(new AcousticTerminal(
port.Name,
port.Path,
port.Position,
AcousticTerminalKind.Source,
port.Name));
}
private void AddAcousticBranch(IReadOnlyDictionary<string, string> fields, int line)
@ -550,6 +570,25 @@ public static class PatchScript
GetBoundFloat(fields, line, 1, $"/acoustic/branches/{_acousticBranches.Count}/coupling", "coupling"),
TryGetAny(fields, ["passive"], out var passive) ? ParseBool(passive, line) : true);
AddAcousticBranchRecord(branch);
AddAcousticTerminalRecord(new AcousticTerminal(
BranchFromTerminalName(branch),
branch.FromPath,
branch.FromPosition,
AcousticTerminalKind.Junction,
branch.Name,
Math.Max(0, branch.Coupling)));
AddAcousticTerminalRecord(new AcousticTerminal(
BranchToTerminalName(branch),
branch.ToPath,
branch.ToPosition,
AcousticTerminalKind.Junction,
branch.Name,
Math.Max(0, branch.Opening)));
AddAcousticConnectionRecord(new AcousticConnection(
BranchConnectionName(branch),
[BranchFromTerminalName(branch), BranchToTerminalName(branch)],
AcousticConnectionLaw.AreaScattering,
branch.Coupling));
}
private void AddAcousticRadiationPort(IReadOnlyDictionary<string, string> fields, int line)
@ -571,6 +610,59 @@ public static class PatchScript
GetBoundFloat(fields, line, -0.85f, $"/acoustic/radiation/{_acousticRadiationPorts.Count}/reflection", "reflection"),
GetBoundFloat(fields, line, 1, $"/acoustic/radiation/{_acousticRadiationPorts.Count}/loss", "loss"));
AddAcousticRadiationPortRecord(port);
AddAcousticTerminalRecord(new AcousticTerminal(
port.Name,
port.Path,
port.Position,
AcousticTerminalKind.Radiation,
port.Name,
Math.Max(0, port.Opening),
port.Reflection));
}
private void AddAcousticTerminal(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_acousticTerminalsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate acoustic terminal `{name}`");
}
var path = Required(fields, "path", line);
RequireAcousticPath(path, line);
var terminal = new AcousticTerminal(
name,
path,
GetBoundFloat(fields, line, 0, $"/acoustic/terminals/{_acousticTerminals.Count}/position", "position", "pos", "at"),
ParseAcousticTerminalKind(GetAny(fields, ["kind", "terminal_kind", "type"], "junction"), line),
GetAny(fields, ["port", "ref"], ""),
GetBoundFloat(fields, line, 1, $"/acoustic/terminals/{_acousticTerminals.Count}/area_scale", "area_scale", "area", "admittance"),
GetBoundFloat(fields, line, 0, $"/acoustic/terminals/{_acousticTerminals.Count}/reflection", "reflection"));
AddAcousticTerminalRecord(terminal);
}
private void AddAcousticConnection(IReadOnlyDictionary<string, string> fields, int line)
{
var name = Required(fields, "name", line);
if (_acousticConnectionsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"duplicate acoustic connection `{name}`");
}
var terminals = ParseNameList(RequiredAny(fields, ["terminals", "ports"], line));
if (terminals.Count < 2)
{
throw new PatchScriptException(line, "connection needs at least two terminals");
}
foreach (var terminal in terminals) RequireAcousticTerminal(terminal, line);
var connection = new AcousticConnection(
name,
terminals,
ParseAcousticConnectionLaw(GetAny(fields, ["law", "scatter", "mode"], "area_scatter"), line),
GetBoundFloat(fields, line, 1, $"/acoustic/connections/{_acousticConnections.Count}/coupling", "coupling"),
GetBoundFloat(fields, line, 1, $"/acoustic/connections/{_acousticConnections.Count}/loss", "loss"));
AddAcousticConnectionRecord(connection);
}
private void AddWaveClock(IReadOnlyDictionary<string, string> fields, int line)
@ -612,11 +704,15 @@ public static class PatchScript
waveClock,
ParseNameList(GetAny(fields, ["sources", "source_ports"], "")),
ParseNameList(GetAny(fields, ["branches"], "")),
ParseNameList(GetAny(fields, ["radiation", "radiation_ports"], "")));
ParseNameList(GetAny(fields, ["radiation", "radiation_ports"], "")),
ParseNameList(GetAny(fields, ["terminals"], "")),
ParseNameList(GetAny(fields, ["connections"], "")));
foreach (var sourceName in network.SourcePorts) RequireAcousticSourcePort(sourceName, line);
foreach (var branchName in network.Branches) RequireAcousticBranch(branchName, line);
foreach (var radiationName in network.RadiationPorts) RequireAcousticRadiationPort(radiationName, line);
AddAcousticNetworkRecord(network);
foreach (var terminalName in network.Terminals) RequireAcousticTerminal(terminalName, line);
foreach (var connectionName in network.Connections) RequireAcousticConnection(connectionName, line);
AddAcousticNetworkRecord(ExpandNetworkGraphSugar(network));
}
private void AddAcousticPathRecord(AcousticPath path)
@ -647,6 +743,20 @@ public static class PatchScript
_acousticRadiationPortsByName[port.Name] = port;
}
private void AddAcousticTerminalRecord(AcousticTerminal terminal)
{
if (_acousticTerminalsByName.ContainsKey(terminal.Name)) return;
_acousticTerminals.Add(terminal);
_acousticTerminalsByName[terminal.Name] = terminal;
}
private void AddAcousticConnectionRecord(AcousticConnection connection)
{
if (_acousticConnectionsByName.ContainsKey(connection.Name)) return;
_acousticConnections.Add(connection);
_acousticConnectionsByName[connection.Name] = connection;
}
private void AddWaveClockRecord(WaveClockPolicy policy)
{
if (_waveClocksByName.ContainsKey(policy.Name)) return;
@ -661,6 +771,51 @@ public static class PatchScript
_acousticNetworksByName[network.Name] = network;
}
private AcousticPortNetwork ExpandNetworkGraphSugar(AcousticPortNetwork network)
{
var terminals = new List<string>(network.Terminals);
var connections = new List<string>(network.Connections);
foreach (var sourceName in network.SourcePorts)
{
AddUnique(terminals, sourceName);
}
foreach (var radiationName in network.RadiationPorts)
{
AddUnique(terminals, radiationName);
}
foreach (var branchName in network.Branches)
{
if (!_acousticBranchesByName.TryGetValue(branchName, out var branch))
{
continue;
}
AddUnique(terminals, BranchFromTerminalName(branch));
AddUnique(terminals, BranchToTerminalName(branch));
AddUnique(connections, BranchConnectionName(branch));
}
return network with
{
Terminals = terminals,
Connections = connections
};
}
private static void AddUnique(List<string> values, string value)
{
if (!values.Contains(value, StringComparer.OrdinalIgnoreCase))
{
values.Add(value);
}
}
private static string BranchFromTerminalName(AcousticBranch branch) => $"{branch.Name}_from";
private static string BranchToTerminalName(AcousticBranch branch) => $"{branch.Name}_to";
private static string BranchConnectionName(AcousticBranch branch) => $"{branch.Name}_connection";
private void RequireAcousticPath(string name, int line)
{
if (!_acousticPathsByName.ContainsKey(name))
@ -693,6 +848,22 @@ public static class PatchScript
}
}
private void RequireAcousticTerminal(string name, int line)
{
if (!_acousticTerminalsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"unknown acoustic terminal `{name}`");
}
}
private void RequireAcousticConnection(string name, int line)
{
if (!_acousticConnectionsByName.ContainsKey(name))
{
throw new PatchScriptException(line, $"unknown acoustic connection `{name}`");
}
}
private Dictionary<string, string> ExpandVoiceFields(Dictionary<string, string> fields, int line)
{
var expanded = new Dictionary<string, string>(_defaults, StringComparer.OrdinalIgnoreCase);
@ -2109,6 +2280,8 @@ public static class PatchScript
"source_port" or "acoustic_source" or "port_source" => "source_port",
"branch" or "acoustic_branch" => "branch",
"radiation_port" or "radiation" or "acoustic_radiation" => "radiation_port",
"terminal" or "term" or "acoustic_terminal" => "terminal",
"connect" or "connection" or "junction" or "acoustic_connection" => "connection",
"wave_clock" or "waveclock" or "clock" => "wave_clock",
"acoustic_network" or "port_network" or "network" => "acoustic_network",
"v" or "voice" => "voice",
@ -2241,6 +2414,27 @@ public static class PatchScript
_ => throw new PatchScriptException(line, $"unknown acoustic radiation kind `{value}`")
};
private static AcousticTerminalKind ParseAcousticTerminalKind(string value, int line) => value.ToLowerInvariant() switch
{
"junction" or "node" or "scatter" => AcousticTerminalKind.Junction,
"source" or "excitation" => AcousticTerminalKind.Source,
"radiation" or "radiator" or "output" => AcousticTerminalKind.Radiation,
"open" => AcousticTerminalKind.Open,
"closed" or "wall" => AcousticTerminalKind.Closed,
"probe" or "diagnostic" => AcousticTerminalKind.Probe,
_ => throw new PatchScriptException(line, $"unknown acoustic terminal kind `{value}`")
};
private static AcousticConnectionLaw ParseAcousticConnectionLaw(string value, int line) => value.ToLowerInvariant() switch
{
"area" or "area_scatter" or "area_scattering" or "scatter" => AcousticConnectionLaw.AreaScattering,
"pressure" or "pressure_continuity" => AcousticConnectionLaw.PressureContinuity,
"admittance" or "admittance_scatter" or "admittance_scattering" => AcousticConnectionLaw.AdmittanceScattering,
"lossy" or "loss" => AcousticConnectionLaw.Lossy,
"bypass" or "pass" => AcousticConnectionLaw.Bypass,
_ => throw new PatchScriptException(line, $"unknown acoustic connection law `{value}`")
};
private static WaveClockDelayStrategy ParseWaveClockDelayStrategy(string value, int line) => value.ToLowerInvariant() switch
{
"unit" or "unit_grid" or "grid" => WaveClockDelayStrategy.UnitGrid,
@ -2260,6 +2454,9 @@ public static class PatchScript
private static string Required(IReadOnlyDictionary<string, string> fields, string key, int line) =>
fields.TryGetValue(key, out var value) ? value : throw new PatchScriptException(line, $"missing `{key}`");
private static string RequiredAny(IReadOnlyDictionary<string, string> fields, string[] keys, int line) =>
TryGetAny(fields, keys, out var value) ? value : throw new PatchScriptException(line, $"missing `{string.Join("|", keys)}`");
private static string GetAny(IReadOnlyDictionary<string, string> fields, string[] keys, string fallback) =>
TryGetAny(fields, keys, out var value) ? value : fallback;

View file

@ -147,3 +147,4 @@
{"timestamp":"2026-05-26T15:30:00+01:00","topic":"physical-voice-practical-researchers","claim":"The practical physical voice modeling lineage points to reusable Aqua semantics: Birkholz/VocalTractLab for multi-level gestures and articulatory controls, Story/TubeTalker for continuous area functions and tract-length/coupling parameters, Smith for bidirectional delay-line scattering and fractional-delay waveguides, and Fels/ArtiSynth for separating biomechanics, acoustics, timeline control, and diagnostics. These are architecture pressures, not modules to clone.","evidence":"Added `research/physical-voice-modeling/PEOPLE.md`, `talks/TALKS.md`, downloaded VTL, Praat, Gnuspeech, SndKit, Story/Birkholz/Fels paper sources, captured talk pages, and saved public YouTube transcripts for Julius Smith CIRMMT and Brad Story NCVS."}
{"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."}

View file

@ -1,6 +1,6 @@
project: AquaSynth
spine_version: 1
updated: 2026-05-26T17:35:00+01:00
updated: 2026-05-26T18:05:00+01:00
prime_directive: >
Build coherent synth machinery. References exist to reveal missing structure,
@ -116,8 +116,8 @@ live_invariants:
- Pink Trombone parity is structural waveguide pressure, not permission to add more formant proxy knobs. AquaSynth now has a `tract` DSL command that lowers into `Voice.Tract` with PT-shaped runtime controls and a Faust tract-voice proxy; it is still an ordinary voice treatment, not a separate graph owner and not an exact 44-cell/28-cell bidirectional waveguide.
- 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`, `AcousticSourcePort`, `AcousticBranch`, `AcousticRadiationPort`, `WaveClockPolicy`, and `AcousticPortNetwork`. Parser commands `path`, `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 current architectural blocker is junction ownership: before full bidirectional lowering, Aqua needs explicit semantics for N-port scattering, deterministic port ordering, source injection, radiation termination, and fractional-delay state. Do not expand the proxy into a second fake synthesizer to dodge that decision.
- 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.
- 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

@ -184,8 +184,15 @@ public sealed class PatchScriptTests
Assert.Equal(["left_labium", "right_labium"], network.SourcePorts);
Assert.Equal(["throat"], network.Branches);
Assert.Equal(["mouth"], network.RadiationPorts);
Assert.Contains("left_labium", network.Terminals);
Assert.Contains("mouth", network.Terminals);
Assert.Contains("throat_from", network.Terminals);
Assert.Contains("throat_to", network.Terminals);
Assert.Contains("throat_connection", network.Connections);
Assert.Equal(2, patch.AcousticPaths.Count);
Assert.Equal(5, patch.AcousticTerminals.Count);
Assert.Single(patch.AcousticConnections);
Assert.All(patch.AcousticSourcePorts, port => Assert.Equal(AcousticSourceKind.Labial, port.Kind));
Assert.Equal(AcousticBranchKind.Bronchial, Assert.Single(patch.AcousticBranches).Kind);
Assert.Equal(AcousticRadiationKind.Lip, Assert.Single(patch.AcousticRadiationPorts).Kind);
@ -207,6 +214,38 @@ public sealed class PatchScriptTests
Assert.Contains("patch_param_2", export.Source);
}
[Fact]
public void ParserSupportsTypedAcousticPathGraph()
{
var patch = PatchScript.Parse("""
param path=/voice/velopharynx default=.18 min=0 max=1 step=.001
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=@/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
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(["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.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/terminals/5/area_scale");
Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/connections/0/coupling");
}
[Fact]
public void TractAreaFunctionOwnsContinuousMorphology()
{