commit 976ed6cef310547cec0d036021d058babaf99ba3
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Fri, 22 Oct 2021 21:59:10 +0800
nativeaudio: package native audio decoding
This initial commit provides native bindings to Windows Media Foundation
for decoding audio files into PCM s16le, playable by github.com/hajimehoshi/oto.
Non-Windows platforms (at least for the moment) fallback to exec'ing
ffmpeg, which will obviously fail if ffmpeg is not available on the
system path.
macOS native bindings are intended to come relatively soon, however
other platforms like Linux, Android and iOS are left as a community
exercise.
Signed-off-by: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Diffstat:
11 files changed, 1162 insertions(+), 0 deletions(-)
diff --git a/audio.go b/audio.go
@@ -0,0 +1,21 @@
+// Package nativeaudio leverages native decoders for each supported OS
+// to decode raw PCM data.
+//
+// Where there are native APIs to call we default to invoking ffmpeg.
+//
+// Windows: Media Foundation
+// macOS: ffmepg (pending native bindings)
+// Linux: ffmpeg
+//
+package nativeaudio
+
+// Play an audio file exactly once, synchronously.
+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) {
+ return load(path)
+}
diff --git a/audio_ffmpeg.go b/audio_ffmpeg.go
@@ -0,0 +1,54 @@
+// This file is a temporary audio implementation for macOS and Linux
+// platforms. For Linux I suspect ffmpeg will be the defacto, but macOS
+// ships an AAC decoder that we can access directly. That is preferable
+// to relying on the ffmepg binary being present since we'd have to
+// provide one or hope for the best.
+
+package nativeaudio
+
+import (
+ "bytes"
+ "fmt"
+ "os/exec"
+)
+
+// play an audio file with ffplay.
+//
+// ffplay -vn <path> -nodisp -autoexit
+//
+// -vn: no video,
+// -nodisp: do not launch graphical window,
+// -autoexit: exit the process after playback is complete.
+func ffmpegPlay(path string) error {
+ if out, err := exec.Command(
+ "ffplay",
+ "-vn",
+ path,
+ "-nodisp",
+ "-autoext",
+ ).CombinedOutput(); err != nil {
+ return fmt.Errorf("ffplay: %s: %w", string(out), err)
+ }
+ return nil
+}
+
+// load raw PCM with ffmpeg.
+//
+// ffmpeg -i <path> -f s16le -
+//
+// s16le is the PCM format specifier, the final dash means "pipe to
+// stdout".
+func ffmpegLoad(path string) ([]byte, error) {
+ buffer := bytes.NewBuffer(nil)
+ cmd := exec.Command(
+ "ffmpeg",
+ "-i", path,
+ "-f", "s16le",
+ "-",
+ )
+ cmd.Stdout = buffer
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("ffmpeg: %w", err)
+ }
+ return buffer.Bytes(), nil
+}
diff --git a/audio_linux.go b/audio_linux.go
@@ -0,0 +1,13 @@
+//go:build linux && cgo
+
+package nativeaudio
+
+// play stub for Linux.
+func play(path string) error {
+ return ffmpegPlay(path)
+}
+
+// load stub for Linux.
+func load(path string) ([]byte, error) {
+ return ffmpegLoad(path)
+}
diff --git a/audio_macos.go b/audio_macos.go
@@ -0,0 +1,13 @@
+//go:build darwin && cgo
+
+package nativeaudio
+
+// play stub for macOS.
+func play(path string) error {
+ return ffmpegPlay(path)
+}
+
+// load stub for macOS.
+func load(path string) ([]byte, error) {
+ return ffmpegLoad(path)
+}
diff --git a/audio_windows.c b/audio_windows.c
@@ -0,0 +1,862 @@
+#include "audio_windows.h"
+
+// ErrorStr creates an error with the provided message as a char*.
+Error*
+ErrorStr(char *s)
+{
+ Error *err = calloc(1, sizeof(Error));
+ err->Str = s;
+ return err;
+}
+
+// ErrorWrap wraps an error with more context.
+Error*
+ErrorWrap(Error *err, char* s)
+{
+ Error *new = calloc(1, sizeof(Error));
+ new->Str = s;
+ new->Err = err;
+ return new;
+}
+
+// ErrorWithCode attaches an error code to the error.
+Error*
+ErrorWithCode(Error *err, int code)
+{
+ err->Code = code;
+ return err;
+}
+
+// NewResult constructs a Result with the provided value and error.
+// Usually one of the hose pointers will be NULL.
+Result
+NewResult(void* value, Error* err)
+{
+ return (Result){value, err};
+}
+
+// CharWiden converst a raw C string to a wide string used by Windows.
+Result
+CharWiden(char* str)
+{
+ int hr = 0;
+ size_t length = 0;
+ WCHAR *target = NULL;
+
+ length = strlen(str);
+ target = calloc(sizeof(WCHAR), length);
+
+ if ((hr = MultiByteToWideChar(CP_ACP, 0, str, -1, target, length)) != 0)
+ {
+ return NewResult(NULL, ErrorWithCode(ErrorStr("converting C string to Windows wide string (WCHAR)"), hr));
+ }
+
+ return NewResult(target, NULL);
+}
+
+// RunMediaSession executes the media session until complete. This
+// should output the sound.
+HRESULT RunMediaSession(IMFMediaSession* pSession){
+
+ HRESULT hr = S_OK;
+
+ BOOL bSessionEvent = TRUE;
+
+ while(bSessionEvent){
+
+ HRESULT hrStatus = S_OK;
+ IMFMediaEvent* pEvent = NULL;
+ MediaEventType meType = MEUnknown;
+
+ MF_TOPOSTATUS TopoStatus = MF_TOPOSTATUS_INVALID;
+
+ hr = pSession->lpVtbl->GetEvent(pSession, 0, &pEvent);
+
+ if(SUCCEEDED(hr)){
+ hr = pEvent->lpVtbl->GetStatus(pEvent, &hrStatus);
+ }
+
+ if(SUCCEEDED(hr)){
+ hr = pEvent->lpVtbl->GetType(pEvent, &meType);
+ }
+
+ if(SUCCEEDED(hr) && SUCCEEDED(hrStatus)){
+
+ switch(meType){
+
+ case MESessionTopologySet:
+ wprintf(L"MESessionTopologySet\n");
+ break;
+
+ case MESessionTopologyStatus:
+
+ hr = pEvent->lpVtbl->GetUINT32(pEvent, &MF_EVENT_TOPOLOGY_STATUS, (UINT32*)&TopoStatus);
+
+ if(SUCCEEDED(hr)){
+
+ switch(TopoStatus){
+
+ case MF_TOPOSTATUS_READY:
+ {
+ wprintf(L"MESessionTopologyStatus: MF_TOPOSTATUS_READY\n");
+ PROPVARIANT varStartPosition;
+ PropVariantInit(&varStartPosition);
+ hr = pSession->lpVtbl->Start(pSession, NULL, &varStartPosition);
+ PropVariantClear(&varStartPosition);
+ }
+ break;
+
+ case MF_TOPOSTATUS_STARTED_SOURCE:
+ wprintf(L"MESessionTopologyStatus: MF_TOPOSTATUS_STARTED_SOURCE\n");
+ break;
+
+ case MF_TOPOSTATUS_ENDED:
+ wprintf(L"MESessionTopologyStatus: MF_TOPOSTATUS_ENDED\n");
+ break;
+
+ default:
+ wprintf(L"MESessionTopologyStatus: %d\n", TopoStatus);
+ break;
+ }
+ }
+ break;
+
+ case MESessionStarted:
+ wprintf(L"MESessionStarted\n");
+ break;
+
+ case MESessionEnded:
+ wprintf(L"MESessionEnded\n");
+ hr = pSession->lpVtbl->Stop(pSession);
+ break;
+
+ case MESessionStopped:
+ wprintf(L"MESessionStopped\n");
+ hr = pSession->lpVtbl->Close(pSession);
+ break;
+
+ case MESessionClosed:
+ wprintf(L"MESessionClosed\n");
+ bSessionEvent = FALSE;
+ break;
+
+ case MESessionNotifyPresentationTime:
+ wprintf(L"MESessionNotifyPresentationTime\n");
+ break;
+
+ case MESessionCapabilitiesChanged:
+ wprintf(L"MESessionCapabilitiesChanged\n");
+ break;
+
+ case MEEndOfPresentation:
+ wprintf(L"MEEndOfPresentation\n");
+ break;
+
+ default:
+ wprintf(L"Media session event: %d\n", meType);
+ break;
+ }
+
+ if(FAILED(hr) || FAILED(hrStatus)){
+ bSessionEvent = FALSE;
+ }
+ }
+ }
+
+ return hr;
+}
+
+// AddOutputNode to the topology.
+HRESULT AddOutputNode(
+ IMFTopology *pTopology, // Topology.
+ IMFStreamSink *pStreamSink, // Stream sink.
+ IMFTopologyNode **ppNode) // Receives the node pointer.
+{
+ IMFTopologyNode *pNode = NULL;
+ HRESULT hr = S_OK;
+
+ // Create the node.
+ hr = MFCreateTopologyNode(MF_TOPOLOGY_OUTPUT_NODE, &pNode);
+
+ if (hr != S_OK)
+ {
+ return hr;
+ }
+
+ // Set the object pointer.
+ if (SUCCEEDED(hr))
+ {
+ hr = pNode->lpVtbl->SetObject(pNode, (IUnknown *)pStreamSink);
+ }
+
+ if (hr != S_OK)
+ {
+ return hr;
+ }
+
+ // Add the node to the topology.
+ if (SUCCEEDED(hr))
+ {
+ hr = pTopology->lpVtbl->AddNode(pTopology, pNode);
+ }
+
+ if (hr != S_OK)
+ {
+ return hr;
+ }
+
+ if (SUCCEEDED(hr))
+ {
+ hr = pNode->lpVtbl->SetUINT32(pNode, &MF_TOPONODE_NOSHUTDOWN_ON_REMOVE, TRUE);
+ }
+
+ if (hr != S_OK)
+ {
+ return hr;
+ }
+
+ // Return the pointer to the caller.
+ if (SUCCEEDED(hr))
+ {
+ *ppNode = pNode;
+ (*ppNode)->lpVtbl->AddRef(*ppNode);
+ }
+
+ return hr;
+}
+
+// Add a source node to a topology.
+HRESULT AddSourceNode(
+ IMFTopology *pTopology, // Topology.
+ IMFMediaSource *pSource, // Media source.
+ IMFPresentationDescriptor *pPD, // Presentation descriptor.
+ IMFStreamDescriptor *pSD, // Stream descriptor.
+ IMFTopologyNode **ppNode) // Receives the node pointer.
+{
+ IMFTopologyNode *pNode = NULL;
+
+ // Create the node.
+ HRESULT hr = MFCreateTopologyNode(MF_TOPOLOGY_SOURCESTREAM_NODE, &pNode);
+ if (FAILED(hr))
+ {
+ goto done;
+ }
+
+ // Set the attributes.
+ hr = pNode->lpVtbl->SetUnknown(pNode, &MF_TOPONODE_SOURCE, (IUnknown *)pSource);
+ if (FAILED(hr))
+ {
+ goto done;
+ }
+
+ hr = pNode->lpVtbl->SetUnknown(pNode, &MF_TOPONODE_PRESENTATION_DESCRIPTOR, (IUnknown *)pPD);
+ if (FAILED(hr))
+ {
+ goto done;
+ }
+
+ hr = pNode->lpVtbl->SetUnknown(pNode, &MF_TOPONODE_STREAM_DESCRIPTOR, (IUnknown *)pSD);
+ if (FAILED(hr))
+ {
+ goto done;
+ }
+
+ // Add the node to the topology.
+ hr = pTopology->lpVtbl->AddNode(pTopology, pNode);
+ if (FAILED(hr))
+ {
+ goto done;
+ }
+
+ // Return the pointer to the caller.
+ *ppNode = pNode;
+ (*ppNode)->lpVtbl->AddRef(*ppNode);
+
+done:
+ return hr;
+}
+
+//-------------------------------------------------------------------
+// ConfigureAudioStream
+//
+// Selects an audio stream from the source file, and configures the
+// stream to deliver decoded PCM audio.
+//
+// We can use the source reader to load all PCM samples in a loop.
+//
+// hr = ConfigureAudioStream(pReader, &pAudioType);
+//-------------------------------------------------------------------
+HRESULT ConfigureAudioStream(
+ IMFSourceReader *pReader, // Pointer to the source reader.
+ IMFMediaType **ppPCMAudio // Receives the audio format.
+ )
+{
+ IMFMediaType *pUncompressedAudioType = NULL;
+ IMFMediaType *pPartialType = NULL;
+
+ // Select the first audio stream, and deselect all other streams.
+ HRESULT hr = pReader->lpVtbl->SetStreamSelection(pReader,
+ (DWORD)MF_SOURCE_READER_ALL_STREAMS, FALSE);
+
+ if (SUCCEEDED(hr))
+ {
+ hr = pReader->lpVtbl->SetStreamSelection(pReader,
+ (DWORD)MF_SOURCE_READER_FIRST_AUDIO_STREAM, TRUE);
+ }
+
+ // Create a partial media type that specifies uncompressed PCM audio.
+ hr = MFCreateMediaType(&pPartialType);
+
+ if (SUCCEEDED(hr))
+ {
+ hr = pPartialType->lpVtbl->SetGUID(pPartialType, &MF_MT_MAJOR_TYPE, &MFMediaType_Audio);
+ }
+
+ if (SUCCEEDED(hr))
+ {
+ hr = pPartialType->lpVtbl->SetGUID(pPartialType, &MF_MT_SUBTYPE, &MFAudioFormat_PCM);
+ }
+
+ // Set this type on the source reader. The source reader will
+ // load the necessary decoder.
+ if (SUCCEEDED(hr))
+ {
+ hr = pReader->lpVtbl->SetCurrentMediaType(pReader,
+ (DWORD)MF_SOURCE_READER_FIRST_AUDIO_STREAM,
+ NULL, pPartialType);
+ }
+
+ // Get the complete uncompressed format.
+ if (SUCCEEDED(hr))
+ {
+ hr = pReader->lpVtbl->GetCurrentMediaType(pReader,
+ (DWORD)MF_SOURCE_READER_FIRST_AUDIO_STREAM,
+ &pUncompressedAudioType);
+ }
+
+ // Ensure the stream is selected.
+ if (SUCCEEDED(hr))
+ {
+ hr = pReader->lpVtbl->SetStreamSelection(pReader,
+ (DWORD)MF_SOURCE_READER_FIRST_AUDIO_STREAM,
+ TRUE);
+ }
+
+ // Return the PCM format to the caller.
+ if (SUCCEEDED(hr))
+ {
+ *ppPCMAudio = pUncompressedAudioType;
+ (*ppPCMAudio)->lpVtbl->AddRef(*ppPCMAudio);
+ }
+
+ return hr;
+}
+
+Error*
+Play(char* path)
+{
+ Error *err = NULL;
+ Result r;
+ HRESULT hr;
+
+ // We have to build a wide string from the C string for the path.
+ // NOTE(jfm): size cannot exceed 256 without dynamic allocation.
+ WCHAR *audio_file_path = NULL;
+
+ // session is the top-level object wherein all stream
+ // processing occurs.
+ IMFMediaSession *session;
+ // resolver can resolve an abitrary byte source.
+ // In our case this will be a plain audio file.
+ IMFSourceResolver *resolver;
+
+ // obj_type describes the source: media source or byte source.
+ MF_OBJECT_TYPE obj_type;
+ // obj is the true source object.
+ IUnknown *obj;
+ // src is the object casted to it's concrete type.
+ IMFMediaSource *src = NULL;
+ // desc provides meta data about the playback.
+ IMFPresentationDescriptor *desc;
+
+ // Stream selection: we assume exactly 1 stream of AAC audio,
+ // however this code may in fact be too brittle.
+ IMFStreamDescriptor *stream_desc; // info about the stream.
+ BOOL fSelected = FALSE; // if a stream exists for the index.
+ DWORD stream_count = 0; // number of streams found.
+
+ // topology configures a graph of stream processing. The only
+ // processing we want is to decode AAC LC audio.
+ IMFTopology *topology;
+
+ // activate is an object that can initialize itself.
+ // This wraps the audio decoder.
+ IMFActivate *activate;
+
+ // source and output nodes: the only two nodes in our topology
+ // graph.
+ IMFTopologyNode *pSourceNode = NULL;
+ IMFTopologyNode *pOutputNode = NULL;
+
+ // Since Windows uses wide strings we need to convert the char*
+ // to such a format.
+ r = CharWiden(path);
+
+ if (r.Err != NULL)
+ {
+ err = ErrorWrap(r.Err, "converting path string");
+ goto cleanup;
+ }
+
+ audio_file_path = (WCHAR*)r.Value;
+
+ // Start the platform.
+ if ((hr = MFStartup(MF_VERSION, MFSTARTUP_FULL)) != S_OK)
+ {
+ err = ErrorWithCode(ErrorStr("starting media platform"), hr);
+ goto cleanup;
+ }
+
+ // Create a media session which orchestrates the media processing
+ // graph.
+ if ((hr = MFCreateMediaSession(NULL, &session)) != S_OK)
+ {
+ err = ErrorWithCode(ErrorStr("creating media session"), hr);
+ goto cleanup;
+
+ }
+
+ // Create a source resolver. This object can open files and urls.
+ if ((hr = MFCreateSourceResolver(&resolver)) != S_OK)
+ {
+ 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(
+ resolver,
+ audio_file_path,
+ MF_RESOLUTION_MEDIASOURCE|MF_RESOLUTION_READ,
+ NULL,
+ &obj_type,
+ &obj
+ )) != S_OK)
+ {
+ 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.
+ src = (IMFMediaSource*)obj;
+
+ // Create a presentation descriptor.
+ // This object describes the media source, e.g. what it's audio
+ // and video streams are.
+ if ((hr = src->lpVtbl->CreatePresentationDescriptor(src, &desc)) != S_OK)
+ {
+ err = ErrorWithCode(ErrorStr("creating presentation descriptor"), hr);
+ goto cleanup;
+
+ }
+
+ // Get information about the audio stream. We pull the count
+ // and validate it, but we assume there's only one stream:
+ // AAC-LC audio.
+ if ((hr = desc->lpVtbl->GetStreamDescriptorCount(desc, &stream_count)) != S_OK)
+ {
+ err = ErrorWithCode(ErrorStr("getting stream descriptor count"), hr);
+ goto cleanup;
+
+ }
+
+ if (stream_count != 1)
+ {
+ err = ErrorWithCode(ErrorStr("expected exactly one stream"), hr);
+ goto cleanup;
+
+ }
+
+ if ((hr = desc->lpVtbl->GetStreamDescriptorByIndex(desc, 0, &fSelected, &stream_desc)) != S_OK)
+ {
+ 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)
+ {
+ err = ErrorWithCode(ErrorStr("connect output"), hr);
+ goto cleanup;
+ }
+
+ if ((hr = session->lpVtbl->SetTopology(session, MFSESSION_SETTOPOLOGY_IMMEDIATE, topology)) != S_OK)
+ {
+ err = ErrorWithCode(ErrorStr("setting topology on session: %ld\n"), hr);
+ goto cleanup;
+
+ }
+
+ hr = RunMediaSession(session);
+
+ if (hr != S_OK)
+ {
+ err = ErrorWithCode(ErrorStr("running media session"), hr);
+ goto cleanup;
+ }
+
+cleanup:
+ free(audio_file_path);
+ if (pSourceNode != NULL)
+ {
+ pSourceNode->lpVtbl->Release(pSourceNode);
+ }
+ if (pOutputNode != NULL)
+ {
+ pOutputNode->lpVtbl->Release(pOutputNode);
+ }
+ if (activate != NULL)
+ {
+ activate->lpVtbl->Release(activate);
+ }
+ if (topology != NULL)
+ {
+ topology->lpVtbl->Release(topology);
+ }
+ if (stream_desc != NULL)
+ {
+ stream_desc->lpVtbl->Release(stream_desc);
+ }
+ if (desc != NULL)
+ {
+ desc->lpVtbl->Release(desc);
+ }
+ if (src != NULL)
+ {
+ src->lpVtbl->Release(src);
+ }
+ if (obj != NULL)
+ {
+ obj->lpVtbl->Release(obj);
+ }
+ if (resolver != NULL)
+ {
+ resolver->lpVtbl->Release(resolver);
+ }
+ if (session != NULL)
+ {
+ session->lpVtbl->Release(session);
+ }
+ return err;
+}
+
+
+// BufferNew allocates a new buffer ready to use.
+Buffer*
+BufferNew()
+{
+ Buffer *buffer = calloc(1, sizeof(Buffer));
+ buffer->Data = NULL;
+ buffer->Len = 0;
+ buffer->Cap = 0;
+ return buffer;
+}
+
+// BufferGrow grows the capacity by at least the provided amount.
+// Growth algorithm uses the double+1 strategy.
+void
+BufferGrow(Buffer *buffer, int amount)
+{
+ BYTE* tmp = NULL;
+ int target = 0;
+ if (buffer->Cap > buffer->Len + amount)
+ {
+ return;
+ }
+ // Expand the target capacity until we can fit the amount.
+ while (target < buffer->Len + amount)
+ {
+ target = target*2+1;
+ }
+ tmp = calloc(target, 1);
+ memcpy(tmp, buffer->Data, buffer->Len);
+ free(buffer->Data);
+ buffer->Data = tmp;
+ buffer->Cap = target;
+}
+
+// BufferWrite the data to the buffer, growing if necessary.
+void
+BufferWrite(Buffer* buffer, int size, BYTE* data)
+{
+ // TMP sum counting.
+ int sum = 0;
+ for (int ii = 0; ii < size; ii++)
+ {
+ sum += data[ii];
+ }
+
+ if (buffer->Cap < buffer->Len + size)
+ {
+ BufferGrow(buffer, size);
+ }
+ memcpy(buffer->Data+buffer->Len, data, size);
+ buffer->Len += size;
+}
+
+// BufferFree frees the memory for the buffer.
+void
+BufferFree(Buffer* buffer)
+{
+ free(buffer->Data);
+ free(buffer);
+}
+
+// Setup the source reader for the audio file at the given path.
+// IMFSourceReader* is returned as result value.
+Result
+NewSourceReaderForFile(char* path)
+{
+ Error *err = NULL; // Dyanmic error.
+ HRESULT hr = S_OK; // Windows return code.
+
+ WCHAR *audio_file_path = NULL; // Path to audio file.
+ IMFMediaType *pAudioType = NULL; // Represents the PCM audio format.
+
+ // reader is returned to caller.
+ IMFSourceReader *reader = NULL; // Object to stream bytes from.
+
+ // Since Windows uses wide strings we need to convert the char*
+ // to such a format.
+ Result r = CharWiden(path);
+
+ if (r.Err != NULL)
+ {
+ err = ErrorWithCode(ErrorWrap(r.Err, "converting path string"), hr);
+ goto cleanup;
+ }
+
+ audio_file_path = (WCHAR*)r.Value;
+
+ hr = MFCreateSourceReaderFromURL(audio_file_path, NULL, &reader);
+
+ if (FAILED(hr))
+ {
+ err = ErrorWithCode(ErrorStr("creating source reader"), hr);
+ goto cleanup;
+ }
+
+ hr = ConfigureAudioStream(reader, &pAudioType);
+
+ if (FAILED(hr))
+ {
+ err = ErrorWithCode(ErrorStr("configuring audio stream"), hr);
+ goto cleanup;
+ }
+
+cleanup:
+
+ if (pAudioType != NULL)
+ {
+ pAudioType->lpVtbl->Release(pAudioType);
+ }
+
+ free(audio_file_path);
+
+ if (err != NULL)
+ {
+ r.Err = err;
+ }
+
+ if (reader != NULL)
+ {
+ r.Value = reader;
+ }
+
+ return r;
+}
+
+Result
+Load(char* path)
+{
+ Result r;
+ Error *err = NULL; // Dyanmic error.
+ HRESULT hr = S_OK; // Windows return code.
+ IMFSourceReader *reader = NULL; // Object to stream bytes from.
+ DWORD cbBuffer = 0; // size of chunk.
+ BYTE *chunk = NULL; // pointer to start of chunk.
+ IMFSample *pSample = NULL; // sample object containing on or more streams.
+ IMFMediaBuffer *bufferReader = NULL; // buffer object containing the raw buffer.
+ Buffer *buffer = NULL; // Buffer to accumulate decoded PCM and return to Go.
+
+ LONGLONG prev_time_stamp = -1;
+ LONGLONG time_stamp = 0;
+
+ hr = MFStartup(MF_VERSION, MFSTARTUP_FULL);
+
+ if (FAILED(hr))
+ {
+ err = ErrorWithCode(ErrorStr("starting media platform"), hr);
+ goto cleanup;
+ }
+
+ r = NewSourceReaderForFile(path);
+
+ if (r.Err != NULL)
+ {
+ err = ErrorWrap(r.Err, "setting up source reader for audio file");
+ goto cleanup;
+ }
+
+ reader = (IMFSourceReader*)(r.Value);
+
+ // 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;
+ }
+
+cleanup:
+ MFShutdown();
+
+ if (reader != NULL)
+ {
+ reader->lpVtbl->Release(reader);
+ }
+
+ if (bufferReader != NULL)
+ {
+ bufferReader->lpVtbl->Release(bufferReader);
+ }
+
+ return NewResult(buffer, err);
+}
+\ No newline at end of file
diff --git a/audio_windows.go b/audio_windows.go
@@ -0,0 +1,45 @@
+//go:build windows && cgo
+
+package nativeaudio
+
+/*
+#cgo CFLAGS: -Wall -Werror
+#cgo LDFLAGS: -lWinmm -lMFPlat -lMf -lMfuuid -loleaut32 -limm32 -lversion -lWindowsApp -lMfreadwrite
+#include "audio_windows.h"
+*/
+import "C"
+import (
+ "fmt"
+ "unsafe"
+)
+
+// play the audio file using Windows Media Foundation.
+func play(path string) error {
+ cPath := C.CString(path)
+ defer C.free(unsafe.Pointer(cPath))
+ err := C.Play(cPath)
+ if err != nil {
+ defer C.free(unsafe.Pointer(err))
+ return fmt.Errorf(C.GoString(err.Str))
+ }
+ return nil
+}
+
+// load raw pcm data from the Windows Media Foundation.
+//
+// PERF(jfm): we can optimize this by allocating the buffer from Go,
+// and passing it in for C to fill up. It would require more
+// orchestration, but would save the copy. At the moment, C allocates
+// its own buffer, we then copy the data and free the C buffer.
+func load(path string) ([]byte, error) {
+ cPath := C.CString(path)
+ defer C.free(unsafe.Pointer(cPath))
+ result := C.Load(cPath)
+ if result.Err != nil {
+ defer C.free(unsafe.Pointer(result.Err))
+ return nil, fmt.Errorf(C.GoString(result.Err.Str))
+ }
+ buffer := (*C.Buffer)(result.Value)
+ defer C.BufferFree(buffer)
+ return C.GoBytes(unsafe.Pointer(buffer.Data), buffer.Len), nil
+}
diff --git a/audio_windows.h b/audio_windows.h
@@ -0,0 +1,63 @@
+#include <crtdbg.h>
+#include <stdlib.h>
+#include <stdio.h>
+#include <Windows.h>
+#include <winbase.h>
+#include <combaseapi.h>
+#include <mfapi.h>
+#include <mfidl.h>
+#include <mferror.h>
+#include <initguid.h>
+#include <wmcodecdsp.h>
+#include <mmdeviceapi.h>
+#include <mfreadwrite.h>
+
+// Error declares an error return containing a message and possibly
+// wrapping another error.
+//
+// Errors are dynamically allocated and are designed to provide immediate
+// feedback with a user facing description.
+//
+// Use the wrapped error code to get the raw code for manual lookup.
+typedef struct Error
+{
+ int Code; // Underlying error code from traditional C calls.
+ struct Error* Err; // Wrapped error, if any.
+ char* Str; // String description of error.
+} Error;
+
+// Result captures a generic value return along side a possible error.
+// Check error before accessing value. Caller must know what type the
+// value can be.
+typedef struct Result
+{
+ void* Value;
+ Error* Err;
+} Result;
+
+// Buffer describes a dynamic byte buffer with a length, capacity and
+// a pointer to the first element.
+typedef struct Buffer
+{
+ int Len; // Len is the currently used region of the buffer.
+ int Cap; // Capacity is the total allocated memory.
+ BYTE* Data; // Data is the pointer to the first byte.
+} Buffer;
+
+void BufferFree(Buffer*);
+
+// Load the decoded PCM data from the given file.
+//
+// Load is implemented over the top of Windows Media Foundation and
+// what a wild ride that is.
+//
+// https://docs.microsoft.com/en-us/windows/win32/medfound/about-the-media-foundation-sdk
+Result Load(char* path);
+
+// Play an audio file at the given file path.
+//
+// Play is implemented over the top of Windows Media Foundation and
+// 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
diff --git a/go.mod b/go.mod
@@ -0,0 +1,3 @@
+module git.sr.ht/~jackmordaunt/nativeaudio
+
+go 1.17
diff --git a/internal/test/audio_test.go b/internal/test/audio_test.go
@@ -0,0 +1,86 @@
+package test
+
+import (
+ "bytes"
+ _ "embed"
+ "encoding/binary"
+ "fmt"
+ "testing"
+
+ "git.sr.ht/~jackmordaunt/nativeaudio"
+)
+
+var (
+ //go:embed compressed.m4a
+ compressed []byte
+ //go:embed uncompressed.pcm
+ uncompressed []byte
+)
+
+// TestLoad ensures that output from the native decoders are close to
+// the output of ffmpeg.
+func TestLoad(t *testing.T) {
+ by, err := nativeaudio.Load("compressed.m4a")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // 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.
+//
+// Decode each sample as a signed integer and compute the absolute
+// difference on average.
+func equal(t *testing.T, left, right []byte) bool {
+ if len(left) == 0 || len(right) == 0 {
+ return false
+ }
+ var (
+ lsamples = make([]int16, len(left)/2)
+ rsamples = make([]int16, len(right)/2)
+ )
+ if err := binary.Read(bytes.NewReader(left), binary.LittleEndian, lsamples); err != nil {
+ panic(fmt.Errorf("left: binary read: %w", err))
+ }
+ if err := binary.Read(bytes.NewReader(right), binary.LittleEndian, rsamples); err != nil {
+ panic(fmt.Errorf("right: binary read: %w", err))
+ }
+ var (
+ size int = min(len(lsamples), len(rsamples))
+ sum int = 0
+ )
+ for ii := 0; ii < size; ii++ {
+ var (
+ lsample = int(lsamples[ii])
+ rsample = int(rsamples[ii])
+ )
+ sum += abs(lsample - rsample)
+ }
+ mean := sum / size
+ t.Logf("mean: %d, sum: %d, size: %d\n", mean, sum, size)
+ if mean > 1 {
+ return false
+ }
+ return true
+}
+
+func min(left, right int) int {
+ if left < right {
+ return left
+ }
+ return right
+}
+
+func abs(n int) int {
+ if n < 0 {
+ return n * -1
+ }
+ return n
+}
diff --git a/internal/test/compressed.m4a b/internal/test/compressed.m4a
Binary files differ.
diff --git a/internal/test/uncompressed.pcm b/internal/test/uncompressed.pcm
Binary files differ.