diff --git a/src/main.py b/src/main.py index 7048379..6f622de 100644 --- a/src/main.py +++ b/src/main.py @@ -9,7 +9,7 @@ import mp3 if __name__ == "__main__": print("Loading project...") - """test_project = Project(song_length=2) + test_project = Project(song_length=8) drum_channel = ProjectChannel( test_project, @@ -23,10 +23,16 @@ if __name__ == "__main__": *librosa.load("120 bpm amen break.mp3", mono=False, sr=test_project.sample_rate), name="120 bpm amen break.mp3" )) + drum_channel.chunks.append(AudioChannelChunk( + drum_channel, + position=1, + *librosa.load("120 bpm amen break.mp3", mono=False, sr=test_project.sample_rate), + name="120 bpm amen break.mp3" + )) test_project.channels.append(drum_channel) - test_project.write_to_file("test_project.tdp")""" + test_project.write_to_file("test_project.tdp") test_project = Project.from_file("test_project.tdp") # start the ui diff --git a/src/song_player.py b/src/song_player.py new file mode 100644 index 0000000..20499b1 --- /dev/null +++ b/src/song_player.py @@ -0,0 +1,89 @@ +import sounddevice as sd +from project import Project +from ui.widgets.play_head import PlayHead + + +class SongPlayer: + def __init__(self, timeline, project: Project): + self.audio = None + self.stream = sd.OutputStream() # default stream + self.project: Project = project + self.playhead = 0 + self.paused = True + + self.timeline = timeline + + def play_callback(self, out, frames, time, status): + if self.paused or self.audio is None: + out.fill(0) + return + + end = self.playhead + frames + chunk = self.audio[self.playhead:end] + + if chunk.ndim == 1: + chunk = chunk[:, None] + + if chunk.shape[1] != out.shape[1]: + raise RuntimeError("Something very bad has happened :sob:, A channel mismatch happened.") + + if len(chunk) < frames: + out[:len(chunk)] = chunk + out[len(chunk):].fill(0) + raise sd.CallbackStop() + + out[:] = chunk + self.playhead = end + + self.timeline.run_worker(self.update_visual_playhead()) + + async def update_visual_playhead(self): + # get how many bars into the song we are + num_bars = self.playhead / self.project.samples_per_bar + + # multiply that with the bar offset to update the ui to show where the playhead is in the song + x_offset = num_bars * self.timeline.bar_offset * 4 + + # now we can finally apply that offset :) + self.timeline.query_one(PlayHead).offset = ( + x_offset, + 0 + ) + + def seek(self, seconds: int): + self.playhead = int(seconds * self.project.sample_rate) + self.timeline.run_worker(self.update_visual_playhead()) + + def pause(self): + self.paused = True + + play_btn = self.timeline.app.query_one("#play-button") + play_btn.variant = "success" + play_btn.label = "▶" + + def play_project(self, project: Project) -> bool: + # this just returns True if playing audio succeeded + self.project = project + self.audio = project.render() + + try: + self.paused = False + self.stream = sd.OutputStream( + samplerate=project.sample_rate, + channels=self.audio.shape[1] if self.audio.ndim > 1 else 1, + callback=self.play_callback + ) + + self.stream.start() + + + play_btn = self.timeline.app.query_one("#play-button") + play_btn.variant = "error" + play_btn.label = "⏸" + + return True + except sd.PortAudioError as e: # woopsies + self.paused = True + self.timeline.app.notify(f"Error: \"{e}\"\n\nSometimes errors can occur if the device you selected in your settings doesn't exist, or it's already in use.", title="Error while playing song", timeout=60, severity="error") + + return False \ No newline at end of file diff --git a/src/test_project.tdp b/src/test_project.tdp index f311e32..591cb24 100644 Binary files a/src/test_project.tdp and b/src/test_project.tdp differ diff --git a/src/ui/app.py b/src/ui/app.py index ae7e7d5..e15ffec 100644 --- a/src/ui/app.py +++ b/src/ui/app.py @@ -1,5 +1,6 @@ from textual.app import App, ComposeResult from textual.widgets import Footer, Tab, Tabs, Header +from textual import on, events from ui.widgets.sidebar import Sidebar from ui.widgets.timeline import Timeline, TimelineRow @@ -12,13 +13,20 @@ from project import ProjectChannel class AppUI(App): CSS_PATH = "../assets/style.tcss" - theme = "tokyo-night" - def __init__(self, project): super().__init__() self.zoom_level = 0.05 self.last_zoom_level = self.zoom_level self.project = project + + @on(events.Key) + async def key_pressed(self, event: events.Key): + if event.key == "space": + timeline = self.query_one(Timeline) + if not timeline.song_player.paused: + timeline.song_player.pause() + else: + timeline.song_player.play_project(self.app.project) def create_channel(self, name: str): self.query_one("#channels").mount(Channel( diff --git a/src/ui/widgets/chunk_types/audio.py b/src/ui/widgets/chunk_types/audio.py index 7064a2d..2d72ccb 100644 --- a/src/ui/widgets/chunk_types/audio.py +++ b/src/ui/widgets/chunk_types/audio.py @@ -14,7 +14,7 @@ from ui.widgets.chunk_types.chunk import Chunk class AudioChunk(Chunk): DEFAULT_CSS = """ AudioChunk { - border: tab $secondary; + border: tab $primary; PlotWidget { height: 1fr; @@ -91,7 +91,7 @@ class AudioChunk(Chunk): x, y, 1.0, - bar_style=self.app.theme_variables["secondary"], + bar_style=self.app.theme_variables["primary"], hires_mode=HiResMode.BRAILLE ) diff --git a/src/ui/widgets/project_settings.py b/src/ui/widgets/project_settings.py index a5e361e..71a24c3 100644 --- a/src/ui/widgets/project_settings.py +++ b/src/ui/widgets/project_settings.py @@ -42,7 +42,12 @@ class ProjectSettings(Horizontal): def on_button_pressed(self, event: Button.Pressed): if event.button.id == "play-button": - sd.play(self.app.project.render()) + song_player = self.app.query_one("#timeline").song_player + + if event.button.variant == "success": # play button + song_player.play_project(self.app.project) + else: # stop button + song_player.pause() def compose(self) -> ComposeResult: yield Button("▶", tooltip="Play song", flat=True, id="play-button", variant="success") # icon becomes "⏸" when song is playing diff --git a/src/ui/widgets/timeline.py b/src/ui/widgets/timeline.py index f166eff..f97ec13 100644 --- a/src/ui/widgets/timeline.py +++ b/src/ui/widgets/timeline.py @@ -7,6 +7,8 @@ from ui.widgets.chunk_types.audio import AudioChunk, Chunk from ui.widgets.play_head import PlayHead from project import ChunkType +from song_player import SongPlayer + class TimelineRow(Horizontal): DEFAULT_CSS = """ @@ -64,6 +66,7 @@ class Timeline(Vertical): super().__init__(id="timeline") self.calc_bar_offset() + self.song_player = SongPlayer(self, self.app.project) def calc_bar_offset(self): self.bar_offset = self.app.project.bpm / 8 * (0.03333333333 / self.app.zoom_level) @@ -87,6 +90,19 @@ class Timeline(Vertical): self.calc_bar_offset() self.handle_zoom() + @on(events.MouseDown) + async def mouse_down(self, event: events.MouseDown): + if event.button != 2: return + + # get bar number + bar_number = event.x / self.bar_offset + + # convert bar number to seconds + seconds = bar_number / self.app.project.seconds_per_bar + + # seek to number of seconds + self.song_player.seek(seconds) + def handle_zoom(self): for chunk in self.query(Chunk): chunk.calculate_size() @@ -100,6 +116,9 @@ class Timeline(Vertical): bar_line.display = False else: bar_line.display = True + + if self.song_player.paused and self.song_player.project: + self.run_worker(self.song_player.update_visual_playhead()) def compose(self) -> ComposeResult: @@ -116,7 +135,7 @@ class Timeline(Vertical): elif chunk.chunk_type == ChunkType.AUDIO: yield AudioChunk(chunk.audio_data, chunk.sample_rate, chunk.name, chunk.position) - for i in range(1, self.app.project.song_length): + for i in range(1, self.app.project.song_length+1): bar = None if i % 4 == 0: bar = Rule.vertical(classes="bar-line", line_style="double")