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:
parent
571dae7cc9
commit
7a9dc5a2f7
@ -9,6 +9,17 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [2.03.15] - 2026-06-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@ -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.
|
(not in a container) so it can type into any application via xdotool.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "2.03.15"
|
__version__ = "2.03.16"
|
||||||
|
|||||||
@ -1791,47 +1791,101 @@ notebook.bt-nb tab:checked label {
|
|||||||
self.snd_after = self._sound_field(snd_card, "Stop sound", self.cfg.sound_after,
|
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)
|
"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.",
|
empty_note: str = "Leave empty to use the built-in system sound.",
|
||||||
clear_tip: str = "Clear — use the built-in system sound",
|
clear_tip: str = "Clear — use the built-in system sound",
|
||||||
width: int = 150) -> Gtk.FileChooserButton:
|
width: int = 150):
|
||||||
row = Gtk.Box(spacing=10); row.set_margin_top(3); row.set_margin_bottom(3)
|
"""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)
|
lbl = Gtk.Label(label=label, xalign=0.0); lbl.set_size_request(width, -1)
|
||||||
if tooltip:
|
if tooltip:
|
||||||
lbl.set_tooltip_text(tooltip)
|
lbl.set_tooltip_text(f"{tooltip} {empty_note}")
|
||||||
row.pack_start(lbl, False, False, 0)
|
row.pack_start(lbl, False, False, 0)
|
||||||
chooser = Gtk.FileChooserButton(title=label, action=Gtk.FileChooserAction.OPEN)
|
|
||||||
af = Gtk.FileFilter(); af.set_name("Audio")
|
path_entry = Gtk.Entry()
|
||||||
for pat in ("*.wav", "*.oga", "*.ogg", "*.flac"):
|
path_entry.set_hexpand(True)
|
||||||
af.add_pattern(pat)
|
path_entry.set_editable(False)
|
||||||
chooser.add_filter(af)
|
path_entry.set_placeholder_text(empty_note)
|
||||||
if value:
|
if value:
|
||||||
chooser.set_filename(value)
|
path_entry.set_text(value)
|
||||||
chooser.set_hexpand(True)
|
row.pack_start(path_entry, True, True, 0)
|
||||||
if tooltip:
|
|
||||||
chooser.set_tooltip_text(f"{tooltip} {empty_note}")
|
browse = Gtk.Button.new_from_icon_name("document-open-symbolic", Gtk.IconSize.BUTTON)
|
||||||
row.pack_start(chooser, True, True, 0)
|
browse.set_tooltip_text("Browse for audio file (WAV, MP3, OGG, FLAC, …)")
|
||||||
play = Gtk.Button.new_from_icon_name("media-playback-start-symbolic", Gtk.IconSize.BUTTON)
|
browse.connect("clicked", lambda _b, e=path_entry, t=label: self._browse_sound(e, t))
|
||||||
play.set_tooltip_text("Play this sound now")
|
row.pack_start(browse, False, False, 0)
|
||||||
play.connect("clicked", lambda _b, c=chooser: self._play_sound_file(c.get_filename()))
|
|
||||||
row.pack_start(play, 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 = Gtk.Button.new_from_icon_name("edit-clear-symbolic", Gtk.IconSize.BUTTON)
|
||||||
clr.set_tooltip_text(clear_tip)
|
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)
|
row.pack_start(clr, False, False, 0)
|
||||||
|
|
||||||
if isinstance(page, Gtk.ListBox):
|
if isinstance(page, Gtk.ListBox):
|
||||||
if not row.get_margin_start():
|
|
||||||
row.set_margin_start(12); row.set_margin_end(8)
|
row.set_margin_start(12); row.set_margin_end(8)
|
||||||
row.set_margin_top(6); row.set_margin_bottom(6)
|
row.set_margin_top(6); row.set_margin_bottom(6)
|
||||||
_lb_add(page, row)
|
_lb_add(page, row)
|
||||||
else:
|
else:
|
||||||
page.pack_start(row, False, False, 0)
|
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:
|
def _play_sound_file(self, path) -> None:
|
||||||
from . import sound
|
from . import sound
|
||||||
if path:
|
self._stop_preview()
|
||||||
sound.play(path)
|
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 ==========================================================
|
# ===== General ==========================================================
|
||||||
def _build_general(self, page: Gtk.Box) -> None:
|
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 "
|
"and names the fastest and most accurate. Add an engine preset for "
|
||||||
"each model you want in the comparison.")
|
"each model you want in the comparison.")
|
||||||
|
|
||||||
wavf = Gtk.FileChooserButton(title="WAV file", action=Gtk.FileChooserAction.OPEN)
|
wavf = Gtk.FileChooserButton(title="Audio file", action=Gtk.FileChooserAction.OPEN)
|
||||||
fa = Gtk.FileFilter(); fa.set_name("Audio (.wav)"); fa.add_pattern("*.wav"); wavf.add_filter(fa)
|
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)
|
self.bench_wav = _labeled(page, "Audio (.wav)", wavf)
|
||||||
reff = Gtk.FileChooserButton(title="Reference transcript", action=Gtk.FileChooserAction.OPEN)
|
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)
|
ft = Gtk.FileFilter(); ft.set_name("Text (.txt)"); ft.add_pattern("*.txt"); reff.add_filter(ft)
|
||||||
|
|||||||
@ -7,12 +7,24 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
_FREEDESKTOP = "/usr/share/sounds/freedesktop/stereo/{name}.oga"
|
_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:
|
def play(path: str = "", *, fallback: str | None = None) -> "subprocess.Popen | None":
|
||||||
"""Play `path` (a WAV/OGA file); if unset/missing, play the freedesktop
|
"""Play `path` (WAV/MP3/OGG/FLAC/…); fallback to a freedesktop system sound.
|
||||||
`fallback` system sound. Returns immediately (fire-and-forget)."""
|
|
||||||
|
Returns the Popen object so callers can terminate a preview, or None.
|
||||||
|
"""
|
||||||
target = ""
|
target = ""
|
||||||
if path:
|
if path:
|
||||||
expanded = os.path.expanduser(path)
|
expanded = os.path.expanduser(path)
|
||||||
@ -23,16 +35,22 @@ def play(path: str = "", *, fallback: str | None = None) -> None:
|
|||||||
if os.path.exists(fd):
|
if os.path.exists(fd):
|
||||||
target = fd
|
target = fd
|
||||||
if not target:
|
if not target:
|
||||||
return
|
return None
|
||||||
|
|
||||||
# aplay only handles WAV; pw-play/paplay handle WAV + OGA, so try them first.
|
ext = os.path.splitext(target)[1].lower()
|
||||||
for player in _PLAYERS:
|
for player, extra, wav_only in _PLAYERS:
|
||||||
if not shutil.which(player):
|
if not shutil.which(player):
|
||||||
continue
|
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
|
continue
|
||||||
try:
|
try:
|
||||||
subprocess.Popen([player, target], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
proc = subprocess.Popen(
|
||||||
return
|
[player] + extra + [target],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
return proc
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
|
return None
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user