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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-10 00:37:07 +02:00
parent 571dae7cc9
commit 7a9dc5a2f7
4 changed files with 124 additions and 38 deletions

View File

@ -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

View File

@ -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"

View File

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

View File

@ -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