diff --git a/CLAUDE.md b/CLAUDE.md index 4636c1b..313e521 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ Server-side (SSR): Both clients point directly at DeepDrftAPI (server-to-server, - Root contains typed **MediaVaults** (Media, Image, Audio) - Each vault has a JSON `index` file listing entries + per-entry metadata - Entries are user-supplied strings sanitized to `[a-zA-Z0-9-]` + file extension - - Binary hierarchy: `FileBinary` → `MediaBinary` (+ Extension/MIME) → `AudioBinary` (+ Duration/Bitrate) | `ImageBinary` (+ AspectRatio) + - Binary hierarchy (**read/load path**): `FileBinary` → `MediaBinary` (+ Extension/MIME) → `AudioBinary` (+ Duration/Bitrate) | `ImageBinary` (+ AspectRatio). The **write/store path** is streaming: audio processors return a `ProcessedAudio` plan (metadata + streamed `WriteToAsync` callback); `RegisterResourceStreamingAsync` / `MediaVault.AddEntryStreamingAsync` write bytes to a temp file then `File.Move` atomic-rename into place — the full `AudioBinary` buffer is never materialized on this path. - **Error-handling philosophy**: public operations swallow exceptions and return `null`/`false` — callers must check return values, not catch. ## Key Architectural Decisions @@ -57,7 +57,7 @@ The split between host projects (`DeepDrftPublic`, `DeepDrftManager`, `DeepDrftC `TrackEntity` holds *only* metadata. The link to binary content is `EntryKey` (string) — the entry id inside the `tracks` vault in FileDatabase. Dual-database add flow: -1. `DeepDrftContent.TrackService.AddTrackFromWavAsync` processes WAV, generates entry GUID, stores audio in vault, returns unpersisted `TrackEntity`. +1. `TrackContentService.AddTrackAsync` routes the audio file by extension (`AudioProcessorRouter`), produces a `ProcessedAudio` plan (bounded-header metadata + streamed `WriteToAsync` callback — no whole-file `AudioBinary` buffer), and stores it in the vault via `FileDatabase.RegisterResourceStreamingAsync` / `MediaVault.AddEntryStreamingAsync` (atomic temp→rename on the Linux host). Returns an unpersisted `TrackEntity` with `DurationSeconds` populated from the header parse. Wave 1 OOM fix. 2. `DeepDrftAPI.Services.UnifiedTrackService.UploadAsync` persists the entity to SQL via `DeepDrftData.TrackManager` and returns the persisted entity with `Id`. If step 1 succeeds and step 2 fails, audio is orphaned in the vault (no rollback today). diff --git a/DeepDrftAPI/CLAUDE.md b/DeepDrftAPI/CLAUDE.md index 2263b78..64e84f2 100644 --- a/DeepDrftAPI/CLAUDE.md +++ b/DeepDrftAPI/CLAUDE.md @@ -11,7 +11,7 @@ Dual-database authority for tracks (SQL metadata + FileDatabase binary), release ## What lives here now (only) - `Program.cs`, `Startup.cs`: HTTP host config, DI wiring, middleware setup, port binding. AuthBlocks startup: `AddAuthBlocks`, `UseAuthBlocksStartupAsync`, `MapAuthBlocks`, authentication/authorization middleware. -- `Services/UnifiedTrackService.cs`: Host-internal orchestrator. Coordinates vault write + SQL persist for upload (`UploadAsync`), and SQL delete + vault remove for delete (`DeleteAsync`). +- `Services/UnifiedTrackService.cs`: Host-internal orchestrator. Coordinates streaming vault write + SQL persist for upload (`UploadAsync`), and SQL delete + vault remove for delete (`DeleteAsync`). The upload/replace hot path streams audio into the vault via the `ProcessedAudio` plan (Wave 1 OOM fix) and then computes both waveform datums in a single bounded streaming pass via `TryStoreWaveformDatumsAsync` (Wave 2 OOM fix) — neither path buffers the whole audio file in a managed `byte[]`. - `Services/UnifiedReleaseService.cs`: Host-internal orchestrator. Coordinates release mutations (mix waveform compute + store, session hero-image upload + link). - `Controllers/TrackController.cs`: Track endpoints (see below). - `Controllers/ReleaseController.cs`: Release endpoints (see below). @@ -74,7 +74,7 @@ Admin backfill: computes and stores a waveform profile for an existing track fro - **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`. - **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey). -- Fetches audio from vault, decodes it, computes a loudness profile, and stores the profile in the `waveform-profiles` vault. +- Streams the vault audio in bounded ≤80 KB chunks (no whole-file load) via `WaveformProfileService.ComputeAndStoreProfileStreamingAsync`. Tri-state result: `null` = no vault audio → 404; `false` = audio present but not WAV-decodable or vault write failed → 500; `true` = stored → 200. - Returns 200 on success. Returns 404 if no audio is stored under that key. Returns 500 if WAV decoding or vault write fails. ### GET api/track/{trackId}/waveform/high-res (unauthenticated) @@ -91,7 +91,7 @@ Server-side trigger: compute and store the per-track high-res datum for any trac - **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`. - **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey). -- Calls `WaveformProfileService.ComputeAndStoreHighResAsync` via `UnifiedTrackService`. +- Reads the duration from vault index metadata (no audio body load), then streams the vault audio in bounded chunks via `WaveformProfileService.ComputeAndStoreHighResStreamingAsync`. Tri-state result: `null` = no vault audio → 404; `false` = audio present but not WAV-decodable or vault write failed → 500; `true` = stored → 200. - Returns 200 on success. Returns 404 if no audio stored under that key. Returns 500 on compute/storage failure. ### GET api/track/meta/by-key/{entryKey} (unauthenticated) @@ -170,7 +170,7 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele - `releaseId` (long?, optional): the SQL release ID to attach this track to. Omit (null) on the first row of a submit — this is the **CREATE path**, which mints a new release and blocks a pre-existing (title, artist) with 409. Set to the release id returned by row 1 for rows 2..N of a within-batch multi-track Cut — this is the **ATTACH path**, which skips the (title, artist) pre-existing check and attaches directly to the already-created release after validating the id matches the natural key. The upload form is create-only; appending to a pre-existing release must go through the edit tools. - The upload stream is copied to a staging file under the **upload staging directory** (resolved from `Upload:StagingPath`, defaulting to a `staging` subdirectory under the FileDatabase vault path — on the data disk, **never** `Path.GetTempPath()`) with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The staging file is always deleted in a `finally` block — success or failure. The framework's own multipart file-section buffer is relocated off the system temp mount too: `Startup.ConfigureDomainServices` sets the `ASPNETCORE_TEMP` env var to the same staging directory, so neither on-disk copy of a large body lands on `/tmp` (a small RAM-backed tmpfs on the Linux host). - `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the staging file, not buffered in memory. -- `UnifiedTrackService.UploadAsync` orchestrates: release resolution (CREATE or ATTACH, see above) → `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`). Release resolution runs the cardinality guard on both paths and, on the CREATE path, calls `ITrackService.FindOrCreateRelease` (returns `(ReleaseDto Release, bool WasCreated)`); if `WasCreated` is false, a concurrent upload won the race and the request is rejected as a duplicate rather than silently attaching. +- `UnifiedTrackService.UploadAsync` orchestrates: release resolution (CREATE or ATTACH, see above) → `TrackContentService.AddTrackAsync` (format-agnostic streaming vault write via router — audio is streamed to the vault via the `ProcessedAudio` plan, no whole-file buffer — Wave 1 OOM fix) → `TrackManager` (SQL persist with `createdByUserId`) → `TryStoreWaveformDatumsAsync` (best-effort: reads duration from vault index metadata, then computes both waveform datums from a single bounded streaming pass over the stored audio — Wave 2 OOM fix). Release resolution runs the cardinality guard on both paths and, on the CREATE path, calls `ITrackService.FindOrCreateRelease` (returns `(ReleaseDto Release, bool WasCreated)`); if `WasCreated` is false, a concurrent upload won the race and the request is rejected as a duplicate rather than silently attaching. - Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 for two distinct domain conditions: a pre-existing (title, artist) duplicate on the CREATE path (`DUPLICATE_RELEASE:` marker → 409 Conflict), or a track-number conflict within the release (`CARDINALITY_VIOLATION:` marker → 409 Conflict). Returns 500 if processing fails. ### DELETE api/track/{id:long} ([ApiKeyAuthorize]) @@ -190,7 +190,7 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele - **Route parameter `id`** (long): the SQL track ID. - **Form field `audioFile`** (`IFormFile`, required): the replacement audio bytes. File name must end in `.wav`, `.mp3`, or `.flac`. - `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` mirror the upload ceiling. The body is streamed to a staging file under the upload staging directory (the same off-`/tmp` data-disk location as the upload path; correct extension preserved for the audio processor), always deleted in a `finally` block. -- Calls `UnifiedTrackService.ReplaceAudioAsync`, which: looks up SQL row by id → calls `TrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath)` (registers new audio under the existing `EntryKey`; removes the stale backing file only on a cross-format swap, after the new write succeeds) → regenerates both waveform datums (best-effort; a datum failure is logged and swallowed) → writes the new audio's duration to `DurationSeconds` via `ITrackService.SetDuration` (unconditional overwrite; a failure is surfaced, not swallowed, to prevent derived aggregates like `MixRuntimeSeconds` from silently going stale). +- Calls `UnifiedTrackService.ReplaceAudioAsync`, which: looks up SQL row by id → calls `TrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath)` (streams new audio into the vault under the existing `EntryKey` via the `ProcessedAudio` plan, no whole-file buffer — Wave 1 OOM fix; removes the stale backing file only on a cross-format swap, after the new write succeeds) → regenerates both waveform datums from a single bounded streaming pass over the freshly stored audio via `TryStoreWaveformDatumsAsync` (best-effort; a datum failure is logged and swallowed — Wave 2 OOM fix) → writes the new audio's duration to `DurationSeconds` via `ITrackService.SetDuration` (unconditional overwrite; a failure is surfaced, not swallowed, to prevent derived aggregates like `MixRuntimeSeconds` from silently going stale). - Returns 200 on success. Returns 400 if the file is missing or the format is unsupported. Returns 404 if the track id is not found. Returns 500 if vault processing fails. ### GET api/track/page (unauthenticated) @@ -288,7 +288,7 @@ Legacy endpoint: formerly served the high-res waveform datum for a Mix release f ### POST api/release/{id:long}/mix/waveform ([ApiKeyAuthorize]) -Server-side trigger: fetch the Mix's track audio from the vault, compute the duration-derived high-res datum, store it in the `track-waveforms` vault under the track's `EntryKey`, and link it via `MixMetadata.WaveformEntryKey`. Delegates to `WaveformProfileService.ComputeAndStoreHighResAsync` — the same shared seam used by the upload path and the generalized CMS generate action. No request body. +Server-side trigger: fetch the Mix's track audio from the vault, compute the duration-derived high-res datum, store it in the `track-waveforms` vault under the track's `EntryKey`, and link it via `MixMetadata.WaveformEntryKey`. Delegates to `WaveformProfileService.ComputeAndStoreHighResStreamingAsync` (streams the vault audio in bounded chunks, no whole-file buffer) — the same shared seam used by the upload path and the generalized CMS generate action. No request body. - **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`. - **Route parameter `id`** (long): the SQL release ID. diff --git a/DeepDrftContent/CLAUDE.md b/DeepDrftContent/CLAUDE.md index d29c78a..14f9e5d 100644 --- a/DeepDrftContent/CLAUDE.md +++ b/DeepDrftContent/CLAUDE.md @@ -18,7 +18,9 @@ DeepDrftContent.Services/ │ ├── Services/ # FileDatabase, MediaVault, IndexSystem, IndexWatcher │ └── Utils/ # StructuralMap, StructuralSet, FileUtils ├── Processors/ -│ └── AudioProcessor.cs # WAV file parsing, metadata extraction +│ ├── AudioProcessor.cs # WAV file parsing, metadata extraction, streaming PCM header parse +│ ├── AudioStoreStream.cs # Bounded streaming copy/prefix helpers used by the processors +│ └── ProcessedAudio.cs # Store-path plan: metadata + streamed WriteToAsync callback ├── Constants/ │ └── VaultConstants.cs # Vault name definitions ├── TrackService.cs # Content-side orchestrator @@ -47,6 +49,8 @@ FileBinary (base: byte buffer) Each has a matching `*Dto` variant for base64 JSON transport (e.g., `AudioBinaryDto` with buffer encoded as base64). +**Read/load path**: vault reads (`LoadResourceAsync`) still return a full-buffer `AudioBinary`. **Write/store path** is streaming: audio processors return a `ProcessedAudio` plan (metadata + `WriteToAsync` callback); `RegisterResourceStreamingAsync` / `MediaVault.AddEntryStreamingAsync` write bytes to a temp file, then `File.Move` atomic-rename into place. The full `AudioBinary` buffer is never materialized on the store path. + ### Index lifecycle - **DirectoryIndex**: Root index file (at `{rootPath}/index`). Tracks which vaults exist. @@ -70,19 +74,33 @@ public async Task RegisterResourceAsync(string vaultId, string entryId, Fi try { /* store and update index */ } catch { return false; } // Swallow, return false } + +// Streaming counterpart (Wave 1 OOM fix) — same bool contract: +public async Task RegisterResourceStreamingAsync( + string vaultId, string entryId, MetaData metaData, + Func writeContent, CancellationToken ct) +{ + try { /* stream to temp → atomic rename → update index */ } + catch { return false; } // Swallow (logs non-cancel exceptions), return false +} ``` +`MediaVault.AddEntryStreamingAsync` (called internally) writes bytes to a temp file in the vault directory, then `File.Move(temp, final, overwrite: true)` (atomic POSIX rename on the Linux prod host), then updates the index. A cancel or I/O fault before the rename leaves any prior backing file intact; the temp file is cleaned up best-effort. + **Callers must check return values.** Do not change this without a deliberate design pass — it's embedded in all FileDatabase tests and client code. ## Audio processors Multi-format support via router pattern. All processors live in `DeepDrftContent/Processors/`: -- `AudioProcessor.ProcessWavFileAsync(filePath)`: WAV-specific processor. Validates RIFF/WAVE structure and format code. Accepts standard PCM (audioFormat=1) and WAVE_FORMAT_EXTENSIBLE (audioFormat=0xFFFE) when the SubFormat GUID indicates PCM. Normalizes EXTENSIBLE-PCM uploads to standard 44-byte PCM WAV before storing. Parses fmt and data chunks; extracts duration and bitrate. Returns `AudioBinary` with metadata. On parse failure, logs warning and returns defaults (180s / 1411 kbps / 44.1 kHz / 16-bit stereo). Accepts standard PCM (audioFormat=1), WAVE_FORMAT_EXTENSIBLE with PCM SubFormat (0x0001), IEEE Float SubFormat (0x0003), and Padded 24-in-32 containers; normalizes Float and padded inputs to standard 24-bit PCM before storage. -- `Mp3AudioProcessor.ProcessMp3FileAsync(filePath)`: MP3 processor. Skips ID3v2 tag, finds first valid MPEG frame sync, decodes frame header (bitrate, sample rate, channels). Reads Xing/Info header for VBR total-frame count (accurate duration); VBRI header as fallback; CBR estimate from file size otherwise. Returns `AudioBinary` with original bytes and `.mp3` extension. On parse failure, falls back to defaults (180s / 320 kbps). -- `FlacAudioProcessor.ProcessFlacFileAsync(filePath)`: FLAC processor. Validates `fLaC` magic, reads STREAMINFO metadata block (20-bit sample rate, 3-bit channels, 5-bit bits-per-sample, 36-bit total samples — all bit-packed). Computes duration from `totalSamples / sampleRate`; average bitrate from file size. Returns `AudioBinary` with original bytes and `.flac` extension. On parse failure, falls back to defaults (180s / 1411 kbps). -- `AudioProcessorRouter.ProcessAudioFileAsync(filePath)`: Routes by extension — `.wav` → `AudioProcessor`, `.mp3` → `Mp3AudioProcessor`, `.flac` → `FlacAudioProcessor`. Throws `ArgumentException` for unsupported extensions. -- `WaveformProfileService.ComputeAndStoreHighResAsync(entryKey)`: The shared compute seam for the duration-derived high-res waveform datum (~333 samples/sec). Medium-neutral — computes for any track by `EntryKey`, stores in the `track-waveforms` vault. Called by the upload path (`UnifiedTrackService.UploadAsync` for every new track), the CMS per-row generate action, and the Mix release trigger (now a legacy delegate). Phase 12 generalization of the former Mix-only compute. +- `AudioProcessor.ProcessWavFileAsync(filePath)`: WAV-specific processor. Validates RIFF/WAVE structure and format code. Accepts standard PCM (audioFormat=1) and WAVE_FORMAT_EXTENSIBLE (audioFormat=0xFFFE) when the SubFormat GUID indicates PCM. Normalizes EXTENSIBLE-PCM uploads to standard 44-byte PCM WAV before storing. Parses fmt and data chunks; extracts duration and bitrate. **Returns `ProcessedAudio`** (metadata + streamed `WriteToAsync` callback — no whole-file buffer). Header window grows in 64 KB steps capped at 8 MB; the audio body is never read during processing. Standard PCM WAV is stored verbatim (passthrough via `AudioStoreStream.CopyFileAsync`); EXTENSIBLE-PCM / IEEE-float / padded-container WAVs stream their normalization to standard 24-bit PCM. On parse failure, falls back to defaults (180s / 1411 kbps). Also exposes `TryExtractPcm(ReadOnlySpan)` for the whole-buffer waveform parity oracle and `TryReadPcmStreamInfoAsync(stream, totalLength)` for the streaming waveform compute path (bounded header parse from a stream; returns `WavPcmStreamInfo?` with `DataStart`/`DataLength`/format fields). +- `Mp3AudioProcessor.ProcessMp3FileAsync(filePath)`: MP3 processor. Reads a bounded ≤8 MB prefix for header parsing; skips ID3v2 tag, finds first valid MPEG frame sync, decodes frame header (bitrate, sample rate, channels). Reads Xing/Info header for VBR total-frame count (accurate duration); VBRI header as fallback; CBR estimate from file size otherwise. **Returns `ProcessedAudio`** (passthrough plan — MP3 stored unmodified). On parse failure, falls back to defaults (180s / 320 kbps). +- `FlacAudioProcessor.ProcessFlacFileAsync(filePath)`: FLAC processor. Reads a bounded ≤64 KB prefix. Validates `fLaC` magic, reads STREAMINFO metadata block (20-bit sample rate, 3-bit channels, 5-bit bits-per-sample, 36-bit total samples — all bit-packed). Computes duration from `totalSamples / sampleRate`; average bitrate from file size. **Returns `ProcessedAudio`** (passthrough plan — FLAC stored unmodified). On parse failure, falls back to defaults (180s / 1411 kbps). +- `AudioProcessorRouter.ProcessAudioFileAsync(filePath)`: Routes by extension — `.wav` → `AudioProcessor`, `.mp3` → `Mp3AudioProcessor`, `.flac` → `FlacAudioProcessor`. **Returns `ProcessedAudio?`**. Throws `ArgumentException` for unsupported extensions. +- `ProcessedAudio`: Store-path plan returned by all processors. Carries `Extension`, `Duration`, `Bitrate`, `Size`, and a `WriteToAsync(destination, ct)` callback that streams the canonical vault bytes to the destination without materializing the whole file. `ProcessedAudio.Passthrough(sourcePath, ...)` builds a passthrough plan via `AudioStoreStream.CopyFileAsync`. +- `AudioStoreStream`: Internal bounded-buffer streaming helpers. `CopyFileAsync(sourcePath, destination, ct)` does a bounded 80 KB disk-to-disk copy; `ReadPrefixAsync(path, cap, ct)` reads at most `cap` bytes from the start of a file (used by processors for header parsing without loading the body). +- `ILoudnessAlgorithm` / `ILoudnessAccumulator`: The loudness strategy interface now exposes both `Compute(ReadOnlySpan, ...)` (whole-buffer, retained as the parity oracle for tests — no production callers) and `CreateAccumulator(pcmByteLength, ...)` → `ILoudnessAccumulator` (streaming: `Add(ReadOnlySpan)` / `Finish()` → `double[]`). `RmsLoudnessAlgorithm` implements both; `Compute` is defined in terms of the accumulator, so streaming and whole-buffer outputs are byte-identical. +- `WaveformProfileService`: Streaming loudness compute + vault store. The whole-buffer `ComputeAndStoreAsync` / `ComputeAndStoreHighResAsync` methods are **retained but have no production callers** — they exist as the byte-identity parity oracle for tests; do not delete them. The production paths are: `ComputeAndStoreProfileStreamingAsync` (512-bucket seeker profile, tri-state `bool?`), `ComputeAndStoreHighResStreamingAsync` (duration-derived high-res datum, tri-state `bool?`), and `ComputeAndStoreAllStreamingAsync` (both datums in a SINGLE streaming pass, used by the upload/replace hot path, tri-state `bool?`). Tri-state: `null` = no backing audio stream; `false` = audio present but not WAV-decodable or vault write failed; `true` = stored. Streaming reads the WAV in bounded ≤80 KB chunks through one accumulator per datum; peak memory is O(bucket arrays + read buffer), independent of file size. - `WaveformResolution`: Enum / constants controlling bucket density for the high-res compute. Renamed from `MixWaveformResolution` in Phase 12. Vault stores original bytes with correct extension and MIME type (inferred from file extension or content-type header at upload time). @@ -101,20 +119,37 @@ The primary entry point is `TrackContentService.AddTrackAsync(filePath, mimeType ## Content-side TrackService (orchestrator) -### AddTrackFromWavAsync(filePath) +### AddTrackAsync(audioFilePath, ...) -1. Reads a WAV file from disk. -2. Calls `AudioProcessor.ProcessWavFileAsync` → `AudioBinary`. -3. Generates a GUID entry key (via `Guid.NewGuid().ToString()`). -4. Ensures the `tracks` vault exists (creates if missing). -5. Calls `FileDatabase.RegisterResourceAsync("tracks", entryKey, audioBinary)`. -6. Returns a populated `TrackEntity` (with `Id = 0` since it's not yet in SQL). +The primary upload entry point. Format-agnostic — routes by extension via `AudioProcessorRouter`. -**Note**: The caller (CLI or web) is responsible for then saving this entity to SQL via `DeepDrftWeb.Services.TrackService.Create`. If the vault write succeeds and SQL write fails, audio is orphaned (no compensating rollback). +1. Calls `AudioProcessorRouter.ProcessAudioFileAsync(filePath)` → `ProcessedAudio` plan (no whole-file buffer — Wave 1 OOM fix). +2. Generates a GUID entry key (via `Guid.NewGuid().ToString()`). +3. Ensures the `tracks` vault exists (creates if missing). +4. Builds `MetaData` from the plan's header-extracted fields and calls `FileDatabase.RegisterResourceStreamingAsync("tracks", entryKey, metaData, processed.WriteToAsync)` — bytes are streamed from the staging file to the vault via a bounded copy; `MediaVault.AddEntryStreamingAsync` writes to a temp file then atomic-renames into place. +5. Returns a populated `TrackEntity` (with `Id = 0` since it's not yet in SQL, and `DurationSeconds` populated from the header parse). + +If the vault write succeeds and SQL write fails, audio is orphaned (no compensating rollback). + +### AddTrackFromWavAsync(filePath, ...) + +Backward-compatible shim — delegates to `AddTrackAsync`. The router accepts WAV alongside MP3 and FLAC, so this carries no WAV-specific logic of its own. + +### ReplaceTrackAudioAsync(entryKey, audioFilePath) + +Swaps the vault bytes for an existing track in place, under the same `entryKey`. Captures the old extension from the vault index metadata (not by loading the file) so a cross-format swap can clean up the stale backing file post-success. Streams the new audio via `ProcessedAudio` + `RegisterResourceStreamingAsync` (no whole-file buffer — Wave 1 OOM fix). Returns the new audio's duration on success, `null` on failure (original audio left intact). ### GetAudioBinaryAsync(entryKey) -Reads audio from the `tracks` vault via `FileDatabase.LoadResourceAsync("tracks", entryKey)`. Returns `null` if not found or read fails. +Reads audio from the `tracks` vault via `FileDatabase.LoadResourceAsync("tracks", entryKey)`. Returns a full-buffer `AudioBinary` or `null` if not found. This is the **read/playback path** — unchanged by the streaming store fix. + +### OpenAudioStreamAsync(entryKey) + +Returns a read-only, seekable `Stream` over a track's vault audio without buffering the whole file. Used by the streaming waveform compute path (`WaveformProfileService`). Caller owns and must dispose the stream. Returns `null` if the entry has no backing file. + +### GetAudioDurationAsync(entryKey) + +Reads the stored audio duration from the vault index metadata only — no audio body load. Used by the streaming waveform compute to derive the duration-based bucket count, and by `UnifiedTrackService.BackfillDurationsAsync`. Returns `null` if the entry is unknown or carries no audio metadata. ### InitializeTracksVaultAsync()