Speech: n descriptor, word script, WordRender; first /ˈnibu/ render (a hum, unheard) #17

Merged
aqua merged 1 commit from aqua/nibu-first-render into main 2026-11-10 18:12:40 +00:00
5 changed files with 185 additions and 19 deletions

View file

@ -196,27 +196,52 @@ public static class IpaGestureExperiment
var duration = Math.Clamp(target.DurationSeconds * variant.DurationScale, 0.03f, 1.5f);
var intensity = Math.Clamp(variant.IntensityScale, 0.05f, 2f);
var sustain = Math.Clamp(duration + 0.12f, 0.15f, 2f);
var descriptor = target.Descriptor.Replace(' ', '_');
var frequency = DescriptorTokens(target.Descriptor).Contains("vowel") ? 150 : 135;
return $$"""
patch gain=.45
morphology name=oral length_cm=17 diameters=.55,.75,1.05,1.45,1.25,.8 tongue_index=3 tongue_diameter=1.4 constriction_index=4 constriction_diameter=1 lip_opening=1.2
morphology name=nasal length_cm=12 diameters=.04,.28,.52,.72
waveguide_path name=oral_path morphology=oral strategy=thiran order=1 max_delay=4096 loss=.998
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
radiation_load name=mouth path=oral_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
phoneme_gesture name={{SafeDslName(target.Id)}} ipa={{target.Ipa}} descriptor={{descriptor}} start=0 dur={{Format(duration)}} intensity={{Format(intensity)}}
vocal network=voice freq={{Format(frequency)}} gain=.7 sustain={{Format(sustain)}}
""";
return Scaffold(
[GestureLine(target, 0, duration, intensity)],
Math.Clamp(duration + 0.12f, 0.15f, 2f),
DescriptorTokens(target.Descriptor).Contains("vowel") ? 150 : 135);
}
/// <summary>
/// One word on the same scaffold: the targets run back to back, each at its own intensity (a stress mark is a higher
/// intensity on the stressed vowel). An empty descriptor leaves it to the DSL's own IPA table. Pitch stays flat at <paramref name="frequency"/>; no contour is authored.
/// </summary>
public static string BuildWordScript(IReadOnlyList<(IpaGestureExperimentTarget Target, float Intensity)> phones, float frequency = 140, float tailSeconds = 0.12f)
{
if (phones.Count == 0) throw new ArgumentException("a word needs at least one phone", nameof(phones));
var lines = new List<string>();
var start = 0f;
foreach (var (target, intensity) in phones)
{
ArgumentException.ThrowIfNullOrWhiteSpace(target.Id);
ArgumentException.ThrowIfNullOrWhiteSpace(target.Ipa);
var duration = Math.Clamp(target.DurationSeconds, 0.03f, 1.5f);
lines.Add(GestureLine(target, start, duration, Math.Clamp(intensity, 0.05f, 2f)));
start += duration;
}
return Scaffold(lines, Math.Clamp(start + tailSeconds, 0.15f, 4f), frequency);
}
private static string GestureLine(IpaGestureExperimentTarget target, float start, float duration, float intensity) =>
$"phoneme_gesture name={SafeDslName(target.Id)} ipa={target.Ipa} {(target.Descriptor.Length == 0 ? "" : $"descriptor={target.Descriptor.Replace(' ', '_')} ")}start={Format(start)} dur={Format(duration)} intensity={Format(intensity)}";
private static string Scaffold(IReadOnlyList<string> gestureLines, float sustain, float frequency) => $$"""
patch gain=.45
morphology name=oral length_cm=17 diameters=.55,.75,1.05,1.45,1.25,.8 tongue_index=3 tongue_diameter=1.4 constriction_index=4 constriction_diameter=1 lip_opening=1.2
morphology name=nasal length_cm=12 diameters=.04,.28,.52,.72
waveguide_path name=oral_path morphology=oral strategy=thiran order=1 max_delay=4096 loss=.998
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
radiation_load name=mouth path=oral_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
{{string.Join("\n", gestureLines)}}
vocal network=voice freq={{Format(frequency)}} gain=.7 sustain={{Format(sustain)}}
""";
private static IReadOnlyList<IpaGestureMetricSummary> SummarizeMetrics(IReadOnlyList<IpaGestureExperimentMetric> metrics) =>
metrics
.Where(metric => metric.Layer.Equals("gesture", StringComparison.OrdinalIgnoreCase))

View file

@ -1518,6 +1518,7 @@ public static class PatchScript
"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",

View file

