@convis/audio-native 0.0.7 → 0.0.8-rc1

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/dist/index.d.ts CHANGED
@@ -11,6 +11,12 @@ export declare class AudioHardware {
11
11
  openOutputStream(cfg: StreamConfig): void
12
12
  openInputStream(cfg: StreamConfig): void
13
13
  write(pcm: Float32Array): void
14
+ /**
15
+ * Sets the output channels the audio is played on: "mono" (all channels), "left" or
16
+ * "right". It applies to the current and future output streams, and is ignored by mono
17
+ * devices.
18
+ */
19
+ setOutputChannel(channel: string): void
14
20
  setInputCallback(cb: ((err: Error | null, arg: Float32Array) => any)): void
15
21
  }
16
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@convis/audio-native",
3
- "version": "0.0.7",
3
+ "version": "0.0.8-rc1",
4
4
  "description": "Native Node (N-API) module for @convis/audio",
5
5
  "homepage": "https://github.com/k2d222/convis-audio",
6
6
  "bugs": "https://github.com/k2d222/convis-audio/issues",
@@ -22,12 +22,12 @@
22
22
  "@types/node": "^25.6.2"
23
23
  },
24
24
  "optionalDependencies": {
25
- "@convis/audio-aarch64-apple-darwin": "0.0.7",
26
- "@convis/audio-aarch64-pc-windows-msvc": "0.0.7",
27
- "@convis/audio-aarch64-unknown-linux-gnu": "0.0.7",
28
- "@convis/audio-x86_64-apple-darwin": "0.0.7",
29
- "@convis/audio-x86_64-unknown-linux-gnu": "0.0.7",
30
- "@convis/audio-x86_64-pc-windows-msvc": "0.0.7"
25
+ "@convis/audio-aarch64-pc-windows-msvc": "0.0.8-rc1",
26
+ "@convis/audio-aarch64-unknown-linux-gnu": "0.0.8-rc1",
27
+ "@convis/audio-x86_64-unknown-linux-gnu": "0.0.8-rc1",
28
+ "@convis/audio-x86_64-apple-darwin": "0.0.8-rc1",
29
+ "@convis/audio-aarch64-apple-darwin": "0.0.8-rc1",
30
+ "@convis/audio-x86_64-pc-windows-msvc": "0.0.8-rc1"
31
31
  },
