Add optional eSpeak NG speech workout
This commit is contained in:
parent
117897b83a
commit
d073a5bc0c
6 changed files with 276 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
},
|
||||
|
|
|
|||
241
tests/AquaSynth.Dsl.Tests/EspeakNgReferenceTests.cs
Normal file
241
tests/AquaSynth.Dsl.Tests/EspeakNgReferenceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue