From 2a99d1909b9dcf3c4cebb0c96f77e8a6b400bdde Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 28 May 2026 16:47:44 +0100 Subject: [PATCH] Add graph-coupled tissue valve sources --- docs/audio-doctrine.md | 37 ++++ patches/advanced/bird-syrinx.aqua | 16 +- research/syrinx-modeling/README.md | 22 +-- src/AquaSynth.Core/FaustExport.cs | 103 ++++++++++- src/AquaSynth.Core/Model.cs | 17 +- src/AquaSynth.Core/PatchScript.cs | 30 +++- state/evidence.jsonl | 1 + tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs | 61 ++++++- tools/BirdSyrinxGolf/Program.cs | 170 +++++++++++++++--- 9 files changed, 411 insertions(+), 46 deletions(-) diff --git a/docs/audio-doctrine.md b/docs/audio-doctrine.md index 0075964..db54ee3 100644 --- a/docs/audio-doctrine.md +++ b/docs/audio-doctrine.md @@ -144,6 +144,43 @@ owns text, phonology, allophones, and phonetic intent. AquaSynth owns the articulatory realization: gesture planning, morphology constraints, tract-area targets, excitation events, and Faust lowering. +## Graph Vocal Sources + +Graph voices do not let `freq=` own vocal pitch. For acoustic-graph voices, +`freq=` is only an initializer/hint for defaults such as valve stiffness when a +script has not supplied a physical value. The live source authority is the +source port and its acoustic load. + +Current authority map: + +- Owner: `AcousticSourcePort` owns local excitation semantics at a graph + terminal. +- Inputs: pressure, tension, aperture/opening, mass, damping, stiffness, + saturation, drive, load coupling, rest opening, balance, source position, and + the incident pressure at the graph node. +- Outputs: source flow injected into the acoustic graph; rendered pitch is an + outcome of valve state plus tract/radiation load. +- Derived state: `kind=syrinx` and `kind=labial` are authoring aliases for + `model=tissue_valve`; they are not species modules. +- Forbidden writers: labial/syrinx lowering must not emit a commanded + `os.phasor(... freq ...)` oscillator as the source of truth. +- Shared path: larynx, syrinx, reed-like, and alien morphologies should become + graphs plus source ports, not bespoke renderer families. + +The reusable primitive is `model=tissue_valve`: a Faust-friendly nonlinear +valve state with pressure drive, delayed velocity/displacement state, cubic +aperture saturation, and incident-pressure load feedback. A syrinx is two such +valves feeding an acoustic graph; a larynx is one such valve feeding a tract. +Different species should change morphology and gestures, not add hidden source +renderers. + +The current bird-golf tool extracts reference envelope, dominant pitch, +onset/offset, active-duty, and flux features, then maps those into tissue-valve +candidate parameters. It does not yet express arbitrary time-varying source +gestures through the DSL. That missing automation surface is the next coherent +language feature: general control curves must target stable field paths such as +`/acoustic/sources/0/pressure`, not a bird-shaped side channel. + The current speech-learning lane adds a trainable surface above that boundary: structured utterance metadata lowers into an utterance embedding, then into a synth-driver automation vector for tract controls, envelopes, LFOs, filters, and diff --git a/patches/advanced/bird-syrinx.aqua b/patches/advanced/bird-syrinx.aqua index c88ab7c..326525f 100644 --- a/patches/advanced/bird-syrinx.aqua +++ b/patches/advanced/bird-syrinx.aqua @@ -6,6 +6,18 @@ param path=/bird/left/opening default=.24 min=0 max=1 step=.001 param path=/bird/right/opening default=.21 min=0 max=1 step=.001 param path=/bird/left/tension default=.38 min=0 max=1 step=.001 param path=/bird/right/tension default=.45 min=0 max=1 step=.001 +param path=/bird/left/mass default=.32 min=.02 max=2 step=.001 +param path=/bird/right/mass default=.34 min=.02 max=2 step=.001 +param path=/bird/left/damping default=.14 min=0 max=2 step=.001 +param path=/bird/right/damping default=.16 min=0 max=2 step=.001 +param path=/bird/left/stiffness default=.025 min=0 max=1 step=.001 +param path=/bird/right/stiffness default=.03 min=0 max=1 step=.001 +param path=/bird/left/drive default=1.2 min=0 max=4 step=.001 +param path=/bird/right/drive default=1.1 min=0 max=4 step=.001 +param path=/bird/left/load_coupling default=.42 min=0 max=2 step=.001 +param path=/bird/right/load_coupling default=.42 min=0 max=2 step=.001 +param path=/bird/left/rest_opening default=.025 min=0 max=1 step=.001 +param path=/bird/right/rest_opening default=.025 min=0 max=1 step=.001 param path=/bird/load default=.85 min=0 max=2 step=.001 param path=/bird/beak/opening default=.95 min=0 max=1.5 step=.001 @@ -13,8 +25,8 @@ path name=left_bronchus length_cm=3.8 diameters=.22,.30,.36,.42 path name=right_bronchus length_cm=3.6 diameters=.20,.28,.34,.40 path name=trachea length_cm=8.4 diameters=.38,.48,.56,.46 -source_port name=left_labium path=left_bronchus kind=syrinx position=0 pressure=@/bird/left/pressure tension=@/bird/left/tension opening=@/bird/left/opening noise=.025 impedance=@/bird/load -source_port name=right_labium path=right_bronchus kind=syrinx position=0 pressure=@/bird/right/pressure tension=@/bird/right/tension opening=@/bird/right/opening noise=.02 balance=.96 impedance=@/bird/load +source_port name=left_labium path=left_bronchus kind=syrinx model=tissue_valve position=0 pressure=@/bird/left/pressure tension=@/bird/left/tension opening=@/bird/left/opening noise=.025 impedance=@/bird/load mass=@/bird/left/mass damping=@/bird/left/damping stiffness=@/bird/left/stiffness saturation=.9 drive=@/bird/left/drive load_coupling=@/bird/left/load_coupling rest_opening=@/bird/left/rest_opening +source_port name=right_labium path=right_bronchus kind=syrinx model=tissue_valve position=0 pressure=@/bird/right/pressure tension=@/bird/right/tension opening=@/bird/right/opening noise=.02 balance=.96 impedance=@/bird/load mass=@/bird/right/mass damping=@/bird/right/damping stiffness=@/bird/right/stiffness saturation=.9 drive=@/bird/right/drive load_coupling=@/bird/right/load_coupling rest_opening=@/bird/right/rest_opening terminal name=left_merge path=left_bronchus position=1 kind=junction area_scale=1 terminal name=right_merge path=right_bronchus position=1 kind=junction area_scale=1 diff --git a/research/syrinx-modeling/README.md b/research/syrinx-modeling/README.md index c76e06b..2cc3f56 100644 --- a/research/syrinx-modeling/README.md +++ b/research/syrinx-modeling/README.md @@ -42,27 +42,27 @@ Aqua implication: the oscillator should expose controllable nonlinear regimes. A ## Architecture Intention -Owner: graph-native acoustic lowering owns how pressure waves, source loading, junctions, tract delays, and radiation interact. +Owner: graph-native acoustic lowering owns how pressure waves, source loading, junctions, tract delays, and radiation interact. `AcousticSourcePort` owns local source dynamics at a terminal; `kind=syrinx` and `kind=labial` are now aliases onto `model=tissue_valve`, not independent renderers. -Inputs: morphology graph, labial source parameters, pressure/tension/gating control curves, tract/radiation controls, and optional fitting targets. +Inputs: morphology graph, tissue-valve source parameters, pressure/tension/gating control curves, tract/radiation controls, and optional fitting targets. The implemented v1 valve surface exposes mass, damping, stiffness, saturation, drive, load coupling, and rest opening in addition to pressure, tension, opening, noise, balance, and impedance. -Outputs: emitted Faust DSP in which source dynamics and acoustic network are mutually coupled enough to express source-source and source-tract interaction. +Outputs: emitted Faust DSP in which source dynamics and acoustic network are mutually coupled enough to express source-source and source-tract interaction. The current Faust lowering emits named pressure-drive, force, velocity, displacement, aperture, load-pressure, and flow locals so the source law remains inspectable. -Derived state: direct frequency controls are authoring hints or fit initializers, not the physical owner of pitch. +Derived state: direct frequency controls are authoring hints or fit initializers for default stiffness, not the physical owner of pitch. -Forbidden writers: no bespoke bird renderer, no two-oscillator mixdown path, no species-specific hardcoded syrinx module, and no static spectral-grid compensator pretending to be a model. +Forbidden writers: no bespoke bird renderer, no two-oscillator mixdown path, no species-specific hardcoded syrinx module, no hidden phasor-owned labial excitation, and no static spectral-grid compensator pretending to be a model. Shared paths: human larynx, bird syrinx, and alien valve morphologies should use the same tissue-valve/source-port semantics where possible, differing through morphology, coupling, and control curves. -Deletion line: if the current `kind=syrinx` lowering cannot be explained as pressure/tension/aperture tissue dynamics coupled to graph pressure, it should be demoted to a smoke-test source and replaced. +Deletion line: any future `kind=syrinx` lowering that cannot be explained as pressure/tension/aperture tissue dynamics coupled to graph pressure should be demoted to a smoke-test source and replaced. ## Recommended Next Build Slice -1. Add a `labial_oscillator` or generalized `tissue_valve` source primitive with stateful displacement/velocity, nonlinear stiffness, nonlinear damping, aperture/rest-position, pressure drive, and load feedback. -2. Give `source_port kind=syrinx` a lowering that maps pressure/tension/opening/load to that primitive instead of fixed-frequency excitation. -3. Add control curves for pressure, tension, opening, left/right balance, and beak opening in `BirdSyrinxGolf`. -4. Fit clean bird references by segmented time-varying controls, keeping morphology parameters slower and gesture parameters faster. -5. Score onset timing, amplitude envelope, pitch contour, sidebands/subharmonics, and log-mel distance separately. +1. Add general arbitrary-path control curves for acoustic source/radiation fields, especially pressure, tension, opening, left/right balance, and beak opening. +2. Move glottal graph lowering onto `model=tissue_valve` defaults once the syrinx valve has enough listening evidence. +3. Fit clean bird references by segmented time-varying controls, keeping morphology parameters slower and gesture parameters faster. +4. Score onset timing, amplitude envelope, pitch contour, sidebands/subharmonics, and log-mel distance separately. +5. Add a larynx-style graph patch using the same tissue-valve primitive. ## Source Notes diff --git a/src/AquaSynth.Core/FaustExport.cs b/src/AquaSynth.Core/FaustExport.cs index 007bead..de33d30 100644 --- a/src/AquaSynth.Core/FaustExport.cs +++ b/src/AquaSynth.Core/FaustExport.cs @@ -621,7 +621,10 @@ public static class FaustEmitter { if (sources.TryGetValue(sourceName, out var port)) { - source.AppendLine($"{name}_graph_source_{SafeIdentifier(sourceName)} = {AcousticSourceExpression(patch, port, frequency, parameters)};"); + var sourceExpression = IsTissueValveSource(port) + ? "0.0" + : AcousticSourceExpression(patch, port, frequency, parameters); + source.AppendLine($"{name}_graph_source_{SafeIdentifier(sourceName)} = {sourceExpression};"); } } @@ -839,6 +842,13 @@ public static class FaustEmitter var transient = $"clip01({parameters.Expression(OwnerField(path, "transient"), port.Transient)})"; var balance = $"({parameters.Expression(OwnerField(path, "balance"), port.Balance)}) * ({SourcePositionWeightExpression(port, path, parameters)})"; var impedance = $"max(0.0, {parameters.Expression(OwnerField(path, "impedance"), port.Impedance)})"; + var mass = $"max(0.02, {parameters.Expression(OwnerField(path, "mass"), port.Mass)})"; + var damping = $"max(0.0, {parameters.Expression(OwnerField(path, "damping"), port.Damping)})"; + var stiffness = $"max(0.0, {parameters.Expression(OwnerField(path, "stiffness"), port.Stiffness)})"; + var saturation = $"max(0.0, {parameters.Expression(OwnerField(path, "saturation"), port.Saturation)})"; + var drive = $"max(0.0, {parameters.Expression(OwnerField(path, "drive"), port.Drive)})"; + var loadCoupling = $"max(0.0, {parameters.Expression(OwnerField(path, "load_coupling"), port.LoadCoupling)})"; + var restOpening = $"max(0.0, {parameters.Expression(OwnerField(path, "rest_opening"), port.RestOpening)})"; var effectivePressure = localPressure is null ? pressure : $"clip01(({pressure}) - 0.14 * ({impedance}) * ({localPressure}))"; @@ -858,6 +868,11 @@ public static class FaustEmitter var turbulence = $"(no.noise : fi.highpass(2, 900.0 + 2600.0 * clip01(1.0 - {opening})))"; var apertureNoiseGate = $"min(clip01((0.85 - {opening}) / 0.85), clip01(25.0 * ({opening} - 0.04)))"; var releaseBurstGate = $"clip01({opening} / 0.8) * {release}"; + if (IsTissueValveSource(port)) + { + return TissueValveInlineExpression(frequency, pressure, tension, opening, noise, balance, mass, damping, stiffness, saturation, drive, loadCoupling, restOpening, localPressure); + } + return port.Kind switch { AcousticSourceKind.Glottal => @@ -876,6 +891,39 @@ public static class FaustEmitter }; } + private static bool IsTissueValveSource(AcousticSourcePort port) => + port.Model == AcousticSourceModel.TissueValve || + port.Model == AcousticSourceModel.Default && port.Kind == AcousticSourceKind.Labial; + + private static string TissueValveInlineExpression( + string frequency, + string pressure, + string tension, + string opening, + string noise, + string balance, + string mass, + string damping, + string stiffness, + string saturation, + string drive, + string loadCoupling, + string restOpening, + string? localPressure) + { + var loadPressure = localPressure ?? "0.0"; + var stiffnessHint = $"max(0.00002, min(0.16, pow(2.0 * ma.PI * max(20.0, {frequency}) / ma.SR, 2.0)))"; + var effectiveStiffness = $"max(({stiffness}), ({stiffnessHint}) * (0.35 + 1.65 * ({tension})))"; + var pressureDrive = $"max(0.0, ({pressure}) * ({drive}) - ({loadCoupling}) * ({loadPressure}))"; + var velocityDecay = $"min(0.9995, max(0.20, 1.0 - ((0.008 + 0.08 * ({damping})) / ({mass}))))"; + var displacementDecay = $"min(0.9998, max(0.20, 1.0 - 0.0008 / ({mass})))"; + var velocity = $"((({pressureDrive}) * (0.0004 + 0.0024 * (1.0 - clip01({opening}))) - ({loadCoupling}) * ({loadPressure}) * 0.001) : + ~ *(({velocityDecay}) / (1.0 + 10.0 * ({effectiveStiffness}) + 0.4 * ({saturation}))))"; + var displacement = $"(({velocity}) : + ~ *(({displacementDecay}) / (1.0 + 1.5 * ({effectiveStiffness}))))"; + var aperture = $"max(0.0, ({restOpening}) + ({opening}) + ({displacement}) - ({saturation}) * pow(({displacement}), 3.0))"; + var turbulence = $"(no.noise : fi.highpass(2, 1200.0 + 2800.0 * clip01(1.0 - ({aperture}))))"; + return $"(ma.tanh(({pressureDrive}) * ({aperture}) * (2.0 + 10.0 * ({drive}))) + ({turbulence}) * ({noise}) * ({pressureDrive}) * clip01({aperture}) * (1.0 - 0.55 * ({tension}))) * ({balance})"; + } + private static string GlottalSourceExpression( string phase, string tension, @@ -1463,10 +1511,61 @@ public static class FaustEmitter var prefix = $"{voiceName}_graph_source_{safeSource}_{safeNode}"; var localPressure = $"{voiceName}_graph_node_incident_pressure_{node.Name}"; source.AppendLine($" {prefix}_load_pressure = {localPressure};"); - source.AppendLine($" {prefix}_out = {AcousticSourceExpression(patch, port, frequency, parameters, localPressure)};"); + if (IsTissueValveSource(port)) + { + EmitGraphTissueValveSourceExpression(source, patch, voiceName, node, sourceName, port, frequency, parameters, localPressure); + } + else + { + source.AppendLine($" {prefix}_out = {AcousticSourceExpression(patch, port, frequency, parameters, localPressure)};"); + } return $"{prefix}_out"; } + private static void EmitGraphTissueValveSourceExpression( + StringBuilder source, + SynthPatch patch, + string voiceName, + AcousticGraphNode node, + string sourceName, + AcousticSourcePort port, + string frequency, + ParameterMap parameters, + string localPressure) + { + var index = AcousticSourcePortIndex(patch, port); + var path = $"/acoustic/sources/{index}"; + var safeSource = SafeIdentifier(sourceName); + var safeNode = SafeIdentifier(node.Name); + var prefix = $"{voiceName}_graph_source_{safeSource}_{safeNode}"; + var pressure = $"clip01({parameters.Expression(OwnerField(path, "pressure"), port.Pressure)})"; + var tension = $"clip01({parameters.Expression(OwnerField(path, "tension"), port.Tension)})"; + var opening = $"max(0.0, {parameters.Expression(OwnerField(path, "opening"), port.Opening)})"; + var noise = $"clip01({parameters.Expression(OwnerField(path, "noise"), port.Noise)})"; + var balance = $"({parameters.Expression(OwnerField(path, "balance"), port.Balance)}) * ({SourcePositionWeightExpression(port, path, parameters)})"; + var mass = $"max(0.02, {parameters.Expression(OwnerField(path, "mass"), port.Mass)})"; + var damping = $"max(0.0, {parameters.Expression(OwnerField(path, "damping"), port.Damping)})"; + var stiffness = $"max(0.0, {parameters.Expression(OwnerField(path, "stiffness"), port.Stiffness)})"; + var saturation = $"max(0.0, {parameters.Expression(OwnerField(path, "saturation"), port.Saturation)})"; + var drive = $"max(0.0, {parameters.Expression(OwnerField(path, "drive"), port.Drive)})"; + var loadCoupling = $"max(0.0, {parameters.Expression(OwnerField(path, "load_coupling"), port.LoadCoupling)})"; + var restOpening = $"max(0.0, {parameters.Expression(OwnerField(path, "rest_opening"), port.RestOpening)})"; + + source.AppendLine($" {prefix}_stiffness_hint = max(0.00002, min(0.16, pow(2.0 * ma.PI * max(20.0, {frequency}) / ma.SR, 2.0)));"); + source.AppendLine($" {prefix}_stiffness = max(({stiffness}), {prefix}_stiffness_hint * (0.35 + 1.65 * ({tension})));"); + source.AppendLine($" {prefix}_pressure_drive = max(0.0, ({pressure}) * ({drive}) - ({loadCoupling}) * {prefix}_load_pressure);"); + source.AppendLine($" {prefix}_velocity_decay = min(0.9995, max(0.20, 1.0 - ((0.008 + 0.08 * ({damping})) / ({mass}))));"); + source.AppendLine($" {prefix}_displacement_decay = min(0.9998, max(0.20, 1.0 - 0.0008 / ({mass})));"); + source.AppendLine($" {prefix}_force = ({prefix}_pressure_drive * (0.0004 + 0.0024 * (1.0 - clip01({opening}))) - ({loadCoupling}) * {prefix}_load_pressure * 0.001) / ({mass});"); + source.AppendLine($" {prefix}_velocity = {prefix}_force : + ~ *({prefix}_velocity_decay / (1.0 + 10.0 * {prefix}_stiffness + 0.4 * ({saturation})));"); + source.AppendLine($" {prefix}_displacement = {prefix}_velocity : + ~ *({prefix}_displacement_decay / (1.0 + 1.5 * {prefix}_stiffness));"); + source.AppendLine($" {prefix}_aperture = max(0.0, ({restOpening}) + ({opening}) + {prefix}_displacement - ({saturation}) * pow({prefix}_displacement, 3.0));"); + source.AppendLine($" {prefix}_turbulence = no.noise : fi.highpass(2, 1200.0 + 2800.0 * clip01(1.0 - {prefix}_aperture));"); + source.AppendLine($" {prefix}_flow = ma.tanh({prefix}_pressure_drive * {prefix}_aperture * (2.0 + 10.0 * ({drive})));"); + source.AppendLine($" {prefix}_noise = {prefix}_turbulence * ({noise}) * {prefix}_pressure_drive * clip01({prefix}_aperture) * (1.0 - 0.55 * ({tension}));"); + source.AppendLine($" {prefix}_out = ({prefix}_flow + {prefix}_noise) * ({balance});"); + } + private static string EmitGraphContactSourceExpression( StringBuilder source, string voiceName, diff --git a/src/AquaSynth.Core/Model.cs b/src/AquaSynth.Core/Model.cs index c05db1d..cb7ff9f 100644 --- a/src/AquaSynth.Core/Model.cs +++ b/src/AquaSynth.Core/Model.cs @@ -233,6 +233,13 @@ public enum AcousticSourceKind Synthetic } +public enum AcousticSourceModel +{ + Default, + Legacy, + TissueValve +} + public enum AcousticBranchKind { SideBranch, @@ -312,7 +319,15 @@ public sealed record AcousticSourcePort( float Balance = 1, bool Active = true, AcousticSourcePositionControl? PositionControl = null, - float Impedance = 0.35f); + float Impedance = 0.35f, + AcousticSourceModel Model = AcousticSourceModel.Default, + float Mass = 0.35f, + float Damping = 0.18f, + float Stiffness = 0, + float Saturation = 0.8f, + float Drive = 1, + float LoadCoupling = 0.35f, + float RestOpening = 0.02f); public sealed record AcousticSourcePositionControl( float Index = 0, diff --git a/src/AquaSynth.Core/PatchScript.cs b/src/AquaSynth.Core/PatchScript.cs index 31491ca..8a9f74a 100644 --- a/src/AquaSynth.Core/PatchScript.cs +++ b/src/AquaSynth.Core/PatchScript.cs @@ -564,11 +564,13 @@ public static class PatchScript var path = Required(fields, "path", line); RequireAcousticPath(path, line); + var kind = ParseAcousticSourceKind(GetAny(fields, ["kind", "source_kind"], "glottal"), line); + var model = ParseAcousticSourceModel(GetAny(fields, ["model", "source_model"], ""), kind, line); var port = new AcousticSourcePort( name, path, GetBoundFloat(fields, line, 0, $"/acoustic/sources/{_acousticSourcePorts.Count}/position", "position", "pos", "at"), - ParseAcousticSourceKind(GetAny(fields, ["kind", "source_kind"], "glottal"), line), + kind, GetBoundFloat(fields, line, 0.72f, $"/acoustic/sources/{_acousticSourcePorts.Count}/pressure", "pressure", "intensity"), GetBoundFloat(fields, line, 0.6f, $"/acoustic/sources/{_acousticSourcePorts.Count}/tension", "tension", "tenseness", "tense"), GetBoundFloat(fields, line, 0.5f, $"/acoustic/sources/{_acousticSourcePorts.Count}/opening", "opening", "open"), @@ -577,7 +579,15 @@ public static class PatchScript GetBoundFloat(fields, line, 1, $"/acoustic/sources/{_acousticSourcePorts.Count}/balance", "balance"), TryGetAny(fields, ["active"], out var active) ? ParseBool(active, line) : true, ParseAcousticSourcePositionControl(fields, line, _acousticSourcePorts.Count), - GetBoundFloat(fields, line, 0.35f, $"/acoustic/sources/{_acousticSourcePorts.Count}/impedance", "impedance", "load", "source_impedance")); + GetBoundFloat(fields, line, 0.35f, $"/acoustic/sources/{_acousticSourcePorts.Count}/impedance", "impedance", "load", "source_impedance"), + model, + GetBoundFloat(fields, line, 0.35f, $"/acoustic/sources/{_acousticSourcePorts.Count}/mass", "mass", "valve_mass"), + GetBoundFloat(fields, line, 0.18f, $"/acoustic/sources/{_acousticSourcePorts.Count}/damping", "damping", "damp"), + GetBoundFloat(fields, line, 0, $"/acoustic/sources/{_acousticSourcePorts.Count}/stiffness", "stiffness", "spring"), + GetBoundFloat(fields, line, 0.8f, $"/acoustic/sources/{_acousticSourcePorts.Count}/saturation", "saturation", "sat"), + GetBoundFloat(fields, line, 1, $"/acoustic/sources/{_acousticSourcePorts.Count}/drive", "drive", "flow_gain"), + GetBoundFloat(fields, line, 0.35f, $"/acoustic/sources/{_acousticSourcePorts.Count}/load_coupling", "load_coupling", "load_feedback"), + GetBoundFloat(fields, line, 0.02f, $"/acoustic/sources/{_acousticSourcePorts.Count}/rest_opening", "rest_opening", "rest_aperture")); AddAcousticSourcePortRecord(port); AddAcousticTerminalRecord(new AcousticTerminal( port.Name, @@ -2610,6 +2620,22 @@ public static class PatchScript _ => throw new PatchScriptException(line, $"unknown acoustic source kind `{value}`") }; + private static AcousticSourceModel ParseAcousticSourceModel(string value, AcousticSourceKind kind, int line) + { + if (string.IsNullOrWhiteSpace(value)) + { + return kind == AcousticSourceKind.Labial ? AcousticSourceModel.TissueValve : AcousticSourceModel.Default; + } + + return value.ToLowerInvariant() switch + { + "default" or "auto" => kind == AcousticSourceKind.Labial ? AcousticSourceModel.TissueValve : AcousticSourceModel.Default, + "legacy" or "proxy" => AcousticSourceModel.Legacy, + "tissue_valve" or "tissue-valve" or "valve" or "labial_oscillator" or "labial-oscillator" => AcousticSourceModel.TissueValve, + _ => throw new PatchScriptException(line, $"unknown acoustic source model `{value}`") + }; + } + private static AcousticBranchKind ParseAcousticBranchKind(string value, int line) => value.ToLowerInvariant() switch { "side" or "side_branch" => AcousticBranchKind.SideBranch, diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 7a95fb1..2120eec 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -206,3 +206,4 @@ {"evidence":"Added `tools/BirdSyrinxGolf`, a CC birdcall sidequest tool that downloads Commons/xeno-canto mirrored bird recordings, extracts loudest 1.2 second clips, renders graph-native paired-labium syrinx candidates through Faust, and writes reference/candidate WAVs plus reports under `artifacts/bird-syrinx-golf/`. First run `20260528T141443104` produced candidate WAVs for Common Iora, Bohemian Waxwing, California Quail, and American Crow with log-mel cosine similarities from 0.2356 to 0.3654.","claim":"The syrinx graph now has a real bird-audio fitting surface, but static coarse candidates are not enough for species-like calls. Next improvement should add time-varying pressure, labial tension, left/right gating, beak opening, and morphology curves over the same graph rather than a larger static brute-force grid.","timestamp":"2026-05-28T15:20:00+01:00","topic":"bird-syrinx-golf"} {"evidence":"Replaced noisy Bohemian Waxwing, California Quail, and American Crow bird-golf references with cleaner Commons files: Common Blackbird (CC BY 4.0), Red-footed Falcon (CC BY 3.0), Warbling White-eye (KOGL Type 1), and Eurasian Wren (KOGL Type 1), keeping Common Iora. Run `artifacts/bird-syrinx-golf/20260528T143401133` produced reference/candidate WAVs for all five clean sources with log-mel cosine similarities from 0.2081 to 0.3692.","claim":"Bird reference quality now better isolates syrinx model failures from background-noise contamination. The tool should describe the corpus as open-licensed, not CC-only, because the Korean clean clips are KOGL Type 1.","timestamp":"2026-05-28T15:41:00+01:00","topic":"bird-syrinx-clean-reference-set"} {"evidence":"Created `research/syrinx-modeling` with downloaded PDFs/HTML snapshots and a provenance ledger for public syrinx modeling work: Fee nonlinear dynamics, Laje/Gardner/Mindlin pressure-tension oscillator models, source-source/source-tract coupling papers, Elemans mechanical validation, prosthetic/neurally driven synthesis, and recent nonlinear review material. The distilled summary maps the literature to Aqua ownership: a generalized tissue-valve/labial oscillator source coupled to the acoustic graph, with pitch derived from pressure/tension/aperture/load dynamics instead of owned by `freq=`.","claim":"The next coherent syrinx build is not bigger static golf. It is a reusable graph-coupled nonlinear labial/tissue-valve oscillator with time-varying pressure, tension, opening, bilateral gating, and radiation controls, scored by pitch/envelope/onset/subharmonic structure plus log-mel.","timestamp":"2026-05-28T15:59:00+01:00","topic":"syrinx-modeling-research"} +{"evidence":"Added `AcousticSourcePort.Model=TissueValve` plus mass, damping, stiffness, saturation, drive, load-coupling, and rest-opening fields. `source_port model=tissue_valve` parses explicitly, while `kind=syrinx`/`kind=labial` alias onto the same primitive. Faust graph lowering now emits named pressure-drive, force, velocity, displacement, aperture, load-pressure, flow, and noise locals for tissue-valve sources, and focused tests prove labial/syrinx graph sources no longer emit `os.phasor(... freq ...)` as their source. `patches/advanced/bird-syrinx.aqua` exposes the valve fields; `BirdSyrinxGolf` now extracts reference features and maps them into valve candidates.","claim":"Graph vocal source pitch is no longer owned by fixed frequency for syrinx/labial sources. It is derived from pressure, tension, aperture, mass, damping, stiffness, saturation, drive, and graph incident pressure/load, with `freq=` demoted to a stiffness initializer/hint.","timestamp":"2026-05-28T16:28:12+01:00","topic":"tissue-valve-source"} diff --git a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs index c5a284c..8a1fcd3 100644 --- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs @@ -158,11 +158,17 @@ public sealed class PatchScriptTests var patch = PatchScript.Parse(""" param path=/voice/left_pressure default=.7 min=0 max=1 step=.001 param path=/voice/source_load default=.65 min=0 max=2 step=.001 + param path=/voice/left_mass default=.32 min=.02 max=2 step=.001 + param path=/voice/left_damping default=.16 min=0 max=2 step=.001 + param path=/voice/left_stiffness default=.03 min=0 max=1 step=.001 + param path=/voice/left_drive default=1.2 min=0 max=4 step=.001 + param path=/voice/left_load_coupling default=.44 min=0 max=2 step=.001 + param path=/voice/left_rest_opening default=.03 min=0 max=1 step=.001 param path=/voice/throat_opening default=.8 min=0 max=1 step=.001 param path=/voice/mouth_opening default=1.2 min=0 max=2 step=.001 path name=trachea length_cm=12 diameters=.4,.7,1,1 path name=oral length_cm=17 diameters=.6,1.1,1.6,1.2,.8 - source_port name=left_labium path=trachea kind=labial position=0 pressure=@/voice/left_pressure tension=.55 opening=.4 noise=.02 impedance=@/voice/source_load + source_port name=left_labium path=trachea model=tissue_valve position=0 pressure=@/voice/left_pressure tension=.55 opening=.4 noise=.02 impedance=@/voice/source_load mass=@/voice/left_mass damping=@/voice/left_damping stiffness=@/voice/left_stiffness saturation=.9 drive=@/voice/left_drive load_coupling=@/voice/left_load_coupling rest_opening=@/voice/left_rest_opening source_port name=right_labium path=trachea kind=labial position=0 pressure=.65 tension=.5 opening=.45 noise=.03 balance=.8 position_index=.12 position_width=.05 position_index_scale=1 branch name=throat from_path=trachea from_position=.9 to_path=oral kind=bronchial opening=@/voice/throat_opening coupling=.7 radiation_port name=mouth path=oral kind=lip position=1 opening=@/voice/mouth_opening reflection=-.82 @@ -186,13 +192,22 @@ public sealed class PatchScriptTests Assert.Equal(2, patch.AcousticPaths.Count); Assert.Equal(5, patch.AcousticTerminals.Count); Assert.Single(patch.AcousticConnections); - Assert.All(patch.AcousticSourcePorts, port => Assert.Equal(AcousticSourceKind.Labial, port.Kind)); + Assert.Equal(AcousticSourceKind.Glottal, patch.AcousticSourcePorts[0].Kind); + Assert.Equal(AcousticSourceKind.Labial, patch.AcousticSourcePorts[1].Kind); + Assert.All(patch.AcousticSourcePorts, port => Assert.Equal(AcousticSourceModel.TissueValve, port.Model)); + Assert.Equal(.9f, patch.AcousticSourcePorts[0].Saturation, 5); Assert.NotNull(patch.AcousticSourcePorts[1].PositionControl); Assert.Equal(AcousticBranchKind.Bronchial, Assert.Single(patch.AcousticBranches).Kind); Assert.Equal(AcousticRadiationKind.Lip, Assert.Single(patch.AcousticRadiationPorts).Kind); Assert.Equal(WaveClockDelayStrategy.FractionalThiran, Assert.Single(patch.WaveClocks).Strategy); Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/sources/0/pressure"); Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/sources/0/impedance"); + Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/sources/0/mass"); + Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/sources/0/damping"); + Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/sources/0/stiffness"); + Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/sources/0/drive"); + Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/sources/0/load_coupling"); + Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/sources/0/rest_opening"); Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/branches/0/opening"); Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/radiation/0/opening"); @@ -209,6 +224,11 @@ public sealed class PatchScriptTests Assert.Contains("graph_node_area_left_labium_right_labium", export.Source); Assert.Contains("graph_source_right_labium", export.Source); Assert.Contains("_load_pressure", export.Source); + Assert.Contains("_pressure_drive", export.Source); + Assert.Contains("_velocity", export.Source); + Assert.Contains("_displacement", export.Source); + Assert.Contains("_aperture", export.Source); + Assert.DoesNotContain("os.phasor(1.0, syrinxish_freq", export.Source); Assert.Contains("de.fdelay1a", export.Source); Assert.Contains("patch_param_0", export.Source); Assert.Contains("patch_param_1", export.Source); @@ -243,6 +263,7 @@ public sealed class PatchScriptTests Assert.Equal(3, patch.AcousticPaths.Count); Assert.Equal(2, patch.AcousticSourcePorts.Count); Assert.All(patch.AcousticSourcePorts, port => Assert.Equal(AcousticSourceKind.Labial, port.Kind)); + Assert.All(patch.AcousticSourcePorts, port => Assert.Equal(AcousticSourceModel.TissueValve, port.Model)); Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/sources/0/impedance"); Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/sources/1/impedance"); Assert.Equal(AcousticRadiationKind.Beak, Assert.Single(patch.AcousticRadiationPorts).Kind); @@ -254,8 +275,13 @@ public sealed class PatchScriptTests Assert.Contains("graph_source_left_labium", export.Source); Assert.Contains("graph_source_right_labium", export.Source); Assert.Contains("_load_pressure", export.Source); + Assert.Contains("_pressure_drive", export.Source); + Assert.Contains("_velocity", export.Source); + Assert.Contains("_displacement", export.Source); + Assert.Contains("_aperture", export.Source); Assert.Contains("graph_radiation_flow_beak", export.Source); Assert.Contains("patch_param_0", export.Source); + Assert.DoesNotContain("os.phasor(1.0, bird_syrinx_freq", export.Source); Assert.DoesNotContain("Tract", export.Source); } @@ -1364,6 +1390,37 @@ public sealed class PatchScriptTests Assert.True(validation.Success, validation.Stderr); } + [Fact] + public async Task FaustCompilerRendersTissueValveGraphWhenInstalled() + { + var export = FaustEmitter.EmitScript(""" + path name=left_bronchus length_cm=3.8 diameters=.22,.30,.36,.42 + path name=right_bronchus length_cm=3.6 diameters=.20,.28,.34,.40 + path name=trachea length_cm=8.4 diameters=.38,.48,.56,.46 + source_port name=left_labium path=left_bronchus kind=syrinx position=0 pressure=.82 tension=.44 opening=.22 noise=.015 impedance=.7 mass=.32 damping=.14 stiffness=.025 saturation=.9 drive=1.2 load_coupling=.42 rest_opening=.025 + source_port name=right_labium path=right_bronchus kind=syrinx position=0 pressure=.70 tension=.51 opening=.20 noise=.015 balance=.96 impedance=.7 mass=.34 damping=.16 stiffness=.03 saturation=.9 drive=1.1 load_coupling=.42 rest_opening=.025 + terminal name=left_merge path=left_bronchus position=1 kind=junction area_scale=1 + terminal name=right_merge path=right_bronchus position=1 kind=junction area_scale=1 + terminal name=trachea_base path=trachea position=0 kind=junction area_scale=1 + connect name=syrinx_merge terminals=left_merge,right_merge,trachea_base law=area_scatter coupling=1 + radiation_port name=beak path=trachea kind=beak position=1 opening=.95 reflection=-.72 + wave_clock name=bird_clock strategy=linear max_delay=1024 smoothing_ms=2 + acoustic_network name=bird_syrinx path=trachea wave_clock=bird_clock sources=left_labium,right_labium radiation=beak terminals=left_merge,right_merge,trachea_base connections=syrinx_merge + acoustic network=bird_syrinx freq=1120 gain=.32 sustain=.18 decay=.04 + """, new FaustExportOptions("tissue_valve_render_validation")); + var render = await FaustCompiler.RenderAsync(export.Source, new FaustRenderOptions(DurationSeconds: .18f)); + + if (render is null) + { + return; + } + + Assert.True(render.Samples.Length > 0, render.Stderr); + Assert.All(render.Samples, sample => Assert.True(float.IsFinite(sample), "tissue-valve render produced a non-finite sample")); + Assert.True(render.Samples.Select(MathF.Abs).DefaultIfEmpty(0).Max() > 0.000001f, render.Stderr); + Assert.True(render.Samples.Select(MathF.Abs).DefaultIfEmpty(0).Max() <= 1.2f, render.Stderr); + } + [Fact] public async Task FaustCompilerValidatesSpectralBankWhenInstalled() { diff --git a/tools/BirdSyrinxGolf/Program.cs b/tools/BirdSyrinxGolf/Program.cs index 6ba6e35..cf7f544 100644 --- a/tools/BirdSyrinxGolf/Program.cs +++ b/tools/BirdSyrinxGolf/Program.cs @@ -106,12 +106,14 @@ foreach (var source in sources) var target = LoudestWindow(decoded.Samples, decoded.SampleRate, 1.2f); var target44 = Resample(target, decoded.SampleRate, 44100); NormalizePeak(target44, 0.9f); + var referenceFeatures = ExtractReferenceFeatures(target44, 44100); WriteWav(Path.Combine(sourceDir, "reference-clip.wav"), target44, 44100); File.WriteAllText(Path.Combine(sourceDir, "source.json"), JsonSerializer.Serialize(new SourceSnapshot(source, downloadedFrom), jsonOptions)); + File.WriteAllText(Path.Combine(sourceDir, "reference-features.json"), JsonSerializer.Serialize(referenceFeatures, jsonOptions)); var best = default(CandidateResult?); var candidateIndex = 0; - foreach (var candidate in CandidateGrid(source)) + foreach (var candidate in CandidateGrid(source, referenceFeatures)) { candidateIndex++; var script = SyrinxScript(candidate, target44.Length / 44100f); @@ -138,17 +140,19 @@ foreach (var source in sources) if (best is null) { - rows.Add(new ResultRow(source.Id, source.CommonName, "no-render", 0, 0, 0, 0, "")); + rows.Add(new ResultRow(source.Id, source.CommonName, "no-render", referenceFeatures.DominantHz, referenceFeatures.ActiveDuty, 0, 0, 0, 0, "")); continue; } WriteWav(Path.Combine(sourceDir, "candidate-syrinx.wav"), best.Samples, 44100); File.WriteAllText(Path.Combine(sourceDir, "candidate-syrinx.aqua"), best.Script); - File.WriteAllText(Path.Combine(sourceDir, "report.txt"), Report(source, downloadedFrom, best)); + File.WriteAllText(Path.Combine(sourceDir, "report.txt"), Report(source, downloadedFrom, referenceFeatures, best)); rows.Add(new ResultRow( source.Id, source.CommonName, source.CallType, + referenceFeatures.DominantHz, + referenceFeatures.ActiveDuty, best.Comparison.Score, best.Comparison.LogMelCosineSimilarity, best.Comparison.LogMelDistance, @@ -160,33 +164,59 @@ File.WriteAllText(Path.Combine(runRoot, "summary.md"), Summary(rows)); Console.WriteLine(runRoot); return 0; -static IEnumerable CandidateGrid(BirdSource source) +static IEnumerable CandidateGrid(BirdSource source, ReferenceFeatures features) { - var bases = source.Kind switch + var fallbackBases = source.Kind switch { BirdKind.HarmonicSong => new[] { 750f, 1050f, 1400f }, BirdKind.HighCall => new[] { 1400f, 2200f, 3000f }, BirdKind.LowCall => new[] { 260f, 480f, 760f }, _ => new[] { 800f, 1300f, 2100f } }; - var tensions = source.Kind == BirdKind.LowCall ? new[] { 0.28f, 0.5f } : new[] { 0.38f, 0.62f }; - var openings = source.Kind == BirdKind.HighCall ? new[] { 0.14f, 0.28f } : new[] { 0.20f, 0.38f }; - var loads = new[] { 0.85f }; - - foreach (var frequency in bases) - foreach (var tension in tensions) - foreach (var opening in openings) - foreach (var load in loads) + var featureBase = features.DominantHz is > 120 and < 6000 + ? features.DominantHz + : fallbackBases[1]; + var bases = new[] { + Math.Clamp(featureBase * 0.78f, 120f, 5000f), + Math.Clamp(featureBase, 120f, 5000f), + Math.Clamp(featureBase * 1.24f, 120f, 5000f) + }; + var active = Math.Clamp(features.ActiveDuty, 0.05f, 1f); + var flux = Math.Clamp(features.SpectralFlux, 0f, 1f); + var basePressure = Math.Clamp(0.58f + active * 0.28f + flux * 0.10f, 0.45f, 0.95f); + var baseOpening = source.Kind == BirdKind.HighCall + ? Math.Clamp(0.10f + features.Rms * 1.5f, 0.08f, 0.28f) + : Math.Clamp(0.16f + features.Rms * 1.7f, 0.12f, 0.42f); + var loads = new[] { 0.65f, 0.9f }; + var leftGates = new[] { 1.0f, 0.72f }; + foreach (var frequency in bases) + foreach (var load in loads) + foreach (var leftGate in leftGates) + { + var tension = Math.Clamp(0.18f + frequency / 5200f + flux * 0.12f, 0.18f, 0.88f); + var stiffness = Math.Clamp(MathF.Pow(frequency / 3200f, 2) * 0.09f, 0.004f, 0.16f); + var mass = Math.Clamp(0.48f - frequency / 9000f, 0.10f, 0.46f); + var damping = Math.Clamp(0.08f + (1f - active) * 0.16f + flux * 0.08f, 0.06f, 0.38f); yield return new SyrinxCandidate( frequency, - Pressure: source.Kind == BirdKind.LowCall ? 0.74f : 0.82f, - RightPressure: source.Kind == BirdKind.LowCall ? 0.62f : 0.70f, + Pressure: basePressure * leftGate, + RightPressure: Math.Clamp(basePressure * (0.82f + flux * 0.18f), 0.35f, 0.95f), Tension: tension, RightTension: Math.Clamp(tension + 0.07f, 0, 1), - Opening: opening, - RightOpening: Math.Max(0.05f, opening * 0.88f), + Opening: baseOpening, + RightOpening: Math.Max(0.04f, baseOpening * (0.82f + flux * 0.18f)), Load: load, + Mass: mass, + RightMass: Math.Clamp(mass * 1.06f, 0.08f, 0.6f), + Damping: damping, + RightDamping: Math.Clamp(damping * 1.08f, 0.04f, 0.5f), + Stiffness: stiffness, + RightStiffness: Math.Clamp(stiffness * 1.12f, 0.002f, 0.2f), + Drive: Math.Clamp(0.9f + features.Peak * 0.45f, 0.7f, 1.5f), + RightDrive: Math.Clamp(0.82f + features.Peak * 0.40f, 0.65f, 1.4f), + LoadCoupling: Math.Clamp(0.28f + load * 0.18f, 0.2f, 0.7f), + RestOpening: Math.Clamp(baseOpening * 0.10f, 0.01f, 0.08f), BeakOpening: source.Kind == BirdKind.LowCall ? 0.75f : 0.95f, Gain: source.Kind == BirdKind.LowCall ? 0.42f : 0.32f); } @@ -200,8 +230,8 @@ static string SyrinxScript(SyrinxCandidate candidate, float durationSeconds) => path name=right_bronchus length_cm=3.6 diameters=.20,.28,.34,.40 path name=trachea length_cm=8.4 diameters=.38,.48,.56,.46 - source_port name=left_labium path=left_bronchus kind=syrinx position=0 pressure={{F(candidate.Pressure)}} tension={{F(candidate.Tension)}} opening={{F(candidate.Opening)}} noise=.025 impedance={{F(candidate.Load)}} - source_port name=right_labium path=right_bronchus kind=syrinx position=0 pressure={{F(candidate.RightPressure)}} tension={{F(candidate.RightTension)}} opening={{F(candidate.RightOpening)}} noise=.02 balance=.96 impedance={{F(candidate.Load)}} + source_port name=left_labium path=left_bronchus kind=syrinx model=tissue_valve position=0 pressure={{F(candidate.Pressure)}} tension={{F(candidate.Tension)}} opening={{F(candidate.Opening)}} noise=.025 impedance={{F(candidate.Load)}} mass={{F(candidate.Mass)}} damping={{F(candidate.Damping)}} stiffness={{F(candidate.Stiffness)}} saturation=.9 drive={{F(candidate.Drive)}} load_coupling={{F(candidate.LoadCoupling)}} rest_opening={{F(candidate.RestOpening)}} + source_port name=right_labium path=right_bronchus kind=syrinx model=tissue_valve position=0 pressure={{F(candidate.RightPressure)}} tension={{F(candidate.RightTension)}} opening={{F(candidate.RightOpening)}} noise=.02 balance=.96 impedance={{F(candidate.Load)}} mass={{F(candidate.RightMass)}} damping={{F(candidate.RightDamping)}} stiffness={{F(candidate.RightStiffness)}} saturation=.9 drive={{F(candidate.RightDrive)}} load_coupling={{F(candidate.LoadCoupling)}} rest_opening={{F(candidate.RestOpening)}} terminal name=left_merge path=left_bronchus position=1 kind=junction area_scale=1 terminal name=right_merge path=right_bronchus position=1 kind=junction area_scale=1 @@ -214,7 +244,7 @@ static string SyrinxScript(SyrinxCandidate candidate, float durationSeconds) => acoustic network=bird_syrinx freq={{F(candidate.Frequency)}} gain={{F(candidate.Gain)}} sustain={{F(Math.Max(0.05f, durationSeconds - 0.12f))}} decay=.08 """; -static string Report(BirdSource source, string downloadedFrom, CandidateResult best) => +static string Report(BirdSource source, string downloadedFrom, ReferenceFeatures features, CandidateResult best) => $""" # {source.CommonName} ({source.ScientificName}) @@ -224,6 +254,15 @@ static string Report(BirdSource source, string downloadedFrom, CandidateResult b Author: {source.Author} Call type: {source.CallType} + Reference features: + rms={features.Rms:0.0000} + peak={features.Peak:0.0000} + activeDuty={features.ActiveDuty:0.0000} + onsetSeconds={features.OnsetSeconds:0.0000} + offsetSeconds={features.OffsetSeconds:0.0000} + dominantHz={features.DominantHz:0.0} + spectralFlux={features.SpectralFlux:0.0000} + Best candidate: {best.Candidate} @@ -235,7 +274,7 @@ static string Report(BirdSource source, string downloadedFrom, CandidateResult b centroidRatio={best.Comparison.CentroidRatio:0.0000} articulation={best.Comparison.Articulation.ArticulationScore:0.0000} - This is a first graph-native syrinx golf pass, not an accepted species model. + This is a feature-informed graph-native syrinx golf pass, not an accepted species model. Gesture curves are currently compressed into source parameters because Aqua does not yet have general arbitrary-path control automation. """; static string Summary(IEnumerable rows) @@ -243,17 +282,75 @@ static string Summary(IEnumerable rows) var builder = new StringBuilder(); builder.AppendLine("# Bird Syrinx Golf"); builder.AppendLine(); - builder.AppendLine("All references are open-licensed bird recordings from Wikimedia Commons. Candidates are graph-native Aqua syrinx patches using paired labial source ports."); + builder.AppendLine("All references are open-licensed bird recordings from Wikimedia Commons. Candidates are graph-native Aqua syrinx patches using paired tissue-valve source ports."); builder.AppendLine(); - builder.AppendLine("| id | bird | call | score | logMelCosine | logMelDistance | centroidRatio | candidate |"); - builder.AppendLine("| --- | --- | --- | ---: | ---: | ---: | ---: | --- |"); + builder.AppendLine("| id | bird | call | dominantHz | activeDuty | score | logMelCosine | logMelDistance | centroidRatio | candidate |"); + builder.AppendLine("| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |"); foreach (var row in rows) { - builder.AppendLine(CultureInfo.InvariantCulture, $"| {row.Id} | {row.Bird} | {row.Call} | {row.Score:0.0000} | {row.LogMelCosine:0.0000} | {row.LogMelDistance:0.0000} | {row.CentroidRatio:0.0000} | `{row.Candidate}` |"); + builder.AppendLine(CultureInfo.InvariantCulture, $"| {row.Id} | {row.Bird} | {row.Call} | {row.DominantHz:0.0} | {row.ActiveDuty:0.0000} | {row.Score:0.0000} | {row.LogMelCosine:0.0000} | {row.LogMelDistance:0.0000} | {row.CentroidRatio:0.0000} | `{row.Candidate}` |"); } return builder.ToString(); } +static ReferenceFeatures ExtractReferenceFeatures(IReadOnlyList samples, int sampleRate) +{ + var peak = samples.Select(MathF.Abs).DefaultIfEmpty(0).Max(); + var rms = MathF.Sqrt(samples.Select(sample => sample * sample).DefaultIfEmpty(0).Average()); + var frameSize = Math.Max(64, sampleRate / 100); + var threshold = Math.Max(0.02f, rms * 0.45f); + var frameRms = new List(); + for (var start = 0; start < samples.Count; start += frameSize) + { + var end = Math.Min(samples.Count, start + frameSize); + var sum = 0f; + for (var i = start; i < end; i++) sum += samples[i] * samples[i]; + frameRms.Add(MathF.Sqrt(sum / Math.Max(1, end - start))); + } + + var activeFrames = frameRms + .Select((value, index) => (value, index)) + .Where(frame => frame.value >= threshold) + .Select(frame => frame.index) + .ToArray(); + var activeDuty = frameRms.Count == 0 ? 0 : activeFrames.Length / (float)frameRms.Count; + var onset = activeFrames.Length == 0 ? 0 : activeFrames[0] * frameSize / (float)sampleRate; + var offset = activeFrames.Length == 0 ? samples.Count / (float)sampleRate : Math.Min(samples.Count, (activeFrames[^1] + 1) * frameSize) / (float)sampleRate; + var flux = 0f; + for (var i = 1; i < frameRms.Count; i++) flux += Math.Max(0, frameRms[i] - frameRms[i - 1]); + flux = frameRms.Count <= 1 || peak <= 0 ? 0 : Math.Clamp(flux / (frameRms.Count * peak), 0, 1); + + return new ReferenceFeatures(rms, peak, activeDuty, onset, offset, DominantFrequency(samples, sampleRate, activeFrames, frameSize), flux); +} + +static float DominantFrequency(IReadOnlyList samples, int sampleRate, IReadOnlyList activeFrames, int frameSize) +{ + if (samples.Count < 256) return 0; + var activeCenter = activeFrames.Count == 0 + ? samples.Count / 2 + : Math.Clamp((int)((activeFrames[activeFrames.Count / 2] + 0.5f) * frameSize), 0, samples.Count - 1); + var size = Math.Min(4096, samples.Count); + var start = Math.Clamp(activeCenter - size / 2, 0, Math.Max(0, samples.Count - size)); + var minLag = Math.Max(1, sampleRate / 5000); + var maxLag = Math.Min(size / 2, sampleRate / 120); + var bestLag = 0; + var best = 0f; + for (var lag = minLag; lag <= maxLag; lag++) + { + var sum = 0f; + for (var i = 0; i < size - lag; i++) + { + sum += samples[start + i] * samples[start + i + lag]; + } + if (sum > best) + { + best = sum; + bestLag = lag; + } + } + return bestLag == 0 ? 0 : sampleRate / (float)bestLag; +} + static DecodedAudio DecodeMono(string path, string sourceUrl) { if (sourceUrl.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase)) @@ -462,13 +559,23 @@ public sealed record SyrinxCandidate( float Opening, float RightOpening, float Load, + float Mass, + float RightMass, + float Damping, + float RightDamping, + float Stiffness, + float RightStiffness, + float Drive, + float RightDrive, + float LoadCoupling, + float RestOpening, float BeakOpening, float Gain) { public override string ToString() => string.Create( CultureInfo.InvariantCulture, - $"freq={Frequency:0.#} pressure={Pressure:0.###}/{RightPressure:0.###} tension={Tension:0.###}/{RightTension:0.###} opening={Opening:0.###}/{RightOpening:0.###} load={Load:0.###} beak={BeakOpening:0.###} gain={Gain:0.###}"); + $"freqHint={Frequency:0.#} pressure={Pressure:0.###}/{RightPressure:0.###} tension={Tension:0.###}/{RightTension:0.###} opening={Opening:0.###}/{RightOpening:0.###} mass={Mass:0.###}/{RightMass:0.###} damp={Damping:0.###}/{RightDamping:0.###} stiff={Stiffness:0.###}/{RightStiffness:0.###} drive={Drive:0.###}/{RightDrive:0.###} load={Load:0.###} loadCoupling={LoadCoupling:0.###} rest={RestOpening:0.###} beak={BeakOpening:0.###} gain={Gain:0.###}"); } public sealed record CandidateResult(SyrinxCandidate Candidate, float[] Samples, AudioComparison Comparison, string Script); @@ -477,6 +584,8 @@ public sealed record ResultRow( string Id, string Bird, string Call, + float DominantHz, + float ActiveDuty, float Score, float LogMelCosine, float LogMelDistance, @@ -484,3 +593,12 @@ public sealed record ResultRow( string Candidate); public sealed record DecodedAudio(int SampleRate, float[] Samples); + +public sealed record ReferenceFeatures( + float Rms, + float Peak, + float ActiveDuty, + float OnsetSeconds, + float OffsetSeconds, + float DominantHz, + float SpectralFlux);