From 56a2b22edcb26dccab0e4d7035f429299bbe5760 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 26 May 2026 16:52:27 +0100 Subject: [PATCH] Add acoustic network Faust proxy --- docs/physical-voice-modeling-research.md | 32 ++- docs/pink-trombone-dsl-parity-plan.md | 37 +++- src/AquaSynth.Core/FaustExport.cs | 193 ++++++++++++++++++ src/AquaSynth.Core/PatchScript.cs | 47 ++++- state/evidence.jsonl | 1 + state/spine.yaml | 3 +- tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs | 26 ++- 7 files changed, 326 insertions(+), 13 deletions(-) diff --git a/docs/physical-voice-modeling-research.md b/docs/physical-voice-modeling-research.md index fd4f17c..fe2d48e 100644 --- a/docs/physical-voice-modeling-research.md +++ b/docs/physical-voice-modeling-research.md @@ -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. diff --git a/docs/pink-trombone-dsl-parity-plan.md b/docs/pink-trombone-dsl-parity-plan.md index 7e432d0..35e6f1a 100644 --- a/docs/pink-trombone-dsl-parity-plan.md +++ b/docs/pink-trombone-dsl-parity-plan.md @@ -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 diff --git a/src/AquaSynth.Core/FaustExport.cs b/src/AquaSynth.Core/FaustExport.cs index eb5f359..aa9c154 100644 --- a/src/AquaSynth.Core/FaustExport.cs +++ b/src/AquaSynth.Core/FaustExport.cs @@ -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 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(); + 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(); + 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(); + 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 diff --git a/src/AquaSynth.Core/PatchScript.cs b/src/AquaSynth.Core/PatchScript.cs index 9bb72ed..1f2eb79 100644 --- a/src/AquaSynth.Core/PatchScript.cs +++ b/src/AquaSynth.Core/PatchScript.cs @@ -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 fields, string ownerPath, int line) + { + var voiceFields = new Dictionary(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 fields, string ownerPath, int line) { var voiceFields = new Dictionary(fields, StringComparer.OrdinalIgnoreCase); @@ -1508,6 +1538,20 @@ public static class PatchScript return shape.AreaFunction; } + private AcousticPortNetwork? ParseAcousticNetworkReference(IReadOnlyDictionary 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 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", diff --git a/state/evidence.jsonl b/state/evidence.jsonl index d13199c..ceb7167 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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."} diff --git a/state/spine.yaml b/state/spine.yaml index 8ba08b1..bb9760d 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -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. diff --git a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs index 3d44ae9..00e956e 100644 --- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs @@ -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]