Vowel gestures calibrated by measurement; exporter sample rate from callers #18

Merged
aqua merged 10 commits from aqua/tract-voice into main 2026-11-10 18:13:29 +00:00
25 changed files with 1145 additions and 167 deletions

View file

@ -68,6 +68,21 @@ public sealed class ControlSplineTimeline
.ToList(),
StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 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.
/// </summary>
public static ControlSpline[] SurfaceSplines(IEnumerable<ControlSpline> 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<string> SurfacePaths => _pointsBySurface.Keys.ToArray();
public void SetFuturePoint(string surfacePath, float timeSeconds, float normalizedValue, float nowSeconds) =>

View file

@ -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)));
/// <summary>
/// Emits the area, reflection and junction definitions and the recursive ladder for <paramref name="members"/>, and returns,
/// per path, the identifier of its lip output (the right-going wave leaving its last cell).
/// </summary>
private static IReadOnlyDictionary<string, string> EmitVocalLadder(
StringBuilder source,
SynthPatch patch,
VocalNetwork network,
string name,
IReadOnlyList<LadderPath> members,
IReadOnlyDictionary<string, string> inputs,
IReadOnlyDictionary<string, string> admittances,
ParameterMap parameters,
List<string> warnings)
{
var junctions = new List<LadderJunction>();
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<string, string>(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;
}
}

View file

@ -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<string> 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<string>();
source.AppendLine($"{name}_primitive_drive = 1.0;");
var members = new List<LadderPath>();
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<string, string>(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<string>()
.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<string>()
.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<string, string>(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<string, string>()
: 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<string> 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<string> 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);
/// <summary>
/// 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 <paramref name="areaPath"/>. Acoustic
/// graph terminals and segments and the vocal ladder's cells all read their area here.
/// </summary>
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);

View file

