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 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(() => 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 _)); + } +} diff --git a/tools/IpaTrialWorker/Program.cs b/tools/IpaTrialWorker/Program.cs index f7089d1..1e7cbbc 100644 --- a/tools/IpaTrialWorker/Program.cs +++ b/tools/IpaTrialWorker/Program.cs @@ -267,7 +267,7 @@ static async Task SongScoreAsync(Dictionary 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)); diff --git a/tools/TractGraphRenderer/Program.cs b/tools/TractGraphRenderer/Program.cs index c872da3..4a492cf 100644 --- a/tools/TractGraphRenderer/Program.cs +++ b/tools/TractGraphRenderer/Program.cs @@ -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)); diff --git a/tools/WordRender/Program.cs b/tools/WordRender/Program.cs index 807b57a..1b597e2 100644 --- a/tools/WordRender/Program.cs +++ b/tools/WordRender/Program.cs @@ -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