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