284 lines
11 KiB
Python
284 lines
11 KiB
Python
"""Tkinter GUI wiring the board state and audio engine together.
|
|
|
|
Cross-platform: auto-selects VB-Cable (Windows) or a null-sink/virtual device (Linux).
|
|
Playback is overlapping and non-blocking; the engine runs each sound on its own thread,
|
|
and finish/start callbacks are marshaled back to the Tk thread via ``root.after``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tkinter as tk
|
|
from tkinter import filedialog, messagebox, simpledialog, ttk
|
|
|
|
import audio_engine
|
|
from board import Board
|
|
|
|
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
|
GRID_COLUMNS = 4
|
|
|
|
FILE_TYPES = [
|
|
("Audio files", "*.wav *.mp3 *.ogg *.flac *.aiff *.aif *.m4a *.aac"),
|
|
("All files", "*.*"),
|
|
]
|
|
|
|
IDLE_BG = "#3b4252"
|
|
PLAYING_BG = "#a3be8c"
|
|
FG = "#eceff4"
|
|
|
|
|
|
class SoundboardApp:
|
|
def __init__(self, root: tk.Tk):
|
|
self.root = root
|
|
self.root.title("Soundboard")
|
|
self.root.geometry("640x480")
|
|
self.root.minsize(420, 320)
|
|
|
|
self.board = Board(CONFIG_PATH)
|
|
self.engine = audio_engine.AudioEngine(volume=self.board.volume / 100.0)
|
|
|
|
# button_id -> set of active playback ids (for the "playing" indicator)
|
|
self._active: dict[str, set[int]] = {}
|
|
self._button_widgets: dict[str, tk.Button] = {}
|
|
|
|
self._devices = self._safe_list_devices()
|
|
self._build_toolbar()
|
|
self._build_grid_area()
|
|
|
|
self._init_device_selection()
|
|
self.board.on_change = self._rebuild_grid
|
|
self._rebuild_grid()
|
|
|
|
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
|
|
|
|
# ------------------------------------------------------------------ setup
|
|
def _safe_list_devices(self):
|
|
try:
|
|
return audio_engine.list_output_devices()
|
|
except Exception as exc: # audio backend unavailable — degrade gracefully
|
|
messagebox.showwarning(
|
|
"Audio unavailable",
|
|
f"Could not query audio output devices:\n{exc}\n\n"
|
|
"Playback will not work until this is resolved.",
|
|
)
|
|
return []
|
|
|
|
def _build_toolbar(self):
|
|
bar = ttk.Frame(self.root, padding=(8, 6))
|
|
bar.pack(side=tk.TOP, fill=tk.X)
|
|
|
|
ttk.Label(bar, text="Output:").pack(side=tk.LEFT)
|
|
self.device_var = tk.StringVar()
|
|
self.device_combo = ttk.Combobox(
|
|
bar, textvariable=self.device_var, state="readonly", width=34,
|
|
values=[d["name"] for d in self._devices],
|
|
)
|
|
self.device_combo.pack(side=tk.LEFT, padx=(4, 8))
|
|
self.device_combo.bind("<<ComboboxSelected>>", self._on_device_change)
|
|
|
|
ttk.Button(bar, text="Add Sound", command=self._on_add).pack(side=tk.LEFT)
|
|
ttk.Button(bar, text="Stop All", command=self.engine.stop_all).pack(side=tk.LEFT, padx=4)
|
|
|
|
ttk.Label(bar, text="Volume").pack(side=tk.LEFT, padx=(12, 2))
|
|
self.volume_var = tk.IntVar(value=self.board.volume)
|
|
self.volume_scale = ttk.Scale(
|
|
bar, from_=0, to=100, orient=tk.HORIZONTAL,
|
|
command=self._on_volume_move, length=120,
|
|
)
|
|
self.volume_scale.set(self.board.volume)
|
|
self.volume_scale.pack(side=tk.LEFT)
|
|
self.volume_label = ttk.Label(bar, text=f"{self.board.volume}%", width=4)
|
|
self.volume_label.pack(side=tk.LEFT)
|
|
# Persist volume only when the user releases the slider.
|
|
self.volume_scale.bind("<ButtonRelease-1>", self._on_volume_commit)
|
|
|
|
def _build_grid_area(self):
|
|
outer = ttk.Frame(self.root)
|
|
outer.pack(fill=tk.BOTH, expand=True)
|
|
canvas = tk.Canvas(outer, highlightthickness=0)
|
|
scrollbar = ttk.Scrollbar(outer, orient=tk.VERTICAL, command=canvas.yview)
|
|
self.grid_frame = ttk.Frame(canvas, padding=8)
|
|
self.grid_frame.bind(
|
|
"<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
|
|
)
|
|
canvas.create_window((0, 0), window=self.grid_frame, anchor="nw")
|
|
canvas.configure(yscrollcommand=scrollbar.set)
|
|
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
|
|
status = ttk.Frame(self.root)
|
|
status.pack(side=tk.BOTTOM, fill=tk.X)
|
|
self.status_var = tk.StringVar(value="Ready")
|
|
ttk.Label(status, textvariable=self.status_var, anchor="w", padding=(8, 2)).pack(
|
|
fill=tk.X
|
|
)
|
|
|
|
def _init_device_selection(self):
|
|
saved = self.board.output_device
|
|
chosen = None
|
|
if saved and any(saved.lower() in d["name"].lower() for d in self._devices):
|
|
chosen = next(d for d in self._devices if saved.lower() in d["name"].lower())
|
|
if chosen is None:
|
|
chosen = audio_engine.auto_select_device(self._devices)
|
|
if chosen is not None:
|
|
self.device_var.set(chosen["name"])
|
|
self._apply_device(chosen["name"])
|
|
|
|
# ------------------------------------------------------------- callbacks
|
|
def _on_device_change(self, _event=None):
|
|
self._apply_device(self.device_var.get())
|
|
|
|
def _apply_device(self, name: str):
|
|
try:
|
|
self.engine.set_device(name)
|
|
except ValueError as exc:
|
|
messagebox.showerror("Device error", str(exc))
|
|
return
|
|
self.board.set_output_device(name)
|
|
self.status_var.set(f"Output: {name}")
|
|
|
|
def _on_volume_move(self, value):
|
|
vol = int(float(value))
|
|
self.volume_label.config(text=f"{vol}%")
|
|
self.engine.set_volume(vol / 100.0)
|
|
|
|
def _on_volume_commit(self, _event=None):
|
|
self.board.set_volume(int(self.volume_scale.get()))
|
|
|
|
def _on_add(self):
|
|
path = filedialog.askopenfilename(title="Add Sound", filetypes=FILE_TYPES)
|
|
if not path:
|
|
return
|
|
ext = os.path.splitext(path)[1].lower().lstrip(".")
|
|
if ext not in audio_engine.SUPPORTED_EXTS:
|
|
messagebox.showwarning("Unsupported", f"Unsupported file type: .{ext}")
|
|
return
|
|
self.board.add_button(path) # triggers _rebuild_grid via on_change
|
|
|
|
# --------------------------------------------------------------- grid UI
|
|
def _rebuild_grid(self):
|
|
for child in self.grid_frame.winfo_children():
|
|
child.destroy()
|
|
self._button_widgets.clear()
|
|
|
|
for col in range(GRID_COLUMNS):
|
|
self.grid_frame.columnconfigure(col, weight=1, uniform="cells")
|
|
|
|
if not self.board.buttons:
|
|
ttk.Label(
|
|
self.grid_frame,
|
|
text='No sounds yet — click "Add Sound" to get started.',
|
|
).grid(row=0, column=0, columnspan=GRID_COLUMNS, pady=20)
|
|
return
|
|
|
|
for idx, b in enumerate(self.board.buttons):
|
|
row, col = divmod(idx, GRID_COLUMNS)
|
|
playing = bool(self._active.get(b["id"]))
|
|
btn = tk.Button(
|
|
self.grid_frame,
|
|
text=b["label"],
|
|
wraplength=120,
|
|
height=3,
|
|
bg=PLAYING_BG if playing else IDLE_BG,
|
|
fg=FG,
|
|
activebackground=PLAYING_BG,
|
|
relief=tk.RAISED,
|
|
command=lambda bid=b["id"]: self._play_button(bid),
|
|
)
|
|
btn.grid(row=row, column=col, sticky="nsew", padx=4, pady=4)
|
|
btn.bind("<Button-3>", lambda e, bid=b["id"]: self._show_context_menu(e, bid))
|
|
self._button_widgets[b["id"]] = btn
|
|
|
|
def _set_button_playing(self, button_id: str, playing: bool):
|
|
btn = self._button_widgets.get(button_id)
|
|
if btn is not None:
|
|
btn.config(bg=PLAYING_BG if playing else IDLE_BG)
|
|
|
|
# ------------------------------------------------------------- playback
|
|
def _play_button(self, button_id: str):
|
|
try:
|
|
button = self.board.get_button(button_id)
|
|
except KeyError:
|
|
return
|
|
path = button["file_path"]
|
|
if not os.path.exists(path):
|
|
messagebox.showerror("Missing file", f"File not found:\n{path}")
|
|
return
|
|
if self.engine.device_index is None:
|
|
messagebox.showwarning("No device", "Select an output device first.")
|
|
return
|
|
|
|
self.engine.play(
|
|
path,
|
|
on_start=lambda pid, bid=button_id: self.root.after(0, self._on_play_start, bid, pid),
|
|
on_finish=lambda pid, err, bid=button_id: self.root.after(
|
|
0, self._on_play_finish, bid, pid, err
|
|
),
|
|
)
|
|
|
|
def _on_play_start(self, button_id: str, pid: int):
|
|
self._active.setdefault(button_id, set()).add(pid)
|
|
self._set_button_playing(button_id, True)
|
|
|
|
def _on_play_finish(self, button_id: str, pid: int, err):
|
|
active = self._active.get(button_id)
|
|
if active:
|
|
active.discard(pid)
|
|
if not active:
|
|
self._active.pop(button_id, None)
|
|
self._set_button_playing(button_id, False)
|
|
if err is not None:
|
|
self.status_var.set(f"Playback error: {err}")
|
|
|
|
# ------------------------------------------------------- context menu
|
|
def _show_context_menu(self, event, button_id: str):
|
|
menu = tk.Menu(self.root, tearoff=0)
|
|
menu.add_command(label="Rename", command=lambda: self._rename(button_id))
|
|
menu.add_command(label="Stop this sound", command=lambda: self._stop_button(button_id))
|
|
menu.add_separator()
|
|
menu.add_command(label="Remove", command=lambda: self._remove(button_id))
|
|
try:
|
|
menu.tk_popup(event.x_root, event.y_root)
|
|
finally:
|
|
menu.grab_release()
|
|
|
|
def _rename(self, button_id: str):
|
|
try:
|
|
current = self.board.get_button(button_id)["label"]
|
|
except KeyError:
|
|
return
|
|
new = simpledialog.askstring("Rename", "New label:", initialvalue=current, parent=self.root)
|
|
if new and new.strip():
|
|
self.board.rename_button(button_id, new)
|
|
|
|
def _stop_button(self, button_id: str):
|
|
try:
|
|
self.engine.stop_file(self.board.get_button(button_id)["file_path"])
|
|
except KeyError:
|
|
pass
|
|
|
|
def _remove(self, button_id: str):
|
|
try:
|
|
label = self.board.get_button(button_id)["label"]
|
|
except KeyError:
|
|
return
|
|
if messagebox.askyesno("Remove", f'Remove "{label}" from the board?\n(The file stays on disk.)'):
|
|
self._stop_button(button_id)
|
|
self.board.remove_button(button_id)
|
|
|
|
# ------------------------------------------------------------- shutdown
|
|
def _on_close(self):
|
|
self.engine.stop_all()
|
|
self.board.save()
|
|
self.root.destroy()
|
|
|
|
|
|
def main():
|
|
root = tk.Tk()
|
|
SoundboardApp(root)
|
|
root.mainloop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|