def _play_cb_enter(self): """ Callback for pressing enter on audio list. """ logger.debug(f"State: {self.player_state}") return self.player_state.on_audio_list_enter_press(self)
def _on_previous_track_click(self): """ Callback for clicking previous button. """ logger.debug(f"State: {self.player_state}") return self.player_state.on_previous_track_click(self)
def _on_reload_click(self): """ Callback for clicking reload button. """ logger.debug(f"State: {self.player_state}") return self.player_state.on_reload_click(self)
def _on_next_track_click(self): """ Callback for clicking next track button. """ logger.debug(f"State: {self.player_state}") return self.player_state.on_next_track_click(self)
def _adjust_playback_right(self): """ Move audio playback cursor to right """ logger.debug(f"State: {self.player_state}") return self.player_state.adjust_playback_right(self)
def play_stream(self, audio_file: Union[None, pathlib.Path] = None, next_=False) -> int: """ Load audio and starts audio stream. Returns True if successful. :param audio_file: If not None, will play given index of track instead :param next_: Used to not refresh playlist when playing next. """ if not audio_file: audio_file = self.selected_idx_path try: self.stream.load_stream(audio_file) except IndexError: logger.debug( f"Invalid idx: {audio_file} / {len(self.path_wrapper)}") return False except RuntimeError as err: msg = f"ERR: {str(err).split(':')[-1]}" logger.warning(msg) self.write_info(msg) return False self.current_playing_file = audio_file self.refresh_list(search_files=False) self.stream.start_stream() if not next_: self.init_playlist() return True
def _play_cb_space_bar(self): """ Callback for pressing space bar on audio list. """ logger.debug(f"State: {self.player_state}") return self.player_state.on_audio_list_space_press(self)
def start_stream(stream_manager: StreamManager): logger.debug("Starting Stream.") try: stream_manager.stream.start() except Exception as err: logger.critical(f"Got {type(err)}") raise else: stream_manager.new_state(StreamPlayingState)
def init_playlist(self: AudioPlayer): """ Create itertools.cycle generator that acts as a playlist """ # Shuffling is harder than imagined! # https://engineering.atspotify.com/2014/02/28/how-to-shuffle-songs/ copy = [i for i in self.path_wrapper.audio_file_list] cycle_gen = itertools.cycle(copy) while next(cycle_gen) != self.current_playing_file: pass self._current_play_generator = cycle_gen logger.debug( f"Initialized playlist generator from directory '{self.path_wrapper.current_path.as_posix()}'" )
def adjust_playback_left(audio_player: AudioPlayer): """ Moves audio playback cursor to the left. Moves by 5% of total playback """ reference = audio_player.stream.audio_info.loaded_data current_frame = reference.tell() total_frame = reference.frames offset = total_frame // 20 reference.seek(0 if offset > current_frame else (current_frame - offset)) logger.debug( f"Adjusted left from {current_frame} to {reference.tell()}") # Trigger playback callback to display new values callback = audio_player.show_progress_wrapper(paused=True) callback(audio_player.stream.audio_info, reference.tell())
def play_next(self): """ Play next track. Called by finished callback of sounddevice when conditions are met. """ logger.debug(f"Stop Flag: {self.stream.stop_flag}") if not self.stream.stop_flag: next_ = self.playlist_next() logger.debug(f"Playing Next - {next_}") with self.maintain_current_view(): if not self.play_stream(next_, True): # TODO: add mark_as_error # TODO: add total player length with playlist gen so it can find infinite fail loop logger.warning("Error playing next track. Moving on.") self.play_next() else: # update state self.player_state = AudioRunning logger.debug( f"Next track started, state: {self.player_state}") try: self.mark_as_playing(self.current_playing_idx) except IndexError: pass
def __init__(self, audio_dir: str): self.audio_dir = pathlib.Path(audio_dir) self.loaded_data = sf.SoundFile(self.audio_dir.as_posix()) self.total_frame = self.loaded_data.frames self.tag_data = TinyTag.get(self.audio_dir.as_posix()) self.title = self.tag_data.title if self.tag_data.title else self.audio_dir.name # saving reference for tiny bit faster access try: self.duration_tag = round(self.tag_data.duration, 1) except TypeError: logger.warning( f"No tag 'duration' exists in {self.title}, calculating estimate." ) self.duration_tag = round( self.loaded_data.frames / self.loaded_data.samplerate, 1) logger.debug( f"Audio detail - Title: {self.title}, Duration: {self.duration_tag}" )
def load_stream(stream_manager: StreamManager, audio_dir: str): logger.debug("Stopping and loading new audio.") logger.debug("Delegating to: StreamPlayingState.stop_stream") StreamPlayingState.stop_stream(stream_manager) logger.debug("Delegating to: AudioUnloadedState.load_stream") AudioUnloadedState.load_stream(stream_manager, audio_dir)
def pause_stream(stream_manager: StreamManager): logger.debug("Resuming Stream") stream_manager.new_state(StreamPlayingState) stream_manager.stream.start()
def stop_stream(stream_manager: StreamManager): logger.debug("Delegating to: StreamPlayingState.stop_stream") StreamPlayingState.stop_stream(stream_manager)
def pause_stream(stream_manager: StreamManager): logger.debug("Pausing Stream.") stream_manager.stream.stop() stream_manager.new_state(StreamPausedState)
def stop_stream(stream_manager: StreamManager): logger.debug("Stopping Stream and resetting playback progress.") stream_manager.stream.stop() stream_manager.audio_info.loaded_data.seek(0)
def callback(): logger.debug(f"Playback finished. Stop flag: {stream_manager.stop_flag}") stream_manager.new_state(new_next_state) if not stream_manager.stop_flag: stream_manager.finished_cb()
def stream_callback_closure(stream_manager: StreamManager, raw=False) -> Callable: # Collecting names here to reduce call overhead. last_frame = -1 dtype = sd.default.dtype[1] audio_ref = stream_manager.audio_info.loaded_data channel = audio_ref.channels callback = stream_manager.stream_cb audio_info = stream_manager.audio_info cycle = itertools.cycle((not n for n in range(stream_manager.callback_minimum_cycle))) # to reduce load, custom callback will be called every n-th iteration of this generator. # 3rd parameter is time but that is for internal use. Replacing with underscore. def stream_cb(data_out, frames: int, _, status: sd.CallbackFlags) -> None: nonlocal last_frame, stream_manager assert not status read = audio_ref.read(frames, fill_value=0) * stream_manager.multiplier try: data_out[:] = read except ValueError: try: data_out[:] = read.reshape(read.shape[0], 1) except Exception: stream_manager.stop_flag = True raise # if last_frame == (current_frame := audio_ref.tell()): # raise sd.CallbackAbort current_frame = audio_ref.tell() if last_frame == current_frame: raise sd.CallbackAbort last_frame = current_frame if next(cycle): callback(audio_info, current_frame) # Stream callback signature for user-supplied callbacks # Providing current_frame and duration to reduce call overhead from user-callback side. def stream_cb_raw(data_out, frames: int, _, status: sd.CallbackFlags) -> None: nonlocal last_frame try: assert not status except AssertionError: logger.critical(str(status)) raise # if (written := audio_ref.buffer_read_into(data_out, dtype)) < frames: # data_out[written:] = [[0.0] * channel for _ in range(frames - written)] # raise sd.CallbackStop # # if last_frame == (current_frame := audio_ref.tell()): # raise sd.CallbackAbort written = audio_ref.buffer_read_into(data_out, dtype) if written < frames: data_out[written:] = [[0.0] * channel for _ in range(frames - written)] raise sd.CallbackStop current_frame = audio_ref.tell() if last_frame == current_frame: raise sd.CallbackAbort last_frame = current_frame if next(cycle): callback(audio_info, current_frame) # Stream callback signature for user-supplied callbacks # Providing current_frame and duration to reduce call overhead from user-callback side. logger.debug(f"Using {'Raw' if raw else 'Numpy'} callback.") return stream_cb_raw if raw else stream_cb
class PathWrapper: primary_formats = set("." + key.lower() for key in sf.available_formats().keys()) secondary_formats = {".m4a", ".mp3"} if PY_DUB_ENABLED else set() supported_formats = primary_formats | secondary_formats supported_formats = supported_formats | set(key.upper() for key in supported_formats) # re_match_pattern = "$|".join(final_supported_formats) # subtypes = soundfile.available_subtypes() logger.debug(f"Available formats: {supported_formats}") # Considering idx 0 to always be step out! def __init__(self, path: str = "./"): self.current_path = pathlib.Path(path).absolute() self.audio_file_list: List[pathlib.Path] = [] self.folder_list: List[pathlib.Path] = [] def list_audio(self) -> Generator[pathlib.Path, None, None]: yield from (path_obj for path_obj in self.list_file() if path_obj.suffix in self.supported_formats) def list_folder(self) -> Generator[pathlib.Path, None, None]: """ First element will be current folder location. either use next() or list()[1:] to skip it. """ yield self.current_path.parent yield from (path_ for path_ in self.current_path.iterdir() if path_.is_dir()) def list_file(self) -> Generator[pathlib.Path, None, None]: """ Can't use glob as it match folders such as .git, using pathlib.Path object instead. """ yield from (item for item in self.current_path.iterdir() if item.is_file()) def step_in(self, directory_idx: int): """ Relative / Absolute paths supported. """ try: self.current_path = self.folder_list[directory_idx] except IndexError as err: raise NotADirectoryError( f"Directory index {directory_idx} does not exist!") from err self.refresh_list() return self.current_path def step_out(self): if self.current_path == self.current_path.parent: return self.current_path self.current_path = self.current_path.parent self.refresh_list() return self.current_path def refresh_list(self): self.audio_file_list.clear() self.folder_list.clear() self.audio_file_list.extend(self.list_audio()) self.folder_list.extend(self.list_folder()) def fetch_meta(self): # This might have to deal the cases such as path changing before generator fires up. for file_dir in self.list_audio(): yield TinyTag.get(file_dir) def fetch_tag_data(self): for file_dir in self.list_audio(): yield TinyTag.get(file_dir) def __len__(self): return len(self.folder_list) + len(self.audio_file_list) def __getitem__(self, item: int): # logger.debug(f"idx: {item}, len_f: {len(self.folder_list)}, len_a: {len(self.audio_file_list)}") try: return self.folder_list[item] except IndexError as err: if len(self) == 0: raise IndexError( "No file or folder to index in current directory." ) from err return self.audio_file_list[item - len(self.folder_list)] def index(self, target: Union[str, pathlib.Path]): path_ = pathlib.Path(target) try: return len(self.folder_list) + self.audio_file_list.index(path_) except ValueError as err: raise IndexError( f"Cannot find given target '{path_.as_posix()}'!") from err
def load_stream(stream_manager: StreamManager, audio_dir: str): logger.debug("Loading new file.") logger.debug("Delegating to: StreamPlayingState.load_stream") AudioUnloadedState.load_stream(stream_manager, audio_dir)
def __del__(self): self.loaded_data.close() logger.debug(f"Dropping loaded file <{self.title}>")
from __future__ import annotations from sys import platform import py_cui from LoggingConfigurator import logger from Player.PlayerLogic import AudioPlayer try: # noinspection PyUnresolvedReferences import pretty_errors pretty_errors.activate() except ImportError: pass VERSION_TAG = "0.0.4a - dev" logger.debug(f"Platform: {platform} Version: {VERSION_TAG}") # ------------------------------------------------------------------ def draw_player(): """ TUI driver """ root = py_cui.PyCUI(5, 7, auto_focus_buttons=True) root.set_title(f"CUI Audio Player - v{VERSION_TAG}") root.set_widget_border_characters("╔", "╗", "╚", "╝", "═", "║") root.set_refresh_timeout(0.1) # this don't have to be a second. Might be an example of downside of ABC
def new_state(self, status: Type[StreamState]): logger.debug(f"Switching state: {self.stream_state} -> {status}") self.stream_state = status