nativeaudio

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

commit b654445175fea5e719d2d00636bac17f70e1e82b
parent 8cded971ac79011745b25cb8a0dd66fa4f05b87b
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Thu,  2 May 2024 15:08:24 +0800

nativeaudio: [macos] native implementation

This commit implements audio decoding using AudioToolbox and
AVFoundation.

Diffstat:
Maudio_macos.go | 564++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 557 insertions(+), 7 deletions(-)

diff --git a/audio_macos.go b/audio_macos.go @@ -2,25 +2,575 @@ package nativeaudio +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework AudioToolbox -framework AVFoundation + +#include <AudioToolbox/AudioToolbox.h> +#include <AVFoundation/AVFoundation.h> + +// Pre-declare exported Go functions to make them visible in the +// C pseudo package. + +OSStatus InputDataProc( + AudioConverterRef inAudioConverter, + UInt32 * ioNumberDataPackets, + AudioBufferList * ioData, + AudioStreamPacketDescription ** outDataPacketDescription, + void * inUserData +); + +OSStatus AudioFileReadProcImpl( + void *inClientData, + SInt64 inPosition, + UInt32 requestCount, + void *buffer, + UInt32 *actualCount +); + +SInt64 AudioFileGetSizeProcImpl( + void *inClientData +); +*/ +import "C" +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "runtime" + "sync/atomic" + "unsafe" + + "git.sr.ht/~jackmordaunt/nativeaudio/internal" + "github.com/ebitengine/oto/v3" +) + +func start() error { + return nil +} + +func end() error { + return nil +} + // play stub for macOS. func play(path string) error { - return FFmpegPlay(path) + data, format, err := load(path) + if err != nil { + return err + } + ctx, ready, err := oto.NewContext(&oto.NewContextOptions{ + SampleRate: format.SampleRate, + ChannelCount: format.Channels, + Format: oto.FormatSignedInt16LE, + }) + if err != nil { + return fmt.Errorf("starting playback context: %w", err) + } + <-ready + done := make(chan any) + player := ctx.NewPlayer(internal.NewTriggerReader(bytes.NewReader(data), func() { close(done) })) + player.Play() + <-done + ctx.Suspend() + return player.Close() } // load stub for macOS. -func load(path string) ([]byte, Format, error) { - return FFmpegLoad(path) +func load(path string) (_ []byte, f Format, _ error) { + inputf, err := os.Open(path) + if err != nil { + return nil, f, err + } + + defer inputf.Close() + + inputBuf, err := io.ReadAll(inputf) + if err != nil { + return nil, f, err + } + + return decode(inputBuf) } // decode stub for macOS. -func decode(by []byte) ([]byte, Format, error) { - return FFmpegDecode(by) +func decode(buf []byte) (_ []byte, f Format, _ error) { + var pinner runtime.Pinner + defer pinner.Unpin() + + pinner.Pin(&buf) + pinner.Pin(unsafe.SliceData(buf)) + + // Allocate an "AudioFile" backed by a byte slice. + inputFile, err := OpenAudioFileBuffer(&buf) + if err != nil { + return nil, f, fmt.Errorf("opening file with callbacks: %w", err) + } + + defer inputFile.Dispose() + + pinner.Pin(inputFile) + + // Query the input format. + var inputDescription C.AudioStreamBasicDescription + + if _, err := inputFile.GetProperty( + C.kAudioFilePropertyDataFormat, + C.UInt32(unsafe.Sizeof(inputDescription)), + unsafe.Pointer(&inputDescription), + ); err != nil { + return nil, f, fmt.Errorf("querying for property kAudioFilePropertyDataFormat: %w", err) + } + + var inputUsesPacketDescriptions C.Boolean + + // For variable encodings, the bytes and frames per packet are found + // during decoding rather than defined globally. + if inputDescription.mBytesPerPacket == 0 || inputDescription.mFramesPerPacket == 0 { + inputUsesPacketDescriptions = _true + } + + // Define the output format. + outputDescription := C.AudioStreamBasicDescription{ + mSampleRate: inputDescription.mSampleRate, + mChannelsPerFrame: inputDescription.mChannelsPerFrame, + mFormatID: C.kAudioFormatLinearPCM, + mFormatFlags: C.kAudioFormatFlagIsSignedInteger | C.kAudioFormatFlagIsPacked, + mBytesPerPacket: 2 * inputDescription.mChannelsPerFrame, + mFramesPerPacket: 1, + mBytesPerFrame: 2 * inputDescription.mChannelsPerFrame, + mBitsPerChannel: 16, + } + + // Allocate the AudioConverter with our input and output formats. + // This handles the conversion between audio formats. + audioConverter, err := NewAudioConverter(&inputDescription, &outputDescription) + if err != nil { + return nil, f, fmt.Errorf("creating audio converter: %w", err) + } + + defer audioConverter.Dispose() + + magicCookieSize, err := inputFile.GetPropertySize(C.kAudioFilePropertyMagicCookieData) + if err != nil { + return nil, f, fmt.Errorf("getting magic cookie property: %w", err) + } + + // If a magic cookie exists in the input, set it on the AudioConverter. + // + // The magic cookie is a completely opaque piece of data, written and read only + // by the codec itself. A magic cookie is only present for codecs that require it; + // this API will return NULL if one does not exist. This API is specific to audio + // format descriptions, and will return NULL if called with a non-audio format + // description. + // + // https://developer.apple.com/documentation/coremedia/1489508-cmaudioformatdescriptiongetmagic + if magicCookieSize > 0 { + magicCookie := make([]byte, 0, magicCookieSize) + + pinner.Pin(unsafe.SliceData(magicCookie)) + + if _, err := inputFile.GetProperty( + C.kAudioFilePropertyMagicCookieData, + magicCookieSize, + unsafe.Pointer(unsafe.SliceData(magicCookie)), + ); err != nil { + return nil, f, fmt.Errorf("getting magic cookie: %w", err) + } + + if err := audioConverter.SetProperty( + C.kAudioConverterDecompressionMagicCookie, + magicCookieSize, + unsafe.Pointer(unsafe.SliceData(magicCookie)), + ); err != nil { + return nil, f, fmt.Errorf("setting magic cookie: %w", err) + } + } + + var ( + maxInputPacketSize C.UInt32 + maxOutputPacketSize C.UInt32 + ) + + if _, err := inputFile.GetProperty( + C.kAudioFilePropertyMaximumPacketSize, + C.UInt32(unsafe.Sizeof(maxInputPacketSize)), + unsafe.Pointer(&maxInputPacketSize), + ); err != nil { + return nil, f, fmt.Errorf("getting maximum packet size from input: %w", err) + } + + maxOutputPacketSize = outputDescription.mBytesPerPacket + + // Allocate the InputContext. + // This is a structure that we define and use within the [InputDataProc]. + // It gets passed in as a void pointer. + ic := NewInputContext( + inputFile, + inputDescription, + maxInputPacketSize, + inputUsesPacketDescriptions, + ) + + pinner.Pin(unsafe.SliceData(ic.mPacketDescriptions)) + pinner.Pin(ic) + + // This constant is derived from the example. I don't know what a "good" value is. + packetsPerLoop := C.UInt32(10000) + + packetBuffer := make([]byte, 0, packetsPerLoop*maxOutputPacketSize) + pinner.Pin(unsafe.Pointer(unsafe.SliceData(packetBuffer))) + + // Perform the conversion, collecting the results into a Go byte slice. + + var out []byte + + for { + numPackets := packetsPerLoop + + // Initialize AudioBufferList with a single buffer because we are + // working with interleaved PCM samples. + abl := C.AudioBufferList{ + mNumberBuffers: 1, + mBuffers: [1]C.AudioBuffer{{ + mNumberChannels: outputDescription.mChannelsPerFrame, + mDataByteSize: C.UInt32(cap(packetBuffer)), + mData: unsafe.Pointer(unsafe.SliceData(packetBuffer)), + }}, + } + + if err := audioConverter.FillComplexBuffer( + (C.AudioConverterComplexInputDataProc)(C.InputDataProc), + unsafe.Pointer(ic), + &numPackets, + &abl, + nil, + ); err != nil { + return nil, f, fmt.Errorf("filling buffer: %w", err) + } + + if numPackets > 0 { + out = append(out, unsafe.Slice((*byte)(abl.mBuffers[0].mData), abl.mBuffers[0].mDataByteSize)...) + } + + if numPackets < packetsPerLoop { + break + } + } + + f.Channels = int(outputDescription.mChannelsPerFrame) + f.BitDepth = int(outputDescription.mBitsPerChannel / 8) + f.SampleRate = int(outputDescription.mSampleRate) + + return out, f, nil } -func start() error { +const ( + _false = C.Boolean(0) + _true = C.Boolean(1) +) + +// AudioConverter is responsible for converting between audio formats. +type AudioConverter struct { + ref C.AudioConverterRef + disposed atomic.Bool +} + +func NewAudioConverter(inSourceFormat, inDestinationFormat *C.AudioStreamBasicDescription) (*AudioConverter, error) { + ac := &AudioConverter{} + if err := C.AudioConverterNew(inSourceFormat, inDestinationFormat, &ac.ref); err != C.noErr { + return nil, fmt.Errorf("AudioConverterNew: %v", err) + } + return ac, nil +} + +func (ac *AudioConverter) FillComplexBuffer( + inInputDataProc C.AudioConverterComplexInputDataProc, + inInputDataProcUserData unsafe.Pointer, + ioOutputDataPacketSize *C.UInt32, + outOutputData *C.AudioBufferList, + outPacketDescription *C.AudioStreamPacketDescription, +) error { + if err := C.AudioConverterFillComplexBuffer( + ac.ref, + inInputDataProc, + inInputDataProcUserData, + ioOutputDataPacketSize, + outOutputData, + outPacketDescription, + ); err != C.noErr { + return fmt.Errorf("AudioConverterFillComplexBuffer: %v", err) + } return nil } -func end() error { +func (ac *AudioConverter) SetProperty( + inPropertyID C.AudioFilePropertyID, + inDataSize C.UInt32, + inPropertyData unsafe.Pointer, +) error { + if err := C.AudioConverterSetProperty(ac.ref, inPropertyID, inDataSize, inPropertyData); err != C.noErr { + return fmt.Errorf("AudioConverterSetProperty: %v", err) + } return nil } + +func (ac *AudioConverter) Dispose() { + if ac.disposed.Swap(true) { + return + } + if ac.ref != nil { + C.AudioConverterDispose(ac.ref) + } +} + +type AudioFile struct { + id C.AudioFileID + nextPacket C.SInt64 +} + +func OpenAudioFile(path string) (*AudioFile, error) { + inputFileURL := C.CFURLCreateFromFileSystemRepresentation( + C.kCFAllocatorDefault, + (*C.UInt8)(unsafe.Pointer(unsafe.StringData(path))), + C.long(len(path)), + _false, + ) + + runtime.KeepAlive(path) + + var fileID C.AudioFileID + + if err := C.AudioFileOpenURL(inputFileURL, C.kAudioFileReadPermission, 0, &fileID); err != C.noErr { + return nil, fmt.Errorf("AudioFileOpenURL: %v", err) + } + + C.CFRelease(C.CFTypeRef(inputFileURL)) + + af := &AudioFile{id: fileID} + + return af, nil +} + +// OpenAudioFileBuffer allocates an AudioFile that operates on a +// set of callbacks instead of a true file. This can be used to supply +// data from arbitrary sources. +// +// The particular implementation here simply wraps a Go byte slice and +// copies the data into the out buffer. +// +// This could be made lazy by wrapping an [io.Reader] instead. +// +// Make sure that [buf] is pinned. +func OpenAudioFileBuffer(buf *[]byte) (*AudioFile, error) { + var outAudioFile C.AudioFileID + + // WriteProc and SetSizeProc must be nil, otherwise AudioToolbox considers + // it a writeable file, which restricts what formats it can accept. + if err := C.AudioFileOpenWithCallbacks( + unsafe.Pointer(buf), + (C.AudioFile_ReadProc)(C.AudioFileReadProcImpl), + nil, + (C.AudioFile_GetSizeProc)(C.AudioFileGetSizeProcImpl), + nil, + 0, + &outAudioFile, + ); err != C.noErr { + return nil, fmt.Errorf("AudioFileOpenWithCallbacks: %v", err) + } + + return &AudioFile{id: outAudioFile}, nil +} + +func (af *AudioFile) ID() C.AudioFileID { + return af.id +} + +func (af *AudioFile) Dispose() { + C.AudioFileClose(af.id) +} + +func (af *AudioFile) NextPacket() C.SInt64 { + return af.nextPacket +} + +func (af *AudioFile) ReadPackets( + ioNumBytes C.UInt32, + outPacketDescriptions *C.AudioStreamPacketDescription, + ioNumPackets *C.UInt32, + outBuffer unsafe.Pointer, +) error { + if err := C.AudioFileReadPacketData( + af.id, + _false, + &ioNumBytes, + outPacketDescriptions, + af.nextPacket, + ioNumPackets, + outBuffer, + ); err != C.noErr { + return fmt.Errorf("AudioFileReadPacketData: %v", err) + } + af.nextPacket += C.SInt64(*ioNumPackets) + return nil +} + +func (af *AudioFile) WritePackets( + inNumBytes C.UInt32, + inPacketDescriptions *C.AudioStreamPacketDescription, + inNumPackets C.UInt32, + inBuffer unsafe.Pointer, +) error { + if err := C.AudioFileWritePackets( + af.id, + _false, + inNumBytes, + inPacketDescriptions, + af.nextPacket, + &inNumPackets, + inBuffer, + ); err != C.noErr { + return fmt.Errorf("AudioFileWritePackets: %v", err) + } + af.nextPacket += C.SInt64(inNumPackets) + return nil +} + +func (af *AudioFile) GetProperty( + inPropertyID C.AudioFilePropertyID, + inDataSize C.UInt32, + outPropertyData unsafe.Pointer, +) (C.UInt32, error) { + dataSize := inDataSize + if err := C.AudioFileGetProperty(af.id, inPropertyID, &dataSize, outPropertyData); err != C.noErr { + return 0, fmt.Errorf("AudioFileGetProperty: %v", err) + } + return dataSize, nil +} + +func (af *AudioFile) GetPropertySize(inPropertyID C.AudioFilePropertyID) (C.UInt32, error) { + var ( + size C.UInt32 + isWritable C.UInt32 + ) + if err := C.AudioFileGetPropertyInfo(af.id, inPropertyID, &size, &isWritable); err != C.noErr { + if err == C.kAudioFileUnsupportedPropertyError { + return 0, nil + } + return 0, fmt.Errorf("AudioFileGetPropertyInfo: %v", err) + } + return size, nil +} + +// InputContext is smuggled into the [InputDataProc]. +type InputContext struct { + mInputFile *AudioFile + mMaxInputPacketSize C.UInt32 + mInputUsesPacketDescriptions C.Boolean + mInputDescription C.AudioStreamBasicDescription + mPacketDescriptions []C.AudioStreamPacketDescription +} + +func NewInputContext( + inputFile *AudioFile, + inputDescription C.AudioStreamBasicDescription, + maxInputPacketSize C.UInt32, + inputUsesPacketDescriptions C.Boolean, +) *InputContext { + return &InputContext{ + mInputFile: inputFile, + mInputDescription: inputDescription, + mMaxInputPacketSize: maxInputPacketSize, + mInputUsesPacketDescriptions: inputUsesPacketDescriptions, + mPacketDescriptions: make([]C.AudioStreamPacketDescription, 0, 8), + } +} + +func (ic *InputContext) PacketsRead() C.SInt64 { + return ic.mInputFile.NextPacket() +} + +// InputDataProc reads audio packets from the input file. +// +//export InputDataProc +func InputDataProc( + inAudioConverter C.AudioConverterRef, + ioNumberDataPackets *C.UInt32, + ioData *C.AudioBufferList, + outDataPacketDescription **C.AudioStreamPacketDescription, + inUserData unsafe.Pointer, +) C.OSStatus { + ic := (*InputContext)(inUserData) + + if ic.mInputUsesPacketDescriptions == _true { + // Cap the number of data packets to the capacity of the slice. + if int(*ioNumberDataPackets) > cap(ic.mPacketDescriptions) { + *ioNumberDataPackets = C.UInt32(cap(ic.mPacketDescriptions)) + } + *outDataPacketDescription = unsafe.SliceData(ic.mPacketDescriptions) + } + + if err := ic.mInputFile.ReadPackets( + ioData.mBuffers[0].mDataByteSize, + *outDataPacketDescription, + ioNumberDataPackets, + ioData.mBuffers[0].mData, + ); err != nil { + return unwrapOSStatus(err) + } + + return C.noErr +} + +// AudioFileReadProcImpl copies data from a Go byte slice to the +// output buffer. +// +//export AudioFileReadProcImpl +func AudioFileReadProcImpl( + inClientData unsafe.Pointer, + inPosition C.SInt64, + requestCount C.UInt32, + buffer unsafe.Pointer, + actualCount *C.UInt32, +) C.OSStatus { + inBuf := *(*[]byte)(inClientData) + + // Assuming the the out buffer is sized to contain the requested number of bytes. + // This is not memory we control. + outBuf := unsafe.Slice((*byte)(buffer), requestCount) + + dst := outBuf[:int(requestCount)] + src := inBuf[int(inPosition) : int(inPosition)+int(requestCount)] + + n := copy(dst, src) + + *actualCount = C.UInt32(n) + + return C.noErr +} + +//export AudioFileGetSizeProcImpl +func AudioFileGetSizeProcImpl( + inClientData unsafe.Pointer, +) C.SInt64 { + inBuf := *(*[]byte)(inClientData) + return C.SInt64(cap(inBuf)) +} + +// unwrapOSStatus extracts the [OSStatus] from an [error] for conforming to C +// function signatures. +func unwrapOSStatus(err error) C.OSStatus { + var errOSStatus ErrOSStatus + if errors.As(err, &errOSStatus) { + return C.OSStatus(errOSStatus) + } + panic(fmt.Errorf("unwrapping OSStatus from %v", err)) +} + +type ErrOSStatus C.OSStatus + +func (e ErrOSStatus) Error() string { + return fmt.Sprintf("%v", C.OSStatus(e)) +}