Add realtime-drivable control curves

This commit is contained in:
Meta Vens 2026-05-28 19:18:20 +01:00
commit aa779473c4
9 changed files with 290 additions and 9 deletions

View file

@ -174,12 +174,18 @@ 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.
General control curves are a parameter-layer primitive. They target stable
`param path=` values, not random downstream fields, so authored gestures,
realtime host control, and learned controllers share one authority. Faust
lowering keeps the realtime hslider as the base value and emits a separate
curve-depth hslider, allowing the host to ride, reduce, or disable the authored
gesture without recompiling. Downstream field bindings still read the same
`patch_param_N` expression.
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.
candidate parameters plus pressure/opening/beak curves. This is still a first
gesture surface, not a full motor planner.
The current speech-learning lane adds a trainable surface above that boundary:
structured utterance metadata lowers into an utterance embedding, then into a

View file

@ -21,6 +21,12 @@ 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
curve name=left_pressure path=/bird/left/pressure points=0:.08,.035:.82,.32:.88,.78:.74,1.08:.38 depth=1
curve name=right_pressure path=/bird/right/pressure points=0:.04,.04:.68,.28:.74,.72:.62,1.08:.28 depth=1
curve name=left_opening path=/bird/left/opening points=0:.02,.03:.24,.76:.20,1.08:.04 depth=.85
curve name=right_opening path=/bird/right/opening points=0:.02,.035:.21,.70:.18,1.08:.04 depth=.85
curve name=beak_opening path=/bird/beak/opening points=0:.35,.06:.92,.82:1.05,1.08:.55 depth=.7
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

View file

@ -58,7 +58,7 @@ Deletion line: any future `kind=syrinx` lowering that cannot be explained as pre
## Recommended Next Build Slice
1. Add general arbitrary-path control curves for acoustic source/radiation fields, especially pressure, tension, opening, left/right balance, and beak opening.
1. Extend control curves from single-parameter authored gestures into reusable gesture sets that can be fitted, stored, and driven by learned controllers.
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.

View file

