Lower acoustic path graphs to Faust
This commit is contained in:
parent
ad39a89b89
commit
249f3058b1
6 changed files with 368 additions and 19 deletions
|
|
@ -251,9 +251,12 @@ 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.
|
||||
The first graph compiler cut now exists. It splits paths at terminal positions,
|
||||
creates bidirectional segment state in Faust, scatters connected terminals by
|
||||
area-weighted pressure continuity, injects declared source ports at source
|
||||
terminals, and radiates declared radiation ports. Faust validation passes for a
|
||||
three-path trachea/oral/nasal graph. The remaining limits are named rather than
|
||||
hidden: segment delays are currently integer sample delays at the 44.1 kHz
|
||||
target, co-located terminals fall back to the response proxy until same-node
|
||||
aggregation is explicit, and the connection laws are compiled only as the first
|
||||
area-scattering form.
|
||||
|
|
|
|||
|
|
@ -167,10 +167,16 @@ 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.
|
||||
The first graph lowering cut now compiles typed graphs into bidirectional Faust
|
||||
segment state. It splits each path at terminal positions, orders connection
|
||||
ports deterministically, scatters connection groups with area-weighted pressure
|
||||
continuity, injects source terminals, and sums radiation terminals. Faust
|
||||
validation passes on a three-path trachea/oral/nasal graph.
|
||||
|
||||
The remaining implementation pressure is now narrower: replace integer segment
|
||||
delays with wave-clock-driven fractional delays, add explicit same-node
|
||||
terminal aggregation for co-located sources/junctions, and broaden connection
|
||||
laws beyond the first area-scattering form.
|
||||
|
||||
Authority map for the required rebuild:
|
||||
|
||||
|
|
@ -189,8 +195,8 @@ Authority map for the required rebuild:
|
|||
- 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 compiles `AcousticTerminal`/`AcousticConnection` records;
|
||||
- Deletion line: do not expand the proxy into a second fake synthesizer.
|
||||
`AcousticTerminal`/`AcousticConnection` records now own graph lowering;
|
||||
`branch` can remain only as shorthand that emits those records.
|
||||
|
||||
Exact Pink Trombone timing still needs pressure, but the next backend cut is no
|
||||
|
|
|
|||
|
|
@ -398,12 +398,23 @@ public static class FaustEmitter
|
|||
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);
|
||||
var terminals = patch.AcousticTerminals.ToDictionary(terminal => terminal.Name, StringComparer.OrdinalIgnoreCase);
|
||||
var connections = patch.AcousticConnections.ToDictionary(connection => connection.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";
|
||||
}
|
||||
|
||||
if (network.Terminals.Count > 0)
|
||||
{
|
||||
var graph = AcousticGraphExpression(source, patch, network, name, frequency, parameters, warnings, paths, sources, radiation, terminals, connections);
|
||||
if (graph.Length > 0)
|
||||
{
|
||||
return graph;
|
||||
}
|
||||
}
|
||||
|
||||
var sourceExpressions = new List<string>();
|
||||
foreach (var sourceName in network.SourcePorts)
|
||||
{
|
||||
|
|
@ -491,6 +502,238 @@ public static class FaustEmitter
|
|||
return $"{name}_acoustic_radiated";
|
||||
}
|
||||
|
||||
private static string AcousticGraphExpression(
|
||||
StringBuilder source,
|
||||
SynthPatch patch,
|
||||
AcousticPortNetwork network,
|
||||
string name,
|
||||
string frequency,
|
||||
ParameterMap parameters,
|
||||
List<string> warnings,
|
||||
IReadOnlyDictionary<string, AcousticPath> paths,
|
||||
IReadOnlyDictionary<string, AcousticSourcePort> sources,
|
||||
IReadOnlyDictionary<string, AcousticRadiationPort> radiation,
|
||||
IReadOnlyDictionary<string, AcousticTerminal> terminals,
|
||||
IReadOnlyDictionary<string, AcousticConnection> connections)
|
||||
{
|
||||
var networkTerminals = new List<AcousticTerminal>();
|
||||
foreach (var terminalName in network.Terminals)
|
||||
{
|
||||
if (terminals.TryGetValue(terminalName, out var terminal))
|
||||
{
|
||||
networkTerminals.Add(terminal);
|
||||
}
|
||||
else
|
||||
{
|
||||
warnings.Add($"{name}: acoustic network `{network.Name}` has unknown terminal `{terminalName}`");
|
||||
}
|
||||
}
|
||||
if (networkTerminals.Count < 2)
|
||||
{
|
||||
warnings.Add($"{name}: acoustic graph `{network.Name}` needs at least two valid terminals; using response proxy");
|
||||
return "";
|
||||
}
|
||||
|
||||
var terminalIndexes = networkTerminals
|
||||
.Select((terminal, index) => (terminal.Name, index))
|
||||
.ToDictionary(item => item.Name, item => item.index, StringComparer.OrdinalIgnoreCase);
|
||||
var segments = new List<AcousticGraphSegment>();
|
||||
foreach (var group in networkTerminals.GroupBy(terminal => terminal.Path, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!paths.TryGetValue(group.Key, out var path))
|
||||
{
|
||||
warnings.Add($"{name}: acoustic graph `{network.Name}` terminal path `{group.Key}` is unknown");
|
||||
continue;
|
||||
}
|
||||
|
||||
var ordered = group
|
||||
.OrderBy(terminal => Math.Clamp(terminal.Position, 0, 1))
|
||||
.ThenBy(terminal => terminal.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var duplicatePosition = ordered
|
||||
.GroupBy(terminal => MathF.Round(Math.Clamp(terminal.Position, 0, 1), 4))
|
||||
.FirstOrDefault(positionGroup => positionGroup.Count() > 1);
|
||||
if (duplicatePosition is not null)
|
||||
{
|
||||
warnings.Add($"{name}: acoustic graph `{network.Name}` has co-located terminals on path `{group.Key}`; using response proxy until same-node terminal aggregation is implemented");
|
||||
return "";
|
||||
}
|
||||
|
||||
for (var i = 0; i < ordered.Count - 1; i++)
|
||||
{
|
||||
if (Math.Abs(ordered[i + 1].Position - ordered[i].Position) < 0.0001f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
segments.Add(new AcousticGraphSegment(
|
||||
segments.Count,
|
||||
path,
|
||||
ordered[i],
|
||||
ordered[i + 1],
|
||||
Math.Clamp(ordered[i].Position, 0, 1),
|
||||
Math.Clamp(ordered[i + 1].Position, 0, 1)));
|
||||
}
|
||||
}
|
||||
|
||||
if (segments.Count == 0)
|
||||
{
|
||||
warnings.Add($"{name}: acoustic graph `{network.Name}` produced no path segments; using response proxy");
|
||||
return "";
|
||||
}
|
||||
|
||||
var incident = networkTerminals.ToDictionary(terminal => terminal.Name, _ => new List<AcousticGraphPort>(), StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
incident[segment.Start.Name].Add(new AcousticGraphPort(segment, true));
|
||||
incident[segment.End.Name].Add(new AcousticGraphPort(segment, false));
|
||||
}
|
||||
|
||||
var terminalConnection = new Dictionary<string, AcousticConnection>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var connectionName in network.Connections)
|
||||
{
|
||||
if (!connections.TryGetValue(connectionName, out var connection))
|
||||
{
|
||||
warnings.Add($"{name}: acoustic network `{network.Name}` has unknown connection `{connectionName}`");
|
||||
continue;
|
||||
}
|
||||
foreach (var terminalName in connection.Terminals)
|
||||
{
|
||||
if (terminalIndexes.ContainsKey(terminalName))
|
||||
{
|
||||
terminalConnection[terminalName] = connection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
source.AppendLine($"{name}_graph_drive = 1.0;");
|
||||
foreach (var terminal in networkTerminals)
|
||||
{
|
||||
var terminalIndex = AcousticTerminalIndex(patch, terminal);
|
||||
var terminalPath = $"/acoustic/terminals/{terminalIndex}";
|
||||
source.AppendLine($"{name}_graph_terminal_area_{SafeIdentifier(terminal.Name)} = max(0.000001, {F(TerminalArea(paths, terminal))} * max(0.0, {parameters.Expression(OwnerField(terminalPath, "area_scale"), terminal.AreaScale)}));");
|
||||
source.AppendLine($"{name}_graph_terminal_reflection_{SafeIdentifier(terminal.Name)} = {parameters.Expression(OwnerField(terminalPath, "reflection"), terminal.Reflection)};");
|
||||
}
|
||||
|
||||
foreach (var connectionName in network.Connections)
|
||||
{
|
||||
if (!connections.TryGetValue(connectionName, out var connection))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var connectionIndex = AcousticConnectionIndex(patch, connection);
|
||||
var connectionPath = $"/acoustic/connections/{connectionIndex}";
|
||||
source.AppendLine($"{name}_graph_connection_coupling_{SafeIdentifier(connection.Name)} = clip01({parameters.Expression(OwnerField(connectionPath, "coupling"), connection.Coupling)});");
|
||||
source.AppendLine($"{name}_graph_connection_loss_{SafeIdentifier(connection.Name)} = clip01({parameters.Expression(OwnerField(connectionPath, "loss"), connection.Loss)});");
|
||||
}
|
||||
|
||||
foreach (var sourceName in network.SourcePorts)
|
||||
{
|
||||
if (sources.TryGetValue(sourceName, out var port))
|
||||
{
|
||||
source.AppendLine($"{name}_graph_source_{SafeIdentifier(sourceName)} = {AcousticSourceExpression(patch, port, frequency, parameters)};");
|
||||
}
|
||||
}
|
||||
|
||||
var stateCount = segments.Count * 2;
|
||||
var stateInputs = segments.Select(segment => $"r{segment.Index}")
|
||||
.Concat(segments.Select(segment => $"l{segment.Index}"))
|
||||
.ToList();
|
||||
var nextStates = new List<string>(stateCount);
|
||||
source.AppendLine($"{name}_graph(x) = {name}_graph_loop ~ si.bus({stateCount}) : (si.block({stateCount}), _) with {{");
|
||||
source.AppendLine($" {name}_graph_loop({string.Join(", ", stateInputs)}) = {string.Join(", ", NextStatesPlaceholder(stateCount))}, {name}_graph_out with {{");
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
source.AppendLine($" {name}_graph_in_{segment.Index}_start = l{segment.Index};");
|
||||
source.AppendLine($" {name}_graph_in_{segment.Index}_end = r{segment.Index};");
|
||||
}
|
||||
|
||||
var connectionGroups = network.Connections
|
||||
.Select(connectionName => connections.TryGetValue(connectionName, out var connection) ? connection : null)
|
||||
.Where(connection => connection is not null)
|
||||
.Cast<AcousticConnection>()
|
||||
.ToList();
|
||||
foreach (var connection in connectionGroups)
|
||||
{
|
||||
var connectionTerminals = connection.Terminals
|
||||
.Where(terminalIndexes.ContainsKey)
|
||||
.Select(terminalName => terminals[terminalName])
|
||||
.ToList();
|
||||
var ports = connectionTerminals
|
||||
.SelectMany(terminal => incident[terminal.Name].Select(port => (terminal, port)))
|
||||
.ToList();
|
||||
if (ports.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var safeConnection = SafeIdentifier(connection.Name);
|
||||
var weightedIncoming = ports
|
||||
.Select(item => $"{name}_graph_terminal_area_{SafeIdentifier(item.terminal.Name)} * {GraphIncoming(name, item.port)}");
|
||||
var areaSum = ports.Select(item => $"{name}_graph_terminal_area_{SafeIdentifier(item.terminal.Name)}");
|
||||
source.AppendLine($" {name}_graph_connection_pressure_{safeConnection} = 2.0 * ({string.Join(" + ", weightedIncoming)}) / max(0.000001, {string.Join(" + ", areaSum)});");
|
||||
foreach (var (terminal, port) in ports)
|
||||
{
|
||||
var incoming = GraphIncoming(name, port);
|
||||
var outgoing = GraphOutgoing(name, port);
|
||||
source.AppendLine($" {outgoing} = (({name}_graph_connection_pressure_{safeConnection} - {incoming}) * {name}_graph_connection_coupling_{safeConnection} + {incoming} * (1.0 - {name}_graph_connection_coupling_{safeConnection})) * {name}_graph_connection_loss_{safeConnection};");
|
||||
}
|
||||
}
|
||||
|
||||
var radiated = new List<string>();
|
||||
foreach (var terminal in networkTerminals)
|
||||
{
|
||||
var ports = incident[terminal.Name];
|
||||
if (ports.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (terminalConnection.ContainsKey(terminal.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var safeTerminal = SafeIdentifier(terminal.Name);
|
||||
var sourceInjection = terminal.Kind == AcousticTerminalKind.Source && sources.ContainsKey(terminal.Port.Length == 0 ? terminal.Name : terminal.Port)
|
||||
? $"{name}_graph_source_{SafeIdentifier(terminal.Port.Length == 0 ? terminal.Name : terminal.Port)}"
|
||||
: "0.0";
|
||||
var reflection = $"{name}_graph_terminal_reflection_{safeTerminal}";
|
||||
foreach (var port in ports)
|
||||
{
|
||||
var outgoing = GraphOutgoing(name, port);
|
||||
var incoming = GraphIncoming(name, port);
|
||||
source.AppendLine($" {outgoing} = {incoming} * {reflection} + ({sourceInjection}) / {F(Math.Max(1, ports.Count))};");
|
||||
}
|
||||
|
||||
if (terminal.Kind == AcousticTerminalKind.Radiation && radiation.ContainsKey(terminal.Port.Length == 0 ? terminal.Name : terminal.Port))
|
||||
{
|
||||
var radiationPort = radiation[terminal.Port.Length == 0 ? terminal.Name : terminal.Port];
|
||||
var radiationIndex = AcousticRadiationPortIndex(patch, radiationPort);
|
||||
var radiationPath = $"/acoustic/radiation/{radiationIndex}";
|
||||
var opening = $"max(0.0, {parameters.Expression(OwnerField(radiationPath, "opening"), radiationPort.Opening)})";
|
||||
var loss = $"clip01({parameters.Expression(OwnerField(radiationPath, "loss"), radiationPort.Loss)})";
|
||||
radiated.Add($"(({string.Join(" + ", ports.Select(port => GraphIncoming(name, port)))}) * {opening} * {loss})");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var delaySamples = Math.Max(1, (int)MathF.Round((segment.EndPosition - segment.StartPosition) * segment.Path.AreaFunction.LengthMeters / segment.Path.PropagationSpeedMetersPerSecond * 44100));
|
||||
var loss = F(Math.Clamp(segment.Path.Loss, 0, 1));
|
||||
source.AppendLine($" {name}_graph_next_r{segment.Index} = {GraphOutgoing(name, new AcousticGraphPort(segment, true))} : de.delay({delaySamples}, {delaySamples}) * {loss};");
|
||||
source.AppendLine($" {name}_graph_next_l{segment.Index} = {GraphOutgoing(name, new AcousticGraphPort(segment, false))} : de.delay({delaySamples}, {delaySamples}) * {loss};");
|
||||
nextStates.Add($"{name}_graph_next_r{segment.Index}");
|
||||
}
|
||||
nextStates.AddRange(segments.Select(segment => $"{name}_graph_next_l{segment.Index}"));
|
||||
source.Replace(string.Join(", ", NextStatesPlaceholder(stateCount)), string.Join(", ", nextStates));
|
||||
source.AppendLine($" {name}_graph_out = {(radiated.Count == 0 ? "0.0" : string.Join(" + ", radiated))};");
|
||||
source.AppendLine(" };");
|
||||
source.AppendLine("};");
|
||||
source.AppendLine($"{name}_acoustic_graph_radiated = {name}_graph({name}_graph_drive);");
|
||||
return $"{name}_acoustic_graph_radiated";
|
||||
}
|
||||
|
||||
private static string AcousticSourceExpression(
|
||||
SynthPatch patch,
|
||||
AcousticSourcePort port,
|
||||
|
|
@ -1057,6 +1300,44 @@ public static class FaustEmitter
|
|||
: 0;
|
||||
}
|
||||
|
||||
private static float TerminalArea(IReadOnlyDictionary<string, AcousticPath> paths, AcousticTerminal terminal)
|
||||
{
|
||||
if (!paths.TryGetValue(terminal.Path, out var path))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var diameter = path.AreaFunction.DiameterAt(Math.Clamp(terminal.Position, 0, 1));
|
||||
return Math.Max(0.000001f, diameter * diameter);
|
||||
}
|
||||
|
||||
private static string GraphIncoming(string voiceName, AcousticGraphPort port) =>
|
||||
port.AtStart
|
||||
? $"{voiceName}_graph_in_{port.Segment.Index}_start"
|
||||
: $"{voiceName}_graph_in_{port.Segment.Index}_end";
|
||||
|
||||
private static string GraphOutgoing(string voiceName, AcousticGraphPort port) =>
|
||||
port.AtStart
|
||||
? $"{voiceName}_graph_out_{port.Segment.Index}_start"
|
||||
: $"{voiceName}_graph_out_{port.Segment.Index}_end";
|
||||
|
||||
private static IEnumerable<string> NextStatesPlaceholder(int count) =>
|
||||
Enumerable.Range(0, count).Select(i => $"__graph_next_state_{i}__");
|
||||
|
||||
private static int AcousticConnectionIndex(SynthPatch patch, AcousticConnection connection)
|
||||
{
|
||||
for (var i = 0; i < patch.AcousticConnections.Count; i++)
|
||||
{
|
||||
if (ReferenceEquals(patch.AcousticConnections[i], connection) ||
|
||||
patch.AcousticConnections[i].Name.Equals(connection.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int AcousticBranchIndex(SynthPatch patch, AcousticBranch branch)
|
||||
{
|
||||
for (var i = 0; i < patch.AcousticBranches.Count; i++)
|
||||
|
|
@ -1085,6 +1366,20 @@ public static class FaustEmitter
|
|||
return 0;
|
||||
}
|
||||
|
||||
private static int AcousticTerminalIndex(SynthPatch patch, AcousticTerminal terminal)
|
||||
{
|
||||
for (var i = 0; i < patch.AcousticTerminals.Count; i++)
|
||||
{
|
||||
if (ReferenceEquals(patch.AcousticTerminals[i], terminal) ||
|
||||
patch.AcousticTerminals[i].Name.Equals(terminal.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int AcousticSourcePortIndex(SynthPatch patch, AcousticSourcePort port)
|
||||
{
|
||||
for (var i = 0; i < patch.AcousticSourcePorts.Count; i++)
|
||||
|
|
@ -1112,6 +1407,16 @@ public static class FaustEmitter
|
|||
|
||||
private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||
|
||||
private sealed record AcousticGraphSegment(
|
||||
int Index,
|
||||
AcousticPath Path,
|
||||
AcousticTerminal Start,
|
||||
AcousticTerminal End,
|
||||
float StartPosition,
|
||||
float EndPosition);
|
||||
|
||||
private sealed record AcousticGraphPort(AcousticGraphSegment Segment, bool AtStart);
|
||||
|
||||
private sealed class ParameterMap
|
||||
{
|
||||
private readonly Dictionary<string, int> _parameterIndexes = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
|
|
|||
|
|
@ -148,3 +148,4 @@
|
|||
{"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."}
|
||||
{"timestamp":"2026-05-26T18:35:00+01:00","topic":"physical-voice-typed-graph-faust-lowering","claim":"Typed acoustic path graphs now compile to Faust bidirectional segment state when terminals are present. The lowering splits paths at terminals, creates right/left segment state, scatters connected terminals by area-weighted pressure continuity, injects source terminals, and sums radiation terminals. Remaining known limits are integer sample segment delays, fallback for co-located same-path terminals, and only first area-scattering law lowering.","evidence":"Added `AcousticGraphExpression` in `FaustExport`; focused tests `ParserSupportsReusableAcousticPortNetworkPrimitives`, `ParserSupportsTypedAcousticPathGraph`, `FaustCompilerValidatesAcousticPathGraphWhenInstalled`, `ParserSupportsPinkTromboneStyleTractVoice`, and `WaveguideTractDefaultsToAcousticUnitDelayGrid` passed; `dotnet build src/AquaSynth.Core/AquaSynth.Core.csproj --no-restore --disable-build-servers -p:UseSharedCompilation=false -p:BuildInParallel=false` passed."}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
project: AquaSynth
|
||||
spine_version: 1
|
||||
updated: 2026-05-26T18:05:00+01:00
|
||||
updated: 2026-05-26T18:35:00+01:00
|
||||
|
||||
prime_directive: >
|
||||
Build coherent synth machinery. References exist to reveal missing structure,
|
||||
|
|
@ -117,7 +117,7 @@ live_invariants:
|
|||
- Physical voice modeling should generalize around continuous acoustic morphologies, acoustic port networks, source ports, branch/radiation ports, and wave-clock policies. A larynx, syrinx, or alien voice should differ by topology and source-port configuration, not by special-case modules. Faust `physmodels.lib`/`delays.lib` provide useful primitives (`pm.chain`, `pm.l2s`, `pm.waveguideFd*`, `de.fdelay*`, Thiran/allpass and Lagrange delays), but AquaSynth should still generate tract/branch scattering where live area controls and inspectable training parameters matter.
|
||||
- The physical voice research packet now includes a practical implementation/researcher map (`research/physical-voice-modeling/PEOPLE.md`) and talk/transcript ledger (`research/physical-voice-modeling/talks/TALKS.md`). Birkholz/VocalTractLab pressures multi-level articulatory control and gestures; Story/TubeTalker pressures continuous parametric area functions and length/coupling; Smith pressures bidirectional delay-line scattering and fractional-delay waveguides; Fels/ArtiSynth pressures separation between biomechanics, acoustics, timeline controls, and diagnostics. These are reusable DSL semantics, not modules to clone.
|
||||
- Physical voice implementation has started moving PT pressure into neutral acoustic owners: `AcousticPath`, `AcousticTerminal`, `AcousticConnection`, `AcousticSourcePort`, `AcousticBranch`, `AcousticRadiationPort`, `WaveClockPolicy`, and `AcousticPortNetwork`. Parser commands `path`, `terminal`, `connect`, `source_port`, `branch`, `radiation_port`, `wave_clock`, and `acoustic_network` now exist. Existing `tract_shape`, `glottis`, `tract_injection`, `nasal_branch`, and `tract` commands populate acoustic records as compatibility aliases while the current `Voice.Tract` Faust renderer still owns audio.
|
||||
- `acoustic` / `acoustic_voice` can now render an `AcousticPortNetwork` through a compact Faust response proxy 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.
|
||||
- `acoustic` / `acoustic_voice` can now render an `AcousticPortNetwork` through a compact Faust response proxy or, when typed terminals are present, a compiled bidirectional Faust segment graph. Graph lowering splits paths at terminal positions, orders connection ports deterministically, scatters area-weighted connection groups, injects source terminals, and sums radiation terminals. Source, branch, radiation, terminal, and connection controls can bind through ordinary Aqua parameters into emitted Faust. Remaining pressure: segment delays are integer sample delays at the 44.1 kHz target, co-located terminals fall back to the response proxy until same-node aggregation is explicit, and connection laws beyond first area scattering still need real lowering. Do not expand the proxy into a second fake synthesizer to dodge those decisions.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ public sealed class PatchScriptTests
|
|||
path name=trachea length_cm=12 diameters=.4,.7,1,1
|
||||
path name=oral length_cm=17 diameters=.6,1.1,1.6,1.2,.8
|
||||
source_port name=left_labium path=trachea kind=labial position=.1 pressure=@/voice/left_pressure tension=.55 opening=.4 noise=.02
|
||||
source_port name=right_labium path=trachea kind=labial position=.1 pressure=.65 tension=.5 opening=.45 noise=.03 balance=.8
|
||||
source_port name=right_labium path=trachea kind=labial position=.12 pressure=.65 tension=.5 opening=.45 noise=.03 balance=.8
|
||||
branch name=throat from_path=trachea from_position=.9 to_path=oral kind=bronchial opening=@/voice/throat_opening coupling=.7
|
||||
radiation_port name=mouth path=oral kind=lip position=1 opening=@/voice/mouth_opening reflection=-.82
|
||||
wave_clock name=continuous strategy=thiran order=1 max_delay=4096 smoothing_ms=3
|
||||
|
|
@ -205,10 +205,10 @@ public sealed class PatchScriptTests
|
|||
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("acoustic_graph_radiated", export.Source);
|
||||
Assert.Contains("graph_loop", export.Source);
|
||||
Assert.Contains("graph_connection_pressure_throat_connection", export.Source);
|
||||
Assert.Contains("graph_terminal_area_throat_from", export.Source);
|
||||
Assert.Contains("patch_param_0", export.Source);
|
||||
Assert.Contains("patch_param_1", export.Source);
|
||||
Assert.Contains("patch_param_2", export.Source);
|
||||
|
|
@ -244,6 +244,13 @@ public sealed class PatchScriptTests
|
|||
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");
|
||||
|
||||
var export = FaustEmitter.Emit(patch, new FaustExportOptions("humanish"));
|
||||
Assert.Contains("acoustic_graph_radiated", export.Source);
|
||||
Assert.Contains("graph_connection_pressure_velopharynx", export.Source);
|
||||
Assert.Contains("graph_next_r", export.Source);
|
||||
Assert.Contains("graph_next_l", export.Source);
|
||||
Assert.Contains("patch_param_0", export.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -1147,6 +1154,33 @@ public sealed class PatchScriptTests
|
|||
Assert.True(validation.Success, validation.Stderr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FaustCompilerValidatesAcousticPathGraphWhenInstalled()
|
||||
{
|
||||
var export = FaustEmitter.EmitScript("""
|
||||
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=.18
|
||||
connect name=velopharynx terminals=trachea_top,oral_back,nasal_gate law=area_scatter coupling=.18
|
||||
acoustic_network name=humanish path=oral sources=folds radiation=mouth,nostril terminals=trachea_top,oral_back,nasal_gate connections=velopharynx
|
||||
acoustic network=humanish freq=130 gain=.1
|
||||
""", new FaustExportOptions("acoustic_graph_validation"));
|
||||
var validation = await FaustCompiler.ValidateAsync(export.Source);
|
||||
|
||||
if (validation is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.True(validation.Success, validation.Stderr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FaustCompilerValidatesSpectralBankWhenInstalled()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue