you can play your song now :D
This commit is contained in:
10
src/main.py
10
src/main.py
@@ -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
89
src/song_player.py
Normal 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.
@@ -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,13 +13,20 @@ 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(
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -100,6 +116,9 @@ class Timeline(Vertical):
|
|||||||
bar_line.display = False
|
bar_line.display = False
|
||||||
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user