Add optional eSpeak NG speech workout

This commit is contained in:
Meta Vens 2026-05-19 16:11:07 +01:00
commit d073a5bc0c
6 changed files with 276 additions and 1 deletions

View file

@ -431,6 +431,34 @@ Metrics:
- intelligibility/listening notes for complete syllables;
- script/IPA terseness only after audio is credible.
## Optional eSpeak NG Workout
The first IPA-adjacent parity lane uses eSpeak NG as a development reference.
It is not an anatomy oracle. eSpeak NG is a compact formant-synthesis TTS system
that can render speech to WAV and emit IPA transcriptions from text. That makes
it useful for early intelligibility, phoneme timing, and broad coverage pressure
while AquaSynth grows its own tract model.
The optional test `EspeakNgReferenceRendererWritesTinyIpaWorkoutWhenInstalled`
looks for `ESPEAK_NG` or `espeak-ng`/`espeak` on `PATH`. When present, it renders
a tiny workout under ignored artifacts:
```text
artifacts/parity/espeak-ng-ipa-workout/<timestamp>-tiny/
open-vowel.wav
bilabial-open.wav
alveolar-open.wav
velar-open.wav
sibilant-open.wav
nasal-open.wav
report.md
```
The report records the source text, eSpeak IPA transcription, sample counts, and
basic audio features. This is the first external speech judge for the vocal
tract lane. It should pressure AquaSynth's future IPA/gesture/render path, not
replace it.
## First Work Packets
1. Add `docs/ipa-vocal-tract-roadmap.md` and preserve the boundary doctrine.

View file

@ -73,6 +73,9 @@ First deliverable:
- Human baseline vowel tract-area planner.
- A small render proof that can say a few vowels before the supported IPA table
expands.
- Optional eSpeak NG workout fixtures that render tiny text/IPA-adjacent speech
references when `ESPEAK_NG` or an `espeak-ng` binary is available. These are
intelligibility/timing references, not tract-anatomy truth.
### Tier 1: DX7/Dexed

View file

@ -107,3 +107,4 @@
{"date":"2026-05-19","evidence":"Added docs/ipa-vocal-tract-roadmap.md with staged IPA support from tokenization/vowels through pulmonic consonants, expanded IPA, airstream/phonation, and Weksa alien morphologies; linked it from docs/reference-synth-roadmap.md and state.","lesson":"AquaSynth should treat IPA as an authoring input for phonetic intent, not as the internal synth model. The planned machine is tokenizer -\u003e feature mapper -\u003e phonology profile -\u003e gesture planner -\u003e morphology -\u003e tract plan -\u003e Faust renderer, growing supported IPA only as tract/excitation physics earn each category.","topic":"ipa-vocal-tract-roadmap"}
{"date":"2026-05-19","topic":"vocal-tract-boundary-contracts","lesson":"Defend the Weksa/AquaSynth tract boundary with three real bones: PhoneticIntent for language-owned phones/features/timing, ArticulatoryPlan for AquaSynth-owned gesture realization, and ArticulatoryConstraintReport for host-visible anatomy vetoes. Morphology capabilities must reject impossible phones such as bilabials for a beaked speaker instead of silently substituting liar-slurry.","evidence":"Added VocalTractModel.cs contracts and VocalTractModelTests.cs coverage for inspectable Weksa intent, bilabial veto on a beaked morphology, and acceptance on a human baseline; updated audio doctrine and IPA roadmap boundary section."}
{"date":"2026-05-19","evidence":"Added VocalTractPlanner.Plan, VocalTractPlanResult, VocalTractHostReaction, rejection coverage for /pa/ against a beaked morphology, and accepted /a/ coverage for the human baseline gesture path.","lesson":"The first vocal-tract proof artifact is VocalTractPlanResult: host-visible branching around one intent and one morphology. Rejected morphology constraints carry diagnostic codes and no plan; accepted vowel intents emit inspectable glottal-source and tract-area gestures before any renderer exists.","topic":"vocal-tract-plan-result-proof"}
{"date":"2026-05-19","evidence":"Added EspeakNgReferenceTests with optional renderer discovery, tiny fixtures for a/pa/ta/ka/sa/ma, WAV artifact/report generation under artifacts/parity/espeak-ng-ipa-workout, and docs/state entries naming eSpeak as intelligibility/timing pressure only.","lesson":"The first vocal-tract parity workout uses optional eSpeak NG as a development speech reference, not an anatomy oracle. The lane renders tiny speech fixtures and captures eSpeak IPA transcription/audio features when ESPEAK_NG or espeak-ng is available, while normal builds skip cleanly when it is absent.","topic":"espeak-ng-ipa-workout"}

