@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.
- package/README.md +10 -4
- package/extensions/nes/index.ts +129 -24
- package/extensions/nes/native/nes-core/Cargo.lock +814 -1
- package/extensions/nes/native/nes-core/Cargo.toml +5 -0
- package/extensions/nes/native/nes-core/index.d.ts +1 -0
- package/extensions/nes/native/nes-core/index.node +0 -0
- package/extensions/nes/native/nes-core/native.d.ts +1 -0
- package/extensions/nes/native/nes-core/package.json +3 -1
- package/extensions/nes/native/nes-core/src/audio_cpal.rs +217 -0
- package/extensions/nes/native/nes-core/src/lib.rs +47 -1
- package/extensions/nes/nes-core.ts +6 -3
- package/package.json +1 -1
- package/spec.md +1 -1
|
@@ -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;
|
|
Binary file
|
|
@@ -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 =
|
|
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
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
|
|
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
|