From 37c580c80034a4e2dc3e348fa1984371d492c28f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 31 May 2026 03:37:07 +0100 Subject: [PATCH] Add song target evidence records --- docs/audio-doctrine.md | 8 + src/AquaSynth.Core/IpaTrialResults.cs | 30 ++ src/AquaSynth.Core/PatchScript.cs | 156 +++++++ state/evidence.jsonl | 1 + state/scratch.md | 21 + state/spine.yaml | 1 + .../IpaTrialOrchestratorTests.cs | 17 + tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs | 34 ++ tools/IpaTrialWorker/Program.cs | 383 +++++++++++++++++- tools/run-song-snippet-trial-swarm.ps1 | 161 +++++--- 10 files changed, 750 insertions(+), 62 deletions(-) diff --git a/docs/audio-doctrine.md b/docs/audio-doctrine.md index 2540778..f721c98 100644 --- a/docs/audio-doctrine.md +++ b/docs/audio-doctrine.md @@ -318,6 +318,14 @@ Useful metrics: - zero-crossing and centroid ratios for noise/brightness drift - script readability and terseness scores for DSL golf work +Song-scene parity targets must publish target evidence before candidate golf: +log-mel spectrogram CSV, per-band statistics, first and second derivative +summaries, RMS envelope, and envelope autocorrelation. Those artifacts are not +loose side files only; scoring writes them as typed song challenge evidence +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. + 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/IpaTrialResults.cs b/src/AquaSynth.Core/IpaTrialResults.cs index 25b7f92..7a296b2 100644 --- a/src/AquaSynth.Core/IpaTrialResults.cs +++ b/src/AquaSynth.Core/IpaTrialResults.cs @@ -42,11 +42,28 @@ public sealed record PrimitiveTimelineFact( [property: Key(6)] int BlockEnd, [property: Key(7)] string Summary); +[CultDocument("aquasynth.song_challenge_evidence", "aquasynth.song_challenge_evidence.v1")] +[MessagePackObject] +public sealed record SongChallengeEvidenceDocument( + [property: Key(0)] + [property: CultName] + string EvidenceId, + [property: Key(1)] string ChallengeId, + [property: Key(2)] string Kind, + [property: Key(3)] string ContentType, + [property: Key(4)] string Content, + [property: Key(5)] string ContentHash, + [property: Key(6)] string SourcePath, + [property: Key(7)] string CreatedAtUtc); + public static class IpaTrialResultCultCacheStore { public static Task UpsertResultsAsync(string filePath, IEnumerable results) => UpsertAsync(filePath, results, result => $"ipa-trial-result:{result.TrialId}"); + public static Task UpsertSongChallengeEvidenceAsync(string filePath, IEnumerable documents) => + UpsertAsync(filePath, documents, document => $"song-challenge-evidence:{document.EvidenceId}"); + public static async Task> ReadResultsAsync(string filePath) { if (!File.Exists(filePath)) @@ -60,6 +77,19 @@ public static class IpaTrialResultCultCacheStore .ToArray(); } + public static async Task> ReadSongChallengeEvidenceAsync(string filePath) + { + if (!File.Exists(filePath)) + { + return []; + } + + using var cache = await CultCacheMessagePack.OpenAsync(filePath).ConfigureAwait(false); + return cache.GetAll() + .OrderBy(document => document.EvidenceId, StringComparer.Ordinal) + .ToArray(); + } + private static async Task UpsertAsync( string filePath, IEnumerable documents, diff --git a/src/AquaSynth.Core/PatchScript.cs b/src/AquaSynth.Core/PatchScript.cs index 3d4f5ff..44a3a99 100644 --- a/src/AquaSynth.Core/PatchScript.cs +++ b/src/AquaSynth.Core/PatchScript.cs @@ -319,6 +319,14 @@ public static class PatchScript FlushPendingOperatorGraph(); AddControlCurve(fields, line); break; + case "pattern": + FlushPendingOperatorGraph(); + AddPatternCurve(fields, line); + break; + case "scale": + FlushPendingOperatorGraph(); + AddScaleCurve(fields, line); + break; case "control_spline": FlushPendingOperatorGraph(); AddControlSpline(fields, line); @@ -2531,6 +2539,87 @@ public static class PatchScript GetAny(fields, ["rate", "automation", "automation_rate"], "control"))); } + private void AddPatternCurve(IReadOnlyDictionary fields, int line) + { + var parameterPath = Required(fields, "path", line); + var pattern = RequiredAny(fields, ["pattern", "pat", "steps"], line); + var stepSeconds = GetFloat(fields, line, GetFloat(fields, line, 0.25f, "beat"), "step", "step_seconds", "dt"); + if (stepSeconds <= 0) + { + throw new PatchScriptException(line, "pattern step must be greater than zero"); + } + + var high = GetFloat(fields, line, 1, "high", "on", "hit"); + var low = GetFloat(fields, line, 0, "low", "off", "rest"); + var values = PatternValues(pattern, high, low, line); + EnsureParameter(parameterPath, low, Math.Min(low, high), Math.Max(low, high), GetFloat(fields, line, 0.001f, "step_size"), "", line); + AddControlCurve(SyntheticFields( + ("name", GetAny(fields, ["name", "n"], SafeName(parameterPath.Trim('/')) + "_pattern")), + ("path", parameterPath), + ("points", PointsText(values.Select((value, index) => (index * stepSeconds, value)))), + ("interp", "hold"), + ("loop", GetAny(fields, ["loop"], "true")), + ("mode", GetAny(fields, ["mode"], "blend")), + ("depth", GetAny(fields, ["depth", "amount", "mix"], "1"))), line); + } + + private void AddScaleCurve(IReadOnlyDictionary fields, int line) + { + var parameterPath = Required(fields, "path", line); + var stepSeconds = GetFloat(fields, line, GetFloat(fields, line, 0.25f, "beat"), "step", "step_seconds", "dt"); + if (stepSeconds <= 0) + { + throw new PatchScriptException(line, "scale step must be greater than zero"); + } + + var frequencies = TryGetAny(fields, ["freqs", "frequencies", "notes"], out var frequenciesText) + ? ParseLooseFloatList(frequenciesText, line) + : ScaleFrequencies(fields, line); + if (frequencies.Count == 0) + { + throw new PatchScriptException(line, "scale needs at least one frequency"); + } + + var degrees = TryGetAny(fields, ["degrees", "pattern", "pat", "steps"], out var degreeText) + ? ParseLooseIntList(degreeText, line) + : Enumerable.Range(0, frequencies.Count).ToArray(); + if (degrees.Count == 0) + { + throw new PatchScriptException(line, "scale needs at least one degree"); + } + + var values = degrees + .Select(degree => frequencies[Math.Clamp(degree, 0, frequencies.Count - 1)]) + .ToArray(); + var min = GetFloat(fields, line, frequencies.Min(), "min"); + var max = GetFloat(fields, line, frequencies.Max(), "max"); + EnsureParameter(parameterPath, values[0], min, max, GetFloat(fields, line, 0.01f, "step_size"), "Hz", line); + AddControlCurve(SyntheticFields( + ("name", GetAny(fields, ["name", "n"], SafeName(parameterPath.Trim('/')) + "_scale")), + ("path", parameterPath), + ("points", PointsText(values.Select((value, index) => (index * stepSeconds, value)))), + ("interp", "hold"), + ("loop", GetAny(fields, ["loop"], "true")), + ("mode", GetAny(fields, ["mode"], "blend")), + ("depth", GetAny(fields, ["depth", "amount", "mix"], "1"))), 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))) + { + return; + } + + AddParameter(SyntheticFields( + ("path", path), + ("default", defaultValue.ToString(CultureInfo.InvariantCulture)), + ("min", min.ToString(CultureInfo.InvariantCulture)), + ("max", max.ToString(CultureInfo.InvariantCulture)), + ("step", step.ToString(CultureInfo.InvariantCulture)), + ("unit", unit)), line); + } + private void AddGestureGroup(IReadOnlyDictionary fields, int line) { var name = Required(fields, "name", line); @@ -3370,6 +3459,8 @@ public static class PatchScript "param" or "parameter" => "param", "control_surface" or "surface" or "controlsurface" => "control_surface", "curve" or "control_curve" or "automation" => "curve", + "pattern" or "pat" or "seq" or "sequence" => "pattern", + "scale" or "notes" or "melody" => "scale", "control_spline" or "spline" or "gesture_spline" or "gesturespline" => "control_spline", "phoneme_gesture" or "phone_gesture" or "ipa_gesture" or "phoneme" or "phone" => "phoneme_gesture", "gesture" or "gesture_group" or "gesturegroup" => "gesture", @@ -3580,6 +3671,71 @@ public static class PatchScript ? Array.Empty() : value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + private static Dictionary SyntheticFields(params (string Key, string Value)[] fields) => + fields.ToDictionary(field => field.Key, field => field.Value, StringComparer.OrdinalIgnoreCase); + + private static string PointsText(IEnumerable<(float Time, float Value)> points) => + string.Join(",", points.Select(point => + point.Time.ToString("0.######", CultureInfo.InvariantCulture) + + ":" + + point.Value.ToString("0.######", CultureInfo.InvariantCulture))); + + private static IReadOnlyList PatternValues(string pattern, float high, float low, int line) + { + var compact = pattern.Replace("\"", "", StringComparison.Ordinal).Replace("'", "", StringComparison.Ordinal); + var values = new List(); + foreach (var token in compact.Split([',', ' ', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + foreach (var character in token) + { + values.Add(character switch + { + 'x' or 'X' or '1' or '*' => high, + '.' or '-' or '_' or '~' or '0' => low, + _ when char.IsDigit(character) => Math.Clamp((character - '0') / 9f, 0, 1) * (high - low) + low, + _ => throw new PatchScriptException(line, $"bad pattern step `{character}`") + }); + } + } + + if (values.Count == 0) + { + throw new PatchScriptException(line, "pattern needs at least one step"); + } + + return values; + } + + private static IReadOnlyList ScaleFrequencies(IReadOnlyDictionary fields, int line) + { + var root = GetFloat(fields, line, 440, "root", "freq", "base"); + var intervals = TryGetAny(fields, ["intervals"], out var intervalsText) + ? ParseLooseIntList(intervalsText, line) + : ScaleIntervals(GetAny(fields, ["scale", "mode"], "minor-pentatonic")); + return intervals.Select(interval => root * MathF.Pow(2f, interval / 12f)).ToArray(); + } + + private static IReadOnlyList ScaleIntervals(string scale) => scale.ToLowerInvariant() switch + { + "major" or "ionian" => [0, 2, 4, 5, 7, 9, 11, 12], + "minor" or "aeolian" => [0, 2, 3, 5, 7, 8, 10, 12], + "pentatonic" or "major-pentatonic" => [0, 2, 4, 7, 9, 12], + "minor-pentatonic" or "minor_pentatonic" => [0, 3, 5, 7, 10, 12], + "minor-pentatonic-plus-tritone" or "minor_pentatonic_plus_tritone" or "alien" => [0, 3, 5, 6, 7, 10, 12], + "chromatic" => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + _ => [0, 3, 5, 7, 10, 12] + }; + + private static IReadOnlyList ParseLooseFloatList(string value, int line) => + value.Split([',', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(item => ParseFloat(item, line)) + .ToArray(); + + private static IReadOnlyList ParseLooseIntList(string value, int line) => + value.Split([',', '|', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(item => ParseInt(item, line)) + .ToArray(); + private static IReadOnlyList ParseControlCurvePoints(string value, int line) { var points = value diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 3718a7f..4c6c359 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -249,3 +249,4 @@ {"date":"2026-05-31","area":"ipa-trial-learning-loop","lesson":"The loose source-golf round obeyed the file-count ritual but did not learn: it rotated families across unrelated phonetic targets, assigned stop-release ideas to vowel/approximant lanes, gave `p` an oral-pressure tweak instead of the stop-release owner, and produced aggregate regressions without converting them into the next hypothesis. The trial loop now passes previous evaluation paths into the hypothesizer, requires `Previous Round Learning`, requires a `Target-Class Lane Plan`, forbids round-robin family assignment, and lets the evaluator fail class alignment when families do not match phonetic owner classes or explicit transfer-test mutations.","evidence":"artifacts/parity/ipa-trial-loops/20260531T001831690/round-001/hypotheses.md; artifacts/parity/ipa-trial-loops/20260531T001831690/round-001/evaluation.md; tools/run-ipa-trial-loop.ps1; docs/ipa-vocal-tract-roadmap.md"} {"date":"2026-05-31","area":"song-snippet-trial-swarm","lesson":"Song-scene parity needs musical control evidence in addition to spectrogram loss. The new song challenge lane freezes a seeded local clip, derives tempo from onset-envelope autocorrelation, derives a register/root/scale hint from dominant-frequency autocorrelation plus spectral fallback, and prompts agents to sequence explicit AquaSynth `param` + `curve interp=hold loop=true` controls instead of inventing unimplemented Strudel syntax. Register-bounded scale frequencies are a temporary explicit contract until native scale/note sugar has a real DSL/lowering owner. Song scoring must not require vocal-network primitive timelines; non-vocal scene patches now score with an empty timeline witness instead of failing before render.","evidence":"tools/IpaTrialWorker/Program.cs; tools/run-song-snippet-trial-swarm.ps1; docs/audio-doctrine.md; artifacts/parity/song-snippet-smoke/challenge.md; artifacts/parity/song-snippet-swarms/20260531T012627197/challenge.json; artifacts/parity/song-snippet-score-smoke/smoke-001/evaluator-report.md"} {"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"} diff --git a/state/scratch.md b/state/scratch.md index ac9d28b..069b055 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -1713,3 +1713,24 @@ Next likely slice: current frequency/gate controls. - Keep tests focused on structure first, then add rendered audio comparison once the render path is explicit. + +2026-05-31 song-scene evidence slice: + +- `pattern` and `scale` patch-script sugar are live. They create the backing + parameter when needed and lower to hold-loop control curves, giving song + workers Strudel-ish rhythmic/pitch controls without inventing parser magic. +- Song snippet challenges now default to 30 seconds in the swarm harness, can + assign random album sources per agent, and prepare a separate seeded challenge + for each agent. +- `song-prepare` writes target analysis artifacts: + `logmel-spectrogram.csv`, `logmel-band-stats.csv`, `rms-envelope.csv`, + `rms-envelope-autocorr.csv`, and `analysis.md`. +- `song-score` upserts those artifacts into the `.cc` store as typed + `SongChallengeEvidenceDocument` records and adds target metrics plus + `cultmesh://aquasynth/song-challenge-evidence/...` artifact URIs to each + result. Qdrant indexing now creates evidence chunks from those database + records as well as trial summaries. +- Smoke proof: + `artifacts/parity/song-snippet-smoke/cultmesh-evidence/song-trial-results.cc` + has one scored candidate, five target evidence docs, six Qdrant chunks, and a + search hit whose vector chunk is the song challenge analysis report. diff --git a/state/spine.yaml b/state/spine.yaml index 7951a86..0d280b5 100644 --- a/state/spine.yaml +++ b/state/spine.yaml @@ -148,6 +148,7 @@ live_invariants: - `IpaGestureExperiment.WriteRound` is the first frozen batch harness for IPA hypothesis work. It writes `manifest.yaml`, candidate `.aqua` scripts, primitive `ProbeTimelineReport` CSVs, gesture-layer `metrics.csv`, and per-candidate `evidence.jsonl` under a caller-provided artifact root. `IpaGestureExperiment.AnalyzeRound` writes `analysis/metric-summary.csv`, `analysis/candidate-clusters.csv`, and `analysis/science-brief.md` for the science-worker handoff. This harness owns frozen descriptor/spline/primitive evidence only; clean vocal identity, full spectrogram parity, optimizer checkpoints, and worker orchestration stay downstream. Focused verification: `PatchScriptTests` pass 93/93. - `IpaTrialOrchestrator.RunAsync` is the first local render/scoring worker for IPA trials. It generates candidate patches through `IpaGestureExperiment`, renders them through Faust, compares against local PT fixture references with `AudioAnalyzer`, writes WAV/report artifacts, and stores typed `IpaTrialResult` records in `ipa-trial-results.cc`. The five-seed trial run at `tests/AquaSynth.Dsl.Tests/bin/Debug/net10.0/artifacts/parity/ipa-trials/20260529T214127955/five-seed-trials` showed voiced vowels/nasals became audible only after primitive `SourcePort` lowering gained a true oscillatory voiced carrier; sibilants are pressure, plosives remain weak. Verification: opt-in IPA trial test passed, and focused `PatchScriptTests|IpaTrialResultsRoundTripThroughCultCache` pass 94/94. - External IPA exploration now has a worker/tool boundary. `tools/IpaTrialWorker` owns `seed`, `score`, `search`, `show`, and audit-only `dump` commands over the typed `.cc` trial-result store; `score` renders external Codex-authored `.aqua` candidates through `IpaTrialOrchestrator.RunCandidateScriptsAsync`, while `search` is the semantic retrieval organ for hypothesis agents. `IpaTrialResult` records now embed extracted primitive timeline facts from `ProbeTimelineReport` for contact closure/reservoir/release, source load/flow, branch admittance/exchanged flow, radiation boundary/output, path area/delay/energy, and passivity; `search`, `show`, `dump`, and summary CSV output surface release/radiation/passivity witnesses directly from the `.cc` record. `search` follows VoidBot's Qdrant shape: `.cc` records plus timeline facts and referenced local artifacts become evidence chunks, local Ollama `qwen3-embedding:0.6b` produces 1024-dim vectors, Qdrant collection `aquasynth_ipa_trial_results` stores stable SHA-256 UUID points with trial/candidate payload metadata, and the report surfaces vector scores, excerpts, tags, metric facts, timeline facts, contrast candidates, `class_focus`, per-hit `class_match`, and `show` commands. Qdrant is a derived index; CultCache remains truth, and lexical/metric search is fallback when the vector stack is unavailable. Retrieval tuning added phonetic class-affinity scoring and candidate-specific mixed-lane ownership so `mix-p` can support stop seams without every mixed candidate matching stop evidence. `tools/run-ipa-trial-loop.ps1` orchestrates external `codex exec` hypothesis/evaluator workers around that store. Source-edit-enabled rounds now allow agents to golf DSL syntax, parser/model records, Faust lowering, worker evidence surfaces, and tests when evidence points at a repeated owner failure; agents must write `source-change-plan.md`, and the loop runs a focused build before scoring. `hypotheses.md` is the hypothesis worker report of record; `hypothesis-agent.md` is only the external agent's final chat summary. The first loose source-edit round produced no source edits and no aggregate metric win, then exposed a deeper learning-loop fault: family ideas were rotated across unrelated targets instead of aligned to phonetic owners. Production prompts now pass the previous evaluation path into the hypothesizer, require `Previous Round Learning`, require `Target-Class Lane Plan`, forbid round-robin family assignment, and let the evaluator fail class alignment when family/target assignments do not match phonetic owner classes or explicit transfer-test mutations. Candidate reports must include a `Scene Voice Model` naming primary-articulatory, tract-noise, release/transient, background-room, and recording-condition voices, with general AquaSynth synth primitives allowed for helper scene voices but not accepted as articulation evidence unless lower-layer witnesses move. At least one scene/noise or recording-condition family in a source-golf round must implement real auxiliary `.aqua` `voice` declarations instead of satisfying scene modeling with prose. The active trial shape is five target sets of five phoneme lanes each: vowels, nasals/approximants, fricatives, stops, and mixed generalization. Candidate names are exactly `____.aqua`, with lowercase kebab-case family ids. Three dry external Codex smoke passes refined hypothesizer/evaluator prompts into exact 25-lane output, retrieval receipts, claim audits, evidence-quality ledgers/verdicts, and fail-closed evaluator compliance. A later five-pass prompt optimization showed the best science comes from fixed receipt packets plus constrained reasoning; production prompts now require contrast pairs, `show` metric values, primitive timeline facts/excerpts or explicit `missing`, class-consistent reference matrices, scene voice models, `X owns Y so Z remains true` owner sentences, at least five micro-sweep perturbations before a seam is actionable, and a novelty gate rejecting loudness/dressing-only families as loss-landscape novelty. Verification: focused builds/tests pass, old-store show compatibility passes, and a fresh one-candidate smoke under `artifacts/parity/ipa-timeline-fact-smoke/20260531T233217641` proves release/radiation/passivity facts persist and retrieve through Qdrant/Ollama. + - Song-snippet parity now treats target analysis as CultMesh database evidence. `song-prepare` writes each 30-second clip's log-mel spectrogram CSV, per-band first/second derivative stats, RMS envelope, envelope autocorrelation, and analysis report. `song-score` upserts those as typed `SongChallengeEvidenceDocument` records in the same `.cc` store as `IpaTrialResult`, adds target metrics and `cultmesh://aquasynth/song-challenge-evidence/...` artifacts to every result, and `search/index` derive Qdrant chunks from the database records. `pattern` and `scale` DSL sugar are live authoring conveniences that lower into normal parameters and hold-loop curves; prompt agents may propose richer Strudel-like syntax, but candidate patches must use implemented syntax. - TurbulenceJet now includes a release-timed burst-noise component in addition to the deterministic pressure pulse, but a stronger trial barely moved papa; the source is not the main remaining articulation bottleneck. Graph radiation now mixes 30% raw boundary flow with 70% high-passed flow after local admittance weighting, down from the over-bright 15/85 blend. Latest radiation/source artifact under `artifacts/parity/pink-trombone-utterance-logmel/20260527T174236049`: mama/papa/thrombosis cosine 0.6578/0.7692/0.5585, rmsRatio 3.0006/2.6153/2.3488, articulation 0.1437/0.1173/0.1191. Static `closure-release` under `artifacts/parity/pink-trombone-logmel/20260527T174139661` has rmsRatio 0.9900 and speechBandRatio 0.0842. This is still not speaking; next pressure is true tract resonance/passive scattering and pressure storage, not more burst gain. - Same-path two-port area discontinuities now emit `graph_area_energy_in_*` and `graph_area_energy_out_*` probes alongside the three-port branch `graph_connection_energy_*` probes. Use these to inspect passivity and formant-body failures at the scatter sites rather than judging only final output loudness. - `FaustExportOptions.DebugProbeUi` wraps graph probe signals in Faust `vbargraph` UI controls under `/debug/...` without changing audio output arity. Native streaming Faust patches now separate writable controls from read-only probe zones and can read/snapshot probe values after `ProcessBlock`; use this for acoustic graph passivity/radiation inspection, leave it off for normal parity renders. diff --git a/tests/AquaSynth.Dsl.Tests/IpaTrialOrchestratorTests.cs b/tests/AquaSynth.Dsl.Tests/IpaTrialOrchestratorTests.cs index 718e57e..df5cbd3 100644 --- a/tests/AquaSynth.Dsl.Tests/IpaTrialOrchestratorTests.cs +++ b/tests/AquaSynth.Dsl.Tests/IpaTrialOrchestratorTests.cs @@ -36,7 +36,21 @@ public sealed class IpaTrialOrchestratorTests [new PrimitiveTimelineFact("contact_release_peak", "contact:contact", "released_flow", .12f, "flow", 2, 2, "release witness")]); await IpaTrialResultCultCacheStore.UpsertResultsAsync(store, [result]); + await IpaTrialResultCultCacheStore.UpsertSongChallengeEvidenceAsync( + store, + [ + new SongChallengeEvidenceDocument( + "challenge-a:logmel-band-stats", + "challenge-a", + "logmel-band-stats", + "text/csv", + "band,mean,abs_delta_mean\n0,-1.2,.3", + "sha256:evidence", + "reference.wav", + "2026-05-29T12:00:00.0000000Z") + ]); var loaded = await IpaTrialResultCultCacheStore.ReadResultsAsync(store); + var evidence = await IpaTrialResultCultCacheStore.ReadSongChallengeEvidenceAsync(store); var single = Assert.Single(loaded); Assert.Equal("trial-a", single.TrialId); @@ -44,6 +58,9 @@ public sealed class IpaTrialOrchestratorTests Assert.Contains(single.Metrics, metric => metric.Name == "log_mel_cosine"); Assert.Contains(single.KnownLies, lie => lie.Contains("not an IPA sample", StringComparison.Ordinal)); Assert.Contains(single.TimelineFacts, fact => fact.Name == "contact_release_peak" && fact.Value > 0); + var evidenceDocument = Assert.Single(evidence); + Assert.Equal("logmel-band-stats", evidenceDocument.Kind); + Assert.Contains("abs_delta_mean", evidenceDocument.Content, StringComparison.Ordinal); } finally { diff --git a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs index 86fb3b1..384835d 100644 --- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs @@ -65,6 +65,40 @@ public sealed class PatchScriptTests Assert.Equal(.01f, lane.Modulator.Bias, 5); } + [Fact] + public void ParserSupportsStrudelLikePatternAndScaleSugar() + { + var patch = PatchScript.Parse(""" + scale name=lead_scale path=/seq/pitch freqs=493.883,587.33,659.255,698.457 degrees=0,1,3,2 step=.2 + pattern name=lead_gate path=/seq/gate pattern=x.-x step=.1 high=.8 low=.05 + voice wave=saw freq=@/seq/pitch gain=@/seq/gate sustain=30 decay=.2 + """); + + Assert.Contains(patch.Parameters, parameter => parameter.Path == "/seq/pitch" && parameter.Unit == "Hz"); + Assert.Contains(patch.Parameters, parameter => parameter.Path == "/seq/gate"); + var scale = Assert.Single(patch.ControlCurves, curve => curve.Name == "lead_scale"); + Assert.Equal(ControlCurveInterpolation.Hold, scale.Interpolation); + Assert.True(scale.Loop); + Assert.Equal([493.883f, 587.33f, 698.457f, 659.255f], scale.Points.Select(point => point.Value).ToArray()); + var gate = Assert.Single(patch.ControlCurves, curve => curve.Name == "lead_gate"); + Assert.Equal([.8f, .05f, .05f, .8f], gate.Points.Select(point => point.Value).ToArray()); + } + + [Fact] + public void ParserSupportsNamedScaleIntervals() + { + var patch = PatchScript.Parse(""" + scale path=/seq/pitch root=440 scale=minor-pentatonic-plus-tritone degrees=0,1,2,3 step=.25 + voice freq=@/seq/pitch gain=.1 + """); + + var curve = Assert.Single(patch.ControlCurves); + Assert.Equal(440, curve.Points[0].Value, 3); + Assert.Equal(523.251f, curve.Points[1].Value, 3); + Assert.Equal(587.33f, curve.Points[2].Value, 3); + Assert.Equal(622.254f, curve.Points[3].Value, 3); + } + [Fact] public void ParserSupportsVoiceVowelFrames() { diff --git a/tools/IpaTrialWorker/Program.cs b/tools/IpaTrialWorker/Program.cs index 4203f9d..92ab166 100644 --- a/tools/IpaTrialWorker/Program.cs +++ b/tools/IpaTrialWorker/Program.cs @@ -120,6 +120,15 @@ static async Task SongPrepareAsync(Dictionary options) var analysis = analyzer.Analyze(clip); var tempo = EstimateTempo(clip, sampleRate); var register = EstimateRegister(clip, sampleRate, analysis.Features.SpectralCentroidHz); + var spectrogramPath = Path.Combine(artifactRoot, "logmel-spectrogram.csv"); + var bandStatsPath = Path.Combine(artifactRoot, "logmel-band-stats.csv"); + var envelopePath = Path.Combine(artifactRoot, "rms-envelope.csv"); + var envelopeAutocorrPath = Path.Combine(artifactRoot, "rms-envelope-autocorr.csv"); + var analysisReportPath = Path.Combine(artifactRoot, "analysis.md"); + await File.WriteAllTextAsync(spectrogramPath, SpectrogramCsv(analysis.LogMelSpectrogram), Encoding.UTF8); + await File.WriteAllTextAsync(bandStatsPath, SpectrogramBandStatsCsv(analysis.LogMelSpectrogram), Encoding.UTF8); + await File.WriteAllTextAsync(envelopePath, EnvelopeCsv(analysis.RmsEnvelope, sampleRate, analyzer.Config.HopSize), Encoding.UTF8); + await File.WriteAllTextAsync(envelopeAutocorrPath, AutocorrelationCsv(analysis.RmsEnvelope, sampleRate / (float)Math.Max(1, analyzer.Config.HopSize)), Encoding.UTF8); var features = new SongChallengeFeatures( analysis.Features.DurationSeconds, analysis.Features.Peak, @@ -138,6 +147,12 @@ static async Task SongPrepareAsync(Dictionary options) register.RootNote, register.SuggestedScale, string.Join(",", register.ScaleFrequencies.Select(value => value.ToString("0.###", CultureInfo.InvariantCulture)))); + var artifacts = new SongChallengeAnalysisArtifacts( + spectrogramPath, + bandStatsPath, + envelopePath, + envelopeAutocorrPath, + analysisReportPath); var challenge = new SongChallenge( challengeId, source, @@ -147,7 +162,9 @@ static async Task SongPrepareAsync(Dictionary options) clipLength / (float)sampleRate, sampleRate, referenceWav, - features); + features, + artifacts); + await File.WriteAllTextAsync(analysisReportPath, SongAnalysisReport(challenge, analysis), Encoding.UTF8); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(output))!); await File.WriteAllTextAsync(output, JsonSerializer.Serialize(challenge, JsonOptions()), Encoding.UTF8); await File.WriteAllTextAsync(Path.Combine(artifactRoot, "challenge.md"), SongChallengeReport(challenge), Encoding.UTF8); @@ -178,6 +195,8 @@ static async Task SongScoreAsync(Dictionary options) Directory.CreateDirectory(candidateRoot); Directory.CreateDirectory(timelineRoot); var analyzer = new AudioAnalyzer(new AudioAnalysisConfig(SampleRate: challenge.SampleRate)); + var challengeEvidence = SongChallengeEvidenceDocuments(challenge).ToArray(); + await IpaTrialResultCultCacheStore.UpsertSongChallengeEvidenceAsync(store, challengeEvidence); var results = new List(); foreach (var candidate in candidates) @@ -212,6 +231,9 @@ static async Task SongScoreAsync(Dictionary options) Artifact("candidate-dsp", dspPath), Artifact("primitive-timeline", timelinePath) }; + metrics.AddRange(SongTargetMetrics(challenge, challengeEvidence)); + artifacts.AddRange(SongChallengeArtifacts(challenge)); + artifacts.AddRange(challengeEvidence.Select(SongChallengeEvidenceArtifact)); var timelineFacts = PrimitiveTimelineFactExtractor.Extract(timeline).ToArray(); string verdict; string evaluation; @@ -263,7 +285,8 @@ static async Task SongScoreAsync(Dictionary options) verdict, [ "This is a local-only song clip challenge; reference audio is not redistributed by the repo.", - "The target is a randomly selected ten-second scene-audio snippet, not IPA articulation truth.", + $"The target is a randomly selected {challenge.DurationSeconds:0.###}-second scene-audio snippet, not IPA articulation truth.", + "Challenge spectrogram, derivative, envelope, and autocorrelation artifacts are stored as typed CultMesh/CultCache evidence documents in the .cc database.", "Full-patch FM/AM/noise/scene modeling is allowed; primitive timeline facts remain diagnostic when present." ], [ @@ -1145,6 +1168,12 @@ static string? ResolveArtifactPath(string uri, string store) static IEnumerable BuildEvidenceChunks(IReadOnlyList results, string store) { + var songEvidenceByChallenge = IpaTrialResultCultCacheStore.ReadSongChallengeEvidenceAsync(store) + .GetAwaiter() + .GetResult() + .GroupBy(document => document.ChallengeId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase); + foreach (var result in results) { var text = new StringBuilder(); @@ -1182,6 +1211,29 @@ static IEnumerable BuildEvidenceChunks(IReadOnlyList static SpeechRenderArtifact Artifact(string kind, string path) => new(kind, path, File.Exists(path) ? Sha256(path) : ""); +static IEnumerable SongChallengeArtifacts(SongChallenge challenge) +{ + if (challenge.Artifacts is null) + { + yield break; + } + + yield return Artifact("target-analysis-report", challenge.Artifacts.AnalysisReportMarkdown); + yield return Artifact("target-logmel-spectrogram", challenge.Artifacts.LogMelSpectrogramCsv); + yield return Artifact("target-logmel-band-stats", challenge.Artifacts.LogMelBandStatsCsv); + yield return Artifact("target-rms-envelope", challenge.Artifacts.RmsEnvelopeCsv); + yield return Artifact("target-rms-envelope-autocorr", challenge.Artifacts.RmsEnvelopeAutocorrCsv); +} + +static SpeechRenderArtifact SongChallengeEvidenceArtifact(SongChallengeEvidenceDocument document) => + new( + $"cultmesh-song-challenge-{document.Kind}", + $"cultmesh://aquasynth/song-challenge-evidence/{Uri.EscapeDataString(document.EvidenceId)}", + document.ContentHash); + +static IReadOnlyList SongChallengeEvidenceDocuments(SongChallenge challenge) +{ + var documents = new List(); + if (challenge.Artifacts is null) + { + return documents; + } + + AddSongChallengeEvidence(documents, challenge, "analysis-report", "text/markdown", challenge.Artifacts.AnalysisReportMarkdown); + AddSongChallengeEvidence(documents, challenge, "logmel-spectrogram", "text/csv", challenge.Artifacts.LogMelSpectrogramCsv); + AddSongChallengeEvidence(documents, challenge, "logmel-band-stats", "text/csv", challenge.Artifacts.LogMelBandStatsCsv); + AddSongChallengeEvidence(documents, challenge, "rms-envelope", "text/csv", challenge.Artifacts.RmsEnvelopeCsv); + AddSongChallengeEvidence(documents, challenge, "rms-envelope-autocorr", "text/csv", challenge.Artifacts.RmsEnvelopeAutocorrCsv); + return documents; +} + +static void AddSongChallengeEvidence( + List documents, + SongChallenge challenge, + string kind, + string contentType, + string path) +{ + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + return; + } + + documents.Add(new SongChallengeEvidenceDocument( + $"{challenge.ChallengeId}:{kind}", + challenge.ChallengeId, + kind, + contentType, + File.ReadAllText(path, Encoding.UTF8), + Sha256(path), + challenge.SourcePath, + DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture))); +} + +static AutocorrelationPoint ReadAutocorrPeak(string? path) +{ + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + return new AutocorrelationPoint(0, 0, 0); + } + + return File.ReadLines(path, Encoding.UTF8) + .Skip(1) + .Select(line => line.Split(',')) + .Where(parts => parts.Length >= 3) + .Select(parts => new AutocorrelationPoint( + int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var lagFrames) ? lagFrames : 0, + float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var lagSeconds) ? lagSeconds : 0, + float.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var correlation) ? correlation : 0)) + .OrderByDescending(point => point.Correlation) + .FirstOrDefault() ?? new AutocorrelationPoint(0, 0, 0); +} + static string Sha256(string path) { using var stream = File.OpenRead(path); @@ -1945,6 +2075,187 @@ static float[] RmsEnvelope(IReadOnlyList samples, int frameSize) return envelope.ToArray(); } +static string SpectrogramCsv(Spectrogram spectrogram) +{ + var builder = new StringBuilder(); + builder.AppendLine("frame,band,value"); + for (var frame = 0; frame < spectrogram.Frames; frame++) + { + for (var band = 0; band < spectrogram.Bands; band++) + { + builder.Append(frame).Append(','); + builder.Append(band).Append(','); + builder.AppendLine(spectrogram.At(frame, band).ToString("0.########", CultureInfo.InvariantCulture)); + } + } + + return builder.ToString(); +} + +static string SpectrogramBandStatsCsv(Spectrogram spectrogram) +{ + var builder = new StringBuilder(); + builder.AppendLine("band,mean,min,max,stddev,delta_mean,abs_delta_mean,delta2_mean,abs_delta2_mean"); + foreach (var band in SpectrogramBandStats(spectrogram)) + { + builder.Append(band.Band).Append(','); + builder.Append(band.Mean.ToString("0.########", CultureInfo.InvariantCulture)).Append(','); + builder.Append(band.Min.ToString("0.########", CultureInfo.InvariantCulture)).Append(','); + builder.Append(band.Max.ToString("0.########", CultureInfo.InvariantCulture)).Append(','); + builder.Append(band.StdDev.ToString("0.########", CultureInfo.InvariantCulture)).Append(','); + builder.Append(band.DeltaMean.ToString("0.########", CultureInfo.InvariantCulture)).Append(','); + builder.Append(band.AbsDeltaMean.ToString("0.########", CultureInfo.InvariantCulture)).Append(','); + builder.Append(band.Delta2Mean.ToString("0.########", CultureInfo.InvariantCulture)).Append(','); + builder.AppendLine(band.AbsDelta2Mean.ToString("0.########", CultureInfo.InvariantCulture)); + } + + return builder.ToString(); +} + +static IReadOnlyList SpectrogramBandStats(Spectrogram spectrogram) +{ + var summaries = new List(); + for (var band = 0; band < spectrogram.Bands; band++) + { + var values = new float[Math.Max(0, spectrogram.Frames)]; + for (var frame = 0; frame < values.Length; frame++) + { + values[frame] = spectrogram.At(frame, band); + } + + var deltas = new float[Math.Max(0, values.Length - 1)]; + for (var index = 1; index < values.Length; index++) + { + deltas[index - 1] = values[index] - values[index - 1]; + } + + var delta2 = new float[Math.Max(0, deltas.Length - 1)]; + for (var index = 1; index < deltas.Length; index++) + { + delta2[index - 1] = deltas[index] - deltas[index - 1]; + } + + summaries.Add(new SpectrogramBandSummary( + band, + Mean(values), + values.Length == 0 ? 0 : values.Min(), + values.Length == 0 ? 0 : values.Max(), + StdDev(values), + Mean(deltas), + MeanAbs(deltas), + Mean(delta2), + MeanAbs(delta2))); + } + + return summaries; +} + +static string EnvelopeCsv(IReadOnlyList envelope, float sampleRate, int hopSize) +{ + var builder = new StringBuilder(); + builder.AppendLine("frame,time_seconds,rms"); + var frameRate = sampleRate / Math.Max(1, hopSize); + for (var frame = 0; frame < envelope.Count; frame++) + { + builder.Append(frame).Append(','); + builder.Append((frame / Math.Max(1e-6f, frameRate)).ToString("0.########", CultureInfo.InvariantCulture)).Append(','); + builder.AppendLine(envelope[frame].ToString("0.########", CultureInfo.InvariantCulture)); + } + + return builder.ToString(); +} + +static string AutocorrelationCsv(IReadOnlyList values, float frameRate) +{ + var builder = new StringBuilder(); + builder.AppendLine("lag_frames,lag_seconds,correlation"); + foreach (var point in Autocorrelation(values, frameRate, Math.Min(Math.Max(1, values.Count - 1), 512))) + { + builder.Append(point.LagFrames).Append(','); + builder.Append(point.LagSeconds.ToString("0.########", CultureInfo.InvariantCulture)).Append(','); + builder.AppendLine(point.Correlation.ToString("0.########", CultureInfo.InvariantCulture)); + } + + return builder.ToString(); +} + +static IReadOnlyList Autocorrelation(IReadOnlyList values, float frameRate, int maxLags) +{ + if (values.Count < 2) + { + return []; + } + + var mean = Mean(values); + var centered = values.Select(value => value - mean).ToArray(); + var energy = centered.Sum(value => value * value); + if (energy <= 1e-12f) + { + return []; + } + + var limit = Math.Min(maxLags, values.Count - 1); + var points = new List(limit); + for (var lag = 1; lag <= limit; lag++) + { + var sum = 0f; + for (var index = 0; index + lag < centered.Length; index++) + { + sum += centered[index] * centered[index + lag]; + } + + points.Add(new AutocorrelationPoint( + lag, + lag / Math.Max(1e-6f, frameRate), + sum / energy)); + } + + return points; +} + +static float Mean(IReadOnlyList values) => + values.Count == 0 ? 0 : values.Sum() / values.Count; + +static float MeanAbs(IReadOnlyList values) => + values.Count == 0 ? 0 : values.Sum(value => MathF.Abs(value)) / values.Count; + +static float StdDev(IReadOnlyList values) +{ + if (values.Count == 0) + { + return 0; + } + + var mean = Mean(values); + var variance = values.Sum(value => (value - mean) * (value - mean)) / values.Count; + return MathF.Sqrt(Math.Max(0, variance)); +} + +static IReadOnlyList SongTargetMetrics( + SongChallenge challenge, + IReadOnlyList evidence) +{ + var metrics = new List + { + new("target_duration_seconds", challenge.DurationSeconds, 0), + new("target_rms", challenge.Features.Rms, 0), + new("target_active_duty", challenge.Features.ActiveDuty, 0), + new("target_spectral_centroid_hz", challenge.Features.SpectralCentroidHz, 0), + new("target_spectral_flux", challenge.Features.SpectralFlux, 0), + new("target_tempo_bpm", challenge.Features.TempoBpm, 0), + new("target_tempo_confidence", challenge.Features.TempoConfidence, 0), + new("target_dominant_hz", challenge.Features.DominantHz, 0), + new("target_register_low_hz", challenge.Features.RegisterLowHz, 0), + new("target_register_high_hz", challenge.Features.RegisterHighHz, 0), + new("target_evidence_document_count", evidence.Count, 0) + }; + + var autocorrPeak = ReadAutocorrPeak(challenge.Artifacts?.RmsEnvelopeAutocorrCsv); + metrics.Add(new SpeechScoreMetric("target_envelope_autocorr_peak", autocorrPeak.Correlation, 0)); + metrics.Add(new SpeechScoreMetric("target_envelope_autocorr_peak_lag_seconds", autocorrPeak.LagSeconds, 0)); + return metrics; +} + static IReadOnlyList SongComparisonMetrics(AudioComparison comparison) => [ new("log_mel_cosine", comparison.LogMelCosineSimilarity, .25f), @@ -1983,6 +2294,41 @@ static float SongConfidence(IReadOnlyList metrics) static string SongEvaluationSentence(SongChallenge challenge, AudioComparison comparison, string verdict) => $"Song snippet `{challenge.ChallengeId}` is `{verdict}`: logMelCosine={comparison.LogMelCosineSimilarity:0.0000}, logMelDistance={comparison.LogMelDistance:0.0000}, score={comparison.Score:0.0000}, rmsRatio={comparison.RmsRatio:0.0000}, centroidRatio={comparison.CentroidRatio:0.0000}."; +static string SongAnalysisReport(SongChallenge challenge, AudioAnalysis analysis) +{ + var bands = SpectrogramBandStats(analysis.LogMelSpectrogram); + var brightest = bands.OrderByDescending(band => band.Mean).Take(5).Select(band => band.Band).ToArray(); + var mostMoving = bands.OrderByDescending(band => band.AbsDeltaMean).Take(5).Select(band => band.Band).ToArray(); + var mostAccelerating = bands.OrderByDescending(band => band.AbsDelta2Mean).Take(5).Select(band => band.Band).ToArray(); + var autocorr = Autocorrelation(analysis.RmsEnvelope, analysis.RmsEnvelope.Length / Math.Max(1f, challenge.DurationSeconds), maxLags: 64) + .OrderByDescending(point => point.Correlation) + .Take(5) + .ToArray(); + var builder = new StringBuilder(); + builder.AppendLine($"# Song Challenge Analysis: {challenge.ChallengeId}"); + builder.AppendLine(); + builder.AppendLine($"spectrogram_frames: `{analysis.LogMelSpectrogram.Frames}`"); + builder.AppendLine($"spectrogram_bands: `{analysis.LogMelSpectrogram.Bands}`"); + builder.AppendLine($"rms_envelope_frames: `{analysis.RmsEnvelope.Length}`"); + builder.AppendLine($"brightest_bands: `{string.Join(",", brightest)}`"); + builder.AppendLine($"highest_delta_bands: `{string.Join(",", mostMoving)}`"); + builder.AppendLine($"highest_delta2_bands: `{string.Join(",", mostAccelerating)}`"); + builder.AppendLine(); + builder.AppendLine("## Strong Envelope Autocorrelation Lags"); + foreach (var point in autocorr) + { + builder.AppendLine(CultureInfo.InvariantCulture, $"- lag_frames `{point.LagFrames}`, lag_seconds `{point.LagSeconds:0.######}`, correlation `{point.Correlation:0.######}`"); + } + + builder.AppendLine(); + builder.AppendLine("## Artifact Paths"); + builder.AppendLine($"- log_mel_spectrogram_csv: `{challenge.Artifacts?.LogMelSpectrogramCsv}`"); + builder.AppendLine($"- log_mel_band_stats_csv: `{challenge.Artifacts?.LogMelBandStatsCsv}`"); + builder.AppendLine($"- rms_envelope_csv: `{challenge.Artifacts?.RmsEnvelopeCsv}`"); + builder.AppendLine($"- rms_envelope_autocorr_csv: `{challenge.Artifacts?.RmsEnvelopeAutocorrCsv}`"); + return builder.ToString(); +} + static string SongChallengeReport(SongChallenge challenge) => $""" # Song Snippet Challenge: {challenge.ChallengeId} @@ -2012,6 +2358,13 @@ static string SongChallengeReport(SongChallenge challenge) => root_note: `{challenge.Features.RootNote}` suggested_scale: `{challenge.Features.SuggestedScale}` scale_frequencies_hz: `{challenge.Features.ScaleFrequenciesHz}` + + ## Analysis Artifacts + analysis_report: `{challenge.Artifacts?.AnalysisReportMarkdown ?? ""}` + log_mel_spectrogram_csv: `{challenge.Artifacts?.LogMelSpectrogramCsv ?? ""}` + log_mel_band_stats_csv: `{challenge.Artifacts?.LogMelBandStatsCsv ?? ""}` + rms_envelope_csv: `{challenge.Artifacts?.RmsEnvelopeCsv ?? ""}` + rms_envelope_autocorr_csv: `{challenge.Artifacts?.RmsEnvelopeAutocorrCsv ?? ""}` """; static string SongComparisonReport(SongChallenge challenge, IpaTrialScriptCandidate candidate, AudioComparison comparison, string verdict) => @@ -2214,7 +2567,15 @@ sealed record SongChallenge( float DurationSeconds, int SampleRate, string ReferenceWavPath, - SongChallengeFeatures Features); + SongChallengeFeatures Features, + SongChallengeAnalysisArtifacts? Artifacts = null); + +sealed record SongChallengeAnalysisArtifacts( + string LogMelSpectrogramCsv, + string LogMelBandStatsCsv, + string RmsEnvelopeCsv, + string RmsEnvelopeAutocorrCsv, + string AnalysisReportMarkdown); sealed record SongChallengeFeatures( float DurationSeconds, @@ -2243,6 +2604,22 @@ sealed record SongRegister( string SuggestedScale, float[] ScaleFrequencies); +sealed record SpectrogramBandSummary( + int Band, + float Mean, + float Min, + float Max, + float StdDev, + float DeltaMean, + float AbsDeltaMean, + float Delta2Mean, + float AbsDelta2Mean); + +sealed record AutocorrelationPoint( + int LagFrames, + float LagSeconds, + float Correlation); + sealed record SearchOptions( string QdrantUrl, string OllamaUrl, diff --git a/tools/run-song-snippet-trial-swarm.ps1 b/tools/run-song-snippet-trial-swarm.ps1 index 422cf0c..54a9da7 100644 --- a/tools/run-song-snippet-trial-swarm.ps1 +++ b/tools/run-song-snippet-trial-swarm.ps1 @@ -2,9 +2,11 @@ param( [int]$Passes = 1, [int]$AgentsPerPass = 5, [string]$Source = "D:\Music\Reinier\Heyoka\Gate Code\Heyoka - Alien Gibberish.mp3", - [float]$DurationSeconds = 10, + [string]$SourceFolder = "", + [float]$DurationSeconds = 30, [int]$Seed = 0, [switch]$NewSegmentPerPass, + [switch]$RandomSourcePerAgent, [string]$CodexCommand = "codex", [string]$CodexModel = "gpt-5.3-codex" ) @@ -132,6 +134,26 @@ function Assert-SongCandidates { } } +function Get-SongSources { + param( + [string]$Source, + [string]$SourceFolder + ) + + if ($SourceFolder.Length -gt 0) { + $files = @(Get-ChildItem -LiteralPath $SourceFolder -File | + Where-Object { $_.Extension -in @(".mp3", ".wav", ".flac", ".aiff", ".aif", ".ogg") } | + Sort-Object Name) + if ($files.Count -eq 0) { + throw "No supported song files found in '$SourceFolder'." + } + + return @($files | ForEach-Object { $_.FullName }) + } + + return @($Source) +} + $repoRoot = Resolve-RepoRoot $codex = Get-Command $CodexCommand -ErrorAction Stop $loopId = New-Timestamp @@ -140,6 +162,7 @@ $logRoot = Join-Path $loopRoot "logs" $workerProject = Join-Path $repoRoot "tools/IpaTrialWorker/IpaTrialWorker.csproj" $storePath = Join-Path $loopRoot "song-trial-results.cc" $seedValue = if ($Seed -eq 0) { Get-Random -Minimum 1 -Maximum ([int]::MaxValue) } else { $Seed } +$songSources = @(Get-SongSources -Source $Source -SourceFolder $SourceFolder) New-Item -ItemType Directory -Force -Path $loopRoot, $logRoot | Out-Null Set-Content -LiteralPath (Join-Path $loopRoot "loop-index.md") -Value @" @@ -147,12 +170,15 @@ Set-Content -LiteralPath (Join-Path $loopRoot "loop-index.md") -Value @" - repo: $repoRoot - source: $Source +- source_folder: $SourceFolder +- song_sources: $($songSources -join '; ') - codex: $($codex.Source) - passes: $Passes - agents_per_pass: $AgentsPerPass - seed: $seedValue - duration_seconds: $DurationSeconds - new_segment_per_pass: $NewSegmentPerPass +- random_source_per_agent: $RandomSourcePerAgent - store: $storePath - worker: $workerProject @@ -162,35 +188,8 @@ for ($pass = 1; $pass -le $Passes; $pass++) { $passId = "pass-{0:000}" -f $pass $passDir = Join-Path $loopRoot $passId $patchRoot = Join-Path $passDir "proposed-patches" - $challengeRoot = Join-Path $passDir "challenge" - $challengePath = Join-Path $challengeRoot "challenge.json" $phaseSeed = if ($NewSegmentPerPass) { $seedValue + (($pass - 1) * 7919) } else { $seedValue } - New-Item -ItemType Directory -Force -Path $passDir, $patchRoot, $challengeRoot | Out-Null - - Invoke-LoggedProcess ` - -FilePath "dotnet" ` - -ArgumentList @( - "run", - "--project", - $workerProject, - "--", - "song-prepare", - "--source", - $Source, - "--artifact-root", - $challengeRoot, - "--duration-seconds", - ([string]$DurationSeconds), - "--seed", - ([string]$phaseSeed), - "--challenge-id", - "song-snippet-$loopId-$passId", - "--output", - $challengePath - ) ` - -LogPath (Join-Path $logRoot "$passId-challenge-prepare.log") - - $challengeMarkdown = Join-Path $challengeRoot "challenge.md" + New-Item -ItemType Directory -Force -Path $passDir, $patchRoot | Out-Null $preEvidence = Join-Path $passDir "semantic-search-before.md" if (Test-Path -LiteralPath $storePath) { @@ -224,7 +223,40 @@ for ($pass = 1; $pass -le $Passes; $pass++) { $agentId = "agent-{0:00}" -f $agent $agentDir = Join-Path $passDir $agentId $agentPatchDir = Join-Path $patchRoot $agentId - New-Item -ItemType Directory -Force -Path $agentDir, $agentPatchDir | Out-Null + $agentChallengeRoot = Join-Path $agentDir "challenge" + $agentChallengePath = Join-Path $agentChallengeRoot "challenge.json" + New-Item -ItemType Directory -Force -Path $agentDir, $agentPatchDir, $agentChallengeRoot | Out-Null + $sourceIndex = if ($RandomSourcePerAgent) { + (($phaseSeed + ($agent * 1543)) % $songSources.Count) + } + else { + (($phaseSeed - $seedValue) / 7919) % $songSources.Count + } + $agentSource = $songSources[[int]$sourceIndex] + $agentSeed = $phaseSeed + ($agent * 104729) + Invoke-LoggedProcess ` + -FilePath "dotnet" ` + -ArgumentList @( + "run", + "--project", + $workerProject, + "--", + "song-prepare", + "--source", + $agentSource, + "--artifact-root", + $agentChallengeRoot, + "--duration-seconds", + ([string]$DurationSeconds), + "--seed", + ([string]$agentSeed), + "--challenge-id", + "song-snippet-$loopId-$passId-$agentId", + "--output", + $agentChallengePath + ) ` + -LogPath (Join-Path $logRoot "$passId-$agentId-challenge-prepare.log") + $challengeMarkdown = Join-Path $agentChallengeRoot "challenge.md" $outputPath = Join-Path $agentDir "hypothesis-agent.md" $promptPath = Join-Path $agentDir "hypothesis-agent.prompt.md" $logPath = Join-Path $logRoot "$passId-$agentId-hypothesis-agent.log" @@ -232,26 +264,32 @@ for ($pass = 1; $pass -le $Passes; $pass++) { You are an AquaSynth song-snippet parity worker. Challenge: -- frozen challenge JSON: $challengePath +- frozen challenge JSON: $agentChallengePath - human-readable challenge report: $challengeMarkdown - shared prior evidence: $preEvidence - patch output directory: $agentPatchDir - candidate filename prefix: $agentId - phase id: $passId - phase seed: $phaseSeed +- assigned source: $agentSource +- agent seed: $agentSeed +- required duration: $DurationSeconds seconds Goal: -Write exactly one parseable AquaSynth `.aqua` patch that tries to reproduce the frozen 10 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. +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. 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. +- Use spectrogram band means, first derivatives, second derivatives, and envelope autocorrelation peaks as evidence for which voices should be steady, pulsed, noisy, bright, or accelerating. - Use those timing facts to build rhythmic controls. - Current sequencing syntax is Strudel-ish through ordinary AquaSynth parameter curves: - declare a parameter: `param path=/seq/kick default=0 min=0 max=1 step=.001` - step a pattern with hold interpolation: `curve name=kick_seq path=/seq/kick points=0:1,0.12:0,0.48:1,0.60:0 interp=hold loop=true` + - new sugar now exists: `pattern name=kick_seq path=/seq/kick pattern=x..x step= high=1 low=0` - use beat-derived times from `beat_seconds`; eighth notes are `beat_seconds/2`, sixteenths are `beat_seconds/4`. - bind parameters into voices or primitives, for example `gain=@/seq/kick`, `tremolo_hz=`, `noise=@/seq/hiss`, `freq=@/seq/pitch`. -- Do not invent unimplemented syntax like `s("bd sn")` unless you also make a source-edit plan; use `param` + `curve interp=hold loop=true` as the live DSP sequencing surface. +- Do not invent unimplemented syntax like `s("bd sn")` inside the `.aqua` candidate. You may propose it in `instrument-conventions.md`. Register and scale: - The challenge report includes `dominant_hz`, `register_low_hz`, `register_high_hz`, `root_note`, `suggested_scale`, and `scale_frequencies_hz`. @@ -259,8 +297,9 @@ Register and scale: - Current scale sugar is explicit frequency sequencing: - declare pitch: `param path=/seq/pitch default= min= max= step=.01 unit=Hz` - step the scale: `curve name=lead_scale path=/seq/pitch points=0:,0.25:,0.5:,0.75: interp=hold loop=true` + - new sugar now exists: `scale name=lead_scale path=/seq/pitch freqs= degrees=0,1,3,2 step=` - bind it: `freq=@/seq/pitch` -- Do not invent native `scale`, `note`, or Strudel pattern syntax in the patch unless you also edit the DSL/lowering source and document the new syntax. For candidate patches, explicit scale frequencies are the contract. +- Candidate patches may use implemented `pattern` and `scale` sugar or the explicit lowered `param`/`curve` form. Proposed future sugar goes in the report. Instrument and DSL convention invention: - Invent at least one reusable instrument role for this segment, such as `alien-lead`, `dust-hat`, `codec-bed`, `rubber-bass`, `vowel-drone`, or another precise name. @@ -269,14 +308,14 @@ Instrument and DSL convention invention: - the control surfaces each role would want; - any Strudel-like sugar that would make the patch shorter; - the exact lowering into today's AquaSynth syntax using explicit `voice`, `param`, and `curve` lines. -- The `.aqua` candidate must use today's implemented syntax only. Proposed sugar is evidence for the next DSL cut, not magic accepted by the parser. +- The `.aqua` candidate must use today's implemented syntax only: `pattern`, `scale`, `param`, `curve`, and ordinary patch/voice syntax are legal. Proposed future sugar is evidence for the next DSL cut, not magic accepted by the parser. Evidence contract: - Read $challengeMarkdown and $preEvidence. -- Write $agentDir/hypotheses.md with: reference features, tempo/rhythm plan, register/scale plan, scene voices, synthesis owners, invented instrument roles, expected metric movement, known risks. +- 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. -- Make the patch duration/gates cover the 10 second target. +- Make the patch duration/gates cover the $DurationSeconds second target. Return a short final summary naming the patch and report. "@ @@ -294,28 +333,32 @@ Return a short final summary naming the patch and report. Wait-CodexJobs -Jobs $jobs Assert-SongCandidates -PatchDirectory $patchRoot -ExpectedCount $AgentsPerPass - Invoke-LoggedProcess ` - -FilePath "dotnet" ` - -ArgumentList @( - "run", - "--project", - $workerProject, - "--", - "song-score", - "--patch-root", - $patchRoot, - "--challenge", - $challengePath, - "--artifact-root", - $passDir, - "--batch-id", - $passId, - "--store", - $storePath, - "--hypothesizer", - "external-codex-song-swarm-$passId" - ) ` - -LogPath (Join-Path $logRoot "$passId-score-candidates.log") + for ($agent = 1; $agent -le $AgentsPerPass; $agent++) { + $agentId = "agent-{0:00}" -f $agent + $agentDir = Join-Path $passDir $agentId + Invoke-LoggedProcess ` + -FilePath "dotnet" ` + -ArgumentList @( + "run", + "--project", + $workerProject, + "--", + "song-score", + "--patch-root", + (Join-Path $patchRoot $agentId), + "--challenge", + (Join-Path $agentDir "challenge/challenge.json"), + "--artifact-root", + $agentDir, + "--batch-id", + "$passId-$agentId", + "--store", + $storePath, + "--hypothesizer", + "external-codex-song-swarm-$passId-$agentId" + ) ` + -LogPath (Join-Path $logRoot "$passId-$agentId-score-candidate.log") + } Invoke-LoggedProcess ` -FilePath "dotnet" `