@ -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)}}
""";

View file

@ -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,

View file

@ -8,6 +8,34 @@ public sealed class PatchScriptException(int line, string message) : Exception($
}
public static class PatchScript
{
/// <summary>
/// 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.
/// </summary>
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<string> 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<string> 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<string, string> 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<string> DescriptorTokens(string descriptor) =>

View file

@ -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<ProbeTimelineSample>();
var blockSize = patch.ProbeTimelines.FirstOrDefault(probe => probe.Name.Equals(network.ProbeTimeline, StringComparison.OrdinalIgnoreCase))?.BlockSize ?? 64;

View file

@ -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<WeksaPhone> Phones);
public sealed class WeksaPhonesException(string message) : Exception(message);
/// <summary>
/// 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 <see cref="IpaGestureExperiment"/>.
/// </summary>
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<PhoneDto?>? Phones { get; init; }
}
public static WeksaPhones Parse(string json)
{
LineDto? dto;
try { dto = JsonSerializer.Deserialize<LineDto>(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<WeksaPhone>();
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);
}
}
/// <summary>
/// A Weksa phones line as a word the tract can render. Every phone must be in the DSL's IPA table
/// (<see cref="PatchScript.TryIpaDescriptor"/>); a phone outside it is listed in <see cref="Unsupported"/> 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.
/// </summary>
public sealed record WeksaWordPlan(IReadOnlyList<(IpaGestureExperimentTarget Target, float Intensity)> Phones, IReadOnlyList<WeksaPhone> 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, []);
}
}

View file

@ -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)

View file

@ -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),

View file

@ -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));

View file

@ -4,6 +4,7 @@ using GameCult.Caching.MessagePack;
namespace AquaSynth.Dsl.Tests;
[Collection("NativeFaust")]
public sealed class AquaSynthDaemonServiceTests
{
[Fact]

View file

@ -3,6 +3,7 @@ using AquaSynth.Faust;
namespace AquaSynth.Dsl.Tests;
[Collection("NativeFaust")]
public sealed class FaustRenderedSpeechLossSurfaceTests
{
[Fact]

View file

@ -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.

View file

@ -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"}]}

View file

@ -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);
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}

View file

@ -4,6 +4,7 @@ using System.Text;
namespace AquaSynth.Dsl.Tests;
[Collection("NativeFaust")]
public sealed class PinkTromboneLogMelParityTests
{
private const float SmokeCosineFloor = 0.08f;

View file

@ -2,6 +2,7 @@ using AquaSynth.Faust;
namespace AquaSynth.Dsl.Tests;
[Collection("NativeFaust")]
public sealed class StoreFileNameTests
{
[Fact]

View file

@ -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<double>();
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<string, float, float, float, double, double> 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<double>();
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<double>();
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<string, double, double> 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]}");
}
}

View file

@ -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<object[]> Refused()
{
yield return ["not json at all"];
yield return [Fixture0().Replace("}]}", "}]")]; // truncated
yield return [Fixture0() + " trailing"]; // trailing garbage
yield return ["null"];
yield return [Line($"[{Ph("n", "tertiary")}]")]; // unknown stress value
yield return [Line($"[{Ph("n", "Primary")}]")]; // stress is exact, not case-folded
yield return [Line("""[{"ipa":"n"}]""")]; // missing stress
yield return [Line("""[{"stress":"none"}]""")]; // missing ipa
yield return ["""{"language":"en-US","phones":[{"ipa":"n","stress":"none"}]}"""]; // missing source
yield return ["""{"source":"n","phones":[{"ipa":"n","stress":"none"}]}"""]; // missing language
yield return ["""{"language":"en-US","source":"n"}"""]; // missing phones
yield return [Line($"[{Ph("n", "none")}]", "\"extra\":1,")]; // extra top-level key
yield return [Line("""[{"ipa":"n","stress":"none","dur":90}]""")]; // extra phone key
yield return [Line("""[{"ipa":5,"stress":"none"}]""")]; // wrong type
yield return [Line("""[{"ipa":"n","stress":1}]""")]; // stress as number
yield return [Line("""[{"ipa":null,"stress":"none"}]""")];
yield return [Line("""[{"ipa":"n","stress":null}]""")];
yield return [Line("""[null]""")];
yield return [Line("""[{"ipa":"","stress":"none"}]""")];
yield return [Line("{}")]; // phones not a list
yield return [Line("[]")]; // empty phones
}
private static string Fixture0() => File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "Weksa", "nibu-en-US.phones.json")).Trim();
[Theory]
[MemberData(nameof(Refused))]
public void RefusesMalformedInput(string json) => Assert.Throws<WeksaPhonesException>(() => WeksaPhonesReader.Parse(json));
[Fact]
public void ReportsPhonesTheTractCannotMakeAndRendersNothing()
{
var word = WeksaPhonesReader.Parse(Line($"[{Ph("n", "none")},{Ph("ɪ", "primary")},{Ph("b", "none")},{Ph("ʊ", "none")}]"));
var plan = WeksaWordPlan.From(word, "x");
Assert.False(plan.Renderable);
Assert.Equal(["ɪ", "ʊ"], plan.Unsupported.Select(p => p.Ipa));
Assert.Empty(plan.Phones);
}
[Fact]
public void StressMovesOnlyTheIntensityOfTheMarkedPhone()
{
string Script(WeksaStress onI) => IpaGestureExperiment.BuildWordScript(
WeksaWordPlan.From(new WeksaPhones("en-US", "ˈnibu",
[new("n", WeksaStress.None), new("i", onI), new("b", WeksaStress.None), new("u", WeksaStress.None)]), "w").Phones.ToArray());
var none = Script(WeksaStress.None).Split('\n');
var primary = Script(WeksaStress.Primary).Split('\n');
Assert.Equal(none.Length, primary.Length);
var differing = Enumerable.Range(0, none.Length).Where(i => none[i] != primary[i]).ToArray();
Assert.Single(differing);
Assert.Contains("ipa=i ", primary[differing[0]]);
var strip = (string l) => Regex.Replace(l, @"intensity=\S+", "");
Assert.Equal(strip(none[differing[0]]), strip(primary[differing[0]]));
Assert.Contains("intensity=1.2", primary[differing[0]]);
}
[Fact]
public void PhonesTheWordPlanAcceptsAreExactlyTheDslTable()
{
foreach (var ipa in new[] { "n", "i", "b", "u", "a", "ə" })
Assert.True(PatchScript.TryIpaDescriptor(ipa, out _));
Assert.False(PatchScript.TryIpaDescriptor("ɪ", out _));
}
}

View file

@ -267,7 +267,7 @@ static async Task SongScoreAsync(Dictionary<string, string> options)
: ProbeTimelineReport.Build(patch, timelineNetwork.Name, 12);
await File.WriteAllTextAsync(timelinePath, ProbeTimelineReport.ToCsv(timeline), Encoding.UTF8);
timelineFacts = PrimitiveTimelineFactExtractor.Extract(timeline).ToArray();
var source = FaustEmitter.EmitScript(script, new FaustExportOptions(safeCandidateId)).Source;
var source = FaustEmitter.EmitScript(script, new FaustExportOptions(safeCandidateId, SampleRate: challenge.SampleRate)).Source;
await File.WriteAllTextAsync(dspPath, source, Encoding.UTF8);
artifacts.Add(Artifact("candidate-dsp", dspPath));

View file

@ -21,7 +21,7 @@ if (FaustCompiler.FindFaust() is null)
}
var script = Script(request);
var export = FaustEmitter.EmitScript(script, new FaustExportOptions("aqua_tract_graph_audition"));
var export = FaustEmitter.EmitScript(script, new FaustExportOptions("aqua_tract_graph_audition", SampleRate: request.SampleRate));
var render = await FaustCompiler.RenderAsync(
export.Source,
new FaustRenderOptions(request.SampleRate, request.DurationSeconds));

View file

@ -5,23 +5,32 @@ using System.Text.Json;
using AquaSynth.Dsl;
using AquaSynth.Faust;
// Offline probe: one word as a run of IPA gestures on the IpaGestureExperiment scaffold, rendered through the native
// libfaust path to a mono 16-bit WAV plus script.aqua, source.dsp and manifest.json. It measures peak, rms and NaN;
// it cannot say how the word sounds.
// usage: WordRender <outDir> <label> <freq-hz> <ipa>:<ms>:<intensity> ... e.g. WordRender out nibu 140 n:95:1 i:180:1.2 b:95:1 u:180:.9
// Offline probe: one word, segmented by Weksa (the phones line of `weksa lower --phones`), as a run of IPA gestures on the
// IpaGestureExperiment scaffold, rendered through the native libfaust path to a mono 16-bit WAV plus script.aqua, source.dsp
// and manifest.json. It measures peak, rms and NaN; it cannot say how the word sounds.
// usage: WordRender <outDir> <label> <freq-hz> <phones.json>
// exit: 0 rendered, 1 compile or non-finite output, 2 usage, 3 phones file refused, 4 phones the tract cannot make (listed, nothing rendered)
const int Rate = 48000, Frames = 512;
if (args.Length < 4) { Console.Error.WriteLine("usage: WordRender <outDir> <label> <freq-hz> <ipa>:<ms>:<intensity> ..."); return 2; }
if (args.Length != 4) { Console.Error.WriteLine("usage: WordRender <outDir> <label> <freq-hz> <phones.json>"); return 2; }
var outDir = Path.GetFullPath(args[0]);
var label = args[1];
var freq = float.Parse(args[2], System.Globalization.CultureInfo.InvariantCulture);
var phones = args.Skip(3).Select((spec, i) =>
WeksaPhones word;
try { word = WeksaPhonesReader.Parse(File.ReadAllText(args[3])); }
catch (Exception ex) when (ex is WeksaPhonesException or IOException)
{
var p = spec.Split(':');
var ipa = p[0];
var ms = float.Parse(p[1], System.Globalization.CultureInfo.InvariantCulture);
var intensity = float.Parse(p[2], System.Globalization.CultureInfo.InvariantCulture);
return (new IpaGestureExperimentTarget($"{label}_{i}_{ipa}", ipa, "", ms / 1000f), intensity);
}).ToArray();
Console.Error.WriteLine($"REFUSED {args[3]}: {ex.Message}");
return 3;
}
var plan = WeksaWordPlan.From(word, label);
if (!plan.Renderable)
{
Console.Error.WriteLine("UNSUPPORTED PHONES (the tract has no gesture for them; nothing rendered): " + string.Join(" ", plan.Unsupported.Select(p => p.Ipa)));
return 4;
}
var phones = plan.Phones.ToArray();
Directory.CreateDirectory(outDir);
var script = IpaGestureExperiment.BuildWordScript(phones, freq);
@ -30,12 +39,9 @@ var seconds = phones.Sum(p => p.Item1.DurationSeconds) + 0.12f;
var home = AquaSynthFaustToolchain.Find();
Console.WriteLine($"faust home: {home ?? "(not found)"}");
using var compiler = new AquaSynthPatchCompiler(new AquaSynthNativeOptions(SampleRate: Rate));
// The exporter bakes splines in as offsets summed per surface; the engine's ControlSplineTimeline merges them per surface
// into one absolute curve. Back-to-back gestures only sequence under the timeline, so the DSP is emitted without splines
// (surface sliders only) and the timeline drives the sliders block by block, as NativeFaustRuntime.ControlValuesAt does.
var scripted = PatchScript.Parse(script);
var timeline = ControlSurfaceCatalog.FromPatch(scripted).CreateTimeline();
var source = FaustEmitter.Emit(scripted with { ControlSplines = [] }, new FaustExportOptions(label)).Source;
// The exporter emits one merged spline per surface (the ControlSplineTimeline curve), so the DSP carries the word's motion
// itself and no slider is driven from outside.
var source = FaustEmitter.Emit(PatchScript.Parse(script), new FaustExportOptions(label)).Source;
File.WriteAllText(Path.Combine(outDir, "source.dsp"), source);
if (!compiler.TryCompileSource(new AquaSynthCompileIdentity(label, label, ""), source, seconds, out var patch0, out var cerr))
{
@ -54,8 +60,7 @@ var pcm = new short[blocks * Frames];
double sum = 0; float peak = 0; var nan = false;
for (var blk = 0; blk < blocks; blk++)
{
var ctl = patch.ControlValuesAt(timeline, blk * Frames / (float)Rate);
stream.ProcessBlock(Frames, IntPtr.Zero, outP, ctl);
stream.ProcessBlock(Frames, IntPtr.Zero, outP);
for (var i = 0; i < Frames; i++)
{
var v = buf[i];
@ -78,7 +83,7 @@ using (var w = new BinaryWriter(File.Create(wav)))
var rms = Math.Sqrt(sum / pcm.Length);
File.WriteAllText(Path.Combine(outDir, "manifest.json"), JsonSerializer.Serialize(new
{
label, freq, phones = args.Skip(3), seconds, sampleRate = Rate, frames = pcm.Length, peak, rms, nonFinite = nan,
label, freq, weksa = new { word.Language, word.Source, Phones = word.Phones.Select(p => new { p.Ipa, Stress = p.Stress.ToString() }) }, seconds, sampleRate = Rate, frames = pcm.Length, peak, rms, nonFinite = nan,
wavSha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(wav))).ToLowerInvariant(),
scriptSha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(Path.Combine(outDir, "script.aqua")))).ToLowerInvariant(),
os = RuntimeInformation.OSDescription, faustHome = home