From c4d215dabe05b06ee297796feb97ec158f6aeb8a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 19 May 2026 14:54:27 +0100 Subject: [PATCH] Add vocal tract boundary contracts --- docs/audio-doctrine.md | 18 ++ docs/ipa-vocal-tract-roadmap.md | 18 ++ src/AquaSynth.Core/VocalTractModel.cs | 289 ++++++++++++++++++ state/evidence.jsonl | 1 + state/map.yaml | 3 + state/memory.json | 5 +- .../VocalTractModelTests.cs | 111 +++++++ 7 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 src/AquaSynth.Core/VocalTractModel.cs create mode 100644 tests/AquaSynth.Dsl.Tests/VocalTractModelTests.cs diff --git a/docs/audio-doctrine.md b/docs/audio-doctrine.md index 42b8b2e..0180d64 100644 --- a/docs/audio-doctrine.md +++ b/docs/audio-doctrine.md @@ -137,6 +137,24 @@ authorities. rebuild work, but that is not exact DX7 envelope execution. Keep exactness pressure visible in reference features instead of smuggling it into ADSR. +## Phonetic Tract Boundary + +IPA is an authoring surface, not a synth model. Weksa or another language layer +owns text, phonology, allophones, and phonetic intent. AquaSynth owns the +articulatory realization: gesture planning, morphology constraints, tract-area +targets, excitation events, and Faust lowering. + +The boundary must stay split into three inspectable records: + +- `PhoneticIntent`: language-owned phones, features, timing, and prosody. +- `ArticulatoryPlan`: AquaSynth-owned gestures and tract/source targets. +- `ArticulatoryConstraintReport`: host-visible diagnostics when morphology + cannot produce the requested articulation. + +Anatomy has veto power. If a beaked morphology cannot form a bilabial closure, +the planner must report the missing capability instead of silently substituting +a fake sound and calling it expressive. + ## Metrics Analysis exists to catch regressions and support search, not to crown winners. diff --git a/docs/ipa-vocal-tract-roadmap.md b/docs/ipa-vocal-tract-roadmap.md index 91cec26..a9f26b9 100644 --- a/docs/ipa-vocal-tract-roadmap.md +++ b/docs/ipa-vocal-tract-roadmap.md @@ -18,6 +18,24 @@ The target user feeds Weksa text as IPA or IPA-like phonetic strings. AquaSynth keeps the real authority below that surface: phonetic features, gestures, anatomy, excitation, and acoustic simulation. +## Boundary Bones + +Three handoffs must stay visible in code, docs, tests, and host reports: + +1. `PhoneticIntent` is the inspectable Weksa-to-AquaSynth contract. It carries + IPA tokens, feature bundles, timing, and prosody. It does not know tract + section areas or Faust. +2. `ArticulatoryPlan` is the AquaSynth-owned realization layer. It carries + gestures and tract targets after morphology constraints have had a chance to + bite. It does not pretend an impossible phone was rendered honestly. +3. `ArticulatoryConstraintReport` is the host-visible failure surface. If a + morphology cannot form a requested articulation, the report names the phone, + source event, missing capability, and severity. + +Those records are the first defense against liar-slurry: no parser, planner, +renderer, or host should need to infer whether IPA intent, physical anatomy, or +DSP output owns a decision. + ## Objective Build a research-grounded vocal-tract instrument for human and alien speech: diff --git a/src/AquaSynth.Core/VocalTractModel.cs b/src/AquaSynth.Core/VocalTractModel.cs new file mode 100644 index 0000000..1ec34f8 --- /dev/null +++ b/src/AquaSynth.Core/VocalTractModel.cs @@ -0,0 +1,289 @@ +namespace AquaSynth.Dsl; + +public enum PhoneticManner +{ + Vowel, + Stop, + Nasal, + Fricative, + Affricate, + Approximant, + LateralApproximant, + LateralFricative, + Trill, + TapFlap +} + +public enum PhoneticPlace +{ + None, + Bilabial, + Labiodental, + Dental, + Alveolar, + Postalveolar, + Retroflex, + Palatal, + Velar, + Uvular, + Pharyngeal, + Glottal +} + +public enum Phonation +{ + Voiceless, + Voiced, + Breathy, + Creaky +} + +public enum AirstreamMechanism +{ + PulmonicEgressive, + Ejective, + Implosive, + Click +} + +public enum VowelHeight +{ + None, + Close, + NearClose, + CloseMid, + Mid, + OpenMid, + NearOpen, + Open +} + +public enum VowelBackness +{ + None, + Front, + NearFront, + Central, + NearBack, + Back +} + +public sealed record PhoneticFeatures( + PhoneticManner Manner, + PhoneticPlace Place = PhoneticPlace.None, + Phonation Phonation = Phonation.Voiced, + AirstreamMechanism Airstream = AirstreamMechanism.PulmonicEgressive, + VowelHeight Height = VowelHeight.None, + VowelBackness Backness = VowelBackness.None, + bool Rounded = false, + bool Long = false, + bool Nasalized = false, + bool Lateral = false); + +public sealed record PhoneticProsody( + float Stress = 0, + float PitchTarget = 0, + float Intensity = 1, + string Register = ""); + +public sealed record PhoneticEvent( + string Id, + string Ipa, + PhoneticFeatures Features, + double StartSeconds = 0, + double DurationSeconds = 0, + PhoneticProsody Prosody = null!) +{ + public PhoneticProsody Prosody { get; init; } = Prosody ?? new(); +} + +public sealed record PhoneticIntent( + string Id, + string Language, + string SourceText, + IReadOnlyList Events); + +public enum ArticulatoryCapability +{ + OralTract, + PulmonicPressure, + Voicing, + BilabialClosure, + LabiodentalConstriction, + DentalConstriction, + AlveolarConstriction, + PostalveolarConstriction, + RetroflexConstriction, + PalatalConstriction, + VelarConstriction, + UvularConstriction, + PharyngealConstriction, + GlottalConstriction, + NasalBranch, + LateralChannel, + EjectivePressure, + ImplosivePressure, + ClickCavity, + LipRounding, + SecondarySource +} + +public sealed record VocalTractMorphology( + string Id, + string Name, + float TractLengthCentimeters, + int SectionCount, + IReadOnlyList Capabilities); + +public enum ArticulatoryDiagnosticSeverity +{ + Info, + Warning, + Error +} + +public sealed record ArticulatoryDiagnostic( + ArticulatoryDiagnosticSeverity Severity, + string Code, + string Message, + string EventId = "", + string Ipa = "", + ArticulatoryCapability? RequiredCapability = null); + +public sealed record ArticulatoryConstraintReport( + string IntentId, + string MorphologyId, + IReadOnlyList Diagnostics) +{ + public bool Accepted => Diagnostics.All(diagnostic => diagnostic.Severity != ArticulatoryDiagnosticSeverity.Error); +} + +public enum ArticulatoryGestureKind +{ + TractAreaTarget, + Constriction, + Closure, + Release, + Turbulence, + NasalCoupling, + GlottalSource, + PressureEvent +} + +public sealed record ArticulatoryGesture( + string Id, + string SourceEventId, + ArticulatoryGestureKind Kind, + double StartSeconds, + double DurationSeconds, + string Target = "", + float Amount = 0); + +public sealed record ArticulatoryPlan( + string Id, + PhoneticIntent Intent, + VocalTractMorphology Morphology, + IReadOnlyList Gestures, + ArticulatoryConstraintReport Report); + +public static class VocalTractConstraintEvaluator +{ + public static ArticulatoryConstraintReport Evaluate(PhoneticIntent intent, VocalTractMorphology morphology) + { + var capabilities = morphology.Capabilities.ToHashSet(); + var diagnostics = new List(); + + foreach (var phoneticEvent in intent.Events) + { + Require(diagnostics, capabilities, phoneticEvent, ArticulatoryCapability.OralTract, "missing_oral_tract", "Morphology has no oral tract for phonetic realization."); + if (phoneticEvent.Features.Phonation != Phonation.Voiceless) + { + Require(diagnostics, capabilities, phoneticEvent, ArticulatoryCapability.Voicing, "missing_voicing", "Morphology cannot produce voiced phonation for this event."); + } + + if (phoneticEvent.Features.Rounded) + { + Require(diagnostics, capabilities, phoneticEvent, ArticulatoryCapability.LipRounding, "missing_lip_rounding", "Morphology cannot round or protrude a lip aperture for this event."); + } + + if (phoneticEvent.Features.Nasalized || phoneticEvent.Features.Manner == PhoneticManner.Nasal) + { + Require(diagnostics, capabilities, phoneticEvent, ArticulatoryCapability.NasalBranch, "missing_nasal_branch", "Morphology cannot open a nasal branch for this event."); + } + + if (phoneticEvent.Features.Lateral || + phoneticEvent.Features.Manner is PhoneticManner.LateralApproximant or PhoneticManner.LateralFricative) + { + Require(diagnostics, capabilities, phoneticEvent, ArticulatoryCapability.LateralChannel, "missing_lateral_channel", "Morphology cannot form a lateral channel for this event."); + } + + if (PlaceCapability(phoneticEvent.Features.Place) is { } placeCapability) + { + Require(diagnostics, capabilities, phoneticEvent, placeCapability, MissingPlaceCode(phoneticEvent.Features.Place), MissingPlaceMessage(phoneticEvent.Features.Place)); + } + + switch (phoneticEvent.Features.Airstream) + { + case AirstreamMechanism.PulmonicEgressive: + Require(diagnostics, capabilities, phoneticEvent, ArticulatoryCapability.PulmonicPressure, "missing_pulmonic_pressure", "Morphology cannot produce pulmonic pressure for this event."); + break; + case AirstreamMechanism.Ejective: + Require(diagnostics, capabilities, phoneticEvent, ArticulatoryCapability.EjectivePressure, "missing_ejective_pressure", "Morphology cannot produce ejective pressure for this event."); + break; + case AirstreamMechanism.Implosive: + Require(diagnostics, capabilities, phoneticEvent, ArticulatoryCapability.ImplosivePressure, "missing_implosive_pressure", "Morphology cannot produce implosive pressure for this event."); + break; + case AirstreamMechanism.Click: + Require(diagnostics, capabilities, phoneticEvent, ArticulatoryCapability.ClickCavity, "missing_click_cavity", "Morphology cannot form a click cavity for this event."); + break; + } + } + + return new ArticulatoryConstraintReport(intent.Id, morphology.Id, diagnostics); + } + + private static void Require( + List diagnostics, + HashSet capabilities, + PhoneticEvent phoneticEvent, + ArticulatoryCapability capability, + string code, + string message) + { + if (capabilities.Contains(capability)) + { + return; + } + + diagnostics.Add(new ArticulatoryDiagnostic( + ArticulatoryDiagnosticSeverity.Error, + code, + message, + phoneticEvent.Id, + phoneticEvent.Ipa, + capability)); + } + + private static ArticulatoryCapability? PlaceCapability(PhoneticPlace place) => + place switch + { + PhoneticPlace.Bilabial => ArticulatoryCapability.BilabialClosure, + PhoneticPlace.Labiodental => ArticulatoryCapability.LabiodentalConstriction, + PhoneticPlace.Dental => ArticulatoryCapability.DentalConstriction, + PhoneticPlace.Alveolar => ArticulatoryCapability.AlveolarConstriction, + PhoneticPlace.Postalveolar => ArticulatoryCapability.PostalveolarConstriction, + PhoneticPlace.Retroflex => ArticulatoryCapability.RetroflexConstriction, + PhoneticPlace.Palatal => ArticulatoryCapability.PalatalConstriction, + PhoneticPlace.Velar => ArticulatoryCapability.VelarConstriction, + PhoneticPlace.Uvular => ArticulatoryCapability.UvularConstriction, + PhoneticPlace.Pharyngeal => ArticulatoryCapability.PharyngealConstriction, + PhoneticPlace.Glottal => ArticulatoryCapability.GlottalConstriction, + _ => null + }; + + private static string MissingPlaceCode(PhoneticPlace place) => + $"missing_{place.ToString().ToLowerInvariant()}_capability"; + + private static string MissingPlaceMessage(PhoneticPlace place) => + $"Morphology cannot form the requested {place.ToString().ToLowerInvariant()} articulation."; +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index c6e43b8..8ebd379 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -105,3 +105,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."} diff --git a/state/map.yaml b/state/map.yaml index f30b962..3b5a9f5 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -31,6 +31,7 @@ surfaces: modules: model: src/AquaSynth.Core/Model.cs parser: src/AquaSynth.Core/PatchScript.cs + vocal_tract_model: src/AquaSynth.Core/VocalTractModel.cs faust_emitter: src/AquaSynth.Core/FaustExport.cs faust_compiler: src/AquaSynth.Faust/FaustCompiler.cs native_faust_runtime: src/AquaSynth.Faust/NativeFaustRuntime.cs @@ -39,6 +40,7 @@ modules: zyn_coverage_survey: src/AquaSynth.Core/ZynInstrument.cs ipa_vocal_tract_roadmap: docs/ipa-vocal-tract-roadmap.md package_boundary_tests: tests/AquaSynth.Dsl.Tests/PackageBoundaryTests.cs + vocal_tract_model_tests: tests/AquaSynth.Dsl.Tests/VocalTractModelTests.cs sfxr: src/AquaSynth.Core/SfxrParams.cs analysis: src/AquaSynth.Core/AudioAnalysis.cs scoring: src/AquaSynth.Core/ScriptMetrics.cs @@ -60,6 +62,7 @@ boundaries: - target-code generation, compile-product contracts, dynamic patch compilation policy, cache identity, generated artifact manifests, and Faust toolchain selection in AquaSynth.Faust - live patch compilation, native Faust factory lifetime, and offline sample-buffer render proof in AquaSynth.Faust - IPA-to-phonetic-feature planning, articulatory gesture planning, and human/alien vocal-tract roadmap design + - phonetic intent, articulatory plan, morphology capability, and constraint-report contracts for vocal tract synthesis dev_only: - public-domain DX7 SysEx fixtures - pinned GPL ZynAddSubFX source for parity tests diff --git a/state/memory.json b/state/memory.json index 4901175..ecc5986 100644 --- a/state/memory.json +++ b/state/memory.json @@ -50,6 +50,7 @@ "dynamic patch compilation policy outside the realtime callback", "generated DSP artifact manifests for engine hosting", "live patch compilation, native Faust factory lifetime, and offline sample-buffer render proof for engine hosts", + "phonetic intent, articulatory plan, morphology capability, and constraint-report contracts for vocal tract synthesis", "patch script parser", "SFXR atoms and parameter mapping", "analysis/comparison metrics", @@ -85,8 +86,8 @@ "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. 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.", - "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.", + "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, and ArticulatoryConstraintReport is the host-visible veto surface. 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.", + "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 render proof before expanding consonants." }, "dx7_reference_path": { diff --git a/tests/AquaSynth.Dsl.Tests/VocalTractModelTests.cs b/tests/AquaSynth.Dsl.Tests/VocalTractModelTests.cs new file mode 100644 index 0000000..588f2ba --- /dev/null +++ b/tests/AquaSynth.Dsl.Tests/VocalTractModelTests.cs @@ -0,0 +1,111 @@ +using AquaSynth.Dsl; + +namespace AquaSynth.Dsl.Tests; + +public sealed class VocalTractModelTests +{ + [Fact] + public void PhoneticIntentKeepsWeksaHandoffInspectable() + { + var intent = new PhoneticIntent( + "weksa-utterance-1", + "weksa", + "/qʼoru/", + [ + new PhoneticEvent( + "phone-1", + "qʼ", + new PhoneticFeatures( + PhoneticManner.Stop, + PhoneticPlace.Uvular, + Phonation.Voiceless, + AirstreamMechanism.Ejective), + StartSeconds: 0.0, + DurationSeconds: 0.08, + Prosody: new PhoneticProsody(Stress: 1.0f, PitchTarget: 0.2f, Intensity: 0.8f)) + ]); + + var phone = Assert.Single(intent.Events); + Assert.Equal("weksa", intent.Language); + Assert.Equal("qʼ", phone.Ipa); + Assert.Equal(PhoneticPlace.Uvular, phone.Features.Place); + Assert.Equal(AirstreamMechanism.Ejective, phone.Features.Airstream); + Assert.Equal(0.08, phone.DurationSeconds); + Assert.Equal(1.0f, phone.Prosody.Stress); + } + + [Fact] + public void MorphologyVetoesPhysicallyImpossibleBilabial() + { + var intent = new PhoneticIntent( + "beak-test", + "weksa", + "/pa/", + [ + new PhoneticEvent( + "phone-p", + "p", + new PhoneticFeatures( + PhoneticManner.Stop, + PhoneticPlace.Bilabial, + Phonation.Voiceless), + DurationSeconds: 0.06) + ]); + var beak = new VocalTractMorphology( + "beak", + "Beaked speaker", + 22.0f, + 44, + [ + ArticulatoryCapability.OralTract, + ArticulatoryCapability.PulmonicPressure, + ArticulatoryCapability.Voicing, + ArticulatoryCapability.AlveolarConstriction, + ArticulatoryCapability.VelarConstriction + ]); + + var report = VocalTractConstraintEvaluator.Evaluate(intent, beak); + + Assert.False(report.Accepted); + var diagnostic = Assert.Single(report.Diagnostics); + Assert.Equal("phone-p", diagnostic.EventId); + Assert.Equal("p", diagnostic.Ipa); + Assert.Equal("missing_bilabial_capability", diagnostic.Code); + Assert.Equal(ArticulatoryCapability.BilabialClosure, diagnostic.RequiredCapability); + } + + [Fact] + public void HumanBaselineAcceptsBilabialWhenCapabilityExists() + { + var intent = new PhoneticIntent( + "human-test", + "ipa", + "/pa/", + [ + new PhoneticEvent( + "phone-p", + "p", + new PhoneticFeatures( + PhoneticManner.Stop, + PhoneticPlace.Bilabial, + Phonation.Voiceless), + DurationSeconds: 0.06) + ]); + var human = new VocalTractMorphology( + "human", + "Human baseline", + 17.0f, + 44, + [ + ArticulatoryCapability.OralTract, + ArticulatoryCapability.PulmonicPressure, + ArticulatoryCapability.Voicing, + ArticulatoryCapability.BilabialClosure + ]); + + var report = VocalTractConstraintEvaluator.Evaluate(intent, human); + + Assert.True(report.Accepted); + Assert.Empty(report.Diagnostics); + } +}