@ -111,10 +111,64 @@ public static class FaustEmitter
for (var i = 0; i < patch.Parameters.Count; i++)
{
var parameter = patch.Parameters[i];
source.AppendLine($"{ParameterIdentifier(i)} = hslider(\"{Escape(parameter.Path)}\", {F(parameter.Default)}, {F(parameter.Min)}, {F(parameter.Max)}, {F(parameter.Step)}) : si.smoo;");
var parameterId = ParameterIdentifier(i);
var curve = patch.ControlCurves.FirstOrDefault(candidate => candidate.ParameterPath.Equals(parameter.Path, StringComparison.OrdinalIgnoreCase));
if (curve is null)
{
source.AppendLine($"{parameterId} = hslider(\"{Escape(parameter.Path)}\", {F(parameter.Default)}, {F(parameter.Min)}, {F(parameter.Max)}, {F(parameter.Step)}) : si.smoo;");
continue;
}
var baseId = $"{parameterId}_base";
var curveId = $"{parameterId}_curve";
var depthId = $"{parameterId}_curve_depth";
source.AppendLine($"{baseId} = hslider(\"{Escape(parameter.Path)}\", {F(parameter.Default)}, {F(parameter.Min)}, {F(parameter.Max)}, {F(parameter.Step)}) : si.smoo;");
EmitControlCurve(source, curve, parameter, curveId);
source.AppendLine($"{depthId} = hslider(\"/curves/{Escape(curve.Name)}/depth\", {F(Math.Clamp(curve.Depth, 0, 1))}, 0, 1, 0.001) : si.smoo;");
var blended = curve.Mode == ControlCurveMode.Add
? $"{baseId} + ({curveId}_value - {F(parameter.Default)}) * {depthId}"
: $"{baseId} * (1.0 - {depthId}) + {curveId}_value * {depthId}";
source.AppendLine($"{parameterId} = min({F(parameter.Max)}, max({F(parameter.Min)}, {blended}));");
}
}
private static void EmitControlCurve(StringBuilder source, ControlCurve curve, PatchParameter parameter, string curveId)
{
var points = curve.Points
.OrderBy(point => point.TimeSeconds)
.ToArray();
var lastTime = Math.Max(points[^1].TimeSeconds, 0.0001f);
var scaledTime = $"max(0.0, age * {F(curve.TimeScale)} - {F(curve.TimeOffsetSeconds)})";
var curveTime = curve.Loop && points.Length > 1
? $"wrap01(({scaledTime}) / {F(lastTime)}) * {F(lastTime)}"
: scaledTime;
source.AppendLine($"{curveId}_time = {curveTime};");
source.AppendLine($"{curveId}_value = {ControlCurveValueExpression(curveId, points, curve.Interpolation, parameter.Default)};");
}
private static string ControlCurveValueExpression(
string curveId,
IReadOnlyList<ControlCurvePoint> points,
ControlCurveInterpolation interpolation,
float fallback)
{
if (points.Count == 0) return F(fallback);
if (points.Count == 1) return F(points[0].Value);
var expression = F(points[^1].Value);
for (var i = points.Count - 2; i >= 0; i--)
{
var from = points[i];
var to = points[i + 1];
var segment = interpolation == ControlCurveInterpolation.Hold
? F(from.Value)
: $"seg({curveId}_time, {F(from.TimeSeconds)}, {F(Math.Max(0.0001f, to.TimeSeconds - from.TimeSeconds))}, {F(from.Value)}, {F(to.Value)})";
expression = $"select2({curveId}_time < {F(to.TimeSeconds)}, {expression}, {segment})";
}
return expression;
}
private static string FaustOptions(Playback playback)
{
var voices = playback.Mode == PlaybackMode.Poly ? Math.Max(1, playback.Voices) : 1;

View file

@ -509,6 +509,35 @@ public sealed record PatchParameter(
string AutomationRate = "control",
string Notes = "");
public enum ControlCurveMode
{
Blend,
Add
}
public enum ControlCurveInterpolation
{
Linear,
Hold
}
public sealed record ControlCurvePoint(float TimeSeconds, float Value);
public sealed record ControlCurve(
string Name,
string ParameterPath,
IReadOnlyList<ControlCurvePoint> Points,
ControlCurveMode Mode = ControlCurveMode.Blend,
ControlCurveInterpolation Interpolation = ControlCurveInterpolation.Linear,
float Depth = 1,
float TimeScale = 1,
float TimeOffsetSeconds = 0,
bool Loop = false,
string AutomationRate = "control")
{
public IReadOnlyList<ControlCurvePoint> Points { get; init; } = Points ?? Array.Empty<ControlCurvePoint>();
}
public enum ParameterBindingTransform
{
Identity,
@ -696,6 +725,7 @@ public sealed record SynthPatch
public IReadOnlyList<OperatorGraph> OperatorGraphs { get; init; } = Array.Empty<OperatorGraph>();
public IReadOnlyList<ControlLane> Controls { get; init; } = Array.Empty<ControlLane>();
public IReadOnlyList<PatchParameter> Parameters { get; init; } = Array.Empty<PatchParameter>();
public IReadOnlyList<ControlCurve> ControlCurves { get; init; } = Array.Empty<ControlCurve>();
public IReadOnlyList<ParameterBinding> ParameterBindings { get; init; } = Array.Empty<ParameterBinding>();
public Playback Playback { get; init; } = new();
public Repeat? Repeat { get; init; }

View file

@ -32,6 +32,7 @@ public static class PatchScript
private readonly List<ControlLane> _controls = [];
private readonly List<OperatorGraph> _operatorGraphs = [];
private readonly List<PatchParameter> _parameters = [];
private readonly List<ControlCurve> _controlCurves = [];
private readonly List<ParameterBinding> _parameterBindings = [];
private readonly List<PatchLayer> _layers = [];
private readonly List<HarmonicBank> _harmonicBanks = [];
@ -99,6 +100,7 @@ public static class PatchScript
OperatorGraphs = _operatorGraphs,
Controls = _controls,
Parameters = _parameters,
ControlCurves = _controlCurves,
ParameterBindings = _parameterBindings,
Playback = Playback,
Repeat = Repeat,
@ -235,6 +237,10 @@ public static class PatchScript
FlushPendingOperatorGraph();
AddParameter(fields, line);
break;
case "curve":
FlushPendingOperatorGraph();
AddControlCurve(fields, line);
break;
case "sfxr":
FlushPendingOperatorGraph();
AddSfxrPatch(ParseSfxrCommand(fields, line));
@ -1764,6 +1770,39 @@ public static class PatchScript
GetAny(fields, ["notes", "note"], "")));
}
private void AddControlCurve(IReadOnlyDictionary<string, string> fields, int line)
{
var parameterPath = Required(fields, "path", line);
if (!parameterPath.StartsWith('/'))
{
throw new PatchScriptException(line, "control curve path must start with `/`");
}
if (_parameters.All(parameter => !parameter.Path.Equals(parameterPath, StringComparison.OrdinalIgnoreCase)))
{
throw new PatchScriptException(line, $"control curve targets unknown parameter `{parameterPath}`");
}
if (_controlCurves.Any(curve => curve.ParameterPath.Equals(parameterPath, StringComparison.OrdinalIgnoreCase)))
{
throw new PatchScriptException(line, $"duplicate control curve for `{parameterPath}`");
}
var name = TryGetAny(fields, ["name", "n"], out var nameText)
? nameText
: parameterPath.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? "curve";
var points = ParseControlCurvePoints(RequiredAny(fields, ["points", "pts", "values"], line), line);
_controlCurves.Add(new ControlCurve(
name,
parameterPath,
points,
ParseControlCurveMode(GetAny(fields, ["mode"], "blend"), line),
ParseControlCurveInterpolation(GetAny(fields, ["interp", "interpolation"], "linear"), line),
GetFloat(fields, line, 1, "depth", "amount", "mix"),
GetFloat(fields, line, 1, "time_scale", "speed"),
GetFloat(fields, line, 0, "offset", "time_offset"),
TryGetAny(fields, ["loop"], out var loop) && ParseBool(loop, line),
GetAny(fields, ["rate", "automation", "automation_rate"], "control")));
}
private float GetBoundFloat(
IReadOnlyDictionary<string, string> fields,
int line,
@ -2519,6 +2558,7 @@ public static class PatchScript
"mod" or "wob" or "wobble" or "bus" => "mod",
"lfo" or "control" => "control",
"param" or "parameter" => "param",
"curve" or "control_curve" or "automation" or "gesture" => "curve",
"s" or "sfxr" => "sfxr",
_ => command
};
@ -2694,6 +2734,55 @@ public static class PatchScript
? Array.Empty<string>()
: value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private static IReadOnlyList<ControlCurvePoint> ParseControlCurvePoints(string value, int line)
{
var points = value
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part =>
{
var pieces = part.Split(':', StringSplitOptions.TrimEntries);
if (pieces.Length != 2)
{
throw new PatchScriptException(line, $"bad control curve point `{part}`");
}
return new ControlCurvePoint(ParseFloat(pieces[0], line), ParseFloat(pieces[1], line));
})
.OrderBy(point => point.TimeSeconds)
.ToArray();
if (points.Length == 0)
{
throw new PatchScriptException(line, "control curve needs at least one point");
}
if (points[0].TimeSeconds < 0)
{
throw new PatchScriptException(line, "control curve times must be non-negative");
}
for (var i = 1; i < points.Length; i++)
{
if (Math.Abs(points[i].TimeSeconds - points[i - 1].TimeSeconds) < 0.000001f)
{
throw new PatchScriptException(line, "control curve point times must be unique");
}
}
return points;
}
private static ControlCurveMode ParseControlCurveMode(string value, int line) => value.ToLowerInvariant() switch
{
"blend" or "replace" or "absolute" => ControlCurveMode.Blend,
"add" or "offset" or "relative" => ControlCurveMode.Add,
_ => throw new PatchScriptException(line, $"unknown control curve mode `{value}`")
};
private static ControlCurveInterpolation ParseControlCurveInterpolation(string value, int line) => value.ToLowerInvariant() switch
{
"linear" or "lin" => ControlCurveInterpolation.Linear,
"hold" or "step" or "sample_hold" => ControlCurveInterpolation.Hold,
_ => throw new PatchScriptException(line, $"unknown control curve interpolation `{value}`")
};
private static string Required(IReadOnlyDictionary<string, string> fields, string key, int line) =>
fields.TryGetValue(key, out var value) ? value : throw new PatchScriptException(line, $"missing `{key}`");

