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__":
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

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.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,14 +13,21 @@ 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(
len(self.project.channels),

View File

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

View File

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

View File

@@ -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()
@@ -101,6 +117,9 @@ class Timeline(Vertical):
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")