From 7a9dc5a2f700b966aca94cca4a302d4b57a7559e Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Wed, 10 Jun 2026 00:37:07 +0200 Subject: [PATCH] feat: MP3/OGG/FLAC sound support + browse dialog with auto-preview (v2.03.16) Sound fields accept WAV/MP3/OGG/FLAC/M4A/AAC/AIFF/Opus. Browse dialog auto-plays each file on selection so you can preview before confirming. sound.py falls back to ffplay/gst-play-1.0 for formats not supported by pw-play/paplay. Co-Authored-By: Claude Sonnet 4.6 --- linux/CHANGELOG.md | 11 ++++ linux/blitztext/__init__.py | 2 +- linux/blitztext/gtksettings.py | 111 +++++++++++++++++++++++++-------- linux/blitztext/sound.py | 38 ++++++++--- 4 files changed, 124 insertions(+), 38 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 7372528..ff96ecb 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,6 +9,17 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py). ## [Unreleased] +## [2.03.16] - 2026-06-10 + +### Added +- **MP3/OGG/FLAC support for sound cues.** The sound file picker now accepts + WAV, MP3, OGG, FLAC, M4A, AAC, AIFF, and Opus. Playback uses `ffplay` or + `gst-play-1.0` as a universal fallback when the native `pw-play`/`paplay` + can't handle the format. +- **Browse dialog with auto-preview.** The 📁 browse button opens a + `FileChooserDialog`; selecting a file auto-plays it so you can hear it before + confirming. The ▶ play button still works on the current selection. + ## [2.03.15] - 2026-06-10 ### Added diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py index c1f496c..8d695ee 100644 --- a/linux/blitztext/__init__.py +++ b/linux/blitztext/__init__.py @@ -6,4 +6,4 @@ counterpart to the macOS Blitztext menu bar app: it runs natively on the host (not in a container) so it can type into any application via xdotool. """ -__version__ = "2.03.15" +__version__ = "2.03.16" diff --git a/linux/blitztext/gtksettings.py b/linux/blitztext/gtksettings.py index 03c01b9..f45dfb5 100644 --- a/linux/blitztext/gtksettings.py +++ b/linux/blitztext/gtksettings.py @@ -1791,47 +1791,101 @@ notebook.bt-nb tab:checked label { self.snd_after = self._sound_field(snd_card, "Stop sound", self.cfg.sound_after, "Plays when manual recording stops (paste, paste+Enter, or auto-stop).", width=LW) - def _sound_field(self, page: Gtk.Box, label: str, value: str, tooltip: str = "", + def _sound_field(self, page, label: str, value: str, tooltip: str = "", empty_note: str = "Leave empty to use the built-in system sound.", clear_tip: str = "Clear — use the built-in system sound", - width: int = 150) -> Gtk.FileChooserButton: - row = Gtk.Box(spacing=10); row.set_margin_top(3); row.set_margin_bottom(3) + width: int = 150): + """Sound file picker row. Returns an object with get_filename().""" + row = Gtk.Box(spacing=6); row.set_margin_top(3); row.set_margin_bottom(3) lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(width, -1) if tooltip: - lbl.set_tooltip_text(tooltip) + lbl.set_tooltip_text(f"{tooltip} {empty_note}") row.pack_start(lbl, False, False, 0) - chooser = Gtk.FileChooserButton(title=label, action=Gtk.FileChooserAction.OPEN) - af = Gtk.FileFilter(); af.set_name("Audio") - for pat in ("*.wav", "*.oga", "*.ogg", "*.flac"): - af.add_pattern(pat) - chooser.add_filter(af) + + path_entry = Gtk.Entry() + path_entry.set_hexpand(True) + path_entry.set_editable(False) + path_entry.set_placeholder_text(empty_note) if value: - chooser.set_filename(value) - chooser.set_hexpand(True) - if tooltip: - chooser.set_tooltip_text(f"{tooltip} {empty_note}") - row.pack_start(chooser, True, True, 0) - play = Gtk.Button.new_from_icon_name("media-playback-start-symbolic", Gtk.IconSize.BUTTON) - play.set_tooltip_text("Play this sound now") - play.connect("clicked", lambda _b, c=chooser: self._play_sound_file(c.get_filename())) - row.pack_start(play, False, False, 0) + path_entry.set_text(value) + row.pack_start(path_entry, True, True, 0) + + browse = Gtk.Button.new_from_icon_name("document-open-symbolic", Gtk.IconSize.BUTTON) + browse.set_tooltip_text("Browse for audio file (WAV, MP3, OGG, FLAC, …)") + browse.connect("clicked", lambda _b, e=path_entry, t=label: self._browse_sound(e, t)) + row.pack_start(browse, False, False, 0) + + play_btn = Gtk.Button.new_from_icon_name("media-playback-start-symbolic", Gtk.IconSize.BUTTON) + play_btn.set_tooltip_text("Play this sound now") + play_btn.connect("clicked", lambda _b, e=path_entry: self._play_sound_file(e.get_text())) + row.pack_start(play_btn, False, False, 0) + clr = Gtk.Button.new_from_icon_name("edit-clear-symbolic", Gtk.IconSize.BUTTON) clr.set_tooltip_text(clear_tip) - clr.connect("clicked", lambda _b, c=chooser: c.unselect_all()) + clr.connect("clicked", lambda _b, e=path_entry: e.set_text("")) row.pack_start(clr, False, False, 0) + if isinstance(page, Gtk.ListBox): - if not row.get_margin_start(): - row.set_margin_start(12); row.set_margin_end(8) - row.set_margin_top(6); row.set_margin_bottom(6) + row.set_margin_start(12); row.set_margin_end(8) + row.set_margin_top(6); row.set_margin_bottom(6) _lb_add(page, row) else: page.pack_start(row, False, False, 0) - return chooser + + # Return a thin wrapper so callers use .get_filename() as before. + class _Picker: + def get_filename(self_): + t = path_entry.get_text().strip() + return t if t else None + return _Picker() + + def _browse_sound(self, entry: Gtk.Entry, title: str) -> None: + dlg = Gtk.FileChooserDialog( + title=f"Choose sound — {title}", + parent=self.dlg, + action=Gtk.FileChooserAction.OPEN, + ) + dlg.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, + "_Select", Gtk.ResponseType.OK) + af = Gtk.FileFilter(); af.set_name("Audio files (WAV, MP3, OGG, FLAC, …)") + for pat in ("*.wav", "*.mp3", "*.ogg", "*.oga", "*.flac", "*.m4a", "*.aac", + "*.aif", "*.aiff", "*.opus"): + af.add_pattern(pat) + dlg.add_filter(af) + af_all = Gtk.FileFilter(); af_all.set_name("All files"); af_all.add_pattern("*") + dlg.add_filter(af_all) + cur = entry.get_text().strip() + if cur: + dlg.set_filename(cur) + + # Auto-preview each file as the selection changes. + def _on_selection(d): + fn = d.get_filename() + if fn and os.path.isfile(fn): + self._play_sound_file(fn) + dlg.connect("selection-changed", _on_selection) + + if dlg.run() == Gtk.ResponseType.OK: + fn = dlg.get_filename() + if fn: + entry.set_text(fn) + self._stop_preview() + dlg.destroy() def _play_sound_file(self, path) -> None: from . import sound - if path: - sound.play(path) + self._stop_preview() + if path and os.path.isfile(os.path.expanduser(path)): + self._preview_proc = sound.play(path) + + def _stop_preview(self) -> None: + proc = getattr(self, "_preview_proc", None) + if proc is not None: + try: + proc.terminate() + except Exception: + pass + self._preview_proc = None # ===== General ========================================================== def _build_general(self, page: Gtk.Box) -> None: @@ -2052,8 +2106,11 @@ notebook.bt-nb tab:checked label { "and names the fastest and most accurate. Add an engine preset for " "each model you want in the comparison.") - wavf = Gtk.FileChooserButton(title="WAV file", action=Gtk.FileChooserAction.OPEN) - fa = Gtk.FileFilter(); fa.set_name("Audio (.wav)"); fa.add_pattern("*.wav"); wavf.add_filter(fa) + wavf = Gtk.FileChooserButton(title="Audio file", action=Gtk.FileChooserAction.OPEN) + fa = Gtk.FileFilter(); fa.set_name("Audio (WAV, MP3, OGG, FLAC, …)") + for _p in ("*.wav", "*.mp3", "*.ogg", "*.oga", "*.flac", "*.m4a", "*.opus"): + fa.add_pattern(_p) + wavf.add_filter(fa) self.bench_wav = _labeled(page, "Audio (.wav)", wavf) reff = Gtk.FileChooserButton(title="Reference transcript", action=Gtk.FileChooserAction.OPEN) ft = Gtk.FileFilter(); ft.set_name("Text (.txt)"); ft.add_pattern("*.txt"); reff.add_filter(ft) diff --git a/linux/blitztext/sound.py b/linux/blitztext/sound.py index 65b6981..7f2f1c1 100644 --- a/linux/blitztext/sound.py +++ b/linux/blitztext/sound.py @@ -7,12 +7,24 @@ import shutil import subprocess _FREEDESKTOP = "/usr/share/sounds/freedesktop/stereo/{name}.oga" -_PLAYERS = ("pw-play", "paplay", "aplay") + +# (player, extra_args, wav_only) +# Ordered: native PipeWire/PulseAudio first, then ffplay/gst as universal fallback. +_PLAYERS: list[tuple[str, list[str], bool]] = [ + ("pw-play", [], False), + ("paplay", [], False), + ("aplay", [], True), # WAV only + ("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet"], False), + ("gst-play-1.0", [], False), +] +_NATIVE_EXTS = {".wav", ".oga", ".ogg", ".flac"} -def play(path: str = "", *, fallback: str | None = None) -> None: - """Play `path` (a WAV/OGA file); if unset/missing, play the freedesktop - `fallback` system sound. Returns immediately (fire-and-forget).""" +def play(path: str = "", *, fallback: str | None = None) -> "subprocess.Popen | None": + """Play `path` (WAV/MP3/OGG/FLAC/…); fallback to a freedesktop system sound. + + Returns the Popen object so callers can terminate a preview, or None. + """ target = "" if path: expanded = os.path.expanduser(path) @@ -23,16 +35,22 @@ def play(path: str = "", *, fallback: str | None = None) -> None: if os.path.exists(fd): target = fd if not target: - return + return None - # aplay only handles WAV; pw-play/paplay handle WAV + OGA, so try them first. - for player in _PLAYERS: + ext = os.path.splitext(target)[1].lower() + for player, extra, wav_only in _PLAYERS: if not shutil.which(player): continue - if player == "aplay" and not target.lower().endswith(".wav"): + if wav_only and ext != ".wav": + continue + if player == "paplay" and ext not in _NATIVE_EXTS: continue try: - subprocess.Popen([player, target], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - return + proc = subprocess.Popen( + [player] + extra + [target], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + return proc except OSError: continue + return None