diff --git a/src/AquaSynth.Core/ControlSplineTimeline.cs b/src/AquaSynth.Core/ControlSplineTimeline.cs
index 232e9df..fd279b5 100644
--- a/src/AquaSynth.Core/ControlSplineTimeline.cs
+++ b/src/AquaSynth.Core/ControlSplineTimeline.cs
@@ -68,6 +68,21 @@ public sealed class ControlSplineTimeline
.ToList(),
StringComparer.OrdinalIgnoreCase);
+ ///
+ /// The splines that decide one surface. The timeline merges every enabled spline of a surface into one bezier curve over
+ /// the union of their points, so two or more splines become one merged spline; a single spline keeps its own shape.
+ /// The Faust exporter and the probe report take this instead of summing per-spline offsets.
+ ///
+ public static ControlSpline[] SurfaceSplines(IEnumerable all, string surfacePath)
+ {
+ var splines = all
+ .Where(spline => spline.Enabled && spline.SurfacePath.Equals(surfacePath, StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+ if (splines.Length < 2) return splines;
+ var points = splines.SelectMany(spline => spline.Points).OrderBy(point => point.TimeSeconds).ToArray();
+ return [new ControlSpline(string.Join("_", splines.Select(spline => spline.Name)), surfacePath, points)];
+ }
+
public IReadOnlyList SurfacePaths => _pointsBySurface.Keys.ToArray();
public void SetFuturePoint(string surfacePath, float timeSeconds, float normalizedValue, float nowSeconds) =>
diff --git a/src/AquaSynth.Core/FaustExport.VocalLadder.cs b/src/AquaSynth.Core/FaustExport.VocalLadder.cs
new file mode 100644
index 0000000..ae06b70
--- /dev/null
+++ b/src/AquaSynth.Core/FaustExport.VocalLadder.cs
@@ -0,0 +1,149 @@
+using System.Text;
+
+namespace AquaSynth.Dsl;
+
+// The vocal_network resonator: a Kelly-Lochbaum ladder per waveguide path, one cell per sample (cell length c / sample
+// rate), written as a Faust `letrec` so every wave variable is a one-sample state. Cell areas come from the morphology
+// rest shape deformed by the path's area control (the same Gaussian model the acoustic graph uses). A branch port whose
+// two paths are both in the network becomes a three-port parallel junction (Pink Trombone's velum: ports are the oral
+// cells either side of the join and the branch path's first cell, whose area is the branch opening).
+public static partial class FaustEmitter
+{
+ // Reflection at the closed glottal end of a path that carries a source.
+ private const float LadderGlottalReflection = 0.75f;
+ // Full-open velum diameter in cm, as in Pink Trombone's velum target.
+ private const float VelumOpenDiameterCentimeters = 0.4f;
+
+ private sealed record LadderPath(
+ WaveguidePath Path,
+ AreaFunction Area,
+ int AreaIndex,
+ int PathIndex,
+ string Prefix,
+ int Cells,
+ RadiationLoad? Radiation,
+ int RadiationIndex)
+ {
+ public string A(int i) => $"{Prefix}_A{i}";
+ public string R(int i) => $"{Prefix}_R{i}";
+ public string L(int i) => $"{Prefix}_L{i}";
+ public string Refl(int i) => $"{Prefix}_r{i}";
+ }
+
+ private sealed record LadderJunction(LadderPath Oral, LadderPath Branch, int Interface, string Velum, string Left, string Right, string Nose);
+
+ private static int LadderCells(WaveguidePath path, TractAreaFunction shape, FaustExportOptions options) =>
+ Math.Max(3, (int)MathF.Round(shape.LengthMeters * options.SampleRate / Math.Max(1f, path.PropagationSpeedMetersPerSecond)));
+
+ ///
+ /// Emits the area, reflection and junction definitions and the recursive ladder for , and returns,
+ /// per path, the identifier of its lip output (the right-going wave leaving its last cell).
+ ///
+ private static IReadOnlyDictionary EmitVocalLadder(
+ StringBuilder source,
+ SynthPatch patch,
+ VocalNetwork network,
+ string name,
+ IReadOnlyList members,
+ IReadOnlyDictionary inputs,
+ IReadOnlyDictionary admittances,
+ ParameterMap parameters,
+ List warnings)
+ {
+ var junctions = new List();
+ foreach (var branchName in network.Branches)
+ {
+ var branch = patch.BranchPorts.FirstOrDefault(candidate => candidate.Name.Equals(branchName, StringComparison.OrdinalIgnoreCase));
+ var oral = branch is null ? null : members.FirstOrDefault(member => member.Path.Name.Equals(branch.FromPath, StringComparison.OrdinalIgnoreCase));
+ var nasal = branch is null ? null : members.FirstOrDefault(member => member.Path.Name.Equals(branch.ToPath, StringComparison.OrdinalIgnoreCase));
+ if (branch is null || oral is null || nasal is null || oral == nasal)
+ {
+ continue;
+ }
+
+ var cell = Math.Clamp((int)MathF.Round(Math.Clamp(branch.FromPosition, 0, 1) * oral.Cells), 1, oral.Cells - 2);
+ if (junctions.Any(existing => existing.Oral == oral && existing.Interface == cell) || junctions.Any(existing => existing.Branch == nasal))
+ {
+ warnings.Add($"{name}: branch `{branch.Name}` shares a junction cell or target path with another branch; ignored by the ladder");
+ continue;
+ }
+
+ var velum = $"{name}_primitive_branch_{SafeIdentifier(branch.Name)}_velum_area";
+ source.AppendLine($" {velum} = max(0.000001, pow({admittances[branch.Name]} * {F(VelumOpenDiameterCentimeters)}, 2.0));");
+ var sum = $"({oral.A(cell)} + {oral.A(cell + 1)} + {velum})";
+ var key = $"{name}_primitive_branch_{SafeIdentifier(branch.Name)}";
+ source.AppendLine($" {key}_refl_left = (2.0 * {oral.A(cell)} - {sum}) / {sum};");
+ source.AppendLine($" {key}_refl_right = (2.0 * {oral.A(cell + 1)} - {sum}) / {sum};");
+ source.AppendLine($" {key}_refl_nose = (2.0 * {velum} - {sum}) / {sum};");
+ junctions.Add(new LadderJunction(oral, nasal, cell, velum, $"{key}_refl_left", $"{key}_refl_right", $"{key}_refl_nose"));
+ }
+
+ var targets = junctions.ToDictionary(junction => junction.Branch);
+ var joinedAt = junctions.ToDictionary(junction => (junction.Oral, junction.Interface));
+ var equations = new StringBuilder();
+
+ foreach (var member in members)
+ {
+ var targetJunction = targets.GetValueOrDefault(member);
+ for (var i = 0; i < member.Cells; i++)
+ {
+ if (i == 0 && targetJunction is not null)
+ {
+ source.AppendLine($" {member.A(0)} = {targetJunction.Velum};");
+ continue;
+ }
+
+ var area = DeformedAreaExpression(parameters, member.Area.Shape, member.Area.Deformation, $"/vocal/areas/{member.AreaIndex}/area", (i + 0.5f) / member.Cells);
+ source.AppendLine($" {member.A(i)} = max(0.000001, {area});");
+ }
+
+ for (var i = 1; i < member.Cells; i++)
+ {
+ source.AppendLine($" {member.Refl(i)} = ({member.A(i - 1)} - {member.A(i)}) / ({member.A(i - 1)} + {member.A(i)});");
+ }
+
+ var loss = $"clip01({parameters.Expression(OwnerField($"/vocal/paths/{member.PathIndex}", "loss"), member.Path.Loss)})";
+ var lipReflection = member.Radiation is { } load
+ ? parameters.Expression(OwnerField($"/vocal/radiation/{member.RadiationIndex}", "reflection"), load.Reflection)
+ : F(new RadiationLoad("", "").Reflection);
+ var last = member.Cells - 1;
+
+ equations.AppendLine(targetJunction is { } entering
+ ? $" '{member.R(0)} = ({entering.Oral.L(entering.Interface)} + {entering.Oral.R(entering.Interface - 1)}) * (1.0 + {entering.Nose}) * {loss} + {entering.Nose} * {member.L(0)} * {loss};"
+ : $" '{member.R(0)} = ({member.L(0)} * {F(LadderGlottalReflection)} + {(inputs.TryGetValue(member.Path.Name, out var input) ? input : "0.0")}) * {loss};");
+ for (var i = 1; i < member.Cells; i++)
+ {
+ equations.AppendLine(joinedAt.TryGetValue((member, i), out var join)
+ ? $" '{member.R(i)} = ({join.Right} * {member.L(i)} + (1.0 + {join.Right}) * ({member.R(i - 1)} + {join.Branch.L(0)})) * {loss};"
+ : $" '{member.R(i)} = ({member.R(i - 1)} - {member.Refl(i)} * ({member.R(i - 1)} + {member.L(i)})) * {loss};");
+ }
+
+ for (var i = 0; i < last; i++)
+ {
+ equations.AppendLine(joinedAt.TryGetValue((member, i + 1), out var join)
+ ? $" '{member.L(i)} = ({join.Left} * {member.R(i)} + (1.0 + {join.Left}) * ({join.Branch.L(0)} + {member.L(i + 1)})) * {loss};"
+ : $" '{member.L(i)} = ({member.L(i + 1)} + {member.Refl(i + 1)} * ({member.R(i)} + {member.L(i + 1)})) * {loss};");
+ }
+
+ equations.AppendLine($" '{member.L(last)} = {member.R(last)} * ({lipReflection}) * {loss};");
+ }
+
+ var outputs = string.Join(", ", members.Select(member => member.R(member.Cells - 1)));
+ var ladder = $"{name}_primitive_ladder";
+ source.AppendLine($" {ladder} = ({outputs}) letrec {{");
+ source.Append(equations);
+ source.AppendLine(" };");
+
+ var lips = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (var m = 0; m < members.Count; m++)
+ {
+ var lip = $"{members[m].Prefix}_lip";
+ source.AppendLine(members.Count == 1
+ ? $" {lip} = {ladder};"
+ : $" {lip} = {ladder} : ba.selector({m}, {members.Count});");
+ lips[members[m].Path.Name] = lip;
+ }
+
+ return lips;
+ }
+}
diff --git a/src/AquaSynth.Core/FaustExport.cs b/src/AquaSynth.Core/FaustExport.cs
index 2059892..d18bf3c 100644
--- a/src/AquaSynth.Core/FaustExport.cs
+++ b/src/AquaSynth.Core/FaustExport.cs
@@ -4,11 +4,11 @@ using System.Text;
namespace AquaSynth.Dsl;
-public sealed record FaustExportOptions(string Name = "aquasynth_patch", bool Stereo = false, bool DebugProbeUi = false);
+public sealed record FaustExportOptions(string Name = "aquasynth_patch", bool Stereo = false, bool DebugProbeUi = false, int SampleRate = 48000);
public sealed record FaustExport(string Source, IReadOnlyList Warnings);
-public static class FaustEmitter
+public static partial class FaustEmitter
{
public static FaustExport EmitScript(string script, FaustExportOptions? options = null) =>
Emit(PatchScript.Parse(script), options ?? new FaustExportOptions());
@@ -160,9 +160,7 @@ public static class FaustEmitter
: publicControl;
source.AppendLine($"{baseId} = {boundControl};");
- var splines = patch.ControlSplines
- .Where(spline => spline.Enabled && spline.SurfacePath.Equals(surface.Path, StringComparison.OrdinalIgnoreCase))
- .ToArray();
+ var splines = ControlSplineTimeline.SurfaceSplines(patch.ControlSplines, surface.Path);
if (splines.Length == 0)
{
source.AppendLine($"{normalized} = clip01({baseId});");
@@ -577,6 +575,7 @@ public static class FaustEmitter
var radiated = new List();
source.AppendLine($"{name}_primitive_drive = 1.0;");
+ var members = new List();
foreach (var pathName in network.Paths)
{
if (!paths.TryGetValue(pathName, out var path))
@@ -590,24 +589,27 @@ public static class FaustEmitter
continue;
}
- var safePath = SafeIdentifier(path.Name);
- var areaIndex = AreaFunctionIndex(patch, area);
- var pathIndex = WaveguidePathIndex(patch, path);
- var pathField = $"/vocal/paths/{pathIndex}";
- var representativeArea = $"{name}_primitive_path_{safePath}_area";
- var delay = $"{name}_primitive_path_{safePath}_delay";
- var input = $"{name}_primitive_path_{safePath}_input";
- var incoming = $"{name}_primitive_path_{safePath}_incoming_wave";
- var wave = $"{name}_primitive_path_{safePath}_wave";
- var energyIn = $"{name}_primitive_path_{safePath}_energy_in";
- var energyOut = $"{name}_primitive_path_{safePath}_energy_out";
- var passivity = $"{name}_primitive_path_{safePath}_passivity_ratio";
- var loss = $"clip01({parameters.Expression(OwnerField(pathField, "loss"), path.Loss)})";
- source.AppendLine($" {representativeArea} = {ProbeSignal(options, $"/debug/{name}/path/{path.Name}/area", PrimitiveAreaExpression(patch, parameters, area, areaIndex), 0, 8)};");
- source.AppendLine($" {delay} = {ProbeSignal(options, $"/debug/{name}/path/{path.Name}/delay", $"({F(area.Shape.LengthMeters)} / max(1.0, {parameters.Expression(OwnerField(pathField, "speed"), path.PropagationSpeedMetersPerSecond)})) * ma.SR", 0, path.MaxDelaySamples)};");
- keepAlive.Add(representativeArea);
- keepAlive.Add(delay);
+ var load = network.Radiation
+ .Select(loadName => radiation.TryGetValue(loadName, out var candidate) && candidate.Path.Equals(path.Name, StringComparison.OrdinalIgnoreCase) ? candidate : null)
+ .FirstOrDefault(candidate => candidate is not null);
+ members.Add(new LadderPath(
+ path,
+ area,
+ AreaFunctionIndex(patch, area),
+ WaveguidePathIndex(patch, path),
+ $"{name}_primitive_path_{SafeIdentifier(path.Name)}",
+ LadderCells(path, area.Shape, options),
+ load,
+ load is null ? 0 : RadiationLoadIndex(patch, load)));
+ }
+ // Per-path drive: sources, contact release and branch exchange sum into the path input. A branch whose two paths
+ // are both in this network is a ladder junction instead and exchanges no flow term.
+ var memberNames = members.Select(member => member.Path.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var inputs = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var member in members)
+ {
+ var path = member.Path;
var sourceTerms = network.Sources
.Select(sourceName => sourcePorts.TryGetValue(sourceName, out var port) && port.Path.Equals(path.Name, StringComparison.OrdinalIgnoreCase)
? PrimitiveSourceExpression(source, patch, name, port, frequency, parameters, options, keepAlive)
@@ -623,20 +625,52 @@ public static class FaustEmitter
.Cast()
.ToList();
var branchTerms = network.Branches
- .Select(branchName => branches.TryGetValue(branchName, out var branch) && branch.FromPath.Equals(path.Name, StringComparison.OrdinalIgnoreCase)
+ .Select(branchName => branches.TryGetValue(branchName, out var branch) && branch.FromPath.Equals(path.Name, StringComparison.OrdinalIgnoreCase) && !memberNames.Contains(branch.ToPath)
? PrimitiveBranchExpression(source, patch, name, branch, parameters, options, keepAlive)
: null)
.Where(term => term is not null)
.Cast()
.ToList();
var pathInputs = sourceTerms.Concat(contactTerms).Concat(branchTerms).ToList();
+ var input = $"{member.Prefix}_input";
source.AppendLine($" {input} = {ProbeSignal(options, $"/debug/{name}/path/{path.Name}/input_flow", pathInputs.Count == 0 ? "0.0" : string.Join(" + ", pathInputs), -4, 4)};");
- source.AppendLine($" {incoming} = {ProbeSignal(options, $"/debug/{name}/path/{path.Name}/incoming_wave", input, -4, 4)};");
- source.AppendLine($" {wave} = {ProbeSignal(options, $"/debug/{name}/path/{path.Name}/outgoing_wave", $"({incoming}) : {PrimitiveDelayExpression(path, path.MaxDelaySamples, delay)} * {loss}", -4, 4)};");
+ inputs[path.Name] = input;
+ keepAlive.Add(input);
+ }
+
+ var admittances = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var branchName in network.Branches)
+ {
+ if (branches.TryGetValue(branchName, out var branch) && memberNames.Contains(branch.FromPath) && memberNames.Contains(branch.ToPath))
+ {
+ admittances[branch.Name] = PrimitiveBranchAdmittance(source, patch, name, branch, parameters, options, keepAlive);
+ }
+ }
+
+ var lips = members.Count == 0
+ ? new Dictionary()
+ : EmitVocalLadder(source, patch, network, name, members, inputs, admittances, parameters, warnings);
+
+ foreach (var member in members)
+ {
+ var path = member.Path;
+ var representativeArea = $"{member.Prefix}_area";
+ var delay = $"{member.Prefix}_delay";
+ var incoming = $"{member.Prefix}_incoming_wave";
+ var wave = $"{member.Prefix}_wave";
+ var energyIn = $"{member.Prefix}_energy_in";
+ var energyOut = $"{member.Prefix}_energy_out";
+ var passivity = $"{member.Prefix}_passivity_ratio";
+ var meanArea = $"({string.Join(" + ", Enumerable.Range(0, member.Cells).Select(member.A))}) / {member.Cells}.0";
+ source.AppendLine($" {representativeArea} = {ProbeSignal(options, $"/debug/{name}/path/{path.Name}/area", meanArea, 0, 8)};");
+ source.AppendLine($" {delay} = {ProbeSignal(options, $"/debug/{name}/path/{path.Name}/delay", $"{member.Cells}.0", 0, member.Cells)};");
+ source.AppendLine($" {incoming} = {ProbeSignal(options, $"/debug/{name}/path/{path.Name}/incoming_wave", inputs[path.Name], -4, 4)};");
+ source.AppendLine($" {wave} = {ProbeSignal(options, $"/debug/{name}/path/{path.Name}/outgoing_wave", lips[path.Name], -4, 4)};");
source.AppendLine($" {energyIn} = {ProbeSignal(options, $"/debug/{name}/path/{path.Name}/energy_in", $"{representativeArea} * pow({incoming}, 2.0)", 0, 8)};");
source.AppendLine($" {energyOut} = {ProbeSignal(options, $"/debug/{name}/path/{path.Name}/energy_out", $"{representativeArea} * pow({wave}, 2.0)", 0, 8)};");
source.AppendLine($" {passivity} = {ProbeSignal(options, $"/debug/{name}/path/{path.Name}/passivity_ratio", $"{energyOut} / max(0.000001, {energyIn})", 0, 2)};");
- keepAlive.Add(input);
+ keepAlive.Add(representativeArea);
+ keepAlive.Add(delay);
keepAlive.Add(incoming);
keepAlive.Add(wave);
keepAlive.Add(energyIn);
@@ -662,32 +696,6 @@ public static class FaustEmitter
return $"{name}_primitive_radiated";
}
- private static string PrimitiveAreaExpression(SynthPatch patch, ParameterMap parameters, AreaFunction area, int areaIndex)
- {
- var baseDiameter = area.Shape.AverageDiameter(0, 1);
- if (area.Deformation is null)
- {
- return F(baseDiameter * baseDiameter);
- }
-
- var areaPath = $"/vocal/areas/{areaIndex}";
- var tongue = parameters.Expression(OwnerField(areaPath, "area/tongue_diameter"), area.Deformation.TongueDiameter);
- var constriction = parameters.Expression(OwnerField(areaPath, "area/constriction_diameter"), area.Deformation.ConstrictionDiameter);
- var lip = parameters.Expression(OwnerField(areaPath, "area/lip_opening"), area.Deformation.LipOpening);
- return $"pow(max(0.0001, ({F(baseDiameter)} + ({tongue}) * 0.10 + ({lip}) * 0.08 + min(0.0, ({constriction}) - 1.0) * 0.35)), 2.0)";
- }
-
- private static string PrimitiveDelayExpression(WaveguidePath path, int maxDelay, string delay) =>
- path.DelayStrategy switch
- {
- WaveClockDelayStrategy.UnitGrid => $"de.delay({maxDelay}, int(max(1.0, {delay})))",
- WaveClockDelayStrategy.HalfSampleGrid => $"de.fdelay({maxDelay}, round(max(0.5, {delay}) * 2.0) * 0.5)",
- WaveClockDelayStrategy.FractionalLagrange => $"de.fdelayltv({Math.Clamp(path.DelayOrder, 1, 5)}, {maxDelay}, max({F((Math.Clamp(path.DelayOrder, 1, 5) - 1) * 0.5f)}, {delay}))",
- WaveClockDelayStrategy.FractionalThiran => $"de.fdelay{Math.Clamp(path.DelayOrder, 1, 4)}a({maxDelay}, max({F(Math.Clamp(path.DelayOrder, 1, 4) - 0.5f)}, {delay}))",
- WaveClockDelayStrategy.CrossfadedVariable => $"de.fdelayltv({Math.Clamp(path.DelayOrder, 1, 5)}, {maxDelay}, max({F((Math.Clamp(path.DelayOrder, 1, 5) - 1) * 0.5f)}, {delay}))",
- _ => $"de.fdelay({maxDelay}, max(0.5, {delay}))"
- };
-
private static string PrimitiveSourceExpression(
StringBuilder source,
SynthPatch patch,
@@ -710,7 +718,10 @@ public static class FaustEmitter
var voiced = $"{voiceName}_primitive_source_{safe}_voiced";
var flow = $"{voiceName}_primitive_source_{safe}_flow";
source.AppendLine($" {load} = {ProbeSignal(options, $"/debug/{voiceName}/source/{port.Name}/load_pressure", $"({impedance}) * ({pressure})", 0, 2)};");
- source.AppendLine($" {voiced} = os.osc({frequency}) * clip01({opening}) * clip01(0.25 + {tension});");
+ // Harmonic glottal source: a band-limited saw through a one-pole lowpass. Tilt 0 is the brightest source and 1 the
+ // steepest rolloff; the cutoff sweeps five octaves, 6400 Hz down to 200 Hz, and sits below Nyquist.
+ var cutoff = $"min(0.45 * ma.SR, 200.0 * pow(2.0, (1.0 - clip01({parameters.Expression(OwnerField(owner, "tilt"), port.Tilt)})) * 5.0))";
+ source.AppendLine($" {voiced} = (os.sawtooth({frequency}) : fi.lowpass(1, {cutoff})) * clip01({opening}) * clip01(0.25 + {tension});");
source.AppendLine($" {flow} = {ProbeSignal(options, $"/debug/{voiceName}/source/{port.Name}/flow", $"({flowScale}) * (({voiced}) * ma.tanh((({pressure}) * (0.5 + {tension})) / max(0.05, {impedance})) + 0.08 * no.noise * {parameters.Expression(OwnerField(owner, "noise"), port.Noise)})", -2, 2)};");
keepAlive.Add(load);
keepAlive.Add(voiced);
@@ -742,6 +753,24 @@ public static class FaustEmitter
return released;
}
+ // Open fraction of a branch port: the ladder reads it as the velum area when the branch joins two paths of the network,
+ // otherwise it scales the flow term PrimitiveBranchExpression exchanges.
+ private static string PrimitiveBranchAdmittance(
+ StringBuilder source,
+ SynthPatch patch,
+ string voiceName,
+ BranchPort branch,
+ ParameterMap parameters,
+ FaustExportOptions options,
+ List keepAlive)
+ {
+ var owner = $"/vocal/branches/{BranchPortIndex(patch, branch)}";
+ var admittance = $"{voiceName}_primitive_branch_{SafeIdentifier(branch.Name)}_admittance";
+ source.AppendLine($" {admittance} = {ProbeSignal(options, $"/debug/{voiceName}/branch/{branch.Name}/admittance", $"clip01({parameters.Expression(OwnerField(owner, "opening"), branch.Opening)}) * clip01({parameters.Expression(OwnerField(owner, "coupling"), branch.Coupling)})", 0, 1)};");
+ keepAlive.Add(admittance);
+ return admittance;
+ }
+
private static string PrimitiveBranchExpression(
StringBuilder source,
SynthPatch patch,
@@ -751,18 +780,18 @@ public static class FaustEmitter
FaustExportOptions options,
List keepAlive)
{
- var index = BranchPortIndex(patch, branch);
- var owner = $"/vocal/branches/{index}";
- var safe = SafeIdentifier(branch.Name);
- var admittance = $"{voiceName}_primitive_branch_{safe}_admittance";
- var exchanged = $"{voiceName}_primitive_branch_{safe}_exchanged_flow";
- source.AppendLine($" {admittance} = {ProbeSignal(options, $"/debug/{voiceName}/branch/{branch.Name}/admittance", $"clip01({parameters.Expression(OwnerField(owner, "opening"), branch.Opening)}) * clip01({parameters.Expression(OwnerField(owner, "coupling"), branch.Coupling)})", 0, 1)};");
+ var admittance = PrimitiveBranchAdmittance(source, patch, voiceName, branch, parameters, options, keepAlive);
+ var exchanged = $"{voiceName}_primitive_branch_{SafeIdentifier(branch.Name)}_exchanged_flow";
source.AppendLine($" {exchanged} = {ProbeSignal(options, $"/debug/{voiceName}/branch/{branch.Name}/exchanged_flow", $"0.0 - {admittance} * 0.05", -1, 1)};");
- keepAlive.Add(admittance);
keepAlive.Add(exchanged);
return exchanged;
}
+ // DC and rumble block on the ladder's lip flow, below the lowest authored vowel F1 (about 270 Hz). It does not depend on the
+ // aperture: the ladder's lip cell already carries the lip area, and a corner that rose with aperture (40 + 500 Hz) sat above
+ // /u/'s F1 and put a 450 Hz hump in its envelope. The aperture stays the gain consonant closures use.
+ private const string RadiationHighpassHz = "200.0";
+
private static string PrimitiveRadiationExpression(
StringBuilder source,
SynthPatch patch,
@@ -785,7 +814,7 @@ public static class FaustEmitter
source.AppendLine($" {voiceName}_primitive_radiation_{safe}_reflection = {ProbeSignal(options, $"/debug/{voiceName}/radiation/{load.Name}/reflection", reflection, -1, 1)};");
source.AppendLine($" {boundary} = {ProbeSignal(options, $"/debug/{voiceName}/radiation/{load.Name}/boundary_flow", $"({wave}) * {aperture} / max(0.05, {impedance})", -4, 4)};");
source.AppendLine($" {flow} = {ProbeSignal(options, $"/debug/{voiceName}/radiation/{load.Name}/flow", boundary, -4, 4)};");
- source.AppendLine($" {output} = {ProbeSignal(options, $"/debug/{voiceName}/radiation/{load.Name}/output", $"{flow} : fi.highpass(1, 40.0 + 500.0 * {aperture})", -4, 4)};");
+ source.AppendLine($" {output} = {ProbeSignal(options, $"/debug/{voiceName}/radiation/{load.Name}/output", $"{flow} : fi.highpass(1, {RadiationHighpassHz})", -4, 4)};");
keepAlive.Add($"{voiceName}_primitive_radiation_{safe}_reflection");
keepAlive.Add(boundary);
keepAlive.Add(flow);
@@ -1622,50 +1651,44 @@ public static class FaustEmitter
return "1.0";
}
- var position = Math.Clamp(terminal.Position, 0, 1);
- var restDiameter = path.AreaFunction.DiameterAt(position);
- if (path.AreaControl is not { } area)
- {
- return F(Math.Max(0.000001f, restDiameter * restDiameter));
- }
-
- var pathIndex = AcousticPathIndex(patch, path);
- var areaPath = $"/acoustic/paths/{pathIndex}/area";
- var sections = Math.Max(2, path.AreaFunction.Sections);
- var index = F(position * (sections - 1));
- var tongueIndex = ScaledAreaIndex(parameters.Expression(OwnerField(areaPath, "tongue_index"), area.TongueIndex), area.IndexScale);
- var tongueDiameter = parameters.Expression(OwnerField(areaPath, "tongue_diameter"), area.TongueDiameter);
- var tongueWidth = $"max(0.001, {F(area.TongueWidth)} * {F(sections)})";
- var constrictionIndex = ScaledAreaIndex(parameters.Expression(OwnerField(areaPath, "constriction_index"), area.ConstrictionIndex), area.IndexScale);
- var constrictionDiameter = parameters.Expression(OwnerField(areaPath, "constriction_diameter"), area.ConstrictionDiameter);
- var constrictionWidth = $"max(0.001, {F(area.ConstrictionWidth)} * {F(sections)})";
- var lipOpening = parameters.Expression(OwnerField(areaPath, "lip_opening"), area.LipOpening);
- var lipWeight = MathF.Exp(0 - MathF.Pow((1 - position) / Math.Max(0.001f, area.LipWidth), 2));
- var tongueWeight = $"exp(0.0 - pow(({index} - ({tongueIndex})) / ({tongueWidth}), 2.0))";
- var constrictionWeight = $"exp(0.0 - pow(({index} - ({constrictionIndex})) / ({constrictionWidth}), 2.0))";
- var baseDiameter = $"({F(restDiameter)} + (({tongueDiameter}) - {F(restDiameter)}) * ({tongueWeight}) + (({lipOpening}) - {F(restDiameter)}) * {F(lipWeight)})";
- var diameter = $"max(0.0, min({baseDiameter}, ({constrictionDiameter}) + max(0.0, ({baseDiameter}) - ({constrictionDiameter})) * (1.0 - ({constrictionWeight}))))";
- return $"(({diameter}) * ({diameter}))";
-
- static string ScaledAreaIndex(string expression, float scale) =>
- scale == 1 ? expression : $"(({expression}) * {F(scale)})";
+ return DeformedAreaExpression(
+ parameters,
+ path.AreaFunction,
+ path.AreaControl,
+ $"/acoustic/paths/{AcousticPathIndex(patch, path)}/area",
+ Math.Clamp(terminal.Position, 0, 1));
}
private static string SegmentAreaExpression(
SynthPatch patch,
ParameterMap parameters,
- AcousticGraphSegment segment)
+ AcousticGraphSegment segment) =>
+ DeformedAreaExpression(
+ parameters,
+ segment.Path.AreaFunction,
+ segment.Path.AreaControl,
+ $"/acoustic/paths/{AcousticPathIndex(patch, segment.Path)}/area",
+ (segment.StartPosition + segment.EndPosition) * 0.5f);
+
+ ///
+ /// The one owner of the Gaussian tongue/constriction/lip area deformation: cross-section area (cm^2) of a tract at a
+ /// normalized position, as a Faust expression over the control surfaces under . Acoustic
+ /// graph terminals and segments and the vocal ladder's cells all read their area here.
+ ///
+ private static string DeformedAreaExpression(
+ ParameterMap parameters,
+ TractAreaFunction shape,
+ AcousticAreaControl? area,
+ string areaPath,
+ float position)
{
- var position = (segment.StartPosition + segment.EndPosition) * 0.5f;
- var restDiameter = segment.Path.AreaFunction.DiameterAt(position);
- if (segment.Path.AreaControl is not { } area)
+ var restDiameter = shape.DiameterAt(position);
+ if (area is null)
{
return F(Math.Max(0.000001f, restDiameter * restDiameter));
}
- var pathIndex = AcousticPathIndex(patch, segment.Path);
- var areaPath = $"/acoustic/paths/{pathIndex}/area";
- var sections = Math.Max(2, segment.Path.AreaFunction.Sections);
+ var sections = Math.Max(2, shape.Sections);
var index = F(position * (sections - 1));
var tongueIndex = ScaledAreaIndex(parameters.Expression(OwnerField(areaPath, "tongue_index"), area.TongueIndex), area.IndexScale);
var tongueDiameter = parameters.Expression(OwnerField(areaPath, "tongue_diameter"), area.TongueDiameter);
diff --git a/src/AquaSynth.Core/IpaGestureExperiment.cs b/src/AquaSynth.Core/IpaGestureExperiment.cs
index 146f1ef..6916988 100644
--- a/src/AquaSynth.Core/IpaGestureExperiment.cs
+++ b/src/AquaSynth.Core/IpaGestureExperiment.cs
@@ -234,10 +234,11 @@ public static class IpaGestureExperiment
waveguide_path name=nasal_path morphology=nasal strategy=thiran order=1 max_delay=4096 loss=.997
source_port name=folds path=oral_path kind=glottal position=0 pressure=.66 tension=.54 opening=.42 noise=.04 impedance=.32 flow_scale=.12
branch_port name=velopharynx from=oral_path from_position=.45 to=nasal_path opening=.01 coupling=1
- constriction_contact name=contact path=oral_path position=.92 opening=.5 resistance=.45 stored_pressure=.12
+ constriction_contact name=contact path=oral_path position=.92 opening=1 resistance=.45 stored_pressure=.12
radiation_load name=mouth path=oral_path kind=lip position=1 aperture=.8 reflection=-.82 impedance=.28
+ radiation_load name=nostril path=nasal_path kind=lip position=1 aperture=.8 reflection=-.82 impedance=.28
probe_timeline name=flow network=voice blocks=12 block_size=64
- vocal_network name=voice paths=oral_path,nasal_path sources=folds contacts=contact branches=velopharynx radiation=mouth probe=flow
+ vocal_network name=voice paths=oral_path,nasal_path sources=folds contacts=contact branches=velopharynx radiation=mouth,nostril probe=flow
{{string.Join("\n", gestureLines)}}
vocal network=voice freq={{Format(frequency)}} gain=.7 sustain={{Format(sustain)}}
""";
diff --git a/src/AquaSynth.Core/Model.cs b/src/AquaSynth.Core/Model.cs
index 82e4e79..5052134 100644
--- a/src/AquaSynth.Core/Model.cs
+++ b/src/AquaSynth.Core/Model.cs
@@ -468,7 +468,8 @@ public sealed record SourcePort(
float Opening = 0.5f,
float Noise = 0.08f,
float Impedance = 0.35f,
- float FlowScale = 0.02f);
+ float FlowScale = 0.02f,
+ float Tilt = 0.5f);
public sealed record ConstrictionContact(
string Name,
diff --git a/src/AquaSynth.Core/PatchScript.cs b/src/AquaSynth.Core/PatchScript.cs
index d47cbe5..622bd7c 100644
--- a/src/AquaSynth.Core/PatchScript.cs
+++ b/src/AquaSynth.Core/PatchScript.cs
@@ -8,6 +8,34 @@ public sealed class PatchScriptException(int line, string message) : Exception($
}
public static class PatchScript
{
+ ///
+ /// The DSL's own IPA table: the phones a phoneme_gesture knows without an explicit descriptor. A hand-written script
+ /// that names another phone falls back to an alveolar approximant; a caller that must not be approximated asks here first.
+ ///
+ public static bool TryIpaDescriptor(string ipa, out string descriptor)
+ {
+ var known = ipa switch
+ {
+ "p" => "voiceless_bilabial_plosive",
+ "b" => "voiced_bilabial_plosive",
+ "m" => "voiced_bilabial_nasal",
+ "n" => "voiced_alveolar_nasal",
+ "s" => "voiceless_alveolar_fricative",
+ "z" => "voiced_alveolar_fricative",
+ "ʃ" => "voiceless_postalveolar_fricative",
+ "ʒ" => "voiced_postalveolar_fricative",
+ "ʍ" => "voiceless_labial_velar_fricative",
+ "w" => "voiced_labial_velar_approximant",
+ "i" => "voiced_close_front_unrounded_vowel",
+ "u" => "voiced_close_back_rounded_vowel",
+ "a" or "ɑ" => "voiced_open_back_unrounded_vowel",
+ "æ" => "voiced_open_front_unrounded_vowel",
+ "ə" => "voiced_mid_central_unrounded_vowel",
+ _ => null
+ };
+ descriptor = known ?? "";
+ return known is not null;
+ }
public static SynthPatch Parse(string script)
{
@@ -866,7 +894,8 @@ public static class PatchScript
GetBoundFloat(fields, line, 0.5f, $"/vocal/sources/{_sourcePorts.Count}/opening", "opening", "open"),
GetBoundFloat(fields, line, 0.08f, $"/vocal/sources/{_sourcePorts.Count}/noise", "noise", "aspiration"),
GetBoundFloat(fields, line, 0.35f, $"/vocal/sources/{_sourcePorts.Count}/impedance", "impedance", "load", "source_impedance"),
- GetBoundFloat(fields, line, 0.02f, $"/vocal/sources/{_sourcePorts.Count}/flow_scale", "flow_scale", "scale"));
+ GetBoundFloat(fields, line, 0.02f, $"/vocal/sources/{_sourcePorts.Count}/flow_scale", "flow_scale", "scale"),
+ GetBoundFloat(fields, line, 0.5f, $"/vocal/sources/{_sourcePorts.Count}/tilt", "tilt", "spectral_tilt"));
_sourcePorts.Add(port);
_sourcePortsByName[name] = port;
RegisterSourcePortSurfaces(_sourcePorts.Count - 1, port);
@@ -1312,6 +1341,7 @@ public static class PatchScript
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "noise"), port.Name, "noise", port.Noise);
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "impedance"), port.Name, "impedance", port.Impedance, 0.05f, 2);
RegisterNormalizedPrimitiveSurface(OwnerField(owner, "flow_scale"), port.Name, "flow_scale", port.FlowScale, 0, 0.2f);
+ RegisterNormalizedPrimitiveSurface(OwnerField(owner, "tilt"), port.Name, "tilt", port.Tilt);
}
private void RegisterAreaFunctionSurfaces(int index, AreaFunction area)
@@ -1448,7 +1478,7 @@ public static class PatchScript
if (tokens.Contains("vowel"))
{
- EmitVowelGesture(name, tokens, start, duration, intensity);
+ EmitVowelGesture(name, tokens, start, duration);
return;
}
@@ -1485,18 +1515,54 @@ public static class PatchScript
}
}
- private void EmitVowelGesture(string name, HashSet tokens, float start, float duration, float intensity)
+ // Vowel tract shapes for the Kelly-Lochbaum ladder on the experiment scaffold (six rest diameters, 17 cm, 48 kHz): tongue
+ // section index (0..5), tongue and lip diameters in cm. Each was found by grid search over the exported ladder's impulse
+ // response (peak picking) toward textbook F1/F2, not by hand: measured F1/F2 in Hz in the trailing comment.
+ // Features are (front 0..1, openness 0..1, rounded 0/1); a descriptor selects the nearest anchor, there is no interpolation
+ // because the shapes are not monotone in formant space.
+ private static readonly (float Front, float Open, float Round, float TongueIndex, float TongueCm, float LipCm)[] VowelShapes =
+ [
+ (1.0f, 0.0f, 0, 3.00f, 0.15f, 2.20f), // i 271/2313
+ (0.0f, 0.0f, 1, 2.25f, 0.45f, 0.05f), // u 292/876
+ (0.0f, 1.0f, 0, 1.50f, 0.35f, 1.00f), // a 703/1084
+ (1.0f, 1.0f, 0, 0.50f, 0.30f, 1.00f), // ae 665/1755
+ (1.0f, 0.5f, 0, 2.50f, 1.80f, 2.20f), // e 535/1799
+ (0.0f, 0.5f, 1, 1.50f, 0.10f, 1.60f), // o 454/962
+ (0.5f, 0.5f, 0, 2.00f, 1.20f, 0.60f), // schwa 497/1503
+ ];
+
+ private void EmitVowelGesture(string name, HashSet tokens, float start, float duration)
{
- var frontness = tokens.Contains("front") ? 0.70f : tokens.Contains("back") ? 0.30f : 0.50f;
- var openness = tokens.Contains("close") ? 0.30f : tokens.Contains("mid") ? 0.50f : tokens.Contains("open") ? 0.78f : 0.55f;
- var rounding = tokens.Contains("rounded") ? 0.35f : tokens.Contains("unrounded") ? 0.72f : 0.60f;
- AddAuthoredSpline($"{name}_tongue_index", "/vocal/areas/0/area/tongue_index", start, duration, (0, frontness), (1, frontness));
- AddAuthoredSpline($"{name}_tongue_diameter", "/vocal/areas/0/area/tongue_diameter", start, duration, (0, openness), (1, openness));
- AddAuthoredSpline($"{name}_lip_area", "/vocal/areas/0/area/lip_opening", start, duration, (0, rounding), (1, rounding));
- AddAuthoredSpline($"{name}_lip_radiation", "/vocal/radiation/0/aperture", start, duration, (0, Math.Clamp(rounding + 0.1f * intensity, 0, 1)), (1, Math.Clamp(rounding + 0.1f * intensity, 0, 1)));
+ var front = tokens.Contains("front") ? 1f : tokens.Contains("back") ? 0f : 0.5f;
+ var open = tokens.Contains("close") ? 0f : tokens.Contains("mid") ? 0.5f : tokens.Contains("open") ? 1f : 0.5f;
+ var round = tokens.Contains("rounded") && !tokens.Contains("unrounded") ? 1f : 0f;
+ var shape = VowelShapes
+ .OrderBy(candidate => MathF.Abs(candidate.Front - front) + MathF.Abs(candidate.Open - open) + MathF.Abs(candidate.Round - round))
+ .First();
+ HoldOnSurface($"{name}_tongue_index", "/vocal/areas/0/area/tongue_index", start, duration, shape.TongueIndex);
+ HoldOnSurface($"{name}_tongue_diameter", "/vocal/areas/0/area/tongue_diameter", start, duration, shape.TongueCm);
+ HoldOnSurface($"{name}_lip_area", "/vocal/areas/0/area/lip_opening", start, duration, shape.LipCm);
+ // A vowel leaves the tract unconstricted: hold the constriction at the surface maximum.
+ AddAuthoredSpline($"{name}_constriction_diameter", "/vocal/areas/0/area/constriction_diameter", start, duration, (0, 1), (1, 1));
+ // The ladder's lip cell already carries the lip area into the resonance and the level. Aperture is the radiating
+ // gain the consonant closures use (the lip cell cannot shut), so a vowel keeps it fully open; scaling it by the lip
+ // area as well counted the rounding twice and left /u/ 30 dB under /i/.
+ AddAuthoredSpline($"{name}_lip_radiation", "/vocal/radiation/0/aperture", start, duration, (0, 1), (1, 1));
AddAuthoredSpline($"{name}_velum", "/vocal/branches/0/opening", start, duration, (0, 0.01f), (1, 0.01f));
}
+ // Holds a surface at a value in its own unit (cm, section index); splines carry the surface's normalized range.
+ private void HoldOnSurface(string name, string surfacePath, float start, float duration, float value)
+ {
+ if (!_controlSurfacesByPath.TryGetValue(surfacePath, out var surface) || surface.MaxValue <= surface.MinValue)
+ {
+ return;
+ }
+
+ var normalized = Math.Clamp((value - surface.MinValue) / (surface.MaxValue - surface.MinValue), 0, 1);
+ AddAuthoredSpline(name, surfacePath, start, duration, (0, normalized), (1, normalized));
+ }
+
private static string GestureDescriptor(string ipa, IReadOnlyDictionary fields)
{
var explicitDescriptor = GetAny(fields, ["descriptor", "desc", "features"], "");
@@ -1513,25 +1579,7 @@ public static class PatchScript
return $"{phonation}_{place}_{manner}";
}
- return ipa switch
- {
- "p" => "voiceless_bilabial_plosive",
- "b" => "voiced_bilabial_plosive",
- "m" => "voiced_bilabial_nasal",
- "n" => "voiced_alveolar_nasal",
- "s" => "voiceless_alveolar_fricative",
- "z" => "voiced_alveolar_fricative",
- "ʃ" => "voiceless_postalveolar_fricative",
- "ʒ" => "voiced_postalveolar_fricative",
- "ʍ" => "voiceless_labial_velar_fricative",
- "w" => "voiced_labial_velar_approximant",
- "i" => "voiced_close_front_unrounded_vowel",
- "u" => "voiced_close_back_rounded_vowel",
- "a" or "ɑ" => "voiced_open_back_unrounded_vowel",
- "æ" => "voiced_open_front_unrounded_vowel",
- "ə" => "voiced_mid_central_unrounded_vowel",
- _ => "voiced_alveolar_approximant"
- };
+ return TryIpaDescriptor(ipa, out var known) ? known : "voiced_alveolar_approximant";
}
private static HashSet DescriptorTokens(string descriptor) =>
diff --git a/src/AquaSynth.Core/ProbeTimelineReport.cs b/src/AquaSynth.Core/ProbeTimelineReport.cs
index 6306308..d5ab2fb 100644
--- a/src/AquaSynth.Core/ProbeTimelineReport.cs
+++ b/src/AquaSynth.Core/ProbeTimelineReport.cs
@@ -19,8 +19,9 @@ public static class ProbeTimelineReport
var surfacesByField = patch.ControlSurfaces.ToDictionary(surface => surface.FieldPath, StringComparer.OrdinalIgnoreCase);
var splinesBySurface = patch.ControlSplines
.Where(spline => spline.Enabled)
- .GroupBy(spline => spline.SurfacePath, StringComparer.OrdinalIgnoreCase)
- .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase);
+ .Select(spline => spline.SurfacePath)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(surfacePath => surfacePath, surfacePath => ControlSplineTimeline.SurfaceSplines(patch.ControlSplines, surfacePath), StringComparer.OrdinalIgnoreCase);
var samples = new List();
var blockSize = patch.ProbeTimelines.FirstOrDefault(probe => probe.Name.Equals(network.ProbeTimeline, StringComparison.OrdinalIgnoreCase))?.BlockSize ?? 64;
diff --git a/src/AquaSynth.Core/WeksaPhones.cs b/src/AquaSynth.Core/WeksaPhones.cs
new file mode 100644
index 0000000..9939c79
--- /dev/null
+++ b/src/AquaSynth.Core/WeksaPhones.cs
@@ -0,0 +1,101 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace AquaSynth.Dsl;
+
+public enum WeksaStress { None, Primary, Secondary }
+
+public sealed record WeksaPhone(string Ipa, WeksaStress Stress);
+
+public sealed record WeksaPhones(string Language, string Source, IReadOnlyList Phones);
+
+public sealed class WeksaPhonesException(string message) : Exception(message);
+
+///
+/// Reader for the Weksa phones line (`weksa lower --phones`): {"language","source","phones":[{"ipa","stress"}]}. It is
+/// strict: a missing, extra, null or mistyped key, an unknown stress value, an empty phone list or malformed JSON is an
+/// error, never a guess. Weksa owns the segmentation; durations, pitch and gesture stay with .
+///
+public static class WeksaPhonesReader
+{
+ private static readonly JsonSerializerOptions Strict = new()
+ {
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
+ AllowTrailingCommas = false,
+ ReadCommentHandling = JsonCommentHandling.Disallow,
+ PropertyNameCaseInsensitive = false
+ };
+
+ private sealed class PhoneDto
+ {
+ [JsonPropertyName("ipa")] public required string? Ipa { get; init; }
+ [JsonPropertyName("stress")] public required string? Stress { get; init; }
+ }
+
+ private sealed class LineDto
+ {
+ [JsonPropertyName("language")] public required string? Language { get; init; }
+ [JsonPropertyName("source")] public required string? Source { get; init; }
+ [JsonPropertyName("phones")] public required List? Phones { get; init; }
+ }
+
+ public static WeksaPhones Parse(string json)
+ {
+ LineDto? dto;
+ try { dto = JsonSerializer.Deserialize(json, Strict); }
+ catch (JsonException ex) { throw new WeksaPhonesException($"phones line is not the expected JSON: {ex.Message}"); }
+ if (dto is null) throw new WeksaPhonesException("phones line is null");
+ if (string.IsNullOrWhiteSpace(dto.Language)) throw new WeksaPhonesException("`language` must be a non-empty string");
+ if (string.IsNullOrWhiteSpace(dto.Source)) throw new WeksaPhonesException("`source` must be a non-empty string");
+ if (dto.Phones is null || dto.Phones.Count == 0) throw new WeksaPhonesException("`phones` must be a non-empty list");
+ var phones = new List();
+ for (var i = 0; i < dto.Phones.Count; i++)
+ {
+ var p = dto.Phones[i] ?? throw new WeksaPhonesException($"phones[{i}] is null");
+ if (string.IsNullOrWhiteSpace(p.Ipa)) throw new WeksaPhonesException($"phones[{i}].ipa must be a non-empty string");
+ var stress = p.Stress switch
+ {
+ "none" => WeksaStress.None,
+ "primary" => WeksaStress.Primary,
+ "secondary" => WeksaStress.Secondary,
+ null => throw new WeksaPhonesException($"phones[{i}].stress must be a string"),
+ var other => throw new WeksaPhonesException($"phones[{i}].stress `{other}` is not none, primary or secondary")
+ };
+ phones.Add(new WeksaPhone(p.Ipa, stress));
+ }
+
+ return new WeksaPhones(dto.Language, dto.Source, phones);
+ }
+}
+
+///
+/// A Weksa phones line as a word the tract can render. Every phone must be in the DSL's IPA table
+/// (); a phone outside it is listed in and the plan
+/// carries no targets, so nothing is approximated. Duration is the vowel/consonant length AquaSynth owns; stress moves
+/// only the gesture intensity, the one thing the word script uses stress for.
+///
+public sealed record WeksaWordPlan(IReadOnlyList<(IpaGestureExperimentTarget Target, float Intensity)> Phones, IReadOnlyList Unsupported)
+{
+ public const float VowelSeconds = 0.18f, ConsonantSeconds = 0.095f;
+ public bool Renderable => Unsupported.Count == 0;
+
+ public static float StressIntensity(WeksaStress stress) => stress switch
+ {
+ WeksaStress.Primary => 1.2f,
+ WeksaStress.Secondary => 1.1f,
+ _ => 1f
+ };
+
+ public static WeksaWordPlan From(WeksaPhones word, string label)
+ {
+ var unsupported = word.Phones.Where(p => !PatchScript.TryIpaDescriptor(p.Ipa, out _)).ToArray();
+ if (unsupported.Length > 0) return new WeksaWordPlan([], unsupported);
+ var targets = word.Phones.Select((p, i) =>
+ {
+ PatchScript.TryIpaDescriptor(p.Ipa, out var descriptor);
+ var seconds = descriptor.EndsWith("_vowel", StringComparison.Ordinal) ? VowelSeconds : ConsonantSeconds;
+ return (new IpaGestureExperimentTarget($"{label}_{i}_{p.Ipa}", p.Ipa, "", seconds), StressIntensity(p.Stress));
+ }).ToArray();
+ return new WeksaWordPlan(targets, []);
+ }
+}
diff --git a/src/AquaSynth.Faust/AquaSynthDaemonService.cs b/src/AquaSynth.Faust/AquaSynthDaemonService.cs
index 0021895..16897f6 100644
--- a/src/AquaSynth.Faust/AquaSynthDaemonService.cs
+++ b/src/AquaSynth.Faust/AquaSynthDaemonService.cs
@@ -906,7 +906,7 @@ public sealed class AquaSynthDaemonService : IDisposable
try
{
var faustName = AquaSynthNativeCompiler.SafeFaustName(identity.FaustName);
- var source = FaustEmitter.EmitScript(identity.Script, new FaustExportOptions(faustName)).Source;
+ var source = FaustEmitter.EmitScript(identity.Script, new FaustExportOptions(faustName, SampleRate: options.SampleRate)).Source;
return compiler.TryCompileSource(identity, source, durationSeconds, out patch, out error);
}
catch (Exception ex)
diff --git a/src/AquaSynth.Faust/IpaTrialOrchestrator.cs b/src/AquaSynth.Faust/IpaTrialOrchestrator.cs
index ef37d3d..5f6e2fe 100644
--- a/src/AquaSynth.Faust/IpaTrialOrchestrator.cs
+++ b/src/AquaSynth.Faust/IpaTrialOrchestrator.cs
@@ -156,12 +156,13 @@ public static class IpaTrialOrchestrator
var candidateDir = Path.Combine(round.RoundDirectory, "audio", candidate.Id);
Directory.CreateDirectory(candidateDir);
var script = await File.ReadAllTextAsync(candidate.ScriptPath, cancellationToken).ConfigureAwait(false);
- var source = FaustEmitter.EmitScript(script, new FaustExportOptions(candidate.Id)).Source;
+ var referenceFixture = PinkTromboneParityFixtures.ById(target.ReferenceFixtureId);
+ var reference = referenceRenderer.Render(referenceFixture.Controls, target.Target.DurationSeconds + .18f);
+ // The vocal ladder's cell length is tied to the rate the DSP runs at, which is the reference's rate.
+ var source = FaustEmitter.EmitScript(script, new FaustExportOptions(candidate.Id, SampleRate: reference.SampleRate)).Source;
var dspPath = Path.Combine(candidateDir, "candidate.dsp");
await File.WriteAllTextAsync(dspPath, source, cancellationToken).ConfigureAwait(false);
- var referenceFixture = PinkTromboneParityFixtures.ById(target.ReferenceFixtureId);
- var reference = referenceRenderer.Render(referenceFixture.Controls, target.Target.DurationSeconds + .18f);
var candidateRender = await FaustCompiler.RenderAsync(
source,
new FaustRenderOptions(reference.SampleRate, reference.Samples.Length / (float)reference.SampleRate),
@@ -311,12 +312,12 @@ public static class IpaTrialOrchestrator
var timelinePath = Path.Combine(timelineRoot, $"{safeCandidateId}.csv");
await File.WriteAllTextAsync(timelinePath, ProbeTimelineReport.ToCsv(timeline), cancellationToken).ConfigureAwait(false);
- var source = FaustEmitter.EmitScript(script, new FaustExportOptions(safeCandidateId)).Source;
+ var referenceFixture = PinkTromboneParityFixtures.ById(mapped.target.ReferenceFixtureId);
+ var reference = referenceRenderer.Render(referenceFixture.Controls, mapped.target.Target.DurationSeconds + .18f);
+ var source = FaustEmitter.EmitScript(script, new FaustExportOptions(safeCandidateId, SampleRate: reference.SampleRate)).Source;
var dspPath = Path.Combine(candidateDir, "candidate.dsp");
await File.WriteAllTextAsync(dspPath, source, cancellationToken).ConfigureAwait(false);
- var referenceFixture = PinkTromboneParityFixtures.ById(mapped.target.ReferenceFixtureId);
- var reference = referenceRenderer.Render(referenceFixture.Controls, mapped.target.Target.DurationSeconds + .18f);
var candidateRender = await FaustCompiler.RenderAsync(
source,
new FaustRenderOptions(reference.SampleRate, reference.Samples.Length / (float)reference.SampleRate),
diff --git a/src/AquaSynth.Faust/NativeFaustRuntime.cs b/src/AquaSynth.Faust/NativeFaustRuntime.cs
index 99bc288..b84e3a8 100644
--- a/src/AquaSynth.Faust/NativeFaustRuntime.cs
+++ b/src/AquaSynth.Faust/NativeFaustRuntime.cs
@@ -511,7 +511,7 @@ public sealed class AquaSynthNativeCompiler : IDisposable
ObjectDisposedException.ThrowIf(disposed, this);
var patch = PatchScript.Parse(identity.Script);
var faustName = SafeFaustName(identity.FaustName);
- var export = FaustEmitter.Emit(patch, new FaustExportOptions(faustName));
+ var export = FaustEmitter.Emit(patch, new FaustExportOptions(faustName, SampleRate: options.SampleRate));
var duration = EstimateDuration(patch, options);
var sourcePath = WriteDspSource(identity.Id, export.Source, options.DspSourceDirectory);
return CompileSource(identity, faustName, export.Source, duration, sourcePath, ControlSurfaceCatalog.FromPatch(patch));
diff --git a/tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs b/tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs
index 129491f..f4c5635 100644
--- a/tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs
+++ b/tests/AquaSynth.Dsl.Tests/AquaSynthDaemonServiceTests.cs
@@ -4,6 +4,7 @@ using GameCult.Caching.MessagePack;
namespace AquaSynth.Dsl.Tests;
+[Collection("NativeFaust")]
public sealed class AquaSynthDaemonServiceTests
{
[Fact]
diff --git a/tests/AquaSynth.Dsl.Tests/FaustRenderedSpeechLossSurfaceTests.cs b/tests/AquaSynth.Dsl.Tests/FaustRenderedSpeechLossSurfaceTests.cs
index 5ee5952..d267fbc 100644
--- a/tests/AquaSynth.Dsl.Tests/FaustRenderedSpeechLossSurfaceTests.cs
+++ b/tests/AquaSynth.Dsl.Tests/FaustRenderedSpeechLossSurfaceTests.cs
@@ -3,6 +3,7 @@ using AquaSynth.Faust;
namespace AquaSynth.Dsl.Tests;
+[Collection("NativeFaust")]
public sealed class FaustRenderedSpeechLossSurfaceTests
{
[Fact]
diff --git a/tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/PROVENANCE.txt b/tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/PROVENANCE.txt
new file mode 100644
index 0000000..4683ab0
--- /dev/null
+++ b/tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/PROVENANCE.txt
@@ -0,0 +1 @@
+nibu-en-US.phones.json is data/phonology/fixtures/nibu-en-US.phones.json from GameCult/Weksa PR 16 (branch weksa-lowering/phones-line), commit a7bc2478e53623ce572ac6d997ba058aa7707e44, byte for byte.
diff --git a/tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/nibu-en-US.phones.json b/tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/nibu-en-US.phones.json
new file mode 100644
index 0000000..45e2e11
--- /dev/null
+++ b/tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/nibu-en-US.phones.json
@@ -0,0 +1 @@
+{"language":"en-US","source":"ˈnibu","phones":[{"ipa":"n","stress":"none"},{"ipa":"i","stress":"primary"},{"ipa":"b","stress":"none"},{"ipa":"u","stress":"none"}]}
diff --git a/tests/AquaSynth.Dsl.Tests/GlottalSourceSpectrumTests.cs b/tests/AquaSynth.Dsl.Tests/GlottalSourceSpectrumTests.cs
new file mode 100644
index 0000000..5d43dea
--- /dev/null
+++ b/tests/AquaSynth.Dsl.Tests/GlottalSourceSpectrumTests.cs
@@ -0,0 +1,125 @@
+using AquaSynth.Dsl;
+using AquaSynth.Faust;
+using Xunit.Abstractions;
+
+namespace AquaSynth.Dsl.Tests;
+
+// The primitive vocal network has no resonators, so the rendered spectrum is the glottal source's own: harmonics of the
+// pitch with a tilt-controlled rolloff. These tests render through the native Faust path and measure by DFT.
+[Collection("NativeFaust")]
+public sealed class GlottalSourceSpectrumTests(ITestOutputHelper output)
+{
+ private const int Rate = 48000;
+ private const float Pitch = 137f;
+
+ private static string Script(float tilt, float pitch) => $$"""
+ morphology name=oral length_cm=17 diameters=.6,.8,1.2,1.5,.9
+ waveguide_path name=oral_path morphology=oral loss=.998
+ source_port name=folds path=oral_path pressure=.7 tension=.55 opening=.45 noise=0 impedance=.3 tilt={{tilt}}
+ radiation_load name=mouth path=oral_path aperture=.8 reflection=-.82 impedance=.28
+ vocal_network name=voice paths=oral_path sources=folds radiation=mouth
+ vocal network=voice freq={{pitch}} gain=.2 sustain=1.5
+ """;
+
+ // One second of steady output, so every harmonic of Pitch and every other integer-Hz frequency sits on its own DFT bin.
+ private float[]? Render(float tilt, float pitch = Pitch, int skip = 4800)
+ {
+ var source = FaustEmitter.Emit(PatchScript.Parse(Script(tilt, pitch)), new FaustExportOptions("glottal_spectrum")).Source;
+ using var compiler = new AquaSynthPatchCompiler(new AquaSynthNativeOptions(SampleRate: Rate));
+ if (!compiler.TryCompileSource(new AquaSynthCompileIdentity("glottal_spectrum", "glottal_spectrum", ""), source, 1.4f, out var patch, out var error))
+ {
+ if (error?.Contains("Faust toolchain not found", StringComparison.OrdinalIgnoreCase) == true ||
+ error?.Contains("Faust DLL not found", StringComparison.OrdinalIgnoreCase) == true)
+ {
+ return null;
+ }
+
+ Assert.Fail($"glottal spectrum compile failed: {error}");
+ }
+
+ using (patch)
+ {
+ var samples = patch!.Render(1.0f);
+ Assert.True(samples.Length >= Rate + skip, $"render too short: {samples.Length}");
+ return samples.Skip(skip).Take(Rate).ToArray();
+ }
+ }
+
+ private static double Magnitude(float[] window, double hz)
+ {
+ double re = 0, im = 0;
+ var step = 2 * Math.PI * hz / Rate;
+ for (var n = 0; n < window.Length; n++)
+ {
+ re += window[n] * Math.Cos(step * n);
+ im -= window[n] * Math.Sin(step * n);
+ }
+
+ return Math.Sqrt(re * re + im * im) * 2 / window.Length;
+ }
+
+ private static double Db(double ratio) => 20 * Math.Log10(Math.Max(ratio, 1e-12));
+
+ private double[]? HarmonicsDb(float tilt, int count)
+ {
+ var window = Render(tilt);
+ if (window is null) return null;
+ var fundamental = Magnitude(window, Pitch);
+ Assert.True(fundamental > 1e-5, $"tilt {tilt}: silent fundamental {fundamental}");
+ var levels = Enumerable.Range(1, count).Select(k => Db(Magnitude(window, Pitch * k) / fundamental)).ToArray();
+ output.WriteLine($"tilt {tilt}: " + string.Join(" ", levels.Select(level => level.ToString("F1"))) + $" (fundamental {fundamental:E2})");
+ return levels;
+ }
+
+ [Fact]
+ public void GlottalSourceHasHarmonicsAboveTheFundamental()
+ {
+ var levels = HarmonicsDb(.5f, 8);
+ if (levels is null) return;
+
+ // a sine source would leave harmonics 2..8 at the numerical floor, far below -60 dB
+ for (var k = 1; k < 8; k++)
+ {
+ Assert.True(levels[k] > -45, $"harmonic {k + 1} is {levels[k]:F1} dB re fundamental");
+ }
+ }
+
+ [Fact]
+ public void HigherTiltRollsHarmonicsOffMoreSteeply()
+ {
+ var bright = HarmonicsDb(.1f, 10);
+ var dark = HarmonicsDb(.9f, 10);
+ if (bright is null || dark is null) return;
+
+ Assert.True(bright[9] - dark[9] > 12, $"harmonic 10 re fundamental: tilt .1 {bright[9]:F1} dB, tilt .9 {dark[9]:F1} dB");
+ Assert.True(bright[4] - dark[4] > 6, $"harmonic 5 re fundamental: tilt .1 {bright[4]:F1} dB, tilt .9 {dark[4]:F1} dB");
+ }
+
+ [Fact]
+ public void BrightestHighPitchedSourceDoesNotAlias()
+ {
+ // 2744 Hz is 17.49 samples per period, and 48000 and 2744 share a factor of 8, so a naive saw folds its partials above
+ // Nyquist onto multiples of 8 Hz that are not harmonics of the pitch; the band-limited saw leaves them 44.8 dB down (worst bin, measured) and a naive saw 18.9 dB down. The threshold sits between.
+ const float pitch = 2744f;
+ // The tract rings for a few hundred ms after the note starts; that decaying transient is not periodic and would fill the gaps between harmonics, so the window starts after it.
+ var window = Render(0f, pitch, skip: 16800);
+ if (window is null) return;
+
+ // The vocal tract shapes the harmonics, so the reference is the strongest of them rather than the fundamental.
+ var strongest = Enumerable.Range(1, 6).Max(k => Magnitude(window, pitch * k));
+ var worst = Db(Enumerable.Range(100, 900).Where(n => n % 343 != 0).Max(n => Magnitude(window, 8.0 * n)) / strongest);
+ output.WriteLine($"tilt 0 pitch {pitch}: worst non-harmonic level 800-8000 Hz {worst:F1} dB re strongest harmonic");
+ Assert.True(worst < -32, $"non-harmonic energy {worst:F1} dB re strongest harmonic");
+ }
+
+ [Fact]
+ public void TiltIsAnAuthoredSourceControlSurfaceDefaultingToHalf()
+ {
+ var authored = PatchScript.Parse(Script(.8f, Pitch));
+ Assert.Equal(.8f, Assert.Single(authored.SourcePorts).Tilt, 5);
+ Assert.Contains(authored.ControlSurfaces, surface => surface.Path == "/vocal/sources/0/tilt");
+
+ var defaulted = PatchScript.Parse(Script(.8f, Pitch).Replace("tilt=0.8", "", StringComparison.Ordinal));
+ Assert.Equal(.5f, Assert.Single(defaulted.SourcePorts).Tilt, 5);
+ }
+}
diff --git a/tests/AquaSynth.Dsl.Tests/NativeFaustRuntimeTests.cs b/tests/AquaSynth.Dsl.Tests/NativeFaustRuntimeTests.cs
index d606653..6c3ff1a 100644
--- a/tests/AquaSynth.Dsl.Tests/NativeFaustRuntimeTests.cs
+++ b/tests/AquaSynth.Dsl.Tests/NativeFaustRuntimeTests.cs
@@ -3,6 +3,7 @@ using AquaSynth.Faust;
namespace AquaSynth.Dsl.Tests;
+[Collection("NativeFaust")]
public sealed class NativeFaustRuntimeTests
{
[Fact]
@@ -169,9 +170,81 @@ public sealed class NativeFaustRuntimeTests
var samples = patch.Render(controls);
Assert.Contains("/vocal/sources/0/pressure", controls.Keys);
- Assert.DoesNotContain("/vocal/radiation/0/reflection", controls.Keys);
+ Assert.Contains("/vocal/radiation/0/reflection", controls.Keys);
Assert.True(samples.Length > 1024);
Assert.Contains(samples, sample => MathF.Abs(sample) > 0.0001f);
}
}
+
+ // Two back-to-back gestures on one surface. The engine's ControlSplineTimeline merges them into one absolute curve, so
+ // after the loud gesture ends the quiet one owns the value; summing per-spline offsets would keep the loud tail on top.
+ private const string BackToBackPressure = """
+ morphology name=oral length_cm=17 diameters=.6,.8,1.2,1.5,.9
+ waveguide_path name=oral_path morphology=oral loss=.998
+ source_port name=folds path=oral_path pressure=.5 tension=.55 opening=.45 noise=0 impedance=.3
+ radiation_load name=mouth path=oral_path aperture=.8 reflection=-.82 impedance=.28
+ vocal_network name=voice paths=oral_path sources=folds radiation=mouth
+ {0}
+ vocal network=voice freq=150 gain=.2 sustain=.5
+ """;
+
+ private const string LoudGesture = "control_spline name=loud surface=/vocal/sources/0/pressure points=0:1,0.1:1";
+ private const string QuietGesture = "control_spline name=quiet surface=/vocal/sources/0/pressure points=0.2:.05,0.5:.05";
+
+ [Fact]
+ public void ExporterEmitsOneMergedSplinePerSurfaceWithTimelineSemantics()
+ {
+ var both = PatchScript.Parse(string.Format(BackToBackPressure, LoudGesture + "\n" + QuietGesture));
+ var source = FaustEmitter.Emit(both, new FaustExportOptions("merged")).Source;
+ var surface = both.ControlSurfaces.Single(candidate => candidate.Path == "/vocal/sources/0/pressure");
+
+ var timeline = new ControlSplineTimeline(both.ControlSplines);
+ Assert.InRange(timeline.ValueAt(surface.Path, .35f), .04f, .06f);
+
+ // the surface carries one spline over the union of both gestures' points, not one spline per gesture
+ Assert.Contains("/splines/loud_quiet/0/value", source);
+ Assert.Contains("/splines/loud_quiet/3/value", source);
+ Assert.DoesNotContain("/splines/loud/", source);
+ Assert.DoesNotContain("/splines/quiet/", source);
+
+ // single-spline surfaces keep their own spline
+ var single = FaustEmitter.Emit(PatchScript.Parse(string.Format(BackToBackPressure, QuietGesture)), new FaustExportOptions("single")).Source;
+ Assert.Contains("/splines/quiet/0/value", single);
+ }
+
+ [Fact]
+ public void MergedSurfaceSplinesRenderTheQuietGestureAfterTheLoudOneEnds()
+ {
+ var both = RenderTailRms(string.Format(BackToBackPressure, LoudGesture + "\n" + QuietGesture));
+ var quietOnly = RenderTailRms(string.Format(BackToBackPressure, QuietGesture));
+ if (both is null || quietOnly is null) return;
+
+ Assert.True(quietOnly > 0, "reference render is silent");
+ Assert.InRange(both.Value / quietOnly.Value, .8, 1.25);
+ }
+
+ private static double? RenderTailRms(string script)
+ {
+ using var compiler = new AquaSynthPatchCompiler();
+ var source = FaustEmitter.Emit(PatchScript.Parse(script), new FaustExportOptions("merge_probe")).Source;
+ if (!compiler.TryCompileSource(new AquaSynthCompileIdentity("merge_probe", "merge_probe", script), source, .6f, out var patch, out var error))
+ {
+ if (error?.Contains("Faust toolchain not found", StringComparison.OrdinalIgnoreCase) == true ||
+ error?.Contains("Faust DLL not found", StringComparison.OrdinalIgnoreCase) == true)
+ {
+ return null;
+ }
+
+ Assert.Fail($"merge probe compile failed: {error}");
+ }
+
+ using (patch)
+ {
+ var samples = patch!.Render(1.0f);
+ var rate = 48000;
+ var window = samples.Skip((int)(.35 * rate)).Take((int)(.1 * rate)).ToArray();
+ Assert.True(window.Length > 1000, $"render too short: {samples.Length}");
+ return Math.Sqrt(window.Sum(sample => (double)sample * sample) / window.Length);
+ }
+ }
}
diff --git a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs
index 9ace5ec..5e4eec0 100644
--- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs
+++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs
@@ -316,7 +316,7 @@ public sealed class PatchScriptTests
Assert.Contains("primitive_contact_voices_0_constriction", faust);
Assert.Contains("primitive_branch_voices_0_velopharynx", faust);
Assert.Contains("primitive_radiation_voices_0_lip", faust);
- Assert.Contains("de.fdelay1a", faust);
+ Assert.Contains("primitive_ladder", faust);
Assert.DoesNotContain("graph_loop ~ si.bus", faust);
Assert.DoesNotContain("graph_terminal_area_voices_0_area_", faust);
Assert.DoesNotContain("tract_lf", faust);
@@ -462,7 +462,7 @@ public sealed class PatchScriptTests
var export = FaustEmitter.Emit(patch, new FaustExportOptions("primitive_vocal", DebugProbeUi: true));
Assert.Contains("primitive_radiated", export.Source);
- Assert.Contains("de.fdelay1a", export.Source);
+ Assert.Contains("primitive_ladder", export.Source);
Assert.Contains("/debug/voice_0/path/oral/delay", export.Source);
Assert.Contains("/debug/voice_0/path/oral/incoming_wave", export.Source);
Assert.Contains("/debug/voice_0/path/oral/outgoing_wave", export.Source);
@@ -986,7 +986,7 @@ public sealed class PatchScriptTests
var export = FaustEmitter.Emit(patch, new FaustExportOptions("tract_graph"));
Assert.Contains("primitive_radiated", export.Source);
- Assert.Contains("de.fdelay1a", export.Source);
+ Assert.Contains("primitive_ladder", export.Source);
Assert.Contains("primitive_source_voices_0_source", export.Source);
Assert.Contains("primitive_radiation_voices_0_lip", export.Source);
Assert.DoesNotContain("acoustic_graph_radiated", export.Source);
@@ -1180,7 +1180,7 @@ public sealed class PatchScriptTests
Assert.Contains("primitive_path_voices_0_oral", faust);
Assert.Contains("primitive_branch_voices_0_velopharynx", faust);
Assert.Contains("primitive_contact_voices_0_constriction", faust);
- Assert.Contains("de.fdelay1a", faust);
+ Assert.Contains("primitive_ladder", faust);
Assert.DoesNotContain("graph_loop ~ si.bus", faust);
Assert.DoesNotContain("_wg_", faust);
}
diff --git a/tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs b/tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs
index 8e75b4d..0156fa4 100644
--- a/tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs
+++ b/tests/AquaSynth.Dsl.Tests/PinkTromboneLogMelParityTests.cs
@@ -4,6 +4,7 @@ using System.Text;
namespace AquaSynth.Dsl.Tests;
+[Collection("NativeFaust")]
public sealed class PinkTromboneLogMelParityTests
{
private const float SmokeCosineFloor = 0.08f;
diff --git a/tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs b/tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs
index 0e3f5da..65a8006 100644
--- a/tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs
+++ b/tests/AquaSynth.Dsl.Tests/StoreFileNameTests.cs
@@ -2,6 +2,7 @@ using AquaSynth.Faust;
namespace AquaSynth.Dsl.Tests;
+[Collection("NativeFaust")]
public sealed class StoreFileNameTests
{
[Fact]
diff --git a/tests/AquaSynth.Dsl.Tests/VocalTractFormantTests.cs b/tests/AquaSynth.Dsl.Tests/VocalTractFormantTests.cs
new file mode 100644
index 0000000..ca0f694
--- /dev/null
+++ b/tests/AquaSynth.Dsl.Tests/VocalTractFormantTests.cs
@@ -0,0 +1,333 @@
+using AquaSynth.Dsl;
+using AquaSynth.Faust;
+using Xunit.Abstractions;
+
+namespace AquaSynth.Dsl.Tests;
+
+// The vocal_network resonator is a Kelly-Lochbaum ladder, so the same glottal source gives different vowels as the area
+// function moves. These tests render through the native Faust path with a 10 Hz glottal source (a 10 Hz line spectrum
+// samples the tract's response densely) and read formants as local maxima of the harmonic lines, flattened by the saw's 1/f.
+// Expected values are the ladder prototype's impulse-response peaks (proto/kl.mjs, the same area model and constants).
+[Collection("NativeFaust")]
+public sealed class VocalTractFormantTests(ITestOutputHelper output)
+{
+ private const int Rate = 48000;
+ private const float Pitch = 10f;
+
+ private static string Script(float tongueIndex, float tongueDiameter, float lipOpening, float velum = .01f) => $$"""
+ morphology name=oral length_cm=17 diameters=.55,.75,1.05,1.45,1.25,.8 tongue_index={{tongueIndex}} tongue_diameter={{tongueDiameter}} constriction_index=4 constriction_diameter=4 lip_opening={{lipOpening}}
+ morphology name=nasal length_cm=12 diameters=.04,.28,.52,.72
+ waveguide_path name=oral_path morphology=oral loss=.998
+ waveguide_path name=nasal_path morphology=nasal loss=.997
+ source_port name=folds path=oral_path pressure=.7 tension=.55 opening=.5 noise=0 impedance=.3 flow_scale=.12 tilt=0
+ branch_port name=velopharynx from=oral_path from_position=.45 to=nasal_path opening={{velum}} coupling=1
+ radiation_load name=mouth path=oral_path aperture=.8 reflection=-.82 impedance=.28
+ radiation_load name=nostril path=nasal_path aperture=.8 reflection=-.82 impedance=.28
+ vocal_network name=voice paths=oral_path,nasal_path sources=folds branches=velopharynx radiation=mouth,nostril
+ vocal network=voice freq={{Pitch}} min_freq=5 gain=.5 sustain=1.5
+ """;
+
+ // Three seconds of the 10 Hz source would be wasteful: one second after the attack is ten whole periods, so every
+ // harmonic of Pitch sits exactly on a DFT bin.
+ private float[]? Render(string script)
+ {
+ var source = FaustEmitter.Emit(PatchScript.Parse(script), new FaustExportOptions("vocal_formants", SampleRate: Rate)).Source;
+ using var compiler = new AquaSynthPatchCompiler(new AquaSynthNativeOptions(SampleRate: Rate));
+ if (!compiler.TryCompileSource(new AquaSynthCompileIdentity("vocal_formants", "vocal_formants", ""), source, 1.4f, out var patch, out var error))
+ {
+ if (error?.Contains("Faust toolchain not found", StringComparison.OrdinalIgnoreCase) == true ||
+ error?.Contains("Faust DLL not found", StringComparison.OrdinalIgnoreCase) == true)
+ {
+ return null;
+ }
+
+ Assert.Fail($"vocal formant compile failed: {error}");
+ }
+
+ using (patch)
+ {
+ var samples = patch!.Render(1.0f);
+ Assert.True(samples.Length >= Rate + 4800, $"render too short: {samples.Length}");
+ return samples.Skip(4800).Take(Rate).ToArray();
+ }
+ }
+
+ // Harmonic line magnitudes of the saw, multiplied by frequency to undo its 1/f slope.
+ private static double[] Lines(float[] window)
+ {
+ var n = 1 << 16;
+ var re = new double[n];
+ var im = new double[n];
+ for (var i = 0; i < window.Length; i++) re[i] = window[i] * (0.5 - 0.5 * Math.Cos(2 * Math.PI * i / window.Length));
+ Fft(re, im);
+ var lines = new double[(int)(5000 / Pitch) + 1];
+ for (var k = 1; k < lines.Length; k++)
+ {
+ var bin = (int)Math.Round(k * Pitch * n / Rate);
+ lines[k] = Math.Sqrt(re[bin] * re[bin] + im[bin] * im[bin]) * k;
+ }
+
+ return lines;
+ }
+
+ // Local maxima of the flattened lines that are tallest within +-40 Hz and above 10% of the tallest, in ascending Hz.
+ private static double[] Peaks(float[] window)
+ {
+ var lines = Lines(window);
+ var span = (int)(40 / Pitch);
+ var floor = 0.1 * lines.Max();
+ var peaks = new List();
+ for (var k = (int)(150 / Pitch); k < lines.Length - span; k++)
+ {
+ if (lines[k] < floor) continue;
+ var tallest = true;
+ for (var j = k - span; j <= k + span; j++) tallest &= lines[j] <= lines[k];
+ if (tallest) peaks.Add(k * Pitch);
+ }
+
+ return peaks.ToArray();
+ }
+
+ private static void Fft(double[] re, double[] im)
+ {
+ var n = re.Length;
+ for (int i = 1, j = 0; i < n; i++)
+ {
+ var bit = n >> 1;
+ for (; (j & bit) != 0; bit >>= 1) j ^= bit;
+ j ^= bit;
+ if (i < j) { (re[i], re[j]) = (re[j], re[i]); (im[i], im[j]) = (im[j], im[i]); }
+ }
+
+ for (var len = 2; len <= n; len <<= 1)
+ {
+ var angle = -2 * Math.PI / len;
+ for (var i = 0; i < n; i += len)
+ {
+ for (var k = 0; k < len / 2; k++)
+ {
+ double wr = Math.Cos(angle * k), wi = Math.Sin(angle * k);
+ var a = i + k;
+ var b = i + k + len / 2;
+ var vr = re[b] * wr - im[b] * wi;
+ var vi = re[b] * wi + im[b] * wr;
+ (re[b], im[b]) = (re[a] - vr, im[a] - vi);
+ (re[a], im[a]) = (re[a] + vr, im[a] + vi);
+ }
+ }
+ }
+ }
+
+ public static TheoryData Vowels => new()
+ {
+ { "i", 3f, .15f, 2.2f, 271, 2313 },
+ { "schwa", 2f, 1.2f, .6f, 497, 1503 },
+ { "a", 1.5f, .5f, 2.2f, 731, 1257 },
+ };
+
+ private double[]? FirstTwo(string name, float ti, float td, float lo)
+ {
+ var window = Render(Script(ti, td, lo));
+ if (window is null) return null;
+ var peaks = Peaks(window);
+ output.WriteLine($"{name}: peaks " + string.Join(" ", peaks.Select(p => p.ToString("F0"))));
+ Assert.True(peaks.Length >= 2, $"{name}: fewer than two spectral peaks");
+ return peaks;
+ }
+
+ [Theory]
+ [MemberData(nameof(Vowels))]
+ public void VowelAreaFunctionsPlaceF1AndF2WhereThePrototypePlacedThem(string name, float ti, float td, float lo, double f1, double f2)
+ {
+ var peaks = FirstTwo(name, ti, td, lo);
+ if (peaks is null) return;
+
+ // F1 within 20%: the harmonic-line reading of a low F1 shifts with the output highpass (i reads 230-260 Hz for the 271 Hz impulse peak).
+ Assert.InRange(peaks[0], f1 * .8, f1 * 1.2);
+ Assert.InRange(peaks[1], f2 * .92, f2 * 1.08);
+ }
+
+ [Fact]
+ public void VowelsDifferInTheirFormants()
+ {
+ var i = FirstTwo("i", 3f, .15f, 2.2f);
+ var schwa = FirstTwo("schwa", 2f, 1.2f, .6f);
+ var a = FirstTwo("a", 1.5f, .5f, 2.2f);
+ if (i is null || schwa is null || a is null) return;
+
+ // F1 rises i < schwa < a as the jaw opens; F2 falls i > schwa > a as the constriction moves back.
+ Assert.True(i[0] + 100 < schwa[0] && schwa[0] + 100 < a[0], $"F1 {i[0]} {schwa[0]} {a[0]}");
+ Assert.True(i[1] > schwa[1] + 300 && schwa[1] > a[1] + 100, $"F2 {i[1]} {schwa[1]} {a[1]}");
+ }
+
+ [Fact]
+ public void LadderStaysBoundedForASecondWithTheVelumOpen()
+ {
+ foreach (var (ti, td, lo, velum) in new[] { (3f, .15f, 2.2f, .01f), (1.5f, .5f, 2.2f, 1f), (2f, 1.2f, .6f, 1f) })
+ {
+ var window = Render(Script(ti, td, lo, velum));
+ if (window is null) return;
+ var quarter = window.Length / 4;
+ var rms = Enumerable.Range(0, 4).Select(q => Math.Sqrt(window.Skip(q * quarter).Take(quarter).Sum(x => (double)x * x) / quarter)).ToArray();
+ output.WriteLine($"velum {velum} ti {ti}: peak {window.Max(Math.Abs):F3} rms by quarter {string.Join(" ", rms.Select(r => r.ToString("E2")))}");
+ Assert.All(window, sample => Assert.True(float.IsFinite(sample) && Math.Abs(sample) < 4, $"sample {sample}"));
+ Assert.True(rms[0] > 1e-4, $"silent: rms {rms[0]}");
+ Assert.True(rms[3] < 2 * rms[0] + 1e-6 && rms[3] > rms[0] / 2, $"rms drifted {rms[0]:E2} -> {rms[3]:E2}");
+ }
+ }
+
+ private static double Rms(float[] window) => Math.Sqrt(window.Sum(x => (double)x * x) / window.Length);
+
+ [Fact]
+ public void WithTheMouthClosedOnlyAnOpenVelumLetsSoundOutThroughTheNose()
+ {
+ // constriction_diameter .001 at tract index 4 shuts the oral ladder; the only way out is the junction into the nasal ladder.
+ string Closed(float velum) => Script(1.5f, .5f, 2.2f, velum)
+ .Replace("constriction_diameter=4", "constriction_diameter=.001", StringComparison.Ordinal)
+ .Replace("diameters=.04,.28,.52,.72", "diameters=.9,1.3,1.5,1.2", StringComparison.Ordinal);
+ var shut = Render(Closed(.01f));
+ var open = Render(Closed(1f));
+ if (shut is null || open is null) return;
+
+ output.WriteLine($"mouth shut: velum closed rms {Rms(shut):E2}, velum open rms {Rms(open):E2}; open peaks {string.Join(" ", Peaks(open).Select(p => p.ToString("F0")))}");
+ Assert.True(Rms(open) > 5 * Rms(shut), $"nasal output rms {Rms(open):E2} vs velum-closed {Rms(shut):E2}");
+ }
+
+ // The authored vowel gestures (PatchScript's VowelShapes, reached through the IPA word script) rendered end to end through the
+ // exporter, merged splines and the ladder; the source is a bright 10 Hz saw so the tract's response is sampled densely.
+ private float[]? GestureWindow(string ipa)
+ {
+ var script = IpaGestureExperiment.BuildWordScript([(new IpaGestureExperimentTarget($"v_{ipa}", ipa, "", 1.5f), 1f)], Pitch, 0.1f)
+ .Replace("noise=.04", "noise=0 tilt=1", StringComparison.Ordinal)
+ .Replace($"freq={Pitch}", $"freq={Pitch} min_freq=5", StringComparison.Ordinal);
+ return Render(script);
+ }
+
+ private double[]? GesturePeaks(string ipa)
+ {
+ var window = GestureWindow(ipa);
+ if (window is null) return null;
+ var peaks = LpcPeaks(window);
+ output.WriteLine($"gesture {ipa}: peaks " + string.Join(" ", peaks.Select(p => p.ToString("F0"))));
+ Assert.True(peaks.Length >= 2, $"{ipa}: fewer than two spectral peaks");
+ return peaks;
+ }
+
+ // Formant candidates as the peaks of an LPC envelope (order 12 on the 12 kHz decimation of a 0.25 s steady stretch, pre-emphasis
+ // .97), the same recipe the render measurements use: the envelope sees through the harmonic lines of the voiced source.
+ private static double[] LpcPeaks(float[] window)
+ {
+ const int Decimate = 4, Order = 12;
+ var taps = Enumerable.Range(0, 4 * Decimate + 1).Select(n => 0.5 - 0.5 * Math.Cos(2 * Math.PI * (n + 1) / (4 * Decimate + 2))).ToArray();
+ var norm = taps.Sum();
+ var x = window.Skip(Rate / 4).Take(Rate / 4).Select(v => (double)v).ToArray();
+ var mean = x.Average();
+ var z = new List();
+ for (var n = 2 * Decimate; n < x.Length - 2 * Decimate; n += Decimate)
+ {
+ double acc = 0;
+ for (var k = 0; k < taps.Length; k++) acc += taps[k] * (x[n + k - 2 * Decimate] - mean);
+ z.Add(acc / norm);
+ }
+
+ var y = new double[z.Count];
+ for (var n = 0; n < y.Length; n++) y[n] = (z[n] - (n > 0 ? .97 * z[n - 1] : 0)) * (0.54 - 0.46 * Math.Cos(2 * Math.PI * n / (y.Length - 1)));
+ var r = Enumerable.Range(0, Order + 1).Select(lag => Enumerable.Range(0, y.Length - lag).Sum(n => y[n] * y[n + lag])).ToArray();
+ r[0] *= 1 + 1e-9;
+ var a = new double[Order + 1];
+ a[0] = 1;
+ var err = r[0];
+ for (var m = 1; m <= Order; m++)
+ {
+ var acc = r[m];
+ for (var k = 1; k < m; k++) acc += a[k] * r[m - k];
+ var kappa = -acc / err;
+ var next = (double[])a.Clone();
+ for (var k = 1; k < m; k++) next[k] = a[k] + kappa * a[m - k];
+ next[m] = kappa;
+ a = next;
+ err *= 1 - kappa * kappa;
+ }
+
+ var fs = Rate / (double)Decimate;
+ double Envelope(double hz)
+ {
+ double re = 0, im = 0;
+ for (var k = 0; k <= Order; k++) { re += a[k] * Math.Cos(2 * Math.PI * hz / fs * k); im -= a[k] * Math.Sin(2 * Math.PI * hz / fs * k); }
+ return 1 / Math.Sqrt(re * re + im * im);
+ }
+
+ var peaks = new List();
+ var previous = Envelope(150);
+ var current = Envelope(160);
+ for (var hz = 170.0; hz < 4500; hz += 10)
+ {
+ var next = Envelope(hz);
+ if (current > previous && current >= next) peaks.Add(hz - 10);
+ (previous, current) = (current, next);
+ }
+
+ return peaks.ToArray();
+ }
+
+ // Measured through the exported ladder on 2026-11-08 (impulse peaks i 271/2313, u 292/876, a 703/1084; the textbook values are
+ // i 270/2290, u 300/870, a 730/1090). The envelope of the 10 Hz render reads i 270/2380 and a 690/1060.
+ public static TheoryData GestureVowels => new()
+ {
+ { "i", 271, 2313 },
+ { "a", 703, 1084 },
+ };
+
+ [Theory]
+ [MemberData(nameof(GestureVowels))]
+ public void AuthoredVowelGesturesPlaceF1AndF2NearTheTextbook(string ipa, double f1, double f2)
+ {
+ var peaks = GesturePeaks(ipa);
+ if (peaks is null) return;
+
+ Assert.InRange(peaks[0], f1 * .9, f1 * 1.1);
+ Assert.InRange(peaks[1], f2 * .9, f2 * 1.1);
+ }
+
+ [Fact]
+ public void AuthoredRoundedBackVowelPlacesF1AndF2NearTheTextbook()
+ {
+ // With a radiating highpass that rose with aperture (540 Hz at aperture 1) the envelope read u's F1 at 450 Hz.
+ var peaks = GesturePeaks("u");
+ if (peaks is null) return;
+
+ Assert.InRange(peaks[0], 292 * .85, 350);
+ Assert.InRange(peaks[1], 876 * .9, 876 * 1.1);
+ }
+
+ [Fact]
+ public void AuthoredVowelGesturesDifferInTheirFormants()
+ {
+ var i = GesturePeaks("i");
+ var u = GesturePeaks("u");
+ var a = GesturePeaks("a");
+ if (i is null || u is null || a is null) return;
+
+ Assert.True(a[0] > i[0] + 250, $"F1 i {i[0]} a {a[0]}");
+ // F2: i 2380 > a 1060 > u 880.
+ Assert.True(i[1] > a[1] + 800 && a[1] > u[1] + 100, $"F2 i {i[1]} a {a[1]} u {u[1]}");
+ }
+
+ [Fact]
+ public void RoundedBackVowelIsAudibleNextToTheCloseFrontVowel()
+ {
+ // The rendered /ˈnibu/ had /u/ 29 dB under /i/: the gesture scaled the radiating aperture (an output gain) by the lip area
+ // the ladder already carries. Measured here: -4.3 dB; with the old aperture scale -22.7 dB.
+ var i = GestureWindow("i");
+ var u = GestureWindow("u");
+ if (i is null || u is null) return;
+
+ var a = GestureWindow("a");
+ if (a is not null) output.WriteLine($"a rms {Rms(a):E2}");
+ var gap = 20 * Math.Log10(Rms(u) / Rms(i));
+ var uPeaks = LpcPeaks(u);
+ output.WriteLine($"u rms {Rms(u):E2} i rms {Rms(i):E2} gap {gap:F1} dB; u peaks {string.Join(" ", uPeaks.Select(p => p.ToString("F0")))}");
+ Assert.InRange(gap, -10, 3);
+ Assert.True(uPeaks[1] < 1000, $"u F2 {uPeaks[1]}");
+ }
+}
diff --git a/tests/AquaSynth.Dsl.Tests/WeksaPhonesTests.cs b/tests/AquaSynth.Dsl.Tests/WeksaPhonesTests.cs
new file mode 100644
index 0000000..27ef6d6
--- /dev/null
+++ b/tests/AquaSynth.Dsl.Tests/WeksaPhonesTests.cs
@@ -0,0 +1,96 @@
+using System.Text.RegularExpressions;
+using AquaSynth.Dsl;
+
+namespace AquaSynth.Dsl.Tests;
+
+// Fixtures/Weksa/nibu-en-US.phones.json is Weksa PR 16's fixture (see PROVENANCE.txt); the malformed cases below are
+// edits of it, not invented phones.
+public sealed class WeksaPhonesTests
+{
+ private static string Fixture() => File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "Weksa", "nibu-en-US.phones.json"));
+
+ private static string Line(string phones, string extra = "") =>
+ $$"""{"language":"en-US","source":"ˈnibu",{{extra}}"phones":{{phones}}}""";
+
+ private static string Ph(string ipa, string stress) => $$"""{"ipa":"{{ipa}}","stress":"{{stress}}"}""";
+
+ [Fact]
+ public void AcceptsNibuFixtureAsNIBUWithPrimaryStressOnI()
+ {
+ var word = WeksaPhonesReader.Parse(Fixture());
+ Assert.Equal("en-US", word.Language);
+ Assert.Equal("ˈnibu", word.Source);
+ Assert.Equal(["n", "i", "b", "u"], word.Phones.Select(p => p.Ipa));
+ Assert.Equal([WeksaStress.None, WeksaStress.Primary, WeksaStress.None, WeksaStress.None], word.Phones.Select(p => p.Stress));
+ var plan = WeksaWordPlan.From(word, "nibu");
+ Assert.True(plan.Renderable);
+ Assert.Equal(["n", "i", "b", "u"], plan.Phones.Select(p => p.Target.Ipa));
+ }
+
+ public static IEnumerable