diff --git a/docs/ipa-vocal-tract-roadmap.md b/docs/ipa-vocal-tract-roadmap.md index ea34f9f..4c29d64 100644 --- a/docs/ipa-vocal-tract-roadmap.md +++ b/docs/ipa-vocal-tract-roadmap.md @@ -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/-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. diff --git a/docs/reference-synth-roadmap.md b/docs/reference-synth-roadmap.md index d61d1cc..2e8de63 100644 --- a/docs/reference-synth-roadmap.md +++ b/docs/reference-synth-roadmap.md @@ -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 diff --git a/state/evidence.jsonl b/state/evidence.jsonl index f2de18b..a186fa2 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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"} diff --git a/state/map.yaml b/state/map.yaml index 3b5a9f5..4327cc1 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -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 diff --git a/state/memory.json b/state/memory.json index 08dbfb3..66067bb 100644 --- a/state/memory.json +++ b/state/memory.json @@ -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." }, diff --git a/tests/AquaSynth.Dsl.Tests/EspeakNgReferenceTests.cs b/tests/AquaSynth.Dsl.Tests/EspeakNgReferenceTests.cs new file mode 100644 index 0000000..ffce1af --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/EspeakNgReferenceTests.cs @@ -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 + { + "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 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 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); + } + } +}