@aippy/runtime 0.1.0 → 0.2.0-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,11 +66,33 @@ await pwa.registerServiceWorker('/sw.js');
66
66
  await pwa.sendNotification('Hello!');
67
67
  ```
68
68
 
69
+ ### Audio (iOS Silent Mode Compatible)
70
+
71
+ ```typescript
72
+ import { patchAudioContext } from '@aippy/runtime/audio';
73
+
74
+ // Create and patch AudioContext to bypass iOS silent mode
75
+ const ctx = new AudioContext();
76
+ const patchedCtx = patchAudioContext(ctx);
77
+
78
+ // Unlock audio on user interaction (required on iOS)
79
+ button.onclick = async () => {
80
+ await patchedCtx.unlock();
81
+
82
+ // Use native Web Audio API as normal
83
+ const osc = patchedCtx.createOscillator();
84
+ osc.connect(patchedCtx.destination);
85
+ osc.start();
86
+ osc.stop(patchedCtx.currentTime + 1);
87
+ };
88
+ ```
89
+
69
90
  ## Packages
70
91
 
71
92
  - `@aippy/runtime/core` - Core types and configuration
72
93
  - `@aippy/runtime/device` - Device APIs (camera, geolocation, sensors, file system)
73
94
  - `@aippy/runtime/utils` - Platform detection, performance monitoring, PWA utilities
95
+ - `@aippy/runtime/audio` - iOS-compatible Web Audio API wrapper
74
96
 
75
97
  ## Publishing
76
98
 
@@ -0,0 +1,52 @@
1
+ import { AutoPauseOptions } from './types';
2
+ /**
3
+ * Detects silence in an audio stream and automatically pauses/resumes media element
4
+ * to prevent iOS Safari audio buffer looping bug
5
+ */
6
+ export declare class AudioSilenceDetector {
7
+ private audioContext;
8
+ private mediaElement;
9
+ private analyser;
10
+ private dataArray;
11
+ private rafId;
12
+ private silenceStartTime;
13
+ private isPaused;
14
+ private lastCheckTime;
15
+ private readonly silenceThreshold;
16
+ private readonly silenceDuration;
17
+ private readonly checkInterval;
18
+ private readonly debug;
19
+ constructor(audioContext: AudioContext, mediaElement: HTMLMediaElement, options: Required<AutoPauseOptions>, debug?: boolean);
20
+ /**
21
+ * Connect the detector to the audio stream
22
+ */
23
+ connect(source: AudioNode): void;
24
+ /**
25
+ * Start monitoring audio levels
26
+ */
27
+ start(): void;
28
+ /**
29
+ * Stop monitoring
30
+ */
31
+ stop(): void;
32
+ /**
33
+ * Cleanup resources
34
+ */
35
+ dispose(): void;
36
+ /**
37
+ * Check audio levels and pause/resume as needed
38
+ */
39
+ private check;
40
+ /**
41
+ * Get current audio level (0-1)
42
+ */
43
+ private getAudioLevel;
44
+ /**
45
+ * Pause media element to stop audio output
46
+ */
47
+ private pauseMedia;
48
+ /**
49
+ * Resume media element playback
50
+ */
51
+ private resumeMedia;
52
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Audio module for iOS-compatible Web Audio API
3
+ *
4
+ * This module provides utilities to bypass iOS silent mode restrictions
5
+ * on Web Audio API playback.
6
+ *
7
+ * @module @aippy/runtime/audio
8
+ */
9
+ export { patchAudioContext } from './patchAudioContext';
10
+ export type { AudioContextPatchOptions, AutoPauseOptions, MediaElementType, PatchedAudioContext, } from './types';
11
+ export { createHiddenMediaElement, createHiddenVideoElement, isIOSDevice, isMediaStreamAudioSupported, } from './utils';
12
+ export { useAudioContext } from './useAudioContext';
13
+ export type { UseAudioContextOptions, UseAudioContextReturn } from './useAudioContext';
@@ -0,0 +1,9 @@
1
+ import { c, a, i, b, p, u } from "../useAudioContext-2R1HNn6s.js";
2
+ export {
3
+ c as createHiddenMediaElement,
4
+ a as createHiddenVideoElement,
5
+ i as isIOSDevice,
6
+ b as isMediaStreamAudioSupported,
7
+ p as patchAudioContext,
8
+ u as useAudioContext
9
+ };
@@ -0,0 +1,37 @@
1
+ import { AudioContextPatchOptions, PatchedAudioContext } from './types';
2
+ /**
3
+ * Patches an AudioContext to bypass iOS silent mode restrictions
4
+ *
5
+ * On iOS devices, Web Audio API is affected by the hardware silent switch.
6
+ * This function routes audio through a MediaStreamAudioDestinationNode
7
+ * connected to a hidden video element, which bypasses the restriction.
8
+ *
9
+ * Features:
10
+ * - Auto-pause: Automatically pauses MediaElement when audio becomes silent
11
+ * to prevent iOS Safari audio buffer looping bug (~50ms latency)
12
+ * - Auto-resume: Automatically resumes playback when new audio is detected
13
+ * - Zero user code changes: Use standard Web Audio API after unlock
14
+ *
15
+ * On non-iOS devices, this function returns the original context with
16
+ * minimal modifications (zero overhead).
17
+ *
18
+ * @param audioContext - The AudioContext to patch
19
+ * @param options - Configuration options
20
+ * @returns Enhanced AudioContext with iOS compatibility
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * const ctx = new AudioContext();
25
+ * const patchedCtx = patchAudioContext(ctx);
26
+ *
27
+ * // Unlock on user interaction (iOS only)
28
+ * button.onclick = () => patchedCtx.unlock();
29
+ *
30
+ * // Use native Web Audio API normally
31
+ * const osc = patchedCtx.createOscillator();
32
+ * osc.connect(patchedCtx.destination); // Auto-routed on iOS
33
+ * osc.start();
34
+ * osc.stop(); // Audio will auto-stop ~50ms after silence detected
35
+ * ```
36
+ */
37
+ export declare function patchAudioContext(audioContext: AudioContext, options?: AudioContextPatchOptions): PatchedAudioContext;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Media element type for MediaStream playback
3
+ */
4
+ export type MediaElementType = 'video' | 'audio';
5
+ /**
6
+ * Options for automatic pause/resume behavior
7
+ */
8
+ export interface AutoPauseOptions {
9
+ /**
10
+ * Enable automatic pause when audio becomes silent
11
+ * @default true
12
+ */
13
+ enabled?: boolean;
14
+ /**
15
+ * Silence threshold (0-1). Audio below this level is considered silent
16
+ * @default 0.001
17
+ */
18
+ silenceThreshold?: number;
19
+ /**
20
+ * Duration of silence (ms) before triggering auto-pause
21
+ * @default 50
22
+ */
23
+ silenceDuration?: number;
24
+ /**
25
+ * Interval (ms) for checking audio levels
26
+ * @default 16
27
+ */
28
+ checkInterval?: number;
29
+ }
30
+ /**
31
+ * Options for patching AudioContext
32
+ */
33
+ export interface AudioContextPatchOptions {
34
+ /**
35
+ * Force enable patching even on non-iOS devices
36
+ * @default false
37
+ */
38
+ forceEnable?: boolean;
39
+ /**
40
+ * Automatically cleanup media element when AudioContext is closed
41
+ * @default true
42
+ */
43
+ autoCleanup?: boolean;
44
+ /**
45
+ * Show media element for debugging purposes
46
+ * @default false
47
+ */
48
+ debug?: boolean;
49
+ /**
50
+ * Media element type to use for MediaStream playback
51
+ * Note: 'video' is recommended for better iOS compatibility
52
+ * @default 'video'
53
+ */
54
+ mediaElementType?: MediaElementType;
55
+ /**
56
+ * Auto-pause configuration to prevent audio buffer looping on iOS
57
+ * When enabled, automatically pauses MediaElement when audio becomes silent
58
+ * @default { enabled: true, silenceThreshold: 0.001, silenceDuration: 50, checkInterval: 16 }
59
+ */
60
+ autoPause?: AutoPauseOptions;
61
+ }
62
+ /**
63
+ * Extended AudioContext with iOS compatibility features
64
+ */
65
+ export interface PatchedAudioContext extends AudioContext {
66
+ /**
67
+ * Unlock audio playback on iOS (must be called in user interaction)
68
+ * @returns Promise that resolves when unlocked
69
+ */
70
+ unlock(): Promise<void>;
71
+ /**
72
+ * Manually cleanup patched resources
73
+ */
74
+ cleanup(): void;
75
+ /**
76
+ * Indicates whether the context has been patched
77
+ */
78
+ readonly isPatched: boolean;
79
+ /**
80
+ * Reference to the original AudioDestinationNode
81
+ */
82
+ readonly originalDestination: AudioDestinationNode;
83
+ }
@@ -0,0 +1,68 @@
1
+ import { PatchedAudioContext, AudioContextPatchOptions } from './types';
2
+ /**
3
+ * Options for useAudioContext hook
4
+ */
5
+ export interface UseAudioContextOptions extends AudioContextPatchOptions {
6
+ /**
7
+ * Auto-unlock audio on first user interaction
8
+ * @default true
9
+ */
10
+ autoUnlock?: boolean;
11
+ }
12
+ /**
13
+ * Return type for useAudioContext hook
14
+ */
15
+ export interface UseAudioContextReturn {
16
+ /**
17
+ * Patched AudioContext instance
18
+ */
19
+ audioContext: PatchedAudioContext | null;
20
+ /**
21
+ * Whether audio has been unlocked (ready for playback)
22
+ */
23
+ isUnlocked: boolean;
24
+ /**
25
+ * Manually unlock audio (call during user interaction)
26
+ */
27
+ unlock: () => Promise<void>;
28
+ }
29
+ /**
30
+ * React hook for managing AudioContext lifecycle with iOS silent mode bypass
31
+ *
32
+ * Automatically handles:
33
+ * - AudioContext creation and patching
34
+ * - Auto-pause/resume to prevent iOS audio buffer looping (~50ms latency)
35
+ * - Cleanup on unmount
36
+ * - Optional auto-unlock on first user interaction
37
+ *
38
+ * Note: Built for React 19 - no useCallback needed for optimal performance
39
+ *
40
+ * @param options - Configuration options
41
+ * @returns AudioContext instance, unlock status, and unlock function
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * function AudioComponent() {
46
+ * const { audioContext, isUnlocked, unlock } = useAudioContext();
47
+ *
48
+ * const playSound = async () => {
49
+ * if (!audioContext) return;
50
+ *
51
+ * // Unlock if needed (handles iOS restrictions)
52
+ * if (!isUnlocked) {
53
+ * await unlock();
54
+ * }
55
+ *
56
+ * // Play sound using Web Audio API - just use standard API!
57
+ * const osc = audioContext.createOscillator();
58
+ * osc.connect(audioContext.destination);
59
+ * osc.start();
60
+ * osc.stop(audioContext.currentTime + 0.3);
61
+ * // Audio will auto-stop ~50ms after silence detected
62
+ * };
63
+ *
64
+ * return <button onClick={playSound}>Play Sound</button>;
65
+ * }
66
+ * ```
67
+ */
68
+ export declare function useAudioContext(options?: UseAudioContextOptions): UseAudioContextReturn;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Detects if the current device is running iOS/iPadOS
3
+ * @returns true if device is iOS/iPadOS
4
+ */
5
+ export declare function isIOSDevice(): boolean;
6
+ /**
7
+ * Checks if MediaStreamAudioDestinationNode is supported by the browser
8
+ * @returns true if supported
9
+ */
10
+ export declare function isMediaStreamAudioSupported(): boolean;
11
+ /**
12
+ * Creates a hidden media element for MediaStream playback
13
+ * @param type - Element type ('video' or 'audio')
14
+ * @param debug - If true, makes the element visible for debugging
15
+ * @returns HTMLMediaElement (HTMLVideoElement or HTMLAudioElement)
16
+ */
17
+ export declare function createHiddenMediaElement(type?: 'video' | 'audio', debug?: boolean): HTMLVideoElement | HTMLAudioElement;
18
+ /**
19
+ * @deprecated Use createHiddenMediaElement instead
20
+ * Creates a hidden video element for MediaStream playback
21
+ * @param debug - If true, makes the video element visible for debugging
22
+ * @returns HTMLVideoElement
23
+ */
24
+ export declare function createHiddenVideoElement(debug?: boolean): HTMLVideoElement;
@@ -0,0 +1,2 @@
1
+ export * from './audio/index'
2
+ export {}
@@ -33,7 +33,7 @@ function mergeConfig(userConfig) {
33
33
  }
