Add song target evidence records

This commit is contained in:
Meta Vens 2026-05-31 03:37:07 +01:00
commit 37c580c800
10 changed files with 750 additions and 62 deletions

View file

@ -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<IpaTrialResult> results) =>
UpsertAsync(filePath, results, result => $"ipa-trial-result:{result.TrialId}");
public static Task UpsertSongChallengeEvidenceAsync(string filePath, IEnumerable<SongChallengeEvidenceDocument> documents) =>
UpsertAsync(filePath, documents, document => $"song-challenge-evidence:{document.EvidenceId}");
public static async Task<IReadOnlyList<IpaTrialResult>> ReadResultsAsync(string filePath)
{
if (!File.Exists(filePath))
@ -60,6 +77,19 @@ public static class IpaTrialResultCultCacheStore
.ToArray();
}
public static async Task<IReadOnlyList<SongChallengeEvidenceDocument>> ReadSongChallengeEvidenceAsync(string filePath)
{
if (!File.Exists(filePath))
{
return [];
}
using var cache = await CultCacheMessagePack.OpenAsync(filePath).ConfigureAwait(false);
return cache.GetAll<SongChallengeEvidenceDocument>()
.OrderBy(document => document.EvidenceId, StringComparer.Ordinal)
.ToArray();
}
private static async Task UpsertAsync<T>(
string filePath,
IEnumerable<T> documents,

View file

@ -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<string, string> 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<string, string> 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<string, string> 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<string>()
: value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private static Dictionary<string, string> 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<float> PatternValues(string pattern, float high, float low, int line)
{
var compact = pattern.Replace("\"", "", StringComparison.Ordinal).Replace("'", "", StringComparison.Ordinal);
var values = new List<float>();
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<float> ScaleFrequencies(IReadOnlyDictionary<string, string> 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<int> 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<float> ParseLooseFloatList(string value, int line) =>
value.Split([',', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(item => ParseFloat(item, line))
.ToArray();
private static IReadOnlyList<int> ParseLooseIntList(string value, int line) =>
value.Split([',', '|', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(item => ParseInt(item, line))
.ToArray();
private static IReadOnlyList<ControlCurvePoint> ParseControlCurvePoints(string value, int line)
{
var points = value