diff --git a/.github/workflows/MacOS-videotoolbox-encode.yml b/.github/workflows/MacOS-videotoolbox-encode.yml index 8d48fce824..e6b467ac8d 100644 --- a/.github/workflows/MacOS-videotoolbox-encode.yml +++ b/.github/workflows/MacOS-videotoolbox-encode.yml @@ -8,6 +8,7 @@ on: - 'libretro-common/formats/h265/rh265_sps_crop.c' - 'libretro-common/include/formats/rh265_sps_crop.h' - 'record/drivers/record_metal.*' + - 'record/drivers/record_avfoundation*' - 'record/record_driver.*' - 'gfx/drivers/metal.m' - 'Makefile.common' @@ -32,6 +33,7 @@ on: - 'libretro-common/formats/h265/rh265_sps_crop.c' - 'libretro-common/include/formats/rh265_sps_crop.h' - 'record/drivers/record_metal.*' + - 'record/drivers/record_avfoundation*' - 'record/record_driver.*' - 'gfx/drivers/metal.m' - 'Makefile.common' @@ -66,6 +68,8 @@ jobs: - name: Portable ownership and failure contracts working-directory: samples/record/videotoolbox_encode run: make check thread_test worker_thread_test audio_thread_test av_session_thread_test && ./thread_test && ./worker_thread_test && ./audio_thread_test && ./av_session_thread_test + - name: Native AAC property ABI and advertised rates + run: make -C samples/record/videotoolbox_encode native-audio-check - name: Native hardware encoder and independent pixels run: | rc=0 diff --git a/record/drivers/record_avfoundation.m b/record/drivers/record_avfoundation.m index def9c0a7c9..2278688a25 100644 --- a/record/drivers/record_avfoundation.m +++ b/record/drivers/record_avfoundation.m @@ -292,6 +292,23 @@ static void *avfoundation_record_init(const struct record_params *params) AVEncoderBitRateKey: @(audioBitRate) }; + if (![handle->assetWriter canApplyOutputSettings:audioSettings + forMediaType:AVMediaTypeAudio]) + { + NSMutableDictionary *fallback = + [NSMutableDictionary dictionaryWithDictionary:audioSettings]; + [fallback removeObjectForKey:AVEncoderBitRateKey]; + audioSettings = fallback; + if (![handle->assetWriter canApplyOutputSettings:audioSettings + forMediaType:AVMediaTypeAudio]) + { + RARCH_ERR("[AVFoundation] Unsupported AAC output settings\n"); + avfoundation_release_handle(handle); + return NULL; + } + RARCH_WARN("[AVFoundation] AAC bitrate preset unsupported; using encoder default\n"); + } + /* Create audio input */ handle->audioInput = [[AVAssetWriterInput alloc] initWithMediaType:AVMediaTypeAudio @@ -324,7 +341,7 @@ static void *avfoundation_record_init(const struct record_params *params) } RARCH_LOG("[AVFoundation] Initialized recording to %s (%ux%u @ %.2f fps, " - "video %u kbps, audio %u kbps)\n", + "video %u kbps, audio preset %u kbps)\n", params->filename, handle->width, handle->height, handle->fps, videoBitRate / 1000, audioBitRate / 1000); } @@ -378,6 +395,7 @@ static bool avfoundation_record_push_video(void *data, record_avfoundation_t *handle = (record_avfoundation_t*)data; CVPixelBufferRef pixelBuffer = NULL; CVReturn result; + __block bool success = false; if (!handle || !video_data) return false; @@ -527,15 +545,16 @@ static bool avfoundation_record_push_video(void *data, /* Append pixel buffer on encoding queue */ dispatch_sync(handle->encodingQueue, ^{ - if (handle->videoInput.readyForMoreMediaData) - [handle->pixelBufferAdaptor appendPixelBuffer:pixelBuffer + if (handle->assetWriter.status == AVAssetWriterStatusWriting + && handle->videoInput.readyForMoreMediaData) + success = [handle->pixelBufferAdaptor appendPixelBuffer:pixelBuffer withPresentationTime:presentationTime]; }); CVPixelBufferRelease(pixelBuffer); } - return true; + return success; } static bool avfoundation_record_push_audio(void *data, @@ -546,6 +565,7 @@ static bool avfoundation_record_push_audio(void *data, CMBlockBufferRef blockBuffer = NULL; CMFormatDescriptionRef formatDesc = NULL; OSStatus status; + __block bool success = false; if (!handle || !handle->audioInput || !audio_data || !audio_data->data) return false; @@ -622,14 +642,15 @@ static bool avfoundation_record_push_audio(void *data, /* Append on encoding queue */ dispatch_sync(handle->encodingQueue, ^{ - if (handle->audioInput.readyForMoreMediaData) - [handle->audioInput appendSampleBuffer:sampleBuffer]; + if (handle->assetWriter.status == AVAssetWriterStatusWriting + && handle->audioInput.readyForMoreMediaData) + success = [handle->audioInput appendSampleBuffer:sampleBuffer]; }); CFRelease(sampleBuffer); } - return true; + return success; } static bool avfoundation_record_finalize(void *data) diff --git a/record/drivers/record_avfoundation_audio.h b/record/drivers/record_avfoundation_audio.h index 83c6394fc5..26b8692da4 100644 --- a/record/drivers/record_avfoundation_audio.h +++ b/record/drivers/record_avfoundation_audio.h @@ -8,28 +8,18 @@ static double avfoundation_nearest_aac_sample_rate(double rate) { AudioFormatPropertyID prop = kAudioFormatProperty_AvailableEncodeSampleRates; - AudioStreamBasicDescription desc; + UInt32 format = kAudioFormatMPEG4AAC; UInt32 size = 0; OSStatus status; - desc.mFormatID = kAudioFormatMPEG4AAC; - desc.mSampleRate = 0; - desc.mFormatFlags = 0; - desc.mBytesPerPacket = 0; - desc.mFramesPerPacket = 0; - desc.mBytesPerFrame = 0; - desc.mChannelsPerFrame = 0; - desc.mBitsPerChannel = 0; - desc.mReserved = 0; - - status = AudioFormatGetPropertyInfo(prop, sizeof(desc), &desc, &size); + status = AudioFormatGetPropertyInfo(prop, sizeof(format), &format, &size); if (status == noErr && size > 0) { UInt32 capacity = size; AudioValueRange *ranges = (AudioValueRange*)malloc(size); if (ranges) { - status = AudioFormatGetProperty(prop, sizeof(desc), &desc, + status = AudioFormatGetProperty(prop, sizeof(format), &format, &size, ranges); if (status == noErr && size <= capacity && !(size % sizeof(AudioValueRange))) { diff --git a/samples/record/videotoolbox_encode/Makefile b/samples/record/videotoolbox_encode/Makefile index f161f67063..996a294991 100644 --- a/samples/record/videotoolbox_encode/Makefile +++ b/samples/record/videotoolbox_encode/Makefile @@ -9,7 +9,7 @@ ifneq ($(SANITIZER),) CFLAGS += -fsanitize=$(SANITIZER) -fno-omit-frame-pointer LDFLAGS += -fsanitize=$(SANITIZER) endif -all: session_gpu_refusal_test$(EXE) av_session_test$(EXE) crop_test$(EXE) av_worker_test$(EXE) audio_queue_test$(EXE) slots_test$(EXE) session_test$(EXE) worker_test$(EXE) audio_rate_test$(EXE) +all: session_gpu_refusal_test$(EXE) av_session_test$(EXE) crop_test$(EXE) av_worker_test$(EXE) audio_queue_test$(EXE) slots_test$(EXE) session_test$(EXE) worker_test$(EXE) audio_rate_test$(EXE) audio_rate_native_test$(EXE) av_session_thread_test: av_session_thread_test.c $(RA)/record/record_avfoundation_session.c $(CC) $(CFLAGS) -pthread -o $@ av_session_thread_test.c $(RA)/record/record_avfoundation_session.c $(RA)/record/record_avfoundation_worker.c $(RA)/record/record_gpu_worker_vt.c $(RA)/record/record_gpu_queue.c $(RA)/record/record_audio_queue.c $(RA)/libretro-common/queues/retro_spsc.c $(LDFLAGS) av_session_test$(EXE): av_worker_test.c $(RA)/record/record_avfoundation_session.c $(RA)/record/record_avfoundation_session.h @@ -22,6 +22,10 @@ audio_thread_test: audio_queue_test.c $(RA)/record/record_audio_queue.c $(RA)/re $(CC) $(CFLAGS) -DRECORD_AUDIO_THREAD_TEST -pthread -o $@ audio_queue_test.c $(RA)/record/record_audio_queue.c $(RA)/libretro-common/queues/retro_spsc.c $(LDFLAGS) audio_rate_test$(EXE): audio_rate_test.c mock/AudioToolbox/AudioToolbox.h $(RA)/record/drivers/record_avfoundation_audio.h $(CC) $(CFLAGS) -Imock -o $@ $< $(LDFLAGS) +audio_rate_native_test$(EXE): audio_rate_native_test.c $(RA)/record/drivers/record_avfoundation_audio.h + $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) $(if $(filter Darwin,$(shell uname -s)),-framework AudioToolbox,) +native-audio-check: audio_rate_native_test$(EXE) + $(RUNNER) ./audio_rate_native_test$(EXE) crop_test$(EXE): crop_test.c $(RA)/libretro-common/formats/h265/rh265_sps_crop.c $(RA)/libretro-common/include/formats/rh265_sps_crop.h $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) worker_test$(EXE): worker_test.c $(RA)/record/record_gpu_worker_vt.c $(RA)/record/record_gpu_worker_vt.h $(RA)/record/record_gpu_queue.c @@ -36,7 +40,7 @@ thread_test: slots_test.c $(RA)/gfx/common/videotoolbox_encode_slots.h $(CC) $(CFLAGS) -DVT_ENCODE_THREAD_TEST -pthread -o $@ $< $(LDFLAGS) worker_thread_test: worker_thread_test.c $(RA)/record/record_gpu_worker_vt.c $(RA)/record/record_gpu_worker_vt.h $(RA)/record/record_gpu_queue.c $(CC) $(CFLAGS) -pthread -o $@ worker_thread_test.c $(RA)/record/record_gpu_worker_vt.c $(RA)/record/record_gpu_queue.c $(RA)/libretro-common/queues/retro_spsc.c $(LDFLAGS) -check: all +check: all legacy-check $(RUNNER) ./session_gpu_refusal_test$(EXE) $(RUNNER) ./av_session_test$(EXE) $(RUNNER) ./crop_test$(EXE) @@ -54,4 +58,7 @@ clean: rm -f audio_queue_test audio_queue_test.exe audio_thread_test rm -f slots_test slots_test.exe session_test session_test.exe worker_test worker_test.exe thread_test worker_thread_test rm -f audio_rate_test audio_rate_test.exe -.PHONY: all check clean + rm -f audio_rate_native_test audio_rate_native_test.exe +legacy-check: legacy_append_run.py legacy_append_test.c $(RA)/record/drivers/record_avfoundation.m + CC="$(CC)" CFLAGS="$(CFLAGS)" LDFLAGS="$(LDFLAGS)" RUNNER="$(RUNNER)" python3 legacy_append_run.py +.PHONY: all check clean legacy-check native-audio-check diff --git a/samples/record/videotoolbox_encode/audio_rate_native_test.c b/samples/record/videotoolbox_encode/audio_rate_native_test.c new file mode 100644 index 0000000000..2188aa3fd2 --- /dev/null +++ b/samples/record/videotoolbox_encode/audio_rate_native_test.c @@ -0,0 +1,70 @@ +/* Copyright (C) 2026 The RetroArch team. SPDX-License-Identifier: GPL-3.0-or-later */ +#include +#ifdef __APPLE__ +#include +#include +#include + +static unsigned info_calls, value_calls; +static OSStatus checked_info(AudioFormatPropertyID property, UInt32 bytes, + const void *input, UInt32 *size) +{ + assert(property == kAudioFormatProperty_AvailableEncodeSampleRates); + assert(bytes == sizeof(UInt32) && input + && *(const UInt32*)input == kAudioFormatMPEG4AAC); + info_calls++; + return AudioFormatGetPropertyInfo(property, bytes, input, size); +} +static OSStatus checked_value(AudioFormatPropertyID property, UInt32 bytes, + const void *input, UInt32 *size, void *output) +{ + assert(property == kAudioFormatProperty_AvailableEncodeSampleRates); + assert(bytes == sizeof(UInt32) && input + && *(const UInt32*)input == kAudioFormatMPEG4AAC); + value_calls++; + return AudioFormatGetProperty(property, bytes, input, size, output); +} +#define AudioFormatGetPropertyInfo checked_info +#define AudioFormatGetProperty checked_value +#include "../../../record/drivers/record_avfoundation_audio.h" +#undef AudioFormatGetPropertyInfo +#undef AudioFormatGetProperty + +int main(void) +{ + static const double inputs[] = {8000, 22050, 44100, 48000}; + UInt32 format = kAudioFormatMPEG4AAC; + UInt32 size = 0, capacity; + AudioValueRange *ranges; + unsigned i, j; + assert(!AudioFormatGetPropertyInfo( + kAudioFormatProperty_AvailableEncodeSampleRates, + sizeof(format), &format, &size)); + assert(size && !(size % sizeof(*ranges))); + capacity = size; + ranges = (AudioValueRange*)malloc(size); + assert(ranges); + assert(!AudioFormatGetProperty(kAudioFormatProperty_AvailableEncodeSampleRates, + sizeof(format), &format, &size, ranges)); + assert(size && size <= capacity && !(size % sizeof(*ranges))); + for (i = 0; i < sizeof(inputs) / sizeof(inputs[0]); i++) + { + double rate = avfoundation_nearest_aac_sample_rate(inputs[i]); + int found = 0; + for (j = 0; j < size / sizeof(*ranges); j++) + if (rate >= ranges[j].mMinimum && rate <= ranges[j].mMaximum) + found = 1; + assert(found && info_calls == i + 1 && value_calls == i + 1); + printf("AAC source %.0f Hz selects advertised output %.0f Hz\n", inputs[i], rate); + } + free(ranges); + puts("PASS: native AudioToolbox property ABI and advertised AAC rates; no encode claim"); + return 0; +} +#else +int main(void) +{ + puts("UNAVAILABLE: native AudioToolbox requires Apple"); + return 77; +} +#endif diff --git a/samples/record/videotoolbox_encode/audio_rate_test.c b/samples/record/videotoolbox_encode/audio_rate_test.c index 79fe280d8b..ff5a4cf7f1 100644 --- a/samples/record/videotoolbox_encode/audio_rate_test.c +++ b/samples/record/videotoolbox_encode/audio_rate_test.c @@ -8,14 +8,16 @@ static unsigned mode,allocation_fail; static AudioValueRange rates[2]={{32000,32000},{48000,48000}}; OSStatus AudioFormatGetPropertyInfo(AudioFormatPropertyID property,UInt32 bytes,const void *input,UInt32 *size) { - const AudioStreamBasicDescription *desc=(const AudioStreamBasicDescription*)input; - assert(property==1 && bytes==sizeof(*desc) && desc->mFormatID==2 - && !desc->mReserved && !desc->mSampleRate); + assert(property==kAudioFormatProperty_AvailableEncodeSampleRates + && bytes==sizeof(UInt32) && input + && *(const UInt32*)input==kAudioFormatMPEG4AAC); *size=sizeof(rates); return mode==1?-1:0; } OSStatus AudioFormatGetProperty(AudioFormatPropertyID property,UInt32 bytes,const void *input,UInt32 *size,void *output) { - (void)property; (void)bytes; (void)input; + assert(property==kAudioFormatProperty_AvailableEncodeSampleRates + && bytes==sizeof(UInt32) && input + && *(const UInt32*)input==kAudioFormatMPEG4AAC); assert(*size==sizeof(rates)); if (mode==3) return -1; memcpy(output,rates,mode==2?sizeof(rates[0]):sizeof(rates)); @@ -40,6 +42,11 @@ int main(void) allocation_fail=0; rates[0].mMinimum=rates[0].mMaximum=0; rates[1].mMinimum=48000; rates[1].mMaximum=32000; assert(avfoundation_nearest_aac_sample_rate(22050)==22050); + mode=0; + rates[0].mMinimum=rates[0].mMaximum=8000; + rates[1].mMinimum=rates[1].mMaximum=22050; + assert(avfoundation_nearest_aac_sample_rate(8000)==8000); + assert(avfoundation_nearest_aac_sample_rate(22050)==22050); puts("PASS: AAC rate ranges, actual response size, malformed ranges and fallback failures"); return 0; } diff --git a/samples/record/videotoolbox_encode/legacy_append_run.py b/samples/record/videotoolbox_encode/legacy_append_run.py new file mode 100644 index 0000000000..2a18f1417a --- /dev/null +++ b/samples/record/videotoolbox_encode/legacy_append_run.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Run the recorder's submission control with portable Apple message stand-ins.""" +import argparse +import os +from pathlib import Path +import re +import shlex +import subprocess +import tempfile + +here = Path(__file__).resolve().parent +root = here.parents[2] +parser = argparse.ArgumentParser() +parser.add_argument('--source', type=Path, default=root / 'record/drivers/record_avfoundation.m') +args = parser.parse_args() +source = args.source.read_text(encoding='utf-8') +fragments = [] +for kind, buffer, extra in [('video', 'pixelBuffer', ', int presentationTime'), + ('audio', 'sampleBuffer', '')]: + start = source.index('static bool avfoundation_record_push_' + kind + '(') + end = source.index('\nstatic bool ', start + 1) + function = source[start:end].rstrip() + tail = function[function.index(' /* Append'):] + declaration = re.search(r'__block bool success\s*=\s*false;', function) + prefix = ('static bool legacy_' + kind + '_submit(record_avfoundation_t *handle, void *' + + buffer + extra + ')\n{\n' + + (declaration.group(0) + '\n' if declaration else '') + ' {\n') + fragments.append(prefix + tail) +start = source.find(' if (![handle->assetWriter canApplyOutputSettings:audioSettings') +body = '' +if start >= 0: + body = source[start:source.index(' /* Create audio input */', start)] +fragments.append('static void *legacy_audio_settings(record_avfoundation_t *handle, ' + 'NSMutableDictionary *audioSettings)\n{\n' + body + + '\n return audioSettings;\n}\n') +code = '\n'.join(fragments).replace('__block ', '') +code = code.replace('dispatch_sync(handle->encodingQueue, ^{', '{').replace('});', '}') +for kind in ('video', 'audio'): + code = code.replace('handle->' + kind + 'Input.readyForMoreMediaData', + 'handle->' + kind + 'Input->ready') +code = code.replace('handle->assetWriter.status', 'handle->assetWriter->status') +code = re.sub(r'\[handle->pixelBufferAdaptor appendPixelBuffer:pixelBuffer\s+' + r'withPresentationTime:presentationTime\]', + 'append_video(handle->pixelBufferAdaptor, pixelBuffer, presentationTime)', code) +code = code.replace('[handle->audioInput appendSampleBuffer:sampleBuffer]', + 'append_audio(handle->audioInput, sampleBuffer)') +code = re.sub(r'\[handle->assetWriter canApplyOutputSettings:audioSettings\s+' + r'forMediaType:AVMediaTypeAudio\]', 'can_apply(handle->assetWriter, audioSettings)', code) +code = code.replace('[NSMutableDictionary dictionaryWithDictionary:audioSettings]', + 'copy_settings(audioSettings)') +code = code.replace('[fallback removeObjectForKey:AVEncoderBitRateKey]', + 'remove_bitrate(fallback, AVEncoderBitRateKey)') +assert not any(token in code for token in ('dispatch_sync', '^{', '[handle', '[fallback', '[NSMutableDictionary')) +compiler = shlex.split(os.environ.get('CC', 'cc')) +flags = shlex.split(os.environ.get('CFLAGS', '-std=c89 -pedantic -Wall -Wextra -Werror')) +with tempfile.TemporaryDirectory(prefix='avf-append-') as temp: + temp = Path(temp) + (temp / 'legacy_append.inc').write_text(code, encoding='utf-8') + suffix = '.exe' if os.name == 'nt' or 'mingw' in compiler[0] else '' + binary = temp / ('append' + suffix) + command = compiler + flags + ['-I' + str(temp), '-I' + str(root / 'libretro-common/include'), + str(here / 'legacy_append_test.c'), '-o', str(binary)] + command += shlex.split(os.environ.get('LDFLAGS', '')) + subprocess.run(command, cwd=here, check=True) + subprocess.run(shlex.split(os.environ.get('RUNNER', '')) + [str(binary)], + cwd=here, check=True, timeout=60) diff --git a/samples/record/videotoolbox_encode/legacy_append_test.c b/samples/record/videotoolbox_encode/legacy_append_test.c new file mode 100644 index 0000000000..11ef0c494b --- /dev/null +++ b/samples/record/videotoolbox_encode/legacy_append_test.c @@ -0,0 +1,124 @@ +/* Copyright (C) 2026 The RetroArch team. SPDX-License-Identifier: GPL-3.0-or-later */ +#include +#include +#include +#include + +enum { AVAssetWriterStatusWriting = 1, AVEncoderBitRateKey = 1 }; +typedef struct +{ + int status; + bool preset_supported, default_supported; + unsigned probes; +} writer_t; +typedef struct +{ + bool ready, accepted; + unsigned calls; +} input_t; +typedef struct +{ + writer_t *assetWriter; + input_t *videoInput, *audioInput, *pixelBufferAdaptor; +} record_avfoundation_t; +typedef struct { bool bitrate; } NSMutableDictionary; +static NSMutableDictionary fallback_settings; +static unsigned released, freed, removed; + +static void release_sample(void *sample) +{ + assert(sample); + released++; +} +#define CVPixelBufferRelease release_sample +#define CFRelease release_sample +static bool append_video(input_t *input, void *sample, int timestamp) +{ + assert(sample && timestamp == 7); + input->calls++; + return input->accepted; +} +static bool append_audio(input_t *input, void *sample) +{ + assert(sample); + input->calls++; + return input->accepted; +} +static bool can_apply(writer_t *writer, NSMutableDictionary *settings) +{ + writer->probes++; + return settings->bitrate ? writer->preset_supported + : writer->default_supported; +} +static NSMutableDictionary *copy_settings(NSMutableDictionary *settings) +{ + fallback_settings = *settings; + return &fallback_settings; +} +static void remove_bitrate(NSMutableDictionary *settings, int key) +{ + assert(key == AVEncoderBitRateKey); + settings->bitrate = false; + removed++; +} +static void avfoundation_release_handle(record_avfoundation_t *handle) +{ + assert(handle); + freed++; +} +static void trace(const char *format, ...) +{ + assert(format); +} +#define RARCH_ERR trace +#define RARCH_WARN trace +#include "legacy_append.inc" + +int main(void) +{ + writer_t writer = {0}; + input_t input = {0}; + record_avfoundation_t handle; + NSMutableDictionary settings; + unsigned status, ready, accepted, audio, cases = 0; + handle.assetWriter = &writer; + handle.videoInput = handle.audioInput = handle.pixelBufferAdaptor = &input; + for (audio = 0; audio < 2; audio++) + for (status = 0; status < 5; status++) + for (ready = 0; ready < 2; ready++) + for (accepted = 0; accepted < 2; accepted++) + { + bool expected = status == AVAssetWriterStatusWriting + && ready && accepted; + bool result; + writer.status = (int)status; + input.ready = ready != 0; + input.accepted = accepted != 0; + input.calls = released = 0; + result = audio ? legacy_audio_submit(&handle, &input) + : legacy_video_submit(&handle, &input, 7); + assert(result == expected && released == 1); + assert(input.calls == (unsigned)(status + == AVAssetWriterStatusWriting && ready)); + cases++; + } + for (ready = 0; ready < 2; ready++) + for (accepted = 0; accepted < 2; accepted++) + { + void *result; + settings.bitrate = true; + writer.preset_supported = ready != 0; + writer.default_supported = accepted != 0; + writer.probes = freed = removed = 0; + result = legacy_audio_settings(&handle, &settings); + assert(!!result == !!(ready || accepted)); + assert(settings.bitrate); + assert(writer.probes == (ready ? 1u : 2u)); + assert(removed == (ready ? 0u : 1u)); + assert(freed == (unsigned)(!ready && !accepted)); + if (result) + assert(((NSMutableDictionary*)result)->bitrate == (ready != 0)); + } + printf("PASS: %u production append/result cases and AAC preset/default/refusal paths; Apple messages are stand-ins\n", cases); + return 0; +}