Files
soundboard-py/soundboard/main.py
T

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