Stream the waveform compute so large uploads no longer buffer the whole file (Wave 2 OOM)
This commit is contained in:
@@ -112,10 +112,10 @@ public class ReleaseController : ControllerBase
|
||||
}
|
||||
|
||||
// POST api/release/{id}/mix/waveform ([ApiKeyAuthorize], no body)
|
||||
// Server-side trigger: fetch the Mix's track audio from the vault, compute a duration-derived high-res
|
||||
// waveform via ComputeAndStoreHighResAsync, store it in the track-waveforms vault, and set
|
||||
// MixMetadata.WaveformEntryKey. 404 when the release is missing or has no stored audio; 500 on
|
||||
// compute/storage failure. Declared before "{id:long}".
|
||||
// Server-side trigger: stream the Mix's track audio from the vault, compute a duration-derived
|
||||
// high-res waveform, store it in the track-waveforms vault, and set MixMetadata.WaveformEntryKey.
|
||||
// 404 when the release is missing or has no stored audio; 500 on compute/storage failure. Declared
|
||||
// before "{id:long}".
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("{id:long}/mix/waveform")]
|
||||
public async Task<ActionResult> GenerateMixWaveform(long id, CancellationToken ct = default)
|
||||
|
||||
@@ -756,15 +756,18 @@ public class TrackController : ControllerBase
|
||||
[HttpPost("{trackId}/waveform")]
|
||||
public async Task<ActionResult> GenerateWaveform(string trackId)
|
||||
{
|
||||
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
|
||||
if (audio is null)
|
||||
// Streaming compute (Wave 2): the WAV is read from the vault in bounded chunks, never buffered
|
||||
// whole. Tri-state: null = no vault audio (404), false = present but uncomputable / write failed
|
||||
// (500), true = stored.
|
||||
var stored = await _waveformProfileService.ComputeAndStoreProfileStreamingAsync(
|
||||
_ => _trackContentService.OpenAudioStreamAsync(trackId), trackId, HttpContext.RequestAborted);
|
||||
if (stored is null)
|
||||
{
|
||||
_logger.LogWarning("GenerateWaveform: no audio in vault for {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var stored = await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, trackId);
|
||||
if (!stored)
|
||||
if (stored is false)
|
||||
{
|
||||
_logger.LogError("GenerateWaveform: profile computation/storage failed for {TrackId}", trackId);
|
||||
return StatusCode(500, "Failed to generate waveform profile.");
|
||||
@@ -784,16 +787,27 @@ public class TrackController : ControllerBase
|
||||
[HttpPost("{trackId}/waveform/high-res")]
|
||||
public async Task<ActionResult> GenerateHighResWaveform(string trackId)
|
||||
{
|
||||
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
|
||||
if (audio is null)
|
||||
// The high-res bucket count is duration-derived. Read the duration from the vault index metadata
|
||||
// (no body load); its absence means the track has no vault audio → 404.
|
||||
var duration = await _trackContentService.GetAudioDurationAsync(trackId);
|
||||
if (duration 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)
|
||||
// Streaming compute (Wave 2): bounded read of the vault WAV. Tri-state mapping as in
|
||||
// GenerateWaveform — null (entry vanished between the metadata read and the compute) → 404.
|
||||
var stored = await _waveformProfileService.ComputeAndStoreHighResStreamingAsync(
|
||||
_ => _trackContentService.OpenAudioStreamAsync(trackId), trackId, duration.Value,
|
||||
HttpContext.RequestAborted);
|
||||
if (stored is null)
|
||||
{
|
||||
_logger.LogWarning("GenerateHighResWaveform: no audio in vault for {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (stored is false)
|
||||
{
|
||||
_logger.LogError("GenerateHighResWaveform: computation/storage failed for {TrackId}", trackId);
|
||||
return StatusCode(500, "Failed to generate high-res waveform datum.");
|
||||
|
||||
@@ -143,8 +143,9 @@ public class UnifiedReleaseService
|
||||
return Result.CreateFailResult(MixHasNoTrackMessage);
|
||||
}
|
||||
|
||||
var audio = await _trackContentService.GetAudioBinaryAsync(entryKey);
|
||||
if (audio is null)
|
||||
// Duration from the vault index metadata (no body load); its absence means no vault audio.
|
||||
var duration = await _trackContentService.GetAudioDurationAsync(entryKey);
|
||||
if (duration is null)
|
||||
{
|
||||
_logger.LogWarning("TriggerMixWaveform: no audio in vault for {EntryKey} (release {ReleaseId})", entryKey, releaseId);
|
||||
return Result.CreateFailResult(MixTrackNoAudioMessage);
|
||||
@@ -152,10 +153,18 @@ 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 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)
|
||||
// high-res datum every track now carries (phase-12 §5). Streamed from the vault in bounded
|
||||
// chunks (Wave 2): a ~GB mix is never buffered whole. Tri-state — null = entry vanished after
|
||||
// the metadata read; false = uncomputable / write failed.
|
||||
var computed = await _waveformProfileService.ComputeAndStoreHighResStreamingAsync(
|
||||
_ => _trackContentService.OpenAudioStreamAsync(entryKey), entryKey, duration.Value, ct);
|
||||
if (computed is null)
|
||||
{
|
||||
_logger.LogWarning("TriggerMixWaveform: no audio in vault for {EntryKey} (release {ReleaseId})", entryKey, releaseId);
|
||||
return Result.CreateFailResult(MixTrackNoAudioMessage);
|
||||
}
|
||||
|
||||
if (computed is false)
|
||||
{
|
||||
_logger.LogError("TriggerMixWaveform: waveform computation/storage failed for {EntryKey}", entryKey);
|
||||
return Result.CreateFailResult("Failed to compute the Mix waveform.");
|
||||
|
||||
@@ -279,8 +279,8 @@ public class UnifiedTrackService
|
||||
// The old waveform no longer matches the new bytes. Regenerate both datums in place, keyed by
|
||||
// the same EntryKey (the re-run overwrites the stale data). The store path no longer hands back
|
||||
// a buffer, so the waveform compute re-reads the freshly stored audio from the vault — the same
|
||||
// path the upload uses. That re-read is whole-file (Wave 2, still unbounded by design); the
|
||||
// store itself is now streamed. Best-effort throughout: a datum failure never fails the replace.
|
||||
// path the upload uses. That re-read is now a bounded streaming pass (Wave 2); neither the store
|
||||
// nor the compute holds the whole file. Best-effort throughout: a datum failure never fails the replace.
|
||||
await TryStoreWaveformDatumsAsync(entryKey, ct);
|
||||
|
||||
// Write the new duration to SQL. The vault bytes are already swapped, so this is the
|
||||
@@ -302,15 +302,16 @@ public class UnifiedTrackService
|
||||
|
||||
// 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.
|
||||
// consumes (phase-12 §5 — every track now carries one, computed at upload). Both are reduced in a
|
||||
// SINGLE streaming pass over the vault audio (Wave 2): the duration comes from the vault index
|
||||
// metadata (no body load) and the PCM is streamed in bounded chunks through two accumulators, so a
|
||||
// ~GB mix never lands its whole body in a managed byte[]. Best-effort throughout — never fails upload.
|
||||
private async Task TryStoreWaveformDatumsAsync(string entryKey, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var audio = await _contentTrackContentService.GetAudioBinaryAsync(entryKey);
|
||||
if (audio is null)
|
||||
var duration = await _contentTrackContentService.GetAudioDurationAsync(entryKey);
|
||||
if (duration is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Waveform datum step: no audio in vault for {EntryKey} immediately after store; skipping.",
|
||||
@@ -318,8 +319,8 @@ public class UnifiedTrackService
|
||||
return;
|
||||
}
|
||||
|
||||
await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, entryKey);
|
||||
await _waveformProfileService.ComputeAndStoreHighResAsync(audio.Buffer, entryKey, audio.Duration);
|
||||
await _waveformProfileService.ComputeAndStoreAllStreamingAsync(
|
||||
_ => _contentTrackContentService.OpenAudioStreamAsync(entryKey), entryKey, duration.Value, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
@@ -350,8 +351,11 @@ public class UnifiedTrackService
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var audio = await _contentTrackContentService.GetAudioBinaryAsync(track.EntryKey);
|
||||
if (audio is null)
|
||||
// Read the duration from the vault index metadata (no audio body load) — the same value the
|
||||
// processor wrote at upload. Bounds this admin path too (Wave 2): a backfill over a catalogue
|
||||
// of long mixes no longer pulls each whole file into memory just to read its runtime.
|
||||
var duration = await _contentTrackContentService.GetAudioDurationAsync(track.EntryKey);
|
||||
if (duration is null)
|
||||
{
|
||||
_logger.LogWarning("BackfillDurationsAsync: no vault audio for {EntryKey} (track {Id}); skipping.",
|
||||
track.EntryKey, track.Id);
|
||||
@@ -359,7 +363,7 @@ public class UnifiedTrackService
|
||||
continue;
|
||||
}
|
||||
|
||||
var write = await _sqlTrackService.UpdateDuration(track.Id, audio.Duration, ct);
|
||||
var write = await _sqlTrackService.UpdateDuration(track.Id, duration.Value, ct);
|
||||
if (!write.Success)
|
||||
{
|
||||
var error = write.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
|
||||
Reference in New Issue
Block a user