32
32
  "napi": {
33
33
  "npmClient": "pnpm",
package/src/lib.rs CHANGED
@@ -1,12 +1,12 @@
1
1
  use std::sync::{
2
- atomic::{AtomicUsize, Ordering},
2
+ atomic::{AtomicU8, AtomicUsize, Ordering},
3
3
  Arc, Mutex,
4
4
  };
5
5
 
6
6
  use anyhow::Context;
7
7
  use cpal::{
8
8
  traits::{DeviceTrait, HostTrait, StreamTrait},
9
- Stream,
9
+ DeviceDescription, Stream,
10
10
  };
11
11
  use napi::{
12
12
  bindgen_prelude::Float32Array,
@@ -24,6 +24,11 @@ const NUM_CHANNELS: u16 = 1;
24
24
  const FRAME_SIZE: usize = (SAMPLE_RATE / 100) as usize; // 10 ms
25
25
  const RING_SIZE: usize = FRAME_SIZE * 10000; // number of frames to store in the buffer. Corresponds to 100s of audio.
26
26
 
27
+ // output channels the mono audio is played on, stored in `AudioHardware::output_channel`
28
+ const CHANNEL_MONO: u8 = 0;
29
+ const CHANNEL_LEFT: u8 = 1;
30
+ const CHANNEL_RIGHT: u8 = 2;
31
+
27
32
  #[napi]
28
33
  pub struct AudioHardware {
29
34
  host: cpal::Host,
@@ -32,6 +37,7 @@ pub struct AudioHardware {
32
37
  input_callback: Option<Arc<ThreadsafeFunction<Float32Array>>>,
33
38
  output_producer: Option<<HeapRb<f32> as Split>::Prod>,
34
39
  output_to_discard: Arc<AtomicUsize>,
40
+ output_channel: Arc<AtomicU8>,
35
41
  apm: Arc<Mutex<AudioProcessing>>,
36
42
  }
37
43
 
@@ -74,6 +80,7 @@ impl AudioHardware {
74
80
  input_callback: None,
75
81
  output_producer: None,
76
82
  output_to_discard: Arc::new(AtomicUsize::new(0)),
83
+ output_channel: Arc::new(AtomicU8::new(CHANNEL_MONO)),
77
84
  apm: Arc::new(Mutex::new(apm)),
78
85
  }
79
86
  }
@@ -120,7 +127,7 @@ impl AudioHardware {
120
127
  let id = device.id().ok()?;
121
128
  let desc = device.description().ok()?;
122
129
  Some(DeviceProperties {
123
- name: desc.name().to_string(),
130
+ name: device_name(&desc),
124
131
  id: id.to_string(),
125
132
  input: desc.supports_input(),
126
133
  output: desc.supports_output(),
@@ -144,8 +151,16 @@ impl AudioHardware {
144
151
  let id = cfg.device_id.parse()?;
145
152
  let device = self.host.device_by_id(&id).context("device unavailable")?;
146
153
 
154
+ // the stream has the channels of the device, so the mono audio can be played on the
155
+ // left or right channel only.
156
+ let channels = device
157
+ .default_output_config()
158
+ .map(|c| c.channels())
159
+ .unwrap_or(NUM_CHANNELS)
160
+ .max(NUM_CHANNELS);
161
+
147
162
  let cpal_config = cpal::StreamConfig {
148
- channels: NUM_CHANNELS,
163
+ channels,
149
164
  sample_rate: SAMPLE_RATE,
150
165
  buffer_size: cpal::BufferSize::Fixed(FRAME_SIZE as u32),
151
166
  };
@@ -153,24 +168,48 @@ impl AudioHardware {
153
168
  let (output_producer, mut output_consumer) = HeapRb::<f32>::new(RING_SIZE).split();
154
169
 
155
170
  let samples_to_discard = self.output_to_discard.clone();
171
+ let output_channel = self.output_channel.clone();
156
172
  let apm = self.apm.clone();
173
+ let channels = channels as usize;
174
+ let mut mono = vec![0.0f32; FRAME_SIZE];
157
175
 
158
176
  let stream = device
159
177
  .build_output_stream(
160
178
  &cpal_config,
161
179
  move |data: &mut [f32], _info: &cpal::OutputCallbackInfo| {
180
+ let frames = data.len() / channels;
181
+ mono.resize(frames, 0f32);
182
+
162
183
  let discard = samples_to_discard.load(Ordering::Acquire);
163
184
  if discard > 0 {
164
185
  let skip = discard.min(output_consumer.occupied_len());
165
186
  output_consumer.skip(skip);
166
187
  samples_to_discard.fetch_sub(skip, Ordering::Release);
167
188
  }
168
- let len = output_consumer.pop_slice(data);
169
- data[len..].fill(0f32);
189
+ let len = output_consumer.pop_slice(&mut mono);
190
+ mono[len..].fill(0f32);
191
+
192
+ // mono plays on every channel (surround too), and so does a mono device
193
+ // whatever the selected channel. Left and right are the first two channels.
194
+ let only = match output_channel.load(Ordering::Relaxed) {
195
+ _ if channels == 1 => None,
196
+ CHANNEL_LEFT => Some(0),
197
+ CHANNEL_RIGHT => Some(1),
198
+ _ => None,
199
+ };
200
+ for (frame, &sample) in data.chunks_exact_mut(channels).zip(mono.iter()) {
201
+ match only {
202
+ Some(channel) => {
203
+ frame.fill(0f32);
204
+ frame[channel] = sample;
205
+ }
206
+ None => frame.fill(sample),
207
+ }
208
+ }
170
209
 
171
210
  // Tell AEC what we are sending to the speakers.
172
211
  if let Ok(mut apm) = apm.lock() {
173
- apm.process_render_f32(&[&data], &mut []).ok();
212
+ apm.process_render_f32(&[&mono], &mut []).ok();
174
213
  }
175
214
  },
176
215
  |err| eprintln!("output stream error: {err}"),
@@ -193,8 +232,16 @@ impl AudioHardware {
193
232
  let id = cfg.device_id.parse()?;
194
233
  let device = self.host.device_by_id(&id).context("device unavailable")?;
195
234
 
235
+ // the stream has the channels of the device and is downmixed here: some multi-channel
236
+ // devices deliver silence when Windows converts their input to mono.
237
+ let channels = device
238
+ .default_input_config()
239
+ .map(|c| c.channels())
240
+ .unwrap_or(NUM_CHANNELS)
241
+ .max(NUM_CHANNELS);
242
+
196
243
  let cpal_config = cpal::StreamConfig {
197
- channels: NUM_CHANNELS,
244
+ channels,
198
245
  sample_rate: SAMPLE_RATE,
199
246
  buffer_size: cpal::BufferSize::Fixed(FRAME_SIZE as u32),
200
247
  };
@@ -202,30 +249,43 @@ impl AudioHardware {
202
249
  let callback = self.input_callback.clone();
203
250
 
204
251
  let apm = self.apm.clone();
252
+ let channels = channels as usize;
253
+ // mono samples not processed yet, the device does not always deliver whole frames
254
+ let mut pending: Vec<f32> = Vec::with_capacity(FRAME_SIZE * 4);
205
255
  let mut processed = vec![0.0f32; FRAME_SIZE];
206
256
 
207
257
  let input_stream = device
208
258
  .build_input_stream(
209
259
  &cpal_config,
210
260
  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
- }
261
+ let Some(cb) = &callback else {
262
+ return;
263
+ };
264
+
265
+ // downmix to mono the way Windows does, the average of the channels
266
+ pending.extend(
267
+ data.chunks_exact(channels)
268
+ .map(|frame| frame.iter().sum::<f32>() / channels as f32),
269
+ );
270
+
271
+ // the AEC works on frames of exactly FRAME_SIZE samples
272
+ while pending.len() >= FRAME_SIZE {
273
+ let frame = &pending[..FRAME_SIZE];
216
274
 
217
275
  // in case AEC fails, we still have the unprocessed data in `processed`
218
- processed.copy_from_slice(data);
276
+ processed.copy_from_slice(frame);
219
277
 
220
278
  // Process capture (apply AEC to mic input).
221
279
  if let Ok(mut apm) = apm.lock() {
222
- apm.process_capture_f32(&[data], &mut [&mut processed]).ok();
280
+ apm.process_capture_f32(&[frame], &mut [&mut processed])
281
+ .ok();
223
282
  }
224
283
 
225
284
  cb.call(
226
285
  Ok(Float32Array::from(processed.clone())),
227
286
  ThreadsafeFunctionCallMode::NonBlocking,
228
287
  );
288
+ pending.drain(..FRAME_SIZE);
229
289
  }
230
290
  },
231
291
  |err| eprintln!("input stream error: {err}"),
@@ -249,8 +309,35 @@ impl AudioHardware {
249
309
  }
250
310
  }
251
311
 
312
+ /// Sets the output channels the audio is played on: "mono" (all channels), "left" or
313
+ /// "right". It applies to the current and future output streams, and is ignored by mono
314
+ /// devices.
315
+ #[napi]
316
+ pub fn set_output_channel(&self, channel: String) -> anyhow::Result<()> {
317
+ let value = match channel.as_str() {
318
+ "mono" => CHANNEL_MONO,
319
+ "left" => CHANNEL_LEFT,
320
+ "right" => CHANNEL_RIGHT,
321
+ _ => anyhow::bail!("unknown output channel: {channel}"),
322
+ };
323
+ self.output_channel.store(value, Ordering::Relaxed);
324
+ Ok(())
325
+ }
326
+
252
327
  #[napi]
253
328
  pub fn set_input_callback(&mut self, cb: Arc<ThreadsafeFunction<Float32Array>>) {
254
329
  self.input_callback = Some(cb);
255
330
  }
256
331
  }
332
+
333
+ /// The name of a device as the OS shows it. On Windows that is the friendly name, e.g.
334
+ /// "Speakers (USB Audio Device)" rather than the "Speakers" of [`DeviceDescription::name`];
335
+ /// cpal puts it in the extended lines when it differs from the name. The launcher names the
336
+ /// devices the same way (`apps/launcher/src-tauri/src/audio_devices.rs` in convis).
337
+ fn device_name(desc: &DeviceDescription) -> String {
338
+ #[cfg(windows)]
339
+ if let Some(friendly_name) = desc.extended().first() {
340
+ return friendly_name.clone();
341
+ }
342
+ desc.name().to_string()
343
+ }