Add acoustic network Faust proxy

This commit is contained in:
Meta Vens 2026-05-26 16:52:27 +01:00
commit 56a2b22edc
7 changed files with 326 additions and 13 deletions

View file

@ -225,9 +225,29 @@ demotes to an `AcousticPath` plus `AcousticBranch`, and each `tract` voice
gets an `AcousticPortNetwork` over generated oral/source/branch/radiation
records.
This is intentionally a structural first cut. Faust lowering still uses the
current `Voice.Tract` renderer, but the neutral acoustic records now exist so
the next cuts can move waveguide/proxy lowering authority out of PT-shaped
fields without losing fixture compatibility. A syrinx can already be authored
as two labial `source_port` records plus bronchial/tract `branch` topology; the
next proof is to give that network an audible lowering path.
The second cut gives those records an audible Faust proxy. An `acoustic` /
`acoustic_voice` command can reference an `acoustic_network`; Faust lowering
builds a compact source/body/radiation model from the declared source ports,
primary path, branches, and radiation ports. This is a response-proxy renderer,
not the final waveguide truth renderer: it derives modal resonances from path
length and area summaries, then mixes branch and radiation responses. It exists
so syrinx-like and alien topologies can be heard and tested before the full
bidirectional network lowering is complete. Source-port pressure/tension/opening
and branch/radiation opening-style controls can bind through normal Aqua
parameters, so the knobs used by training and playground surfaces are attached
to acoustic records instead of side-channel state.
A syrinx can now be authored as two labial `source_port` records plus
bronchial/tract `branch` topology without a species mode. The next deeper cut
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.

View file

@ -151,7 +151,42 @@ Parser commands `path`, `source_port`, `branch`, `radiation_port`,
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.
for the next renderer. 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
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.
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.
- 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.
- 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.
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

