Speech: n descriptor, word script, WordRender; first /ˈnibu/ render (a hum, unheard) #17
5 changed files with 185 additions and 19 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
87
tools/WordRender/Program.cs
Normal file
87
tools/WordRender/Program.cs
Normal 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;
|
||||
12
tools/WordRender/WordRender.csproj
Normal file
12
tools/WordRender/WordRender.csproj
Normal 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>
|
||||
Loading…
Reference in a new issue