From 0d439c22b15c3e01013759fc3a0a1ce3b12c3d18 Mon Sep 17 00:00:00 2001 From: Oliver Ames Date: Wed, 28 Oct 2026 21:27:12 -0400 Subject: [PATCH] Experiment with foreground audio-route recovery --- dm/host/tests/Makefile | 6 ++ dm/host/tests/t_ios_audio_route.c | 19 +++++ dm/ios/Sources/DmAudioRoutePolicy.h | 9 +++ dm/ios/Sources/front_ios.m | 19 +++++ dm/ios/tests/audio_route_check.m | 92 +++++++++++++++++++++++++ dm/ios/tools/build_audio_route_probe.py | 66 ++++++++++++++++++ docs/agents/symptoms.md | 1 + docs/env_switches.tsv | 1 + docs/reference/audio.md | 21 ++++++ 9 files changed, 234 insertions(+) create mode 100644 dm/host/tests/t_ios_audio_route.c create mode 100644 dm/ios/Sources/DmAudioRoutePolicy.h create mode 100644 dm/ios/tests/audio_route_check.m create mode 100644 dm/ios/tools/build_audio_route_probe.py diff --git a/dm/host/tests/Makefile b/dm/host/tests/Makefile index 499e40a4e4..64a8196f0b 100644 --- a/dm/host/tests/Makefile +++ b/dm/host/tests/Makefile @@ -7,6 +7,12 @@ CLANG = clang $(SAN) # stderr that the plain run hides is kept in sanitizer builds: the sanitizers report there QUIET = $(if $(strip $(SAN)),,2>/dev/null) .DEFAULT_GOAL := run +.PHONY: ios-audio-route +ios-audio-route: $(B)/t_ios_audio_route + $(B)/t_ios_audio_route +$(B)/t_ios_audio_route: t_ios_audio_route.c ../../ios/Sources/DmAudioRoutePolicy.h | $(B) + $(CLANG) -O1 -Wall -Wextra -Werror -o $@ $< +run: ios-audio-route # game profile header (dm.h needs game_profile.h, generated by the host Makefile into ../gen//) # tests that use the game profile need GAME= (there is no default game); GINC only expands when used, so tests without the profile build as before GINC = $(if $(strip $(GAME)),-I../gen/$(GAME) -I../../games/$(GAME),$(error GAME is not set: make GAME=: $(shell python3 ../../games/game.py list | awk -F'\t' '{printf "%s (%s) ", $$1, $$3}'))) diff --git a/dm/host/tests/t_ios_audio_route.c b/dm/host/tests/t_ios_audio_route.c new file mode 100644 index 0000000000..fabc00fb3d --- /dev/null +++ b/dm/host/tests/t_ios_audio_route.c @@ -0,0 +1,19 @@ +#include +#include "../../ios/Sources/DmAudioRoutePolicy.h" + +int main(void) { + int failures = 0, checks = 0; + #define CHECK(name, value) do { checks++; if (!(value)) { failures++; fprintf(stderr, "FAIL: %s\n", name); } } while (0) + const unsigned routes[] = {1, 2, 4, 6, 7, 8}; + for (unsigned i = 0; i < sizeof routes / sizeof *routes; i++) { + CHECK("failed foreground activation retries on route events", dm_audio_route_retry(routes[i], 0, 0, 1)); + CHECK("background never activates audio", !dm_audio_route_retry(routes[i], 1, 0, 1)); + CHECK("interruption never activates audio", !dm_audio_route_retry(routes[i], 0, 1, 1)); + CHECK("healthy route keeps native RemoteIO handling", !dm_audio_route_retry(routes[i], 0, 0, 0)); + } + CHECK("own category notification cannot recurse", !dm_audio_route_retry(3, 0, 0, 1)); + CHECK("unknown notification does not guess", !dm_audio_route_retry(0, 0, 0, 1)); + CHECK("future notification does not guess", !dm_audio_route_retry(99, 0, 0, 1)); + printf("audio route policy: %d checks, %d failed\n", checks, failures); + return failures != 0; +} diff --git a/dm/ios/Sources/DmAudioRoutePolicy.h b/dm/ios/Sources/DmAudioRoutePolicy.h new file mode 100644 index 0000000000..ea7d640dea --- /dev/null +++ b/dm/ios/Sources/DmAudioRoutePolicy.h @@ -0,0 +1,9 @@ +// Route changes can retry a failed activation while the game remains foreground. +// Category changes caused by our own setup must not recurse into activation. +#pragma once +static inline int dm_audio_route_retry(unsigned reason, int background, int interrupted, int session_off) { + if (background || interrupted || !session_off) return 0; + // AVAudioSessionRouteChangeReason: new/old device, override, wake, + // no suitable route, or route configuration. Unknown/category are excluded. + return reason == 1 || reason == 2 || reason == 4 || reason == 6 || reason == 7 || reason == 8; +} diff --git a/dm/ios/Sources/front_ios.m b/dm/ios/Sources/front_ios.m index 45aa526bed..92f5cd2981 100644 --- a/dm/ios/Sources/front_ios.m +++ b/dm/ios/Sources/front_ios.m @@ -9,6 +9,7 @@ #include "hle.h" #include "dm.h" #include "haptics_apple.h" +#include "DmAudioRoutePolicy.h" UIView *dm_gamepad_view(CGRect frame); void dm_gamepad_reset(void); @@ -243,6 +244,24 @@ void front_ios_audio_session(void) { static void front_ios_audio_apply(void) { dm_audio_suspend(g_audio_bg || g_audio_intr); } static void front_ios_audio_watch(void) { NSNotificationCenter *nc = NSNotificationCenter.defaultCenter; + // Experimental, default off. A failed foreground activation otherwise only + // retries on returning to the app, even if CarPlay/headphones change route. + const char *route_recovery = getenv("DM_IOS_AUDIO_ROUTE_RECOVERY"); + if (route_recovery && !strcmp(route_recovery, "1")) { + [nc addObserverForName:AVAudioSessionRouteChangeNotification object:nil queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification *n) { + unsigned reason = [n.userInfo[AVAudioSessionRouteChangeReasonKey] unsignedIntValue]; + AVAudioSession *s = AVAudioSession.sharedInstance; + LOG("audio route: reason %u, outputs %s, %.0f Hz, %.2f ms, activation %s", reason, + s.currentRoute.outputs.description.UTF8String, s.sampleRate, s.IOBufferDuration * 1000, + g_session_off ? "failed" : "active"); + if (dm_audio_route_retry(reason, g_audio_bg, g_audio_intr, g_session_off)) { + front_ios_audio_session(); + // Restart a silent fallback only after the native session is + // usable. Healthy route changes keep RemoteIO's own handling. + if (!g_session_off) dm_audio_reset(); + } + }]; + } [nc addObserverForName:AVAudioSessionInterruptionNotification object:nil queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification *n) { NSUInteger type = [n.userInfo[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue], reason = [n.userInfo[AVAudioSessionInterruptionReasonKey] unsignedIntegerValue]; if (type == AVAudioSessionInterruptionTypeBegan) { LOG("audio session: interrupted (reason %lu)", (unsigned long)reason); g_audio_intr = 1; } diff --git a/dm/ios/tests/audio_route_check.m b/dm/ios/tests/audio_route_check.m new file mode 100644 index 0000000000..84d8f44222 --- /dev/null +++ b/dm/ios/tests/audio_route_check.m @@ -0,0 +1,92 @@ +// Exercise the production notification observer with deterministic activation +// responses. Actual CarPlay/USB/Bluetooth routes are deliberately not simulated. +#import +#import +#import +#include +#include +static int activations, resets; +static BOOL activationFails; +@interface ProbeAudioSession : NSObject ++ (instancetype)sharedInstance; +- (BOOL)setCategory:(NSString *)category error:(NSError **)error; +- (BOOL)setPreferredSampleRate:(double)value error:(NSError **)error; +- (BOOL)setPreferredIOBufferDuration:(double)value error:(NSError **)error; +- (BOOL)setActive:(BOOL)value error:(NSError **)error; +@property(readonly) double sampleRate; +@property(readonly) double IOBufferDuration; +@property(readonly) AVAudioSessionRouteDescription *currentRoute; +@end +@implementation ProbeAudioSession ++ (instancetype)sharedInstance { static ProbeAudioSession *s; static dispatch_once_t once; dispatch_once(&once, ^{ s = [self new]; }); return s; } +- (BOOL)setCategory:(NSString *)category error:(NSError **)error { (void)category; (void)error; return YES; } +- (BOOL)setPreferredSampleRate:(double)value error:(NSError **)error { (void)value; (void)error; return YES; } +- (BOOL)setPreferredIOBufferDuration:(double)value error:(NSError **)error { (void)value; (void)error; return YES; } +- (BOOL)setActive:(BOOL)value error:(NSError **)error { (void)value; activations++; if (activationFails && error) *error = [NSError errorWithDomain:@"verification" code:1 userInfo:nil]; return !activationFails; } +- (double)sampleRate { return 48000; } +- (double)IOBufferDuration { return .005; } +- (AVAudioSessionRouteDescription *)currentRoute { return nil; } +@end +#define AVAudioSession ProbeAudioSession +#include "../Sources/front_ios.m" +#undef AVAudioSession +#define main unused_production_main +#include "../Sources/main.m" +#undef main +uint64_t g_t0_ns; +uint64_t now_ns(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return (uint64_t)t.tv_sec * 1000000000ull + t.tv_nsec; } +int dm_main(int argc, char **argv) { (void)argc; (void)argv; return 0; } +void dm_host_preload_start(int argc, char **argv) { (void)argc; (void)argv; } +void dm_haptics_apple_init(void) {} +UIView *dm_gamepad_view(CGRect f) { return [[UIView alloc] initWithFrame:f]; } +void dm_gamepad_reset(void) {} +bool dm_show_launcher(void *v, void (*d)(void)) { (void)v; (void)d; return false; } +uint64_t mtl_present_count(void) { return 0; } +void dm_audio_suspend(int on) { (void)on; } +void dm_audio_reset(void) { resets++; } +void dm_focus_set(int st) { (void)st; } +void guest_clock_freeze_for(int r, int on) { (void)r; (void)on; } +void dm_gfx_suspend(int on) { (void)on; } +static NSMutableArray *checks; +static void check(NSString *name, BOOL passed) { [checks addObject:@{@"name":name,@"passed":@(passed)}]; fprintf(stderr,"%s: %s\n",name.UTF8String,passed?"PASS":"FAIL"); } +static void route(unsigned reason) { [NSNotificationCenter.defaultCenter postNotificationName:AVAudioSessionRouteChangeNotification object:nil userInfo:@{AVAudioSessionRouteChangeReasonKey:@(reason)}]; } +static void runChecks(void) { + checks = [NSMutableArray array]; + unsetenv("DM_IOS_AUDIO_ROUTE_RECOVERY"); front_ios_audio_watch(); g_session_off=1; route(1); + check(@"default off leaves route behavior unchanged",activations==0 && resets==0 && g_session_off); + setenv("DM_IOS_AUDIO_ROUTE_RECOVERY","1",1); front_ios_audio_watch(); + route(1); check(@"failed foreground session reactivates and rebuilds outputs",activations==1 && resets==1 && !g_session_off); + route(2); check(@"healthy route change leaves RemoteIO handling intact",activations==1 && resets==1); + g_session_off=1; route(3); check(@"own category notification cannot recurse",activations==1 && resets==1); + route(0); route(99); check(@"unknown reasons do not invent recovery",activations==1); + g_audio_bg=1; route(1); check(@"background does not activate audio",activations==1); + g_audio_bg=0; g_audio_intr=1; route(1); check(@"interrupted session does not activate audio",activations==1); + g_audio_intr=0; activationFails=YES; route(8); + check(@"failed recovery stays failed without resetting outputs",activations==2 && resets==1 && g_session_off); + activationFails=NO; route(8); + check(@"later route event can recover without foreground cycling",activations==3 && resets==2 && !g_session_off); + NSUInteger failed=0; for (NSDictionary *c in checks) failed += ![c[@"passed"] boolValue]; + NSDictionary *report=@{@"checks":checks,@"failed":@(failed),@"physicalAudioRouteTested":@NO,@"productionAudioObserverUsed":@YES,@"nativeActivationMocked":@YES,@"systemVersion":UIDevice.currentDevice.systemVersion}; + NSURL *docs=[NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0]; + [[NSJSONSerialization dataWithJSONObject:report options:NSJSONWritingPrettyPrinted error:nil] writeToURL:[docs URLByAppendingPathComponent:@"results.json"] atomically:YES]; +} +@interface AudioRouteScene : NSObject +@property(strong,nonatomic) UIWindow *window; +@end +@implementation AudioRouteScene +- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)options { + (void)session; (void)options; + self.window=[[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene]; + UIViewController *vc=[UIViewController new]; self.window.rootViewController=vc; [self.window makeKeyAndVisible]; + dispatch_async(dispatch_get_main_queue(), ^{ runChecks(); }); +} +@end +@interface AudioRouteApp : UIResponder +@end +@implementation AudioRouteApp +@end +int main(int argc,char **argv) { @autoreleasepool { + NSURL *docs=[NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0]; + freopen([docs URLByAppendingPathComponent:@"audio.log"].fileSystemRepresentation,"w",stderr); setvbuf(stderr,NULL,_IOLBF,0); + return UIApplicationMain(argc,argv,nil,NSStringFromClass(AudioRouteApp.class)); +} } diff --git a/dm/ios/tools/build_audio_route_probe.py b/dm/ios/tools/build_audio_route_probe.py new file mode 100644 index 0000000000..47fbc43179 --- /dev/null +++ b/dm/ios/tools/build_audio_route_probe.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Build an unsigned native iOS audio-route observer probe with synthetic host stubs. + +GAME selects only source profile constants. No game files, keys or signing are +used. Install/run the resulting app separately on an authorized test device. +""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys + + +REPO = Path(__file__).resolve().parents[3] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', required=True, type=Path, help='new scratch directory') + args = parser.parse_args() + game = os.environ.get('GAME') or os.environ.get('DM_GAME') + if not game: + parser.error('GAME or DM_GAME must select a source profile') + output = args.output.resolve() + output.mkdir(parents=True) + include = output / 'Include' + include.mkdir() + subprocess.run([sys.executable, str(REPO / 'dm/games/game.py'), 'header', game, + str(include / 'game_profile.h')], check=True) + project = { + 'name': 'AudioRouteVerification', + 'options': {'deploymentTarget': {'iOS': '26.0'}}, + 'targets': {'AudioRouteVerification': { + 'type': 'application', 'platform': 'iOS', + 'sources': [{'path': str(REPO / 'dm/ios/tests/audio_route_check.m')}], + 'dependencies': [{'sdk': name + '.framework'} for name in + ('UIKit', 'Foundation', 'QuartzCore', 'Metal', 'AVFAudio', 'GameController')], + 'settings': {'base': { + 'PRODUCT_BUNDLE_IDENTIFIER': 'dev.anygame.audio-route-verification', + 'ENABLE_DEBUG_DYLIB': 'NO', 'CODE_SIGN_STYLE': 'Automatic', + 'TARGETED_DEVICE_FAMILY': '1', + 'HEADER_SEARCH_PATHS': [str(REPO / 'dm/host'), str(include)], + }}, + 'info': {'path': 'Info.plist', 'properties': { + 'CFBundleDisplayName': 'Audio Route Verification', 'UILaunchScreen': {}, + 'UIFileSharingEnabled': True, + 'UISupportedInterfaceOrientations': ['UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'], + 'UIApplicationSceneManifest': {'UIApplicationSupportsMultipleScenes': False, + 'UISceneConfigurations': {'UIWindowSceneSessionRoleApplication': [{ + 'UISceneConfigurationName': 'Default', 'UISceneDelegateClassName': 'AudioRouteScene'}]}}, + }}, + }}, + } + spec = output / 'project.yml' + spec.write_text(json.dumps(project, indent=2) + '\n') + subprocess.run(['xcodegen', 'generate', '--spec', str(spec)], check=True) + subprocess.run(['xcodebuild', '-project', str(output / 'AudioRouteVerification.xcodeproj'), + '-scheme', 'AudioRouteVerification', '-configuration', 'Debug', + '-destination', 'generic/platform=iOS', '-derivedDataPath', str(output / 'DerivedData'), + '-jobs', '3', 'CODE_SIGNING_ALLOWED=NO', 'build'], check=True) + print(output / 'DerivedData/Build/Products/Debug-iphoneos/AudioRouteVerification.app') + + +if __name__ == '__main__': + main() diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index 4b6493563f..fb49c0c2ed 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -234,6 +234,7 @@ One line per symptom in the [subsystem references](../reference/): what you see ## Audio +- an iOS session whose activation failed can stay silent while the → [Experimental route recovery](../reference/audio.md#experimental-route-recovery) - abort with Result 0x499 in `audio::GetAudioRendererWorkBufferSize`. → [SDK 8's longer `AudioRendererParameter`](../reference/audio.md#sdk-8s-longer-audiorendererparameter) - the game is silent; only the game's updates render (catch-up frames that nothing plays). The log has `audren: OpenAudioRenderer (no event)` and no `render thread started`. → [A renderer opened without an event](../reference/audio.md#a-renderer-opened-without-an-event) - `[RED] audren: a second renderer` right after the first open, with the same work buffer and "original device 1 mode 1". → [The SDK's open with an event calls the open without one (`t_in_open`)](../reference/audio.md#the-sdks-open-with-an-event-calls-the-open-without-one-t_in_open) diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index c7695d9902..0be018be4c 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -53,6 +53,7 @@ DM_HITCH_DUMP diagnostic `=[:]`: when no frame has been presented for tha DM_HLECOUNT diagnostic `=` (`%d` = process ID) writes at exit a TSV of how many times the guest called each HLE function, zero counts included DM_ICON path `=` gives `build_ipa.sh` another app icon (default the `icon.jpg` beside the RomFS) DM_IMPORTS diagnostic When set, logs each import the loader binds to a host function and each forced takeover +DM_IOS_AUDIO_ROUTE_RECOVERY switch `1` enables experimental iOS route diagnostics and retries failed foreground activation after a recognized route event; default off, no background/interruption activation or category-change recursion DM_KBD switch `=`: the system keyboard returns that text as OK without showing an input box; unset with no window, it returns cancel DM_KBD_PLAYER switch `own` makes the Mac keyboard player 1 on its own, with controllers from player 2 on (by default the first controller shares player 1 with the keyboard) DM_KEYS switch Scripted input: `>:[+key][~hold][*count/interval],…` presses buttons, sticks and `Shake` at those times diff --git a/docs/reference/audio.md b/docs/reference/audio.md index cf8641a182..095b55f02a 100644 --- a/docs/reference/audio.md +++ b/docs/reference/audio.md @@ -2,6 +2,27 @@ The audio renderer (`dm/host/hle_audren.c`), AudioOut, audio devices and codecs. Each entry: symptom, cause, fix, the games it was found on. The [symptom index](../agents/symptoms.md) lists every symptom. +## iOS audio session + +### Experimental route recovery + +- **Symptom:** an iOS session whose activation failed can stay silent while the + game remains foreground, even after an audio route becomes available. The + current retry requires another foreground/active transition. +- **Cause:** the front end observes interruptions and media-service resets but + not route changes. A route change alone cannot retry the failed session. +- **Experiment:** `DM_IOS_AUDIO_ROUTE_RECOVERY=1` logs the actual route, sample + rate and buffer duration. Recognized device/route events retry a failed session + only while foreground and uninterrupted. After successful activation, outputs + are recreated to leave a silent fallback. Healthy routes keep RemoteIO's own + handling. Category-change notifications are excluded to avoid recursion from + our own session setup. Default off; no polling or activation retry timer. +- **Found on:** the activation failure was recorded during the Duo/CarPlay + session. Recovery through actual CarPlay/headphone changes is still unverified; + this experiment does not establish the cause of progressive audio glitches. +- **Check:** `make -C dm/host/tests ios-audio-route` runs the actual event policy + without audio hardware. Native compilation and physical route tests are separate. + ## Audio renderer ### Running the SDK's own renderer on the CPU