you can play your song now :D

This commit is contained in:
2026-01-15 07:58:09 +11:00
parent 6350de7899
commit 6a96bdd86a
7 changed files with 135 additions and 8 deletions

View File

@@ -9,7 +9,7 @@ import mp3
if __name__ == "__main__": if __name__ == "__main__":
print("Loading project...") print("Loading project...")
"""test_project = Project(song_length=2) test_project = Project(song_length=8)
drum_channel = ProjectChannel( drum_channel = ProjectChannel(
test_project, test_project,
@@ -23,10 +23,16 @@ if __name__ == "__main__":
*librosa.load("120 bpm amen break.mp3", mono=False, sr=test_project.sample_rate), *librosa.load("120 bpm amen break.mp3", mono=False, sr=test_project.sample_rate),
name="120 bpm amen break.mp3" 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.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") test_project = Project.from_file("test_project.tdp")
# start the ui # start the ui

89
src/song_player.py Normal file
View File

@@ -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

Binary file not shown.

View File

@@ -1,5 +1,6 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.widgets import Footer, Tab, Tabs, Header from textual.widgets import Footer, Tab, Tabs, Header
from textual import on, events
from ui.widgets.sidebar import Sidebar from ui.widgets.sidebar import Sidebar
from ui.widgets.timeline import Timeline, TimelineRow from ui.widgets.timeline import Timeline, TimelineRow
@@ -12,14 +13,21 @@ from project import ProjectChannel
class AppUI(App): class AppUI(App):
CSS_PATH = "../assets/style.tcss" CSS_PATH = "../assets/style.tcss"
theme = "tokyo-night"
def __init__(self, project): def __init__(self, project):
super().__init__() super().__init__()
self.zoom_level = 0.05 self.zoom_level = 0.05
self.last_zoom_level = self.zoom_level self.last_zoom_level = self.zoom_level
self.project = project 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): def create_channel(self, name: str):
self.query_one("#channels").mount(Channel( self.query_one("#channels").mount(Channel(
len(self.project.channels), len(self.project.channels),

View File

@@ -14,7 +14,7 @@ from ui.widgets.chunk_types.chunk import Chunk
class AudioChunk(Chunk): class AudioChunk(Chunk):
DEFAULT_CSS = """ DEFAULT_CSS = """
AudioChunk { AudioChunk {
border: tab $secondary; border: tab $primary;
PlotWidget { PlotWidget {
height: 1fr; height: 1fr;
@@ -91,7 +91,7 @@ class AudioChunk(Chunk):
x, x,
y, y,
1.0, 1.0,
bar_style=self.app.theme_variables["secondary"], bar_style=self.app.theme_variables["primary"],
hires_mode=HiResMode.BRAILLE hires_mode=HiResMode.BRAILLE
) )

View File

@@ -42,7 +42,12 @@ class ProjectSettings(Horizontal):
def on_button_pressed(self, event: Button.Pressed): def on_button_pressed(self, event: Button.Pressed):
if event.button.id == "play-button": 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: def compose(self) -> ComposeResult:
yield Button("", tooltip="Play song", flat=True, id="play-button", variant="success") # icon becomes "⏸" when song is playing yield Button("", tooltip="Play song", flat=True, id="play-button", variant="success") # icon becomes "⏸" when song is playing

View File

@@ -7,6 +7,8 @@ from ui.widgets.chunk_types.audio import AudioChunk, Chunk
from ui.widgets.play_head import PlayHead from ui.widgets.play_head import PlayHead
from project import ChunkType from project import ChunkType
from song_player import SongPlayer
class TimelineRow(Horizontal): class TimelineRow(Horizontal):
DEFAULT_CSS = """ DEFAULT_CSS = """
@@ -64,6 +66,7 @@ class Timeline(Vertical):
super().__init__(id="timeline") super().__init__(id="timeline")
self.calc_bar_offset() self.calc_bar_offset()
self.song_player = SongPlayer(self, self.app.project)
def calc_bar_offset(self): def calc_bar_offset(self):
self.bar_offset = self.app.project.bpm / 8 * (0.03333333333 / self.app.zoom_level) 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.calc_bar_offset()
self.handle_zoom() 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): def handle_zoom(self):
for chunk in self.query(Chunk): for chunk in self.query(Chunk):
chunk.calculate_size() chunk.calculate_size()
@@ -101,6 +117,9 @@ class Timeline(Vertical):
else: else:
bar_line.display = True 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: def compose(self) -> ComposeResult:
@@ -116,7 +135,7 @@ class Timeline(Vertical):
elif chunk.chunk_type == ChunkType.AUDIO: elif chunk.chunk_type == ChunkType.AUDIO:
yield AudioChunk(chunk.audio_data, chunk.sample_rate, chunk.name, chunk.position) 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 bar = None
if i % 4 == 0: if i % 4 == 0:
bar = Rule.vertical(classes="bar-line", line_style="double") bar = Rule.vertical(classes="bar-line", line_style="double")