View file

@ -207,3 +207,4 @@
{"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"}
{"evidence":"Added parameter-layer `ControlCurve` records, parser support through `curve`/`control_curve`/`gesture`, and Faust lowering that keeps each curved parameter's realtime hslider as a base signal while blending or adding an authored curve through a separate `/curves/<name>/depth` hslider. `patches/advanced/bird-syrinx.aqua` and `BirdSyrinxGolf` now express pressure, opening, and beak gestures as Aqua curves targeting normal parameter paths. Focused parser/export/Faust validation tests pass.","claim":"Control gestures are now realtime-drivable parameter semantics, not offline bird-golf substitutions. Authored curves, host controls, and future learned controllers share stable `param path=` authority and can reach acoustic source/radiation fields through the existing binding path.","timestamp":"2026-05-28T19:06:28+01:00","topic":"realtime-control-curves"}

View file

@ -568,6 +568,35 @@ public sealed class PatchScriptTests
Assert.Equal(.45f, patch.Voices[0].Filter.LowPass, 5);
}
[Fact]
public void ParserSupportsRealtimeBlendableControlCurvesOnParameters()
{
var patch = PatchScript.Parse("""
param path=/bird/left/pressure default=.2 min=0 max=1 step=.001 rate=audio
curve name=left_pressure path=/bird/left/pressure points=0:.05,.08:.9,.24:.35 mode=blend depth=.8 loop=true rate=audio
path name=left_bronchus length_cm=3.8 diameters=.22,.30,.36,.42
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 opening=.2 tension=.4
terminal name=left_merge path=left_bronchus position=1 kind=junction
terminal name=trachea_base path=trachea position=0 kind=junction
connect name=merge terminals=left_merge,trachea_base
radiation_port name=beak path=trachea kind=beak position=1 opening=.9
acoustic_network name=bird path=trachea sources=left_labium radiation=beak terminals=left_merge,trachea_base connections=merge
acoustic network=bird freq=1200
""");
var curve = Assert.Single(patch.ControlCurves);
Assert.Equal("left_pressure", curve.Name);
Assert.Equal("/bird/left/pressure", curve.ParameterPath);
Assert.Equal(ControlCurveMode.Blend, curve.Mode);
Assert.Equal(ControlCurveInterpolation.Linear, curve.Interpolation);
Assert.Equal(.8f, curve.Depth, 5);
Assert.True(curve.Loop);
Assert.Equal("audio", curve.AutomationRate);
Assert.Equal([0f, .08f, .24f], curve.Points.Select(point => point.TimeSeconds).ToArray());
Assert.Contains(patch.ParameterBindings, binding => binding.FieldPath == "/acoustic/sources/0/pressure");
}
[Fact]
public void ParserRejectsUnknownParameterReferences()
{
@ -1253,6 +1282,31 @@ public sealed class PatchScriptTests
Assert.Empty(export.Warnings);
}
[Fact]
public void FaustEmitterKeepsControlCurveParametersRealtimeDrivable()
{
var export = FaustEmitter.EmitScript("""
param path=/bird/left/pressure default=.2 min=0 max=1 step=.001
curve name=left_pressure path=/bird/left/pressure points=0:.05,.08:.9,.24:.35 mode=blend depth=.8 loop=true
path name=left_bronchus length_cm=3.8 diameters=.22,.30,.36,.42
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 opening=.2 tension=.4
terminal name=left_merge path=left_bronchus position=1 kind=junction
terminal name=trachea_base path=trachea position=0 kind=junction
connect name=merge terminals=left_merge,trachea_base
radiation_port name=beak path=trachea kind=beak position=1 opening=.9
acoustic_network name=bird path=trachea sources=left_labium radiation=beak terminals=left_merge,trachea_base connections=merge
acoustic network=bird freq=1200
""", new FaustExportOptions("curve_voice"));
Assert.Contains("patch_param_0_base = hslider(\"/bird/left/pressure\", 0.2, 0, 1, 0.001) : si.smoo;", export.Source);
Assert.Contains("patch_param_0_curve_depth = hslider(\"/curves/left_pressure/depth\", 0.8, 0, 1, 0.001) : si.smoo;", export.Source);
Assert.Contains("patch_param_0_curve_time = wrap01((max(0.0, age * 1 - 0)) / 0.24) * 0.24;", export.Source);
Assert.Contains("patch_param_0 = min(1, max(0, patch_param_0_base * (1.0 - patch_param_0_curve_depth) + patch_param_0_curve_value * patch_param_0_curve_depth));", export.Source);
Assert.Contains("clip01(patch_param_0)", export.Source);
Assert.Empty(export.Warnings);
}
[Fact]
public void VoiceLowPassOrderSelectsFaustFilterOrder()
{
@ -1362,6 +1416,24 @@ public sealed class PatchScriptTests
Assert.True(validation.Success, validation.Stderr);
}
[Fact]
public async Task FaustCompilerValidatesControlCurvePatchWhenInstalled()
{
var export = FaustEmitter.EmitScript("""
param path=/macro/brightness default=.2 min=0 max=1 step=.001
curve name=brightness_gesture path=/macro/brightness points=0:.1,.05:.8,.2:.25 mode=blend depth=.75 loop=true
v w=saw f=80 lpf=@/macro/brightness sustain=.15
""");
var validation = await FaustCompiler.ValidateAsync(export.Source);
if (validation is null)
{
return;
}
Assert.True(validation.Success, validation.Stderr);
}
[Fact]
public async Task FaustCompilerValidatesAcousticPathGraphWhenInstalled()
{

View file

@ -226,24 +226,47 @@ static string SyrinxScript(SyrinxCandidate candidate, float durationSeconds) =>
$$"""
patch gain=0.42 soft_clip=true
param path=/bird/left/pressure default={{F(candidate.Pressure)}} min=0 max=1 step=.001
param path=/bird/right/pressure default={{F(candidate.RightPressure)}} min=0 max=1 step=.001
param path=/bird/left/opening default={{F(candidate.Opening)}} min=0 max=1 step=.001
param path=/bird/right/opening default={{F(candidate.RightOpening)}} min=0 max=1 step=.001
param path=/bird/beak/opening default={{F(candidate.BeakOpening)}} min=0 max=1.5 step=.001
curve name=left_pressure path=/bird/left/pressure points={{PressureCurve(candidate.Pressure, durationSeconds)}} depth=1
curve name=right_pressure path=/bird/right/pressure points={{PressureCurve(candidate.RightPressure, durationSeconds)}} depth=1
curve name=left_opening path=/bird/left/opening points={{OpeningCurve(candidate.Opening, durationSeconds)}} depth=.9
curve name=right_opening path=/bird/right/opening points={{OpeningCurve(candidate.RightOpening, durationSeconds)}} depth=.9
curve name=beak_opening path=/bird/beak/opening points={{BeakCurve(candidate.BeakOpening, durationSeconds)}} depth=.65
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 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)}}
source_port name=left_labium path=left_bronchus kind=syrinx model=tissue_valve position=0 pressure=@/bird/left/pressure tension={{F(candidate.Tension)}} opening=@/bird/left/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=@/bird/right/pressure tension={{F(candidate.RightTension)}} opening=@/bird/right/opening 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
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={{F(candidate.BeakOpening)}} reflection=-.72
radiation_port name=beak path=trachea kind=beak position=1 opening=@/bird/beak/opening 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={{F(candidate.Frequency)}} gain={{F(candidate.Gain)}} sustain={{F(Math.Max(0.05f, durationSeconds - 0.12f))}} decay=.08
""";
static string PressureCurve(float value, float durationSeconds) =>
Curve((0, MathF.Max(0.01f, value * 0.08f)), (0.035f, value), (durationSeconds * 0.55f, value * 0.94f), (durationSeconds, value * 0.35f));
static string OpeningCurve(float value, float durationSeconds) =>
Curve((0, MathF.Max(0.01f, value * 0.15f)), (0.03f, value), (durationSeconds * 0.65f, value * 0.86f), (durationSeconds, MathF.Max(0.01f, value * 0.20f)));
static string BeakCurve(float value, float durationSeconds) =>
Curve((0, value * 0.45f), (0.05f, value), (durationSeconds * 0.72f, Math.Min(1.5f, value * 1.08f)), (durationSeconds, value * 0.55f));
static string Curve(params (float Time, float Value)[] points) =>
string.Join(",", points.Select(point => $"{F(Math.Max(0, point.Time))}:{F(Math.Max(0, point.Value))}"));
static string Report(BirdSource source, string downloadedFrom, ReferenceFeatures features, CandidateResult best) =>
$"""
# {source.CommonName} ({source.ScientificName})