Add acoustic network Faust proxy
This commit is contained in:
parent
48b52ac686
commit
56a2b22edc
7 changed files with 326 additions and 13 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue