feat(waveform): generalize high-res compute to every track (Direction B)
Per-track high-res datum keyed by EntryKey in the renamed track-waveforms vault; computed at upload for all tracks, regenerable per-track via CMS, with a re-runnable backfill. Mix read path repointed so it keeps working.
This commit is contained in:
@@ -68,7 +68,8 @@ public class ReleaseController : ControllerBase
|
||||
|
||||
// GET api/release/{entryKey}/mix/waveform (unauthenticated)
|
||||
// Serves the high-res waveform datum for a Mix release as base64. Mirrors GET api/track/{id}/waveform
|
||||
// but reads from the mix-waveforms vault. 404 when the release is not a Mix, carries no waveform key,
|
||||
// but reads the Mix's track datum from the track-waveforms vault. 404 when the release is not a Mix,
|
||||
// carries no waveform key,
|
||||
// or no datum is stored. Public read — addresses by the opaque EntryKey, not the int PK (§3e). The
|
||||
// {entryKey} string segment cannot collide with the ApiKey-gated POST {id:long}/mix/waveform (different
|
||||
// verb + constraint). Declared before the shorter "{entryKey}" route for clarity.
|
||||
@@ -91,7 +92,7 @@ public class ReleaseController : ControllerBase
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.MixWaveforms);
|
||||
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.TrackWaveforms);
|
||||
if (bytes is null)
|
||||
{
|
||||
_logger.LogInformation("Mix waveform key set but no datum stored for release: {EntryKey}", entryKey);
|
||||
|
||||
@@ -138,8 +138,9 @@ public class TrackController : ControllerBase
|
||||
}
|
||||
|
||||
// GET api/track/waveform-status ([ApiKeyAuthorize])
|
||||
// Admin backfill view: returns every track with a flag for whether a waveform profile is
|
||||
// stored in the WaveformProfiles vault. The catalogue is small enough that the CMS panel reads
|
||||
// Admin backfill view: returns every track with flags for whether each waveform datum is stored —
|
||||
// the 512-bucket player-bar profile (WaveformProfiles vault) and the per-track high-res visualizer
|
||||
// datum (TrackWaveforms vault, phase-12 §5). The catalogue is small enough that the CMS panel reads
|
||||
// the whole list unpaged. Declared before the parameterized "{trackId}" route so the literal
|
||||
// segment is never treated as a trackId.
|
||||
[ApiKeyAuthorize]
|
||||
@@ -158,12 +159,14 @@ public class TrackController : ControllerBase
|
||||
foreach (var track in tracks.Value)
|
||||
{
|
||||
var profile = await _waveformProfileService.GetProfileAsync(track.EntryKey);
|
||||
var highRes = await _waveformProfileService.GetProfileAsync(track.EntryKey, VaultConstants.TrackWaveforms);
|
||||
status.Add(new WaveformStatusDto
|
||||
{
|
||||
TrackId = track.Id,
|
||||
EntryKey = track.EntryKey,
|
||||
TrackName = track.TrackName,
|
||||
HasProfile = profile is not null,
|
||||
HasHighRes = highRes is not null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -600,6 +603,35 @@ public class TrackController : ControllerBase
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// POST api/track/{trackId}/waveform/high-res ([ApiKeyAuthorize])
|
||||
// Track-cardinal generalization of the Mix-only waveform trigger (phase-12 §5): compute and store
|
||||
// the per-track high-res datum for ANY track from its vault audio, keyed by EntryKey in the
|
||||
// track-waveforms vault. Drives the CMS per-row "Generate high-res" action and the batch backfill.
|
||||
// Re-runnable: a second call recomputes and overwrites. trackId is the EntryKey. 404 when no audio
|
||||
// is stored under that key; 500 when the WAV cannot be decoded or the vault write fails. Declared
|
||||
// before the parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("{trackId}/waveform/high-res")]
|
||||
public async Task<ActionResult> GenerateHighResWaveform(string trackId)
|
||||
{
|
||||
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
|
||||
if (audio is null)
|
||||
{
|
||||
_logger.LogWarning("GenerateHighResWaveform: no audio in vault for {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var stored = await _waveformProfileService.ComputeAndStoreHighResAsync(
|
||||
audio.Buffer, trackId, audio.Duration);
|
||||
if (!stored)
|
||||
{
|
||||
_logger.LogError("GenerateHighResWaveform: computation/storage failed for {TrackId}", trackId);
|
||||
return StatusCode(500, "Failed to generate high-res waveform datum.");
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPut("{trackId}")]
|
||||
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
|
||||
|
||||
@@ -102,9 +102,11 @@ public class UnifiedReleaseService
|
||||
/// <summary>
|
||||
/// Fetch the Mix's track audio from the vault, compute a high-res waveform datum at a constant time
|
||||
/// resolution (≈333 samples/sec derived from the track's duration; see
|
||||
/// <see cref="MixWaveformResolution"/>), store it in the MixWaveforms vault under the track's
|
||||
/// <see cref="WaveformResolution"/>), store it in the TrackWaveforms vault under the track's
|
||||
/// EntryKey, then point the release's Mix satellite at that same key. The datum key equals the
|
||||
/// track's EntryKey — the Mix is single-track.
|
||||
/// track's EntryKey — the Mix is single-track. Under the per-track model (phase-12 §5) this is the
|
||||
/// same datum every track now carries; the Mix satellite link is kept so the existing public
|
||||
/// <c>GET api/release/{entryKey}/mix/waveform</c> read path keeps resolving until 12.B2 rewires it.
|
||||
/// </summary>
|
||||
public async Task<Result> TriggerMixWaveformAsync(long releaseId, CancellationToken ct)
|
||||
{
|
||||
@@ -148,10 +150,10 @@ public class UnifiedReleaseService
|
||||
}
|
||||
|
||||
// Duration-derived, constant-time-resolution capture (≈333 samples/sec) so long mixes are not
|
||||
// under-sampled by a fixed bucket count — see MixWaveformResolution / spec §F.
|
||||
var bucketCount = MixWaveformResolution.BucketCountForDuration(audio.Duration);
|
||||
var computed = await _waveformProfileService.ComputeAndStoreAsync(
|
||||
audio.Buffer, entryKey, bucketCount, VaultConstants.MixWaveforms);
|
||||
// under-sampled by a fixed bucket count — see WaveformResolution / spec §F. Same per-track
|
||||
// high-res datum every track now carries (phase-12 §5).
|
||||
var computed = await _waveformProfileService.ComputeAndStoreHighResAsync(
|
||||
audio.Buffer, entryKey, audio.Duration);
|
||||
if (!computed)
|
||||
{
|
||||
_logger.LogError("TriggerMixWaveform: waveform computation/storage failed for {EntryKey}", entryKey);
|
||||
|
||||
@@ -158,24 +158,38 @@ public class UnifiedTrackService
|
||||
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
|
||||
}
|
||||
|
||||
// Best-effort waveform profile: both stores succeeded, so the upload is a success
|
||||
// regardless of the profile outcome. A missing profile renders as a flat seekbar on the
|
||||
// Best-effort waveform datums: both stores succeeded, so the upload is a success regardless of
|
||||
// the datum outcome. A missing datum renders as a flat seekbar / blank visualizer on the
|
||||
// frontend, so a failure here is logged and swallowed — never fails the upload.
|
||||
await TryStoreWaveformProfileAsync(tempFilePath, unpersisted.EntryKey, ct);
|
||||
await TryStoreWaveformDatumsAsync(unpersisted.EntryKey, ct);
|
||||
|
||||
return saveResult;
|
||||
}
|
||||
|
||||
private async Task TryStoreWaveformProfileAsync(string tempFilePath, string entryKey, CancellationToken ct)
|
||||
// Compute and store both waveform datums for a freshly uploaded track: the fixed 512-bucket profile
|
||||
// the player-bar seeker consumes, and the duration-derived high-res datum the lava visualizer
|
||||
// consumes (phase-12 §5 — every track now carries one, computed at upload). Both source the same
|
||||
// audio: read it back from the vault once (the authoritative parsed duration + the stored buffer)
|
||||
// rather than re-reading and re-parsing the temp file. Best-effort throughout — never fails upload.
|
||||
private async Task TryStoreWaveformDatumsAsync(string entryKey, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var wavBytes = await File.ReadAllBytesAsync(tempFilePath, ct);
|
||||
await _waveformProfileService.ComputeAndStoreAsync(wavBytes, entryKey);
|
||||
var audio = await _contentTrackContentService.GetAudioBinaryAsync(entryKey);
|
||||
if (audio is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Waveform datum step: no audio in vault for {EntryKey} immediately after store; skipping.",
|
||||
entryKey);
|
||||
return;
|
||||
}
|
||||
|
||||
await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, entryKey);
|
||||
await _waveformProfileService.ComputeAndStoreHighResAsync(audio.Buffer, entryKey, audio.Duration);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Waveform profile step failed for {EntryKey}; upload unaffected.", entryKey);
|
||||
_logger.LogError(ex, "Waveform datum step failed for {EntryKey}; upload unaffected.", entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace DeepDrftAPI
|
||||
if (db is null) throw new Exception("Unable to initialize file database");
|
||||
InitializeTrackVault(db).GetAwaiter().GetResult();
|
||||
InitializeImageVault(db).GetAwaiter().GetResult();
|
||||
InitializeMixWaveformsVault(db).GetAwaiter().GetResult();
|
||||
InitializeTrackWaveformsVault(db).GetAwaiter().GetResult();
|
||||
return db;
|
||||
});
|
||||
|
||||
@@ -66,12 +66,13 @@ namespace DeepDrftAPI
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the mix-waveforms vault exists. Holds high-resolution waveform datums for DJ Mix releases.
|
||||
private static async Task InitializeMixWaveformsVault(FileDatabase fileDatabase)
|
||||
// Ensure the track-waveforms vault exists. Holds the per-track high-resolution waveform datum
|
||||
// (every track — Mix, Session, Cut), keyed by the track's EntryKey.
|
||||
private static async Task InitializeTrackWaveformsVault(FileDatabase fileDatabase)
|
||||
{
|
||||
if (!fileDatabase.HasVault(VaultConstants.MixWaveforms))
|
||||
if (!fileDatabase.HasVault(VaultConstants.TrackWaveforms))
|
||||
{
|
||||
await fileDatabase.CreateVaultAsync(VaultConstants.MixWaveforms, MediaVaultType.Media);
|
||||
await fileDatabase.CreateVaultAsync(VaultConstants.TrackWaveforms, MediaVaultType.Media);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,10 @@ public static class VaultConstants
|
||||
public const string Images = "images";
|
||||
|
||||
/// <summary>
|
||||
/// Vault name for Mix high-resolution waveform datums, keyed by the mix track's EntryKey.
|
||||
/// Vault name for per-track high-resolution waveform datums, keyed by the track's EntryKey.
|
||||
/// Every track (Mix, Session, Cut) carries one — computed at upload, regenerable on demand.
|
||||
/// Distinct from WaveformProfiles (player-bar low-res); same pipeline at higher resolution.
|
||||
/// The datum resolution is duration-derived (≈333 samples/sec, see <c>WaveformResolution</c>).
|
||||
/// </summary>
|
||||
public const string MixWaveforms = "mix-waveforms";
|
||||
public const string TrackWaveforms = "track-waveforms";
|
||||
}
|
||||
@@ -42,9 +42,9 @@ public class WaveformProfileService
|
||||
/// <paramref name="entryKey"/> in <paramref name="vaultName"/> (defaults to
|
||||
/// <see cref="VaultConstants.WaveformProfiles"/> when null). Bucket resolution defaults to
|
||||
/// <see cref="WaveformProfileOptions.BucketCount"/> (512) when <paramref name="bucketCount"/> is null;
|
||||
/// callers pass an explicit count for higher-resolution data — e.g. the Mix datum derives its count
|
||||
/// from the audio duration (≈333 samples/sec, see <c>MixWaveformResolution</c>) so long mixes are not
|
||||
/// under-sampled. This service is content-agnostic: it captures however many buckets it is told to and
|
||||
/// callers pass an explicit count for higher-resolution data — e.g. the per-track high-res datum
|
||||
/// derives its count from the audio duration (≈333 samples/sec, see <c>WaveformResolution</c>) so long
|
||||
/// tracks are not under-sampled. This service is content-agnostic: it captures however many buckets it is told to and
|
||||
/// does not itself decide the count. Returns false (and logs) on any
|
||||
/// failure — a missing profile is handled gracefully downstream, so callers on the upload path
|
||||
/// log-and-continue rather than failing the upload. Does not throw for expected failure modes.
|
||||
@@ -99,6 +99,24 @@ public class WaveformProfileService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a track's high-resolution loudness datum and stores it in the
|
||||
/// <see cref="VaultConstants.TrackWaveforms"/> vault keyed by <paramref name="entryKey"/>. The bucket
|
||||
/// count is duration-derived (≈333 samples/sec, clamped — see <see cref="WaveformResolution"/>) so the
|
||||
/// datum captures at a constant time resolution regardless of track length. This is the single home
|
||||
/// for "the high-res per-track datum" — the upload path, the CMS generate action, and the Mix trigger
|
||||
/// all funnel through it, so every track (Mix, Session, Cut) gets an identical datum keyed the same way.
|
||||
/// Returns false (logged) on any failure, per the content-agnostic contract above.
|
||||
/// </summary>
|
||||
public Task<bool> ComputeAndStoreHighResAsync(
|
||||
ReadOnlyMemory<byte> wavBytes,
|
||||
string entryKey,
|
||||
double durationSeconds)
|
||||
{
|
||||
var bucketCount = WaveformResolution.BucketCountForDuration(durationSeconds);
|
||||
return ComputeAndStoreAsync(wavBytes, entryKey, bucketCount, VaultConstants.TrackWaveforms);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the stored quantized profile bytes for a track from <paramref name="vaultName"/>
|
||||
/// (defaults to <see cref="VaultConstants.WaveformProfiles"/> when null), or null if no profile
|
||||
|
||||
+9
-8
@@ -1,40 +1,41 @@
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Derives the bucket count for a Mix loudness datum from the audio's duration, so the stored
|
||||
/// profile captures at a constant <em>time</em> resolution instead of a fixed bucket count.
|
||||
/// Derives the bucket count for a track's high-resolution loudness datum from the audio's duration, so
|
||||
/// the stored profile captures at a constant <em>time</em> resolution instead of a fixed bucket count.
|
||||
/// Applies to every track (Mix, Session, Cut) — the release is just the host (phase-12 §5).
|
||||
///
|
||||
/// Rationale (phase-9 Mix Visualizer redesign spec §F): the max-zoom window shows one quarter note
|
||||
/// at 180 BPM = 333 ms of audio, and a smooth glassy curve wants ~100+ sample points across that
|
||||
/// window. A fixed 2048-bucket datum gives fractions of a sample per 333 ms window on any real-length
|
||||
/// mix (a 30-minute mix gets ~0.38 buckets), so long content is badly under-sampled. Capturing at a
|
||||
/// constant ≈333 samples/sec (≈3 ms/sample) makes a 333 ms window hold ~111 samples regardless of mix
|
||||
/// audio (a 30-minute mix gets ~0.38 buckets), so long content is badly under-sampled. Capturing at a
|
||||
/// constant ≈333 samples/sec (≈3 ms/sample) makes a 333 ms window hold ~111 samples regardless of
|
||||
/// length — the direct expression of "high enough resolution regardless of content length."
|
||||
///
|
||||
/// This is the orchestration-side derivation (duration → bucket count); the actual compute/store stays
|
||||
/// in <see cref="WaveformProfileService"/>, which is content-agnostic and parameterized by bucket count.
|
||||
/// </summary>
|
||||
public static class MixWaveformResolution
|
||||
public static class WaveformResolution
|
||||
{
|
||||
/// <summary>≈333 samples/sec (≈3 ms/sample): one quarter note at 180 BPM (333 ms) holds ~111 samples.</summary>
|
||||
public const int SamplesPerSecond = 333;
|
||||
|
||||
/// <summary>
|
||||
/// Upper cap on bucket count (~2,000,000 samples ≈ a 100-minute mix at 333/s). Past this length we
|
||||
/// Upper cap on bucket count (~2,000,000 samples ≈ a 100-minute track at 333/s). Past this length we
|
||||
/// accept slightly-below-target density rather than an unbounded datum (spec §F mitigation #1).
|
||||
/// </summary>
|
||||
public const int MaxBucketCount = 2_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// Floor on bucket count. Keeps the historical 2048-bucket density as the minimum so a degenerate
|
||||
/// near-zero or very-short mix still yields a usable profile rather than zero/handful of buckets.
|
||||
/// near-zero or very-short track still yields a usable profile rather than zero/handful of buckets.
|
||||
/// </summary>
|
||||
public const int MinBucketCount = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a track's duration (seconds) to a bucket count of <c>ceil(durationSeconds × 333)</c>,
|
||||
/// clamped to [<see cref="MinBucketCount"/>, <see cref="MaxBucketCount"/>]. Non-finite or negative
|
||||
/// durations fall to the floor. A 60-minute mix → ~1.2M buckets; a 3-minute mix → ~60k.
|
||||
/// durations fall to the floor. A 60-minute track → ~1.2M buckets; a 3-minute track → ~60k.
|
||||
/// </summary>
|
||||
public static int BucketCountForDuration(double durationSeconds)
|
||||
{
|
||||
@@ -43,7 +43,8 @@
|
||||
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</MudTableSortLabel></MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Waveform</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Profile</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">High-res</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
@@ -64,7 +65,7 @@
|
||||
<MudTd DataLabel="Album">@(context.Release?.Title ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Genre">@(context.Release?.Genre ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Release Date">@(context.Release?.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Waveform">
|
||||
<MudTd DataLabel="Profile">
|
||||
@if (HasProfile(context.EntryKey))
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
@@ -74,6 +75,16 @@
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="High-res">
|
||||
@if (HasHighRes(context.EntryKey))
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudTooltip Text="Edit">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
@@ -100,7 +111,7 @@
|
||||
</MudTooltip>
|
||||
@if (!HasProfile(context.EntryKey))
|
||||
{
|
||||
<MudTooltip Text="Generate Waveform">
|
||||
<MudTooltip Text="Generate profile">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
|
||||
Size="Size.Small"
|
||||
Color="Color.Secondary"
|
||||
@@ -108,6 +119,16 @@
|
||||
OnClick="@(() => GenerateOneAsync(context))" />
|
||||
</MudTooltip>
|
||||
}
|
||||
@if (!HasHighRes(context.EntryKey))
|
||||
{
|
||||
<MudTooltip Text="Generate high-res datum">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Waves"
|
||||
Size="Size.Small"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@(_bulkRunning || _generatingHighRes.Contains(context.EntryKey))"
|
||||
OnClick="@(() => GenerateOneHighResAsync(context))" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
@@ -127,7 +148,10 @@
|
||||
|
||||
// EntryKey → HasProfile. Loaded once on init; per-row generate flips a single entry to true.
|
||||
private Dictionary<string, bool> _waveformStatus = new();
|
||||
// EntryKey → HasHighRes (the per-track visualizer datum, phase-12 §5). Same lifecycle as above.
|
||||
private Dictionary<string, bool> _highResStatus = new();
|
||||
private readonly HashSet<string> _generating = new();
|
||||
private readonly HashSet<string> _generatingHighRes = new();
|
||||
|
||||
// The parent owns "Generate All Missing"; while it runs it disables this grid's per-row buttons.
|
||||
private bool _bulkRunning;
|
||||
@@ -140,6 +164,9 @@
|
||||
private bool HasProfile(string entryKey) =>
|
||||
_waveformStatus.TryGetValue(entryKey, out var hasProfile) && hasProfile;
|
||||
|
||||
private bool HasHighRes(string entryKey) =>
|
||||
_highResStatus.TryGetValue(entryKey, out var hasHighRes) && hasHighRes;
|
||||
|
||||
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
|
||||
private static string ThumbUrl(string imagePath) =>
|
||||
$"/api/image/{Uri.EscapeDataString(imagePath)}";
|
||||
@@ -147,16 +174,27 @@
|
||||
/// <summary>Number of tracks with a missing waveform profile — drives the parent's bulk button label.</summary>
|
||||
public int GetMissingCount() => _waveformStatus.Count(kv => !kv.Value);
|
||||
|
||||
/// <summary>Number of tracks missing the high-res visualizer datum — drives the parent's backfill button.</summary>
|
||||
public int GetMissingHighResCount() => _highResStatus.Count(kv => !kv.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Reload the full waveform-status map. Called on init and by the parent after a bulk generate so
|
||||
/// the per-row icons reflect the new state.
|
||||
/// the per-row icons reflect the new state. One status fetch populates both the 512-bucket profile
|
||||
/// map and the high-res datum map.
|
||||
/// </summary>
|
||||
public async Task RefreshWaveformStatusAsync()
|
||||
{
|
||||
var result = await CmsTrackService.GetWaveformStatusAsync();
|
||||
_waveformStatus = result.Success && result.Value is not null
|
||||
? result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile)
|
||||
: new Dictionary<string, bool>();
|
||||
if (result.Success && result.Value is not null)
|
||||
{
|
||||
_waveformStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile);
|
||||
_highResStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasHighRes);
|
||||
}
|
||||
else
|
||||
{
|
||||
_waveformStatus = new Dictionary<string, bool>();
|
||||
_highResStatus = new Dictionary<string, bool>();
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
await OnStatusLoaded.InvokeAsync();
|
||||
@@ -255,4 +293,34 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateOneHighResAsync(TrackDto track)
|
||||
{
|
||||
_generatingHighRes.Add(track.EntryKey);
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateHighResWaveformAsync(track.EntryKey);
|
||||
if (result.Success)
|
||||
{
|
||||
_highResStatus[track.EntryKey] = true;
|
||||
Snackbar.Add($"Generated high-res datum for '{track.TrackName}'.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"High-res generate failed for '{track.TrackName}': {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "High-res waveform generation failed for {EntryKey}", track.EntryKey);
|
||||
Snackbar.Add($"High-res generate failed for '{track.TrackName}' — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_generatingHighRes.Remove(track.EntryKey);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,11 @@
|
||||
|
||||
@if (VM.Mode == BrowseMode.Tracks)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
||||
Disabled="@(_bulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
|
||||
Disabled="@(_bulkRunning || _highResBulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
|
||||
OnClick="GenerateAllMissingAsync">
|
||||
@if (_bulkRunning)
|
||||
{
|
||||
@@ -31,9 +32,25 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Generate All Missing (@(_grid?.GetMissingCount() ?? 0))</span>
|
||||
<span>Generate All Profiles (@(_grid?.GetMissingCount() ?? 0))</span>
|
||||
}
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Waves"
|
||||
Disabled="@(_bulkRunning || _highResBulkRunning || (_grid?.GetMissingHighResCount() ?? 0) == 0)"
|
||||
OnClick="GenerateAllMissingHighResAsync">
|
||||
@if (_highResBulkRunning)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Backfilling @_highResBulkDone / @_highResBulkTotal…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Backfill High-res (@(_grid?.GetMissingHighResCount() ?? 0))</span>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@@ -147,11 +164,17 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Local state for the parent-owned "Generate All Missing" bulk run.
|
||||
// Local state for the parent-owned "Generate All Profiles" bulk run.
|
||||
private bool _bulkRunning;
|
||||
private int _bulkTotal;
|
||||
private int _bulkDone;
|
||||
|
||||
// Local state for the parent-owned "Backfill High-res" bulk run (phase-12 §8a-new). Independent of
|
||||
// the profile bulk above; both disable the grid's per-row buttons while either runs.
|
||||
private bool _highResBulkRunning;
|
||||
private int _highResBulkTotal;
|
||||
private int _highResBulkDone;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// /tracks/archive and /tracks/albums both land on the Releases view (the tab strip); the old
|
||||
@@ -248,4 +271,71 @@
|
||||
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfill the per-track high-res visualizer datum (phase-12 §5) for every track missing one, one
|
||||
/// request at a time so a large backfill does not flood the API with concurrent WAV decodes. This is
|
||||
/// the §8a-new backfill mechanism over the generalized track generate action — re-runnable (a second
|
||||
/// run re-reads status and only retries what is still missing). On completion, refreshes the grid's
|
||||
/// status maps so the per-row icons reflect the new state.
|
||||
/// </summary>
|
||||
private async Task GenerateAllMissingHighResAsync()
|
||||
{
|
||||
var statusResult = await CmsTrackService.GetWaveformStatusAsync();
|
||||
if (!statusResult.Success || statusResult.Value is null)
|
||||
{
|
||||
var error = statusResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load waveform status: {error}", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var missing = statusResult.Value.Where(s => !s.HasHighRes).ToList();
|
||||
if (missing.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_highResBulkRunning = true;
|
||||
_highResBulkTotal = missing.Count;
|
||||
_highResBulkDone = 0;
|
||||
_grid?.SetBulkRunning(true);
|
||||
var failures = 0;
|
||||
|
||||
foreach (var status in missing)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateHighResWaveformAsync(status.EntryKey);
|
||||
if (!result.Success)
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "High-res waveform generation failed for {EntryKey}", status.EntryKey);
|
||||
failures++;
|
||||
}
|
||||
_highResBulkDone++;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
_highResBulkRunning = false;
|
||||
_grid?.SetBulkRunning(false);
|
||||
|
||||
if (_grid is not null)
|
||||
{
|
||||
await _grid.RefreshWaveformStatusAsync();
|
||||
}
|
||||
|
||||
var succeeded = missing.Count - failures;
|
||||
if (failures == 0)
|
||||
{
|
||||
Snackbar.Add($"Backfilled {succeeded} high-res datum(s).", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Backfilled {succeeded} high-res datum(s); {failures} failed.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,6 +501,39 @@ public class CmsTrackService : ICmsTrackService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> GenerateHighResWaveformAsync(string entryKey, CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.PostAsync($"api/track/{Uri.EscapeDataString(entryKey)}/waveform/high-res", null, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for high-res waveform generation of {EntryKey}", entryKey);
|
||||
return Result.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return Result.CreateFailResult("Track audio not found.");
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogError("Content API high-res waveform generation failed for {EntryKey}: {Status} {Body}", entryKey, (int)response.StatusCode, body);
|
||||
return Result.CreateFailResult("Failed to generate high-res waveform datum.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
@@ -105,6 +105,14 @@ public interface ICmsTrackService
|
||||
/// </summary>
|
||||
Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Trigger high-res visualizer datum generation for a single track via
|
||||
/// <c>POST api/track/{entryKey}/waveform/high-res</c> (phase-12 §5). Re-runnable — recomputes on each
|
||||
/// call. Drives the per-row generate action and the batch backfill. Maps a 404 to a "Track audio not
|
||||
/// found." failure.
|
||||
/// </summary>
|
||||
Task<Result> GenerateHighResWaveformAsync(string entryKey, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
|
||||
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
namespace DeepDrftModels.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Per-track waveform profile status for the CMS PreProcessing panel. Tells admins which tracks
|
||||
/// already carry a stored loudness profile and which predate the WaveformSeeker feature and need
|
||||
/// backfilling. <see cref="HasProfile"/> is the existence check; <see cref="EntryKey"/> is the
|
||||
/// vault key used to trigger generation for a missing profile.
|
||||
/// Per-track waveform datum status for the CMS PreProcessing panel. Tells admins which tracks already
|
||||
/// carry each stored datum and which need backfilling. <see cref="HasProfile"/> is the 512-bucket
|
||||
/// player-bar profile; <see cref="HasHighRes"/> is the duration-derived high-res visualizer datum
|
||||
/// (phase-12 §5 — every track now carries one). <see cref="EntryKey"/> is the vault key used to trigger
|
||||
/// generation for either missing datum.
|
||||
/// </summary>
|
||||
public class WaveformStatusDto
|
||||
{
|
||||
@@ -12,4 +13,5 @@ public class WaveformStatusDto
|
||||
public string EntryKey { get; set; } = string.Empty;
|
||||
public string TrackName { get; set; } = string.Empty;
|
||||
public bool HasProfile { get; set; }
|
||||
public bool HasHighRes { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
using DeepDrftContent.Processors;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Behavioral tests for the duration-derived Mix bucket-count derivation. The contract: capture at a
|
||||
/// constant time resolution (≈333 samples/sec) so a 333 ms max-zoom window holds enough samples on any
|
||||
/// mix length, clamped to a sane floor (short/degenerate mixes) and an upper cap (extreme outliers).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class MixWaveformResolutionTests
|
||||
{
|
||||
[Test]
|
||||
public void BucketCountForDuration_TypicalMix_CapturesAtTargetDensity()
|
||||
{
|
||||
// 3 minutes × 333/s = 59,940 — a typical short mix, comfortably inside [floor, cap].
|
||||
var buckets = MixWaveformResolution.BucketCountForDuration(180.0);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo((int)Math.Ceiling(180.0 * MixWaveformResolution.SamplesPerSecond)));
|
||||
Assert.That(buckets, Is.EqualTo(59_940));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_SixtyMinuteMix_ProducesAboutOnePointTwoMillion()
|
||||
{
|
||||
// 60 min × 333/s = 1,198,800 ≈ 1.2M samples (≈1.2 MB datum), still under the cap.
|
||||
var buckets = MixWaveformResolution.BucketCountForDuration(3600.0);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo(1_198_800));
|
||||
Assert.That(buckets, Is.LessThan(MixWaveformResolution.MaxBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_OverHundredMinutes_ClampsToCap()
|
||||
{
|
||||
// 120 min × 333/s = 2,397,600 > cap → clamps to the cap.
|
||||
var buckets = MixWaveformResolution.BucketCountForDuration(7200.0);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo(MixWaveformResolution.MaxBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_NearZeroDuration_HitsFloor()
|
||||
{
|
||||
// 0.1 s × 333/s = 34 buckets, far below the floor → clamps up to the floor.
|
||||
var buckets = MixWaveformResolution.BucketCountForDuration(0.1);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo(MixWaveformResolution.MinBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_ZeroDuration_HitsFloor()
|
||||
{
|
||||
Assert.That(MixWaveformResolution.BucketCountForDuration(0.0), Is.EqualTo(MixWaveformResolution.MinBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_NegativeOrNaN_HitsFloor()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(MixWaveformResolution.BucketCountForDuration(-5.0), Is.EqualTo(MixWaveformResolution.MinBucketCount));
|
||||
Assert.That(MixWaveformResolution.BucketCountForDuration(double.NaN), Is.EqualTo(MixWaveformResolution.MinBucketCount));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_DurationAtFloorBoundary_ReturnsFloorThenGrows()
|
||||
{
|
||||
// floor / 333 = 6.15 s is the duration where the derived count meets the floor exactly.
|
||||
const double floorBoundarySeconds = (double)MixWaveformResolution.MinBucketCount / MixWaveformResolution.SamplesPerSecond;
|
||||
|
||||
// Just below the boundary clamps to the floor; just above derives above the floor.
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(MixWaveformResolution.BucketCountForDuration(floorBoundarySeconds - 0.1), Is.EqualTo(MixWaveformResolution.MinBucketCount));
|
||||
Assert.That(MixWaveformResolution.BucketCountForDuration(floorBoundarySeconds + 1.0), Is.GreaterThan(MixWaveformResolution.MinBucketCount));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_DurationAtCapBoundary_ReturnsCap()
|
||||
{
|
||||
// cap / 333 = 6006.006 s is the duration where the derived count meets the cap exactly.
|
||||
const double capBoundarySeconds = (double)MixWaveformResolution.MaxBucketCount / MixWaveformResolution.SamplesPerSecond;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(MixWaveformResolution.BucketCountForDuration(capBoundarySeconds + 1.0), Is.EqualTo(MixWaveformResolution.MaxBucketCount));
|
||||
Assert.That(MixWaveformResolution.BucketCountForDuration(capBoundarySeconds - 10.0), Is.LessThan(MixWaveformResolution.MaxBucketCount));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Text;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.Processors;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the per-track high-res waveform compute (phase-12 §5, Direction B). These
|
||||
/// exercise the exact content-side path the upload, CMS generate action, and Mix trigger all funnel
|
||||
/// through (<see cref="WaveformProfileService.ComputeAndStoreHighResAsync"/>) over a real
|
||||
/// <see cref="FileDb"/> and a real <see cref="AudioProcessor"/> + <see cref="RmsLoudnessAlgorithm"/>.
|
||||
/// The track's medium is irrelevant here — that is the point of the generalization: the content
|
||||
/// service computes a datum from any track's audio, keyed by EntryKey, with no Mix coupling.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class WaveformProfileServiceTests
|
||||
{
|
||||
private string _testDir = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), "WaveformProfileServiceTests", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
try { Directory.Delete(_testDir, recursive: true); }
|
||||
catch { /* Best-effort cleanup — ignore failures */ }
|
||||
}
|
||||
|
||||
private async Task<WaveformProfileService> CreateServiceAsync(FileDb fileDatabase)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
return new WaveformProfileService(
|
||||
fileDatabase,
|
||||
new AudioProcessor(),
|
||||
new RmsLoudnessAlgorithm(),
|
||||
Options.Create(new WaveformProfileOptions()),
|
||||
NullLogger<WaveformProfileService>.Instance);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ComputeAndStoreHighResAsync_NonMixTrack_StoresDatumInTrackWaveformsVault()
|
||||
{
|
||||
var fileDatabase = await FileDb.FromAsync(_testDir);
|
||||
Assert.That(fileDatabase, Is.Not.Null);
|
||||
var service = await CreateServiceAsync(fileDatabase!);
|
||||
|
||||
// A 2-second mono 16-bit WAV — stands in for "any track" (Cut/Session/Mix alike). No release
|
||||
// or medium is involved; the compute is keyed only by the supplied EntryKey.
|
||||
const string entryKey = "cut-track-entry";
|
||||
var wav = BuildMinimalPcmWav(durationSeconds: 2.0);
|
||||
|
||||
var stored = await service.ComputeAndStoreHighResAsync(wav, entryKey, durationSeconds: 2.0);
|
||||
|
||||
Assert.That(stored, Is.True, "High-res compute should succeed for a decodable PCM WAV");
|
||||
|
||||
var datum = await service.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms);
|
||||
Assert.That(datum, Is.Not.Null, "Datum must be retrievable from the track-waveforms vault by EntryKey");
|
||||
// 2 s × 333/s = 666 buckets, below the floor → clamps to the floor (2048).
|
||||
Assert.That(datum!.Length, Is.EqualTo(WaveformResolution.BucketCountForDuration(2.0)));
|
||||
Assert.That(datum.Length, Is.EqualTo(WaveformResolution.MinBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ComputeAndStoreHighResAsync_LongTrack_BucketCountIsDurationDerived()
|
||||
{
|
||||
var fileDatabase = await FileDb.FromAsync(_testDir);
|
||||
var service = await CreateServiceAsync(fileDatabase!);
|
||||
|
||||
// A 10-second WAV: 10 × 333 = 3330 buckets, above the 2048 floor — proves the count tracks
|
||||
// duration rather than the fixed 512-bucket profile resolution.
|
||||
const string entryKey = "long-track-entry";
|
||||
var wav = BuildMinimalPcmWav(durationSeconds: 10.0);
|
||||
|
||||
var stored = await service.ComputeAndStoreHighResAsync(wav, entryKey, durationSeconds: 10.0);
|
||||
Assert.That(stored, Is.True);
|
||||
|
||||
var datum = await service.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms);
|
||||
Assert.That(datum, Is.Not.Null);
|
||||
Assert.That(datum!.Length, Is.EqualTo(WaveformResolution.BucketCountForDuration(10.0)));
|
||||
Assert.That(datum.Length, Is.GreaterThan(new WaveformProfileOptions().BucketCount),
|
||||
"The high-res datum must be denser than the fixed 512-bucket player-bar profile");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HighResAndProfile_ForSameTrack_StoredInSeparateVaultsKeyedByEntryKey()
|
||||
{
|
||||
var fileDatabase = await FileDb.FromAsync(_testDir);
|
||||
var service = await CreateServiceAsync(fileDatabase!);
|
||||
|
||||
// The two datums a track carries (phase-12 §5): the 512-bucket player-bar profile and the
|
||||
// duration-derived high-res visualizer datum. Both key off the same EntryKey but live in
|
||||
// distinct vaults, so neither overwrites the other.
|
||||
const string entryKey = "shared-key";
|
||||
var wav = BuildMinimalPcmWav(durationSeconds: 10.0);
|
||||
|
||||
Assert.That(await service.ComputeAndStoreAsync(wav, entryKey), Is.True);
|
||||
Assert.That(await service.ComputeAndStoreHighResAsync(wav, entryKey, durationSeconds: 10.0), Is.True);
|
||||
|
||||
var profile = await service.GetProfileAsync(entryKey);
|
||||
var highRes = await service.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(profile, Is.Not.Null);
|
||||
Assert.That(highRes, Is.Not.Null);
|
||||
Assert.That(profile!.Length, Is.EqualTo(new WaveformProfileOptions().BucketCount));
|
||||
Assert.That(highRes!.Length, Is.EqualTo(WaveformResolution.BucketCountForDuration(10.0)));
|
||||
Assert.That(highRes.Length, Is.Not.EqualTo(profile.Length), "The two datums must differ in resolution");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ComputeAndStoreHighResAsync_IsRerunnable_OverwritesPriorDatum()
|
||||
{
|
||||
var fileDatabase = await FileDb.FromAsync(_testDir);
|
||||
var service = await CreateServiceAsync(fileDatabase!);
|
||||
|
||||
// The backfill / regenerate path must be re-runnable: a second compute for the same key
|
||||
// overwrites cleanly rather than failing or duplicating.
|
||||
const string entryKey = "rerun-key";
|
||||
var wav = BuildMinimalPcmWav(durationSeconds: 10.0);
|
||||
|
||||
Assert.That(await service.ComputeAndStoreHighResAsync(wav, entryKey, durationSeconds: 10.0), Is.True);
|
||||
Assert.That(await service.ComputeAndStoreHighResAsync(wav, entryKey, durationSeconds: 10.0), Is.True,
|
||||
"A re-run must succeed and overwrite the prior datum");
|
||||
|
||||
var datum = await service.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms);
|
||||
Assert.That(datum, Is.Not.Null);
|
||||
Assert.That(datum!.Length, Is.EqualTo(WaveformResolution.BucketCountForDuration(10.0)));
|
||||
}
|
||||
|
||||
// Builds a minimal standard-PCM mono 16-bit 44.1 kHz WAV with a full-scale square wave across the
|
||||
// requested duration. Real PCM (not silence) so the loudness algorithm produces a non-degenerate
|
||||
// envelope. Mirrors the chunk layout AudioProcessor expects (RIFF/WAVE/fmt /data).
|
||||
private static byte[] BuildMinimalPcmWav(double durationSeconds)
|
||||
{
|
||||
const int sampleRate = 44100;
|
||||
const ushort channels = 1;
|
||||
const ushort bitsPerSample = 16;
|
||||
const ushort blockAlign = channels * (bitsPerSample / 8);
|
||||
const uint byteRate = sampleRate * blockAlign;
|
||||
|
||||
var frames = (int)(sampleRate * durationSeconds);
|
||||
var data = new byte[frames * blockAlign];
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
// Alternating full-scale square wave so RMS reads as loud, not silent.
|
||||
var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue;
|
||||
data[i * 2] = (byte)(sample & 0xFF);
|
||||
data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("RIFF"));
|
||||
w.Write((uint)(36 + data.Length));
|
||||
w.Write(Encoding.ASCII.GetBytes("WAVE"));
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("fmt "));
|
||||
w.Write(16u);
|
||||
w.Write((ushort)1); // PCM
|
||||
w.Write(channels);
|
||||
w.Write((uint)sampleRate);
|
||||
w.Write(byteRate);
|
||||
w.Write(blockAlign);
|
||||
w.Write(bitsPerSample);
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("data"));
|
||||
w.Write((uint)data.Length);
|
||||
w.Write(data);
|
||||
|
||||
w.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using DeepDrftContent.Processors;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Behavioral tests for the duration-derived high-res bucket-count derivation. The contract: capture at
|
||||
/// a constant time resolution (≈333 samples/sec) so a 333 ms max-zoom window holds enough samples on any
|
||||
/// track length, clamped to a sane floor (short/degenerate tracks) and an upper cap (extreme outliers).
|
||||
/// Applies to every track (Mix, Session, Cut) under the per-track model — phase-12 §5.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class WaveformResolutionTests
|
||||
{
|
||||
[Test]
|
||||
public void BucketCountForDuration_TypicalTrack_CapturesAtTargetDensity()
|
||||
{
|
||||
// 3 minutes × 333/s = 59,940 — a typical short track, comfortably inside [floor, cap].
|
||||
var buckets = WaveformResolution.BucketCountForDuration(180.0);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo((int)Math.Ceiling(180.0 * WaveformResolution.SamplesPerSecond)));
|
||||
Assert.That(buckets, Is.EqualTo(59_940));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_SixtyMinuteTrack_ProducesAboutOnePointTwoMillion()
|
||||
{
|
||||
// 60 min × 333/s = 1,198,800 ≈ 1.2M samples (≈1.2 MB datum), still under the cap.
|
||||
var buckets = WaveformResolution.BucketCountForDuration(3600.0);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo(1_198_800));
|
||||
Assert.That(buckets, Is.LessThan(WaveformResolution.MaxBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_OverHundredMinutes_ClampsToCap()
|
||||
{
|
||||
// 120 min × 333/s = 2,397,600 > cap → clamps to the cap.
|
||||
var buckets = WaveformResolution.BucketCountForDuration(7200.0);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo(WaveformResolution.MaxBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_NearZeroDuration_HitsFloor()
|
||||
{
|
||||
// 0.1 s × 333/s = 34 buckets, far below the floor → clamps up to the floor.
|
||||
var buckets = WaveformResolution.BucketCountForDuration(0.1);
|
||||
|
||||
Assert.That(buckets, Is.EqualTo(WaveformResolution.MinBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_ZeroDuration_HitsFloor()
|
||||
{
|
||||
Assert.That(WaveformResolution.BucketCountForDuration(0.0), Is.EqualTo(WaveformResolution.MinBucketCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_NegativeOrNaN_HitsFloor()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(WaveformResolution.BucketCountForDuration(-5.0), Is.EqualTo(WaveformResolution.MinBucketCount));
|
||||
Assert.That(WaveformResolution.BucketCountForDuration(double.NaN), Is.EqualTo(WaveformResolution.MinBucketCount));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_DurationAtFloorBoundary_ReturnsFloorThenGrows()
|
||||
{
|
||||
// floor / 333 = 6.15 s is the duration where the derived count meets the floor exactly.
|
||||
const double floorBoundarySeconds = (double)WaveformResolution.MinBucketCount / WaveformResolution.SamplesPerSecond;
|
||||
|
||||
// Just below the boundary clamps to the floor; just above derives above the floor.
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(WaveformResolution.BucketCountForDuration(floorBoundarySeconds - 0.1), Is.EqualTo(WaveformResolution.MinBucketCount));
|
||||
Assert.That(WaveformResolution.BucketCountForDuration(floorBoundarySeconds + 1.0), Is.GreaterThan(WaveformResolution.MinBucketCount));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BucketCountForDuration_DurationAtCapBoundary_ReturnsCap()
|
||||
{
|
||||
// cap / 333 = 6006.006 s is the duration where the derived count meets the cap exactly.
|
||||
const double capBoundarySeconds = (double)WaveformResolution.MaxBucketCount / WaveformResolution.SamplesPerSecond;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(WaveformResolution.BucketCountForDuration(capBoundarySeconds + 1.0), Is.EqualTo(WaveformResolution.MaxBucketCount));
|
||||
Assert.That(WaveformResolution.BucketCountForDuration(capBoundarySeconds - 10.0), Is.LessThan(WaveformResolution.MaxBucketCount));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user