diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index a277cbb..9878ca4 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -5,6 +5,7 @@ using DeepDrftContent.Constants; using DeepDrftContent.FileDatabase.Services; using DeepDrftContent.FileDatabase.Models; using DeepDrftContent.Processors; +using DeepDrftContent.Processors.Opus; using DeepDrftData; using DeepDrftModels.DTOs; using DeepDrftModels.Enums; @@ -20,6 +21,7 @@ public class TrackController : ControllerBase private readonly UnifiedTrackService _unifiedService; private readonly ITrackService _sqlTrackService; private readonly WaveformProfileService _waveformProfileService; + private readonly TrackFormatResolver _formatResolver; private readonly UploadStagingDirectory _stagingDirectory; private readonly ILogger _logger; @@ -35,6 +37,7 @@ public class TrackController : ControllerBase UnifiedTrackService unifiedService, ITrackService sqlTrackService, WaveformProfileService waveformProfileService, + TrackFormatResolver formatResolver, UploadStagingDirectory stagingDirectory, ILogger logger) { @@ -43,6 +46,7 @@ public class TrackController : ControllerBase _unifiedService = unifiedService; _sqlTrackService = sqlTrackService; _waveformProfileService = waveformProfileService; + _formatResolver = formatResolver; _stagingDirectory = stagingDirectory; _logger = logger; } @@ -642,10 +646,27 @@ public class TrackController : ControllerBase // --- Parameterized routes --- + // GET api/track/{trackId}?format=opus|lossless (unauthenticated) + // Streams the track's audio bytes with HTTP Range support. The optional `format` selector (Phase 18.3) + // picks the delivery rendering: absent or unrecognized ⇒ Lossless (byte-identical to pre-Phase-18 — + // the existing zero-copy disk-stream path, untouched); `opus` ⇒ the derived Ogg Opus 320 artifact + // when present, falling back to lossless when it is not (C2 — never 404/silence). The Opus path serves + // the resolved in-memory bytes via File(..., enableRangeProcessing: true) so Range: bytes=X- still + // yields 206 (load-bearing for streaming + seek), matching the lossless disk-stream's range behavior. [HttpGet("{trackId}")] - public async Task GetTrack(string trackId) + public async Task GetTrack(string trackId, [FromQuery] string? format = null) { - _logger.LogInformation("GetTrack called with trackId: {TrackId}", trackId); + _logger.LogInformation("GetTrack called with trackId: {TrackId}, format: {Format}", trackId, format); + + // Only `opus` diverges from today's behavior; everything else (null, "lossless", garbage) takes the + // unchanged lossless disk-stream path below, preserving the large-file zero-copy streaming. Routing + // lossless through the resolver would force the whole source (up to ~1 GB) into memory per request — + // a regression the resolver's in-memory byte[] result is fine for Opus (small) but not for lossless. + if (Enum.TryParse(format, ignoreCase: true, out var requestedFormat) + && requestedFormat == AudioFormat.Opus) + { + return await GetTrackOpusAsync(trackId); + } try { @@ -700,6 +721,58 @@ public class TrackController : ControllerBase } } + // The ?format=opus arm of GetTrack. Resolves the Opus artifact (or the lossless fallback when none + // exists, C2) via TrackFormatResolver and serves the resolved bytes with explicit range processing. + // enableRangeProcessing:true is the load-bearing detail the 18.2 reviewer flagged: File(byte[], ...) + // does NOT get ASP.NET's automatic range handling unless asked, so without this flag a Range: bytes=X- + // would silently return the whole body as 200 instead of a 206 slice — breaking seek for the Opus path + // (and Phase 21 windowing). The resolver reports the *actually-served* format via ResolvedAudio, so the + // content-type matches the bytes (audio/ogg on a hit, the source MIME on a fallback) and the eventual + // client decoder dispatches correctly. + private async Task GetTrackOpusAsync(string trackId) + { + try + { + var resolved = await _formatResolver.ResolveAsync(trackId, AudioFormat.Opus); + if (resolved is null) + { + _logger.LogWarning("Track not found for Opus request: {TrackId}", trackId); + return NotFound(); + } + + _logger.LogInformation( + "Streaming track {TrackId} as {Format} ({Size} bytes, {ContentType})", + trackId, resolved.ResolvedFormat, resolved.Audio.Buffer.Length, resolved.ContentType); + + return File(resolved.Audio.Buffer, resolved.ContentType, enableRangeProcessing: true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving track as Opus: {TrackId}", trackId); + return StatusCode(500, "Internal server error"); + } + } + + // GET api/track/{trackId}/opus/seekdata (unauthenticated) + // Returns the Opus setup-header + granule→byte seek-index sidecar bytes (Phase 18.3). The client + // fetches this once on track load and parses it into OpusSeekData (18.4) before issuing any Opus seek. + // Raw octet-stream — the bytes are the OpusSidecar blob exactly as 18.1 stored them. 404 when no sidecar + // is stored (no Opus artifact yet, or an older derive predating the sidecar); the client then degrades + // to lossless, mirroring the C2 posture of the audio path. Same public auth posture as the audio stream. + // The "opus/seekdata" literal suffix keeps this distinct from the audio and waveform routes. + [HttpGet("{trackId}/opus/seekdata")] + public async Task GetOpusSeekData(string trackId) + { + var sidecar = await _formatResolver.GetOpusSidecarAsync(trackId); + if (sidecar is null) + { + _logger.LogInformation("No Opus sidecar for track: {TrackId}", trackId); + return NotFound(); + } + + return File(sidecar, "application/octet-stream"); + } + // GET api/track/{trackId}/waveform (unauthenticated) // Returns the stored waveform loudness profile for a track, base64-encoded. Public listener // data, same auth posture as GET api/track/{trackId} streaming. 404 when no profile is stored diff --git a/DeepDrftPublic.Client/Clients/TrackMediaClient.cs b/DeepDrftPublic.Client/Clients/TrackMediaClient.cs index f38d1d7..cd715e1 100644 --- a/DeepDrftPublic.Client/Clients/TrackMediaClient.cs +++ b/DeepDrftPublic.Client/Clients/TrackMediaClient.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using DeepDrftModels.DTOs; +using DeepDrftModels.Enums; using Microsoft.Extensions.DependencyInjection; using NetBlocks.Models; @@ -45,23 +46,37 @@ public class TrackMediaClient } /// - /// Fetches the WAV stream for a track via an HTTP Range request starting at a + /// Fetches the audio stream for a track via an HTTP Range request starting at a /// file-absolute byte offset. is the position from - /// the start of the file on disk (including the WAV header) — callers seeking into - /// audio data must add the header size themselves. The cancellation token aborts - /// the in-flight server connection rather than leaving the server draining bytes - /// into a dead socket. + /// the start of the file on disk (including any container/header bytes) — callers + /// seeking into audio data must add the header size themselves. The cancellation + /// token aborts the in-flight server connection rather than leaving the server + /// draining bytes into a dead socket. + /// + /// selects the delivery rendering (Phase 18): the default + /// sends no format query param, so existing + /// callers hit the byte-identical pre-Phase-18 endpoint; + /// requests the low-data Ogg Opus artifact, which the server resolves and falls back to + /// lossless when absent (C2). The response + /// reports the format actually served, so the JS decoder dispatches on the real bytes. + /// /// public async Task> GetTrackMedia( string trackId, long byteOffset = 0, + AudioFormat format = AudioFormat.Lossless, CancellationToken cancellationToken = default) { try { // Same URL for every seek — only the Range header differs. byteOffset 0 is // not special-cased: "bytes=0-" requests the whole file from the start. - using var request = new HttpRequestMessage(HttpMethod.Get, $"api/track/{trackId}"); + // Lossless omits the format param entirely so the request is byte-identical to + // the pre-Phase-18 endpoint; only Opus appends ?format=opus. + var uri = format == AudioFormat.Lossless + ? $"api/track/{trackId}" + : $"api/track/{trackId}?format={format.ToString().ToLowerInvariant()}"; + using var request = new HttpRequestMessage(HttpMethod.Get, uri); request.Headers.Range = new RangeHeaderValue(byteOffset, null); // Use HttpCompletionOption.ResponseHeadersRead to get stream immediately @@ -115,4 +130,33 @@ public class TrackMediaClient return ApiResult.CreateFailResult(e.Message); } } + + /// + /// Fetches a track's Opus seek/setup sidecar — the combined OpusHead/OpusTags setup header plus the + /// granule→byte seek index (Phase 18). The caller (18.5 player wiring) fetches this once on track load + /// and parses it into the JS-side OpusSeekData before issuing any Opus seek. A 404 means no Opus + /// artifact / sidecar exists for the track (legacy row, not backfilled, or transcode failed); callers + /// treat that as "this track has no Opus seek data — stay on lossless" rather than an error, so it + /// surfaces as a fail result with a stable message rather than throwing (mirrors GetWaveformProfileAsync). + /// + public async Task> GetOpusSidecarAsync(string trackId, CancellationToken cancellationToken = default) + { + try + { + var response = await _http.GetAsync($"api/track/{trackId}/opus/seekdata", cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return ApiResult.CreateFailResult("No Opus sidecar available"); + } + + response.EnsureSuccessStatusCode(); + + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + return ApiResult.CreatePassResult(bytes); + } + catch (Exception e) + { + return ApiResult.CreateFailResult(e.Message); + } + } } diff --git a/DeepDrftPublic/Controllers/TrackProxyController.cs b/DeepDrftPublic/Controllers/TrackProxyController.cs index 104d2be..9ba40bc 100644 --- a/DeepDrftPublic/Controllers/TrackProxyController.cs +++ b/DeepDrftPublic/Controllers/TrackProxyController.cs @@ -205,21 +205,26 @@ public class TrackProxyController : ControllerBase /// /// Proxies audio streaming from DeepDrftAPI as a transparent HTTP Range relay. - /// Forwards the incoming Range header upstream and relays the upstream status - /// (200 full, 206 partial, 416 unsatisfiable) and range-related response headers - /// back to the browser verbatim. The proxy does not slice — the upstream already did. + /// Forwards the incoming Range header upstream and the optional format selector + /// (Phase 18.3 — opus|lossless, threaded the same way the listing params are), + /// and relays the upstream status (200 full, 206 partial, 416 unsatisfiable) and + /// range-related response headers back to the browser verbatim. The proxy does not + /// slice — the upstream already did. /// [HttpGet("{trackId}")] public async Task GetTrack( string trackId, + [FromQuery] string? format = null, CancellationToken ct = default) { var rangeHeader = Request.Headers.Range.ToString(); - _logger.LogInformation("Proxying track {TrackId} range '{Range}'", trackId, rangeHeader); + _logger.LogInformation("Proxying track {TrackId} range '{Range}' format '{Format}'", trackId, rangeHeader, format); - var request = new HttpRequestMessage( - HttpMethod.Get, - $"api/track/{Uri.EscapeDataString(trackId)}"); + var path = $"api/track/{Uri.EscapeDataString(trackId)}"; + if (!string.IsNullOrWhiteSpace(format)) + path += $"?format={Uri.EscapeDataString(format)}"; + + var request = new HttpRequestMessage(HttpMethod.Get, path); // Forward the browser's Range header upstream so DeepDrftAPI slices the file. // TryAddWithoutValidation avoids RangeHeaderValue reparsing — we relay the raw @@ -355,4 +360,40 @@ public class TrackProxyController : ControllerBase return Content(json, "application/json"); } } + + /// + /// Proxies a track's Opus seek/setup sidecar (raw bytes) from DeepDrftAPI (Phase 18.3). Unauthenticated, + /// same posture as the audio stream forward. The sidecar is a small one-time fetch (≤ ~115 KB), so it is + /// buffered and relayed; a 404 (no Opus artifact / no sidecar stored) passes through so the client + /// degrades to lossless rather than treating it as an error. The "opus/seekdata" 3-segment route makes a + /// collision with the parameterized "{trackId}" audio route impossible. + /// + [HttpGet("{trackId}/opus/seekdata")] + public async Task GetOpusSeekData(string trackId, CancellationToken ct = default) + { + var path = $"api/track/{Uri.EscapeDataString(trackId)}/opus/seekdata"; + + HttpResponseMessage upstream; + try + { + upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Upstream call to DeepDrftAPI track/{TrackId}/opus/seekdata failed", trackId); + return StatusCode(502, "Upstream unavailable"); + } + + using (upstream) + { + if (!upstream.IsSuccessStatusCode) + { + _logger.LogWarning("DeepDrftAPI track/{TrackId}/opus/seekdata returned {Status}", trackId, (int)upstream.StatusCode); + return StatusCode((int)upstream.StatusCode); + } + + var bytes = await upstream.Content.ReadAsByteArrayAsync(ct); + return File(bytes, "application/octet-stream"); + } + } } diff --git a/DeepDrftTests/TrackFormatDeliveryTests.cs b/DeepDrftTests/TrackFormatDeliveryTests.cs new file mode 100644 index 0000000..eba434d --- /dev/null +++ b/DeepDrftTests/TrackFormatDeliveryTests.cs @@ -0,0 +1,289 @@ +using System.Text; +using DeepDrftAPI.Controllers; +using DeepDrftContent.Constants; +using DeepDrftContent.FileDatabase.Models; +using DeepDrftContent.Processors; +using DeepDrftContent.Processors.Opus; +using DeepDrftModels.Enums; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ContentTrackService = DeepDrftContent.TrackContentService; +using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; + +namespace DeepDrftTests; + +/// +/// Delivery-layer tests for the Phase 18.3 ?format= stream selector and the Opus seek/setup sidecar +/// endpoint on . These exercise the real , the real +/// , and the real over temp-directory +/// vaults — the same integration posture as . +/// +/// The SQL-only collaborators (UnifiedTrackService, ITrackService) are passed as null: the +/// actions under test (, ) +/// only touch the FileDatabase + resolver path, never the SQL services, so standing up a database is not +/// required to assert the delivery contract. +/// +/// The Range→206 contract is asserted at the load-bearing seam: ASP.NET performs the actual byte-slicing +/// for any whose .EnableRangeProcessing is true. The lossless +/// path proves this via the disk-stream ; the resolved Opus path via the +/// in-memory — both must report range processing enabled, the explicit fix +/// the 18.2 reviewer flagged for the byte[] path. +/// +[TestFixture] +public class TrackFormatDeliveryTests +{ + private string _testDir = string.Empty; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), "TrackFormatDeliveryTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_testDir); + } + + [TearDown] + public void TearDown() + { + try { Directory.Delete(_testDir, recursive: true); } + catch { /* Best-effort cleanup — ignore failures */ } + } + + // --- Format resolution at the endpoint --- + + [Test] + public async Task GetTrack_FormatOpus_WhenOpusArtifactPresent_ServesOpusBytesAndOggContentType() + { + var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false); + + var result = await controller.GetTrack(entryKey, format: "opus"); + + var file = result as FileContentResult; + Assert.That(file, Is.Not.Null, "Opus delivery serves an in-memory byte[] (FileContentResult)"); + Assert.Multiple(() => + { + Assert.That(file!.ContentType, Is.EqualTo("audio/ogg"), "Opus bytes must carry the audio/ogg content-type"); + Assert.That(file.FileContents, Is.EqualTo(OpusBytes), "The served bytes must be the Opus artifact, not the source"); + Assert.That(file.EnableRangeProcessing, Is.True, "Range processing must be enabled on the resolved Opus byte[] path"); + }); + } + + // --- The C2 fallback --- + + [Test] + public async Task GetTrack_FormatOpus_WhenNoOpusArtifact_FallsBackToLosslessBytesAndContentType() + { + // No Opus artifact stored — the resolver degrades to lossless (C2): the listener still gets audio, + // never a 404 or silence, and the content-type reports the lossless format actually served. + var (controller, entryKey) = await SeedAsync(withOpus: false, withSidecar: false); + + var result = await controller.GetTrack(entryKey, format: "opus"); + + var file = result as FileContentResult; + Assert.That(file, Is.Not.Null, "The fallback still serves resolved bytes via the byte[] path"); + Assert.Multiple(() => + { + Assert.That(file!.ContentType, Is.EqualTo("audio/wav"), "Fallback content-type must be the lossless source's MIME"); + Assert.That(file.FileContents, Is.EqualTo(_sourceWav), "Fallback must serve the lossless source bytes"); + Assert.That(file.EnableRangeProcessing, Is.True, "Range processing stays enabled on the fallback path too"); + }); + } + + [Test] + public async Task GetTrack_NoFormatParam_ServesLosslessDiskStream_ByteIdenticalToPrePhase18() + { + // The no-format path must be byte-identical to today: the zero-copy disk-stream FileStreamResult, + // NOT the resolver's in-memory byte[] path (which would force the whole source into memory). + var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false); + + var result = await controller.GetTrack(entryKey, format: null); + + var file = result as FileStreamResult; + Assert.That(file, Is.Not.Null, "Lossless delivery streams from disk (FileStreamResult), not a byte[]"); + Assert.Multiple(() => + { + Assert.That(file!.ContentType, Is.EqualTo("audio/wav")); + Assert.That(file.EnableRangeProcessing, Is.True, "Range→206 must work on the lossless disk-stream path"); + }); + } + + [Test] + public async Task GetTrack_FormatLossless_TakesTheLosslessDiskStreamPath() + { + // An explicit format=lossless must behave exactly like no param — the disk-stream path, never Opus. + var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false); + + var result = await controller.GetTrack(entryKey, format: "lossless"); + + Assert.That(result, Is.InstanceOf(), + "format=lossless must take the disk-stream path even when an Opus artifact exists"); + } + + [Test] + public async Task GetTrack_FormatOpus_WhenTrackDoesNotExist_Returns404() + { + var controller = BuildController(await FreshDbAsync()); + + var result = await controller.GetTrack("no-such-track", format: "opus"); + + Assert.That(result, Is.InstanceOf(), + "When even the lossless source is missing, the Opus request 404s (no audio at all)"); + } + + // --- Sidecar 200 / 404 --- + + [Test] + public async Task GetOpusSeekData_WhenSidecarPresent_Returns200WithRawBytes() + { + var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: true); + + var result = await controller.GetOpusSeekData(entryKey); + + var file = result as FileContentResult; + Assert.That(file, Is.Not.Null, "A stored sidecar is served as raw bytes"); + Assert.Multiple(() => + { + Assert.That(file!.ContentType, Is.EqualTo("application/octet-stream")); + Assert.That(file.FileContents, Is.EqualTo(SidecarBytes), "The served bytes must be the stored sidecar blob"); + }); + } + + [Test] + public async Task GetOpusSeekData_WhenNoSidecar_Returns404() + { + var (controller, entryKey) = await SeedAsync(withOpus: true, withSidecar: false); + + var result = await controller.GetOpusSeekData(entryKey); + + Assert.That(result, Is.InstanceOf(), + "No sidecar → 404, so the client degrades to lossless rather than treating it as an error"); + } + + // --- Fixtures + helpers --- + + private static readonly byte[] OpusBytes = Encoding.ASCII.GetBytes("OggS-fake-opus-payload-for-delivery-test"); + private static readonly byte[] SidecarBytes = Encoding.ASCII.GetBytes("setup-header+seek-index-sidecar-blob"); + + private byte[] _sourceWav = []; + + private async Task FreshDbAsync() + { + var db = await FileDb.FromAsync(_testDir); + Assert.That(db, Is.Not.Null); + return db!; + } + + // Seeds a track's lossless source in the tracks vault and, optionally, its Opus artifact and sidecar in + // the track-opus vault, then returns a controller wired over those real vaults plus the entry key. + private async Task<(TrackController Controller, string EntryKey)> SeedAsync(bool withOpus, bool withSidecar) + { + var db = await FreshDbAsync(); + var content = new ContentTrackService(db, new AudioProcessorRouter( + new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor())); + + var wavPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav"); + _sourceWav = BuildMinimalPcmWav(2.0); + await File.WriteAllBytesAsync(wavPath, _sourceWav); + + var seeded = await content.AddTrackAsync(wavPath, "Track", "Artist"); + Assert.That(seeded, Is.Not.Null); + var entryKey = seeded!.EntryKey; + + // GetAudioBinaryAsync re-reads what AddTrackAsync stored, so the bytes we assert the fallback against + // are the exact stored source bytes (the processor may normalize the input WAV before storing). + var storedSource = await content.GetAudioBinaryAsync(entryKey); + Assert.That(storedSource, Is.Not.Null); + _sourceWav = storedSource!.Buffer; + + await db.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio); + + if (withOpus) + { + var opus = new AudioBinary(new AudioBinaryParams(OpusBytes, OpusBytes.Length, ".opus", 2.0, 320)); + Assert.That( + await db.RegisterResourceAsync(VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey), opus), + Is.True); + } + + if (withSidecar) + { + var sidecar = new MediaBinary(new MediaBinaryParams(SidecarBytes, SidecarBytes.Length, ".opusidx")); + Assert.That( + await db.RegisterResourceAsync(VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey), sidecar), + Is.True); + } + + return (BuildController(db, content), entryKey); + } + + private static TrackController BuildController(FileDb db, ContentTrackService? content = null) + { + content ??= new ContentTrackService(db, new AudioProcessorRouter( + new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor())); + + var waveforms = new WaveformProfileService( + db, new AudioProcessor(), new RmsLoudnessAlgorithm(), + Options.Create(new WaveformProfileOptions()), NullLogger.Instance); + + var resolver = new TrackFormatResolver(db, content, NullLogger.Instance); + + // SQL-only collaborators are null: the delivery actions under test never touch them. + var controller = new TrackController( + content, + db, + unifiedService: null!, + sqlTrackService: null!, + waveforms, + resolver, + stagingDirectory: null!, + NullLogger.Instance) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + return controller; + } + + // Standard-PCM mono 16-bit 44.1 kHz WAV, full-scale square wave. Same layout as the other suites. + 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++) + { + 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(); + } +}