opuslib 0.1.4 → 0.2.0
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/CHANGELOG.md +107 -0
- package/README.md +140 -9
- package/android/src/main/cpp/opus_jni_wrapper.cpp +34 -0
- package/android/src/main/java/expo/modules/opuslib/AudioProcessor.kt +273 -0
- package/android/src/main/java/expo/modules/opuslib/AudioRecordManager.kt +66 -149
- package/android/src/main/java/expo/modules/opuslib/OpusEncoder.kt +14 -0
- package/android/src/main/java/expo/modules/opuslib/OpuslibModule.kt +47 -5
- package/build/Opuslib.types.d.ts +96 -2
- package/build/Opuslib.types.d.ts.map +1 -1
- package/build/Opuslib.types.js.map +1 -1
- package/build/OpuslibModule.d.ts +28 -1
- package/build/OpuslibModule.d.ts.map +1 -1
- package/build/OpuslibModule.js +25 -0
- package/build/OpuslibModule.js.map +1 -1
- package/build/OpuslibModule.web.d.ts +6 -0
- package/build/OpuslibModule.web.d.ts.map +1 -1
- package/build/OpuslibModule.web.js +6 -0
- package/build/OpuslibModule.web.js.map +1 -1
- package/ios/AudioEngineManager.swift +137 -168
- package/ios/AudioProcessor.swift +246 -0
- package/ios/OpusCtlHelpers.h +8 -0
- package/ios/OpusCtlHelpers.m +4 -0
- package/ios/OpusEncoder.swift +13 -0
- package/ios/OpuslibModule.swift +55 -6
- package/package.json +1 -1
- package/src/Opuslib.types.ts +106 -2
- package/src/OpuslibModule.ts +55 -2
- package/src/OpuslibModule.web.ts +8 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"OpuslibModule.d.ts","sourceRoot":"","sources":["../src/OpuslibModule.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,WAAW,EACX,eAAe,EACf,cAAc,EACd,UAAU,EACV,YAAY,EACb,MAAM,iBAAiB,CAAA;AAoCxB;;;;;GAKG;;IAED;;;;;;;;;;;;;;;OAeG;6BACsB,WAAW;IAEpC;;OAEG;;IAGH;;OAEG;;IAGH;;OAEG;;IAGH
|
|
1
|
+
{"version":3,"file":"OpuslibModule.d.ts","sourceRoot":"","sources":["../src/OpuslibModule.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,WAAW,EACX,eAAe,EACf,cAAc,EACd,iBAAiB,EACjB,aAAa,EACb,UAAU,EACV,YAAY,EACb,MAAM,iBAAiB,CAAA;AAoCxB;;;;;GAKG;;IAED;;;;;;;;;;;;;;;OAeG;6BACsB,WAAW;IAEpC;;OAEG;;IAGH;;OAEG;;IAGH;;OAEG;;IAGH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA+BG;iBAgBsE;QACvE,CACE,SAAS,EAAE,YAAY,EACvB,QAAQ,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,GACzC,YAAY,CAAA;QACf,CACE,SAAS,EAAE,WAAW,EACtB,QAAQ,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,GACxC,YAAY,CAAA;QACf,CACE,SAAS,EAAE,cAAc,EACzB,QAAQ,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,GAC3C,YAAY,CAAA;QACf,CACE,SAAS,EAAE,UAAU,EACrB,QAAQ,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,GACvC,YAAY,CAAA;QACf,CAAC,SAAS,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,GAAG,YAAY,CAAA;KAC1E;IAED;;;;;OAKG;qCAES,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,KACxC,YAAY;IAEf;;;;;;;OAOG;wCAES,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,KAC3C,YAAY;IAEf;;;;;;OAMG;oCAES,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,KACvC,YAAY;IAEf;;;;;OAKG;iCAC0B,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,KAAG,YAAY;;AA5IzE,wBA8IC"}
|
package/build/OpuslibModule.js
CHANGED
|
@@ -53,6 +53,14 @@ export default {
|
|
|
53
53
|
* websocket.send(event.data)
|
|
54
54
|
* })
|
|
55
55
|
*
|
|
56
|
+
* // Listen for session lifecycle
|
|
57
|
+
* Opuslib.addListener('audioStarted', (event) => {
|
|
58
|
+
* console.log('Started; decoder pre-skip:', event.preSkip)
|
|
59
|
+
* })
|
|
60
|
+
* Opuslib.addListener('audioEnd', (event) => {
|
|
61
|
+
* console.log(`Ended: ${event.totalPackets} packets in ${event.totalDuration}ms`)
|
|
62
|
+
* })
|
|
63
|
+
*
|
|
56
64
|
* // Listen for errors
|
|
57
65
|
* const errorSub = Opuslib.addListener('error', (event) => {
|
|
58
66
|
* console.error('Error:', event.message)
|
|
@@ -71,6 +79,23 @@ export default {
|
|
|
71
79
|
* @returns Subscription object with remove() method
|
|
72
80
|
*/
|
|
73
81
|
addAmplitudeListener: (listener) => emitter.addListener('amplitude', listener),
|
|
82
|
+
/**
|
|
83
|
+
* Listen for the `audioStarted` event, emitted once when streaming begins.
|
|
84
|
+
* Carries the active config and the Opus encoder `preSkip` (lookahead) so a
|
|
85
|
+
* decoder knows how many samples to skip at the start of the stream.
|
|
86
|
+
*
|
|
87
|
+
* @param listener Event listener callback
|
|
88
|
+
* @returns Subscription object with remove() method
|
|
89
|
+
*/
|
|
90
|
+
addAudioStartedListener: (listener) => emitter.addListener('audioStarted', listener),
|
|
91
|
+
/**
|
|
92
|
+
* Listen for the `audioEnd` event, emitted once when streaming stops (after
|
|
93
|
+
* the final buffered audio has been flushed). Carries the session summary.
|
|
94
|
+
*
|
|
95
|
+
* @param listener Event listener callback
|
|
96
|
+
* @returns Subscription object with remove() method
|
|
97
|
+
*/
|
|
98
|
+
addAudioEndListener: (listener) => emitter.addListener('audioEnd', listener),
|
|
74
99
|
/**
|
|
75
100
|
* Listen for error events
|
|
76
101
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"OpuslibModule.js","sourceRoot":"","sources":["../src/OpuslibModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,YAAY,EAAE,MAAM,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"OpuslibModule.js","sourceRoot":"","sources":["../src/OpuslibModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,YAAY,EAAE,MAAM,MAAM,CAAA;AAwCtE,kCAAkC;AAClC,MAAM,aAAa,GAAG,mBAAmB,CAAoB,SAAS,CAAC,CAAA;AAEvE,+CAA+C;AAC/C,MAAM,OAAO,GAAG,IAAI,YAAY,CAAC,aAAoB,CAAC,CAAA;AAEtD;;;;;GAKG;AACH,eAAe;IACb;;;;;;;;;;;;;;;OAeG;IACH,cAAc,EAAE,CAAC,MAAmB,EAAE,EAAE,CAAC,aAAa,CAAC,cAAc,CAAC,MAAM,CAAC;IAE7E;;OAEG;IACH,aAAa,EAAE,GAAG,EAAE,CAAC,aAAa,CAAC,aAAa,EAAE;IAElD;;OAEG;IACH,cAAc,EAAE,GAAG,EAAE,CAAC,aAAa,CAAC,cAAc,EAAE;IAEpD;;OAEG;IACH,eAAe,EAAE,GAAG,EAAE,CAAC,aAAa,CAAC,eAAe,EAAE;IAEtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA+BG;IACH,WAAW,EAAE,CAAC,CACZ,SAKW,EACX,QAOS,EACK,EAAE,CAAE,OAAe,CAAC,WAAW,CAAC,SAAS,EAAE,QAAQ,CAAC,CAkBnE;IAED;;;;;OAKG;IACH,oBAAoB,EAAE,CACpB,QAAyC,EAC3B,EAAE,CAAE,OAAe,CAAC,WAAW,CAAC,WAAW,EAAE,QAAQ,CAAC;IAEtE;;;;;;;OAOG;IACH,uBAAuB,EAAE,CACvB,QAA4C,EAC9B,EAAE,CAAE,OAAe,CAAC,WAAW,CAAC,cAAc,EAAE,QAAQ,CAAC;IAEzE;;;;;;OAMG;IACH,mBAAmB,EAAE,CACnB,QAAwC,EAC1B,EAAE,CAAE,OAAe,CAAC,WAAW,CAAC,UAAU,EAAE,QAAQ,CAAC;IAErE;;;;;OAKG;IACH,gBAAgB,EAAE,CAAC,QAAqC,EAAgB,EAAE,CACvE,OAAe,CAAC,WAAW,CAAC,OAAO,EAAE,QAAQ,CAAC;CAClD,CAAA","sourcesContent":["import { NativeModule, requireNativeModule, EventEmitter } from 'expo'\n\nimport type {\n AudioConfig,\n AudioChunkEvent,\n AmplitudeEvent,\n AudioStartedEvent,\n AudioEndEvent,\n ErrorEvent,\n Subscription,\n} from './Opuslib.types'\n\n/**\n * Opuslib Native Module Interface\n *\n * Provides native audio capture and Opus 1.6.1 encoding with DRED support\n */\ndeclare class OpuslibModuleType extends NativeModule {\n /**\n * Start audio streaming with Opus encoding\n * @param config Audio configuration\n */\n startStreaming(config: AudioConfig): Promise<void>\n\n /**\n * Stop audio streaming\n */\n stopStreaming(): Promise<void>\n\n /**\n * Pause audio streaming (keeps resources allocated)\n */\n pauseStreaming(): void\n\n /**\n * Resume audio streaming\n */\n resumeStreaming(): void\n}\n\n// Load the native module from JSI\nconst OpuslibModule = requireNativeModule<OpuslibModuleType>('Opuslib')\n\n// Create event emitter for listening to events\nconst emitter = new EventEmitter(OpuslibModule as any)\n\n/**\n * Opuslib - Opus 1.6.1 Audio Encoding with DRED Support\n *\n * This module provides real-time audio capture and Opus 1.6.1 encoding\n * with Deep Redundancy (DRED) for improved quality on lossy networks.\n */\nexport default {\n /**\n * Start audio streaming with Opus encoding\n *\n * @param config Audio configuration\n * @example\n * ```ts\n * await Opuslib.startStreaming({\n * sampleRate: 16000,\n * channels: 1,\n * bitrate: 24000,\n * frameSize: 20,\n * packetDuration: 20,\n * dredDuration: 100, // Enable 100ms DRED recovery\n * })\n * ```\n */\n startStreaming: (config: AudioConfig) => OpuslibModule.startStreaming(config),\n\n /**\n * Stop audio streaming and release resources\n */\n stopStreaming: () => OpuslibModule.stopStreaming(),\n\n /**\n * Pause audio streaming (keeps resources allocated)\n */\n pauseStreaming: () => OpuslibModule.pauseStreaming(),\n\n /**\n * Resume audio streaming\n */\n resumeStreaming: () => OpuslibModule.resumeStreaming(),\n\n /**\n * Listen for events (audioChunk, amplitude, or error)\n *\n * @param eventName Event type to listen for\n * @param listener Event listener callback\n * @returns Subscription object with remove() method\n * @example\n * ```ts\n * // Listen for audio chunks\n * const subscription = Opuslib.addListener('audioChunk', (event) => {\n * console.log('Received Opus packet:', event.data.byteLength, 'bytes')\n * websocket.send(event.data)\n * })\n *\n * // Listen for session lifecycle\n * Opuslib.addListener('audioStarted', (event) => {\n * console.log('Started; decoder pre-skip:', event.preSkip)\n * })\n * Opuslib.addListener('audioEnd', (event) => {\n * console.log(`Ended: ${event.totalPackets} packets in ${event.totalDuration}ms`)\n * })\n *\n * // Listen for errors\n * const errorSub = Opuslib.addListener('error', (event) => {\n * console.error('Error:', event.message)\n * })\n *\n * // Later: unsubscribe\n * subscription.remove()\n * errorSub.remove()\n * ```\n */\n addListener: ((\n eventName:\n | 'audioChunk'\n | 'amplitude'\n | 'audioStarted'\n | 'audioEnd'\n | 'error',\n listener: (\n event:\n | AudioChunkEvent\n | AmplitudeEvent\n | AudioStartedEvent\n | AudioEndEvent\n | ErrorEvent,\n ) => void,\n ): Subscription => (emitter as any).addListener(eventName, listener)) as {\n (\n eventName: 'audioChunk',\n listener: (event: AudioChunkEvent) => void,\n ): Subscription\n (\n eventName: 'amplitude',\n listener: (event: AmplitudeEvent) => void,\n ): Subscription\n (\n eventName: 'audioStarted',\n listener: (event: AudioStartedEvent) => void,\n ): Subscription\n (\n eventName: 'audioEnd',\n listener: (event: AudioEndEvent) => void,\n ): Subscription\n (eventName: 'error', listener: (event: ErrorEvent) => void): Subscription\n },\n\n /**\n * Listen for amplitude events (for waveform visualization)\n *\n * @param listener Event listener callback\n * @returns Subscription object with remove() method\n */\n addAmplitudeListener: (\n listener: (event: AmplitudeEvent) => void,\n ): Subscription => (emitter as any).addListener('amplitude', listener),\n\n /**\n * Listen for the `audioStarted` event, emitted once when streaming begins.\n * Carries the active config and the Opus encoder `preSkip` (lookahead) so a\n * decoder knows how many samples to skip at the start of the stream.\n *\n * @param listener Event listener callback\n * @returns Subscription object with remove() method\n */\n addAudioStartedListener: (\n listener: (event: AudioStartedEvent) => void,\n ): Subscription => (emitter as any).addListener('audioStarted', listener),\n\n /**\n * Listen for the `audioEnd` event, emitted once when streaming stops (after\n * the final buffered audio has been flushed). Carries the session summary.\n *\n * @param listener Event listener callback\n * @returns Subscription object with remove() method\n */\n addAudioEndListener: (\n listener: (event: AudioEndEvent) => void,\n ): Subscription => (emitter as any).addListener('audioEnd', listener),\n\n /**\n * Listen for error events\n *\n * @param listener Event listener callback\n * @returns Subscription object with remove() method\n */\n addErrorListener: (listener: (event: ErrorEvent) => void): Subscription =>\n (emitter as any).addListener('error', listener),\n}\n"]}
|
|
@@ -16,6 +16,12 @@ declare const _default: {
|
|
|
16
16
|
addAmplitudeListener: () => {
|
|
17
17
|
remove: () => void;
|
|
18
18
|
};
|
|
19
|
+
addAudioStartedListener: () => {
|
|
20
|
+
remove: () => void;
|
|
21
|
+
};
|
|
22
|
+
addAudioEndListener: () => {
|
|
23
|
+
remove: () => void;
|
|
24
|
+
};
|
|
19
25
|
addErrorListener: () => {
|
|
20
26
|
remove: () => void;
|
|
21
27
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"OpuslibModule.web.d.ts","sourceRoot":"","sources":["../src/OpuslibModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAElD;;;;;GAKG;;6BAE8B,WAAW,KAAG,OAAO,CAAC,IAAI,CAAC;yBAMjC,OAAO,CAAC,IAAI,CAAC;0BAMlB,IAAI;2BAMH,IAAI
|
|
1
|
+
{"version":3,"file":"OpuslibModule.web.d.ts","sourceRoot":"","sources":["../src/OpuslibModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAElD;;;;;GAKG;;6BAE8B,WAAW,KAAG,OAAO,CAAC,IAAI,CAAC;yBAMjC,OAAO,CAAC,IAAI,CAAC;0BAMlB,IAAI;2BAMH,IAAI;;;;;;;;;;;;;;;;;AAnB3B,wBA4CC"}
|
|
@@ -23,6 +23,12 @@ export default {
|
|
|
23
23
|
addAmplitudeListener: () => ({
|
|
24
24
|
remove: () => { },
|
|
25
25
|
}),
|
|
26
|
+
addAudioStartedListener: () => ({
|
|
27
|
+
remove: () => { },
|
|
28
|
+
}),
|
|
29
|
+
addAudioEndListener: () => ({
|
|
30
|
+
remove: () => { },
|
|
31
|
+
}),
|
|
26
32
|
addErrorListener: () => ({
|
|
27
33
|
remove: () => { },
|
|
28
34
|
}),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"OpuslibModule.web.js","sourceRoot":"","sources":["../src/OpuslibModule.web.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,eAAe;IACb,cAAc,EAAE,KAAK,EAAE,MAAmB,EAAiB,EAAE;QAC3D,MAAM,IAAI,KAAK,CACb,+DAA+D,CAChE,CAAA;IACH,CAAC;IAED,aAAa,EAAE,KAAK,IAAmB,EAAE;QACvC,MAAM,IAAI,KAAK,CACb,+DAA+D,CAChE,CAAA;IACH,CAAC;IAED,cAAc,EAAE,GAAS,EAAE;QACzB,MAAM,IAAI,KAAK,CACb,+DAA+D,CAChE,CAAA;IACH,CAAC;IAED,eAAe,EAAE,GAAS,EAAE;QAC1B,MAAM,IAAI,KAAK,CACb,+DAA+D,CAChE,CAAA;IACH,CAAC;IAED,WAAW,EAAE,GAAG,EAAE,CAAC,CAAC;QAClB,MAAM,EAAE,GAAG,EAAE,GAAE,CAAC;KACjB,CAAC;IAEF,oBAAoB,EAAE,GAAG,EAAE,CAAC,CAAC;QAC3B,MAAM,EAAE,GAAG,EAAE,GAAE,CAAC;KACjB,CAAC;IAEF,gBAAgB,EAAE,GAAG,EAAE,CAAC,CAAC;QACvB,MAAM,EAAE,GAAG,EAAE,GAAE,CAAC;KACjB,CAAC;CACH,CAAA","sourcesContent":["import type { AudioConfig } from './Opuslib.types'\n\n/**\n * Web implementation of Opuslib (placeholder)\n *\n * Note: Web implementation requires WebRTC or WebAssembly Opus encoder\n * This is a stub for now - native platforms only\n */\nexport default {\n startStreaming: async (config: AudioConfig): Promise<void> => {\n throw new Error(\n 'Opuslib is not supported on web platform. Use iOS or Android.',\n )\n },\n\n stopStreaming: async (): Promise<void> => {\n throw new Error(\n 'Opuslib is not supported on web platform. Use iOS or Android.',\n )\n },\n\n pauseStreaming: (): void => {\n throw new Error(\n 'Opuslib is not supported on web platform. Use iOS or Android.',\n )\n },\n\n resumeStreaming: (): void => {\n throw new Error(\n 'Opuslib is not supported on web platform. Use iOS or Android.',\n )\n },\n\n addListener: () => ({\n remove: () => {},\n }),\n\n addAmplitudeListener: () => ({\n remove: () => {},\n }),\n\n addErrorListener: () => ({\n remove: () => {},\n }),\n}\n"]}
|
|
1
|
+
{"version":3,"file":"OpuslibModule.web.js","sourceRoot":"","sources":["../src/OpuslibModule.web.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,eAAe;IACb,cAAc,EAAE,KAAK,EAAE,MAAmB,EAAiB,EAAE;QAC3D,MAAM,IAAI,KAAK,CACb,+DAA+D,CAChE,CAAA;IACH,CAAC;IAED,aAAa,EAAE,KAAK,IAAmB,EAAE;QACvC,MAAM,IAAI,KAAK,CACb,+DAA+D,CAChE,CAAA;IACH,CAAC;IAED,cAAc,EAAE,GAAS,EAAE;QACzB,MAAM,IAAI,KAAK,CACb,+DAA+D,CAChE,CAAA;IACH,CAAC;IAED,eAAe,EAAE,GAAS,EAAE;QAC1B,MAAM,IAAI,KAAK,CACb,+DAA+D,CAChE,CAAA;IACH,CAAC;IAED,WAAW,EAAE,GAAG,EAAE,CAAC,CAAC;QAClB,MAAM,EAAE,GAAG,EAAE,GAAE,CAAC;KACjB,CAAC;IAEF,oBAAoB,EAAE,GAAG,EAAE,CAAC,CAAC;QAC3B,MAAM,EAAE,GAAG,EAAE,GAAE,CAAC;KACjB,CAAC;IAEF,uBAAuB,EAAE,GAAG,EAAE,CAAC,CAAC;QAC9B,MAAM,EAAE,GAAG,EAAE,GAAE,CAAC;KACjB,CAAC;IAEF,mBAAmB,EAAE,GAAG,EAAE,CAAC,CAAC;QAC1B,MAAM,EAAE,GAAG,EAAE,GAAE,CAAC;KACjB,CAAC;IAEF,gBAAgB,EAAE,GAAG,EAAE,CAAC,CAAC;QACvB,MAAM,EAAE,GAAG,EAAE,GAAE,CAAC;KACjB,CAAC;CACH,CAAA","sourcesContent":["import type { AudioConfig } from './Opuslib.types'\n\n/**\n * Web implementation of Opuslib (placeholder)\n *\n * Note: Web implementation requires WebRTC or WebAssembly Opus encoder\n * This is a stub for now - native platforms only\n */\nexport default {\n startStreaming: async (config: AudioConfig): Promise<void> => {\n throw new Error(\n 'Opuslib is not supported on web platform. Use iOS or Android.',\n )\n },\n\n stopStreaming: async (): Promise<void> => {\n throw new Error(\n 'Opuslib is not supported on web platform. Use iOS or Android.',\n )\n },\n\n pauseStreaming: (): void => {\n throw new Error(\n 'Opuslib is not supported on web platform. Use iOS or Android.',\n )\n },\n\n resumeStreaming: (): void => {\n throw new Error(\n 'Opuslib is not supported on web platform. Use iOS or Android.',\n )\n },\n\n addListener: () => ({\n remove: () => {},\n }),\n\n addAmplitudeListener: () => ({\n remove: () => {},\n }),\n\n addAudioStartedListener: () => ({\n remove: () => {},\n }),\n\n addAudioEndListener: () => ({\n remove: () => {},\n }),\n\n addErrorListener: () => ({\n remove: () => {},\n }),\n}\n"]}
|
|
@@ -2,52 +2,42 @@ import AVFoundation
|
|
|
2
2
|
import ExpoModulesCore
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* AudioEngineManager - Manages AVAudioEngine for real-time audio capture
|
|
5
|
+
* AudioEngineManager - Manages AVAudioEngine for real-time audio capture.
|
|
6
6
|
*
|
|
7
|
-
* This class handles
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - Audio interruption handling
|
|
13
|
-
* - Microphone permission management
|
|
7
|
+
* This class handles ONLY audio capture (AVAudioEngine lifecycle, audio session
|
|
8
|
+
* configuration, the real-time tap callback, and interruption handling). All
|
|
9
|
+
* Opus 1.6.1 encoding is delegated to AudioProcessor, which runs on its own
|
|
10
|
+
* serial queue. The tap callback only converts the PCM format and copies the
|
|
11
|
+
* samples across to the encoding queue — it never blocks on the encoder.
|
|
14
12
|
*/
|
|
15
13
|
class AudioEngineManager {
|
|
16
14
|
// Audio engine and nodes
|
|
17
15
|
private var audioEngine: AVAudioEngine?
|
|
18
16
|
private var inputNode: AVAudioInputNode?
|
|
19
17
|
|
|
20
|
-
// Opus encoder
|
|
21
|
-
private var opusEncoder: OpusEncoder?
|
|
22
|
-
|
|
23
18
|
// Audio format converter
|
|
24
19
|
private var audioConverter: AVAudioConverter?
|
|
25
20
|
|
|
26
|
-
//
|
|
27
|
-
private var
|
|
28
|
-
|
|
21
|
+
// Encoding processor (owns the encoder, runs on a separate serial queue)
|
|
22
|
+
private var processor: AudioProcessor?
|
|
23
|
+
|
|
24
|
+
// Configuration (immutable after init)
|
|
25
|
+
private let config: AudioConfig
|
|
29
26
|
|
|
30
|
-
//
|
|
27
|
+
// Recording state
|
|
31
28
|
private var isRecording = false
|
|
32
29
|
private var isPaused = false
|
|
33
30
|
private var loggedFirstBuffer = false
|
|
34
31
|
|
|
35
|
-
// Frame accumulation for packet duration
|
|
36
|
-
private var frameBuffer: [[Int16]] = []
|
|
37
|
-
private let framesPerPacket: Int
|
|
38
|
-
|
|
39
32
|
// Event callbacks
|
|
40
|
-
private var onAudioChunk: ((
|
|
33
|
+
private var onAudioChunk: (([EncodedFrame], Double, Int, Double, Int) -> Void)?
|
|
34
|
+
private var onStarted: ((_ timestamp: Double, _ sampleRate: Int, _ channels: Int, _ bitrate: Int, _ frameSize: Double, _ preSkip: Int) -> Void)?
|
|
35
|
+
private var onEnd: ((_ timestamp: Double, _ totalDuration: Double, _ totalPackets: Int) -> Void)?
|
|
41
36
|
private var onAmplitude: ((Float, Float, Double) -> Void)?
|
|
42
37
|
private var onError: ((Error) -> Void)?
|
|
43
38
|
|
|
44
|
-
// Debug file handles
|
|
45
|
-
private var pcmFileHandle: FileHandle?
|
|
46
|
-
private var pcmFileURL: URL?
|
|
47
|
-
|
|
48
39
|
init(config: AudioConfig) {
|
|
49
40
|
self.config = config
|
|
50
|
-
self.framesPerPacket = Int(config.packetDuration / config.frameSize)
|
|
51
41
|
|
|
52
42
|
// Register for interruption notifications
|
|
53
43
|
NotificationCenter.default.addObserver(
|
|
@@ -68,15 +58,28 @@ class AudioEngineManager {
|
|
|
68
58
|
// Configure audio session
|
|
69
59
|
try configureAudioSession()
|
|
70
60
|
|
|
71
|
-
// Create
|
|
72
|
-
let
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
61
|
+
// Create and start the AudioProcessor (encoding thread)
|
|
62
|
+
let proc = AudioProcessor(config: config)
|
|
63
|
+
proc.setOnAudioChunk { [weak self] frames, timestamp, seq, duration, frameCount in
|
|
64
|
+
self?.onAudioChunk?(frames, timestamp, seq, duration, frameCount)
|
|
65
|
+
}
|
|
66
|
+
proc.setOnStarted { [weak self] timestamp, sampleRate, channels, bitrate, frameSize, preSkip in
|
|
67
|
+
self?.onStarted?(timestamp, sampleRate, channels, bitrate, frameSize, preSkip)
|
|
68
|
+
}
|
|
69
|
+
proc.setOnEnd { [weak self] timestamp, totalDuration, totalPackets in
|
|
70
|
+
self?.onEnd?(timestamp, totalDuration, totalPackets)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Debug file
|
|
74
|
+
var debugURL: URL? = nil
|
|
75
|
+
if config.saveDebugAudio == true {
|
|
76
|
+
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
77
|
+
let timestamp = Date().timeIntervalSince1970
|
|
78
|
+
debugURL = documentsPath.appendingPathComponent("debug_pcm_\(timestamp).raw")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try proc.start(debugFileURL: debugURL)
|
|
82
|
+
processor = proc
|
|
80
83
|
|
|
81
84
|
// Create and configure AVAudioEngine
|
|
82
85
|
audioEngine = AVAudioEngine()
|
|
@@ -114,13 +117,13 @@ class AudioEngineManager {
|
|
|
114
117
|
// Calculate buffer size for one frame
|
|
115
118
|
let bufferSize: AVAudioFrameCount = 1024
|
|
116
119
|
|
|
117
|
-
// Install tap
|
|
120
|
+
// Install tap — real-time thread callback, only does format convert + copy + post
|
|
118
121
|
inputNode.installTap(
|
|
119
122
|
onBus: 0,
|
|
120
123
|
bufferSize: bufferSize,
|
|
121
124
|
format: hardwareFormat
|
|
122
|
-
) { [weak self] buffer,
|
|
123
|
-
self?.
|
|
125
|
+
) { [weak self] buffer, _ in
|
|
126
|
+
self?.onTapBuffer(buffer)
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
// Start audio engine
|
|
@@ -128,28 +131,7 @@ class AudioEngineManager {
|
|
|
128
131
|
|
|
129
132
|
isRecording = true
|
|
130
133
|
|
|
131
|
-
|
|
132
|
-
if config.saveDebugAudio == true {
|
|
133
|
-
do {
|
|
134
|
-
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
135
|
-
let timestamp = Date().timeIntervalSince1970
|
|
136
|
-
|
|
137
|
-
pcmFileURL = documentsPath.appendingPathComponent("debug_pcm_\(timestamp).raw")
|
|
138
|
-
|
|
139
|
-
// Create PCM file
|
|
140
|
-
FileManager.default.createFile(atPath: pcmFileURL!.path, contents: nil, attributes: nil)
|
|
141
|
-
|
|
142
|
-
// Open file handle for writing
|
|
143
|
-
pcmFileHandle = try FileHandle(forWritingTo: pcmFileURL!)
|
|
144
|
-
|
|
145
|
-
print("[AudioEngineManager] Debug audio file created:")
|
|
146
|
-
print(" PCM: \(pcmFileURL!.path)")
|
|
147
|
-
} catch {
|
|
148
|
-
print("[AudioEngineManager] Failed to create debug files: \(error)")
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
print("[AudioEngineManager] Started recording: \(hardwareFormat.sampleRate)Hz → \(config.sampleRate)Hz, \(config.channels) ch, DRED: \(dredDuration)ms")
|
|
134
|
+
print("[AudioEngineManager] Started recording: \(hardwareFormat.sampleRate)Hz → \(config.sampleRate)Hz, \(config.channels) ch")
|
|
153
135
|
}
|
|
154
136
|
|
|
155
137
|
func stop() {
|
|
@@ -157,26 +139,21 @@ class AudioEngineManager {
|
|
|
157
139
|
return
|
|
158
140
|
}
|
|
159
141
|
|
|
160
|
-
|
|
142
|
+
isRecording = false
|
|
143
|
+
|
|
144
|
+
// Stop audio capture
|
|
161
145
|
inputNode?.removeTap(onBus: 0)
|
|
162
146
|
audioEngine?.stop()
|
|
163
147
|
|
|
148
|
+
// Flush and stop the encoding thread (synchronous — drains remaining samples,
|
|
149
|
+
// pads the final partial frame with silence, and emits audioEnd)
|
|
150
|
+
processor?.flushAndStop()
|
|
151
|
+
processor = nil
|
|
152
|
+
|
|
164
153
|
// Clean up
|
|
165
154
|
audioEngine = nil
|
|
166
155
|
inputNode = nil
|
|
167
|
-
opusEncoder = nil
|
|
168
156
|
audioConverter = nil
|
|
169
|
-
frameBuffer.removeAll()
|
|
170
|
-
|
|
171
|
-
isRecording = false
|
|
172
|
-
sequenceNumber = 0
|
|
173
|
-
|
|
174
|
-
// Close debug file handle
|
|
175
|
-
if let fileHandle = pcmFileHandle {
|
|
176
|
-
fileHandle.closeFile()
|
|
177
|
-
pcmFileHandle = nil
|
|
178
|
-
print("[AudioEngineManager] Closed PCM debug file: \(pcmFileURL?.path ?? "")")
|
|
179
|
-
}
|
|
180
157
|
|
|
181
158
|
print("[AudioEngineManager] Stopped recording")
|
|
182
159
|
}
|
|
@@ -193,10 +170,18 @@ class AudioEngineManager {
|
|
|
193
170
|
|
|
194
171
|
// MARK: - Event Handlers
|
|
195
172
|
|
|
196
|
-
func setOnAudioChunk(_ callback: @escaping (
|
|
173
|
+
func setOnAudioChunk(_ callback: @escaping ([EncodedFrame], Double, Int, Double, Int) -> Void) {
|
|
197
174
|
self.onAudioChunk = callback
|
|
198
175
|
}
|
|
199
176
|
|
|
177
|
+
func setOnStarted(_ callback: @escaping (_ timestamp: Double, _ sampleRate: Int, _ channels: Int, _ bitrate: Int, _ frameSize: Double, _ preSkip: Int) -> Void) {
|
|
178
|
+
self.onStarted = callback
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func setOnEnd(_ callback: @escaping (_ timestamp: Double, _ totalDuration: Double, _ totalPackets: Int) -> Void) {
|
|
182
|
+
self.onEnd = callback
|
|
183
|
+
}
|
|
184
|
+
|
|
200
185
|
func setOnAmplitude(_ callback: @escaping (Float, Float, Double) -> Void) {
|
|
201
186
|
self.onAmplitude = callback
|
|
202
187
|
}
|
|
@@ -205,142 +190,126 @@ class AudioEngineManager {
|
|
|
205
190
|
self.onError = callback
|
|
206
191
|
}
|
|
207
192
|
|
|
208
|
-
// MARK: -
|
|
193
|
+
// MARK: - Real-time Audio Thread (tap callback)
|
|
194
|
+
// Only does format conversion + copy + post. No encoding, no locks.
|
|
209
195
|
|
|
210
|
-
private func
|
|
211
|
-
guard !isPaused else {
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
guard let audioConverter = audioConverter else {
|
|
216
|
-
return
|
|
217
|
-
}
|
|
196
|
+
private func onTapBuffer(_ buffer: AVAudioPCMBuffer) {
|
|
197
|
+
guard !isPaused else { return }
|
|
198
|
+
guard let audioConverter = audioConverter else { return }
|
|
218
199
|
|
|
219
200
|
// Calculate output buffer size based on sample rate conversion
|
|
220
|
-
let
|
|
221
|
-
let inputSampleRate = buffer.format.sampleRate
|
|
222
|
-
let sampleRateRatio = outputSampleRate / inputSampleRate
|
|
201
|
+
let sampleRateRatio = Double(config.sampleRate) / buffer.format.sampleRate
|
|
223
202
|
let outputFrameCapacity = AVAudioFrameCount(Double(buffer.frameLength) * sampleRateRatio)
|
|
224
203
|
|
|
225
|
-
// Create output buffer in desired format (16kHz, Int16)
|
|
204
|
+
// Create output buffer in desired format (e.g. 16kHz, Int16)
|
|
226
205
|
guard let outputFormat = audioConverter.outputFormat as? AVAudioFormat,
|
|
227
206
|
let convertedBuffer = AVAudioPCMBuffer(
|
|
228
207
|
pcmFormat: outputFormat,
|
|
229
208
|
frameCapacity: outputFrameCapacity
|
|
230
|
-
) else {
|
|
231
|
-
return
|
|
232
|
-
}
|
|
209
|
+
) else { return }
|
|
233
210
|
|
|
234
211
|
// Convert audio from hardware format to desired format
|
|
235
212
|
var error: NSError?
|
|
236
|
-
let inputBlock: AVAudioConverterInputBlock = {
|
|
213
|
+
let inputBlock: AVAudioConverterInputBlock = { _, outStatus in
|
|
237
214
|
outStatus.pointee = .haveData
|
|
238
215
|
return buffer
|
|
239
216
|
}
|
|
240
217
|
|
|
241
218
|
let status = audioConverter.convert(to: convertedBuffer, error: &error, withInputFrom: inputBlock)
|
|
219
|
+
if status == .error { return }
|
|
242
220
|
|
|
243
|
-
|
|
244
|
-
return
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// Process the converted Int16 data
|
|
248
|
-
guard let channelData = convertedBuffer.int16ChannelData else {
|
|
249
|
-
return
|
|
250
|
-
}
|
|
221
|
+
guard let channelData = convertedBuffer.int16ChannelData else { return }
|
|
251
222
|
|
|
252
223
|
let frameLength = Int(convertedBuffer.frameLength)
|
|
253
224
|
let channelDataPointer = channelData[0]
|
|
254
225
|
|
|
255
|
-
// DEBUG: Log first buffer to verify conversion
|
|
256
226
|
if !loggedFirstBuffer {
|
|
257
227
|
print("[AudioEngineManager] First converted buffer: \(frameLength) samples at \(convertedBuffer.format.sampleRate)Hz")
|
|
258
228
|
loggedFirstBuffer = true
|
|
259
229
|
}
|
|
260
230
|
|
|
261
|
-
// Copy PCM
|
|
262
|
-
let
|
|
263
|
-
|
|
264
|
-
// Save PCM to debug file if enabled
|
|
265
|
-
if let fileHandle = pcmFileHandle {
|
|
266
|
-
let data = Data(bytes: channelDataPointer, count: frameLength * MemoryLayout<Int16>.size)
|
|
267
|
-
fileHandle.write(data)
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
frameBuffer.append(frame)
|
|
271
|
-
|
|
272
|
-
// Calculate how many samples we need for one packet
|
|
273
|
-
let samplesPerPacket = Int(Double(config.sampleRate) * config.packetDuration / 1000.0)
|
|
274
|
-
let currentSampleCount = frameBuffer.reduce(0) { $0 + $1.count }
|
|
275
|
-
|
|
276
|
-
// When we have enough samples for a packet, encode and send
|
|
277
|
-
if currentSampleCount >= samplesPerPacket {
|
|
278
|
-
encodeAndSendPacket(timestamp: time.sampleTime)
|
|
279
|
-
}
|
|
231
|
+
// Copy the PCM and post it to the encoding queue (does not block the tap)
|
|
232
|
+
let samples = Array(UnsafeBufferPointer(start: channelDataPointer, count: frameLength))
|
|
233
|
+
processor?.pushSamples(samples)
|
|
280
234
|
}
|
|
281
235
|
|
|
282
|
-
|
|
283
|
-
guard let opusEncoder = opusEncoder else {
|
|
284
|
-
return
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Flatten frame buffer into continuous PCM data
|
|
288
|
-
let pcmData = frameBuffer.flatMap { $0 }
|
|
289
|
-
|
|
290
|
-
// Calculate samples per frame
|
|
291
|
-
let samplesPerFrame = Int(Double(config.sampleRate) * config.frameSize / 1000.0)
|
|
292
|
-
|
|
293
|
-
// We should only encode when we have at least one frame
|
|
294
|
-
guard pcmData.count >= samplesPerFrame else {
|
|
295
|
-
return
|
|
296
|
-
}
|
|
236
|
+
// MARK: - Audio Session
|
|
297
237
|
|
|
298
|
-
|
|
299
|
-
let
|
|
238
|
+
private func configureAudioSession() throws {
|
|
239
|
+
let audioSession = AVAudioSession.sharedInstance()
|
|
300
240
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
241
|
+
let sessionConfig = config.iosAudioSession
|
|
242
|
+
let category = Self.mapCategory(sessionConfig?.category)
|
|
243
|
+
let mode = Self.mapMode(sessionConfig?.mode)
|
|
244
|
+
let options = Self.mapOptions(sessionConfig?.options)
|
|
245
|
+
|
|
246
|
+
// Try the requested config first; if it is an invalid combination, fall back to safe defaults
|
|
247
|
+
do {
|
|
248
|
+
try audioSession.setCategory(category, mode: mode, options: options)
|
|
249
|
+
print("[AudioEngineManager] Audio session configured: category=\(category.rawValue), mode=\(mode.rawValue), options=\(options.rawValue)")
|
|
250
|
+
} catch {
|
|
251
|
+
print("[AudioEngineManager] Custom audio session config failed (\(error.localizedDescription)), falling back to defaults")
|
|
252
|
+
do {
|
|
253
|
+
try audioSession.setCategory(.record, mode: .measurement, options: [])
|
|
254
|
+
print("[AudioEngineManager] Audio session configured with defaults: category=record, mode=measurement, options=[]")
|
|
255
|
+
} catch {
|
|
256
|
+
print("[AudioEngineManager] Fallback audio session config also failed: \(error.localizedDescription), continuing with current session")
|
|
306
257
|
}
|
|
307
|
-
|
|
308
|
-
encodedPacket = opusEncoder.encode(pcm: baseAddress, frameSize: samplesPerFrame)
|
|
309
258
|
}
|
|
310
259
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
return
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Calculate timestamp in milliseconds
|
|
318
|
-
let timestampMs = Date().timeIntervalSince1970 * 1000
|
|
260
|
+
// setPreferredSampleRate / setPreferredIOBufferDuration are hints, not hard requirements — don't let them crash
|
|
261
|
+
do { try audioSession.setPreferredSampleRate(Double(config.sampleRate)) }
|
|
262
|
+
catch { print("[AudioEngineManager] setPreferredSampleRate failed: \(error.localizedDescription)") }
|
|
319
263
|
|
|
320
|
-
|
|
321
|
-
|
|
264
|
+
do { try audioSession.setPreferredIOBufferDuration(config.frameSize / 1000.0) }
|
|
265
|
+
catch { print("[AudioEngineManager] setPreferredIOBufferDuration failed: \(error.localizedDescription)") }
|
|
322
266
|
|
|
323
|
-
|
|
267
|
+
try audioSession.setActive(true)
|
|
268
|
+
}
|
|
324
269
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
270
|
+
// MARK: - String → AVAudioSession Mapping
|
|
271
|
+
|
|
272
|
+
private static func mapCategory(_ value: String?) -> AVAudioSession.Category {
|
|
273
|
+
guard let value = value else { return .record }
|
|
274
|
+
switch value {
|
|
275
|
+
case "record": return .record
|
|
276
|
+
case "playAndRecord": return .playAndRecord
|
|
277
|
+
case "playback": return .playback
|
|
278
|
+
case "ambient": return .ambient
|
|
279
|
+
default:
|
|
280
|
+
print("[AudioEngineManager] Unknown category '\(value)', falling back to .record")
|
|
281
|
+
return .record
|
|
331
282
|
}
|
|
332
283
|
}
|
|
333
284
|
|
|
334
|
-
private func
|
|
335
|
-
let
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
285
|
+
private static func mapMode(_ value: String?) -> AVAudioSession.Mode {
|
|
286
|
+
guard let value = value else { return .measurement }
|
|
287
|
+
switch value {
|
|
288
|
+
case "default": return .default
|
|
289
|
+
case "voiceChat": return .voiceChat
|
|
290
|
+
case "measurement": return .measurement
|
|
291
|
+
case "spokenAudio": return .spokenAudio
|
|
292
|
+
default:
|
|
293
|
+
print("[AudioEngineManager] Unknown mode '\(value)', falling back to .measurement")
|
|
294
|
+
return .measurement
|
|
295
|
+
}
|
|
296
|
+
}
|
|
342
297
|
|
|
343
|
-
|
|
298
|
+
private static func mapOptions(_ values: [String]?) -> AVAudioSession.CategoryOptions {
|
|
299
|
+
guard let values = values else { return [] }
|
|
300
|
+
var options: AVAudioSession.CategoryOptions = []
|
|
301
|
+
for value in values {
|
|
302
|
+
switch value {
|
|
303
|
+
case "mixWithOthers": options.insert(.mixWithOthers)
|
|
304
|
+
case "defaultToSpeaker": options.insert(.defaultToSpeaker)
|
|
305
|
+
case "allowBluetooth": options.insert(.allowBluetooth)
|
|
306
|
+
case "allowAirPlay": options.insert(.allowAirPlay)
|
|
307
|
+
case "allowBluetoothA2DP": options.insert(.allowBluetoothA2DP)
|
|
308
|
+
default:
|
|
309
|
+
print("[AudioEngineManager] Unknown option '\(value)', skipping")
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return options
|
|
344
313
|
}
|
|
345
314
|
|
|
346
315
|
@objc private func handleInterruption(_ notification: Notification) {
|