Add vocal tract boundary contracts

This commit is contained in:
Meta Vens 2026-05-19 14:54:27 +01:00
commit c4d215dabe
7 changed files with 443 additions and 2 deletions

View file

@ -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.

View file

@ -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:

View file

@ -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<PhoneticEvent> 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<ArticulatoryCapability> 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<ArticulatoryDiagnostic> 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<ArticulatoryGesture> Gestures,
ArticulatoryConstraintReport Report);
public static class VocalTractConstraintEvaluator
{
public static ArticulatoryConstraintReport Evaluate(PhoneticIntent intent, VocalTractMorphology morphology)
{
var capabilities = morphology.Capabilities.ToHashSet();
var diagnostics = new List<ArticulatoryDiagnostic>();
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<ArticulatoryDiagnostic> diagnostics,
HashSet<ArticulatoryCapability> 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.";
}

View file

@ -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."}

View file

@ -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

View file

@ -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": {

View file

@ -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);
}
}