nativeaudio

audio playback for Go
Log | Files | Refs | README | LICENSE

commit b3d9551a31372069f565f83b35239a60662e66e8
parent 08f125a1e34fc4d76e971f7132364b310bf09acd
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Tue, 26 Oct 2021 13:47:23 +0800

nativeaudio: [wip] decode buffered bytes

Decode function that should decode a byte buffer and return the
uncompressed PCM s16le with playback metadata.

Signed-off-by: Jack Mordaunt <jackmordaunt.dev@gmail.com>

Diffstat:
Maudio.go | 20+++++++++++++++++---
Maudio_windows.c | 294++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Maudio_windows.go | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++----
Maudio_windows.h | 39++++++++++++++++++++++++++++++++++++---
Minternal/test/audio_test.go | 17+++++++++++++++++
5 files changed, 400 insertions(+), 27 deletions(-)

diff --git a/audio.go b/audio.go @@ -14,8 +14,22 @@ func Play(path string) error { return play(path) } -// Load decode and buffer an audio file. The buffer should contain raw -// PCM data (s16le). -func Load(path string) (pcm []byte, err error) { +// Load compressed data, returning the uncompressed data as PCM data +// (s16le) and details about the PCM required to playback correctly. +func Load(path string) (uncompressed []byte, err error) { return load(path) } + +// Decode compressed data, returning the uncompressed data as PCM data +// (s16le) and details about the PCM required to playback correctly. +func Decode(compressed []byte) (uncompressed []byte, format Format, err error) { + return decode(compressed) +} + +// Format desribes the features of the associated PCM data necessary +// for correct playback. +type Format struct { + SampleRate int + Channels int + BitDepth int +} diff --git a/audio_windows.c b/audio_windows.c @@ -28,6 +28,7 @@ ErrorWithCode(Error *err, int code) } // ErrorFree deallocates the error and any wrapped errors. +// BUG: heap corruption. void ErrorFree(Error* err) { @@ -37,10 +38,10 @@ ErrorFree(Error* err) while (err != NULL) { cursor = err; - err = err->Err; if (cursor->Str != NULL) free(cursor->Str); free(cursor); + err = err->Err; } } @@ -74,7 +75,8 @@ CharWiden(char* str) // RunMediaSession executes the media session until complete. This // should output the sound. -HRESULT RunMediaSession(IMFMediaSession* pSession){ +HRESULT +RunMediaSession(IMFMediaSession* pSession){ HRESULT hr = S_OK; @@ -429,7 +431,7 @@ Play(char* path) audio_file_path = (WCHAR*)r.Value; // Start the platform. - if ((hr = MFStartup(MF_VERSION, MFSTARTUP_FULL)) != S_OK) + if ((hr = MFStartup(MF_VERSION, MFSTARTUP_LITE)) != S_OK) { err = ErrorWithCode(ErrorStr("starting media platform"), hr); goto cleanup; @@ -441,7 +443,6 @@ Play(char* path) { err = ErrorWithCode(ErrorStr("creating media session"), hr); goto cleanup; - } // Create a source resolver. This object can open files and urls. @@ -449,9 +450,8 @@ Play(char* path) { err = ErrorWithCode(ErrorStr("creating source resolver"), hr); goto cleanup; - } - + // Create a "media source" from the sound file. // Perhaps a bytestream would also work? if ((hr = resolver->lpVtbl->CreateObjectFromURL( @@ -465,14 +465,12 @@ Play(char* path) { err = ErrorWithCode(ErrorStr("creating object from url"), hr); goto cleanup; - } if (obj_type != MF_OBJECT_MEDIASOURCE) { err = ErrorWithCode(ErrorStr("not a media source"), hr); goto cleanup; - } // We know it's a media source so we can do the cast safely. @@ -485,7 +483,6 @@ Play(char* path) { err = ErrorWithCode(ErrorStr("creating presentation descriptor"), hr); goto cleanup; - } // Get information about the audio stream. We pull the count @@ -495,7 +492,6 @@ Play(char* path) { err = ErrorWithCode(ErrorStr("getting stream descriptor count"), hr); goto cleanup; - } if (stream_count != 1) @@ -509,42 +505,36 @@ Play(char* path) { err = ErrorWithCode(ErrorStr("getting stream descriptor by index"), hr); goto cleanup; - } if (!fSelected) { err = ErrorWithCode(ErrorStr("stream was not selected"), hr); goto cleanup; - } if ((hr = MFCreateAudioRendererActivate(&activate)) != S_OK) { err = ErrorWithCode(ErrorStr("creating audio renderer activate"), hr); goto cleanup; - } if ((hr = MFCreateTopology(&topology)) != S_OK) { err = ErrorWithCode(ErrorStr("creating topology"), hr); goto cleanup; - } if ((hr = AddSourceNode(topology, src, desc, stream_desc, &pSourceNode)) != S_OK) { err = ErrorWithCode(ErrorStr("adding source node"), hr); goto cleanup; - } if ((hr = AddOutputNode(topology, (IMFStreamSink *)activate, &pOutputNode)) != S_OK) { err = ErrorWithCode(ErrorStr("adding output node"), hr); goto cleanup; - } if ((hr = pSourceNode->lpVtbl->ConnectOutput(pSourceNode, 0, pOutputNode, 0)) != S_OK) @@ -557,7 +547,6 @@ Play(char* path) { err = ErrorWithCode(ErrorStr("setting topology on session: %ld\n"), hr); goto cleanup; - } hr = RunMediaSession(session); @@ -739,6 +728,277 @@ cleanup: return r; } +// decode buffers the decoded PCM data and returns it via out. +// +// By default an unconfigured AAC decoder will output PCM 16le which is +// luckily what we need. +Error* +decode(IMFSourceReader * reader, Buffer ** out) +{ + assert(reader); + + IMFMediaBuffer *bufferReader = NULL; // buffer object containing the raw buffer. + LONGLONG prev_time_stamp = -1; + IMFSample *pSample = NULL; // sample object containing on or more streams. + LONGLONG time_stamp = 0; + Buffer *buffer = NULL; // Buffer to accumulate decoded PCM and return to Go. + BYTE *chunk = NULL; // pointer to start of chunk. + DWORD cbBuffer = 0; // size of chunk. + HRESULT hr = S_OK; + Error *err = NULL; + + // Heap allocated buffer to accumulate the audio data. + // NOTE(jfm): Free from cgo side with BufferFree(). + buffer = BufferNew(); + + // Stream all the data into a byte buffer. + + // NOTE(jfm): we can create a streaming api by extracting this loop + // to the Go side, and implement something like an io.Reader. + // However this api currently reads the entire thing and passes + // it all back to Go at once. + while (1) { + DWORD dwFlags = 0; + + // Read the next sample. + hr = reader->lpVtbl->ReadSample( + reader, + (DWORD)MF_SOURCE_READER_FIRST_AUDIO_STREAM, + 0, + NULL, + &dwFlags, + &time_stamp, + &pSample + ); + + // NOTE(jfm): Avoid chunks that we have already seen. + // + // For some reason, ReadSample can produce more than + // one sample at time stamp "0". + // + // Emitting all of them produces both larger files and + // audio artefacts. + if (time_stamp == prev_time_stamp) + { + continue; + } + + prev_time_stamp = time_stamp; + + if (FAILED(hr)) + { + err = ErrorWithCode(ErrorStr("reading sample"), hr); + goto cleanup; + } + + if (dwFlags & MF_SOURCE_READERF_CURRENTMEDIATYPECHANGED) + { + break; + } + if (dwFlags & MF_SOURCE_READERF_ENDOFSTREAM) + { + break; + } + + if (pSample == NULL) + { + continue; + } + + // Get a pointer to the buffer object. + hr = pSample->lpVtbl->ConvertToContiguousBuffer(pSample, &bufferReader); + + if (FAILED(hr)) + { + err = ErrorWithCode(ErrorStr("converting to contiguous buffer"), hr); + goto cleanup; + } + + // Get read/write access to the next chunk of audio data. + hr = bufferReader->lpVtbl->Lock(bufferReader, &chunk, NULL, &cbBuffer); + + if (FAILED(hr)) + { + err = ErrorWithCode(ErrorStr("locking buffer"), hr); + goto cleanup; + } + + BufferWrite(buffer, cbBuffer, chunk); + + // Unlock the reader that we just copied from. + hr = bufferReader->lpVtbl->Unlock(bufferReader); + + if (FAILED(hr)) + { + err = ErrorWithCode(ErrorStr("unlocking buffer"), hr); + goto cleanup; + } + + chunk = NULL; + } + + *out = buffer; + +cleanup: + + if (pSample != NULL) + { + pSample->lpVtbl->Release(pSample); + } + + if (bufferReader != NULL) + { + bufferReader->lpVtbl->Release(bufferReader); + } + + return err; +} + +// Decode the compresed audio data into uncompressed s16le PCM. +// +// We get a bit lucky here because Media Foundation defaults to that +// PCM format when it auto-inits the AAC decoder. +// +// For different input formats (other than AAC) the PCM format may not +// be guaranteed. +DecodeResult +Decode(BYTE* compressed, UINT size) +{ + assert(compressed); + + HRESULT hr = S_OK; + DecodeResult r = { + .Uncompressed = NULL, + .Format = { .Channels = 0, .SampleRate = 0, .BitDepth = 0}, + .Err = NULL, + }; + + hr = MFStartup(MF_VERSION, MFSTARTUP_LITE); + + if (FAILED(hr)) + { + r.Err = ErrorWithCode(ErrorStr("initializing media foundation"), hr); + goto cleanup; + } + + IMFByteStream *stream = NULL; + IStream *mem_stream = SHCreateMemStream(compressed, size); + + hr = MFCreateMFByteStreamOnStream(mem_stream, &stream); + + if (FAILED(hr)) + { + r.Err = ErrorWithCode(ErrorStr("creating byte stream"), hr); + goto cleanup; + } + + IMFSourceReader *reader = NULL; + hr = MFCreateSourceReaderFromByteStream(stream, NULL, &reader); + + if (FAILED(hr)) + { + r.Err = ErrorWithCode(ErrorStr("creating source reader from byte stream"), hr); + goto cleanup; + } + + // Deselect all streams and then select the first audio stream. + + hr = reader->lpVtbl->SetStreamSelection(reader, MF_SOURCE_READER_ALL_STREAMS, FALSE); + + if (FAILED(hr)) + { + r.Err = ErrorWithCode(ErrorStr("deslecting all streams"), hr); + goto cleanup; + } + + hr = reader->lpVtbl->SetStreamSelection(reader, MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE); + + if (FAILED(hr)) + { + r.Err = ErrorWithCode(ErrorStr("selecting first audio stream"), hr); + goto cleanup; + } + + IMFMediaType * m_type = NULL; + + hr = reader->lpVtbl->GetCurrentMediaType( + reader, + (DWORD)MF_SOURCE_READER_FIRST_AUDIO_STREAM, + &m_type + ); + + if (FAILED(hr)) + { + r.Err = ErrorWithCode(ErrorStr("getting media type"), hr); + goto cleanup; + } + + // WAVE format gives us the right meta data, so we'll use it. + // + // Format tag == 5648 (MPEG_HEAAC) + WAVEFORMATEX * format = NULL; + hr = MFCreateWaveFormatExFromMFMediaType( + m_type, + &format, + NULL, + MFWaveFormatExConvertFlag_Normal + ); + + if (FAILED(hr)) + { + r.Err = ErrorWithCode(ErrorStr("getting meta data"), hr); + goto cleanup; + } + + assert(format); + + Buffer * buffer = NULL; + + r.Err = decode(reader, &buffer); + + if (r.Err != NULL) + { + r.Err = ErrorWrap(r.Err, "decode minor"); + } + + assert(buffer); + +cleanup: + + if (stream != NULL) + { + stream->lpVtbl->Release(stream); + } + + if (reader != NULL) + { + reader->lpVtbl->Release(reader); + } + + hr = MFShutdown(); + + if (FAILED(hr)) + { + // Capture the shutdown error only if we didn't already encounter one. + if (r.Err == NULL) + { + r.Err = ErrorWithCode(ErrorStr("shutting down media foundation"), hr); + } + } + + if (buffer != NULL) + { + r.Uncompressed = buffer; + } + + r.Format.SampleRate = format->nSamplesPerSec; + r.Format.BitDepth = format->wBitsPerSample/8; + r.Format.Channels = format->nChannels; + + return r; +} + + Result Load(char* path) { diff --git a/audio_windows.go b/audio_windows.go @@ -2,14 +2,20 @@ package nativeaudio +// -g: includes dwarf debug data + /* -#cgo CFLAGS: -Wall -Werror -#cgo LDFLAGS: -lWinmm -lMFPlat -lMf -lMfuuid -loleaut32 -limm32 -lversion -lWindowsApp -lMfreadwrite +#cgo CFLAGS: -Wall -Werror -g +#cgo LDFLAGS: -lWinmm -lMf -lMfplat -lMfuuid -loleaut32 -limm32 -lversion -lWindowsApp -lMfreadwrite -lShlwapi #include "audio_windows.h" */ import "C" + import ( + "errors" "fmt" + "runtime" + "strings" "unsafe" ) @@ -36,10 +42,53 @@ func load(path string) ([]byte, error) { defer C.free(unsafe.Pointer(cPath)) result := C.Load(cPath) if result.Err != nil { - defer C.ErrorFree(result.Err) - return nil, fmt.Errorf(C.GoString(result.Err.Str)) + // defer C.ErrorFree(result.Err) + return nil, collectErrors(result.Err) } buffer := (*C.Buffer)(result.Value) defer C.BufferFree(buffer) return C.GoBytes(unsafe.Pointer(buffer.Data), buffer.Len), nil } + +// decode compressed data, returning the uncompressed data as PCM data +// (s16le) and details about the PCM required to playback correctly. +// +// TODO(jfm) [perf]: avoid copying buffer in (CBytes does a copy). +func decode(compressed []byte) (uncompressed []byte, format Format, err error) { + defer runtime.KeepAlive(compressed) + r := C.Decode((*C.uchar)(C.CBytes(compressed)), C.uint(len(compressed))) + if r.Err != nil && r.Err.Str != nil { + // TODO(jfm): Free result. + return nil, format, collectErrors(r.Err) + } + defer C.BufferFree(r.Uncompressed) + uncompressed = C.GoBytes(unsafe.Pointer(r.Uncompressed.Data), r.Uncompressed.Len) + format = Format{ + Channels: int(r.Format.Channels), + BitDepth: int(r.Format.BitDepth), + SampleRate: int(r.Format.SampleRate), + } + return uncompressed, format, nil +} + +// collectErrors unwraps all the errors in the chain and coalesces them +// into a single Go error. +func collectErrors(err *C.Error) error { + var buf strings.Builder + for first := err; err != nil; err = err.Err { + if err.Str != nil { + if err != first { + buf.WriteString(": ") + } + buf.WriteString(strings.TrimSpace(C.GoString(err.Str))) + if err.Code != 0 { + buf.WriteString(fmt.Sprintf(" (%d)", err.Code)) + } + } + } + str := strings.TrimSpace(buf.String()) + if str == "" { + panic("error message is empty") + } + return errors.New(str) +} diff --git a/audio_windows.h b/audio_windows.h @@ -5,12 +5,14 @@ #include <winbase.h> #include <combaseapi.h> #include <mfapi.h> -#include <mfidl.h> +#include <Mfidl.h> #include <mferror.h> #include <initguid.h> #include <wmcodecdsp.h> #include <mmdeviceapi.h> #include <mfreadwrite.h> +#include <shlwapi.h> +#include <assert.h> // Error declares an error return containing a message and possibly // wrapping another error. @@ -35,6 +37,16 @@ typedef struct Result Error* Err; } Result; + +// Format describes uncompressed PCM necessary for correct playback. +typedef struct Format +{ + int SampleRate; + int Channels; + int BitDepth; +} Format; + + // Buffer describes a dynamic byte buffer with a length, capacity and // a pointer to the first element. typedef struct Buffer @@ -44,6 +56,15 @@ typedef struct Buffer BYTE* Data; // Data is the pointer to the first byte. } Buffer; + +// DecodeResult captures the result of decoding an audio buffer. +typedef struct DecodeResult +{ + Buffer* Uncompressed; + Format Format; + Error* Err; +} DecodeResult; + // BufferFree deallocates the memory for a buffer, including the pointer // to it and it's pointer to the raw data. void BufferFree(Buffer*); @@ -65,4 +86,16 @@ Result Load(char* path); // what a wild ride that is. // // https://docs.microsoft.com/en-us/windows/win32/medfound/about-the-media-foundation-sdk -Error* Play(char *path); -\ No newline at end of file +Error* Play(char *path); + +// Decode a buffer of compressed audio using Windows Media Foundation. +DecodeResult Decode(BYTE* compressed, UINT size); + +// TODO: native volume (https://docs.microsoft.com/en-us/windows/win32/api/mfidl/nn-mfidl-imfaudiostreamvolume) + + +// Stub. +HRESULT MFCreateMFByteStreamOnStream( + IStream *pStream, + IMFByteStream **ppByteStream +); +\ No newline at end of file diff --git a/internal/test/audio_test.go b/internal/test/audio_test.go @@ -33,6 +33,23 @@ func TestLoad(t *testing.T) { } } +// // TestDecode ensures that output from the native decoders are similar to +// // the output of ffmpeg. +// func TestDecode(t *testing.T) { +// by, f, err := nativeaudio.Decode(compressed) +// if err != nil { +// t.Fatalf("unexpected error: %v", err) +// } +// t.Logf("format: %+v", f) +// // Test passes on exact match, otherwise do a tolerance test. +// if bytes.Equal(by, uncompressed) { +// return +// } +// if !equal(t, by, uncompressed) { +// t.Fatalf("native output does not match ffmpeg output") +// } +// } + // equal decodes the PCM samples and tests if they are "close enough" // using a heuristic tolerance. //