View file

@ -36,6 +36,7 @@ modules:
faust_compiler: src/AquaSynth.Faust/FaustCompiler.cs
native_faust_runtime: src/AquaSynth.Faust/NativeFaustRuntime.cs
dx7_reference_render_tests: tests/AquaSynth.Dsl.Tests/Dx7ReferenceParityTests.cs
espeak_ng_reference_tests: tests/AquaSynth.Dsl.Tests/EspeakNgReferenceTests.cs
zyn_reference_reader: src/AquaSynth.Core/ZynInstrument.cs
zyn_coverage_survey: src/AquaSynth.Core/ZynInstrument.cs
ipa_vocal_tract_roadmap: docs/ipa-vocal-tract-roadmap.md
@ -76,5 +77,6 @@ checks:
faust_validation: automatic in tests when Faust is installed
dexed_py_reference: optional in tests when AQUASYNTH_DX7_PYTHON or a Python with dexed-py is available
zyn_reference_source: pinned submodule used only for parity tests; never package content
espeak_ng_reference: optional command-line development reference, found through ESPEAK_NG or PATH; never package content
zyn_upstream_instrument_bank: nested GPL submodule used only for test/development pressure surveys and oracle renders; never package content

View file

@ -86,7 +86,7 @@
"rule": "Add DSL abstractions only when reference rebuilds prove the missing invariant. Do not consume references that cannot be backed by parity tests; parsing alone is inventory, not evidence of expressivity."
},
"ipa_vocal_tract_path": {
"status": "Roadmap drafted for an IPA-to-vocal-tract lane, and the first Core contracts now defend the boundary: PhoneticIntent is the Weksa/language-owned handoff, ArticulatoryPlan is the AquaSynth realization layer, VocalTractMorphology declares anatomy capabilities, ArticulatoryConstraintReport is the host-visible veto surface, and VocalTractPlanResult is the tiny end-to-end proof artifact for host branching. IPA is an authoring input only: tokenization and feature mapping lower into language phonology profiles, timed articulatory gestures, morphology-specific tract/excitation curves, and Faust DSP. The staged support plan starts with tokenization and vowels, then core pulmonic consonants, expanded human IPA, airstream/phonation, and Weksa/alien morphologies.",
"status": "Roadmap drafted for an IPA-to-vocal-tract lane, and the first Core contracts now defend the boundary: PhoneticIntent is the Weksa/language-owned handoff, ArticulatoryPlan is the AquaSynth realization layer, VocalTractMorphology declares anatomy capabilities, ArticulatoryConstraintReport is the host-visible veto surface, and VocalTractPlanResult is the tiny end-to-end proof artifact for host branching. IPA is an authoring input only: tokenization and feature mapping lower into language phonology profiles, timed articulatory gestures, morphology-specific tract/excitation curves, and Faust DSP. The staged support plan starts with tokenization and vowels, then core pulmonic consonants, expanded human IPA, airstream/phonation, and Weksa/alien morphologies. Optional eSpeak NG tests now provide a first external speech workout when ESPEAK_NG or espeak-ng is installed; eSpeak is intelligibility/timing pressure, not anatomy truth.",
"lesson": "Do not build a complete IPA parser before the tract can say basic syllables. The durable machine is tokenizer -> feature mapper -> phonology profile -> gesture planner -> morphology -> tract plan -> Faust renderer, with dependency-injected seams and mocks at each boundary. Weksa should use IPA-like phonetic input without letting human IPA semantics own alien anatomy. Anatomy has veto power: if a morphology lacks bilabial closure or another required capability, AquaSynth reports that failure instead of smearing over it.",
"next": "Implement Stage 0/1 fixtures: IPA token spans, vowel feature bundles, human baseline vowel tract-area targets, and a tiny audible vowel render proof before expanding consonants."
},

