Add song snippet trial swarm
This commit is contained in:
parent
06875c1eef
commit
d5975f6726
8 changed files with 1253 additions and 6 deletions
|
|
@ -5,6 +5,10 @@
|
|||
<ProjectReference Include="..\..\src\AquaSynth.Faust\AquaSynth.Faust.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NAudio" Version="2.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Globalization;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
|
@ -7,6 +8,7 @@ using System.Text.Json.Serialization;
|
|||
|
||||
using AquaSynth.Dsl;
|
||||
using AquaSynth.Faust;
|
||||
using NAudio.Wave;
|
||||
|
||||
var command = args.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(command) || command is "-h" or "--help")
|
||||
|
|
@ -26,6 +28,12 @@ try
|
|||
case "score":
|
||||
await ScoreAsync(options);
|
||||
return 0;
|
||||
case "song-prepare":
|
||||
await SongPrepareAsync(options);
|
||||
return 0;
|
||||
case "song-score":
|
||||
await SongScoreAsync(options);
|
||||
return 0;
|
||||
case "dump":
|
||||
await DumpAsync(options);
|
||||
return 0;
|
||||
|
|
@ -80,6 +88,205 @@ static async Task ScoreAsync(Dictionary<string, string> options)
|
|||
Console.WriteLine(store);
|
||||
}
|
||||
|
||||
static async Task SongPrepareAsync(Dictionary<string, string> options)
|
||||
{
|
||||
var source = Required(options, "source");
|
||||
var artifactRoot = Required(options, "artifact-root");
|
||||
var durationSeconds = FloatValue(options, "duration-seconds", 10f);
|
||||
var sampleRate = IntValue(options, "sample-rate", 44100);
|
||||
var seed = IntValue(options, "seed", RandomNumberGenerator.GetInt32(1, int.MaxValue));
|
||||
var challengeId = Value(options, "challenge-id", $"song-snippet-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfff}");
|
||||
var output = Value(options, "output", Path.Combine(artifactRoot, "challenge.json"));
|
||||
|
||||
if (!File.Exists(source))
|
||||
{
|
||||
throw new FileNotFoundException("Song source file was not found.", source);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(artifactRoot);
|
||||
var decoded = DecodeMono(source);
|
||||
var resampled = Resample(decoded.Samples, decoded.SampleRate, sampleRate);
|
||||
var clipLength = Math.Min(resampled.Length, Math.Max(1, (int)MathF.Round(durationSeconds * sampleRate)));
|
||||
var maxStart = Math.Max(0, resampled.Length - clipLength);
|
||||
var random = new Random(seed);
|
||||
var startSample = maxStart == 0 ? 0 : random.Next(0, maxStart + 1);
|
||||
var clip = new float[clipLength];
|
||||
Array.Copy(resampled, startSample, clip, 0, clipLength);
|
||||
NormalizePeak(clip, .9f);
|
||||
|
||||
var referenceWav = Path.Combine(artifactRoot, "reference.wav");
|
||||
WriteWav(referenceWav, clip, sampleRate);
|
||||
var analyzer = new AudioAnalyzer(new AudioAnalysisConfig(SampleRate: sampleRate));
|
||||
var analysis = analyzer.Analyze(clip);
|
||||
var tempo = EstimateTempo(clip, sampleRate);
|
||||
var register = EstimateRegister(clip, sampleRate, analysis.Features.SpectralCentroidHz);
|
||||
var features = new SongChallengeFeatures(
|
||||
analysis.Features.DurationSeconds,
|
||||
analysis.Features.Peak,
|
||||
analysis.Features.Rms,
|
||||
analysis.Features.ZeroCrossingRate,
|
||||
analysis.Features.SpectralCentroidHz,
|
||||
analysis.Features.SpectralRolloffHz,
|
||||
ActiveDuty(clip, sampleRate),
|
||||
SpectralFlux(clip, sampleRate),
|
||||
tempo.Bpm,
|
||||
tempo.BeatSeconds,
|
||||
tempo.Confidence,
|
||||
register.DominantHz,
|
||||
register.LowHz,
|
||||
register.HighHz,
|
||||
register.RootNote,
|
||||
register.SuggestedScale,
|
||||
string.Join(",", register.ScaleFrequencies.Select(value => value.ToString("0.###", CultureInfo.InvariantCulture))));
|
||||
var challenge = new SongChallenge(
|
||||
challengeId,
|
||||
source,
|
||||
Path.GetFileName(source),
|
||||
seed,
|
||||
startSample / (float)sampleRate,
|
||||
clipLength / (float)sampleRate,
|
||||
sampleRate,
|
||||
referenceWav,
|
||||
features);
|
||||
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);
|
||||
Console.WriteLine(output);
|
||||
}
|
||||
|
||||
static async Task SongScoreAsync(Dictionary<string, string> options)
|
||||
{
|
||||
var patchRoot = Required(options, "patch-root");
|
||||
var challengePath = Required(options, "challenge");
|
||||
var artifactRoot = Required(options, "artifact-root");
|
||||
var batchId = Value(options, "batch-id", $"song-round-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfff}");
|
||||
var store = Value(options, "store", Path.Combine(artifactRoot, "song-trial-results.cc"));
|
||||
var hypothesizer = Value(options, "hypothesizer", "external-codex-song-worker");
|
||||
var challenge = JsonSerializer.Deserialize<SongChallenge>(
|
||||
await File.ReadAllTextAsync(challengePath, Encoding.UTF8),
|
||||
JsonOptions()) ?? throw new InvalidDataException($"Could not read song challenge `{challengePath}`.");
|
||||
var referenceSamples = ReadMonoPcm16Wav(challenge.ReferenceWavPath);
|
||||
var candidates = SongCandidateScripts(patchRoot, hypothesizer);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException($"No .aqua song candidates found under `{patchRoot}`.");
|
||||
}
|
||||
|
||||
var batchDirectory = Path.Combine(artifactRoot, batchId);
|
||||
var candidateRoot = Path.Combine(batchDirectory, "song-candidates");
|
||||
var timelineRoot = Path.Combine(batchDirectory, "song-timelines");
|
||||
Directory.CreateDirectory(candidateRoot);
|
||||
Directory.CreateDirectory(timelineRoot);
|
||||
var analyzer = new AudioAnalyzer(new AudioAnalysisConfig(SampleRate: challenge.SampleRate));
|
||||
var results = new List<IpaTrialResult>();
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
var startedStamp = Stopwatch.GetTimestamp();
|
||||
var safeCandidateId = SafeName(candidate.CandidateId);
|
||||
var candidateDir = Path.Combine(candidateRoot, safeCandidateId);
|
||||
var audioDir = Path.Combine(candidateDir, "audio");
|
||||
Directory.CreateDirectory(audioDir);
|
||||
var scriptCopy = Path.Combine(candidateDir, "candidate.aqua");
|
||||
File.Copy(candidate.ScriptPath, scriptCopy, overwrite: true);
|
||||
var script = await File.ReadAllTextAsync(scriptCopy, Encoding.UTF8);
|
||||
var patch = PatchScript.Parse(script);
|
||||
var timelineNetwork = patch.VocalNetworks.FirstOrDefault();
|
||||
var timeline = timelineNetwork is null
|
||||
? Array.Empty<ProbeTimelineSample>()
|
||||
: ProbeTimelineReport.Build(patch, timelineNetwork.Name, 12);
|
||||
var timelinePath = Path.Combine(timelineRoot, $"{safeCandidateId}.csv");
|
||||
await File.WriteAllTextAsync(timelinePath, ProbeTimelineReport.ToCsv(timeline), Encoding.UTF8);
|
||||
var source = FaustEmitter.EmitScript(script, new FaustExportOptions(safeCandidateId)).Source;
|
||||
var dspPath = Path.Combine(candidateDir, "candidate.dsp");
|
||||
await File.WriteAllTextAsync(dspPath, source, Encoding.UTF8);
|
||||
|
||||
var candidateWav = Path.Combine(audioDir, "candidate.wav");
|
||||
var metrics = new List<SpeechScoreMetric>();
|
||||
var artifacts = new List<SpeechRenderArtifact>
|
||||
{
|
||||
Artifact("song-challenge", challengePath),
|
||||
Artifact("reference-wav", challenge.ReferenceWavPath),
|
||||
Artifact("candidate-script", scriptCopy),
|
||||
Artifact("candidate-dsp", dspPath),
|
||||
Artifact("primitive-timeline", timelinePath)
|
||||
};
|
||||
var timelineFacts = PrimitiveTimelineFactExtractor.Extract(timeline).ToArray();
|
||||
string verdict;
|
||||
string evaluation;
|
||||
var render = await FaustCompiler.RenderAsync(
|
||||
source,
|
||||
new FaustRenderOptions(challenge.SampleRate, challenge.DurationSeconds));
|
||||
if (render is null || render.Samples.Length == 0)
|
||||
{
|
||||
verdict = "render-failed";
|
||||
evaluation = render is null
|
||||
? "Faust was not available to render this song candidate."
|
||||
: $"Faust render produced no samples. stderr: {render.Stderr}";
|
||||
metrics.Add(new SpeechScoreMetric("render_failed", 1, 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
var matched = MatchLength(render.Samples, referenceSamples.Length);
|
||||
NormalizePeak(matched, .9f);
|
||||
WriteWav(candidateWav, matched, challenge.SampleRate);
|
||||
artifacts.Add(Artifact("candidate-wav", candidateWav));
|
||||
var comparison = analyzer.Compare(referenceSamples, matched);
|
||||
metrics.AddRange(SongComparisonMetrics(comparison));
|
||||
verdict = SongVerdict(comparison);
|
||||
evaluation = SongEvaluationSentence(challenge, comparison, verdict);
|
||||
var comparisonPath = Path.Combine(audioDir, "comparison.txt");
|
||||
await File.WriteAllTextAsync(comparisonPath, SongComparisonReport(challenge, candidate, comparison, verdict), Encoding.UTF8);
|
||||
artifacts.Add(Artifact("comparison-report", comparisonPath));
|
||||
}
|
||||
|
||||
var latency = Stopwatch.GetElapsedTime(startedStamp).TotalMilliseconds;
|
||||
results.Add(new IpaTrialResult(
|
||||
$"{batchId}:song-snippet:{safeCandidateId}",
|
||||
batchId,
|
||||
startedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
"song-snippet",
|
||||
["song", "snippet", "alien-gibberish"],
|
||||
challenge.ChallengeId,
|
||||
safeCandidateId,
|
||||
candidate.HypothesizerId,
|
||||
candidate.Hypothesis,
|
||||
scriptCopy,
|
||||
challenge.ReferenceWavPath,
|
||||
File.Exists(candidateWav) ? candidateWav : "",
|
||||
timelinePath,
|
||||
metrics.ToArray(),
|
||||
artifacts.ToArray(),
|
||||
"song-snippet-audio-evaluator",
|
||||
evaluation,
|
||||
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.",
|
||||
"Full-patch FM/AM/noise/scene modeling is allowed; primitive timeline facts remain diagnostic when present."
|
||||
],
|
||||
[
|
||||
new SpeechTimingReceipt(
|
||||
"song-snippet-render-score",
|
||||
startedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
latency,
|
||||
0,
|
||||
SongConfidence(metrics),
|
||||
"Local worker rendered an AquaSynth candidate against a frozen song snippet and wrote CultCache trial result data.")
|
||||
],
|
||||
timelineFacts));
|
||||
}
|
||||
|
||||
await IpaTrialResultCultCacheStore.UpsertResultsAsync(store, results);
|
||||
var summaryPath = Path.Combine(batchDirectory, "summary.csv");
|
||||
await File.WriteAllTextAsync(summaryPath, SongSummaryCsv(results), Encoding.UTF8);
|
||||
var reportPath = Path.Combine(batchDirectory, "evaluator-report.md");
|
||||
await File.WriteAllTextAsync(reportPath, SongEvaluatorReport(challenge, results), Encoding.UTF8);
|
||||
Console.WriteLine(batchDirectory);
|
||||
Console.WriteLine(store);
|
||||
}
|
||||
|
||||
static async Task DumpAsync(Dictionary<string, string> options)
|
||||
{
|
||||
var store = Required(options, "store");
|
||||
|
|
@ -409,6 +616,28 @@ static IReadOnlyList<IpaTrialScriptCandidate> CandidateScripts(string patchRoot,
|
|||
.ToArray();
|
||||
}
|
||||
|
||||
static IReadOnlyList<IpaTrialScriptCandidate> SongCandidateScripts(string patchRoot, string hypothesizer)
|
||||
{
|
||||
if (!Directory.Exists(patchRoot))
|
||||
{
|
||||
throw new DirectoryNotFoundException(patchRoot);
|
||||
}
|
||||
|
||||
return Directory.EnumerateFiles(patchRoot, "*.aqua", SearchOption.AllDirectories)
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(path =>
|
||||
{
|
||||
var stem = Path.GetFileNameWithoutExtension(path);
|
||||
return new IpaTrialScriptCandidate(
|
||||
"song-snippet",
|
||||
stem,
|
||||
path,
|
||||
hypothesizer,
|
||||
$"External Codex-authored song-snippet patch candidate `{stem}`.");
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
static string TargetIdFromStem(string stem, IReadOnlyList<string> targetIds)
|
||||
{
|
||||
var explicitSplit = stem.Split(["__"], StringSplitOptions.None);
|
||||
|
|
@ -1246,6 +1475,24 @@ static string TimelineFact(IpaTrialResult result, string name) =>
|
|||
.Max() ?? 0)
|
||||
.ToString("0.######", CultureInfo.InvariantCulture);
|
||||
|
||||
static SpeechRenderArtifact Artifact(string kind, string path) =>
|
||||
new(kind, path, File.Exists(path) ? Sha256(path) : "");
|
||||
|
||||
static string Sha256(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
var hash = SHA256.HashData(stream);
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
static string SafeName(string value) =>
|
||||
new(value.Select(character => char.IsLetterOrDigit(character) ? character : '_').ToArray());
|
||||
|
||||
static string Escape(string value) =>
|
||||
value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')
|
||||
? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\""
|
||||
: value;
|
||||
|
||||
static IpaTrialResult? ContrastCandidate(IpaTrialResult result, IReadOnlyList<IpaTrialResult> allResults)
|
||||
{
|
||||
var sameTarget = allResults
|
||||
|
|
@ -1286,9 +1533,550 @@ static string StableUuid(string id)
|
|||
return $"{hex[..8]}-{hex[8..12]}-{hex[12..16]}-{hex[16..20]}-{hex[20..32]}";
|
||||
}
|
||||
|
||||
static DecodedAudio DecodeMono(string path)
|
||||
{
|
||||
using var reader = new AudioFileReader(path);
|
||||
var interleaved = new float[4096 * reader.WaveFormat.Channels];
|
||||
var mono = new List<float>();
|
||||
int read;
|
||||
while ((read = reader.Read(interleaved, 0, interleaved.Length)) > 0)
|
||||
{
|
||||
for (var index = 0; index < read; index += reader.WaveFormat.Channels)
|
||||
{
|
||||
var sum = 0f;
|
||||
for (var channel = 0; channel < reader.WaveFormat.Channels && index + channel < read; channel++)
|
||||
{
|
||||
sum += interleaved[index + channel];
|
||||
}
|
||||
|
||||
mono.Add(sum / reader.WaveFormat.Channels);
|
||||
}
|
||||
}
|
||||
|
||||
return new DecodedAudio(reader.WaveFormat.SampleRate, mono.ToArray());
|
||||
}
|
||||
|
||||
static float[] Resample(IReadOnlyList<float> samples, int sourceRate, int targetRate)
|
||||
{
|
||||
if (sourceRate == targetRate)
|
||||
{
|
||||
return samples.ToArray();
|
||||
}
|
||||
|
||||
var result = new float[Math.Max(1, (int)Math.Round(samples.Count * (double)targetRate / sourceRate))];
|
||||
for (var index = 0; index < result.Length; index++)
|
||||
{
|
||||
var position = index * (sourceRate / (double)targetRate);
|
||||
var left = Math.Clamp((int)Math.Floor(position), 0, samples.Count - 1);
|
||||
var right = Math.Clamp(left + 1, 0, samples.Count - 1);
|
||||
var t = (float)(position - left);
|
||||
result[index] = samples[left] * (1 - t) + samples[right] * t;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static float[] MatchLength(IReadOnlyList<float> samples, int length)
|
||||
{
|
||||
var result = new float[length];
|
||||
for (var index = 0; index < result.Length && index < samples.Count; index++)
|
||||
{
|
||||
result[index] = samples[index];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static void NormalizePeak(IList<float> samples, float peak)
|
||||
{
|
||||
var current = samples.Select(MathF.Abs).DefaultIfEmpty(0).Max();
|
||||
if (current <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var gain = peak / current;
|
||||
for (var index = 0; index < samples.Count; index++)
|
||||
{
|
||||
samples[index] *= gain;
|
||||
}
|
||||
}
|
||||
|
||||
static float[] ReadMonoPcm16Wav(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
using var reader = new BinaryReader(stream, Encoding.ASCII);
|
||||
if (new string(reader.ReadChars(4)) != "RIFF")
|
||||
{
|
||||
throw new InvalidDataException($"`{path}` is not a RIFF WAV file.");
|
||||
}
|
||||
|
||||
reader.ReadInt32();
|
||||
if (new string(reader.ReadChars(4)) != "WAVE")
|
||||
{
|
||||
throw new InvalidDataException($"`{path}` is not a WAVE file.");
|
||||
}
|
||||
|
||||
short channels = 1;
|
||||
short bitsPerSample = 16;
|
||||
byte[]? data = null;
|
||||
while (stream.Position < stream.Length)
|
||||
{
|
||||
var chunkId = new string(reader.ReadChars(4));
|
||||
var chunkSize = reader.ReadInt32();
|
||||
if (chunkId == "fmt ")
|
||||
{
|
||||
var format = reader.ReadInt16();
|
||||
channels = reader.ReadInt16();
|
||||
reader.ReadInt32();
|
||||
reader.ReadInt32();
|
||||
reader.ReadInt16();
|
||||
bitsPerSample = reader.ReadInt16();
|
||||
if (chunkSize > 16)
|
||||
{
|
||||
reader.ReadBytes(chunkSize - 16);
|
||||
}
|
||||
|
||||
if (format != 1 || bitsPerSample != 16)
|
||||
{
|
||||
throw new InvalidDataException($"`{path}` must be PCM16.");
|
||||
}
|
||||
}
|
||||
else if (chunkId == "data")
|
||||
{
|
||||
data = reader.ReadBytes(chunkSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
reader.ReadBytes(chunkSize);
|
||||
}
|
||||
}
|
||||
|
||||
if (data is null)
|
||||
{
|
||||
throw new InvalidDataException($"`{path}` has no data chunk.");
|
||||
}
|
||||
|
||||
var samples = new List<float>(data.Length / Math.Max(1, channels * sizeof(short)));
|
||||
for (var offset = 0; offset + sizeof(short) * channels <= data.Length; offset += sizeof(short) * channels)
|
||||
{
|
||||
var sum = 0f;
|
||||
for (var channel = 0; channel < channels; channel++)
|
||||
{
|
||||
sum += BitConverter.ToInt16(data, offset + channel * sizeof(short)) / (float)short.MaxValue;
|
||||
}
|
||||
|
||||
samples.Add(sum / channels);
|
||||
}
|
||||
|
||||
return samples.ToArray();
|
||||
}
|
||||
|
||||
static void WriteWav(string path, IReadOnlyList<float> samples, int sampleRate)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
using var stream = File.Create(path);
|
||||
using var writer = new BinaryWriter(stream, Encoding.ASCII);
|
||||
var dataSize = samples.Count * sizeof(short);
|
||||
writer.Write("RIFF"u8);
|
||||
writer.Write(36 + dataSize);
|
||||
writer.Write("WAVE"u8);
|
||||
writer.Write("fmt "u8);
|
||||
writer.Write(16);
|
||||
writer.Write((short)1);
|
||||
writer.Write((short)1);
|
||||
writer.Write(sampleRate);
|
||||
writer.Write(sampleRate * sizeof(short));
|
||||
writer.Write((short)sizeof(short));
|
||||
writer.Write((short)16);
|
||||
writer.Write("data"u8);
|
||||
writer.Write(dataSize);
|
||||
foreach (var sample in samples)
|
||||
{
|
||||
writer.Write((short)Math.Clamp(MathF.Round(sample * short.MaxValue), short.MinValue, short.MaxValue));
|
||||
}
|
||||
}
|
||||
|
||||
static float ActiveDuty(IReadOnlyList<float> samples, int sampleRate)
|
||||
{
|
||||
var frameSize = Math.Max(64, sampleRate / 100);
|
||||
var peak = samples.Select(MathF.Abs).DefaultIfEmpty(0).Max();
|
||||
var threshold = Math.Max(.01f, peak * .04f);
|
||||
var active = 0;
|
||||
var frames = 0;
|
||||
for (var start = 0; start < samples.Count; start += frameSize)
|
||||
{
|
||||
var end = Math.Min(samples.Count, start + frameSize);
|
||||
var rms = MathF.Sqrt(samples.Skip(start).Take(end - start).Select(sample => sample * sample).DefaultIfEmpty(0).Average());
|
||||
if (rms >= threshold)
|
||||
{
|
||||
active++;
|
||||
}
|
||||
|
||||
frames++;
|
||||
}
|
||||
|
||||
return active / (float)Math.Max(1, frames);
|
||||
}
|
||||
|
||||
static float SpectralFlux(IReadOnlyList<float> samples, int sampleRate)
|
||||
{
|
||||
var frameSize = Math.Max(64, sampleRate / 100);
|
||||
var previous = 0f;
|
||||
var flux = 0f;
|
||||
var frames = 0;
|
||||
for (var start = 0; start < samples.Count; start += frameSize)
|
||||
{
|
||||
var end = Math.Min(samples.Count, start + frameSize);
|
||||
var rms = MathF.Sqrt(samples.Skip(start).Take(end - start).Select(sample => sample * sample).DefaultIfEmpty(0).Average());
|
||||
if (frames > 0)
|
||||
{
|
||||
flux += MathF.Abs(rms - previous);
|
||||
}
|
||||
|
||||
previous = rms;
|
||||
frames++;
|
||||
}
|
||||
|
||||
var peak = samples.Select(MathF.Abs).DefaultIfEmpty(0).Max();
|
||||
return frames <= 1 || peak <= 0 ? 0 : Math.Clamp(flux / (frames * peak), 0, 1);
|
||||
}
|
||||
|
||||
static TempoEstimate EstimateTempo(IReadOnlyList<float> samples, int sampleRate)
|
||||
{
|
||||
var hopSize = Math.Max(64, sampleRate / 100);
|
||||
var frameRate = sampleRate / (float)hopSize;
|
||||
var envelope = RmsEnvelope(samples, hopSize)
|
||||
.Select(value => MathF.Log(1e-7f + value))
|
||||
.ToArray();
|
||||
if (envelope.Length < 4)
|
||||
{
|
||||
return new TempoEstimate(0, 0, 0);
|
||||
}
|
||||
|
||||
var onset = new float[envelope.Length];
|
||||
for (var index = 1; index < envelope.Length; index++)
|
||||
{
|
||||
onset[index] = MathF.Max(0, envelope[index] - envelope[index - 1]);
|
||||
}
|
||||
|
||||
var mean = onset.Average();
|
||||
for (var index = 0; index < onset.Length; index++)
|
||||
{
|
||||
onset[index] = MathF.Max(0, onset[index] - mean);
|
||||
}
|
||||
|
||||
var minBpm = 60f;
|
||||
var maxBpm = 200f;
|
||||
var minLag = Math.Max(1, (int)MathF.Floor(frameRate * 60f / maxBpm));
|
||||
var maxLag = Math.Min(onset.Length - 1, (int)MathF.Ceiling(frameRate * 60f / minBpm));
|
||||
var bestLag = 0;
|
||||
var best = 0f;
|
||||
var energy = onset.Sum(value => value * value);
|
||||
if (energy <= 1e-9f)
|
||||
{
|
||||
return new TempoEstimate(0, 0, 0);
|
||||
}
|
||||
|
||||
for (var lag = minLag; lag <= maxLag; lag++)
|
||||
{
|
||||
var sum = 0f;
|
||||
for (var index = 0; index + lag < onset.Length; index++)
|
||||
{
|
||||
sum += onset[index] * onset[index + lag];
|
||||
}
|
||||
|
||||
var normalized = sum / energy;
|
||||
if (normalized > best)
|
||||
{
|
||||
best = normalized;
|
||||
bestLag = lag;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestLag <= 0)
|
||||
{
|
||||
return new TempoEstimate(0, 0, 0);
|
||||
}
|
||||
|
||||
var beatSeconds = bestLag / frameRate;
|
||||
var bpm = 60f / Math.Max(1e-6f, beatSeconds);
|
||||
return new TempoEstimate(bpm, beatSeconds, Math.Clamp(best, 0, 1));
|
||||
}
|
||||
|
||||
static SongRegister EstimateRegister(IReadOnlyList<float> samples, int sampleRate, float spectralCentroidHz)
|
||||
{
|
||||
var dominantHz = DominantFrequency(samples, sampleRate);
|
||||
if (dominantHz <= 0)
|
||||
{
|
||||
var fallback = Math.Clamp(spectralCentroidHz * .25f, 55f, 880f);
|
||||
dominantHz = float.IsFinite(fallback) ? fallback : 220f;
|
||||
}
|
||||
|
||||
var midi = (int)MathF.Round(69f + 12f * MathF.Log2(dominantHz / 440f));
|
||||
var rootMidi = midi;
|
||||
while (rootMidi > 72)
|
||||
{
|
||||
rootMidi -= 12;
|
||||
}
|
||||
|
||||
while (rootMidi < 36)
|
||||
{
|
||||
rootMidi += 12;
|
||||
}
|
||||
|
||||
var rootHz = MidiToFrequency(rootMidi);
|
||||
var lowHz = rootHz;
|
||||
while (lowHz > 220f)
|
||||
{
|
||||
lowHz *= .5f;
|
||||
}
|
||||
|
||||
while (lowHz < 55f)
|
||||
{
|
||||
lowHz *= 2f;
|
||||
}
|
||||
|
||||
var highHz = Math.Min(4000f, lowHz * 8f);
|
||||
var scaleName = spectralCentroidHz > 1800f ? "minor-pentatonic-plus-tritone" : "minor-pentatonic";
|
||||
var intervals = scaleName == "minor-pentatonic-plus-tritone"
|
||||
? new[] { 0, 3, 5, 6, 7, 10, 12, 15, 17, 18, 19, 22, 24 }
|
||||
: new[] { 0, 3, 5, 7, 10, 12, 15, 17, 19, 22, 24 };
|
||||
var scaleFrequencies = intervals
|
||||
.Select(interval => MidiToFrequency(rootMidi + interval))
|
||||
.Where(value => value >= lowHz * .9f && value <= highHz * 1.1f)
|
||||
.ToArray();
|
||||
|
||||
return new SongRegister(
|
||||
dominantHz,
|
||||
lowHz,
|
||||
highHz,
|
||||
NoteName(rootMidi),
|
||||
scaleName,
|
||||
scaleFrequencies);
|
||||
}
|
||||
|
||||
static float DominantFrequency(IReadOnlyList<float> samples, int sampleRate)
|
||||
{
|
||||
if (samples.Count < 128)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var windowSize = Math.Min(samples.Count, Math.Max(2048, sampleRate / 5));
|
||||
var hopSize = Math.Max(256, windowSize / 4);
|
||||
var bestStart = 0;
|
||||
var bestEnergy = 0f;
|
||||
for (var start = 0; start + windowSize <= samples.Count; start += hopSize)
|
||||
{
|
||||
var energy = 0f;
|
||||
for (var index = start; index < start + windowSize; index++)
|
||||
{
|
||||
energy += samples[index] * samples[index];
|
||||
}
|
||||
|
||||
if (energy > bestEnergy)
|
||||
{
|
||||
bestEnergy = energy;
|
||||
bestStart = start;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestEnergy <= 1e-9f)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var minLag = Math.Max(1, sampleRate / 2000);
|
||||
var maxLag = Math.Min(windowSize - 1, sampleRate / 40);
|
||||
var bestLag = 0;
|
||||
var bestScore = 0f;
|
||||
for (var lag = minLag; lag <= maxLag; lag++)
|
||||
{
|
||||
var sum = 0f;
|
||||
var left = 0f;
|
||||
var right = 0f;
|
||||
for (var offset = 0; offset + lag < windowSize; offset++)
|
||||
{
|
||||
var a = samples[bestStart + offset];
|
||||
var b = samples[bestStart + offset + lag];
|
||||
sum += a * b;
|
||||
left += a * a;
|
||||
right += b * b;
|
||||
}
|
||||
|
||||
var normalized = sum / MathF.Sqrt(Math.Max(1e-12f, left * right));
|
||||
if (normalized > bestScore)
|
||||
{
|
||||
bestScore = normalized;
|
||||
bestLag = lag;
|
||||
}
|
||||
}
|
||||
|
||||
return bestLag <= 0 || bestScore < .08f ? 0 : sampleRate / (float)bestLag;
|
||||
}
|
||||
|
||||
static float MidiToFrequency(int midi) =>
|
||||
440f * MathF.Pow(2f, (midi - 69) / 12f);
|
||||
|
||||
static string NoteName(int midi)
|
||||
{
|
||||
ReadOnlySpan<string> names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
||||
var octave = midi / 12 - 1;
|
||||
var pitchClass = ((midi % 12) + 12) % 12;
|
||||
return $"{names[pitchClass]}{octave}";
|
||||
}
|
||||
|
||||
static float[] RmsEnvelope(IReadOnlyList<float> samples, int frameSize)
|
||||
{
|
||||
var envelope = new List<float>();
|
||||
for (var start = 0; start < samples.Count; start += frameSize)
|
||||
{
|
||||
var end = Math.Min(samples.Count, start + frameSize);
|
||||
var sum = 0f;
|
||||
for (var index = start; index < end; index++)
|
||||
{
|
||||
sum += samples[index] * samples[index];
|
||||
}
|
||||
|
||||
envelope.Add(MathF.Sqrt(sum / Math.Max(1, end - start)));
|
||||
}
|
||||
|
||||
return envelope.ToArray();
|
||||
}
|
||||
|
||||
static IReadOnlyList<SpeechScoreMetric> SongComparisonMetrics(AudioComparison comparison) =>
|
||||
[
|
||||
new("log_mel_cosine", comparison.LogMelCosineSimilarity, .25f),
|
||||
new("log_mel_distance", comparison.LogMelDistance, .15f),
|
||||
new("audio_score", comparison.Score, .15f),
|
||||
new("envelope_distance", comparison.EnvelopeDistance, .10f),
|
||||
new("rms_ratio", comparison.RmsRatio, .10f),
|
||||
new("centroid_ratio", comparison.CentroidRatio, .10f),
|
||||
new("zero_crossing_ratio", comparison.ZeroCrossingRatio, .05f),
|
||||
new("articulation_score", comparison.Articulation.ArticulationScore, .05f),
|
||||
new("speech_band_ratio", comparison.Articulation.SpeechBandRatio, .05f)
|
||||
];
|
||||
|
||||
static string SongVerdict(AudioComparison comparison)
|
||||
{
|
||||
if (comparison.LogMelCosineSimilarity >= .70f && comparison.Score >= .45f)
|
||||
{
|
||||
return "promising";
|
||||
}
|
||||
|
||||
if (comparison.LogMelCosineSimilarity >= .35f || comparison.Score >= .30f)
|
||||
{
|
||||
return "pressure";
|
||||
}
|
||||
|
||||
return "weak";
|
||||
}
|
||||
|
||||
static float SongConfidence(IReadOnlyList<SpeechScoreMetric> metrics)
|
||||
{
|
||||
var cosine = metrics.FirstOrDefault(metric => metric.Name == "log_mel_cosine")?.Value ?? 0;
|
||||
var score = metrics.FirstOrDefault(metric => metric.Name == "audio_score")?.Value ?? 0;
|
||||
return Math.Clamp((cosine + score) * .5f, 0, 1);
|
||||
}
|
||||
|
||||
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 SongChallengeReport(SongChallenge challenge) =>
|
||||
$"""
|
||||
# Song Snippet Challenge: {challenge.ChallengeId}
|
||||
|
||||
source: `{challenge.SourcePath}`
|
||||
file: `{challenge.SourceFileName}`
|
||||
seed: `{challenge.Seed}`
|
||||
start_seconds: `{challenge.StartSeconds.ToString("0.######", CultureInfo.InvariantCulture)}`
|
||||
duration_seconds: `{challenge.DurationSeconds.ToString("0.######", CultureInfo.InvariantCulture)}`
|
||||
sample_rate: `{challenge.SampleRate}`
|
||||
reference_wav: `{challenge.ReferenceWavPath}`
|
||||
|
||||
## Reference Features
|
||||
peak: `{challenge.Features.Peak.ToString("0.######", CultureInfo.InvariantCulture)}`
|
||||
rms: `{challenge.Features.Rms.ToString("0.######", CultureInfo.InvariantCulture)}`
|
||||
active_duty: `{challenge.Features.ActiveDuty.ToString("0.######", CultureInfo.InvariantCulture)}`
|
||||
zero_crossing_rate: `{challenge.Features.ZeroCrossingRate.ToString("0.######", CultureInfo.InvariantCulture)}`
|
||||
spectral_centroid_hz: `{challenge.Features.SpectralCentroidHz.ToString("0.######", CultureInfo.InvariantCulture)}`
|
||||
spectral_rolloff_hz: `{challenge.Features.SpectralRolloffHz.ToString("0.######", CultureInfo.InvariantCulture)}`
|
||||
spectral_flux: `{challenge.Features.SpectralFlux.ToString("0.######", CultureInfo.InvariantCulture)}`
|
||||
tempo_bpm: `{challenge.Features.TempoBpm.ToString("0.######", CultureInfo.InvariantCulture)}`
|
||||
beat_seconds: `{challenge.Features.BeatSeconds.ToString("0.######", CultureInfo.InvariantCulture)}`
|
||||
tempo_confidence: `{challenge.Features.TempoConfidence.ToString("0.######", CultureInfo.InvariantCulture)}`
|
||||
dominant_hz: `{challenge.Features.DominantHz.ToString("0.######", CultureInfo.InvariantCulture)}`
|
||||
register_low_hz: `{challenge.Features.RegisterLowHz.ToString("0.######", CultureInfo.InvariantCulture)}`
|
||||
register_high_hz: `{challenge.Features.RegisterHighHz.ToString("0.######", CultureInfo.InvariantCulture)}`
|
||||
root_note: `{challenge.Features.RootNote}`
|
||||
suggested_scale: `{challenge.Features.SuggestedScale}`
|
||||
scale_frequencies_hz: `{challenge.Features.ScaleFrequenciesHz}`
|
||||
""";
|
||||
|
||||
static string SongComparisonReport(SongChallenge challenge, IpaTrialScriptCandidate candidate, AudioComparison comparison, string verdict) =>
|
||||
$"""
|
||||
candidate={candidate.CandidateId}
|
||||
challenge={challenge.ChallengeId}
|
||||
verdict={verdict}
|
||||
logMelCosine={comparison.LogMelCosineSimilarity:0.######}
|
||||
logMelDistance={comparison.LogMelDistance:0.######}
|
||||
score={comparison.Score:0.######}
|
||||
envelopeDistance={comparison.EnvelopeDistance:0.######}
|
||||
rmsRatio={comparison.RmsRatio:0.######}
|
||||
centroidRatio={comparison.CentroidRatio:0.######}
|
||||
zeroCrossingRatio={comparison.ZeroCrossingRatio:0.######}
|
||||
articulation={comparison.Articulation.ArticulationScore:0.######}
|
||||
speechBandRatio={comparison.Articulation.SpeechBandRatio:0.######}
|
||||
""";
|
||||
|
||||
static string SongSummaryCsv(IReadOnlyList<IpaTrialResult> results)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("trial_id,candidate_id,reference_id,verdict,log_mel_cosine,log_mel_distance,audio_score,envelope_distance,rms_ratio,centroid_ratio,zero_crossing_ratio,articulation_score");
|
||||
foreach (var result in results)
|
||||
{
|
||||
builder.Append(Escape(result.TrialId)).Append(',');
|
||||
builder.Append(Escape(result.CandidateId)).Append(',');
|
||||
builder.Append(Escape(result.ReferenceId)).Append(',');
|
||||
builder.Append(Escape(result.Verdict)).Append(',');
|
||||
builder.Append(Metric(result, "log_mel_cosine")).Append(',');
|
||||
builder.Append(Metric(result, "log_mel_distance")).Append(',');
|
||||
builder.Append(Metric(result, "audio_score")).Append(',');
|
||||
builder.Append(Metric(result, "envelope_distance")).Append(',');
|
||||
builder.Append(Metric(result, "rms_ratio")).Append(',');
|
||||
builder.Append(Metric(result, "centroid_ratio")).Append(',');
|
||||
builder.Append(Metric(result, "zero_crossing_ratio")).Append(',');
|
||||
builder.AppendLine(Metric(result, "articulation_score"));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
static string SongEvaluatorReport(SongChallenge challenge, IReadOnlyList<IpaTrialResult> results)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine($"# Song Snippet Evaluator Report: {challenge.ChallengeId}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(SongChallengeReport(challenge));
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("| candidate | verdict | logMelCosine | score | envelopeDistance | rmsRatio | centroidRatio |");
|
||||
builder.AppendLine("| --- | --- | ---: | ---: | ---: | ---: | ---: |");
|
||||
foreach (var result in results.OrderByDescending(result => MetricValue(result, "log_mel_cosine")))
|
||||
{
|
||||
builder.AppendLine(CultureInfo.InvariantCulture, $"| {result.CandidateId} | {result.Verdict} | {MetricValue(result, "log_mel_cosine"):0.######} | {MetricValue(result, "audio_score"):0.######} | {MetricValue(result, "envelope_distance"):0.######} | {MetricValue(result, "rms_ratio"):0.######} | {MetricValue(result, "centroid_ratio"):0.######} |");
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
static float MetricValue(IpaTrialResult result, string name) =>
|
||||
result.Metrics.FirstOrDefault(metric => metric.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Value ?? 0;
|
||||
|
||||
static JsonSerializerOptions JsonOptions() => new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
static SearchOptions SearchOptionsFrom(Dictionary<string, string> options) => new(
|
||||
|
|
@ -1367,6 +2155,16 @@ static bool BoolValue(Dictionary<string, string> options, string key) =>
|
|||
value.Equals("1", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Equals("yes", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
static int IntValue(Dictionary<string, string> options, string key, int fallback) =>
|
||||
int.TryParse(Value(options, key, fallback.ToString(CultureInfo.InvariantCulture)), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: fallback;
|
||||
|
||||
static float FloatValue(Dictionary<string, string> options, string key, float fallback) =>
|
||||
float.TryParse(Value(options, key, fallback.ToString(CultureInfo.InvariantCulture)), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: fallback;
|
||||
|
||||
static void PrintHelp()
|
||||
{
|
||||
Console.WriteLine("""
|
||||
|
|
@ -1375,6 +2173,8 @@ static void PrintHelp()
|
|||
Commands:
|
||||
seed --artifact-root <dir> [--batch-id five-seed-trials] [--store <trial-results.cc>]
|
||||
score --patch-root <dir> --artifact-root <dir> [--batch-id round-001] [--store <trial-results.cc>] [--hypothesizer id]
|
||||
song-prepare --source <audio-file> --artifact-root <dir> [--duration-seconds 10] [--seed n] [--output challenge.json]
|
||||
song-score --patch-root <dir> --challenge <challenge.json> --artifact-root <dir> [--batch-id round-001] [--store <trial-results.cc>] [--hypothesizer id]
|
||||
dump --store <trial-results.cc> --output <report.md>
|
||||
index --store <trial-results.cc> [--output <report.md>] [--force true]
|
||||
search --store <trial-results.cc> --query <text> --output <report.md> [--limit 12] [--no-vector true] [--require-vector true] [--skip-index true]
|
||||
|
|
@ -1401,6 +2201,48 @@ static string ReadPayloadString(Dictionary<string, JsonElement> payload, string
|
|||
return value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : value.ToString();
|
||||
}
|
||||
|
||||
sealed record DecodedAudio(int SampleRate, float[] Samples);
|
||||
|
||||
sealed record TempoEstimate(float Bpm, float BeatSeconds, float Confidence);
|
||||
|
||||
sealed record SongChallenge(
|
||||
string ChallengeId,
|
||||
string SourcePath,
|
||||
string SourceFileName,
|
||||
int Seed,
|
||||
float StartSeconds,
|
||||
float DurationSeconds,
|
||||
int SampleRate,
|
||||
string ReferenceWavPath,
|
||||
SongChallengeFeatures Features);
|
||||
|
||||
sealed record SongChallengeFeatures(
|
||||
float DurationSeconds,
|
||||
float Peak,
|
||||
float Rms,
|
||||
float ZeroCrossingRate,
|
||||
float SpectralCentroidHz,
|
||||
float SpectralRolloffHz,
|
||||
float ActiveDuty,
|
||||
float SpectralFlux,
|
||||
float TempoBpm,
|
||||
float BeatSeconds,
|
||||
float TempoConfidence,
|
||||
float DominantHz,
|
||||
float RegisterLowHz,
|
||||
float RegisterHighHz,
|
||||
string RootNote,
|
||||
string SuggestedScale,
|
||||
string ScaleFrequenciesHz);
|
||||
|
||||
sealed record SongRegister(
|
||||
float DominantHz,
|
||||
float LowHz,
|
||||
float HighHz,
|
||||
string RootNote,
|
||||
string SuggestedScale,
|
||||
float[] ScaleFrequencies);
|
||||
|
||||
sealed record SearchOptions(
|
||||
string QdrantUrl,
|
||||
string OllamaUrl,
|
||||
|
|
|
|||
342
tools/run-song-snippet-trial-swarm.ps1
Normal file
342
tools/run-song-snippet-trial-swarm.ps1
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
param(
|
||||
[int]$Passes = 1,
|
||||
[int]$AgentsPerPass = 5,
|
||||
[string]$Source = "D:\Music\Reinier\Heyoka\Gate Code\Heyoka - Alien Gibberish.mp3",
|
||||
[float]$DurationSeconds = 10,
|
||||
[int]$Seed = 0,
|
||||
[string]$CodexCommand = "codex",
|
||||
[string]$CodexModel = "gpt-5.3-codex"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Resolve-RepoRoot {
|
||||
$dir = Get-Item -LiteralPath (Get-Location)
|
||||
while ($null -ne $dir) {
|
||||
if (Test-Path -LiteralPath (Join-Path $dir.FullName "AquaSynth.sln")) {
|
||||
return $dir.FullName
|
||||
}
|
||||
|
||||
$dir = $dir.Parent
|
||||
}
|
||||
|
||||
throw "Could not find AquaSynth.sln above the current directory."
|
||||
}
|
||||
|
||||
function New-Timestamp {
|
||||
return [DateTimeOffset]::UtcNow.ToString("yyyyMMddTHHmmssfff")
|
||||
}
|
||||
|
||||
function Invoke-LoggedProcess {
|
||||
param(
|
||||
[string]$FilePath,
|
||||
[string[]]$ArgumentList,
|
||||
[string]$LogPath
|
||||
)
|
||||
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogPath) | Out-Null
|
||||
$display = "$FilePath $($ArgumentList -join ' ')"
|
||||
Add-Content -LiteralPath $LogPath -Value "[$([DateTimeOffset]::UtcNow.ToString("O"))] $display"
|
||||
& $FilePath @ArgumentList *>&1 | Tee-Object -FilePath $LogPath -Append
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Command failed with exit code $LASTEXITCODE`: $display"
|
||||
}
|
||||
}
|
||||
|
||||
function Start-CodexAgentJob {
|
||||
param(
|
||||
[string]$RepoRoot,
|
||||
[string]$Prompt,
|
||||
[string]$PromptPath,
|
||||
[string]$OutputPath,
|
||||
[string]$LogPath,
|
||||
[string]$CodexCommand,
|
||||
[string]$CodexModel,
|
||||
[string]$JobName
|
||||
)
|
||||
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $OutputPath) | Out-Null
|
||||
Set-Content -LiteralPath $PromptPath -Value $Prompt
|
||||
|
||||
$args = @(
|
||||
"exec",
|
||||
"--cd", $RepoRoot,
|
||||
"--full-auto",
|
||||
"--output-last-message", $OutputPath,
|
||||
"--config", 'shell_environment_policy.inherit="all"',
|
||||
"--config", 'sandbox_permissions=["workspace-write"]'
|
||||
)
|
||||
if ($CodexModel.Length -gt 0) {
|
||||
$args += @("--model", $CodexModel)
|
||||
}
|
||||
$args += "-"
|
||||
|
||||
Start-Job -Name $JobName -ArgumentList $CodexCommand, $args, $PromptPath, $LogPath -ScriptBlock {
|
||||
param(
|
||||
[string]$JobCodexCommand,
|
||||
[string[]]$JobArgs,
|
||||
[string]$JobPromptPath,
|
||||
[string]$JobLogPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $JobLogPath) | Out-Null
|
||||
Add-Content -LiteralPath $JobLogPath -Value "[$([DateTimeOffset]::UtcNow.ToString("O"))] $JobCodexCommand $($JobArgs -join ' ')"
|
||||
Get-Content -LiteralPath $JobPromptPath -Raw | & $JobCodexCommand @JobArgs 2>&1 | Tee-Object -FilePath $JobLogPath -Append
|
||||
[pscustomobject]@{
|
||||
ExitCode = $LASTEXITCODE
|
||||
LogPath = $JobLogPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-CodexJobs {
|
||||
param([System.Management.Automation.Job[]]$Jobs)
|
||||
|
||||
foreach ($job in $Jobs) {
|
||||
Wait-Job -Job $job | Out-Null
|
||||
}
|
||||
|
||||
$failures = @()
|
||||
foreach ($job in $Jobs) {
|
||||
$received = Receive-Job -Job $job -Keep
|
||||
$exitRecord = $received | Where-Object { $_.PSObject.Properties.Name -contains "ExitCode" } | Select-Object -Last 1
|
||||
if ($null -eq $exitRecord -or $exitRecord.ExitCode -ne 0) {
|
||||
$failures += $job.Name
|
||||
}
|
||||
}
|
||||
|
||||
Remove-Job -Job $Jobs -Force
|
||||
if ($failures.Count -gt 0) {
|
||||
throw "External Codex job(s) failed: $($failures -join ', ')"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-SongCandidates {
|
||||
param(
|
||||
[string]$PatchDirectory,
|
||||
[int]$ExpectedCount
|
||||
)
|
||||
|
||||
$files = @(Get-ChildItem -LiteralPath $PatchDirectory -Filter "*.aqua" -File -Recurse)
|
||||
if ($files.Count -ne $ExpectedCount) {
|
||||
throw "Song challenge wrote $($files.Count) .aqua candidates; expected $ExpectedCount."
|
||||
}
|
||||
|
||||
foreach ($file in $files) {
|
||||
$stem = [IO.Path]::GetFileNameWithoutExtension($file.Name)
|
||||
if ($stem -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*__[a-z0-9]+(-[a-z0-9]+)*__[a-z0-9]+(-[a-z0-9]+)*$') {
|
||||
throw "Candidate '$stem' must be <agent-id>__<family>__<hypothesis>.aqua with lowercase kebab-case segments."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$repoRoot = Resolve-RepoRoot
|
||||
$codex = Get-Command $CodexCommand -ErrorAction Stop
|
||||
$loopId = New-Timestamp
|
||||
$loopRoot = Join-Path $repoRoot "artifacts/parity/song-snippet-swarms/$loopId"
|
||||
$logRoot = Join-Path $loopRoot "logs"
|
||||
$workerProject = Join-Path $repoRoot "tools/IpaTrialWorker/IpaTrialWorker.csproj"
|
||||
$storePath = Join-Path $loopRoot "song-trial-results.cc"
|
||||
$challengePath = Join-Path $loopRoot "challenge.json"
|
||||
$challengeRoot = Join-Path $loopRoot "challenge"
|
||||
$seedValue = if ($Seed -eq 0) { Get-Random -Minimum 1 -Maximum ([int]::MaxValue) } else { $Seed }
|
||||
New-Item -ItemType Directory -Force -Path $loopRoot, $logRoot, $challengeRoot | Out-Null
|
||||
|
||||
Set-Content -LiteralPath (Join-Path $loopRoot "loop-index.md") -Value @"
|
||||
# Song Snippet Trial Swarm $loopId
|
||||
|
||||
- repo: $repoRoot
|
||||
- source: $Source
|
||||
- codex: $($codex.Source)
|
||||
- passes: $Passes
|
||||
- agents_per_pass: $AgentsPerPass
|
||||
- seed: $seedValue
|
||||
- duration_seconds: $DurationSeconds
|
||||
- challenge: $challengePath
|
||||
- store: $storePath
|
||||
- worker: $workerProject
|
||||
|
||||
"@
|
||||
|
||||
Invoke-LoggedProcess `
|
||||
-FilePath "dotnet" `
|
||||
-ArgumentList @(
|
||||
"run",
|
||||
"--project",
|
||||
$workerProject,
|
||||
"--",
|
||||
"song-prepare",
|
||||
"--source",
|
||||
$Source,
|
||||
"--artifact-root",
|
||||
$challengeRoot,
|
||||
"--duration-seconds",
|
||||
([string]$DurationSeconds),
|
||||
"--seed",
|
||||
([string]$seedValue),
|
||||
"--output",
|
||||
$challengePath
|
||||
) `
|
||||
-LogPath (Join-Path $logRoot "challenge-prepare.log")
|
||||
|
||||
$challengeMarkdown = Join-Path $challengeRoot "challenge.md"
|
||||
|
||||
for ($pass = 1; $pass -le $Passes; $pass++) {
|
||||
$passId = "pass-{0:000}" -f $pass
|
||||
$passDir = Join-Path $loopRoot $passId
|
||||
$patchRoot = Join-Path $passDir "proposed-patches"
|
||||
New-Item -ItemType Directory -Force -Path $passDir, $patchRoot | Out-Null
|
||||
|
||||
$preEvidence = Join-Path $passDir "semantic-search-before.md"
|
||||
if (Test-Path -LiteralPath $storePath) {
|
||||
Invoke-LoggedProcess `
|
||||
-FilePath "dotnet" `
|
||||
-ArgumentList @(
|
||||
"run",
|
||||
"--project",
|
||||
$workerProject,
|
||||
"--",
|
||||
"search",
|
||||
"--store",
|
||||
$storePath,
|
||||
"--query",
|
||||
"song snippet alien gibberish rhythm tempo timbre spectral envelope modulation scene voices weak promising",
|
||||
"--limit",
|
||||
"40",
|
||||
"--output",
|
||||
$preEvidence,
|
||||
"--skip-index",
|
||||
"true"
|
||||
) `
|
||||
-LogPath (Join-Path $logRoot "$passId-search-before.log")
|
||||
}
|
||||
else {
|
||||
Set-Content -LiteralPath $preEvidence -Value "# No prior song-snippet evidence yet"
|
||||
}
|
||||
|
||||
$jobs = @()
|
||||
for ($agent = 1; $agent -le $AgentsPerPass; $agent++) {
|
||||
$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
|
||||
$outputPath = Join-Path $agentDir "hypothesis-agent.md"
|
||||
$promptPath = Join-Path $agentDir "hypothesis-agent.prompt.md"
|
||||
$logPath = Join-Path $logRoot "$passId-$agentId-hypothesis-agent.log"
|
||||
$prompt = @"
|
||||
You are an AquaSynth song-snippet parity worker.
|
||||
|
||||
Challenge:
|
||||
- frozen challenge JSON: $challengePath
|
||||
- human-readable challenge report: $challengeMarkdown
|
||||
- shared prior evidence: $preEvidence
|
||||
- patch output directory: $agentPatchDir
|
||||
- candidate filename prefix: $agentId
|
||||
|
||||
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.
|
||||
|
||||
Rhythm and tempo:
|
||||
- The challenge report includes `tempo_bpm`, `beat_seconds`, and `tempo_confidence`, estimated from spectral/RMS onset autocorrelation.
|
||||
- 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`
|
||||
- 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=<tempo_bpm/60>`, `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.
|
||||
|
||||
Register and scale:
|
||||
- The challenge report includes `dominant_hz`, `register_low_hz`, `register_high_hz`, `root_note`, `suggested_scale`, and `scale_frequencies_hz`.
|
||||
- Songs tend to pick a register and stay there. Treat those bounds as the default melodic playground unless your hypothesis explicitly tests octave spread.
|
||||
- Current scale sugar is explicit frequency sequencing:
|
||||
- declare pitch: `param path=/seq/pitch default=<dominant_hz> min=<register_low_hz> max=<register_high_hz> step=.01 unit=Hz`
|
||||
- step the scale: `curve name=lead_scale path=/seq/pitch points=0:<scale0>,0.25:<scale1>,0.5:<scale2>,0.75:<scale3> interp=hold loop=true`
|
||||
- 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.
|
||||
|
||||
Evidence contract:
|
||||
- Read $challengeMarkdown and $preEvidence.
|
||||
- Write $agentDir/hypotheses.md with: reference features, tempo/rhythm plan, register/scale plan, scene voices, synthesis owners, expected metric movement, known risks.
|
||||
- Write exactly one `.aqua` file under $agentPatchDir named `<agent-id>__<family>__<hypothesis>.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.
|
||||
|
||||
Return a short final summary naming the patch and report.
|
||||
"@
|
||||
$jobs += Start-CodexAgentJob `
|
||||
-RepoRoot $repoRoot `
|
||||
-Prompt $prompt `
|
||||
-PromptPath $promptPath `
|
||||
-OutputPath $outputPath `
|
||||
-LogPath $logPath `
|
||||
-CodexCommand $CodexCommand `
|
||||
-CodexModel $CodexModel `
|
||||
-JobName "$passId-$agentId"
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
Invoke-LoggedProcess `
|
||||
-FilePath "dotnet" `
|
||||
-ArgumentList @(
|
||||
"run",
|
||||
"--project",
|
||||
$workerProject,
|
||||
"--",
|
||||
"index",
|
||||
"--store",
|
||||
$storePath,
|
||||
"--output",
|
||||
(Join-Path $passDir "vector-index-after.md")
|
||||
) `
|
||||
-LogPath (Join-Path $logRoot "$passId-index-after.log")
|
||||
|
||||
Invoke-LoggedProcess `
|
||||
-FilePath "dotnet" `
|
||||
-ArgumentList @(
|
||||
"run",
|
||||
"--project",
|
||||
$workerProject,
|
||||
"--",
|
||||
"search",
|
||||
"--store",
|
||||
$storePath,
|
||||
"--query",
|
||||
"$passId song snippet current candidates promising weak rhythm tempo timbre spectral scene voices",
|
||||
"--limit",
|
||||
"40",
|
||||
"--output",
|
||||
(Join-Path $passDir "semantic-search-after.md"),
|
||||
"--skip-index",
|
||||
"true"
|
||||
) `
|
||||
-LogPath (Join-Path $logRoot "$passId-search-after.log")
|
||||
}
|
||||
|
||||
Write-Host "Song snippet trial swarm complete: $loopRoot"
|
||||
Loading…
Reference in a new issue