diff --git a/docs/audio-doctrine.md b/docs/audio-doctrine.md index f721c98..1506d54 100644 --- a/docs/audio-doctrine.md +++ b/docs/audio-doctrine.md @@ -326,6 +326,13 @@ records in the `.cc` CultCache/CultMesh store and references them from each trial result. Qdrant/Ollama retrieval may derive vectors from those records, but the `.cc` records remain truth. +Noise is not a scene by itself. Background air, dust, codec grit, tape, vinyl, +and room color need a role owner that shapes spectrum and time. Prefer +`texture`/`noise_texture` sugar or an equivalently gated, band-shaped explicit +voice for scene noise. Full-duration raw `wave=noise` beds with broad filters +are failed evidence: they sound like broken subtractive synthesis because that +is exactly what they are. + Do not confuse high metric agreement with musical success. The game context, the reference sound, and the user’s ear still outrank the spreadsheet. Annoying, but civilization has survived worse. diff --git a/src/AquaSynth.Core/PatchScript.cs b/src/AquaSynth.Core/PatchScript.cs index 44a3a99..fee8c50 100644 --- a/src/AquaSynth.Core/PatchScript.cs +++ b/src/AquaSynth.Core/PatchScript.cs @@ -275,6 +275,10 @@ public static class PatchScript FlushPendingOperatorGraph(); Voices.Add(ParseVoice(ExpandVoiceFields(fields, line), VoicePath(Voices.Count), line)); break; + case "texture": + FlushPendingOperatorGraph(); + AddNoiseTexture(fields, line); + break; case "acoustic_voice": FlushPendingOperatorGraph(); Voices.Add(ParseAcousticVoice(ExpandVoiceFields(fields, line), VoicePath(Voices.Count), line)); @@ -2604,6 +2608,59 @@ public static class PatchScript ("depth", GetAny(fields, ["depth", "amount", "mix"], "1"))), line); } + private void AddNoiseTexture(IReadOnlyDictionary fields, int line) + { + var name = SafeName(GetAny(fields, ["name", "n"], $"texture_{Voices.Count}")); + var role = NoiseTextureRole(GetAny(fields, ["role", "kind", "type"], name)); + var duration = GetAny(fields, ["sustain", "s", "gate", "hold", "duration"], "30"); + var gainText = GetAny(fields, ["gain", "g"], role.Gain.ToString(CultureInfo.InvariantCulture)); + var gatePath = GetAny(fields, ["path", "gate_path"], $"/textures/{name}/gate"); + + if (!gainText.StartsWith('@')) + { + var gainValue = Math.Clamp(ParseFloat(gainText, line), 0, 1); + if (TryGetAny(fields, ["pattern", "pat", "steps"], out var pattern)) + { + AddPatternCurve(SyntheticFields( + ("name", $"{name}_gate"), + ("path", gatePath), + ("pattern", pattern), + ("step", GetAny(fields, ["step", "step_seconds", "dt"], role.StepSeconds.ToString(CultureInfo.InvariantCulture))), + ("high", GetAny(fields, ["high", "on", "hit"], gainValue.ToString(CultureInfo.InvariantCulture))), + ("low", GetAny(fields, ["low", "off", "rest"], (gainValue * role.RestScale).ToString(CultureInfo.InvariantCulture))), + ("loop", GetAny(fields, ["loop"], "true"))), line); + } + else + { + EnsureParameter(gatePath, gainValue, 0, Math.Max(gainValue * 2, 0.001f), 0.001f, "", line); + AddControlCurve(SyntheticFields( + ("name", $"{name}_gate_motion"), + ("path", gatePath), + ("points", TextureGatePoints(gainValue, role.MotionScale)), + ("interp", "linear"), + ("loop", "true")), line); + } + + gainText = "@" + gatePath; + } + + Voices.Add(ParseVoice(ExpandVoiceFields(SyntheticFields( + ("wave", "noise"), + ("freq", GetAny(fields, ["freq", "frequency"], role.FrequencyHz.ToString(CultureInfo.InvariantCulture))), + ("gain", gainText), + ("attack", GetAny(fields, ["attack", "a"], role.AttackSeconds.ToString(CultureInfo.InvariantCulture))), + ("sustain", duration), + ("decay", GetAny(fields, ["decay", "d", "release", "rel"], role.DecaySeconds.ToString(CultureInfo.InvariantCulture))), + ("noise", GetAny(fields, ["noise", "nz"], "1")), + ("hpf", GetAny(fields, ["hpf", "h"], role.HighPass.ToString(CultureInfo.InvariantCulture))), + ("lpf", GetAny(fields, ["lpf", "l"], role.LowPass.ToString(CultureInfo.InvariantCulture))), + ("bpf", GetAny(fields, ["bpf", "bp"], role.BandPass.ToString(CultureInfo.InvariantCulture))), + ("bpf_q", GetAny(fields, ["bpf_q", "bpq"], role.BandPassQ.ToString(CultureInfo.InvariantCulture))), + ("tremolo", GetAny(fields, ["tremolo", "tr"], role.TremoloDepth.ToString(CultureInfo.InvariantCulture))), + ("tremolo_hz", GetAny(fields, ["tremolo_hz", "th"], role.TremoloHz.ToString(CultureInfo.InvariantCulture))), + ("drive", GetAny(fields, ["drive"], role.Drive.ToString(CultureInfo.InvariantCulture)))), line), VoicePath(Voices.Count), line)); + } + private void EnsureParameter(string path, float defaultValue, float min, float max, float step, string unit, int line) { if (_parameters.Any(parameter => parameter.Path.Equals(path, StringComparison.OrdinalIgnoreCase))) @@ -3447,6 +3504,7 @@ public static class PatchScript "acoustic_network" or "port_network" or "network" => "acoustic_network", "vocal_network" or "vocalnetwork" => "vocal_network", "v" or "voice" => "voice", + "texture" or "noise_texture" or "scene_texture" or "dust" or "air" or "air_wash" or "codec_bed" => "texture", "acoustic" or "acoustic_voice" or "av" => "acoustic_voice", "vocal" or "vocal_voice" or "vv" => "vocal_voice", "tract" or "vt" or "tractvoice" or "tract_voice" => "tract", @@ -3674,6 +3732,50 @@ public static class PatchScript private static Dictionary SyntheticFields(params (string Key, string Value)[] fields) => fields.ToDictionary(field => field.Key, field => field.Value, StringComparer.OrdinalIgnoreCase); + private static string TextureGatePoints(float gain, float motionScale) + { + var low = Math.Max(0, gain * Math.Max(0, 1 - motionScale)); + var high = Math.Min(1, gain * (1 + motionScale)); + return string.Join(",", new[] + { + (0f, gain), + (0.37f, high), + (0.91f, low), + (1.43f, gain * 0.85f), + (2.11f, high * 0.9f), + (3.0f, gain) + }.Select(point => + point.Item1.ToString("0.######", CultureInfo.InvariantCulture) + + ":" + + point.Item2.ToString("0.######", CultureInfo.InvariantCulture))); + } + + private static NoiseTextureDefaults NoiseTextureRole(string role) + { + var normalized = role.ToLowerInvariant().Replace("_", "-", StringComparison.Ordinal); + if (normalized.Contains("dust", StringComparison.Ordinal) || normalized.Contains("hat", StringComparison.Ordinal)) + { + return new(7600, 0.075f, 0.001f, 0.09f, 0.78f, 0.42f, 0.66f, 4.5f, 0.22f, 5.0f, 0.16f, 0.08f, 0.35f, 0.125f); + } + + if (normalized.Contains("codec", StringComparison.Ordinal) || normalized.Contains("bit", StringComparison.Ordinal)) + { + return new(4200, 0.045f, 0.015f, 0.55f, 0.38f, 0.34f, 0.44f, 5.5f, 0.18f, 1.7f, 0.20f, 0.18f, 0.45f, 0.25f); + } + + if (normalized.Contains("room", StringComparison.Ordinal) || normalized.Contains("bed", StringComparison.Ordinal)) + { + return new(1800, 0.04f, 0.05f, 0.8f, 0.14f, 0.32f, 0.24f, 0.9f, 0.20f, 0.23f, 0.05f, 0.35f, 0.55f, 0.5f); + } + + if (normalized.Contains("tape", StringComparison.Ordinal) || normalized.Contains("vinyl", StringComparison.Ordinal)) + { + return new(2600, 0.04f, 0.02f, 0.7f, 0.22f, 0.48f, 0.34f, 1.2f, 0.15f, 0.67f, 0.10f, 0.25f, 0.5f, 0.25f); + } + + return new(5800, 0.045f, 0.02f, 0.55f, 0.58f, 0.48f, 0.56f, 1.8f, 0.24f, 0.31f, 0.08f, 0.24f, 0.5f, 0.25f); + } + private static string PointsText(IEnumerable<(float Time, float Value)> points) => string.Join(",", points.Select(point => point.Time.ToString("0.######", CultureInfo.InvariantCulture) + @@ -3886,4 +3988,20 @@ public static class PatchScript return fields.Where(pair => !excluded.Contains(pair.Key)) .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase); } + + private sealed record NoiseTextureDefaults( + float FrequencyHz, + float Gain, + float AttackSeconds, + float DecaySeconds, + float HighPass, + float LowPass, + float BandPass, + float BandPassQ, + float TremoloDepth, + float TremoloHz, + float Drive, + float RestScale, + float MotionScale, + float StepSeconds); } diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 221e6ab..c7aaf6c 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -251,3 +251,4 @@ {"date":"2026-05-31","area":"song-snippet-experiment-phases","lesson":"Three song-snippet phases across different segments improved best log-mel cosine from `0.308233` to `0.380805`, still pressure rather than close parity. Winning candidates converged on bright FM/formant `alien-lead` plus clocked high-band `dust-hat` noise and a continuous `codec-bed`/air layer; syrinx-heavy proposals scored worse. The durable DSL pressure is named scene-instrument sugar (`alien-lead-chatter`, `dust-hat-grid`, `codec-bed-hum`, `air-wash-hiss`) that lowers into explicit `param`, `curve interp=hold loop=true`, and `voice` records. Role sugar may own brevity/defaults; lowered controls remain DSP authority.","evidence":"artifacts/parity/song-snippet-swarms/20260531T014033022/experiment-report.md; artifacts/parity/song-snippet-swarms/20260531T014033022/song-trial-results.cc; docs/audio-doctrine.md; tools/run-song-snippet-trial-swarm.ps1"} {"date":"2026-05-31","area":"song-snippet-cultmesh-evidence","lesson":"Song-scene agents need target evidence as first-class database material, not loose analysis files. `song-prepare` now emits a log-mel spectrogram, per-band stats with first and second derivatives, RMS envelope, envelope autocorrelation, and an analysis report for every frozen clip; `song-score` upserts those artifacts as typed `SongChallengeEvidenceDocument` records in the `.cc` CultCache/CultMesh store, adds target metrics and `cultmesh://aquasynth/song-challenge-evidence/...` artifact URIs to every result, and indexes the evidence records into Qdrant-derived semantic chunks.","evidence":"src/AquaSynth.Core/IpaTrialResults.cs; tools/IpaTrialWorker/Program.cs; tools/run-song-snippet-trial-swarm.ps1; tests/AquaSynth.Dsl.Tests/IpaTrialOrchestratorTests.cs; artifacts/parity/song-snippet-smoke/cultmesh-evidence/index.md; artifacts/parity/song-snippet-smoke/cultmesh-evidence/search.md"} {"date":"2026-05-31","area":"song-snippet-album-swarm","lesson":"A 3-pass album swarm over random 30-second clips produced 15 scored trials and 15 full target evidence bundles in the shared `.cc` store. The agents improved from pass-001 best cosine `0.434277` to pass-002 best `0.590958`, then pass-003 best `0.502573`; aggregate mean cosine was `0.353315`, with 11 pressure verdicts and 4 weak verdicts. The strongest families converged on bright formant/vowel leads plus clocked dust/noise grids, while the semantic index successfully carried 90 bounded Qdrant chunks derived from full CultCache evidence docs.","evidence":"artifacts/parity/song-snippet-swarms/20260531T031003707/experiment-report.md; artifacts/parity/song-snippet-swarms/20260531T031003707/song-trial-results.cc; artifacts/parity/song-snippet-swarms/20260531T031003707/pass-003/vector-index-after.md; artifacts/parity/song-snippet-swarms/20260531T031003707/pass-003/semantic-search-after.md"} +{"date":"2026-05-31","area":"song-scene-texture-owner","lesson":"The album-swarm patches exposed a bad scene-noise owner: agents modeled air/dust/recording condition with full-duration broad `wave=noise` voices, producing static white hiss that sounds like broken subtractive synthesis. The DSL now has `texture`/`noise_texture` sugar with role defaults for dust, air, codec, room, tape/vinyl; it lowers into a gated noise voice with bandpass/highpass/lowpass/tremolo shaping and an owned gate curve or pattern. Future song prompts require texture or an equivalently gated/band-shaped explicit voice for background noise.","evidence":"src/AquaSynth.Core/PatchScript.cs; tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs; tools/run-song-snippet-trial-swarm.ps1; docs/audio-doctrine.md"} diff --git a/state/scratch.md b/state/scratch.md index de71347..4adc346 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -1744,3 +1744,8 @@ Next likely slice: 0.502573. Best overall was `agent_04__shard_vowel_lead__bright_dust_grid_v2`; strongest repeated pattern is bright formant/vowel lead plus clocked dust/noise grid. +- Listening correction: those clocked dust/noise grids still sit on persistent + broad white hiss. Added `texture`/`noise_texture` DSL sugar so air, dust, + codec, room, and tape/vinyl noise are owned as gated, band-shaped scene + roles. Future agents should not use full-duration raw `wave=noise` beds for + recording color. diff --git a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs index 384835d..8f838a3 100644 --- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs @@ -99,6 +99,31 @@ public sealed class PatchScriptTests Assert.Equal(622.254f, curve.Points[3].Value, 3); } + [Fact] + public void ParserSupportsShapedNoiseTextureSugar() + { + var patch = PatchScript.Parse(""" + texture name=dust_hat role=dust pattern=x..x step=.125 gain=.12 sustain=30 + texture name=air_wash role=air gain=.04 sustain=30 + voice wave=saw freq=220 gain=.1 + """); + + Assert.Equal(3, patch.Voices.Count); + Assert.All(patch.Voices.Take(2), voice => Assert.Equal(Waveform.Noise, voice.Oscillator.Waveform)); + Assert.Contains(patch.Parameters, parameter => parameter.Path == "/textures/dust_hat/gate"); + Assert.Contains(patch.Parameters, parameter => parameter.Path == "/textures/air_wash/gate"); + Assert.Contains(patch.ControlCurves, curve => curve.Name == "dust_hat_gate" && curve.Interpolation == ControlCurveInterpolation.Hold); + Assert.Contains(patch.ControlCurves, curve => curve.Name == "air_wash_gate_motion" && curve.Interpolation == ControlCurveInterpolation.Linear); + var dust = patch.Voices[0]; + Assert.True(dust.Filter.BandPass > 0); + Assert.True(dust.Filter.BandPassQ > 1); + Assert.True(dust.Color.TremoloDepth > 0); + + var faust = FaustEmitter.Emit(patch).Source; + Assert.Contains("fi.resonbp", faust); + Assert.Contains("/textures/dust_hat/gate", faust); + } + [Fact] public void ParserSupportsVoiceVowelFrames() { diff --git a/tools/run-song-snippet-trial-swarm.ps1 b/tools/run-song-snippet-trial-swarm.ps1 index 92447d2..92f4da1 100644 --- a/tools/run-song-snippet-trial-swarm.ps1 +++ b/tools/run-song-snippet-trial-swarm.ps1 @@ -278,6 +278,11 @@ Challenge: Goal: Write exactly one parseable AquaSynth `.aqua` patch that tries to reproduce the frozen $DurationSeconds second reference clip. This is scene-audio parity, not IPA articulation. You may use ordinary voices, FM, AM/tremolo, filters, formants, noise layers, acoustic vocal/syrinx primitives, curves, and helper voices. +Noise and texture: +- Do not model background or recording color as a full-duration raw `voice wave=noise` bed. That produces static white-noise wash and will be treated as broken subtractive synthesis, not scene modeling. +- Use the shaped texture owner for noise roles: `texture name=dust_hat role=dust pattern=x..x step= gain=.08 sustain=$DurationSeconds`, `texture name=air_wash role=air gain=.035 sustain=$DurationSeconds`, `texture name=codec_bed role=codec gain=.04 sustain=$DurationSeconds`. +- `texture` lowers to a gated noise voice with role-specific bandpass/highpass/lowpass/tremolo defaults and a control curve or pattern gate. Raw `voice wave=noise` is only acceptable for short transients with explicit gates and narrow filters. + Rhythm and tempo: - The challenge report includes `tempo_bpm`, `beat_seconds`, and `tempo_confidence`, estimated from spectral/RMS onset autocorrelation. - The challenge report also points to `analysis_report`, `log_mel_spectrogram_csv`, `log_mel_band_stats_csv`, `rms_envelope_csv`, and `rms_envelope_autocorr_csv`; read them before writing the patch. @@ -315,6 +320,7 @@ Evidence contract: - Write $agentDir/hypotheses.md with: reference features, cited analysis artifacts, tempo/rhythm plan, register/scale plan, scene voices, synthesis owners, invented instrument roles, expected metric movement, known risks. - Write exactly one `.aqua` file under $agentPatchDir named `____.aqua`. - Include at least three scene voices or layers in the patch, such as primary alien-voice/body, rhythmic transient/noise, and background/recording color. +- Background/recording-color layers must use `texture` or an equivalently gated and band-shaped explicit voice; a static full-duration white-noise bed is failed evidence. - Make the patch duration/gates cover the $DurationSeconds second target. Return a short final summary naming the patch and report.