From 75cdbd1880fb89dac2aa1d37df79f5c2441b4108 Mon Sep 17 00:00:00 2001 From: dedobbin Date: Fri, 11 Sep 2026 21:08:23 +0100 Subject: [PATCH 1/3] add vscode dir to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 54f7034..17ee80f 100644 --- a/.gitignore +++ b/.gitignore @@ -151,6 +151,9 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +# vscode +.vscode/ + *.exe .idea/* From e80afade004cddd682c8f9aa631d3d45b824f5c1 Mon Sep 17 00:00:00 2001 From: dedobbin Date: Sat, 12 Sep 2026 19:59:23 +0100 Subject: [PATCH 2/3] add buffered sound player --- pyproject.toml | 4 +- src/binary_waterfall/buffer_player.py | 116 ++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/binary_waterfall/buffer_player.py diff --git a/pyproject.toml b/pyproject.toml index f67a3a9..98966f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,9 @@ dependencies = [ "PyQt5", "pydub", "moviepy", - "proglog" + "proglog", + "numpy", + "sounddevice" ] [project.urls] diff --git a/src/binary_waterfall/buffer_player.py b/src/binary_waterfall/buffer_player.py new file mode 100644 index 0000000..7aad248 --- /dev/null +++ b/src/binary_waterfall/buffer_player.py @@ -0,0 +1,116 @@ +import numpy as np +import sounddevice as sd + +UINT8_MIDPOINT = 1 << 7 +INT16_SIGN_BIT = 1 << 15 +INT24_SIGN_BIT = 1 << 23 +INT24_MODULUS = 1 << 24 +INT32_SIGN_BIT = 1 << 31 + +def pcm_bytes_to_float32(raw, sample_bytes, num_channels): + raw = bytes(raw) + bytes_per_frame = sample_bytes * num_channels + if bytes_per_frame <= 0 or len(raw) < bytes_per_frame: + return np.zeros((0, max(num_channels, 1)), dtype=np.float32) + + # throw tail away if not a multiple of the frame size + usable = len(raw) - (len(raw) % bytes_per_frame) + raw = raw[:usable] + frames = usable // bytes_per_frame + + # convert int to float in range of -1.0 to 1.0 + if sample_bytes == 1: + samples = np.frombuffer(raw, dtype=np.uint8).astype(np.float32) + samples = (samples - UINT8_MIDPOINT) / float(UINT8_MIDPOINT) + elif sample_bytes == 2: + samples = np.frombuffer(raw, dtype="= INT24_SIGN_BIT, values - INT24_MODULUS, values) + samples = values.astype(np.float32) / float(INT24_SIGN_BIT) + elif sample_bytes == 4: + samples = np.frombuffer(raw, dtype=" Date: Sat, 12 Sep 2026 19:59:55 +0100 Subject: [PATCH 3/3] integrate new sound player --- src/binary_waterfall/generators.py | 122 +++++++--------- src/binary_waterfall/outputs.py | 223 ++++++++++++++++++++--------- src/binary_waterfall/window.py | 14 +- 3 files changed, 216 insertions(+), 143 deletions(-) diff --git a/src/binary_waterfall/generators.py b/src/binary_waterfall/generators.py index ad7bab1..5f27f3d 100644 --- a/src/binary_waterfall/generators.py +++ b/src/binary_waterfall/generators.py @@ -1,7 +1,7 @@ import os import shutil -import tempfile import math +import threading import wave from PIL import Image, ImageOps import pydub @@ -31,7 +31,6 @@ def __init__(self, playhead_visible=constants.DEFAULTS["playhead_visible"] ): # Initialize class variables - self.audio_length_ms = None self.volume = None self.sample_rate = None self.sample_bytes = None @@ -40,20 +39,16 @@ def __init__(self, self.color_bytes = None self.unused_color_bytes = None self.used_color_bytes = None - self.filename = None self.height = None self.width = None self.dim = None - self.total_bytes = None self.file = None - self.audio_filename = None self.flip_v = None self.flip_h = None self.alignment = None self.playhead_visible = None - # Make the temp dir for the class instance - self.temp_dir = tempfile.mkdtemp() + self._file_lock = threading.Lock() # Set the filename in self.set_filename(filename=filename) @@ -89,38 +84,22 @@ def close_file(self): if self.file is not None: self.file.close() self.file = None - self.filename = None def set_filename(self, filename): - # Delete current audio file if it exists - self.delete_audio() - if filename is None: # Reset all vars and close the file pointer self.close_file() - self.total_bytes = None - self.audio_filename = None return if not os.path.isfile(filename): raise FileNotFoundError(f"File not found: \"{filename}\"") - self.filename = os.path.realpath(filename) + if self.file is not None: + self.file.close() + self.file = None # Open file - self.file = open(self.filename, "rb") - - # Get total number of bytes - self.file.seek(0, os.SEEK_END) - self.total_bytes = self.file.tell() - self.file.seek(0) - - # Compute audio file name - file_path, file_main_name = os.path.split(self.filename) - self.audio_filename = os.path.join( - self.temp_dir, - file_main_name + os.path.extsep + "wav" - ) + self.file = open(os.path.realpath(filename), "rb") def set_dims(self, width, height): if width < 4: @@ -308,72 +287,75 @@ def set_audio_settings(self, self.sample_rate = sample_rate self.volume = volume - # Re-compute audio file - self.compute_audio() - - def delete_audio(self): - if self.audio_filename is None: - # Do nothing - return - try: - os.remove(self.audio_filename) - except FileNotFoundError: - pass - - def get_audio_length(self): - audio_length = pydub.AudioSegment.from_file(self.audio_filename).duration_seconds - audio_length_ms = math.ceil(audio_length * 1000) + def get_audio_length_ms(self): + total_bytes = self.get_total_bytes() + if self.file is None or not total_bytes or not self.sample_rate: + return 0 - return audio_length_ms + bytes_per_frame = self.sample_bytes * self.num_channels + frames = total_bytes // bytes_per_frame + return math.ceil(frames * 1000 / self.sample_rate) - def compute_audio(self): - if self.filename is None: - # If there is no file set, reset the vars - self.audio_length_ms = None - return + def write_wav(self, filename): + if self.file is None: + raise ValueError("No file is open") - # Delete current file if it exists - self.delete_audio() + helpers.make_file_path(filename) - # Compute the new file (full volume) - with wave.open(self.audio_filename, "wb") as f: + with wave.open(filename, "wb") as f: f.setnchannels(self.num_channels) f.setsampwidth(self.sample_bytes) f.setframerate(self.sample_rate) - self.file.seek(0) - for chunk in iter(lambda: self.file.read(4096), b""): - f.writeframesraw(chunk) + with self._file_lock: + self.file.seek(0) + for chunk in iter(lambda: self.file.read(4096), b""): + f.writeframesraw(chunk) if self.volume != 100: - # Reduce the audio volume factor = self.volume / 100 - audio = pydub.AudioSegment.from_file(file=self.audio_filename, format="wav") + audio = pydub.AudioSegment.from_file(file=filename, format="wav") audio += pydub.audio_segment.ratio_to_db(factor) - temp_filename = self.audio_filename + ".temp" + temp_filename = filename + ".temp" audio.export(temp_filename, format="wav") - self.delete_audio() - shutil.move(temp_filename, self.audio_filename) - - # Get audio length - self.audio_length_ms = self.get_audio_length() + try: + os.remove(filename) + except FileNotFoundError: + pass + shutil.move(temp_filename, filename) def change_filename(self, new_filename): self.set_filename(new_filename) - self.compute_audio() + + def get_total_bytes(self): + if self.file is None: + return 0 + return os.fstat(self.file.fileno()).st_size def get_file_bytes(self, address, count): - self.file.seek(address) - return self.file.read(count) + if self.file is None: + raise ValueError("No file is open") + if count <= 0: + raise ValueError("count must be positive") + if address < 0: + address = 0 + with self._file_lock: + self.file.seek(address) + return self.file.read(count) def get_address(self, ms): + audio_length_ms = self.get_audio_length_ms() + total_bytes = self.get_total_bytes() + if not audio_length_ms or not total_bytes: + return 0 + # Get the size of a single "block" (a row, we only move in increments of 1 row) address_block_size = self.width * self.color_bytes # Get the total number of blocks (rows) in the file (round up because we don't want to clip a row off) - total_blocks = math.ceil(self.total_bytes / address_block_size) + total_blocks = math.ceil(total_bytes / address_block_size) # Get the block index of the current audio location - address_block_index = round(total_blocks * (ms / self.audio_length_ms)) + address_block_index = round(total_blocks * (ms / audio_length_ms)) # Adjust index for other alignments if self.alignment == constants.AlignmentCode.START: @@ -502,11 +484,9 @@ def get_frame_qimage(self, ms): qimg = qimg.mirrored(horizontal=self.flip_h, vertical=self.flip_v) return qimg - + def cleanup(self): self.close_file() - self.delete_audio() - shutil.rmtree(self.temp_dir) # Watermarker class diff --git a/src/binary_waterfall/outputs.py b/src/binary_waterfall/outputs.py index 945e8de..f3afdcf 100644 --- a/src/binary_waterfall/outputs.py +++ b/src/binary_waterfall/outputs.py @@ -1,16 +1,16 @@ import os import shutil import math -import time +import threading import tempfile import pydub from moviepy.editor import ImageSequenceClip, AudioFileClip from PIL import Image -from PyQt5.QtCore import QUrl -from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent +from PyQt5.QtCore import QTimer from PyQt5.QtGui import QImage, QPixmap +import numpy as np -from . import generators, helpers, constants +from . import generators, helpers, constants, buffer_player # Image playback class @@ -45,27 +45,27 @@ def __init__(self, # Initialize player as black self.clear_image() - # Make the QMediaPlayer for audio playback - self.audio = QMediaPlayer() - # self.audio_output = QAudioOutput() - # self.audio.setAudioOutput(self.audio_output) + self._audio_index = 0 + self._audio_lock = threading.Lock() + self._playback_finished = False + + self.audio = buffer_player.BufferPlayer( + self.obtain_data, + samplerate=self.bw.sample_rate, + channels=self.bw.num_channels, + ) # Set audio playback settings self.set_volume(100) - # Set set_image_timestamp to run when the audio position is changed - self.audio.positionChanged.connect(self.set_image_timestamp) - self.audio.positionChanged.connect(self.set_seekbar_if_given) - # Also, make sure it's updating more frequently (default is too slow when playing) self.fps_min = 1 self.fps_max = 120 + self._ui_timer = QTimer(self.display) + self._ui_timer.timeout.connect(self._on_playback_tick) self.set_fps(fps) - # Setup change state handler - self.audio.stateChanged.connect(self.state_changed_handler) - def __del__(self): - self.running = False + self.audio.close() def set_dims(self, max_dim): self.max_dim = max_dim @@ -81,7 +81,7 @@ def set_dims(self, max_dim): def set_fps(self, fps): self.fps = min(max(fps, self.fps_min), self.fps_max) self.frame_ms = math.floor(1000 / self.fps) - self.audio.setNotifyInterval(self.frame_ms) + self._ui_timer.setInterval(self.frame_ms) def clear_image(self): background_image = Image.new( @@ -109,7 +109,7 @@ def update_dims(self, max_dim): self.set_dims(max_dim=max_dim) # Update image - if self.bw.filename is None: + if self.bw.file is None: self.clear_image() else: self.set_image(self.image) @@ -134,26 +134,35 @@ def set_image(self, image): self.display.setPixmap(qpixmap) def get_position(self): - return self.audio.position() + with self._audio_lock: + frame = self._audio_index + if self.bw.sample_rate <= 0: + return 0 + return int(frame * 1000 / self.bw.sample_rate) def get_duration(self): - return self.audio.duration() + return self.bw.get_audio_length_ms() def set_position(self, ms): duration = self.get_duration() - # Validate it's in range, and if it's not, clip it ms = math.ceil(ms) if ms < 0: ms = 0 if ms > duration: ms = duration - if self.bw.filename is not None: - self.audio.setPosition(ms) + with self._audio_lock: + self._audio_index = self._ms_to_frame(ms) + self._playback_finished = ( + duration > 0 and self._audio_index >= self._frame_count() + ) + + if self.bw.file is not None: + self.set_image_timestamp(ms) + self.set_seekbar_if_given(ms) - # If the file is at the end, pause - if ms == duration: + if duration > 0 and ms >= duration: self.pause() def set_playbutton_if_given(self, play): @@ -164,19 +173,37 @@ def set_seekbar_if_given(self, ms): if self.set_seekbar_function is not None: self.set_seekbar_function(ms) - def state_changed_handler(self, media_state): - if media_state == self.audio.PlayingState: - self.set_playbutton_if_given(play=False) - elif media_state == self.audio.PausedState: - self.set_playbutton_if_given(play=True) - elif media_state == self.audio.StoppedState: - self.set_playbutton_if_given(play=True) + def _on_playback_tick(self): + if self._playback_finished: + duration = self.get_duration() + self.pause() + self.set_position(duration) + return + + ms = self.get_position() + self.set_image_timestamp(ms) + self.set_seekbar_if_given(ms) def play(self): - self.audio.play() + if self.bw.file is None: + return + + duration = self.get_duration() + if duration > 0 and self.get_position() >= duration: + self.set_position(0) + + with self._audio_lock: + self._playback_finished = False + + self.audio.start() + self._ui_timer.start() + self.set_playbutton_if_given(play=False) + self._on_playback_tick() def pause(self): - self.audio.pause() + self._ui_timer.stop() + self.audio.stop() + self.set_playbutton_if_given(play=True) def forward(self, ms=5000): new_pos = self.get_position() + ms @@ -195,49 +222,49 @@ def frame_back(self): def restart(self): self.set_position(0) - def set_audio_file(self, filename): - if filename is None: - url = QUrl(None) - else: - url = QUrl.fromLocalFile(self.bw.audio_filename) - media = QMediaContent(url) - self.audio.setMedia(media) + def _sync_audio_stream(self): + self.audio.update_stream_safe( + sample_rate=self.bw.sample_rate, + num_channels=self.bw.num_channels, + ) def open_file(self, filename): self.close_file() self.bw.change_filename(filename) + self._sync_audio_stream() - self.set_audio_file(self.bw.audio_filename) + with self._audio_lock: + self._audio_index = 0 + self._playback_finished = False self.set_image_timestamp(self.get_position()) + self.set_seekbar_if_given(self.get_position()) + self.set_playbutton_if_given(play=True) def close_file(self): self.pause() - self.audio.stop() - time.sleep(0.001) # Without a short delay here, we crash - self.set_audio_file(None) + with self._audio_lock: + self._audio_index = 0 + self._playback_finished = False self.bw.change_filename(None) - self.restart() + self.set_seekbar_if_given(0) self.clear_image() def file_is_open(self): - if self.bw.filename is None: + if self.bw.file is None: return False else: return True def is_playing(self): - if self.audio.state() == self.audio.PlayingState: - return True - else: - return False + return self.audio.is_playing def set_image_timestamp(self, ms): - if self.bw.filename is None: + if self.bw.file is None: self.clear_image() else: self.set_image(self.bw.get_frame_qimage(ms)) @@ -252,15 +279,76 @@ def set_audio_settings(self, sample_rate, volume ): + was_playing = self.is_playing() + ms = self.get_position() + + self.pause() + self.bw.set_audio_settings( num_channels=num_channels, sample_bytes=sample_bytes, sample_rate=sample_rate, volume=volume ) - # Re-open newly computed file - self.set_audio_file(None) - self.set_audio_file(self.bw.audio_filename) + self._sync_audio_stream() + self.set_position(ms) + + if was_playing: + self.play() + + def obtain_data(self, n_frames): + sample_bytes = self.bw.sample_bytes + num_channels = self.bw.num_channels + bytes_per_frame = sample_bytes * num_channels + + with self._audio_lock: + if self.bw.file is None or bytes_per_frame <= 0: + self._playback_finished = True + return None + + byte_start = self._audio_index * bytes_per_frame + if byte_start >= self.bw.get_total_bytes(): + self._playback_finished = True + return None + + chunk_bytes = self.bw.get_file_bytes(byte_start, n_frames * bytes_per_frame) + frames_read = len(chunk_bytes) // bytes_per_frame + self._audio_index += frames_read + if frames_read < n_frames: + self._playback_finished = True + + chunk = buffer_player.pcm_bytes_to_float32( + chunk_bytes, + sample_bytes=sample_bytes, + num_channels=num_channels, + ) + + if self.bw.volume != 100: + chunk = chunk * np.float32(self.bw.volume / 100.0) + np.clip(chunk, -1.0, 1.0, out=chunk) + + if frames_read < n_frames: + pad = np.zeros((n_frames - frames_read, num_channels), dtype=np.float32) + if frames_read == 0: + chunk = pad + else: + chunk = np.vstack([chunk, pad]) + + return chunk + + def _frame_count(self): + if self.bw.file is None: + return 0 + bytes_per_frame = self.bw.sample_bytes * self.bw.num_channels + if bytes_per_frame <= 0: + return 0 + return self.bw.get_total_bytes() // bytes_per_frame + + def _ms_to_frame(self, ms): + if self.bw.sample_rate <= 0: + return 0 + frame = int(round((ms / 1000) * self.bw.sample_rate)) + return min(max(frame, 0), self._frame_count()) # Renderer class @@ -279,7 +367,7 @@ def export_frame(self, ): helpers.make_file_path(filename) - if self.bw.audio_filename is None: + if self.bw.file is None: # If no file is loaded, make a black image source = Image.new( mode="RGBA", @@ -316,17 +404,22 @@ def export_audio(self, filename): helpers.make_file_path(filename) if filename_ext == constants.AudioFormatCode.WAVE.value: - # Just copy the .wav file - shutil.copy(self.bw.audio_filename, filename) - elif filename_ext == constants.AudioFormatCode.MP3.value: - # Use Pydub to export MP3 - pydub.AudioSegment.from_wav(self.bw.audio_filename).export(filename, format="mp3") - elif filename_ext == constants.AudioFormatCode.FLAC.value: - # Use Pydub to export FLAC - pydub.AudioSegment.from_wav(self.bw.audio_filename).export(filename, format="flac") + self.bw.write_wav(filename) + return + + wav_file = filename + ".temp.wav" + try: + self.bw.write_wav(wav_file) + if filename_ext == constants.AudioFormatCode.MP3.value: + pydub.AudioSegment.from_wav(wav_file).export(filename, format="mp3") + elif filename_ext == constants.AudioFormatCode.FLAC.value: + pydub.AudioSegment.from_wav(wav_file).export(filename, format="flac") + finally: + if os.path.isfile(wav_file): + os.remove(wav_file) def get_frame_count(self, fps): - audio_duration = self.bw.get_audio_length() / 1000 + audio_duration = self.bw.get_audio_length_ms() / 1000 frame_count = round(audio_duration * fps) return frame_count diff --git a/src/binary_waterfall/window.py b/src/binary_waterfall/window.py index d5c7ff0..bee2cf7 100644 --- a/src/binary_waterfall/window.py +++ b/src/binary_waterfall/window.py @@ -333,11 +333,11 @@ def set_volume(self, value): self.set_volume_icon(mute=False) def update_seekbar(self): - if self.bw.filename is None: + if self.bw.file is None: self.seek_bar.setEnabled(False) self.seek_bar.setValue(0) else: - self.seek_bar.setMaximum(self.bw.audio_length_ms) + self.seek_bar.setMaximum(self.bw.get_audio_length_ms()) self.seek_bar.setEnabled(True) def seekbar_moved(self, position): @@ -398,7 +398,7 @@ def open_file_clicked(self): if filename != "": self.player.open_file(filename=filename) - + file_path, file_title = os.path.split(filename) file_savename, file_ext = os.path.splitext(file_title) self.set_file_savename(file_savename) @@ -500,7 +500,7 @@ def player_settings_clicked(self): QTimer.singleShot(10, self.resize_window) def export_image_clicked(self): - if self.bw.audio_filename is None: + if self.bw.file is None: choice = QMessageBox.critical( self, "Error", @@ -555,7 +555,7 @@ def export_image_clicked(self): ) def export_audio_clicked(self): - if self.bw.audio_filename is None: + if self.bw.file is None: choice = QMessageBox.critical( self, "Error", @@ -596,7 +596,7 @@ def export_audio_clicked(self): ) def export_sequence_clicked(self): - if self.bw.audio_filename is None: + if self.bw.file is None: choice = QMessageBox.critical( self, "Error", @@ -669,7 +669,7 @@ def export_sequence_clicked(self): ) def export_video_clicked(self): - if self.bw.audio_filename is None: + if self.bw.file is None: choice = QMessageBox.critical( self, "Error",