34
34
  };
35
35
  }
36
- const version = "0.1.0";
36
+ const version = "0.2.0-dev.2";
37
37
  const packageJson = {
38
38
  version
39
39
  };
@@ -3,6 +3,7 @@ import { A, E, c } from "../errors-DAz5_jDJ.js";
3
3
  import { CameraAPI, FileSystemAPI, GeolocationAPI, SensorsAPI, camera, fileSystem, geolocation, sensors, vibrate } from "../device/index.js";
4
4
  import { c as c2, a, P, b, p, d } from "../pwa-BkviTQoN.js";
5
5
  import { a as a2, b as b2 } from "../useTweaks-mK5PAWOs.js";
6
+ import { c as c3, a as a3, i, b as b3, p as p2, u } from "../useAudioContext-2R1HNn6s.js";
6
7
  export {
7
8
  A as AippyRuntimeError,
8
9
  CameraAPI,
@@ -20,14 +21,20 @@ export {
20
21
  b2 as aippyTweaksRuntime,
21
22
  camera,
22
23
  c as createError,
24
+ c3 as createHiddenMediaElement,
25
+ a3 as createHiddenVideoElement,
23
26
  fileSystem,
24
27
  geolocation,
25
28
  getConfigFromEnv,
26
29
  getVersionInfo,
30
+ i as isIOSDevice,
31
+ b3 as isMediaStreamAudioSupported,
27
32
  mergeConfig,
33
+ p2 as patchAudioContext,
28
34
  b as performanceMonitor,
29
35
  p as platform,
30
36
  d as pwa,
31
37
  sensors,
38
+ u as useAudioContext,
32
39
  vibrate
33
40
  };
package/dist/index.d.ts CHANGED
@@ -2,3 +2,4 @@ export * from './core';
2
2
  export * from './device';
3
3
  export * from './utils';
4
4
  export * from './tweaks';
5
+ export * from './audio';
@@ -0,0 +1,52 @@
1
+ declare global {
2
+ interface Window {
3
+ aippyTweaksRuntime?: {
4
+ tweaks: (config: any) => any;
5
+ tweaksInstance?: Record<string, any>;
6
+ };
7
+ processNativeData?: (data: any) => void;
8
+ }
9
+ interface WebKit {
10
+ messageHandlers: {
11
+ aippyListener: {
12
+ postMessage: (data: any) => void;
13
+ };
14
+ };
15
+ }
16
+ }
17
+ export interface TweakItem {
18
+ id: string;
19
+ index: number;
20
+ name: string;
21
+ description: string;
22
+ group?: string;
23
+ type: 'number' | 'color' | 'boolean' | 'text' | 'image';
24
+ value: number | string | boolean;
25
+ }
26
+ export interface NumberTweakItem extends TweakItem {
27
+ type: 'number';
28
+ min?: number;
29
+ max?: number;
30
+ step?: number;
31
+ value: number;
32
+ }
33
+ export interface BooleanTweakItem extends TweakItem {
34
+ type: 'boolean';
35
+ value: boolean;
36
+ }
37
+ export interface TextTweakItem extends TweakItem {
38
+ type: 'text';
39
+ value: string;
40
+ }
41
+ export interface ColorTweakItem extends TweakItem {
42
+ type: 'color';
43
+ value: string;
44
+ }
45
+ export interface ImageTweakItem extends TweakItem {
46
+ type: 'image';
47
+ value: string;
48
+ }
49
+ export type TweakConfig = NumberTweakItem | BooleanTweakItem | TextTweakItem | ColorTweakItem | ImageTweakItem;
50
+ export interface TweaksConfig {
51
+ [key: string]: TweakConfig;
52
+ }
@@ -0,0 +1,343 @@
1
+ import { useRef, useEffect } from "react";
2
+ function isIOSDevice() {
3
+ const userAgent = navigator.userAgent;
4
+ if (/iPad|iPhone|iPod/.test(userAgent)) {
5
+ return true;
6
+ }
7
+ if (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) {
8
+ return true;
9
+ }
10
+ return false;
11
+ }
12
+ function isMediaStreamAudioSupported() {
13
+ try {
14
+ if (!window.AudioContext) {
15
+ return false;
16
+ }
17
+ const tempContext = new AudioContext();
18
+ const hasMethod = typeof tempContext.createMediaStreamDestination === "function";
19
+ tempContext.close();
20
+ return hasMethod;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+ function createHiddenMediaElement(type = "video", debug = false) {
26
+ const element = document.createElement(type);
27
+ element.muted = false;
28
+ element.autoplay = true;
29
+ if (type === "video") {
30
+ element.playsInline = true;
31
+ }
32
+ if (debug) {
33
+ element.style.cssText = "position:fixed;bottom:10px;right:10px;width:200px;background:#ff0000;z-index:9999;";
34
+ } else {
35
+ element.style.cssText = "position:fixed;width:1px;height:1px;opacity:0;pointer-events:none;";
36
+ }
37
+ return element;
38
+ }
39
+ function createHiddenVideoElement(debug = false) {
40
+ return createHiddenMediaElement("video", debug);
41
+ }
42
+ class AudioSilenceDetector {
43
+ constructor(audioContext, mediaElement, options, debug = false) {
44
+ this.audioContext = audioContext;
45
+ this.mediaElement = mediaElement;
46
+ this.silenceThreshold = options.silenceThreshold;
47
+ this.silenceDuration = options.silenceDuration;
48
+ this.checkInterval = options.checkInterval;
49
+ this.debug = debug;
50
+ this.analyser = audioContext.createAnalyser();
51
+ this.analyser.fftSize = 512;
52
+ this.analyser.smoothingTimeConstant = 0.3;
53
+ this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
54
+ }
55
+ analyser;
56
+ dataArray;
57
+ rafId = null;
58
+ silenceStartTime = 0;
59
+ isPaused = false;
60
+ lastCheckTime = 0;
61
+ silenceThreshold;
62
+ silenceDuration;
63
+ checkInterval;
64
+ debug;
65
+ /**
66
+ * Connect the detector to the audio stream
67
+ */
68
+ connect(source) {
69
+ source.connect(this.analyser);
70
+ }
71
+ /**
72
+ * Start monitoring audio levels
73
+ */
74
+ start() {
75
+ if (this.rafId !== null) return;
76
+ this.lastCheckTime = performance.now();
77
+ this.check();
78
+ if (this.debug) {
79
+ console.log("[AudioSilenceDetector] Started monitoring");
80
+ }
81
+ }
82
+ /**
83
+ * Stop monitoring
84
+ */
85
+ stop() {
86
+ if (this.rafId !== null) {
87
+ cancelAnimationFrame(this.rafId);
88
+ this.rafId = null;
89
+ }
90
+ if (this.debug) {
91
+ console.log("[AudioSilenceDetector] Stopped monitoring");
92
+ }
93
+ }
94
+ /**
95
+ * Cleanup resources
96
+ */
97
+ dispose() {
98
+ this.stop();
99
+ this.analyser.disconnect();
100
+ }
101
+ /**
102
+ * Check audio levels and pause/resume as needed
103
+ */
104
+ check = () => {
105
+ const now = performance.now();
106
+ const elapsed = now - this.lastCheckTime;
107
+ if (elapsed >= this.checkInterval) {
108
+ this.lastCheckTime = now;
109
+ const volume = this.getAudioLevel();
110
+ if (volume < this.silenceThreshold) {
111
+ if (this.silenceStartTime === 0) {
112
+ this.silenceStartTime = now;
113
+ } else {
114
+ const silenceDuration = now - this.silenceStartTime;
115
+ if (silenceDuration >= this.silenceDuration && !this.isPaused) {
116
+ this.pauseMedia();
117
+ }
118
+ }
119
+ } else {
120
+ this.silenceStartTime = 0;
121
+ if (this.isPaused) {
122
+ this.resumeMedia();
123
+ }
124
+ }
125
+ }
126
+ this.rafId = requestAnimationFrame(this.check);
127
+ };
128
+ /**
129
+ * Get current audio level (0-1)
130
+ */
131
+ getAudioLevel() {
132
+ this.analyser.getByteTimeDomainData(this.dataArray);
133
+ let sum = 0;
134
+ for (let i = 0; i < this.dataArray.length; i++) {
135
+ const normalized = (this.dataArray[i] - 128) / 128;
136
+ sum += normalized * normalized;
137
+ }
138
+ return Math.sqrt(sum / this.dataArray.length);
139
+ }
140
+ /**
141
+ * Pause media element to stop audio output
142
+ */
143
+ pauseMedia() {
144
+ try {
145
+ this.mediaElement.pause();
146
+ this.isPaused = true;
147
+ if (this.debug) {
148
+ console.log("[AudioSilenceDetector] Paused media element (silence detected)");
149
+ }
150
+ } catch (error) {
151
+ console.error("[AudioSilenceDetector] Failed to pause:", error);
152
+ }
153
+ }
154
+ /**
155
+ * Resume media element playback
156
+ */
157
+ resumeMedia() {
158
+ try {
159
+ if (this.audioContext.state === "running") {
160
+ this.mediaElement.play().catch((error) => {
161
+ if (this.debug) {
162
+ console.warn("[AudioSilenceDetector] Failed to resume:", error);
163
+ }
164
+ });
165
+ }
166
+ this.isPaused = false;
167
+ if (this.debug) {
168
+ console.log("[AudioSilenceDetector] Resumed media element (audio detected)");
169
+ }
170
+ } catch (error) {
171
+ console.error("[AudioSilenceDetector] Failed to resume:", error);
172
+ }
173
+ }
174
+ }
175
+ function patchAudioContext(audioContext, options = {}) {
176
+ const {
177
+ forceEnable = false,
178
+ autoCleanup = true,
179
+ debug = false,
180
+ mediaElementType = "video",
181
+ autoPause = {}
182
+ } = options;
183
+ const autoPauseOptions = {
184
+ enabled: autoPause.enabled ?? true,
185
+ silenceThreshold: autoPause.silenceThreshold ?? 1e-3,
186
+ silenceDuration: autoPause.silenceDuration ?? 50,
187
+ checkInterval: autoPause.checkInterval ?? 16
188
+ };
189
+ const needsPatch = forceEnable || isIOSDevice();
190
+ if (!needsPatch) {
191
+ return Object.assign(audioContext, {
192
+ unlock: async () => {
193
+ if (audioContext.state === "suspended") {
194
+ await audioContext.resume();
195
+ }
196
+ },
197
+ cleanup: () => {
198
+ },
199
+ isPatched: false,
200
+ originalDestination: audioContext.destination
201
+ });
202
+ }
203
+ if (!isMediaStreamAudioSupported()) {
204
+ console.warn(
205
+ "[AudioContext] MediaStreamAudioDestinationNode not supported, falling back to native"
206
+ );
207
+ return Object.assign(audioContext, {
208
+ unlock: async () => audioContext.resume(),
209
+ cleanup: () => {
210
+ },
211
+ isPatched: false,
212
+ originalDestination: audioContext.destination
213
+ });
214
+ }
215
+ const originalDestination = audioContext.destination;
216
+ const streamDestination = audioContext.createMediaStreamDestination();
217
+ const mediaElement = createHiddenMediaElement(mediaElementType, debug);
218
+ mediaElement.srcObject = streamDestination.stream;
219
+ document.body.appendChild(mediaElement);
220
+ let silenceDetector = null;
221
+ if (autoPauseOptions.enabled) {
222
+ silenceDetector = new AudioSilenceDetector(
223
+ audioContext,
224
+ mediaElement,
225
+ autoPauseOptions,
226
+ debug
227
+ );
228
+ silenceDetector.connect(streamDestination);
229
+ }
230
+ Object.defineProperty(audioContext, "destination", {
231
+ get: () => streamDestination,
232
+ enumerable: true,
233
+ configurable: true
234
+ });
235
+ if (!("maxChannelCount" in streamDestination)) {
236
+ Object.defineProperty(streamDestination, "maxChannelCount", {
237
+ get: () => originalDestination.maxChannelCount,
238
+ enumerable: true
239
+ });
240
+ }
241
+ let isUnlocked = false;
242
+ const unlock = async () => {
243
+ if (isUnlocked) {
244
+ return;
245
+ }
246
+ try {
247
+ await mediaElement.play();
248
+ if (audioContext.state === "suspended") {
249
+ await audioContext.resume();
250
+ }
251
+ if (silenceDetector) {
252
+ silenceDetector.start();
253
+ }
254
+ isUnlocked = true;
255
+ if (debug) {
256
+ console.log("[AudioContext] iOS unlock successful");
257
+ }
258
+ } catch (error) {
259
+ console.error("[AudioContext] Unlock failed:", error);
260
+ throw error;
261
+ }
262
+ };
263
+ const cleanup = () => {
264
+ try {
265
+ if (silenceDetector) {
266
+ silenceDetector.dispose();
267
+ silenceDetector = null;
268
+ }
269
+ mediaElement.pause();
270
+ mediaElement.srcObject = null;
271
+ mediaElement.remove();
272
+ if (debug) {
273
+ console.log("[AudioContext] Cleanup completed");
274
+ }
275
+ } catch (error) {
276
+ console.error("[AudioContext] Cleanup error:", error);
277
+ }
278
+ };
279
+ if (autoCleanup) {
280
+ const originalClose = audioContext.close.bind(audioContext);
281
+ audioContext.close = async () => {
282
+ cleanup();
283
+ return originalClose();
284
+ };
285
+ }
286
+ return Object.assign(audioContext, {
287
+ unlock,
288
+ cleanup,
289
+ isPatched: true,
290
+ originalDestination
291
+ });
292
+ }
293
+ function useAudioContext(options = {}) {
294
+ const { autoUnlock = true, ...patchOptions } = options;
295
+ const audioContextRef = useRef(null);
296
+ const isUnlockedRef = useRef(false);
297
+ const unlockFnRef = useRef(null);
298
+ useEffect(() => {
299
+ const ctx = new AudioContext();
300
+ audioContextRef.current = patchAudioContext(ctx, patchOptions);
301
+ return () => {
302
+ audioContextRef.current?.cleanup();
303
+ audioContextRef.current?.close();
304
+ };
305
+ }, []);
306
+ if (!unlockFnRef.current) {
307
+ unlockFnRef.current = async () => {
308
+ const ctx = audioContextRef.current;
309
+ if (!ctx || isUnlockedRef.current) return;
310
+ try {
311
+ await ctx.unlock();
312
+ isUnlockedRef.current = true;
313
+ } catch (error) {
314
+ console.warn("Failed to unlock audio:", error);
315
+ }
316
+ };
317
+ }
318
+ useEffect(() => {
319
+ if (!autoUnlock) return;
320
+ const handleInteraction = async () => {
321
+ await unlockFnRef.current?.();
322
+ };
323
+ document.addEventListener("click", handleInteraction, { once: true });
324
+ document.addEventListener("touchstart", handleInteraction, { once: true });
325
+ return () => {
326
+ document.removeEventListener("click", handleInteraction);
327
+ document.removeEventListener("touchstart", handleInteraction);
328
+ };
329
+ }, [autoUnlock]);
330
+ return {
331
+ audioContext: audioContextRef.current,
332
+ isUnlocked: isUnlockedRef.current,
333
+ unlock: unlockFnRef.current
334
+ };
335
+ }
336
+ export {
337
+ createHiddenVideoElement as a,
338
+ isMediaStreamAudioSupported as b,
339
+ createHiddenMediaElement as c,
340
+ isIOSDevice as i,
341
+ patchAudioContext as p,
342
+ useAudioContext as u
343
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aippy/runtime",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-dev.2",
4
4
  "description": "Aippy Runtime SDK - Runtime SDK for Aippy projects",
5
5
  "private": false,
6
6
  "type": "module",
@@ -27,6 +27,10 @@
27
27
  "./tweaks": {
28
28
  "import": "./dist/tweaks/index.js",
29
29
  "types": "./dist/tweaks/index.d.ts"
30
+ },
31
+ "./audio": {
32
+ "import": "./dist/audio/index.js",
33
+ "types": "./dist/audio/index.d.ts"
30
34
  }
31
35
  },
32
36
  "files": [
@@ -51,6 +55,7 @@
51
55
  "url": "https://discord.com/invite/G94ZAx6gVq"
52
56
  },
53
57
  "devDependencies": {
58
+ "@eslint/js": "^9.36.0",
54
59
  "@types/node": "^24.5.2",
55
60
  "@types/react": "^19.2.2",
56
61
  "@types/ua-parser-js": "^0.7.39",