@ -613,6 +613,47 @@ public sealed class PatchScriptTests
Assert.True(fricativeControls["/vocal/sources/0/noise"] > nasalControls["/vocal/sources/0/noise"]);
}
[Fact]
public void BareNIsAnAlveolarNasalNotTheApproximantFallback()
{
var patch = PatchScript.Parse("""
morphology name=oral length_cm=17 diameters=.6,.8,1.2,1.6,1.3,.9 tongue_index=3 tongue_diameter=1.4 constriction_index=4 constriction_diameter=1 lip_opening=1.5
morphology name=nasal length_cm=12 diameters=.05,.35,.6,.8
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=.45 noise=.05 impedance=.3
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=.4 stored_pressure=.1
radiation_load name=mouth path=oral_path aperture=.8 reflection=-.82 impedance=.28
vocal_network name=voice paths=oral_path,nasal_path sources=folds contacts=contact branches=velopharynx radiation=mouth
phoneme_gesture name=en ipa=n start=0 dur=.1
vocal network=voice freq=140 gain=.2 sustain=.3
""");
Assert.Equal("voiced_alveolar_nasal", patch.PhonemeGestures.Single().Descriptor);
Assert.Contains(patch.ControlSplines, spline => spline.Name.StartsWith("en_velum", StringComparison.Ordinal) && spline.SurfacePath == "/vocal/branches/0/opening");
var catalog = ControlSurfaceCatalog.FromPatch(patch);
Assert.True(catalog.CreateTimeline().ControlValuesAt(.03f)["/vocal/branches/0/opening"] > .5f, "the velum opens for n");
}
[Fact]
public void WordScriptRunsGesturesBackToBackAndLeavesDescriptorsToTheDslTable()
{
var script = IpaGestureExperiment.BuildWordScript(
[
(new IpaGestureExperimentTarget("w_n", "n", "", .1f), 1f),
(new IpaGestureExperimentTarget("w_i", "i", "", .2f), 1.2f),
(new IpaGestureExperimentTarget("w_b", "b", "", .1f), 1f)
], frequency: 140);
var patch = PatchScript.Parse(script);
Assert.Equal(["n", "i", "b"], patch.PhonemeGestures.Select(gesture => gesture.Ipa));
Assert.Equal([0f, .1f, .3f], patch.PhonemeGestures.Select(gesture => gesture.StartSeconds));
Assert.Equal(["voiced_alveolar_nasal", "voiced_close_front_unrounded_vowel", "voiced_bilabial_plosive"], patch.PhonemeGestures.Select(gesture => gesture.Descriptor));
Assert.Equal(1.2f, patch.PhonemeGestures[1].Intensity);
Assert.Throws<ArgumentException>(() => IpaGestureExperiment.BuildWordScript([]));
}
[Fact]
public void IpaGestureExperimentWritesFrozenRoundBundle()
{

View file

@ -0,0 +1,87 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
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
const int Rate = 48000, Frames = 512;
if (args.Length < 4) { Console.Error.WriteLine("usage: WordRender <outDir> <label> <freq-hz> <ipa>:<ms>:<intensity> ..."); 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) =>
{
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();
Directory.CreateDirectory(outDir);
var script = IpaGestureExperiment.BuildWordScript(phones, freq);
File.WriteAllText(Path.Combine(outDir, "script.aqua"), script + "\n");
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;
File.WriteAllText(Path.Combine(outDir, "source.dsp"), source);
if (!compiler.TryCompileSource(new AquaSynthCompileIdentity(label, label, ""), source, seconds, out var patch0, out var cerr))
{
Console.Error.WriteLine("COMPILE FAILED: " + cerr);
return 1;
}
using var patch = patch0!;
using var stream = patch.CreateStreamingPatch();
var buf = GC.AllocateArray<float>(Frames, pinned: true);
var ptrs = GC.AllocateArray<IntPtr>(1, pinned: true);
unsafe { ptrs[0] = (IntPtr)Unsafe.AsPointer(ref buf[0]); }
var outP = Marshal.UnsafeAddrOfPinnedArrayElement(ptrs, 0);
var blocks = (int)Math.Ceiling(seconds * Rate / Frames);
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);
for (var i = 0; i < Frames; i++)
{
var v = buf[i];
nan |= float.IsNaN(v) || float.IsInfinity(v);
peak = Math.Max(peak, Math.Abs(v));
sum += (double)v * v;
pcm[blk * Frames + i] = (short)(Math.Clamp(v, -1f, 1f) * 32767);
}
}
var wav = Path.Combine(outDir, $"{label}.wav");
using (var w = new BinaryWriter(File.Create(wav)))
{
var db = pcm.Length * 2;
w.Write("RIFF"u8); w.Write(36 + db); w.Write("WAVEfmt "u8); w.Write(16); w.Write((short)1); w.Write((short)1);
w.Write(Rate); w.Write(Rate * 2); w.Write((short)2); w.Write((short)16); w.Write("data"u8); w.Write(db);
foreach (var s in pcm) w.Write(s);
}
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,
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
}, new JsonSerializerOptions { WriteIndented = true }));
Console.WriteLine($"{wav} {seconds:F2}s peak={peak:F3} rms={rms:F4} nonFinite={nan}");
return nan ? 1 : 0;

View file

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AquaSynth.Faust\AquaSynth.Faust.csproj" />
</ItemGroup>
</Project>