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/* 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=" 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",