@convis/audio-native 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/audio.node +0 -0
- package/dist/index.d.ts +26 -0
- package/package.json +50 -0
- package/src/audio-decode.d.ts +8 -0
- package/src/lib.rs +256 -0
package/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
This module is written in Rust. We generate Node (N-API) bindings using [napi-rs](https://napi.rs).
|
|
2
|
+
|
|
3
|
+
It comprises a main module `@convis/audio` which contains the TypeScript code and glue code to load the native module (`dist/index.js`). It optionally depends on three modules `@convis/audio-<target>` which contain just the pre-compiled `.node` file. This is a common approach in Node.
|
|
4
|
+
|
|
5
|
+
## Local development (`pnpm dev` in current folder)
|
|
6
|
+
|
|
7
|
+
napi-rs compiles Rust and generates 3 files in `dist/`: `audio.<local target>.node`, `index.js`, and `index.d.ts`.
|
|
8
|
+
* The `.node` file is gitignored. It contains the native Rust module.
|
|
9
|
+
* `index.js` is the glue code to import the `.node` file. It finds it either in the same directory, or in one of the precompiled modules.
|
|
10
|
+
* `index.d.ts` contains automatically-generated typings for the exported Rust methods and structs.
|
|
11
|
+
|
|
12
|
+
## Releases
|
|
13
|
+
|
|
14
|
+
* Trigger the GitHub workflow here: <https://github.com/k2d222/convis-audio/actions/workflows/build.yml>
|
|
15
|
+
* When completed: `pnpm download-artifacts`
|
|
16
|
+
* Check that the `.node` files were copied to `platforms/<target>/audio-<target>.node`
|
|
17
|
+
* Bump package versions and commit
|
|
18
|
+
* `pnpm publish -r`
|
package/dist/audio.node
ADDED
|
Binary file
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* auto-generated by NAPI-RS */
|
|
2
|
+
/* eslint-disable */
|
|
3
|
+
export declare class AudioHardware {
|
|
4
|
+
constructor()
|
|
5
|
+
apiName(): string
|
|
6
|
+
getSampleRate(): number
|
|
7
|
+
clearOutputQueue(): void
|
|
8
|
+
defaultInput(): string | null
|
|
9
|
+
defaultOutput(): string | null
|
|
10
|
+
listDevices(): Array<DeviceProperties>
|
|
11
|
+
openOutputStream(cfg: StreamConfig): void
|
|
12
|
+
openInputStream(cfg: StreamConfig): void
|
|
13
|
+
write(pcm: Float32Array): void
|
|
14
|
+
setInputCallback(cb: ((err: Error | null, arg: Float32Array) => any)): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DeviceProperties {
|
|
18
|
+
input: boolean
|
|
19
|
+
output: boolean
|
|
20
|
+
name: string
|
|
21
|
+
id: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StreamConfig {
|
|
25
|
+
deviceId: string
|
|
26
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@convis/audio-native",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Native Node (N-API) module for @convis/audio",
|
|
5
|
+
"homepage": "https://github.com/k2d222/convis-audio",
|
|
6
|
+
"bugs": "https://github.com/k2d222/convis-audio/issues",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Mathis Brossier <mathis.brossier@gmail.com>",
|
|
9
|
+
"repository": "github:k2d222/convis-audio",
|
|
10
|
+
"files": [
|
|
11
|
+
"./dist/*",
|
|
12
|
+
"./src/*"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"node": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@napi-rs/cli": "^3.2.0",
|
|
21
|
+
"@types/node": "^25.6.2"
|
|
22
|
+
},
|
|
23
|
+
"optionalDependencies": {
|
|
24
|
+
"@convis/audio-aarch64-apple-darwin": "0.0.1",
|
|
25
|
+
"@convis/audio-x86_64-pc-windows-msvc": "0.0.1",
|
|
26
|
+
"@convis/audio-x86_64-unknown-linux-gnu": "0.0.1",
|
|
27
|
+
"@convis/audio-x86_64-apple-darwin": "0.0.1",
|
|
28
|
+
"@convis/audio-aarch64-pc-windows-msvc": "0.0.1",
|
|
29
|
+
"@convis/audio-aarch64-unknown-linux-gnu": "0.0.1"
|
|
30
|
+
},
|
|
31
|
+
"napi": {
|
|
32
|
+
"npmClient": "pnpm",
|
|
33
|
+
"binaryName": "audio",
|
|
34
|
+
"targets": [
|
|
35
|
+
"x86_64-unknown-linux-gnu",
|
|
36
|
+
"x86_64-pc-windows-msvc",
|
|
37
|
+
"x86_64-apple-darwin",
|
|
38
|
+
"aarch64-unknown-linux-gnu",
|
|
39
|
+
"aarch64-pc-windows-msvc",
|
|
40
|
+
"aarch64-apple-darwin"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "rm -rf dist/ && pnpm run napi:build:release",
|
|
45
|
+
"dev": "pnpm napi:build:debug --watch",
|
|
46
|
+
"napi:build:debug": "napi build -o dist/",
|
|
47
|
+
"napi:build:release": "napi build --release --strip -o dist/",
|
|
48
|
+
"download-artifacts": "sh scripts/download-artifacts.sh"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/lib.rs
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
use std::sync::{
|
|
2
|
+
atomic::{AtomicUsize, Ordering},
|
|
3
|
+
Arc, Mutex,
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
use anyhow::Context;
|
|
7
|
+
use cpal::{
|
|
8
|
+
traits::{DeviceTrait, HostTrait, StreamTrait},
|
|
9
|
+
Stream,
|
|
10
|
+
};
|
|
11
|
+
use napi::{
|
|
12
|
+
bindgen_prelude::Float32Array,
|
|
13
|
+
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
|
14
|
+
};
|
|
15
|
+
use napi_derive::napi;
|
|
16
|
+
use ringbuf::{
|
|
17
|
+
traits::{Consumer, Observer, Producer, Split},
|
|
18
|
+
HeapRb,
|
|
19
|
+
};
|
|
20
|
+
use sonora::AudioProcessing;
|
|
21
|
+
|
|
22
|
+
const SAMPLE_RATE: u32 = 24_000;
|
|
23
|
+
const NUM_CHANNELS: u16 = 1;
|
|
24
|
+
const FRAME_SIZE: usize = (SAMPLE_RATE / 100) as usize; // 10 ms
|
|
25
|
+
const RING_SIZE: usize = FRAME_SIZE * 1024;
|
|
26
|
+
|
|
27
|
+
#[napi]
|
|
28
|
+
pub struct AudioHardware {
|
|
29
|
+
host: cpal::Host,
|
|
30
|
+
input_stream: Option<Stream>,
|
|
31
|
+
output_stream: Option<Stream>,
|
|
32
|
+
input_callback: Option<Arc<ThreadsafeFunction<Float32Array>>>,
|
|
33
|
+
output_producer: Option<<HeapRb<f32> as Split>::Prod>,
|
|
34
|
+
output_to_discard: Arc<AtomicUsize>,
|
|
35
|
+
apm: Arc<Mutex<AudioProcessing>>,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#[napi(object)]
|
|
39
|
+
pub struct StreamConfig {
|
|
40
|
+
pub device_id: String,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#[napi(object)]
|
|
44
|
+
pub struct DeviceProperties {
|
|
45
|
+
pub input: bool,
|
|
46
|
+
pub output: bool,
|
|
47
|
+
pub name: String,
|
|
48
|
+
pub id: String,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#[napi]
|
|
52
|
+
impl AudioHardware {
|
|
53
|
+
#[napi(constructor)]
|
|
54
|
+
pub fn new() -> Self {
|
|
55
|
+
let apm_config = sonora::Config {
|
|
56
|
+
echo_canceller: Some(sonora::config::EchoCanceller::default()),
|
|
57
|
+
noise_suppression: Some(sonora::config::NoiseSuppression::default()),
|
|
58
|
+
gain_controller2: Some(sonora::config::GainController2::default()),
|
|
59
|
+
..Default::default()
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
let stream_config = sonora::StreamConfig::new(SAMPLE_RATE, NUM_CHANNELS);
|
|
63
|
+
|
|
64
|
+
let apm = AudioProcessing::builder()
|
|
65
|
+
.config(apm_config)
|
|
66
|
+
.capture_config(stream_config)
|
|
67
|
+
.render_config(stream_config)
|
|
68
|
+
.build();
|
|
69
|
+
|
|
70
|
+
Self {
|
|
71
|
+
host: cpal::default_host(),
|
|
72
|
+
input_stream: None,
|
|
73
|
+
output_stream: None,
|
|
74
|
+
input_callback: None,
|
|
75
|
+
output_producer: None,
|
|
76
|
+
output_to_discard: Arc::new(AtomicUsize::new(0)),
|
|
77
|
+
apm: Arc::new(Mutex::new(apm)),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[napi]
|
|
82
|
+
pub fn api_name(&self) -> String {
|
|
83
|
+
self.host.id().to_string()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#[napi]
|
|
87
|
+
pub fn get_sample_rate(&self) -> u32 {
|
|
88
|
+
SAMPLE_RATE as u32
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#[napi]
|
|
92
|
+
pub fn clear_output_queue(&mut self) {
|
|
93
|
+
if let Some(producer) = &self.output_producer {
|
|
94
|
+
self.output_to_discard
|
|
95
|
+
.store(producer.occupied_len(), Ordering::Release);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#[napi]
|
|
100
|
+
pub fn default_input(&self) -> Option<String> {
|
|
101
|
+
self.host
|
|
102
|
+
.default_input_device()
|
|
103
|
+
.and_then(|device| device.id().ok())
|
|
104
|
+
.map(|id| id.to_string())
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#[napi]
|
|
108
|
+
pub fn default_output(&self) -> Option<String> {
|
|
109
|
+
self.host
|
|
110
|
+
.default_output_device()
|
|
111
|
+
.and_then(|device| device.id().ok())
|
|
112
|
+
.map(|id| id.to_string())
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#[napi]
|
|
116
|
+
pub fn list_devices(&self) -> anyhow::Result<Vec<DeviceProperties>> {
|
|
117
|
+
let props = match self.host.devices() {
|
|
118
|
+
Ok(devices) => devices
|
|
119
|
+
.filter_map(|device| {
|
|
120
|
+
let id = device.id().ok()?;
|
|
121
|
+
let desc = device.description().ok()?;
|
|
122
|
+
Some(DeviceProperties {
|
|
123
|
+
name: desc.name().to_string(),
|
|
124
|
+
id: id.to_string(),
|
|
125
|
+
input: desc.supports_input(),
|
|
126
|
+
output: desc.supports_output(),
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
.collect(),
|
|
130
|
+
Err(e) => {
|
|
131
|
+
eprintln!("failed to list audio devices: {e}");
|
|
132
|
+
vec![]
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
Ok(props)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#[napi]
|
|
140
|
+
pub fn open_output_stream(&mut self, cfg: StreamConfig) -> anyhow::Result<()> {
|
|
141
|
+
self.output_stream.take(); // close the stream
|
|
142
|
+
self.output_producer.take();
|
|
143
|
+
|
|
144
|
+
let id = cfg.device_id.parse()?;
|
|
145
|
+
let device = self.host.device_by_id(&id).context("device unavailable")?;
|
|
146
|
+
|
|
147
|
+
let cpal_config = cpal::StreamConfig {
|
|
148
|
+
channels: NUM_CHANNELS,
|
|
149
|
+
sample_rate: SAMPLE_RATE,
|
|
150
|
+
buffer_size: cpal::BufferSize::Fixed(FRAME_SIZE as u32),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
let (output_producer, mut output_consumer) = HeapRb::<f32>::new(RING_SIZE).split();
|
|
154
|
+
|
|
155
|
+
let samples_to_discard = self.output_to_discard.clone();
|
|
156
|
+
let apm = self.apm.clone();
|
|
157
|
+
|
|
158
|
+
let stream = device
|
|
159
|
+
.build_output_stream(
|
|
160
|
+
&cpal_config,
|
|
161
|
+
move |data: &mut [f32], _info: &cpal::OutputCallbackInfo| {
|
|
162
|
+
let discard = samples_to_discard.load(Ordering::Acquire);
|
|
163
|
+
if discard > 0 {
|
|
164
|
+
let skip = discard.min(output_consumer.occupied_len());
|
|
165
|
+
output_consumer.skip(skip);
|
|
166
|
+
samples_to_discard.fetch_sub(skip, Ordering::Release);
|
|
167
|
+
}
|
|
168
|
+
let len = output_consumer.pop_slice(data);
|
|
169
|
+
data[len..].fill(0f32);
|
|
170
|
+
|
|
171
|
+
// Tell AEC what we are sending to the speakers.
|
|
172
|
+
if let Ok(mut apm) = apm.lock() {
|
|
173
|
+
apm.process_render_f32(&[&data], &mut []).ok();
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
|err| eprintln!("output stream error: {err}"),
|
|
177
|
+
None,
|
|
178
|
+
)
|
|
179
|
+
.context("failed to build output stream")?;
|
|
180
|
+
|
|
181
|
+
stream.play().context("failed to play output stream")?;
|
|
182
|
+
|
|
183
|
+
self.output_producer = Some(output_producer);
|
|
184
|
+
self.output_stream = Some(stream);
|
|
185
|
+
|
|
186
|
+
Ok(())
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#[napi]
|
|
190
|
+
pub fn open_input_stream(&mut self, cfg: StreamConfig) -> anyhow::Result<()> {
|
|
191
|
+
self.input_stream.take(); // close the stream
|
|
192
|
+
|
|
193
|
+
let id = cfg.device_id.parse()?;
|
|
194
|
+
let device = self.host.device_by_id(&id).context("device unavailable")?;
|
|
195
|
+
|
|
196
|
+
let cpal_config = cpal::StreamConfig {
|
|
197
|
+
channels: NUM_CHANNELS,
|
|
198
|
+
sample_rate: SAMPLE_RATE,
|
|
199
|
+
buffer_size: cpal::BufferSize::Fixed(FRAME_SIZE as u32),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
let callback = self.input_callback.clone();
|
|
203
|
+
|
|
204
|
+
let apm = self.apm.clone();
|
|
205
|
+
let mut processed = vec![0.0f32; FRAME_SIZE];
|
|
206
|
+
|
|
207
|
+
let input_stream = device
|
|
208
|
+
.build_input_stream(
|
|
209
|
+
&cpal_config,
|
|
210
|
+
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
|
211
|
+
if let Some(cb) = &callback {
|
|
212
|
+
if processed.len() != data.len() {
|
|
213
|
+
eprintln!("unexpected: processed.len() != data.len()");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// in case AEC fails, we still have the unprocessed data in `processed`
|
|
218
|
+
processed.copy_from_slice(data);
|
|
219
|
+
|
|
220
|
+
// Process capture (apply AEC to mic input).
|
|
221
|
+
if let Ok(mut apm) = apm.lock() {
|
|
222
|
+
apm.process_capture_f32(&[data], &mut [&mut processed]).ok();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
cb.call(
|
|
226
|
+
Ok(Float32Array::from(processed.clone())),
|
|
227
|
+
ThreadsafeFunctionCallMode::NonBlocking,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
|err| eprintln!("input stream error: {err}"),
|
|
232
|
+
None,
|
|
233
|
+
)
|
|
234
|
+
.context("failed to build input stream")?;
|
|
235
|
+
|
|
236
|
+
input_stream.play().context("failed to play input stream")?;
|
|
237
|
+
self.input_stream = Some(input_stream);
|
|
238
|
+
|
|
239
|
+
Ok(())
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#[napi]
|
|
243
|
+
pub fn write(&mut self, pcm: &[f32]) {
|
|
244
|
+
if let Some(output_producer) = &mut self.output_producer {
|
|
245
|
+
let len = output_producer.push_slice(pcm);
|
|
246
|
+
if len < pcm.len() {
|
|
247
|
+
eprintln!("audio overrun! pushed {len} bytes out of {}, buffer capacity: {}, buffer vacant: {}", pcm.len(), output_producer.capacity(), output_producer.vacant_len());
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#[napi]
|
|
253
|
+
pub fn set_input_callback(&mut self, cb: Arc<ThreadsafeFunction<Float32Array>>) {
|
|
254
|
+
self.input_callback = Some(cb);
|
|
255
|
+
}
|
|
256
|
+
}
|