@ -185,6 +185,8 @@ public static class FaustEmitter
var oscillator = oscillatorOverride ??
(voice.Tract is { } tract
? TractExpression(source, tract, ownerPath, name, frequency, noteGate, parameters)
: voice.AcousticNetwork is { } acousticNetwork
? AcousticNetworkExpression(source, patch, acousticNetwork, ownerPath, name, frequency, parameters, warnings)
: OscillatorExpression(patch, voice, ownerPath, frequency, dutyExpression, fmIndex, parameters));
var envelope = voice.RateLevelEnvelope is not null
? RateLevelEnvelopeExpression(voice.RateLevelEnvelope, noteGate)
@ -382,6 +384,144 @@ public static class FaustEmitter
};
}
private static string AcousticNetworkExpression(
StringBuilder source,
SynthPatch patch,
AcousticPortNetwork network,
string ownerPath,
string name,
string frequency,
ParameterMap parameters,
List<string> warnings)
{
var paths = patch.AcousticPaths.ToDictionary(path => path.Name, StringComparer.OrdinalIgnoreCase);
var sources = patch.AcousticSourcePorts.ToDictionary(port => port.Name, StringComparer.OrdinalIgnoreCase);
var branches = patch.AcousticBranches.ToDictionary(branch => branch.Name, StringComparer.OrdinalIgnoreCase);
var radiation = patch.AcousticRadiationPorts.ToDictionary(port => port.Name, StringComparer.OrdinalIgnoreCase);
if (!paths.TryGetValue(network.PrimaryPath, out var primaryPath))
{
warnings.Add($"{name}: acoustic network `{network.Name}` has unknown primary path `{network.PrimaryPath}`");
return "0.0";
}
var sourceExpressions = new List<string>();
foreach (var sourceName in network.SourcePorts)
{
if (!sources.TryGetValue(sourceName, out var port))
{
warnings.Add($"{name}: acoustic network `{network.Name}` has unknown source port `{sourceName}`");
continue;
}
sourceExpressions.Add(AcousticSourceExpression(patch, port, frequency, parameters));
}
if (sourceExpressions.Count == 0)
{
sourceExpressions.Add("0.0");
warnings.Add($"{name}: acoustic network `{network.Name}` has no valid source ports");
}
var baseInput = $"({string.Join(" + ", sourceExpressions)})";
var lengthMeters = Math.Max(0.001f, primaryPath.AreaFunction.LengthMeters);
var quarterWave = primaryPath.PropagationSpeedMetersPerSecond / (4 * lengthMeters);
var back = primaryPath.AreaFunction.AverageDiameter(0.1f, 0.35f);
var middle = primaryPath.AreaFunction.AverageDiameter(0.35f, 0.7f);
var front = primaryPath.AreaFunction.AverageDiameter(0.7f, 1f);
var reflectionEnergy = primaryPath.AreaFunction.ReflectionCoefficients.Count == 0
? 0.12f
: MathF.Min(1, primaryPath.AreaFunction.ReflectionCoefficients.Sum(item => MathF.Abs(item)) / primaryPath.AreaFunction.ReflectionCoefficients.Count);
var loss = F(Math.Clamp(primaryPath.Loss, 0, 1));
var q = F(2.5f + reflectionEnergy * 8f + (1 - Math.Clamp(primaryPath.Loss, 0, 1)) * 18f);
source.AppendLine($"{name}_acoustic_source = {baseInput};");
source.AppendLine($"{name}_acoustic_f1 = max(40.0, {F(quarterWave)} * (1.0 + {F(back - middle)} * 0.18));");
source.AppendLine($"{name}_acoustic_f2 = max(80.0, {F(quarterWave * 3)} * (1.0 + {F(middle - front)} * 0.14));");
source.AppendLine($"{name}_acoustic_f3 = max(120.0, {F(quarterWave * 5)} * (1.0 + {F(front - back)} * 0.10));");
source.AppendLine($"{name}_acoustic_body = ({name}_acoustic_source * 0.12 + ({name}_acoustic_source : fi.resonbp({name}_acoustic_f1, {q}, 1.0)) * 0.9 + ({name}_acoustic_source : fi.resonbp({name}_acoustic_f2, {q}, 1.0)) * 0.65 + ({name}_acoustic_source : fi.resonbp({name}_acoustic_f3, {q}, 1.0)) * 0.35) * {loss};");
var branchExpressions = new List<string>();
foreach (var branchName in network.Branches)
{
if (!branches.TryGetValue(branchName, out var branch) ||
!paths.TryGetValue(branch.ToPath, out var branchPath))
{
warnings.Add($"{name}: acoustic network `{network.Name}` has unknown branch `{branchName}`");
continue;
}
var branchLength = Math.Max(0.001f, branchPath.AreaFunction.LengthMeters);
var branchQuarterWave = branchPath.PropagationSpeedMetersPerSecond / (4 * branchLength);
var branchIndex = AcousticBranchIndex(patch, branch);
var branchPathName = $"/acoustic/branches/{branchIndex}";
var branchOpening = $"clip01({parameters.Expression(OwnerField(branchPathName, "opening"), branch.Opening)})";
var branchCoupling = $"max(0.0, {parameters.Expression(OwnerField(branchPathName, "coupling"), branch.Coupling)})";
var branchQ = F(2f + Math.Clamp(branch.Coupling, 0, 1) * 8f);
var branchKindGain = branch.Kind == AcousticBranchKind.Nasal ? "0.75" : branch.Kind == AcousticBranchKind.Bronchial ? "0.55" : "0.45";
source.AppendLine($"{name}_branch_{SafeIdentifier(branch.Name)} = ({name}_acoustic_source : fi.resonbp({F(branchQuarterWave)}, {branchQ}, 1.0) : fi.lowpass(2, {F(branchQuarterWave * 8)})) * {branchOpening} * {branchCoupling} * {branchKindGain};");
branchExpressions.Add($"{name}_branch_{SafeIdentifier(branch.Name)}");
}
var radiationExpressions = new List<string>();
foreach (var radiationName in network.RadiationPorts)
{
if (!radiation.TryGetValue(radiationName, out var port))
{
warnings.Add($"{name}: acoustic network `{network.Name}` has unknown radiation port `{radiationName}`");
continue;
}
var radiationIndex = AcousticRadiationPortIndex(patch, port);
var radiationPath = $"/acoustic/radiation/{radiationIndex}";
var opening = $"max(0.0, {parameters.Expression(OwnerField(radiationPath, "opening"), port.Opening)})";
var reflection = $"min(1.0, abs({parameters.Expression(OwnerField(radiationPath, "reflection"), port.Reflection)}))";
var portLoss = $"clip01({parameters.Expression(OwnerField(radiationPath, "loss"), port.Loss)})";
var highpass = port.Kind is AcousticRadiationKind.Lip or AcousticRadiationKind.Beak
? "80.0"
: port.Kind == AcousticRadiationKind.Nostril
? "40.0"
: "20.0";
radiationExpressions.Add($"(({name}_acoustic_body : fi.highpass(1, {highpass})) * {opening} * (0.55 + 0.45 * {reflection}) * {portLoss})");
}
if (radiationExpressions.Count == 0)
{
radiationExpressions.Add($"{name}_acoustic_body");
}
var branchMix = branchExpressions.Count == 0 ? "0.0" : string.Join(" + ", branchExpressions);
source.AppendLine($"{name}_acoustic_branches = {branchMix};");
source.AppendLine($"{name}_acoustic_radiated = ({string.Join(" + ", radiationExpressions)}) + {name}_acoustic_branches;");
return $"{name}_acoustic_radiated";
}
private static string AcousticSourceExpression(
SynthPatch patch,
AcousticSourcePort port,
string frequency,
ParameterMap parameters)
{
var index = AcousticSourcePortIndex(patch, port);
var path = $"/acoustic/sources/{index}";
var pressure = $"clip01({parameters.Expression(OwnerField(path, "pressure"), port.Pressure)})";
var tension = $"clip01({parameters.Expression(OwnerField(path, "tension"), port.Tension)})";
var opening = $"max(0.0, {parameters.Expression(OwnerField(path, "opening"), port.Opening)})";
var noise = $"clip01({parameters.Expression(OwnerField(path, "noise"), port.Noise)})";
var balance = parameters.Expression(OwnerField(path, "balance"), port.Balance);
var detune = port.Kind == AcousticSourceKind.Labial ? $" * (1.0 + ({balance} - 0.5) * 0.018)" : "";
var phase = $"os.phasor(1.0, {frequency}{detune})";
return port.Kind switch
{
AcousticSourceKind.Glottal or AcousticSourceKind.Labial =>
$"((sin(2.0 * ma.PI * {phase}) - {tension} * 0.35 * sin(4.0 * ma.PI * {phase})) * {pressure} * {opening} + no.noise * {noise} * {pressure} * (1.0 - {tension})) * {balance}",
AcousticSourceKind.Reed =>
$"(ma.tanh(sin(2.0 * ma.PI * {phase}) * (1.0 + {pressure} * 8.0)) * {opening} + no.noise * {noise} * 0.2) * {balance}",
AcousticSourceKind.TurbulenceJet =>
$"no.noise * {noise} * {pressure} * {opening} * {balance}",
AcousticSourceKind.Click =>
$"no.noise * {pressure} * {opening} * exp(0.0 - age * 120.0) * {balance}",
AcousticSourceKind.Synthetic =>
$"sin(2.0 * ma.PI * {phase}) * {pressure} * {opening} * {balance}",
_ => "0.0"
};
}
private static string TractExpression(
StringBuilder source,
VocalTract tract,
@ -917,6 +1057,59 @@ public static class FaustEmitter
: 0;
}
private static int AcousticBranchIndex(SynthPatch patch, AcousticBranch branch)
{
for (var i = 0; i < patch.AcousticBranches.Count; i++)
{
if (ReferenceEquals(patch.AcousticBranches[i], branch) ||
patch.AcousticBranches[i].Name.Equals(branch.Name, StringComparison.OrdinalIgnoreCase))
{
return i;
}
}
return 0;
}
private static int AcousticRadiationPortIndex(SynthPatch patch, AcousticRadiationPort port)
{
for (var i = 0; i < patch.AcousticRadiationPorts.Count; i++)
{
if (ReferenceEquals(patch.AcousticRadiationPorts[i], port) ||
patch.AcousticRadiationPorts[i].Name.Equals(port.Name, StringComparison.OrdinalIgnoreCase))
{
return i;
}
}
return 0;
}
private static int AcousticSourcePortIndex(SynthPatch patch, AcousticSourcePort port)
{
for (var i = 0; i < patch.AcousticSourcePorts.Count; i++)
{
if (ReferenceEquals(patch.AcousticSourcePorts[i], port) ||
patch.AcousticSourcePorts[i].Name.Equals(port.Name, StringComparison.OrdinalIgnoreCase))
{
return i;
}
}
return 0;
}
private static string SafeIdentifier(string value)
{
var builder = new StringBuilder(value.Length);
foreach (var character in value)
{
builder.Append(char.IsAsciiLetterOrDigit(character) ? character : '_');
}
return builder.Length == 0 ? "unnamed" : builder.ToString();
}
private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\"");
private sealed class ParameterMap

