@live-assistant/audio 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Recep Tayyip Ekşi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @live-assistant/audio
2
+
3
+ `Microphone` and `PcmPlayer` for iOS, Android (`react-native-audio-api`, a peer dependency) and the web (Web Audio). The platform is picked through file extensions.
4
+
5
+ - Frames are resampled to the rate the session asks for, not the rate the recorder claims to deliver.
6
+ - `level()` on both classes feeds animations. The player's level follows the playhead, and `remainingSeconds()` says how much audio has not been heard yet.
7
+ - On iOS the session runs in `voiceChat` mode, so the app's own output is cancelled. Android has no echo cancellation here (`cancelsEcho` is false), so the controller holds the microphone shut while the assistant is audible.
8
+
9
+ See the [overview](../README.md).
@@ -0,0 +1,3 @@
1
+ export { Microphone } from './microphone';
2
+ export { PcmPlayer } from './pcm-player';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PcmPlayer = exports.Microphone = void 0;
4
+ var microphone_1 = require("./microphone");
5
+ Object.defineProperty(exports, "Microphone", { enumerable: true, get: function () { return microphone_1.Microphone; } });
6
+ var pcm_player_1 = require("./pcm-player");
7
+ Object.defineProperty(exports, "PcmPlayer", { enumerable: true, get: function () { return pcm_player_1.PcmPlayer; } });
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA,2CAA0C;AAAjC,wGAAA,UAAU,OAAA;AACnB,2CAAyC;AAAhC,uGAAA,SAAS,OAAA"}
@@ -0,0 +1,13 @@
1
+ import type { AssistantFailure, AssistantMicrophone, Result } from '@live-assistant/core';
2
+ export declare class Microphone implements AssistantMicrophone {
3
+ private readonly clock;
4
+ readonly cancelsEcho: boolean;
5
+ private recorder;
6
+ private readonly levels;
7
+ constructor(clock?: () => number);
8
+ level(): number;
9
+ ensureAccess(): Promise<Result<void, AssistantFailure>>;
10
+ start(sampleRate: number, onFrame: (samples: Float32Array<ArrayBuffer>) => void): Promise<Result<void, AssistantFailure>>;
11
+ stop(): Promise<void>;
12
+ }
13
+ //# sourceMappingURL=microphone.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"microphone.d.ts","sourceRoot":"","sources":["../src/microphone.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAgC1F,qBAAa,UAAW,YAAW,mBAAmB;IAMxC,OAAO,CAAC,QAAQ,CAAC,KAAK;IALlC,QAAQ,CAAC,WAAW,UAAyB;IAE7C,OAAO,CAAC,QAAQ,CAA8B;IAC9C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAuB;gBAEjB,KAAK,GAAE,MAAM,MAAyC;IAEnF,KAAK,IAAI,MAAM;IAIT,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,gBAAgB,CAAC,CAAC;IAWvD,KAAK,CACT,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,CAAC,OAAO,EAAE,YAAY,CAAC,WAAW,CAAC,KAAK,IAAI,GACpD,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,gBAAgB,CAAC,CAAC;IAkCpC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAU5B"}
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Microphone = void 0;
4
+ const react_native_1 = require("react-native");
5
+ const react_native_audio_api_1 = require("react-native-audio-api");
6
+ const core_1 = require("@live-assistant/core");
7
+ /**
8
+ * Microphone capture on iOS and Android, over `react-native-audio-api`.
9
+ *
10
+ * @remarks
11
+ * - **The requested rate is a wish; the delivered rate is a fact.** The
12
+ * recorder documents `sampleRate` as a preference, so every frame is
13
+ * converted from the rate the buffer actually reports. Frames sent at the
14
+ * wrong rate do not error — the model simply hears them sped up.
15
+ * - **Echo cancellation is iOS-only, and the split is the library's.**
16
+ * `voiceChat` puts the session on Apple's Voice-Processing I/O unit, which
17
+ * cancels the app's own output. Android's recorder opens its input without
18
+ * the `VoiceCommunication` preset that engages the platform's canceller, so
19
+ * `cancelsEcho` is false there and the caller should stop sending while the
20
+ * assistant is audible.
21
+ * - **The audio session is exclusive until it is handed back.** Left active
22
+ * after a failed or finished capture, it keeps the user's music silent until
23
+ * the app restarts — so every exit path deactivates it.
24
+ * - **`level()` lags what was said by one frame (~100 ms).** A frame arrives
25
+ * when it has been captured, and its slices are laid out from that moment.
26
+ */
27
+ // 100 ms at 16 kHz: prompt enough for the model, and not a socket write per 20 ms.
28
+ const BUFFER_FRAMES = 1600;
29
+ const MONO = 1;
30
+ const FIRST_CHANNEL = 0;
31
+ const MS_PER_SECOND = 1000;
32
+ // The library's own word for a granted permission — see its `PermissionStatus`.
33
+ const PERMISSION_GRANTED = 'Granted';
34
+ const reasonOf = (error) => (error instanceof Error ? error.message : undefined);
35
+ class Microphone {
36
+ constructor(clock = () => Date.now() / MS_PER_SECOND) {
37
+ this.clock = clock;
38
+ this.cancelsEcho = react_native_1.Platform.OS === 'ios';
39
+ this.recorder = null;
40
+ this.levels = new core_1.LevelTimeline();
41
+ }
42
+ level() {
43
+ return this.levels.levelAt(this.clock());
44
+ }
45
+ async ensureAccess() {
46
+ // This can THROW rather than resolve: the Android module force-unwraps
47
+ // `currentActivity`, which is absent while the app is backgrounded.
48
+ try {
49
+ const permission = await react_native_audio_api_1.AudioManager.requestRecordingPermissions();
50
+ return permission === PERMISSION_GRANTED ? (0, core_1.ok)(undefined) : (0, core_1.fail)({ code: core_1.AssistantFailureCode.MicrophoneDenied });
51
+ }
52
+ catch (error) {
53
+ return (0, core_1.fail)({ code: core_1.AssistantFailureCode.MicrophoneDenied, detail: reasonOf(error) });
54
+ }
55
+ }
56
+ async start(sampleRate, onFrame) {
57
+ const access = await this.ensureAccess();
58
+ if (!access.ok)
59
+ return access;
60
+ try {
61
+ // Restarting replaces the callback: keeping the old one sent frames to a
62
+ // closure belonging to a session that had ended.
63
+ if (this.recorder !== null)
64
+ await this.stop();
65
+ react_native_audio_api_1.AudioManager.setAudioSessionOptions({
66
+ iosCategory: 'playAndRecord',
67
+ iosMode: 'voiceChat',
68
+ iosOptions: ['defaultToSpeaker', 'allowBluetoothHFP'],
69
+ });
70
+ await react_native_audio_api_1.AudioManager.setAudioSessionActivity(true);
71
+ const recorder = new react_native_audio_api_1.AudioRecorder();
72
+ recorder.onAudioReady({ sampleRate, bufferLength: BUFFER_FRAMES, channelCount: MONO }, (event) => {
73
+ const samples = (0, core_1.resample)(event.buffer.getChannelData(FIRST_CHANNEL), event.buffer.sampleRate, sampleRate);
74
+ this.levels.push(samples, sampleRate, this.clock());
75
+ onFrame(samples);
76
+ });
77
+ await recorder.start();
78
+ this.recorder = recorder;
79
+ return (0, core_1.ok)(undefined);
80
+ }
81
+ catch (error) {
82
+ // `stop()` returns early while `this.recorder` is null — which it is if
83
+ // the constructor or `start()` threw — so the session is handed back here.
84
+ await this.stop();
85
+ await react_native_audio_api_1.AudioManager.setAudioSessionActivity(false).catch(() => undefined);
86
+ return (0, core_1.fail)({ code: core_1.AssistantFailureCode.MicrophoneUnavailable, detail: reasonOf(error) });
87
+ }
88
+ }
89
+ async stop() {
90
+ this.levels.clear();
91
+ const recorder = this.recorder;
92
+ this.recorder = null;
93
+ if (recorder === null)
94
+ return;
95
+ recorder.clearOnAudioReady();
96
+ await recorder.stop();
97
+ await react_native_audio_api_1.AudioManager.setAudioSessionActivity(false);
98
+ }
99
+ }
100
+ exports.Microphone = Microphone;
101
+ //# sourceMappingURL=microphone.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"microphone.js","sourceRoot":"","sources":["../src/microphone.ts"],"names":[],"mappings":";;;AAAA,+CAAwC;AACxC,mEAAqE;AACrE,+CAA+F;AAG/F;;;;;;;;;;;;;;;;;;;GAmBG;AACH,mFAAmF;AACnF,MAAM,aAAa,GAAG,IAAI,CAAC;AAC3B,MAAM,IAAI,GAAG,CAAC,CAAC;AACf,MAAM,aAAa,GAAG,CAAC,CAAC;AACxB,MAAM,aAAa,GAAG,IAAI,CAAC;AAC3B,gFAAgF;AAChF,MAAM,kBAAkB,GAAG,SAAS,CAAC;AAErC,MAAM,QAAQ,GAAG,CAAC,KAAc,EAAsB,EAAE,CAAC,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;AAE9G,MAAa,UAAU;IAMrB,YAA6B,QAAsB,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,aAAa;QAAtD,UAAK,GAAL,KAAK,CAAiD;QAL1E,gBAAW,GAAG,uBAAQ,CAAC,EAAE,KAAK,KAAK,CAAC;QAErC,aAAQ,GAAyB,IAAI,CAAC;QAC7B,WAAM,GAAG,IAAI,oBAAa,EAAE,CAAC;IAEwC,CAAC;IAEvF,KAAK;QACH,OAAO,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;IAC3C,CAAC;IAED,KAAK,CAAC,YAAY;QAChB,uEAAuE;QACvE,oEAAoE;QACpE,IAAI,CAAC;YACH,MAAM,UAAU,GAAG,MAAM,qCAAY,CAAC,2BAA2B,EAAE,CAAC;YACpE,OAAO,UAAU,KAAK,kBAAkB,CAAC,CAAC,CAAC,IAAA,SAAE,EAAC,SAAS,CAAC,CAAC,CAAC,CAAC,IAAA,WAAI,EAAC,EAAE,IAAI,EAAE,2BAAoB,CAAC,gBAAgB,EAAE,CAAC,CAAC;QACnH,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,IAAA,WAAI,EAAC,EAAE,IAAI,EAAE,2BAAoB,CAAC,gBAAgB,EAAE,MAAM,EAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QACxF,CAAC;IACH,CAAC;IAED,KAAK,CAAC,KAAK,CACT,UAAkB,EAClB,OAAqD;QAErD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,YAAY,EAAE,CAAC;QACzC,IAAI,CAAC,MAAM,CAAC,EAAE;YAAE,OAAO,MAAM,CAAC;QAE9B,IAAI,CAAC;YACH,yEAAyE;YACzE,iDAAiD;YACjD,IAAI,IAAI,CAAC,QAAQ,KAAK,IAAI;gBAAE,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC;YAE9C,qCAAY,CAAC,sBAAsB,CAAC;gBAClC,WAAW,EAAE,eAAe;gBAC5B,OAAO,EAAE,WAAW;gBACpB,UAAU,EAAE,CAAC,kBAAkB,EAAE,mBAAmB,CAAC;aACtD,CAAC,CAAC;YACH,MAAM,qCAAY,CAAC,uBAAuB,CAAC,IAAI,CAAC,CAAC;YAEjD,MAAM,QAAQ,GAAG,IAAI,sCAAa,EAAE,CAAC;YACrC,QAAQ,CAAC,YAAY,CAAC,EAAE,UAAU,EAAE,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,IAAI,EAAE,EAAE,CAAC,KAAK,EAAE,EAAE;gBAC/F,MAAM,OAAO,GAAG,IAAA,eAAQ,EAAC,KAAK,CAAC,MAAM,CAAC,cAAc,CAAC,aAAa,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC;gBAC1G,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;gBACpD,OAAO,CAAC,OAAO,CAAC,CAAC;YACnB,CAAC,CAAC,CAAC;YACH,MAAM,QAAQ,CAAC,KAAK,EAAE,CAAC;YACvB,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;YACzB,OAAO,IAAA,SAAE,EAAC,SAAS,CAAC,CAAC;QACvB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,wEAAwE;YACxE,2EAA2E;YAC3E,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC;YAClB,MAAM,qCAAY,CAAC,uBAAuB,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;YACzE,OAAO,IAAA,WAAI,EAAC,EAAE,IAAI,EAAE,2BAAoB,CAAC,qBAAqB,EAAE,MAAM,EAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAC7F,CAAC;IACH,CAAC;IAED,KAAK,CAAC,IAAI;QACR,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QACpB,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC;QAC/B,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;QACrB,IAAI,QAAQ,KAAK,IAAI;YAAE,OAAO;QAE9B,QAAQ,CAAC,iBAAiB,EAAE,CAAC;QAC7B,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;QACtB,MAAM,qCAAY,CAAC,uBAAuB,CAAC,KAAK,CAAC,CAAC;IACpD,CAAC;CACF;AAtED,gCAsEC"}
@@ -0,0 +1,17 @@
1
+ import type { AssistantFailure, AssistantMicrophone, Result } from '@live-assistant/core';
2
+ export declare class Microphone implements AssistantMicrophone {
3
+ private readonly clock;
4
+ /** `echoCancellation` is requested on the stream below. */
5
+ readonly cancelsEcho = true;
6
+ private stream;
7
+ private context;
8
+ private processor;
9
+ private onFrame;
10
+ private readonly levels;
11
+ constructor(clock?: () => number);
12
+ level(): number;
13
+ ensureAccess(): Promise<Result<void, AssistantFailure>>;
14
+ start(sampleRate: number, onFrame: (samples: Float32Array<ArrayBuffer>) => void): Promise<Result<void, AssistantFailure>>;
15
+ stop(): Promise<void>;
16
+ }
17
+ //# sourceMappingURL=microphone.web.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"microphone.web.d.ts","sourceRoot":"","sources":["../src/microphone.web.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AA2B1F,qBAAa,UAAW,YAAW,mBAAmB;IAUxC,OAAO,CAAC,QAAQ,CAAC,KAAK;IATlC,2DAA2D;IAC3D,QAAQ,CAAC,WAAW,QAAQ;IAE5B,OAAO,CAAC,MAAM,CAA4B;IAC1C,OAAO,CAAC,OAAO,CAA6B;IAC5C,OAAO,CAAC,SAAS,CAAoC;IACrD,OAAO,CAAC,OAAO,CAA+D;IAC9E,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAuB;gBAEjB,KAAK,GAAE,MAAM,MAAyC;IAEnF,KAAK,IAAI,MAAM;IAIT,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,gBAAgB,CAAC,CAAC;IAMvD,KAAK,CACT,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,CAAC,OAAO,EAAE,YAAY,CAAC,WAAW,CAAC,KAAK,IAAI,GACpD,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,gBAAgB,CAAC,CAAC;IA2CpC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAa5B"}
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Microphone = void 0;
4
+ const core_1 = require("@live-assistant/core");
5
+ /**
6
+ * Microphone capture in the browser, over `getUserMedia`.
7
+ *
8
+ * @remarks
9
+ * - **Why not the same library as native.** `react-native-audio-api`'s web
10
+ * build ships no `AudioRecorder`; importing it on web yields `undefined`.
11
+ * - **`echoCancellation` is the web spelling of iOS's `voiceChat`.** Off, the
12
+ * tab's own output re-enters the microphone and the model concludes the user
13
+ * interrupted it.
14
+ * - **`ScriptProcessorNode`, deprecated and chosen anyway.** `AudioWorklet`
15
+ * needs its processor served as a separate module URL, which a bundled
16
+ * package cannot promise. Every current browser still runs a script
17
+ * processor; the swap is local to this file the day one drops it.
18
+ * - **A refusal and a broken device arrive down the same path.** The browser's
19
+ * prompt lives inside `getUserMedia`, so only the error's NAME tells a
20
+ * pressed "Block" from a missing device.
21
+ */
22
+ // A power of two, as the API requires; 4096 at 48 kHz is ~85 ms.
23
+ const PROCESSOR_BUFFER = 4096;
24
+ const MONO = 1;
25
+ const FIRST_CHANNEL = 0;
26
+ const SILENT = 0;
27
+ const MS_PER_SECOND = 1000;
28
+ const REFUSAL_NAMES = ['NotAllowedError', 'SecurityError'];
29
+ class Microphone {
30
+ constructor(clock = () => Date.now() / MS_PER_SECOND) {
31
+ this.clock = clock;
32
+ /** `echoCancellation` is requested on the stream below. */
33
+ this.cancelsEcho = true;
34
+ this.stream = null;
35
+ this.context = null;
36
+ this.processor = null;
37
+ this.onFrame = null;
38
+ this.levels = new core_1.LevelTimeline();
39
+ }
40
+ level() {
41
+ return this.levels.levelAt(this.clock());
42
+ }
43
+ async ensureAccess() {
44
+ // The browser cannot ask ahead of use; all that can be checked is that
45
+ // there is an API to ask with. `start` reports a refusal.
46
+ return navigator.mediaDevices === undefined ? (0, core_1.fail)({ code: core_1.AssistantFailureCode.MicrophoneDenied }) : (0, core_1.ok)(undefined);
47
+ }
48
+ async start(sampleRate, onFrame) {
49
+ // A restart keeps the open stream but replaces the callback, as the port promises.
50
+ this.onFrame = onFrame;
51
+ if (this.stream !== null)
52
+ return (0, core_1.ok)(undefined);
53
+ if (navigator.mediaDevices === undefined)
54
+ return (0, core_1.fail)({ code: core_1.AssistantFailureCode.MicrophoneDenied });
55
+ try {
56
+ const stream = await navigator.mediaDevices.getUserMedia({
57
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: MONO },
58
+ });
59
+ const context = new AudioContext();
60
+ const source = context.createMediaStreamSource(stream);
61
+ const processor = context.createScriptProcessor(PROCESSOR_BUFFER, MONO, MONO);
62
+ processor.onaudioprocess = (event) => {
63
+ const samples = (0, core_1.resample)(event.inputBuffer.getChannelData(FIRST_CHANNEL), context.sampleRate, sampleRate);
64
+ this.levels.push(samples, sampleRate, this.clock());
65
+ this.onFrame?.(samples);
66
+ };
67
+ source.connect(processor);
68
+ // A script processor only runs while connected to the destination, so
69
+ // it is — through a muted gain.
70
+ const silence = context.createGain();
71
+ silence.gain.value = SILENT;
72
+ processor.connect(silence);
73
+ silence.connect(context.destination);
74
+ this.stream = stream;
75
+ this.context = context;
76
+ this.processor = processor;
77
+ return (0, core_1.ok)(undefined);
78
+ }
79
+ catch (error) {
80
+ await this.stop();
81
+ if (error instanceof Error && REFUSAL_NAMES.includes(error.name)) {
82
+ return (0, core_1.fail)({ code: core_1.AssistantFailureCode.MicrophoneDenied, detail: error.message });
83
+ }
84
+ return (0, core_1.fail)({
85
+ code: core_1.AssistantFailureCode.MicrophoneUnavailable,
86
+ detail: error instanceof Error ? error.message : undefined,
87
+ });
88
+ }
89
+ }
90
+ async stop() {
91
+ this.levels.clear();
92
+ this.onFrame = null;
93
+ if (this.processor !== null)
94
+ this.processor.onaudioprocess = null;
95
+ this.processor?.disconnect();
96
+ for (const track of this.stream?.getTracks() ?? [])
97
+ track.stop();
98
+ const context = this.context;
99
+ this.processor = null;
100
+ this.stream = null;
101
+ this.context = null;
102
+ await context?.close();
103
+ }
104
+ }
105
+ exports.Microphone = Microphone;
106
+ //# sourceMappingURL=microphone.web.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"microphone.web.js","sourceRoot":"","sources":["../src/microphone.web.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,15 @@
1
+ import type { AssistantFailure, AssistantPlayer, Result } from '@live-assistant/core';
2
+ export declare class PcmPlayer implements AssistantPlayer {
3
+ private context;
4
+ private queue;
5
+ /** The rate the provider sends at, not necessarily one the device runs at. */
6
+ private sourceRate;
7
+ private readonly levels;
8
+ level(): number;
9
+ remainingSeconds(): number;
10
+ prepare(sampleRate: number): Promise<Result<void, AssistantFailure>>;
11
+ enqueue(samples: Float32Array<ArrayBuffer>): void;
12
+ flush(): void;
13
+ stop(): Promise<void>;
14
+ }
15
+ //# sourceMappingURL=pcm-player.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pcm-player.d.ts","sourceRoot":"","sources":["../src/pcm-player.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AA2BtF,qBAAa,SAAU,YAAW,eAAe;IAC/C,OAAO,CAAC,OAAO,CAA6B;IAC5C,OAAO,CAAC,KAAK,CAA2C;IACxD,8EAA8E;IAC9E,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAuB;IAE9C,KAAK,IAAI,MAAM;IAIf,gBAAgB,IAAI,MAAM;IAIpB,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,gBAAgB,CAAC,CAAC;IAqB1E,OAAO,CAAC,OAAO,EAAE,YAAY,CAAC,WAAW,CAAC,GAAG,IAAI;IAYjD,KAAK,IAAI,IAAI;IAKP,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAQ5B"}
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PcmPlayer = void 0;
4
+ const react_native_audio_api_1 = require("react-native-audio-api");
5
+ const core_1 = require("@live-assistant/core");
6
+ /**
7
+ * Streaming playback on iOS and Android, over `react-native-audio-api`.
8
+ *
9
+ * @remarks
10
+ * - **A queue source, not a buffer per chunk.** Starting a source node for each
11
+ * small chunk leaves audible seams, because each start is scheduled against
12
+ * the clock separately. One queue node plays them back to back, and
13
+ * `clearBuffers` drops everything not yet heard in a single call.
14
+ * - **The context runs at the DEVICE's rate, and the stream is converted to
15
+ * meet it.** Asking Android's audio HAL for a rate it does not run at is a
16
+ * request it may honour, quietly refuse, or crash on (a native SIGSEGV in
17
+ * `libaudioclient.so` was reported). Conversion is free when rates agree.
18
+ * - **`start(0, 0)`, both arguments explicit.** The library's default offset
19
+ * is -1 and its own guard rejects -1, so the no-argument call throws.
20
+ * - **Levels follow the playhead.** Each chunk is laid on a timeline against
21
+ * the context's `currentTime` where the previous one ends, as the queue plays
22
+ * it, so `level()` is the loudness of what is heard now.
23
+ */
24
+ const MONO = 1;
25
+ const FIRST_CHANNEL = 0;
26
+ const IMMEDIATELY = 0;
27
+ const FROM_START = 0;
28
+ const reasonOf = (error) => (error instanceof Error ? error.message : undefined);
29
+ class PcmPlayer {
30
+ constructor() {
31
+ this.context = null;
32
+ this.queue = null;
33
+ /** The rate the provider sends at, not necessarily one the device runs at. */
34
+ this.sourceRate = 0;
35
+ this.levels = new core_1.LevelTimeline();
36
+ }
37
+ level() {
38
+ return this.context === null ? 0 : this.levels.levelAt(this.context.currentTime);
39
+ }
40
+ remainingSeconds() {
41
+ return this.context === null ? 0 : this.levels.remainingAt(this.context.currentTime);
42
+ }
43
+ async prepare(sampleRate) {
44
+ // Set before the early return: a second prepare at another rate must not
45
+ // leave buffers labelled with a rate they are not.
46
+ this.sourceRate = sampleRate;
47
+ if (this.queue !== null)
48
+ return (0, core_1.ok)(undefined);
49
+ try {
50
+ const context = new react_native_audio_api_1.AudioContext();
51
+ const queue = context.createBufferQueueSource();
52
+ queue.connect(context.destination);
53
+ queue.start(IMMEDIATELY, FROM_START);
54
+ this.context = context;
55
+ this.queue = queue;
56
+ return (0, core_1.ok)(undefined);
57
+ }
58
+ catch (error) {
59
+ await this.stop();
60
+ return (0, core_1.fail)({ code: core_1.AssistantFailureCode.PlayerUnavailable, detail: reasonOf(error) });
61
+ }
62
+ }
63
+ enqueue(samples) {
64
+ const context = this.context;
65
+ const queue = this.queue;
66
+ if (context === null || queue === null || samples.length === 0)
67
+ return;
68
+ const playable = (0, core_1.resample)(samples, this.sourceRate, context.sampleRate);
69
+ const buffer = context.createBuffer(MONO, playable.length, context.sampleRate);
70
+ buffer.copyToChannel(playable, FIRST_CHANNEL);
71
+ queue.enqueueBuffer(buffer);
72
+ this.levels.push(samples, this.sourceRate, context.currentTime);
73
+ }
74
+ flush() {
75
+ this.queue?.clearBuffers();
76
+ this.levels.clear();
77
+ }
78
+ async stop() {
79
+ const context = this.context;
80
+ this.flush();
81
+ this.queue?.stop();
82
+ this.queue = null;
83
+ this.context = null;
84
+ await context?.close();
85
+ }
86
+ }
87
+ exports.PcmPlayer = PcmPlayer;
88
+ //# sourceMappingURL=pcm-player.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pcm-player.js","sourceRoot":"","sources":["../src/pcm-player.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,15 @@
1
+ import type { AssistantFailure, AssistantPlayer, Result } from '@live-assistant/core';
2
+ export declare class PcmPlayer implements AssistantPlayer {
3
+ private context;
4
+ private scheduled;
5
+ private nextStartTime;
6
+ private streamRate;
7
+ private readonly levels;
8
+ level(): number;
9
+ remainingSeconds(): number;
10
+ prepare(sampleRate: number): Promise<Result<void, AssistantFailure>>;
11
+ enqueue(samples: Float32Array<ArrayBuffer>): void;
12
+ flush(): void;
13
+ stop(): Promise<void>;
14
+ }
15
+ //# sourceMappingURL=pcm-player.web.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pcm-player.web.d.ts","sourceRoot":"","sources":["../src/pcm-player.web.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAsBtF,qBAAa,SAAU,YAAW,eAAe;IAC/C,OAAO,CAAC,OAAO,CAA6B;IAC5C,OAAO,CAAC,SAAS,CAAoC;IACrD,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAuB;IAE9C,KAAK,IAAI,MAAM;IAIf,gBAAgB,IAAI,MAAM;IAIpB,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,gBAAgB,CAAC,CAAC;IAe1E,OAAO,CAAC,OAAO,EAAE,YAAY,CAAC,WAAW,CAAC,GAAG,IAAI;IAoBjD,KAAK,IAAI,IAAI;IAOP,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAM5B"}
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PcmPlayer = void 0;
4
+ const core_1 = require("@live-assistant/core");
5
+ /**
6
+ * Streaming playback in the browser, over the Web Audio graph.
7
+ *
8
+ * @remarks
9
+ * - **Why not the same library as native.** `react-native-audio-api`'s web
10
+ * build exports no `AudioBufferQueueSourceNode`, so the queue is built from
11
+ * scheduled source nodes.
12
+ * - **The queue is a clock cursor, not a list.** Each chunk starts exactly
13
+ * where the previous one ends, which keeps the reply gapless; the cursor is
14
+ * nudged to `currentTime` when the queue has drained.
15
+ * - **`flush` stops the scheduled nodes** — everything not yet started never
16
+ * starts, and the level timeline is cleared with it.
17
+ * - **Autoplay policy** suspends a context created outside a user gesture; the
18
+ * tap that starts the assistant is that gesture, so `resume` resolves there.
19
+ */
20
+ const MONO = 1;
21
+ const FIRST_CHANNEL = 0;
22
+ const reasonOf = (error) => (error instanceof Error ? error.message : undefined);
23
+ class PcmPlayer {
24
+ constructor() {
25
+ this.context = null;
26
+ this.scheduled = new Set();
27
+ this.nextStartTime = 0;
28
+ this.streamRate = 0;
29
+ this.levels = new core_1.LevelTimeline();
30
+ }
31
+ level() {
32
+ return this.context === null ? 0 : this.levels.levelAt(this.context.currentTime);
33
+ }
34
+ remainingSeconds() {
35
+ return this.context === null ? 0 : this.levels.remainingAt(this.context.currentTime);
36
+ }
37
+ async prepare(sampleRate) {
38
+ this.streamRate = sampleRate;
39
+ if (this.context !== null)
40
+ return (0, core_1.ok)(undefined);
41
+ try {
42
+ this.context = new AudioContext();
43
+ this.nextStartTime = 0;
44
+ await this.context.resume();
45
+ return (0, core_1.ok)(undefined);
46
+ }
47
+ catch (error) {
48
+ await this.stop();
49
+ return (0, core_1.fail)({ code: core_1.AssistantFailureCode.PlayerUnavailable, detail: reasonOf(error) });
50
+ }
51
+ }
52
+ enqueue(samples) {
53
+ const context = this.context;
54
+ if (context === null || samples.length === 0)
55
+ return;
56
+ const buffer = context.createBuffer(MONO, samples.length, this.streamRate);
57
+ buffer.copyToChannel(samples, FIRST_CHANNEL);
58
+ const source = context.createBufferSource();
59
+ source.buffer = buffer;
60
+ source.connect(context.destination);
61
+ const startAt = Math.max(this.nextStartTime, context.currentTime);
62
+ source.start(startAt);
63
+ this.nextStartTime = startAt + buffer.duration;
64
+ this.levels.push(samples, this.streamRate, context.currentTime);
65
+ this.scheduled.add(source);
66
+ source.onended = () => this.scheduled.delete(source);
67
+ }
68
+ flush() {
69
+ for (const source of this.scheduled)
70
+ source.stop();
71
+ this.scheduled.clear();
72
+ this.nextStartTime = this.context?.currentTime ?? 0;
73
+ this.levels.clear();
74
+ }
75
+ async stop() {
76
+ this.flush();
77
+ const context = this.context;
78
+ this.context = null;
79
+ await context?.close();
80
+ }
81
+ }
82
+ exports.PcmPlayer = PcmPlayer;
83
+ //# sourceMappingURL=pcm-player.web.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pcm-player.web.js","sourceRoot":"","sources":["../src/pcm-player.web.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@live-assistant/audio",
3
+ "version": "0.1.0",
4
+ "description": "Microphone capture and streaming PCM playback for @live-assistant/core, on iOS, Android and the web, with levels an animation can read every frame.",
5
+ "license": "MIT",
6
+ "author": "Recep Tayyip Ekşi",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Recipely-Team/live-assistant.git",
10
+ "directory": "packages/assistant-audio"
11
+ },
12
+ "homepage": "https://github.com/Recipely-Team/live-assistant/tree/main/packages/assistant-audio#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/Recipely-Team/live-assistant/issues"
15
+ },
16
+ "keywords": [
17
+ "audio",
18
+ "microphone",
19
+ "pcm",
20
+ "react-native",
21
+ "voice",
22
+ "realtime"
23
+ ],
24
+ "main": "dist/index.js",
25
+ "types": "dist/index.d.ts",
26
+ "files": [
27
+ "dist",
28
+ "src",
29
+ "README.md",
30
+ "LICENSE",
31
+ "!src/**/__tests__",
32
+ "!src/**/__fixtures__"
33
+ ],
34
+ "sideEffects": false,
35
+ "scripts": {
36
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
37
+ "prepack": "npm run build"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "@live-assistant/core": "0.1.0"
44
+ },
45
+ "peerDependencies": {
46
+ "react-native": ">=0.76",
47
+ "react-native-audio-api": ">=0.13.3"
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { Microphone } from './microphone';
2
+ export { PcmPlayer } from './pcm-player';
@@ -0,0 +1,106 @@
1
+ import { Platform } from 'react-native';
2
+ import { AudioManager, AudioRecorder } from 'react-native-audio-api';
3
+ import { AssistantFailureCode, LevelTimeline, fail, ok, resample } from '@live-assistant/core';
4
+ import type { AssistantFailure, AssistantMicrophone, Result } from '@live-assistant/core';
5
+
6
+ /**
7
+ * Microphone capture on iOS and Android, over `react-native-audio-api`.
8
+ *
9
+ * @remarks
10
+ * - **The requested rate is a wish; the delivered rate is a fact.** The
11
+ * recorder documents `sampleRate` as a preference, so every frame is
12
+ * converted from the rate the buffer actually reports. Frames sent at the
13
+ * wrong rate do not error — the model simply hears them sped up.
14
+ * - **Echo cancellation is iOS-only, and the split is the library's.**
15
+ * `voiceChat` puts the session on Apple's Voice-Processing I/O unit, which
16
+ * cancels the app's own output. Android's recorder opens its input without
17
+ * the `VoiceCommunication` preset that engages the platform's canceller, so
18
+ * `cancelsEcho` is false there and the caller should stop sending while the
19
+ * assistant is audible.
20
+ * - **The audio session is exclusive until it is handed back.** Left active
21
+ * after a failed or finished capture, it keeps the user's music silent until
22
+ * the app restarts — so every exit path deactivates it.
23
+ * - **`level()` lags what was said by one frame (~100 ms).** A frame arrives
24
+ * when it has been captured, and its slices are laid out from that moment.
25
+ */
26
+ // 100 ms at 16 kHz: prompt enough for the model, and not a socket write per 20 ms.
27
+ const BUFFER_FRAMES = 1600;
28
+ const MONO = 1;
29
+ const FIRST_CHANNEL = 0;
30
+ const MS_PER_SECOND = 1000;
31
+ // The library's own word for a granted permission — see its `PermissionStatus`.
32
+ const PERMISSION_GRANTED = 'Granted';
33
+
34
+ const reasonOf = (error: unknown): string | undefined => (error instanceof Error ? error.message : undefined);
35
+
36
+ export class Microphone implements AssistantMicrophone {
37
+ readonly cancelsEcho = Platform.OS === 'ios';
38
+
39
+ private recorder: AudioRecorder | null = null;
40
+ private readonly levels = new LevelTimeline();
41
+
42
+ constructor(private readonly clock: () => number = () => Date.now() / MS_PER_SECOND) {}
43
+
44
+ level(): number {
45
+ return this.levels.levelAt(this.clock());
46
+ }
47
+
48
+ async ensureAccess(): Promise<Result<void, AssistantFailure>> {
49
+ // This can THROW rather than resolve: the Android module force-unwraps
50
+ // `currentActivity`, which is absent while the app is backgrounded.
51
+ try {
52
+ const permission = await AudioManager.requestRecordingPermissions();
53
+ return permission === PERMISSION_GRANTED ? ok(undefined) : fail({ code: AssistantFailureCode.MicrophoneDenied });
54
+ } catch (error) {
55
+ return fail({ code: AssistantFailureCode.MicrophoneDenied, detail: reasonOf(error) });
56
+ }
57
+ }
58
+
59
+ async start(
60
+ sampleRate: number,
61
+ onFrame: (samples: Float32Array<ArrayBuffer>) => void,
62
+ ): Promise<Result<void, AssistantFailure>> {
63
+ const access = await this.ensureAccess();
64
+ if (!access.ok) return access;
65
+
66
+ try {
67
+ // Restarting replaces the callback: keeping the old one sent frames to a
68
+ // closure belonging to a session that had ended.
69
+ if (this.recorder !== null) await this.stop();
70
+
71
+ AudioManager.setAudioSessionOptions({
72
+ iosCategory: 'playAndRecord',
73
+ iosMode: 'voiceChat',
74
+ iosOptions: ['defaultToSpeaker', 'allowBluetoothHFP'],
75
+ });
76
+ await AudioManager.setAudioSessionActivity(true);
77
+
78
+ const recorder = new AudioRecorder();
79
+ recorder.onAudioReady({ sampleRate, bufferLength: BUFFER_FRAMES, channelCount: MONO }, (event) => {
80
+ const samples = resample(event.buffer.getChannelData(FIRST_CHANNEL), event.buffer.sampleRate, sampleRate);
81
+ this.levels.push(samples, sampleRate, this.clock());
82
+ onFrame(samples);
83
+ });
84
+ await recorder.start();
85
+ this.recorder = recorder;
86
+ return ok(undefined);
87
+ } catch (error) {
88
+ // `stop()` returns early while `this.recorder` is null — which it is if
89
+ // the constructor or `start()` threw — so the session is handed back here.
90
+ await this.stop();
91
+ await AudioManager.setAudioSessionActivity(false).catch(() => undefined);
92
+ return fail({ code: AssistantFailureCode.MicrophoneUnavailable, detail: reasonOf(error) });
93
+ }
94
+ }
95
+
96
+ async stop(): Promise<void> {
97
+ this.levels.clear();
98
+ const recorder = this.recorder;
99
+ this.recorder = null;
100
+ if (recorder === null) return;
101
+
102
+ recorder.clearOnAudioReady();
103
+ await recorder.stop();
104
+ await AudioManager.setAudioSessionActivity(false);
105
+ }
106
+ }
@@ -0,0 +1,110 @@
1
+ import { AssistantFailureCode, LevelTimeline, fail, ok, resample } from '@live-assistant/core';
2
+ import type { AssistantFailure, AssistantMicrophone, Result } from '@live-assistant/core';
3
+
4
+ /**
5
+ * Microphone capture in the browser, over `getUserMedia`.
6
+ *
7
+ * @remarks
8
+ * - **Why not the same library as native.** `react-native-audio-api`'s web
9
+ * build ships no `AudioRecorder`; importing it on web yields `undefined`.
10
+ * - **`echoCancellation` is the web spelling of iOS's `voiceChat`.** Off, the
11
+ * tab's own output re-enters the microphone and the model concludes the user
12
+ * interrupted it.
13
+ * - **`ScriptProcessorNode`, deprecated and chosen anyway.** `AudioWorklet`
14
+ * needs its processor served as a separate module URL, which a bundled
15
+ * package cannot promise. Every current browser still runs a script
16
+ * processor; the swap is local to this file the day one drops it.
17
+ * - **A refusal and a broken device arrive down the same path.** The browser's
18
+ * prompt lives inside `getUserMedia`, so only the error's NAME tells a
19
+ * pressed "Block" from a missing device.
20
+ */
21
+ // A power of two, as the API requires; 4096 at 48 kHz is ~85 ms.
22
+ const PROCESSOR_BUFFER = 4096;
23
+ const MONO = 1;
24
+ const FIRST_CHANNEL = 0;
25
+ const SILENT = 0;
26
+ const MS_PER_SECOND = 1000;
27
+ const REFUSAL_NAMES: readonly string[] = ['NotAllowedError', 'SecurityError'];
28
+
29
+ export class Microphone implements AssistantMicrophone {
30
+ /** `echoCancellation` is requested on the stream below. */
31
+ readonly cancelsEcho = true;
32
+
33
+ private stream: MediaStream | null = null;
34
+ private context: AudioContext | null = null;
35
+ private processor: ScriptProcessorNode | null = null;
36
+ private onFrame: ((samples: Float32Array<ArrayBuffer>) => void) | null = null;
37
+ private readonly levels = new LevelTimeline();
38
+
39
+ constructor(private readonly clock: () => number = () => Date.now() / MS_PER_SECOND) {}
40
+
41
+ level(): number {
42
+ return this.levels.levelAt(this.clock());
43
+ }
44
+
45
+ async ensureAccess(): Promise<Result<void, AssistantFailure>> {
46
+ // The browser cannot ask ahead of use; all that can be checked is that
47
+ // there is an API to ask with. `start` reports a refusal.
48
+ return navigator.mediaDevices === undefined ? fail({ code: AssistantFailureCode.MicrophoneDenied }) : ok(undefined);
49
+ }
50
+
51
+ async start(
52
+ sampleRate: number,
53
+ onFrame: (samples: Float32Array<ArrayBuffer>) => void,
54
+ ): Promise<Result<void, AssistantFailure>> {
55
+ // A restart keeps the open stream but replaces the callback, as the port promises.
56
+ this.onFrame = onFrame;
57
+ if (this.stream !== null) return ok(undefined);
58
+ if (navigator.mediaDevices === undefined) return fail({ code: AssistantFailureCode.MicrophoneDenied });
59
+
60
+ try {
61
+ const stream = await navigator.mediaDevices.getUserMedia({
62
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: MONO },
63
+ });
64
+ const context = new AudioContext();
65
+ const source = context.createMediaStreamSource(stream);
66
+ const processor = context.createScriptProcessor(PROCESSOR_BUFFER, MONO, MONO);
67
+
68
+ processor.onaudioprocess = (event) => {
69
+ const samples = resample(event.inputBuffer.getChannelData(FIRST_CHANNEL), context.sampleRate, sampleRate);
70
+ this.levels.push(samples, sampleRate, this.clock());
71
+ this.onFrame?.(samples);
72
+ };
73
+ source.connect(processor);
74
+ // A script processor only runs while connected to the destination, so
75
+ // it is — through a muted gain.
76
+ const silence = context.createGain();
77
+ silence.gain.value = SILENT;
78
+ processor.connect(silence);
79
+ silence.connect(context.destination);
80
+
81
+ this.stream = stream;
82
+ this.context = context;
83
+ this.processor = processor;
84
+ return ok(undefined);
85
+ } catch (error) {
86
+ await this.stop();
87
+ if (error instanceof Error && REFUSAL_NAMES.includes(error.name)) {
88
+ return fail({ code: AssistantFailureCode.MicrophoneDenied, detail: error.message });
89
+ }
90
+ return fail({
91
+ code: AssistantFailureCode.MicrophoneUnavailable,
92
+ detail: error instanceof Error ? error.message : undefined,
93
+ });
94
+ }
95
+ }
96
+
97
+ async stop(): Promise<void> {
98
+ this.levels.clear();
99
+ this.onFrame = null;
100
+ if (this.processor !== null) this.processor.onaudioprocess = null;
101
+ this.processor?.disconnect();
102
+ for (const track of this.stream?.getTracks() ?? []) track.stop();
103
+ const context = this.context;
104
+
105
+ this.processor = null;
106
+ this.stream = null;
107
+ this.context = null;
108
+ await context?.close();
109
+ }
110
+ }
@@ -0,0 +1,92 @@
1
+ import { AudioContext } from 'react-native-audio-api';
2
+ import type { AudioBufferQueueSourceNode } from 'react-native-audio-api';
3
+ import { AssistantFailureCode, LevelTimeline, fail, ok, resample } from '@live-assistant/core';
4
+ import type { AssistantFailure, AssistantPlayer, Result } from '@live-assistant/core';
5
+
6
+ /**
7
+ * Streaming playback on iOS and Android, over `react-native-audio-api`.
8
+ *
9
+ * @remarks
10
+ * - **A queue source, not a buffer per chunk.** Starting a source node for each
11
+ * small chunk leaves audible seams, because each start is scheduled against
12
+ * the clock separately. One queue node plays them back to back, and
13
+ * `clearBuffers` drops everything not yet heard in a single call.
14
+ * - **The context runs at the DEVICE's rate, and the stream is converted to
15
+ * meet it.** Asking Android's audio HAL for a rate it does not run at is a
16
+ * request it may honour, quietly refuse, or crash on (a native SIGSEGV in
17
+ * `libaudioclient.so` was reported). Conversion is free when rates agree.
18
+ * - **`start(0, 0)`, both arguments explicit.** The library's default offset
19
+ * is -1 and its own guard rejects -1, so the no-argument call throws.
20
+ * - **Levels follow the playhead.** Each chunk is laid on a timeline against
21
+ * the context's `currentTime` where the previous one ends, as the queue plays
22
+ * it, so `level()` is the loudness of what is heard now.
23
+ */
24
+ const MONO = 1;
25
+ const FIRST_CHANNEL = 0;
26
+ const IMMEDIATELY = 0;
27
+ const FROM_START = 0;
28
+
29
+ const reasonOf = (error: unknown): string | undefined => (error instanceof Error ? error.message : undefined);
30
+
31
+ export class PcmPlayer implements AssistantPlayer {
32
+ private context: AudioContext | null = null;
33
+ private queue: AudioBufferQueueSourceNode | null = null;
34
+ /** The rate the provider sends at, not necessarily one the device runs at. */
35
+ private sourceRate = 0;
36
+ private readonly levels = new LevelTimeline();
37
+
38
+ level(): number {
39
+ return this.context === null ? 0 : this.levels.levelAt(this.context.currentTime);
40
+ }
41
+
42
+ remainingSeconds(): number {
43
+ return this.context === null ? 0 : this.levels.remainingAt(this.context.currentTime);
44
+ }
45
+
46
+ async prepare(sampleRate: number): Promise<Result<void, AssistantFailure>> {
47
+ // Set before the early return: a second prepare at another rate must not
48
+ // leave buffers labelled with a rate they are not.
49
+ this.sourceRate = sampleRate;
50
+ if (this.queue !== null) return ok(undefined);
51
+
52
+ try {
53
+ const context = new AudioContext();
54
+ const queue = context.createBufferQueueSource();
55
+ queue.connect(context.destination);
56
+ queue.start(IMMEDIATELY, FROM_START);
57
+
58
+ this.context = context;
59
+ this.queue = queue;
60
+ return ok(undefined);
61
+ } catch (error) {
62
+ await this.stop();
63
+ return fail({ code: AssistantFailureCode.PlayerUnavailable, detail: reasonOf(error) });
64
+ }
65
+ }
66
+
67
+ enqueue(samples: Float32Array<ArrayBuffer>): void {
68
+ const context = this.context;
69
+ const queue = this.queue;
70
+ if (context === null || queue === null || samples.length === 0) return;
71
+
72
+ const playable = resample(samples, this.sourceRate, context.sampleRate);
73
+ const buffer = context.createBuffer(MONO, playable.length, context.sampleRate);
74
+ buffer.copyToChannel(playable, FIRST_CHANNEL);
75
+ queue.enqueueBuffer(buffer);
76
+ this.levels.push(samples, this.sourceRate, context.currentTime);
77
+ }
78
+
79
+ flush(): void {
80
+ this.queue?.clearBuffers();
81
+ this.levels.clear();
82
+ }
83
+
84
+ async stop(): Promise<void> {
85
+ const context = this.context;
86
+ this.flush();
87
+ this.queue?.stop();
88
+ this.queue = null;
89
+ this.context = null;
90
+ await context?.close();
91
+ }
92
+ }
@@ -0,0 +1,87 @@
1
+ import { AssistantFailureCode, LevelTimeline, fail, ok } from '@live-assistant/core';
2
+ import type { AssistantFailure, AssistantPlayer, Result } from '@live-assistant/core';
3
+
4
+ /**
5
+ * Streaming playback in the browser, over the Web Audio graph.
6
+ *
7
+ * @remarks
8
+ * - **Why not the same library as native.** `react-native-audio-api`'s web
9
+ * build exports no `AudioBufferQueueSourceNode`, so the queue is built from
10
+ * scheduled source nodes.
11
+ * - **The queue is a clock cursor, not a list.** Each chunk starts exactly
12
+ * where the previous one ends, which keeps the reply gapless; the cursor is
13
+ * nudged to `currentTime` when the queue has drained.
14
+ * - **`flush` stops the scheduled nodes** — everything not yet started never
15
+ * starts, and the level timeline is cleared with it.
16
+ * - **Autoplay policy** suspends a context created outside a user gesture; the
17
+ * tap that starts the assistant is that gesture, so `resume` resolves there.
18
+ */
19
+ const MONO = 1;
20
+ const FIRST_CHANNEL = 0;
21
+
22
+ const reasonOf = (error: unknown): string | undefined => (error instanceof Error ? error.message : undefined);
23
+
24
+ export class PcmPlayer implements AssistantPlayer {
25
+ private context: AudioContext | null = null;
26
+ private scheduled = new Set<AudioBufferSourceNode>();
27
+ private nextStartTime = 0;
28
+ private streamRate = 0;
29
+ private readonly levels = new LevelTimeline();
30
+
31
+ level(): number {
32
+ return this.context === null ? 0 : this.levels.levelAt(this.context.currentTime);
33
+ }
34
+
35
+ remainingSeconds(): number {
36
+ return this.context === null ? 0 : this.levels.remainingAt(this.context.currentTime);
37
+ }
38
+
39
+ async prepare(sampleRate: number): Promise<Result<void, AssistantFailure>> {
40
+ this.streamRate = sampleRate;
41
+ if (this.context !== null) return ok(undefined);
42
+
43
+ try {
44
+ this.context = new AudioContext();
45
+ this.nextStartTime = 0;
46
+ await this.context.resume();
47
+ return ok(undefined);
48
+ } catch (error) {
49
+ await this.stop();
50
+ return fail({ code: AssistantFailureCode.PlayerUnavailable, detail: reasonOf(error) });
51
+ }
52
+ }
53
+
54
+ enqueue(samples: Float32Array<ArrayBuffer>): void {
55
+ const context = this.context;
56
+ if (context === null || samples.length === 0) return;
57
+
58
+ const buffer = context.createBuffer(MONO, samples.length, this.streamRate);
59
+ buffer.copyToChannel(samples, FIRST_CHANNEL);
60
+
61
+ const source = context.createBufferSource();
62
+ source.buffer = buffer;
63
+ source.connect(context.destination);
64
+
65
+ const startAt = Math.max(this.nextStartTime, context.currentTime);
66
+ source.start(startAt);
67
+ this.nextStartTime = startAt + buffer.duration;
68
+ this.levels.push(samples, this.streamRate, context.currentTime);
69
+
70
+ this.scheduled.add(source);
71
+ source.onended = () => this.scheduled.delete(source);
72
+ }
73
+
74
+ flush(): void {
75
+ for (const source of this.scheduled) source.stop();
76
+ this.scheduled.clear();
77
+ this.nextStartTime = this.context?.currentTime ?? 0;
78
+ this.levels.clear();
79
+ }
80
+
81
+ async stop(): Promise<void> {
82
+ this.flush();
83
+ const context = this.context;
84
+ this.context = null;
85
+ await context?.close();
86
+ }
87
+ }