@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 +21 -0
- package/README.md +9 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/microphone.d.ts +13 -0
- package/dist/microphone.d.ts.map +1 -0
- package/dist/microphone.js +101 -0
- package/dist/microphone.js.map +1 -0
- package/dist/microphone.web.d.ts +17 -0
- package/dist/microphone.web.d.ts.map +1 -0
- package/dist/microphone.web.js +106 -0
- package/dist/microphone.web.js.map +1 -0
- package/dist/pcm-player.d.ts +15 -0
- package/dist/pcm-player.d.ts.map +1 -0
- package/dist/pcm-player.js +88 -0
- package/dist/pcm-player.js.map +1 -0
- package/dist/pcm-player.web.d.ts +15 -0
- package/dist/pcm-player.web.d.ts.map +1 -0
- package/dist/pcm-player.web.js +83 -0
- package/dist/pcm-player.web.js.map +1 -0
- package/package.json +49 -0
- package/src/index.ts +2 -0
- package/src/microphone.ts +106 -0
- package/src/microphone.web.ts +110 -0
- package/src/pcm-player.ts +92 -0
- package/src/pcm-player.web.ts +87 -0
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).
|
package/dist/index.d.ts
ADDED
|
@@ -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":"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"}
|
|
@@ -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,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
|
+
}
|