View file

@ -189,6 +189,10 @@ public static class PatchScript
FlushPendingOperatorGraph();
Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), VoicePath(Voices.Count), line));
break;
case "acoustic_voice":
FlushPendingOperatorGraph();
Voices.Add(ParseAcousticVoice(ExpandVoiceFields(fields, line), VoicePath(Voices.Count), line));
break;
case "tract":
FlushPendingOperatorGraph();
Voices.Add(ParseTractVoice(ExpandVoiceFields(fields, line), VoicePath(Voices.Count), line));
@ -904,10 +908,36 @@ public static class PatchScript
FormantFrames = formantFrames,
FormantFrameRateHz = GetBoundFloat(fields, line, 0.5f, OwnerField(ownerPath, "color/vowel_rate"), "vowel_hz", "vowel_rate", "vowels_hz"),
Modulators = modulators,
Gain = GetBoundFloat(fields, line, 0.2f, OwnerField(ownerPath, "gain"), "gain", "g") * gainScale
Gain = GetBoundFloat(fields, line, 0.2f, OwnerField(ownerPath, "gain"), "gain", "g") * gainScale,
AcousticNetwork = ParseAcousticNetworkReference(fields, line)
};
}
private Voice ParseAcousticVoice(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var voiceFields = new Dictionary<string, string>(fields, StringComparer.OrdinalIgnoreCase);
if (!HasAny(voiceFields, "wave", "w"))
{
voiceFields["wave"] = "saw";
}
if (!HasAny(voiceFields, "gain", "g"))
{
voiceFields["gain"] = "0.35";
}
if (!HasAny(voiceFields, "sustain", "s", "gate", "hold", "duration"))
{
voiceFields["sustain"] = "0.35";
}
var voice = ParseVoice(voiceFields, ownerPath, line);
if (voice.AcousticNetwork is null)
{
throw new PatchScriptException(line, "acoustic voice needs `network` or `acoustic_network`");
}
return voice;
}
private Voice ParseTractVoice(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var voiceFields = new Dictionary<string, string>(fields, StringComparer.OrdinalIgnoreCase);
@ -1508,6 +1538,20 @@ public static class PatchScript
return shape.AreaFunction;
}
private AcousticPortNetwork? ParseAcousticNetworkReference(IReadOnlyDictionary<string, string> fields, int line)
{
if (!TryGetAny(fields, ["network", "acoustic_network"], out var networkName))
{
return null;
}
if (!_acousticNetworksByName.TryGetValue(networkName, out var network))
{
throw new PatchScriptException(line, $"unknown acoustic network `{networkName}`");
}
return network;
}
private GlottalSource? ParseGlottalSourceReference(IReadOnlyDictionary<string, string> fields, string ownerPath, int line)
{
var hasInlineGlottis = HasAny(fields, "aspiration", "breath", "skew", "open_phase");
@ -2068,6 +2112,7 @@ public static class PatchScript
"wave_clock" or "waveclock" or "clock" => "wave_clock",
"acoustic_network" or "port_network" or "network" => "acoustic_network",
"v" or "voice" => "voice",
"acoustic" or "acoustic_voice" or "av" => "acoustic_voice",
"tract" or "vt" or "tractvoice" or "tract_voice" => "tract",
"opgraph" or "ops" or "operators" => "opgraph",
"operator" or "op" => "operator",

View file

@ -146,3 +146,4 @@
{"timestamp":"2026-05-26T14:50:00+01:00","topic":"physical-voice-research-packet","claim":"Physical voice modeling research is now persisted as a local packet under `research/physical-voice-modeling/`, with a distilled summary, source manifest, hashes, local Faust library captures, an open waveguide vocal-tract PDF, and downloaded HTML snapshots for fractional-delay vocal-tract and DDSP review sources.","evidence":"Added `research/physical-voice-modeling/README.md`, `SUMMARY.md`, `SOURCES.md`, and source files under `research/physical-voice-modeling/sources/`; updated docs and spine to point at the packet."}
{"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."}

View file

@ -1,6 +1,6 @@
project: AquaSynth
spine_version: 1
updated: 2026-05-26T16:25:00+01:00
updated: 2026-05-26T17:35:00+01:00
prime_directive: >
Build coherent synth machinery. References exist to reveal missing structure,
@ -117,6 +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`, `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.
- 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

@ -164,15 +164,18 @@ public sealed class PatchScriptTests
public void ParserSupportsReusableAcousticPortNetworkPrimitives()
{
var patch = PatchScript.Parse("""
param path=/voice/left_pressure default=.7 min=0 max=1 step=.001
param path=/voice/throat_opening default=.8 min=0 max=1 step=.001
param path=/voice/mouth_opening default=1.2 min=0 max=2 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
source_port name=left_labium path=trachea kind=labial position=.1 pressure=.7 tension=.55 opening=.4 noise=.02
source_port name=left_labium path=trachea kind=labial position=.1 pressure=@/voice/left_pressure tension=.55 opening=.4 noise=.02
source_port name=right_labium path=trachea kind=labial position=.1 pressure=.65 tension=.5 opening=.45 noise=.03 balance=.8
branch name=throat from_path=trachea from_position=.9 to_path=oral kind=bronchial opening=.8 coupling=.7
radiation_port name=mouth path=oral kind=lip position=1 opening=1.4 reflection=-.82
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
acoustic_network name=syrinxish path=trachea wave_clock=continuous sources=left_labium,right_labium branches=throat radiation=mouth
v w=saw f=140 gain=.1
acoustic network=syrinxish freq=140 gain=.1
""");
var network = Assert.Single(patch.AcousticNetworks);
@ -187,6 +190,21 @@ public sealed class PatchScriptTests
Assert.Equal(AcousticBranchKind.Bronchial, Assert.Single(patch.AcousticBranches).Kind);
Assert.Equal(AcousticRadiationKind.Lip, Assert.Single(patch.AcousticRadiationPorts).Kind);
Assert.Equal(WaveClockDelayStrategy.FractionalThiran, Assert.Single(patch.WaveClocks).Strategy);
Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/sources/0/pressure");
Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/branches/0/opening");
Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/radiation/0/opening");
var voice = Assert.Single(patch.Voices);
Assert.Equal(network, voice.AcousticNetwork);
var export = FaustEmitter.Emit(patch, new FaustExportOptions("syrinxish"));
Assert.Contains("acoustic_source", export.Source);
Assert.Contains("acoustic_body", export.Source);
Assert.Contains("branch_throat", export.Source);
Assert.Contains("acoustic_radiated", export.Source);
Assert.Contains("patch_param_0", export.Source);
Assert.Contains("patch_param_1", export.Source);
Assert.Contains("patch_param_2", export.Source);
}
[Fact]