From 142c3f23e5ae50f29e6213a325d3658b1e05dbd6 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Mon, 17 Aug 2026 01:22:18 +0200 Subject: [PATCH 1/8] Show model downloads in the GUI Models are fetched on first use inside birdnet.load; the library's tqdm bar goes to stderr, which the frozen GUI diverts to the log file, so a first-run download of several hundred MB looked like a hang. gui.utils.download_progress registers birdnet's scoped download callback for the duration of an analysis: with a gr.Progress it drives the bar (fraction plus MB counts, unknown sizes keep the bar visible), without one it announces the download as a toast, and a retry is surfaced as a warning. UI failures inside the callback are logged, never raised - an exception escaping it aborts the download in the library. The CLI keeps the library's tqdm bar unchanged. Co-Authored-By: Claude Fable 5 --- birdnet_analyzer/gui/analysis.py | 65 ++++++++++++++++---------------- birdnet_analyzer/gui/utils.py | 52 +++++++++++++++++++++++++ birdnet_analyzer/lang/de.json | 2 + birdnet_analyzer/lang/en.json | 2 + birdnet_analyzer/lang/fi.json | 2 + birdnet_analyzer/lang/fr.json | 2 + birdnet_analyzer/lang/id.json | 2 + birdnet_analyzer/lang/pt-br.json | 2 + birdnet_analyzer/lang/ru.json | 2 + birdnet_analyzer/lang/se.json | 2 + birdnet_analyzer/lang/tlh.json | 2 + birdnet_analyzer/lang/zh_CN.json | 2 + birdnet_analyzer/lang/zh_TW.json | 2 + 13 files changed, 107 insertions(+), 32 deletions(-) diff --git a/birdnet_analyzer/gui/analysis.py b/birdnet_analyzer/gui/analysis.py index 2183993c7..5728a9852 100644 --- a/birdnet_analyzer/gui/analysis.py +++ b/birdnet_analyzer/gui/analysis.py @@ -130,35 +130,36 @@ def run_analysis( if progress is not None: progress(0, desc=f"{loc.localize('progress-starting')} ...") - return analyze( - audio_input=input_dir or input_path, # type: ignore - # TODO: workaround while lib is not ignoring confidence with top_n - min_conf=confidence if not use_top_n else 0, - sensitivity=sensitivity, - locale=locale, - overlap=overlap, - audio_speed=audio_speed, - fmin=fmin, - fmax=fmax, - batch_size=batch_size, - rtype=output_types, - sf_thresh=sf_thresh, - lat=lat, - lon=lon, - week=week, - slist=slist, - top_n=top_n if use_top_n else None, - output=output_path, - merge_consecutive=merge_consecutive, - additional_columns=additional_columns, - model="perch" if use_perch else "birdnet", - birdnet=birdnet_version, - classifier=custom_classifier, - cc_species_list=None, # always default search path in GUI currently - on_update=on_update, - save_params=save_params, - n_producers=n_producers, - n_workers=n_workers, - split_tables=split_tables, - _return_only=bool(input_path), # only for single file tab - ) + with gu.download_progress(progress if callable(progress) else None): + return analyze( + audio_input=input_dir or input_path, # type: ignore + # TODO: workaround while lib is not ignoring confidence with top_n + min_conf=confidence if not use_top_n else 0, + sensitivity=sensitivity, + locale=locale, + overlap=overlap, + audio_speed=audio_speed, + fmin=fmin, + fmax=fmax, + batch_size=batch_size, + rtype=output_types, + sf_thresh=sf_thresh, + lat=lat, + lon=lon, + week=week, + slist=slist, + top_n=top_n if use_top_n else None, + output=output_path, + merge_consecutive=merge_consecutive, + additional_columns=additional_columns, + model="perch" if use_perch else "birdnet", + birdnet=birdnet_version, + classifier=custom_classifier, + cc_species_list=None, # always default search path in GUI currently + on_update=on_update, + save_params=save_params, + n_producers=n_producers, + n_workers=n_workers, + split_tables=split_tables, + _return_only=bool(input_path), # only for single file tab + ) diff --git a/birdnet_analyzer/gui/utils.py b/birdnet_analyzer/gui/utils.py index 6d2472807..a9f0c86f8 100644 --- a/birdnet_analyzer/gui/utils.py +++ b/birdnet_analyzer/gui/utils.py @@ -199,6 +199,58 @@ def wrapper(*args, **kwargs): return wrapper +def _format_bytes(n: int) -> str: + return f"{n / 1e6:.0f} MB" if n < 1e9 else f"{n / 1e9:.1f} GB" + + +def download_progress(progress: gr.Progress | None = None): + """Shows the birdnet library's model downloads while the ``with`` block runs. + + Models are fetched on first use inside ``birdnet.load``; the library's own tqdm + bar goes to stderr, which the frozen GUI diverts to the log file. This routes the + updates to ``progress`` when a bar is available and to toasts otherwise, so a + first-run download of several hundred MB never looks like a hang. + """ + import birdnet + + def on_update(update: birdnet.DownloadProgress) -> None: + # The library's descriptions read "Downloading acoustic model v3.0 (...)"; + # keep the localized verb and only the object. + name = update.description.removeprefix("Downloading ") + if update.attempt > 1: + name = f"{name} ({update.attempt}/{update.max_attempts})" + + label = f"{loc.localize('progress-downloading-model')}: {name}" + + # An exception escaping this callback aborts the download in the library + # (that is its cancel path), so UI hiccups must not leak out of here. + try: + if update.status == "started" and progress is None: + gr.Info(label) + elif update.status == "progress" and progress is not None: + if update.bytes_total: + done = _format_bytes(update.bytes_done) + total = _format_bytes(update.bytes_total) + progress( + min(update.bytes_done / update.bytes_total, 1.0), + desc=f"{label} ({done} / {total})", + ) + else: + # Unknown size: keep the bar visible (None would hide it). + progress(0.0, desc=f"{label} ({_format_bytes(update.bytes_done)})") + elif update.status == "retrying": + gr.Warning( + f"{loc.localize('progress-download-retrying')}: {name} - " + f"{update.error}" + ) + # "failed" is terminal: the library raises right after it, and that + # error reaches the user through the operation's own error handling. + except Exception: + logging.getLogger(__name__).exception("Download progress UI update failed") + + return birdnet.download_progress_callback(on_update) + + def select_folder(state_key=None): """Opens a folder selection dialog and returns the selected folder path. diff --git a/birdnet_analyzer/lang/de.json b/birdnet_analyzer/lang/de.json index 3a40b29e9..066c33adb 100644 --- a/birdnet_analyzer/lang/de.json +++ b/birdnet_analyzer/lang/de.json @@ -225,6 +225,8 @@ "progress-analyzing": "Analysiere", "progress-autotune": "Autotune läuft", "progress-build-classifier": "Daten laden & Klassifikator erstellen", + "progress-download-retrying": "Modell-Download unterbrochen, neuer Versuch", + "progress-downloading-model": "Modell wird heruntergeladen", "progress-extracting-segments": "Segmente extrahieren", "progress-loading-data": "Daten für", "progress-saving": "Gespeichert unter", diff --git a/birdnet_analyzer/lang/en.json b/birdnet_analyzer/lang/en.json index 20f653277..a29483125 100644 --- a/birdnet_analyzer/lang/en.json +++ b/birdnet_analyzer/lang/en.json @@ -225,6 +225,8 @@ "progress-analyzing": "Analyzing", "progress-autotune": "Autotune in progress", "progress-build-classifier": "Loading data & building classifier", + "progress-download-retrying": "Model download interrupted, retrying", + "progress-downloading-model": "Downloading model", "progress-extracting-segments": "Extracting segments", "progress-loading-data": "Loading data for", "progress-saving": "Saving at", diff --git a/birdnet_analyzer/lang/fi.json b/birdnet_analyzer/lang/fi.json index 57c89206e..32e206301 100644 --- a/birdnet_analyzer/lang/fi.json +++ b/birdnet_analyzer/lang/fi.json @@ -225,6 +225,8 @@ "progress-analyzing": "Analysoidaan", "progress-autotune": "Autoviritys käynnissä", "progress-build-classifier": "Ladataan dataa & rakennetaan luokittelijaa", + "progress-download-retrying": "Mallin lataus keskeytyi, yritetään uudelleen", + "progress-downloading-model": "Ladataan mallia", "progress-extracting-segments": "Segmenttejä puretaan", "progress-loading-data": "Ladataan dataa kohteelle", "progress-saving": "Tallennetaan kohteeseen", diff --git a/birdnet_analyzer/lang/fr.json b/birdnet_analyzer/lang/fr.json index 52d4f02c0..9ad145c6e 100644 --- a/birdnet_analyzer/lang/fr.json +++ b/birdnet_analyzer/lang/fr.json @@ -225,6 +225,8 @@ "progress-analyzing": "Analyse en cours", "progress-autotune": "Autotune en progression", "progress-build-classifier": "Chargement des données et construction d'un classificateur", + "progress-download-retrying": "Téléchargement du modèle interrompu, nouvelle tentative", + "progress-downloading-model": "Téléchargement du modèle", "progress-extracting-segments": "Extraction des segments", "progress-loading-data": "Chargement des données pour", "progress-saving": "Enregistrement à", diff --git a/birdnet_analyzer/lang/id.json b/birdnet_analyzer/lang/id.json index a56b1988c..3419eb2bf 100644 --- a/birdnet_analyzer/lang/id.json +++ b/birdnet_analyzer/lang/id.json @@ -225,6 +225,8 @@ "progress-analyzing": "Menganalisis", "progress-autotune": "Autotune dalam progres", "progress-build-classifier": "Memuat data & membangun klasifikator", + "progress-download-retrying": "Pengunduhan model terputus, mencoba lagi", + "progress-downloading-model": "Mengunduh model", "progress-extracting-segments": "Mengekstrak segmen", "progress-loading-data": "Memuat data untuk", "progress-saving": "Menyimpan di", diff --git a/birdnet_analyzer/lang/pt-br.json b/birdnet_analyzer/lang/pt-br.json index 925291491..2a75cf37a 100644 --- a/birdnet_analyzer/lang/pt-br.json +++ b/birdnet_analyzer/lang/pt-br.json @@ -225,6 +225,8 @@ "progress-analyzing": "Analisando", "progress-autotune": "Autotune em progresss", "progress-build-classifier": "Carregando dados e construindo classificador", + "progress-download-retrying": "Download do modelo interrompido, tentando novamente", + "progress-downloading-model": "Baixando modelo", "progress-extracting-segments": "Extraindo segmentos", "progress-loading-data": "Carregando os dados para", "progress-saving": "slvando em", diff --git a/birdnet_analyzer/lang/ru.json b/birdnet_analyzer/lang/ru.json index 8b5ca3c93..0800e83e3 100644 --- a/birdnet_analyzer/lang/ru.json +++ b/birdnet_analyzer/lang/ru.json @@ -225,6 +225,8 @@ "progress-analyzing": "Анализ", "progress-autotune": "Выполняется автонастройка", "progress-build-classifier": "Загрузка данных и создание классификатора", + "progress-download-retrying": "Загрузка модели прервана, повторная попытка", + "progress-downloading-model": "Загрузка модели", "progress-extracting-segments": "Извлечение сегментов", "progress-loading-data": "Загрузка данных для", "progress-saving": "Сохранение в", diff --git a/birdnet_analyzer/lang/se.json b/birdnet_analyzer/lang/se.json index 0ebfc3864..6d2360270 100644 --- a/birdnet_analyzer/lang/se.json +++ b/birdnet_analyzer/lang/se.json @@ -225,6 +225,8 @@ "progress-analyzing": "Analyserar", "progress-autotune": "Autojustering pågår", "progress-build-classifier": "Laddar data och bygger klassificerare", + "progress-download-retrying": "Nedladdningen av modellen avbröts, försöker igen", + "progress-downloading-model": "Laddar ner modell", "progress-extracting-segments": "Extraherar segment", "progress-loading-data": "Laddar data för", "progress-saving": "Sparar i", diff --git a/birdnet_analyzer/lang/tlh.json b/birdnet_analyzer/lang/tlh.json index 1ef7dd347..8f765a8b3 100644 --- a/birdnet_analyzer/lang/tlh.json +++ b/birdnet_analyzer/lang/tlh.json @@ -225,6 +225,8 @@ "progress-analyzing": "poj", "progress-autotune": "autotune Qap", "progress-build-classifier": "De' poQ & tu'law' rurbogh", + "progress-download-retrying": "model Download mev, nIDqa'", + "progress-downloading-model": "model'e' Downloadlu'", "progress-extracting-segments": "'ay'mey lInglu'", "progress-loading-data": "De' poQ", "progress-saving": "pol", diff --git a/birdnet_analyzer/lang/zh_CN.json b/birdnet_analyzer/lang/zh_CN.json index a6b75e03e..8c472a7fb 100644 --- a/birdnet_analyzer/lang/zh_CN.json +++ b/birdnet_analyzer/lang/zh_CN.json @@ -225,6 +225,8 @@ "progress-analyzing": "分析中", "progress-autotune": "自动调优进行中", "progress-build-classifier": "正在加载数据并构建分类器", + "progress-download-retrying": "模型下载中断,正在重试", + "progress-downloading-model": "正在下载模型", "progress-extracting-segments": "正在提取片段", "progress-loading-data": "正在为以下内容加载数据", "progress-saving": "正在保存至", diff --git a/birdnet_analyzer/lang/zh_TW.json b/birdnet_analyzer/lang/zh_TW.json index bbf6d0c52..32a308a68 100644 --- a/birdnet_analyzer/lang/zh_TW.json +++ b/birdnet_analyzer/lang/zh_TW.json @@ -225,6 +225,8 @@ "progress-analyzing": "分析中", "progress-autotune": "自動調諧進行中", "progress-build-classifier": "載入資料中、建立分類器", + "progress-download-retrying": "模型下載中斷,正在重試", + "progress-downloading-model": "正在下載模型", "progress-extracting-segments": "正在擷取片段", "progress-loading-data": "載入資料中", "progress-saving": "儲存中", From 5596f891fd6945bd94e44e54dc5775529171ce73 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Mon, 17 Aug 2026 13:44:26 +0200 Subject: [PATCH 2/8] Move the download-progress tests to their own module They import gui.utils, which imports pywebview at module level; the gui-tests extra in CI has no pywebview, so the module stubs it before the import, like test_startup_imports does. Also keeps test_settings.py free of unrelated additions. Co-Authored-By: Claude Fable 5 --- tests/gui/test_download_progress.py | 107 ++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/gui/test_download_progress.py diff --git a/tests/gui/test_download_progress.py b/tests/gui/test_download_progress.py new file mode 100644 index 000000000..de89a6821 --- /dev/null +++ b/tests/gui/test_download_progress.py @@ -0,0 +1,107 @@ +"""The GUI shows the birdnet library's model downloads.""" + +import sys +from unittest.mock import MagicMock + +import pytest + +gr = pytest.importorskip("gradio") + +# gui.utils imports pywebview at module level, which the gui-tests extra lacks. +sys.modules.setdefault("webview", MagicMock(settings={})) + + +def test_download_progress_routes_library_updates_to_gradio(monkeypatch): + # First-run model downloads happen inside birdnet.load; the frozen GUI diverts the + # library's tqdm bar into the log file, so the updates must reach the gradio + # progress bar (or toasts when there is none) through birdnet's callback hook. + import birdnet + import gradio as gr + + from birdnet_analyzer.gui import utils as gu + + calls = [] + infos = [] + warnings = [] + monkeypatch.setattr(gr, "Info", lambda msg, **kw: infos.append(msg)) + monkeypatch.setattr(gr, "Warning", lambda msg, **kw: warnings.append(msg)) + + def fake_progress(value, desc=None, **kwargs): + calls.append((value, desc)) + + def registered(): + return birdnet.get_download_progress_callback() + + def report(**kwargs): + registered()( + birdnet.DownloadProgress( + description="Downloading acoustic model v3.0", + url="https://example.invalid/model", + attempt=1, + max_attempts=3, + **kwargs, + ) + ) + + with gu.download_progress(fake_progress): + report(status="started", bytes_done=0, bytes_total=1000) + report(status="progress", bytes_done=250, bytes_total=1000) + report(status="progress", bytes_done=5_000_000, bytes_total=None) + report(status="finished", bytes_done=1000, bytes_total=1000) + + assert calls[0][0] == 0.25 + assert "acoustic model v3.0" in calls[0][1] + assert "Downloading Downloading" not in calls[0][1] + assert calls[1][0] == 0.0 + assert "5 MB" in calls[1][1] + assert not infos, "no toasts while a progress bar is available" + + # Without a bar, the start of a download is announced as a toast instead. + with gu.download_progress(None): + report(status="started", bytes_done=0, bytes_total=1000) + report(status="progress", bytes_done=250, bytes_total=1000) + + assert len(infos) == 1 + assert "acoustic model v3.0" in infos[0] + + # The hook is scoped: outside the block the library's default (no callback) is back. + assert registered() is None + + +def test_download_progress_survives_ui_failures(monkeypatch): + # In the library an exception escaping the callback aborts the download, so a + # failing UI call (e.g. no request context for the toast) must be swallowed, and + # a retry announcement must reach the user as a warning. + import birdnet + import gradio as gr + + from birdnet_analyzer.gui import utils as gu + + warnings = [] + monkeypatch.setattr(gr, "Warning", lambda msg, **kw: warnings.append(msg)) + + def broken_progress(*args, **kwargs): + raise RuntimeError("no request context") + + def registered(): + return birdnet.get_download_progress_callback() + + def update(status, **extra): + return birdnet.DownloadProgress( + description="Downloading acoustic model v3.0", + url="https://example.invalid/model", + bytes_done=0, + bytes_total=1000, + attempt=2, + max_attempts=3, + status=status, + **extra, + ) + + with gu.download_progress(broken_progress): + registered()(update("progress")) # must not raise + registered()(update("retrying", error="connection reset")) + + assert len(warnings) == 1 + assert "connection reset" in warnings[0] + assert "(2/3)" in warnings[0] From 5c5a966140e4a6e398c54c1bea1e0c5a2104e147 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Mon, 17 Aug 2026 14:17:13 +0200 Subject: [PATCH 3/8] Drop comments the code already states Co-Authored-By: Claude Fable 5 --- birdnet_analyzer/gui/utils.py | 9 ++------- tests/gui/test_download_progress.py | 9 --------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/birdnet_analyzer/gui/utils.py b/birdnet_analyzer/gui/utils.py index a9f0c86f8..7a20acb14 100644 --- a/birdnet_analyzer/gui/utils.py +++ b/birdnet_analyzer/gui/utils.py @@ -214,16 +214,13 @@ def download_progress(progress: gr.Progress | None = None): import birdnet def on_update(update: birdnet.DownloadProgress) -> None: - # The library's descriptions read "Downloading acoustic model v3.0 (...)"; - # keep the localized verb and only the object. name = update.description.removeprefix("Downloading ") if update.attempt > 1: name = f"{name} ({update.attempt}/{update.max_attempts})" label = f"{loc.localize('progress-downloading-model')}: {name}" - # An exception escaping this callback aborts the download in the library - # (that is its cancel path), so UI hiccups must not leak out of here. + # An exception escaping the callback aborts the download in the library. try: if update.status == "started" and progress is None: gr.Info(label) @@ -236,15 +233,13 @@ def on_update(update: birdnet.DownloadProgress) -> None: desc=f"{label} ({done} / {total})", ) else: - # Unknown size: keep the bar visible (None would hide it). progress(0.0, desc=f"{label} ({_format_bytes(update.bytes_done)})") elif update.status == "retrying": gr.Warning( f"{loc.localize('progress-download-retrying')}: {name} - " f"{update.error}" ) - # "failed" is terminal: the library raises right after it, and that - # error reaches the user through the operation's own error handling. + # "failed": the library raises right after; the operation reports it. except Exception: logging.getLogger(__name__).exception("Download progress UI update failed") diff --git a/tests/gui/test_download_progress.py b/tests/gui/test_download_progress.py index de89a6821..3fc6100bf 100644 --- a/tests/gui/test_download_progress.py +++ b/tests/gui/test_download_progress.py @@ -7,14 +7,10 @@ gr = pytest.importorskip("gradio") -# gui.utils imports pywebview at module level, which the gui-tests extra lacks. sys.modules.setdefault("webview", MagicMock(settings={})) def test_download_progress_routes_library_updates_to_gradio(monkeypatch): - # First-run model downloads happen inside birdnet.load; the frozen GUI diverts the - # library's tqdm bar into the log file, so the updates must reach the gradio - # progress bar (or toasts when there is none) through birdnet's callback hook. import birdnet import gradio as gr @@ -56,7 +52,6 @@ def report(**kwargs): assert "5 MB" in calls[1][1] assert not infos, "no toasts while a progress bar is available" - # Without a bar, the start of a download is announced as a toast instead. with gu.download_progress(None): report(status="started", bytes_done=0, bytes_total=1000) report(status="progress", bytes_done=250, bytes_total=1000) @@ -64,14 +59,10 @@ def report(**kwargs): assert len(infos) == 1 assert "acoustic model v3.0" in infos[0] - # The hook is scoped: outside the block the library's default (no callback) is back. assert registered() is None def test_download_progress_survives_ui_failures(monkeypatch): - # In the library an exception escaping the callback aborts the download, so a - # failing UI call (e.g. no request context for the toast) must be swallowed, and - # a retry announcement must reach the user as a warning. import birdnet import gradio as gr From ab0543cd3cecbcb1db36211e97743f6e72c6c266 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Mon, 17 Aug 2026 14:44:47 +0200 Subject: [PATCH 4/8] Route download progress per event; cover the species tab The library's scoped callback registration is a plain set/restore, so two overlapping GUI events (single-file and multi-file analysis run as separate gradio events) could misroute updates and leave a stale callback registered after both finished. The adapter now keeps one sink per handler thread and registers a single dispatcher with the library while any sink is active; the library invokes the callback synchronously on the thread that called birdnet.load, so the thread id identifies the event. Covered by a test that interleaves two events. The species tab, whose first run downloads the geo model, is wrapped as well; the train, embeddings and search tabs still show nothing during a first-run download. Co-Authored-By: Claude Fable 5 --- birdnet_analyzer/gui/species.py | 17 ++--- birdnet_analyzer/gui/utils.py | 96 +++++++++++++++++++---------- tests/gui/test_download_progress.py | 48 +++++++++++++++ 3 files changed, 120 insertions(+), 41 deletions(-) diff --git a/birdnet_analyzer/gui/species.py b/birdnet_analyzer/gui/species.py index 02c308bbe..94169cf7b 100644 --- a/birdnet_analyzer/gui/species.py +++ b/birdnet_analyzer/gui/species.py @@ -16,14 +16,15 @@ def run_species_list( gu.validate(out_path, loc.localize("validation-no-directory-selected")) - species( - output=os.path.join(out_path, filename or "species_list.txt"), - lat=lat, - lon=lon, - week=None if use_yearlong else week, - sf_thresh=sf_thresh, - locale=locale, - ) + with gu.download_progress(): + species( + output=os.path.join(out_path, filename or "species_list.txt"), + lat=lat, + lon=lon, + week=None if use_yearlong else week, + sf_thresh=sf_thresh, + locale=locale, + ) gr.Info(f"{loc.localize('species-tab-finish-info')} {out_path}") diff --git a/birdnet_analyzer/gui/utils.py b/birdnet_analyzer/gui/utils.py index 7a20acb14..8f0a34972 100644 --- a/birdnet_analyzer/gui/utils.py +++ b/birdnet_analyzer/gui/utils.py @@ -6,9 +6,10 @@ import os import platform import sys +import threading import warnings from collections.abc import Callable -from contextlib import suppress +from contextlib import contextmanager, suppress from html import escape from typing import Literal, cast, get_args @@ -203,7 +204,55 @@ def _format_bytes(n: int) -> str: return f"{n / 1e6:.0f} MB" if n < 1e9 else f"{n / 1e9:.1f} GB" -def download_progress(progress: gr.Progress | None = None): +# Download sinks by the thread that runs birdnet.load; the library's callback fires +# synchronously on that thread. One dispatcher is registered with the library while +# any sink is active, so overlapping GUI events neither misroute nor clobber each +# other's registration (the library's own scoped registration is a plain set/restore). +_DOWNLOAD_SINKS: dict[int, "gr.Progress | None"] = {} +_DOWNLOAD_SINKS_LOCK = threading.Lock() + + +def _show_download_update(update, progress: "gr.Progress | None") -> None: + name = update.description.removeprefix("Downloading ") + if update.attempt > 1: + name = f"{name} ({update.attempt}/{update.max_attempts})" + + label = f"{loc.localize('progress-downloading-model')}: {name}" + + if update.status == "started" and progress is None: + gr.Info(label) + elif update.status == "progress" and progress is not None: + if update.bytes_total: + done = _format_bytes(update.bytes_done) + total = _format_bytes(update.bytes_total) + progress( + min(update.bytes_done / update.bytes_total, 1.0), + desc=f"{label} ({done} / {total})", + ) + else: + progress(0.0, desc=f"{label} ({_format_bytes(update.bytes_done)})") + elif update.status == "retrying": + gr.Warning( + f"{loc.localize('progress-download-retrying')}: {name} - {update.error}" + ) + # "failed": the library raises right after; the operation reports it. + + +def _dispatch_download_update(update) -> None: + with _DOWNLOAD_SINKS_LOCK: + if threading.get_ident() not in _DOWNLOAD_SINKS: + return + progress = _DOWNLOAD_SINKS[threading.get_ident()] + + # An exception escaping the callback aborts the download in the library. + try: + _show_download_update(update, progress) + except Exception: + logging.getLogger(__name__).exception("Download progress UI update failed") + + +@contextmanager +def download_progress(progress: "gr.Progress | None" = None): """Shows the birdnet library's model downloads while the ``with`` block runs. Models are fetched on first use inside ``birdnet.load``; the library's own tqdm @@ -213,37 +262,18 @@ def download_progress(progress: gr.Progress | None = None): """ import birdnet - def on_update(update: birdnet.DownloadProgress) -> None: - name = update.description.removeprefix("Downloading ") - if update.attempt > 1: - name = f"{name} ({update.attempt}/{update.max_attempts})" - - label = f"{loc.localize('progress-downloading-model')}: {name}" - - # An exception escaping the callback aborts the download in the library. - try: - if update.status == "started" and progress is None: - gr.Info(label) - elif update.status == "progress" and progress is not None: - if update.bytes_total: - done = _format_bytes(update.bytes_done) - total = _format_bytes(update.bytes_total) - progress( - min(update.bytes_done / update.bytes_total, 1.0), - desc=f"{label} ({done} / {total})", - ) - else: - progress(0.0, desc=f"{label} ({_format_bytes(update.bytes_done)})") - elif update.status == "retrying": - gr.Warning( - f"{loc.localize('progress-download-retrying')}: {name} - " - f"{update.error}" - ) - # "failed": the library raises right after; the operation reports it. - except Exception: - logging.getLogger(__name__).exception("Download progress UI update failed") - - return birdnet.download_progress_callback(on_update) + thread = threading.get_ident() + with _DOWNLOAD_SINKS_LOCK: + if not _DOWNLOAD_SINKS: + birdnet.set_download_progress_callback(_dispatch_download_update) + _DOWNLOAD_SINKS[thread] = progress + try: + yield + finally: + with _DOWNLOAD_SINKS_LOCK: + _DOWNLOAD_SINKS.pop(thread, None) + if not _DOWNLOAD_SINKS: + birdnet.set_download_progress_callback(None) def select_folder(state_key=None): diff --git a/tests/gui/test_download_progress.py b/tests/gui/test_download_progress.py index 3fc6100bf..4fb1e4b34 100644 --- a/tests/gui/test_download_progress.py +++ b/tests/gui/test_download_progress.py @@ -96,3 +96,51 @@ def update(status, **extra): assert len(warnings) == 1 assert "connection reset" in warnings[0] assert "(2/3)" in warnings[0] + + +def test_overlapping_gui_events_keep_their_own_sink_and_unregister_cleanly(): + import threading + + import birdnet + + from birdnet_analyzer.gui import utils as gu + + def update(desc): + return birdnet.DownloadProgress( + description=desc, + url="u", + bytes_done=1, + bytes_total=2, + attempt=1, + max_attempts=1, + status="progress", + ) + + seen_a, seen_b = [], [] + a_entered, b_entered, a_left = (threading.Event() for _ in range(3)) + + def event_a(): + with gu.download_progress(lambda v, desc=None, **k: seen_a.append(desc)): + a_entered.set() + b_entered.wait(5) + birdnet.get_download_progress_callback()(update("Downloading alpha-model")) + a_left.set() + + def event_b(): + a_entered.wait(5) + with gu.download_progress(lambda v, desc=None, **k: seen_b.append(desc)): + b_entered.set() + a_left.wait(5) + birdnet.get_download_progress_callback()(update("Downloading beta-model")) + + ta, tb = threading.Thread(target=event_a), threading.Thread(target=event_b) + ta.start() + tb.start() + ta.join(10) + tb.join(10) + + assert any("alpha-model" in d for d in seen_a) + assert not any("beta-model" in d for d in seen_a) + assert any("beta-model" in d for d in seen_b) + assert not any("alpha-model" in d for d in seen_b) + assert birdnet.get_download_progress_callback() is None From 3097bf61d36fe1cd41549b3568243f3fa4a48e42 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Mon, 17 Aug 2026 14:16:34 +0200 Subject: [PATCH 5/8] Drop comments the code already states Co-Authored-By: Claude Fable 5 --- birdnet_analyzer/analyze/core.py | 3 +-- birdnet_analyzer/gui/utils.py | 6 +----- tests/gui/test_sensitivity_slider.py | 7 ------- tests/test_model_utils.py | 7 ------- 4 files changed, 2 insertions(+), 21 deletions(-) diff --git a/birdnet_analyzer/analyze/core.py b/birdnet_analyzer/analyze/core.py index d591064df..0e48ae8ee 100644 --- a/birdnet_analyzer/analyze/core.py +++ b/birdnet_analyzer/analyze/core.py @@ -122,8 +122,7 @@ def analyze( ) from birdnet_analyzer.utils import save_params_file - # Settle the sensitivity here so the params file, result columns and the resume - # fingerprint all record the value the analysis actually used. + # Settled before the params file, result columns and resume fingerprint see it. sensitivity = effective_sensitivity(sensitivity, model, birdnet, classifier) species_list_file = slist if isinstance(slist, (str, Path)) else "" diff --git a/birdnet_analyzer/gui/utils.py b/birdnet_analyzer/gui/utils.py index 8f0a34972..defb46e19 100644 --- a/birdnet_analyzer/gui/utils.py +++ b/birdnet_analyzer/gui/utils.py @@ -710,9 +710,7 @@ def on_species_list_change(value, species_choice): else [_CUSTOM_SPECIES, _PREDICT_SPECIES, _ALL_SPECIES] ) - # A disabled slider shows 1.0 so it never displays a value the analysis will - # not use; re-enabling brings back the value the user last set (persisted - # on release, so read live rather than from the state snapshot). + # Slider release persists the value; read it live, the state snapshot is stale. if model_supports_sensitivity(value): persisted = settings.get_tab_settings(state.tab).get("sensitivity_slider") restored = ( @@ -867,8 +865,6 @@ def sample_sliders( label=loc.localize("inference-settings-sensitivity-slider-label"), info=loc.localize("inference-settings-sensitivity-slider-info"), ) - # Show what the analysis will use: a value persisted from a 2.4 run - # would otherwise sit visibly on the disabled slider. if not sensitivity_enabled: sensitivity_slider.value = 1.0 overlap_slider = state.persist( diff --git a/tests/gui/test_sensitivity_slider.py b/tests/gui/test_sensitivity_slider.py index 85c29371b..04ff56bcb 100644 --- a/tests/gui/test_sensitivity_slider.py +++ b/tests/gui/test_sensitivity_slider.py @@ -12,7 +12,6 @@ gr = pytest.importorskip("gradio") -# gui.utils imports pywebview at module level, which the gui-tests extra lacks. sys.modules.setdefault("webview", MagicMock(settings={})) from birdnet_analyzer import settings # noqa: E402 @@ -22,8 +21,6 @@ @pytest.fixture(autouse=True) def no_map_figure(monkeypatch): - # The species-list block draws a plotly map while building; plotly is a gui-only - # extra and the map is irrelevant here. monkeypatch.setattr(gu, "plot_map_scatter_mapbox", lambda *a, **k: None) @@ -41,8 +38,6 @@ def build(): sample, _, model = gu.sample_species_model_settings(gs.TabState("multi")) radio = model["model_selection_radio"] slider = sample["sensitivity_slider"] - # The radio has several change handlers; the one that drives the slider is the one - # listing it among its outputs. handler = next( event.fn for event in demo.fns.values() @@ -54,7 +49,6 @@ def build(): def test_slider_disabled_for_3_0_and_restored_for_2_4(appdir): - # The user set 1.25 while on 2.4 (persisted on slider release). settings.set_tab_setting("multi", "sensitivity_slider", 1.25) settings.set_tab_setting("multi", "model_selection_radio", gu._USE_BIRDNET_2_4) @@ -76,7 +70,6 @@ def test_slider_disabled_for_3_0_and_restored_for_2_4(appdir): def test_slider_built_disabled_at_1_0_when_3_0_is_persisted(appdir): - # A 1.25 persisted from a 2.4 session must not sit visibly on the disabled slider. settings.set_tab_setting("multi", "sensitivity_slider", 1.25) settings.set_tab_setting("multi", "model_selection_radio", gu._USE_BIRDNET_3_0) diff --git a/tests/test_model_utils.py b/tests/test_model_utils.py index 9e773f1ef..9dd98fd24 100644 --- a/tests/test_model_utils.py +++ b/tests/test_model_utils.py @@ -65,10 +65,6 @@ def test_language_for_version_keeps_supported_and_falls_back_otherwise(): def test_supports_sensitivity_only_for_2_4_based_models(): - # Sensitivity scales the sigmoid the analyzer applies to logits: BirdNET 2.4 and - # custom classifiers (2.4 base). BirdNET 3.0 applies its sigmoid inside the model - # (the library raises for a sensitivity other than 1.0); Perch is run on raw logits - # without a sigmoid. Unknown future versions are treated like 3.0. assert model_utils.supports_sensitivity("birdnet", "2.4") assert not model_utils.supports_sensitivity("birdnet", "3.1") assert model_utils.supports_sensitivity("birdnet", "3.0", classifier="cc.tflite") @@ -77,9 +73,6 @@ def test_supports_sensitivity_only_for_2_4_based_models(): def test_run_inference_drops_sensitivity_for_3_0(monkeypatch, tmp_path): - # A non-default sensitivity is coerced to 1.0 before it reaches the library, which - # would otherwise reject it for 3.0 and crash the analysis (GUI state can carry the - # slider value over from a 2.4 run). from contextlib import contextmanager from unittest.mock import MagicMock From 7191a36d66d39f9355b777be3e106b4e6c18f554 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Mon, 17 Aug 2026 14:47:11 +0200 Subject: [PATCH 6/8] Snap a programmatically set sensitivity back to 1.0 while it is disabled A preset or params file can set the slider without changing the model, so the model-change handler never resets it. The slider's own change handler now returns it to 1.0 whenever the selected model does not take a sensitivity, so the disabled slider always shows the value the analysis uses. Co-Authored-By: Claude Fable 5 --- birdnet_analyzer/gui/utils.py | 18 ++++++++++++++++++ tests/gui/test_sensitivity_slider.py | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/birdnet_analyzer/gui/utils.py b/birdnet_analyzer/gui/utils.py index defb46e19..32b705dcf 100644 --- a/birdnet_analyzer/gui/utils.py +++ b/birdnet_analyzer/gui/utils.py @@ -747,6 +747,24 @@ def on_species_list_change(value, species_choice): show_progress="hidden", ) + def keep_disabled_slider_at_default(sensitivity, model_choice): + # A preset or params file can set the slider while the model stays 3.0/Perch, + # which fires no model change to reset it. + if model_supports_sensitivity(model_choice) or sensitivity == 1.0: + return gr.update() + + return gr.update(value=1.0) + + sample_settings["sensitivity_slider"].change( + keep_disabled_slider_at_default, + inputs=[ + sample_settings["sensitivity_slider"], + model_settings["model_selection_radio"], + ], + outputs=sample_settings["sensitivity_slider"], + show_progress="hidden", + ) + def warn_unmatched_species(file, model_choice, locale): """Heads-up when a chosen custom list has species the selected model lacks. diff --git a/tests/gui/test_sensitivity_slider.py b/tests/gui/test_sensitivity_slider.py index 04ff56bcb..e7cf2b747 100644 --- a/tests/gui/test_sensitivity_slider.py +++ b/tests/gui/test_sensitivity_slider.py @@ -76,3 +76,23 @@ def test_slider_built_disabled_at_1_0_when_3_0_is_persisted(appdir): slider, _ = build() assert not slider.interactive assert slider.value == 1.0 + + +def test_programmatic_value_on_disabled_slider_snaps_back_to_1_0(appdir): + settings.set_tab_setting("multi", "model_selection_radio", gu._USE_BIRDNET_3_0) + + with gr.Blocks() as demo: + sample, _, _ = gu.sample_species_model_settings(gs.TabState("multi")) + slider = sample["sensitivity_slider"] + on_slider_change = next( + event.fn + for event in demo.fns.values() + if event.targets + and event.targets[0] == (slider._id, "change") + and slider in event.outputs + ) + + assert on_slider_change(1.3, gu._USE_BIRDNET_3_0) == gr.update(value=1.0) + assert on_slider_change(1.0, gu._USE_BIRDNET_3_0) == gr.update() + assert on_slider_change(1.3, gu._USE_BIRDNET_2_4) == gr.update() + assert on_slider_change(1.3, gu._USE_PERCH) == gr.update(value=1.0) From 872a102b41ae74ad137b506a89c8f14ddce1078a Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Mon, 17 Aug 2026 14:48:10 +0200 Subject: [PATCH 7/8] Document the sensitivity scope in analyze() Co-Authored-By: Claude Fable 5 --- birdnet_analyzer/analyze/core.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/birdnet_analyzer/analyze/core.py b/birdnet_analyzer/analyze/core.py index 0e48ae8ee..908635be6 100644 --- a/birdnet_analyzer/analyze/core.py +++ b/birdnet_analyzer/analyze/core.py @@ -69,8 +69,9 @@ def analyze( week (int, optional): Week of the year for seasonal filtering. Defaults to -1. slist (str | None, optional): Path to a species list file for filtering. Defaults to None. - sensitivity (float, optional): Sensitivity of the detection algorithm. - Defaults to 1.0. + sensitivity (float, optional): Sensitivity of the detection algorithm; only + applies to BirdNET 2.4 and custom classifiers, ignored (with a warning) + for BirdNET 3.0 and Perch. Defaults to 1.0. overlap (float, optional): Overlap between analysis windows in seconds. Defaults to 0. fmin (int, optional): Minimum frequency for analysis in Hz. Defaults to 0. From 3629d8ee5d3e3ca131fb6a7bc508d75f526f3b74 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Mon, 17 Aug 2026 15:12:44 +0200 Subject: [PATCH 8/8] Show the download bar full on the library's finished event Progress events are throttled, so the last one can sit below 100%. Co-Authored-By: Claude Fable 5 --- birdnet_analyzer/gui/utils.py | 7 +++++-- tests/gui/test_download_progress.py | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/birdnet_analyzer/gui/utils.py b/birdnet_analyzer/gui/utils.py index 32b705dcf..2836aac49 100644 --- a/birdnet_analyzer/gui/utils.py +++ b/birdnet_analyzer/gui/utils.py @@ -221,8 +221,11 @@ def _show_download_update(update, progress: "gr.Progress | None") -> None: if update.status == "started" and progress is None: gr.Info(label) - elif update.status == "progress" and progress is not None: - if update.bytes_total: + elif update.status in ("progress", "finished") and progress is not None: + # "progress" is throttled, so only "finished" reliably shows the bar full. + if update.status == "finished": + progress(1.0, desc=f"{label} ({_format_bytes(update.bytes_done)})") + elif update.bytes_total: done = _format_bytes(update.bytes_done) total = _format_bytes(update.bytes_total) progress( diff --git a/tests/gui/test_download_progress.py b/tests/gui/test_download_progress.py index 4fb1e4b34..a13c257d0 100644 --- a/tests/gui/test_download_progress.py +++ b/tests/gui/test_download_progress.py @@ -50,6 +50,7 @@ def report(**kwargs): assert "Downloading Downloading" not in calls[0][1] assert calls[1][0] == 0.0 assert "5 MB" in calls[1][1] + assert calls[2][0] == 1.0, "finished shows the bar full" assert not infos, "no toasts while a progress bar is available" with gu.download_progress(None):