View file

@ -0,0 +1,241 @@
using System.Diagnostics;
using System.Text;
using AquaSynth.Dsl;
namespace AquaSynth.Dsl.Tests;
public sealed class EspeakNgReferenceTests
{
[Fact]
public async Task EspeakNgReferenceRendererWritesTinyIpaWorkoutWhenInstalled()
{
if (!EspeakNgReferenceRenderer.TryFind(out var renderer))
{
return;
}
var root = RepositoryRoot();
var artifactDir = Path.Combine(root, "artifacts", "parity", "espeak-ng-ipa-workout", RunFolderName("tiny"));
Directory.CreateDirectory(artifactDir);
var fixtures = new[]
{
new EspeakNgFixture("open-vowel", "a", "en"),
new EspeakNgFixture("bilabial-open", "pa", "en"),
new EspeakNgFixture("alveolar-open", "ta", "en"),
new EspeakNgFixture("velar-open", "ka", "en"),
new EspeakNgFixture("sibilant-open", "sa", "en"),
new EspeakNgFixture("nasal-open", "ma", "en")
};
var report = new List<string>
{
"eSpeak NG IPA workout",
$"renderer: {renderer.CommandPath}",
"role: optional development reference for intelligibility, phoneme timing, and broad IPA/text coverage; not anatomy truth"
};
foreach (var fixture in fixtures)
{
var wavPath = Path.Combine(artifactDir, $"{fixture.Id}.wav");
var ipa = await renderer.TranscribeIpaAsync(fixture);
var render = await renderer.RenderAsync(fixture, wavPath);
Assert.Equal(0, render.ExitCode);
Assert.True(File.Exists(wavPath), $"eSpeak NG did not write {wavPath}.");
var samples = ReadMonoPcm16Wav(wavPath);
Assert.True(samples.Length > 1024, $"{fixture.Id} rendered too few samples.");
var features = AudioAnalyzer.AnalyzeAudio(samples).Features;
Assert.True(features.Peak > 0.001f, $"{fixture.Id} rendered near silence.");
report.Add($"fixture: {fixture.Id}");
report.Add($" text: {fixture.Text}");
report.Add($" voice: {fixture.Voice}");
report.Add($" ipa: {ipa.Trim()}");
report.Add($" samples: {samples.Length}");
report.Add($" peak: {features.Peak:0.######}");
report.Add($" rms: {features.Rms:0.######}");
report.Add($" centroid_hz: {features.SpectralCentroidHz:0.######}");
}
await File.WriteAllLinesAsync(Path.Combine(artifactDir, "report.md"), report);
}
[Fact]
public void EspeakNgReferenceRendererReportsMissingTool()
{
if (EspeakNgReferenceRenderer.TryFind(out _))
{
return;
}
var missing = EspeakNgReferenceRenderer.MissingToolMessage;
Assert.Contains("ESPEAK_NG", missing);
Assert.Contains("PATH", missing);
}
private static string RepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AquaSynth.sln")))
{
directory = directory.Parent;
}
return directory?.FullName ?? throw new InvalidOperationException("could not find repository root");
}
private static string RunFolderName(string label) =>
$"{DateTime.UtcNow:yyyyMMdd-HHmmss}-{label}";
private static float[] ReadMonoPcm16Wav(string path)
{
var bytes = File.ReadAllBytes(path);
if (bytes.Length < 44 ||
Encoding.ASCII.GetString(bytes, 0, 4) != "RIFF" ||
Encoding.ASCII.GetString(bytes, 8, 4) != "WAVE")
{
throw new InvalidOperationException($"{path} is not a RIFF/WAVE file.");
}
var offset = 12;
ushort channels = 0;
ushort bitsPerSample = 0;
var dataOffset = -1;
var dataSize = 0;
while (offset + 8 <= bytes.Length)
{
var chunk = Encoding.ASCII.GetString(bytes, offset, 4);
var size = BitConverter.ToInt32(bytes, offset + 4);
offset += 8;
if (chunk == "fmt ")
{
var format = BitConverter.ToUInt16(bytes, offset);
channels = BitConverter.ToUInt16(bytes, offset + 2);
bitsPerSample = BitConverter.ToUInt16(bytes, offset + 14);
if (format != 1 || channels == 0 || bitsPerSample != 16)
{
throw new InvalidOperationException($"{path} must be PCM16 WAV.");
}
}
else if (chunk == "data")
{
dataOffset = offset;
dataSize = size;
break;
}
offset += size + (size & 1);
}
if (dataOffset < 0 || channels == 0)
{
throw new InvalidOperationException($"{path} has no readable data chunk.");
}
var frames = dataSize / (channels * 2);
var samples = new float[frames];
for (var frame = 0; frame < frames; frame++)
{
var sum = 0f;
for (var channel = 0; channel < channels; channel++)
{
var index = dataOffset + (frame * channels + channel) * 2;
sum += BitConverter.ToInt16(bytes, index) / 32768f;
}
samples[frame] = sum / channels;
}
return samples;
}
private sealed record EspeakNgFixture(string Id, string Text, string Voice);
private sealed class EspeakNgReferenceRenderer(string commandPath)
{
public const string MissingToolMessage =
"eSpeak NG reference renderer not found. Set ESPEAK_NG to espeak-ng.exe/espeak.exe or put espeak-ng/espeak on PATH.";
public string CommandPath { get; } = commandPath;
public static bool TryFind(out EspeakNgReferenceRenderer renderer)
{
var configured = Environment.GetEnvironmentVariable("ESPEAK_NG");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
renderer = new EspeakNgReferenceRenderer(configured);
return true;
}
foreach (var name in new[] { "espeak-ng", "espeak" })
{
if (TryFindOnPath(name, out var path))
{
renderer = new EspeakNgReferenceRenderer(path);
return true;
}
}
renderer = null!;
return false;
}
public async Task<(int ExitCode, string Stdout, string Stderr)> RenderAsync(EspeakNgFixture fixture, string wavPath)
{
Directory.CreateDirectory(Path.GetDirectoryName(wavPath)!);
return await RunAsync(["-v", fixture.Voice, "-w", wavPath, fixture.Text]);
}
public async Task<string> TranscribeIpaAsync(EspeakNgFixture fixture)
{
var result = await RunAsync(["-q", "--ipa=3", "-v", fixture.Voice, fixture.Text]);
return result.ExitCode == 0 ? result.Stdout : $"unavailable: {result.Stderr.Trim()}";
}
private static bool TryFindOnPath(string name, out string path)
{
var paths = (Environment.GetEnvironmentVariable("PATH") ?? "")
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var extensions = OperatingSystem.IsWindows()
? (Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.CMD;.BAT")
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: [""];
foreach (var directory in paths)
{
foreach (var extension in extensions)
{
var candidate = Path.Combine(directory, name + extension);
if (File.Exists(candidate))
{
path = candidate;
return true;
}
}
}
path = "";
return false;
}
private async Task<(int ExitCode, string Stdout, string Stderr)> RunAsync(IReadOnlyList<string> arguments)
{
var start = new ProcessStartInfo(CommandPath)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
foreach (var argument in arguments)
{
start.ArgumentList.Add(argument);
}
using var process = Process.Start(start) ?? throw new InvalidOperationException($"failed to start `{CommandPath}`");
var stdout = process.StandardOutput.ReadToEndAsync();
var stderr = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
return (process.ExitCode, await stdout, await stderr);
}
}
}