Vowel gestures calibrated by measurement; exporter sample rate from callers #18
25 changed files with 1145 additions and 167 deletions
|
|
@ -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) =>
|
||||
|
|
|
|||
149
src/AquaSynth.Core/FaustExport.VocalLadder.cs
Normal file
149
src/AquaSynth.Core/FaustExport.VocalLadder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)}}
|
||||
""";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
101
src/AquaSynth.Core/WeksaPhones.cs
Normal file
101
src/AquaSynth.Core/WeksaPhones.cs
Normal 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, []);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using GameCult.Caching.MessagePack;
|
|||
|
||||
namespace AquaSynth.Dsl.Tests;
|
||||
|
||||
[Collection("NativeFaust")]
|
||||
public sealed class AquaSynthDaemonServiceTests
|
||||
{
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using AquaSynth.Faust;
|
|||
|
||||
namespace AquaSynth.Dsl.Tests;
|
||||
|
||||
[Collection("NativeFaust")]
|
||||
public sealed class FaustRenderedSpeechLossSurfaceTests
|
||||
{
|
||||
[Fact]
|
||||
|
|
|
|||
1
tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/PROVENANCE.txt
Normal file
1
tests/AquaSynth.Dsl.Tests/Fixtures/Weksa/PROVENANCE.txt
Normal 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.
|
||||
|
|
@ -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"}]}
|
||||
125
tests/AquaSynth.Dsl.Tests/GlottalSourceSpectrumTests.cs
Normal file
125
tests/AquaSynth.Dsl.Tests/GlottalSourceSpectrumTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using System.Text;
|
|||
|
||||
namespace AquaSynth.Dsl.Tests;
|
||||
|
||||
[Collection("NativeFaust")]
|
||||
public sealed class PinkTromboneLogMelParityTests
|
||||
{
|
||||
private const float SmokeCosineFloor = 0.08f;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using AquaSynth.Faust;
|
|||
|
||||
namespace AquaSynth.Dsl.Tests;
|
||||
|
||||
[Collection("NativeFaust")]
|
||||
public sealed class StoreFileNameTests
|
||||
{
|
||||
[Fact]
|
||||
|
|
|
|||
333
tests/AquaSynth.Dsl.Tests/VocalTractFormantTests.cs
Normal file
333
tests/AquaSynth.Dsl.Tests/VocalTractFormantTests.cs
Normal 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]}");
|
||||
}
|
||||
}
|
||||
96
tests/AquaSynth.Dsl.Tests/WeksaPhonesTests.cs
Normal file
96
tests/AquaSynth.Dsl.Tests/WeksaPhonesTests.cs
Normal 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 _));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue