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)
|
// 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
|
// 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
|
// 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
|
// {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.
|
// verb + constraint). Declared before the shorter "{entryKey}" route for clarity.
|
||||||
@@ -91,7 +92,7 @@ public class ReleaseController : ControllerBase
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.MixWaveforms);
|
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.TrackWaveforms);
|
||||||
if (bytes is null)
|
if (bytes is null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Mix waveform key set but no datum stored for release: {EntryKey}", entryKey);
|
_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])
|
// GET api/track/waveform-status ([ApiKeyAuthorize])
|
||||||
// Admin backfill view: returns every track with a flag for whether a waveform profile is
|
// Admin backfill view: returns every track with flags for whether each waveform datum is stored —
|
||||||
// stored in the WaveformProfiles vault. The catalogue is small enough that the CMS panel reads
|
// 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
|
// the whole list unpaged. Declared before the parameterized "{trackId}" route so the literal
|
||||||
// segment is never treated as a trackId.
|
// segment is never treated as a trackId.
|
||||||
[ApiKeyAuthorize]
|
[ApiKeyAuthorize]
|
||||||
@@ -158,12 +159,14 @@ public class TrackController : ControllerBase
|
|||||||
foreach (var track in tracks.Value)
|
foreach (var track in tracks.Value)
|
||||||
{
|
{
|
||||||
var profile = await _waveformProfileService.GetProfileAsync(track.EntryKey);
|
var profile = await _waveformProfileService.GetProfileAsync(track.EntryKey);
|
||||||
|
var highRes = await _waveformProfileService.GetProfileAsync(track.EntryKey, VaultConstants.TrackWaveforms);
|
||||||
status.Add(new WaveformStatusDto
|
status.Add(new WaveformStatusDto
|
||||||
{
|
{
|
||||||
TrackId = track.Id,
|
TrackId = track.Id,
|
||||||
EntryKey = track.EntryKey,
|
EntryKey = track.EntryKey,
|
||||||
TrackName = track.TrackName,
|
TrackName = track.TrackName,
|
||||||
HasProfile = profile is not null,
|
HasProfile = profile is not null,
|
||||||
|
HasHighRes = highRes is not null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,6 +603,35 @@ public class TrackController : ControllerBase
|
|||||||
return Ok();
|
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]
|
[ApiKeyAuthorize]
|
||||||
[HttpPut("{trackId}")]
|
[HttpPut("{trackId}")]
|
||||||
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
|
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
|
||||||
|
|||||||
@@ -102,9 +102,11 @@ public class UnifiedReleaseService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetch the Mix's track audio from the vault, compute a high-res waveform datum at a constant time
|
/// 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
|
/// 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
|
/// 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>
|
/// </summary>
|
||||||
public async Task<Result> TriggerMixWaveformAsync(long releaseId, CancellationToken ct)
|
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
|
// 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.
|
// under-sampled by a fixed bucket count — see WaveformResolution / spec §F. Same per-track
|
||||||
var bucketCount = MixWaveformResolution.BucketCountForDuration(audio.Duration);
|
// high-res datum every track now carries (phase-12 §5).
|
||||||
var computed = await _waveformProfileService.ComputeAndStoreAsync(
|
var computed = await _waveformProfileService.ComputeAndStoreHighResAsync(
|
||||||
audio.Buffer, entryKey, bucketCount, VaultConstants.MixWaveforms);
|
audio.Buffer, entryKey, audio.Duration);
|
||||||
if (!computed)
|
if (!computed)
|
||||||
{
|
{
|
||||||
_logger.LogError("TriggerMixWaveform: waveform computation/storage failed for {EntryKey}", entryKey);
|
_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}");
|
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
|
// Best-effort waveform datums: both stores succeeded, so the upload is a success regardless of
|
||||||
// regardless of the profile outcome. A missing profile renders as a flat seekbar on the
|
// 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.
|
// 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;
|
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
|
try
|
||||||
{
|
{
|
||||||
var wavBytes = await File.ReadAllBytesAsync(tempFilePath, ct);
|
var audio = await _contentTrackContentService.GetAudioBinaryAsync(entryKey);
|
||||||
await _waveformProfileService.ComputeAndStoreAsync(wavBytes, 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)
|
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");
|
if (db is null) throw new Exception("Unable to initialize file database");
|
||||||
InitializeTrackVault(db).GetAwaiter().GetResult();
|
InitializeTrackVault(db).GetAwaiter().GetResult();
|
||||||
InitializeImageVault(db).GetAwaiter().GetResult();
|
InitializeImageVault(db).GetAwaiter().GetResult();
|
||||||
InitializeMixWaveformsVault(db).GetAwaiter().GetResult();
|
InitializeTrackWaveformsVault(db).GetAwaiter().GetResult();
|
||||||
return db;
|
return db;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,12 +66,13 @@ namespace DeepDrftAPI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the mix-waveforms vault exists. Holds high-resolution waveform datums for DJ Mix releases.
|
// Ensure the track-waveforms vault exists. Holds the per-track high-resolution waveform datum
|
||||||
private static async Task InitializeMixWaveformsVault(FileDatabase fileDatabase)
|
// (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";
|
public const string Images = "images";
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
/// 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>
|
/// </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
|
/// <paramref name="entryKey"/> in <paramref name="vaultName"/> (defaults to
|
||||||
/// <see cref="VaultConstants.WaveformProfiles"/> when null). Bucket resolution defaults to
|
/// <see cref="VaultConstants.WaveformProfiles"/> when null). Bucket resolution defaults to
|
||||||
/// <see cref="WaveformProfileOptions.BucketCount"/> (512) when <paramref name="bucketCount"/> is null;
|
/// <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
|
/// callers pass an explicit count for higher-resolution data — e.g. the per-track high-res datum
|
||||||
/// from the audio duration (≈333 samples/sec, see <c>MixWaveformResolution</c>) so long mixes are not
|
/// derives its count from the audio duration (≈333 samples/sec, see <c>WaveformResolution</c>) so long
|
||||||
/// under-sampled. This service is content-agnostic: it captures however many buckets it is told to and
|
/// 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
|
/// 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
|
/// 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.
|
/// 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>
|
/// <summary>
|
||||||
/// Returns the stored quantized profile bytes for a track from <paramref name="vaultName"/>
|
/// 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
|
/// (defaults to <see cref="VaultConstants.WaveformProfiles"/> when null), or null if no profile
|
||||||
|
|||||||
+9
-8
@@ -1,40 +1,41 @@
|
|||||||
namespace DeepDrftContent.Processors;
|
namespace DeepDrftContent.Processors;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Derives the bucket count for a Mix loudness datum from the audio's duration, so the stored
|
/// Derives the bucket count for a track's high-resolution loudness datum from the audio's duration, so
|
||||||
/// profile captures at a constant <em>time</em> resolution instead of a fixed bucket count.
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// 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 mix
|
/// 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."
|
/// 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
|
/// 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.
|
/// in <see cref="WaveformProfileService"/>, which is content-agnostic and parameterized by bucket count.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>≈333 samples/sec (≈3 ms/sample): one quarter note at 180 BPM (333 ms) holds ~111 samples.</summary>
|
||||||
public const int SamplesPerSecond = 333;
|
public const int SamplesPerSecond = 333;
|
||||||
|
|
||||||
/// <summary>
|
/// <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).
|
/// accept slightly-below-target density rather than an unbounded datum (spec §F mitigation #1).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const int MaxBucketCount = 2_000_000;
|
public const int MaxBucketCount = 2_000_000;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Floor on bucket count. Keeps the historical 2048-bucket density as the minimum so a degenerate
|
/// 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>
|
/// </summary>
|
||||||
public const int MinBucketCount = 2048;
|
public const int MinBucketCount = 2048;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a track's duration (seconds) to a bucket count of <c>ceil(durationSeconds × 333)</c>,
|
/// 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
|
/// 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>
|
/// </summary>
|
||||||
public static int BucketCountForDuration(double durationSeconds)
|
public static int BucketCountForDuration(double durationSeconds)
|
||||||
{
|
{
|
||||||
@@ -43,7 +43,8 @@
|
|||||||
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
|
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
|
||||||
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
|
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
|
||||||
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</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>
|
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
<MudTd DataLabel="Album">@(context.Release?.Title ?? "—")</MudTd>
|
<MudTd DataLabel="Album">@(context.Release?.Title ?? "—")</MudTd>
|
||||||
<MudTd DataLabel="Genre">@(context.Release?.Genre ?? "—")</MudTd>
|
<MudTd DataLabel="Genre">@(context.Release?.Genre ?? "—")</MudTd>
|
||||||
<MudTd DataLabel="Release Date">@(context.Release?.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
|
<MudTd DataLabel="Release Date">@(context.Release?.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
|
||||||
<MudTd DataLabel="Waveform">
|
<MudTd DataLabel="Profile">
|
||||||
@if (HasProfile(context.EntryKey))
|
@if (HasProfile(context.EntryKey))
|
||||||
{
|
{
|
||||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
<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" />
|
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
|
||||||
}
|
}
|
||||||
</MudTd>
|
</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">
|
<MudTd DataLabel="Actions">
|
||||||
<MudTooltip Text="Edit">
|
<MudTooltip Text="Edit">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||||
@@ -100,7 +111,7 @@
|
|||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
@if (!HasProfile(context.EntryKey))
|
@if (!HasProfile(context.EntryKey))
|
||||||
{
|
{
|
||||||
<MudTooltip Text="Generate Waveform">
|
<MudTooltip Text="Generate profile">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
|
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
|
||||||
Size="Size.Small"
|
Size="Size.Small"
|
||||||
Color="Color.Secondary"
|
Color="Color.Secondary"
|
||||||
@@ -108,6 +119,16 @@
|
|||||||
OnClick="@(() => GenerateOneAsync(context))" />
|
OnClick="@(() => GenerateOneAsync(context))" />
|
||||||
</MudTooltip>
|
</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>
|
</MudTd>
|
||||||
</RowTemplate>
|
</RowTemplate>
|
||||||
<PagerContent>
|
<PagerContent>
|
||||||
@@ -127,7 +148,10 @@
|
|||||||
|
|
||||||
// EntryKey → HasProfile. Loaded once on init; per-row generate flips a single entry to true.
|
// EntryKey → HasProfile. Loaded once on init; per-row generate flips a single entry to true.
|
||||||
private Dictionary<string, bool> _waveformStatus = new();
|
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> _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.
|
// The parent owns "Generate All Missing"; while it runs it disables this grid's per-row buttons.
|
||||||
private bool _bulkRunning;
|
private bool _bulkRunning;
|
||||||
@@ -140,6 +164,9 @@
|
|||||||
private bool HasProfile(string entryKey) =>
|
private bool HasProfile(string entryKey) =>
|
||||||
_waveformStatus.TryGetValue(entryKey, out var hasProfile) && hasProfile;
|
_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.
|
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
|
||||||
private static string ThumbUrl(string imagePath) =>
|
private static string ThumbUrl(string imagePath) =>
|
||||||
$"/api/image/{Uri.EscapeDataString(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>
|
/// <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);
|
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>
|
/// <summary>
|
||||||
/// Reload the full waveform-status map. Called on init and by the parent after a bulk generate so
|
/// 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>
|
/// </summary>
|
||||||
public async Task RefreshWaveformStatusAsync()
|
public async Task RefreshWaveformStatusAsync()
|
||||||
{
|
{
|
||||||
var result = await CmsTrackService.GetWaveformStatusAsync();
|
var result = await CmsTrackService.GetWaveformStatusAsync();
|
||||||
_waveformStatus = result.Success && result.Value is not null
|
if (result.Success && result.Value is not null)
|
||||||
? result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile)
|
{
|
||||||
: new Dictionary<string, bool>();
|
_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();
|
StateHasChanged();
|
||||||
await OnStatusLoaded.InvokeAsync();
|
await OnStatusLoaded.InvokeAsync();
|
||||||
@@ -255,4 +293,34 @@
|
|||||||
StateHasChanged();
|
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)
|
@if (VM.Mode == BrowseMode.Tracks)
|
||||||
{
|
{
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
<MudButton Variant="Variant.Outlined"
|
<MudButton Variant="Variant.Outlined"
|
||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
||||||
Disabled="@(_bulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
|
Disabled="@(_bulkRunning || _highResBulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
|
||||||
OnClick="GenerateAllMissingAsync">
|
OnClick="GenerateAllMissingAsync">
|
||||||
@if (_bulkRunning)
|
@if (_bulkRunning)
|
||||||
{
|
{
|
||||||
@@ -31,9 +32,25 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span>Generate All Missing (@(_grid?.GetMissingCount() ?? 0))</span>
|
<span>Generate All Profiles (@(_grid?.GetMissingCount() ?? 0))</span>
|
||||||
}
|
}
|
||||||
</MudButton>
|
</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>
|
</MudStack>
|
||||||
|
|
||||||
@@ -147,11 +164,17 @@
|
|||||||
StateHasChanged();
|
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 bool _bulkRunning;
|
||||||
private int _bulkTotal;
|
private int _bulkTotal;
|
||||||
private int _bulkDone;
|
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()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
// /tracks/archive and /tracks/albums both land on the Releases view (the tab strip); the old
|
// /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);
|
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)
|
public async Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ public interface ICmsTrackService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default);
|
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>
|
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
|
||||||
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
|
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
namespace DeepDrftModels.DTOs;
|
namespace DeepDrftModels.DTOs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-track waveform profile status for the CMS PreProcessing panel. Tells admins which tracks
|
/// Per-track waveform datum status for the CMS PreProcessing panel. Tells admins which tracks already
|
||||||
/// already carry a stored loudness profile and which predate the WaveformSeeker feature and need
|
/// carry each stored datum and which need backfilling. <see cref="HasProfile"/> is the 512-bucket
|
||||||
/// backfilling. <see cref="HasProfile"/> is the existence check; <see cref="EntryKey"/> is the
|
/// player-bar profile; <see cref="HasHighRes"/> is the duration-derived high-res visualizer datum
|
||||||
/// vault key used to trigger generation for a missing profile.
|
/// (phase-12 §5 — every track now carries one). <see cref="EntryKey"/> is the vault key used to trigger
|
||||||
|
/// generation for either missing datum.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class WaveformStatusDto
|
public class WaveformStatusDto
|
||||||
{
|
{
|
||||||
@@ -12,4 +13,5 @@ public class WaveformStatusDto
|
|||||||
public string EntryKey { get; set; } = string.Empty;
|
public string EntryKey { get; set; } = string.Empty;
|
||||||
public string TrackName { get; set; } = string.Empty;
|
public string TrackName { get; set; } = string.Empty;
|
||||||
public bool HasProfile { get; set; }
|
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