feat(ios): add audio keep-alive utilities

This commit is contained in:
ajaysehwal
2026-06-04 10:52:37 -04:00
committed by Evan Kaloudis
parent 4efa7c8ae3
commit 7853a04b68
3 changed files with 178 additions and 8 deletions
-7
View File
@@ -4,13 +4,6 @@
/**
* NWCAudioKeepAlive
*
* Experimental iOS module that uses AVAudioSession (.playback category) and a
* looping silent AVAudioEngine buffer to keep the app alive in the background,
* enabling persistent WebSocket (Nostr relay / NWC) connections.
*
* NOTE: This technique is for experimentation only and may be rejected by App
* Store review if the audio session is deemed non-functional.
*
* Emitted events (listen via NativeEventEmitter):
* - NWCAudioInterrupted Audio session was interrupted (e.g. phone call)
* - NWCAudioInterruptionEnded Audio interruption ended; session will resume
-1
View File
@@ -83,7 +83,6 @@ RCT_EXPORT_MODULE();
[self unregisterNotifications];
}
// ─── Exported methods ────────────────────────────────────────────────────────
/**
* Configures AVAudioSession with .playback category (mixWithOthers so we
+178
View File
@@ -0,0 +1,178 @@
import { NativeModules, NativeEventEmitter, Platform } from 'react-native';
export interface AudioKeepAliveStatus {
isActive: boolean;
engineRunning: boolean;
/** Seconds the audio session has been alive */
uptimeSeconds: number;
/** Seconds spent in background since last backgrounding */
backgroundDuration: number;
disconnectCount: number;
lastDisconnectReason: string;
iosVersion: string;
deviceModel: string;
currentOutput: string;
}
export interface AudioInterruptedPayload {
reason: string;
disconnectCount: number;
uptimeSeconds: number;
}
export interface AudioInterruptionEndedPayload {
shouldResume: boolean;
uptimeSeconds: number;
}
export interface AudioRouteChangedPayload {
reason: string;
currentOutput: string;
uptimeSeconds: number;
}
export interface AudioStatusUpdatePayload extends AudioKeepAliveStatus {
reason?: string;
suspectedSuspension?: boolean;
}
export type AudioKeepAliveEventType =
| 'NWCAudioInterrupted'
| 'NWCAudioInterruptionEnded'
| 'NWCAudioRouteChanged'
| 'NWCAudioStatusUpdate'
| 'NWCAudioSuspended';
interface NWCAudioKeepAliveModule {
startAudioKeepAlive(): Promise<AudioKeepAliveStatus>;
stopAudioKeepAlive(): Promise<AudioKeepAliveStatus>;
getStatus(): Promise<AudioKeepAliveStatus>;
addListener(eventType: string): void;
removeListeners(count: number): void;
}
class IOSAudioKeepAliveUtils {
private nativeModule: NWCAudioKeepAliveModule | null = null;
private emitter: NativeEventEmitter | null = null;
private moduleChecked = false;
private getModule(): NWCAudioKeepAliveModule | null {
if (Platform.OS !== 'ios') return null;
if (!this.moduleChecked) {
this.moduleChecked = true;
const mod = NativeModules.NWCAudioKeepAlive as
| NWCAudioKeepAliveModule
| undefined;
if (mod) {
this.nativeModule = mod;
this.emitter = new NativeEventEmitter(mod as any);
console.log('[NWCAudio] Native module loaded');
} else {
console.error('[NWCAudio] Native module not found');
}
}
return this.nativeModule;
}
private getEmitter(): NativeEventEmitter | null {
this.getModule();
return this.emitter;
}
async start(): Promise<AudioKeepAliveStatus | null> {
const mod = this.getModule();
if (!mod) return null;
try {
const status = await mod.startAudioKeepAlive();
console.log(
`[NWCAudio] Started iOS ${status.iosVersion} on ${status.deviceModel}`
);
return status;
} catch (e) {
console.error('[NWCAudio] start() failed:', e);
return null;
}
}
async stop(): Promise<AudioKeepAliveStatus | null> {
const mod = this.getModule();
if (!mod) return null;
try {
const status = await mod.stopAudioKeepAlive();
console.log(
`[NWCAudio] Stopped uptime ${status.uptimeSeconds.toFixed(
0
)}s`
);
return status;
} catch (e) {
console.error('[NWCAudio] stop() failed:', e);
return null;
}
}
async getStatus(): Promise<AudioKeepAliveStatus | null> {
const mod = this.getModule();
if (!mod) return null;
try {
return await mod.getStatus();
} catch (e) {
console.error('[NWCAudio] getStatus() failed:', e);
return null;
}
}
onInterrupted(
handler: (payload: AudioInterruptedPayload) => void
): (() => void) | null {
const emitter = this.getEmitter();
if (!emitter) return null;
const sub = emitter.addListener('NWCAudioInterrupted', handler);
return () => sub.remove();
}
onInterruptionEnded(
handler: (payload: AudioInterruptionEndedPayload) => void
): (() => void) | null {
const emitter = this.getEmitter();
if (!emitter) return null;
const sub = emitter.addListener('NWCAudioInterruptionEnded', handler);
return () => sub.remove();
}
onRouteChanged(
handler: (payload: AudioRouteChangedPayload) => void
): (() => void) | null {
const emitter = this.getEmitter();
if (!emitter) return null;
const sub = emitter.addListener('NWCAudioRouteChanged', handler);
return () => sub.remove();
}
onStatusUpdate(
handler: (payload: AudioStatusUpdatePayload) => void
): (() => void) | null {
const emitter = this.getEmitter();
if (!emitter) return null;
const sub = emitter.addListener('NWCAudioStatusUpdate', handler);
return () => sub.remove();
}
onSuspended(
handler: (payload: { reason: string; uptimeSeconds: number }) => void
): (() => void) | null {
const emitter = this.getEmitter();
if (!emitter) return null;
const sub = emitter.addListener('NWCAudioSuspended', handler);
return () => sub.remove();
}
isAvailable(): boolean {
return this.getModule() !== null;
}
}
export default new IOSAudioKeepAliveUtils();