@tmustier/pi-nes 0.2.34 → 0.2.36

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.
@@ -12,6 +12,11 @@ libc = "0.2"
12
12
  nes_rust = { path = "vendor/nes_rust" }
13
13
  napi = { version = "2", features = ["napi8"] }
14
14
  napi-derive = "2"
15
+ cpal = { version = "0.17.1", optional = true }
16
+ ringbuf = { version = "0.4.8", optional = true }
17
+
18
+ [features]
19
+ audio-cpal = ["cpal", "ringbuf"]
15
20
 
16
21
  [build-dependencies]
17
22
  napi-build = "2"
@@ -32,6 +32,7 @@ export class NativeNes {
32
32
  stepFrame(): void;
33
33
  refreshFramebuffer(): void;
34
34
  setVideoFilter(mode: number): void;
35
+ setAudioEnabled(enabled: boolean): boolean;
35
36
  pressButton(button: number): void;
36
37
  releaseButton(button: number): void;
37
38
  hasBatteryBackedRam(): boolean;
@@ -34,6 +34,7 @@ export declare class NativeNes {
34
34
  stepFrame(): void
35
35
  refreshFramebuffer(): void
36
36
  setVideoFilter(mode: number): void
37
+ setAudioEnabled(enabled: boolean): boolean
37
38
  pressButton(button: number): void
38
39
  releaseButton(button: number): void
39
40
  hasBatteryBackedRam(): boolean
@@ -6,7 +6,9 @@
6
6
  "types": "index.d.ts",
7
7
  "scripts": {
8
8
  "build": "napi build --release --dts native.d.ts",
9
- "build:debug": "napi build --dts native.d.ts"
9
+ "build:debug": "napi build --dts native.d.ts",
10
+ "build:audio": "napi build --release --features audio-cpal --dts native.d.ts",
11
+ "build:audio:debug": "napi build --features audio-cpal --dts native.d.ts"
10
12
  },
11
13
  "devDependencies": {
12
14
  "@napi-rs/cli": "^2.18.2"
@@ -0,0 +1,217 @@
1
+ use std::sync::{Arc, Mutex};
2
+
3
+ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
4
+ use cpal::{SampleFormat, StreamConfig};
5
+ use nes_rust::audio::Audio;
6
+ use ringbuf::{traits::{Consumer, Producer, Split}, HeapCons, HeapProd, HeapRb};
7
+
8
+ const TARGET_SAMPLE_RATE: u32 = 44_100;
9
+ const RING_BUFFER_CAPACITY: usize = 44_100 * 2;
10
+
11
+ #[derive(Clone)]
12
+ pub struct CpalAudio {
13
+ inner: Arc<Mutex<CpalAudioInner>>,
14
+ }
15
+
16
+ struct CpalAudioInner {
17
+ producer: HeapProd<f32>,
18
+ consumer: HeapCons<f32>,
19
+ stream: Option<cpal::Stream>,
20
+ last_sample: f32,
21
+ channels: u16,
22
+ }
23
+
24
+ impl CpalAudio {
25
+ pub fn new() -> Self {
26
+ let ring = HeapRb::<f32>::new(RING_BUFFER_CAPACITY);
27
+ let (producer, consumer) = ring.split();
28
+ Self {
29
+ inner: Arc::new(Mutex::new(CpalAudioInner {
30
+ producer,
31
+ consumer,
32
+ stream: None,
33
+ last_sample: 0.0,
34
+ channels: 2,
35
+ })),
36
+ }
37
+ }
38
+
39
+ pub fn set_enabled(&self, enabled: bool) -> bool {
40
+ if enabled {
41
+ self.start_stream()
42
+ } else {
43
+ self.stop_stream();
44
+ true
45
+ }
46
+ }
47
+
48
+ fn stop_stream(&self) {
49
+ if let Ok(mut inner) = self.inner.lock() {
50
+ inner.stream.take();
51
+ }
52
+ }
53
+
54
+ fn start_stream(&self) -> bool {
55
+ {
56
+ if let Ok(inner) = self.inner.lock() {
57
+ if inner.stream.is_some() {
58
+ return true;
59
+ }
60
+ }
61
+ }
62
+
63
+ let host = cpal::default_host();
64
+ let device = match host.default_output_device() {
65
+ Some(device) => device,
66
+ None => return false,
67
+ };
68
+
69
+ let config = select_output_config(&device).or_else(|| device.default_output_config().ok());
70
+ let config = match config {
71
+ Some(config) => config,
72
+ None => return false,
73
+ };
74
+
75
+ let sample_format = config.sample_format();
76
+ let stream_config: StreamConfig = config.clone().into();
77
+ let channels = stream_config.channels;
78
+ let inner = self.inner.clone();
79
+
80
+ let stream = match sample_format {
81
+ SampleFormat::F32 => device.build_output_stream(
82
+ &stream_config,
83
+ move |data: &mut [f32], _| fill_output_f32(data, channels, &inner),
84
+ log_stream_error,
85
+ None,
86
+ ),
87
+ SampleFormat::I16 => device.build_output_stream(
88
+ &stream_config,
89
+ move |data: &mut [i16], _| fill_output_i16(data, channels, &inner),
90
+ log_stream_error,
91
+ None,
92
+ ),
93
+ SampleFormat::U16 => device.build_output_stream(
94
+ &stream_config,
95
+ move |data: &mut [u16], _| fill_output_u16(data, channels, &inner),
96
+ log_stream_error,
97
+ None,
98
+ ),
99
+ _ => return false,
100
+ };
101
+
102
+ let stream = match stream {
103
+ Ok(stream) => stream,
104
+ Err(_) => return false,
105
+ };
106
+
107
+ if stream.play().is_err() {
108
+ return false;
109
+ }
110
+
111
+ if let Ok(mut inner) = self.inner.lock() {
112
+ inner.channels = channels;
113
+ inner.stream = Some(stream);
114
+ }
115
+
116
+ true
117
+ }
118
+ }
119
+
120
+ impl Audio for CpalAudio {
121
+ fn push(&mut self, value: f32) {
122
+ if let Ok(mut inner) = self.inner.lock() {
123
+ let _ = inner.producer.try_push(value);
124
+ }
125
+ }
126
+
127
+ fn copy_sample_buffer(&mut self, sample_buffer: &mut [f32]) {
128
+ if let Ok(mut inner) = self.inner.lock() {
129
+ for sample in sample_buffer.iter_mut() {
130
+ *sample = next_sample(&mut inner);
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ fn select_output_config(device: &cpal::Device) -> Option<cpal::SupportedStreamConfig> {
137
+ let configs = device.supported_output_configs().ok()?;
138
+ let mut best: Option<(i32, cpal::SupportedStreamConfig)> = None;
139
+
140
+ for config in configs {
141
+ let min = config.min_sample_rate();
142
+ let max = config.max_sample_rate();
143
+ if TARGET_SAMPLE_RATE < min || TARGET_SAMPLE_RATE > max {
144
+ continue;
145
+ }
146
+ let score = score_config(&config);
147
+ let config = config.with_sample_rate(TARGET_SAMPLE_RATE);
148
+ if best.as_ref().map(|(best_score, _)| score > *best_score).unwrap_or(true) {
149
+ best = Some((score, config));
150
+ }
151
+ }
152
+
153
+ best.map(|(_, config)| config)
154
+ }
155
+
156
+ fn score_config(config: &cpal::SupportedStreamConfigRange) -> i32 {
157
+ let mut score = 0;
158
+ match config.sample_format() {
159
+ SampleFormat::F32 => score += 100,
160
+ SampleFormat::I16 => score += 60,
161
+ SampleFormat::U16 => score += 50,
162
+ _ => score += 0,
163
+ }
164
+ if config.channels() >= 2 {
165
+ score += 10;
166
+ }
167
+ score
168
+ }
169
+
170
+ fn fill_output_f32(output: &mut [f32], channels: u16, inner: &Arc<Mutex<CpalAudioInner>>) {
171
+ if let Ok(mut inner) = inner.lock() {
172
+ for frame in output.chunks_mut(channels as usize) {
173
+ let sample = next_sample(&mut inner);
174
+ for out in frame.iter_mut() {
175
+ *out = sample;
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ fn fill_output_i16(output: &mut [i16], channels: u16, inner: &Arc<Mutex<CpalAudioInner>>) {
182
+ if let Ok(mut inner) = inner.lock() {
183
+ for frame in output.chunks_mut(channels as usize) {
184
+ let sample = next_sample(&mut inner);
185
+ let value = (sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
186
+ for out in frame.iter_mut() {
187
+ *out = value;
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ fn fill_output_u16(output: &mut [u16], channels: u16, inner: &Arc<Mutex<CpalAudioInner>>) {
194
+ if let Ok(mut inner) = inner.lock() {
195
+ for frame in output.chunks_mut(channels as usize) {
196
+ let sample = next_sample(&mut inner);
197
+ let normalized = (sample.clamp(-1.0, 1.0) + 1.0) * 0.5;
198
+ let value = (normalized * u16::MAX as f32) as u16;
199
+ for out in frame.iter_mut() {
200
+ *out = value;
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ fn next_sample(inner: &mut CpalAudioInner) -> f32 {
207
+ if let Some(sample) = inner.consumer.try_pop() {
208
+ inner.last_sample = sample;
209
+ sample
210
+ } else {
211
+ inner.last_sample
212
+ }
213
+ }
214
+
215
+ fn log_stream_error(error: cpal::StreamError) {
216
+ eprintln!("NES audio stream error: {error}");
217
+ }
@@ -1,14 +1,53 @@
1
1
  use napi::bindgen_prelude::Uint8Array;
2
2
  use napi_derive::napi;
3
+ use nes_rust::audio::Audio;
3
4
  use nes_rust::button::Button;
5
+ #[cfg(not(feature = "audio-cpal"))]
4
6
  use nes_rust::default_audio::DefaultAudio;
5
7
  use nes_rust::default_input::DefaultInput;
6
8
  use nes_rust::display::{Display, SCREEN_HEIGHT, SCREEN_WIDTH};
7
9
  use nes_rust::rom::Rom;
8
10
  use nes_rust::Nes;
9
11
 
12
+ #[cfg(feature = "audio-cpal")]
13
+ mod audio_cpal;
14
+ #[cfg(feature = "audio-cpal")]
15
+ use audio_cpal::CpalAudio;
16
+
10
17
  const FRAME_BYTE_LEN: usize = (SCREEN_WIDTH * SCREEN_HEIGHT * 3) as usize;
11
18
 
19
+ enum AudioBackend {
20
+ #[cfg(feature = "audio-cpal")]
21
+ Cpal(CpalAudio),
22
+ #[cfg(not(feature = "audio-cpal"))]
23
+ Default,
24
+ }
25
+
26
+ impl AudioBackend {
27
+ fn set_enabled(&self, enabled: bool) -> bool {
28
+ match self {
29
+ #[cfg(feature = "audio-cpal")]
30
+ AudioBackend::Cpal(audio) => audio.set_enabled(enabled),
31
+ #[cfg(not(feature = "audio-cpal"))]
32
+ AudioBackend::Default => false,
33
+ }
34
+ }
35
+ }
36
+
37
+ fn create_audio_backend() -> (Box<dyn Audio>, AudioBackend) {
38
+ #[cfg(feature = "audio-cpal")]
39
+ {
40
+ let audio = CpalAudio::new();
41
+ return (Box::new(audio.clone()), AudioBackend::Cpal(audio));
42
+ }
43
+
44
+ #[cfg(not(feature = "audio-cpal"))]
45
+ {
46
+ let audio = DefaultAudio::new();
47
+ return (Box::new(audio), AudioBackend::Default);
48
+ }
49
+ }
50
+
12
51
  #[derive(Clone, Copy, PartialEq, Eq)]
13
52
  enum VideoFilterMode {
14
53
  Off,
@@ -96,6 +135,7 @@ pub struct NativeNes {
96
135
  framebuffer: Vec<u8>,
97
136
  filter_buffer: Vec<u8>,
98
137
  video_filter: VideoFilterMode,
138
+ audio_backend: AudioBackend,
99
139
  }
100
140
 
101
141
  #[napi]
@@ -104,13 +144,14 @@ impl NativeNes {
104
144
  pub fn new() -> Self {
105
145
  let input = Box::new(DefaultInput::new());
106
146
  let display = Box::new(NativeDisplay::new());
107
- let audio = Box::new(DefaultAudio::new());
147
+ let (audio, audio_backend) = create_audio_backend();
108
148
  let nes = Nes::new(input, display, audio);
109
149
  Self {
110
150
  nes,
111
151
  framebuffer: vec![0; FRAME_BYTE_LEN],
112
152
  filter_buffer: vec![0; FRAME_BYTE_LEN],
113
153
  video_filter: VideoFilterMode::Off,
154
+ audio_backend,
114
155
  }
115
156
  }
116
157
 
@@ -149,6 +190,11 @@ impl NativeNes {
149
190
  };
150
191
  }
151
192
 
193
+ #[napi]
194
+ pub fn set_audio_enabled(&mut self, enabled: bool) -> bool {
195
+ self.audio_backend.set_enabled(enabled)
196
+ }
197
+
152
198
  #[napi]
153
199
  pub fn press_button(&mut self, button: u8) {
154
200
  if let Some(mapped) = map_button(button) {
@@ -61,6 +61,7 @@ interface NativeNesInstance {
61
61
  stepFrame(): void;
62
62
  refreshFramebuffer(): void;
63
63
  setVideoFilter(mode: number): void;
64
+ setAudioEnabled(enabled: boolean): boolean;
64
65
  pressButton(button: number): void;
65
66
  releaseButton(button: number): void;
66
67
  hasBatteryBackedRam(): boolean;
@@ -118,15 +119,16 @@ class NativeNesCore implements NesCore {
118
119
  private hasSram = false;
119
120
 
120
121
  constructor(enableAudio: boolean, videoFilter: VideoFilterMode) {
121
- this.audioWarning = enableAudio
122
- ? "Audio output is disabled (no safe dependency available)."
123
- : null;
124
122
  const module = getNativeModule();
125
123
  if (!module) {
126
124
  throw new Error("Native NES core addon is not available.");
127
125
  }
128
126
  this.nes = new module.NativeNes();
129
127
  this.nes.setVideoFilter(NATIVE_VIDEO_FILTER_MAP[videoFilter]);
128
+ const audioEnabled = this.nes.setAudioEnabled(enableAudio);
129
+ this.audioWarning = enableAudio && !audioEnabled
130
+ ? "Audio output unavailable. Rebuild the native core with --features audio-cpal."
131
+ : null;
130
132
  this.frameBuffer = this.nes.getFramebuffer();
131
133
  }
132
134
 
@@ -192,6 +194,7 @@ class NativeNesCore implements NesCore {
192
194
 
193
195
  dispose(): void {
194
196
  // No explicit native teardown required; napi instance is GC-managed.
197
+ this.nes.setAudioEnabled(false);
195
198
  this.hasSram = false;
196
199
  }
197
200
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-nes",
3
- "version": "0.2.34",
3
+ "version": "0.2.36",
4
4
  "description": "NES emulator extension for pi",
5
5
  "keywords": [
6
6
  "pi-package",
package/spec.md CHANGED
@@ -76,7 +76,7 @@ pi-nes/
76
76
  - `pixelScale` (float, e.g. 1.0)
77
77
  - `keybindings` (button-to-keys map, e.g. `{ "a": ["z"] }`)
78
78
 
79
- Note: audio output is currently disabled; setting `enableAudio` will show a warning.
79
+ Note: audio output is opt-in; requires a native core built with `audio-cpal` and `enableAudio: true`.
80
80
 
81
81
  ## Milestones
82
82
  1. Skeleton extension + `/nes` command + overlay renderer