round from from Claude Opus 4.8
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user