using DeepDrftContent.Constants; using DeepDrftContent.FileDatabase.Models; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; namespace DeepDrftContent.Processors; /// /// Computes a track's waveform loudness profile from its WAV bytes and persists it as a sidecar /// in the vault, keyed by the track's EntryKey. /// The profile is the upload-time, off-the-playback-path representation the frontend fetches to /// render the WaveformSeeker. The loudness measure is injected () /// so it can be swapped without changing storage or the wire format. /// public class WaveformProfileService { private const string ProfileExtension = ".wfp"; private readonly FileDb _fileDatabase; private readonly AudioProcessor _audioProcessor; private readonly ILoudnessAlgorithm _loudnessAlgorithm; private readonly WaveformProfileOptions _options; private readonly ILogger _logger; public WaveformProfileService( FileDb fileDatabase, AudioProcessor audioProcessor, ILoudnessAlgorithm loudnessAlgorithm, IOptions options, ILogger logger) { _fileDatabase = fileDatabase; _audioProcessor = audioProcessor; _loudnessAlgorithm = loudnessAlgorithm; _options = options.Value; _logger = logger; } /// /// Computes the loudness profile from and stores it under /// in (defaults to /// when null). Bucket resolution defaults to /// (512) when is null; /// callers pass an explicit count for higher-resolution data — e.g. the per-track high-res datum /// derives its count from the audio duration (≈333 samples/sec, see WaveformResolution) so long /// tracks are not under-sampled. This service is content-agnostic: it captures however many buckets it is told to and /// does not itself decide the count. Returns false (and logs) on any /// failure — a missing profile is handled gracefully downstream, so callers on the upload path /// log-and-continue rather than failing the upload. Does not throw for expected failure modes. /// public async Task ComputeAndStoreAsync( ReadOnlyMemory wavBytes, string entryKey, int? bucketCount = null, string? vaultName = null) { var effectiveBucketCount = bucketCount ?? _options.BucketCount; var effectiveVaultName = vaultName ?? VaultConstants.WaveformProfiles; try { var pcm = _audioProcessor.TryExtractPcm(wavBytes.Span); if (pcm is null) { _logger.LogWarning( "Waveform profile not computed for {EntryKey}: WAV PCM could not be extracted.", entryKey); return false; } var value = pcm.Value; var profile = _loudnessAlgorithm.Compute( value.Pcm.Span, value.Channels, value.SampleRate, value.BitsPerSample, effectiveBucketCount); var quantized = Quantize(profile); await EnsureVaultAsync(effectiveVaultName); var binary = new MediaBinary(new MediaBinaryParams(quantized, quantized.Length, ProfileExtension)); var stored = await _fileDatabase.RegisterResourceAsync(effectiveVaultName, entryKey, binary); if (!stored) { _logger.LogWarning("Waveform profile vault write failed for {EntryKey}.", entryKey); return false; } return true; } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Waveform profile computation failed for {EntryKey}.", entryKey); return false; } } /// /// Computes a track's high-resolution loudness datum and stores it in the /// vault keyed by . The bucket /// count is duration-derived (≈333 samples/sec, clamped — see ) 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. /// public Task ComputeAndStoreHighResAsync( ReadOnlyMemory wavBytes, string entryKey, double durationSeconds) { var bucketCount = WaveformResolution.BucketCountForDuration(durationSeconds); return ComputeAndStoreAsync(wavBytes, entryKey, bucketCount, VaultConstants.TrackWaveforms); } /// /// Returns the stored quantized profile bytes for a track from /// (defaults to when null), or null if no profile /// is stored (existing tracks predate profiling, and computation may have failed). Each byte is /// a peak-normalized loudness value in [0, 255]. /// public async Task GetProfileAsync(string entryKey, string? vaultName = null) { var binary = await _fileDatabase.LoadResourceAsync( vaultName ?? VaultConstants.WaveformProfiles, entryKey); return binary?.Buffer; } /// /// Maps each [0, 1] bucket to a [0, 255] byte. 1.0 maps to 255; the multiply-by-255 with a /// truncating cast keeps every in-range value within a byte without a clamp branch. /// private static byte[] Quantize(double[] profile) { var bytes = new byte[profile.Length]; for (var i = 0; i < profile.Length; i++) { bytes[i] = (byte)(profile[i] * 255); } return bytes; } private async Task EnsureVaultAsync(string vaultName) { if (!_fileDatabase.HasVault(vaultName)) { await _fileDatabase.CreateVaultAsync(vaultName, MediaVaultType.Media); } } }