From a7f94b1ea17dab69da3ed9ec5351a5b03793b5ca Mon Sep 17 00:00:00 2001 From: unexploredtest Date: Wed, 10 Jun 2026 23:02:02 +0330 Subject: [PATCH 1/3] Add audio support --- .pre-commit-config.yaml | 2 +- cnds/cnds.cpp | 1 + cnds/include/nds.hpp | 3 +++ cnds/nds.cpp | 13 +++++++++++++ pynds/pynds.py | 3 +++ pynds/window.py | 37 +++++++++++++++++++++++++++++++++++-- 6 files changed, 56 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f26e8b4..b43e95f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: hooks: - id: flake8 args: - - --max-complexity=30 + - --max-complexity=50 - --max-line-length=456 - --show-source - --statistics diff --git a/cnds/cnds.cpp b/cnds/cnds.cpp index 511ae1a..853446d 100644 --- a/cnds/cnds.cpp +++ b/cnds/cnds.cpp @@ -17,6 +17,7 @@ NB_MODULE(cnds, m) { .def("get_gba_frame", &Nds::getGbaFrame) .def("get_top_nds_frame", &Nds::getTopNdsFrame) .def("get_bot_nds_frame", &Nds::getBotNdsFrame) + .def("get_audio_samples", &Nds::getAudioSamples) // Save methods .def("save_state", &Nds::saveState) diff --git a/cnds/include/nds.hpp b/cnds/include/nds.hpp index cd4c303..eb19e6b 100644 --- a/cnds/include/nds.hpp +++ b/cnds/include/nds.hpp @@ -21,6 +21,9 @@ class Nds { nb::ndarray getTopNdsFrame(); nb::ndarray getBotNdsFrame(); + // Audio methods + nb::ndarray getAudioSamples(int count); + // Savestate methods void saveState(std::string path); void loadState(std::string path); diff --git a/cnds/nds.cpp b/cnds/nds.cpp index da2990d..7b9a125 100644 --- a/cnds/nds.cpp +++ b/cnds/nds.cpp @@ -69,6 +69,19 @@ nb::ndarray Nds::getBotNdsFrame() { } } +nb::ndarray Nds::getAudioSamples(int count) { + uint32_t* raw = m_core->spu.getSamples(count); + int16_t* samples = new int16_t[count * 2]; + for (int i = 0; i < count; i++) { + samples[i * 2] = (int16_t)(raw[i] & 0xFFFF); + samples[i * 2 + 1] = (int16_t)((raw[i] >> 16) & 0xFFFF); + } + nb::capsule owner(samples, [](void* p) noexcept { + delete[] (int16_t*)p; + }); + return nb::ndarray(samples, {(size_t)count, 2}, owner); +} + void Nds::saveState(std::string path) { m_core->saveStates.setPath(path, m_isGba); m_core->saveStates.saveState(); diff --git a/pynds/pynds.py b/pynds/pynds.py index cbdae30..f60e21b 100644 --- a/pynds/pynds.py +++ b/pynds/pynds.py @@ -54,6 +54,9 @@ def get_frame_shape(self) -> Tuple[int, int]: else: return (NDS[0]*scale, NDS[1]*scale, 4) + def get_audio(self, count: int = 699) -> np.ndarray: + return self._nds.get_audio_samples(count) + def open_window(self, width: int = 800, height: int = 800) -> None: if (self.window.running): self.window.close() diff --git a/pynds/window.py b/pynds/window.py index a3ee3b8..9e58175 100644 --- a/pynds/window.py +++ b/pynds/window.py @@ -1,5 +1,5 @@ -import pygame import numpy as np +import pygame class Window: @@ -10,11 +10,12 @@ def __init__(self, pynds) -> None: self.running = False def init(self, width: int, height: int) -> None: + pygame.mixer.pre_init(frequency=44100, size=-16, channels=2, buffer=512) pygame.init() self.screen = pygame.display.set_mode((width, height), pygame.RESIZABLE) pygame.display.set_caption("PyNDS") - self.running = True + self._audio_buffer = np.zeros((0, 2), dtype=np.int16) # continuous buffer def close(self) -> None: pygame.quit() @@ -29,6 +30,9 @@ def render(self) -> None: elif (self.running): self.process_frame_nds() + if (self.running): + self.process_audio() + def handle_events(self) -> None: for event in pygame.event.get(): if event.type == pygame.QUIT: @@ -138,4 +142,33 @@ def process_frame_nds(self): surface = pygame.transform.scale(surface, (width, height // 2)) self.screen.blit(surface, surface.get_rect(topleft=(0, height // 2))) + def process_audio(self): + samples = self._pynds.get_audio(699) + if samples is not None and len(samples) > 0: + # Resample 32768 -> 44100 + ratio = 44100 / 32768 + new_len = int(len(samples) * ratio) + left = np.interp( + np.linspace(0, len(samples), new_len), + np.arange(len(samples)), + samples[:, 0].astype(np.float32) + ).astype(np.int16) + right = np.interp( + np.linspace(0, len(samples), new_len), + np.arange(len(samples)), + samples[:, 1].astype(np.float32) + ).astype(np.int16) + resampled = np.column_stack((left, right)) + self._audio_buffer = np.concatenate((self._audio_buffer, resampled)) + + # Only play when we have enough buffered samples + CHUNK = 2048 + while len(self._audio_buffer) >= CHUNK: + chunk = np.ascontiguousarray(self._audio_buffer[:CHUNK]) + self._audio_buffer = self._audio_buffer[CHUNK:] + # Find a free channel or wait + ch = pygame.mixer.find_channel(False) + if ch is not None: + sound = pygame.sndarray.make_sound(chunk) + ch.play(sound) pygame.display.flip() From 2e4101aa48490443f8bb83e576405f258d12c320 Mon Sep 17 00:00:00 2001 From: unexploredtest Date: Wed, 10 Jun 2026 23:54:56 +0330 Subject: [PATCH 2/3] Window: Move pygame.display.flip() from audio to frame --- pynds/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pynds/window.py b/pynds/window.py index 9e58175..44ca90d 100644 --- a/pynds/window.py +++ b/pynds/window.py @@ -141,6 +141,7 @@ def process_frame_nds(self): surface = pygame.image.frombuffer(frame_bot, (frame_bot.shape[0], frame_bot.shape[1]), 'RGBA') surface = pygame.transform.scale(surface, (width, height // 2)) self.screen.blit(surface, surface.get_rect(topleft=(0, height // 2))) + pygame.display.flip() def process_audio(self): samples = self._pynds.get_audio(699) @@ -171,4 +172,3 @@ def process_audio(self): if ch is not None: sound = pygame.sndarray.make_sound(chunk) ch.play(sound) - pygame.display.flip() From dac6228bcd543ef6dde7b1f59a861a61bc86d307 Mon Sep 17 00:00:00 2001 From: unexploredtest Date: Thu, 11 Jun 2026 17:43:37 +0330 Subject: [PATCH 3/3] Fix weird audio noise and make audio separate from video --- cnds/cnds.cpp | 1 + cnds/include/nds.hpp | 1 + cnds/nds.cpp | 4 ++++ externals/NooDS | 2 +- pynds/audio.py | 37 +++++++++++++++++++++++++++++++++++++ pynds/pynds.py | 14 ++++++++++++++ pynds/window.py | 35 ----------------------------------- setup.py | 2 +- 8 files changed, 59 insertions(+), 37 deletions(-) create mode 100644 pynds/audio.py diff --git a/cnds/cnds.cpp b/cnds/cnds.cpp index 853446d..50afa4f 100644 --- a/cnds/cnds.cpp +++ b/cnds/cnds.cpp @@ -18,6 +18,7 @@ NB_MODULE(cnds, m) { .def("get_top_nds_frame", &Nds::getTopNdsFrame) .def("get_bot_nds_frame", &Nds::getBotNdsFrame) .def("get_audio_samples", &Nds::getAudioSamples) + .def("get_audio_buffer_number", &Nds::getAudioBufferNumber) // Save methods .def("save_state", &Nds::saveState) diff --git a/cnds/include/nds.hpp b/cnds/include/nds.hpp index eb19e6b..7925d7c 100644 --- a/cnds/include/nds.hpp +++ b/cnds/include/nds.hpp @@ -23,6 +23,7 @@ class Nds { // Audio methods nb::ndarray getAudioSamples(int count); + uint32_t getAudioBufferNumber(); // Savestate methods void saveState(std::string path); diff --git a/cnds/nds.cpp b/cnds/nds.cpp index 7b9a125..256366e 100644 --- a/cnds/nds.cpp +++ b/cnds/nds.cpp @@ -82,6 +82,10 @@ nb::ndarray Nds::getAudioSamples(int count) { return nb::ndarray(samples, {(size_t)count, 2}, owner); } +uint32_t Nds::getAudioBufferNumber() { + return m_core->spu.getBufferNumber(); +} + void Nds::saveState(std::string path) { m_core->saveStates.setPath(path, m_isGba); m_core->saveStates.saveState(); diff --git a/externals/NooDS b/externals/NooDS index c0782e6..9490fd9 160000 --- a/externals/NooDS +++ b/externals/NooDS @@ -1 +1 @@ -Subproject commit c0782e6e2e52be0ec8b96e4bbbb6cbbd30441311 +Subproject commit 9490fd916169d84dc4f2270487a7c656b34e040b diff --git a/pynds/audio.py b/pynds/audio.py new file mode 100644 index 0000000..878474e --- /dev/null +++ b/pynds/audio.py @@ -0,0 +1,37 @@ +import time +import threading + +import sounddevice as sd +import numpy as np + + +class Audio: + def __init__(self, pynds) -> None: + self._pynds = pynds + + self.running = False + + def start(self) -> None: + self.stream = sd.OutputStream(samplerate=32768, channels=2, dtype='int16', blocksize=0) + self.running = True + + self.audio_thread = threading.Thread(target=self.play_audio) + self.audio_thread.start() + + def close(self) -> None: + if (self.running): + self.running = False + self.audio_thread.join() + + def play_audio(self): + self.stream.start() + + while (self.running): + samples = self._pynds.get_audio(699) + data_to_write = np.ascontiguousarray(samples) + self.stream.write(data_to_write) + + time.sleep(0.001) + + self.stream.stop() + self.stream.close() diff --git a/pynds/pynds.py b/pynds/pynds.py index f60e21b..2dca1d1 100644 --- a/pynds/pynds.py +++ b/pynds/pynds.py @@ -6,6 +6,7 @@ from .memory import Memory from .button import Button from .window import Window +from .audio import Audio from .config import config @@ -23,6 +24,7 @@ def __init__(self, path: str, save_path: str = "", auto_detect: bool = True, is_ self.button = Button(self._nds) self.memory = Memory(self._nds) self.window = Window(self) + self.audio = Audio(self) def get_is_gba(self) -> bool: return self.is_gba @@ -57,6 +59,9 @@ def get_frame_shape(self) -> Tuple[int, int]: def get_audio(self, count: int = 699) -> np.ndarray: return self._nds.get_audio_samples(count) + def get_audio_buffer_number(self) -> int: + return self._nds.get_audio_buffer_number() + def open_window(self, width: int = 800, height: int = 800) -> None: if (self.window.running): self.window.close() @@ -66,6 +71,15 @@ def open_window(self, width: int = 800, height: int = 800) -> None: def close_window(self) -> None: self.window.close() + def open_audio(self) -> None: + if (self.audio.running): + self.audio.close() + + self.audio.start() + + def close_audio(self) -> None: + self.audio.close() + def render(self) -> None: if (self.window.running): self.window.render() diff --git a/pynds/window.py b/pynds/window.py index 44ca90d..f9cd52b 100644 --- a/pynds/window.py +++ b/pynds/window.py @@ -10,12 +10,10 @@ def __init__(self, pynds) -> None: self.running = False def init(self, width: int, height: int) -> None: - pygame.mixer.pre_init(frequency=44100, size=-16, channels=2, buffer=512) pygame.init() self.screen = pygame.display.set_mode((width, height), pygame.RESIZABLE) pygame.display.set_caption("PyNDS") self.running = True - self._audio_buffer = np.zeros((0, 2), dtype=np.int16) # continuous buffer def close(self) -> None: pygame.quit() @@ -30,9 +28,6 @@ def render(self) -> None: elif (self.running): self.process_frame_nds() - if (self.running): - self.process_audio() - def handle_events(self) -> None: for event in pygame.event.get(): if event.type == pygame.QUIT: @@ -142,33 +137,3 @@ def process_frame_nds(self): surface = pygame.transform.scale(surface, (width, height // 2)) self.screen.blit(surface, surface.get_rect(topleft=(0, height // 2))) pygame.display.flip() - - def process_audio(self): - samples = self._pynds.get_audio(699) - if samples is not None and len(samples) > 0: - # Resample 32768 -> 44100 - ratio = 44100 / 32768 - new_len = int(len(samples) * ratio) - left = np.interp( - np.linspace(0, len(samples), new_len), - np.arange(len(samples)), - samples[:, 0].astype(np.float32) - ).astype(np.int16) - right = np.interp( - np.linspace(0, len(samples), new_len), - np.arange(len(samples)), - samples[:, 1].astype(np.float32) - ).astype(np.int16) - resampled = np.column_stack((left, right)) - self._audio_buffer = np.concatenate((self._audio_buffer, resampled)) - - # Only play when we have enough buffered samples - CHUNK = 2048 - while len(self._audio_buffer) >= CHUNK: - chunk = np.ascontiguousarray(self._audio_buffer[:CHUNK]) - self._audio_buffer = self._audio_buffer[CHUNK:] - # Find a free channel or wait - ch = pygame.mixer.find_channel(False) - if ch is not None: - sound = pygame.sndarray.make_sound(chunk) - ch.play(sound) diff --git a/setup.py b/setup.py index 71c965c..7ac2809 100644 --- a/setup.py +++ b/setup.py @@ -90,7 +90,7 @@ def build_extensions(self): setup(name='pynds', packages=find_packages(), - install_requires=['numpy', 'pygame'], + install_requires=['numpy', 'pygame', 'sounddevice'], version=version, description='Python bindings for NooDS', author='unexploredtest',