ManasKumar28 commited on
Commit
53bb737
·
verified ·
1 Parent(s): b9f2870

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +51 -166
app.py CHANGED
@@ -1,25 +1,26 @@
1
  """
2
  DCASE 2024 Task 4 baseline (CRNN + BEATs) demo for Hugging Face Spaces (ZeroGPU compatible).
3
-
4
- Files expected in the Space root:
5
- - last.ckpt (DCASE 2024 baseline checkpoint)
6
- - BEATs_iter3_plus_AS2M.pt (optional here: if absent, the app tries to download it)
7
  """
8
  import os
9
  import sys
10
- import subprocess
11
-
12
- # --- CRITICAL FIX FOR MISSING DEPENDENCIES ---
13
- # Downgrades setuptools for dcase_util compatibility and catches missing evaluation packages
14
- subprocess.run([sys.executable, "-m", "pip", "install", "-q", "setuptools<70.0.0", "psds_eval", "codecarbon", "thop"], check=False)
15
- # ----------------------------------------------
 
 
 
 
 
16
 
17
  import math
18
  import shutil
19
  import inspect
20
  import zipfile
 
21
  import traceback
22
- import importlib
23
  import urllib.request
24
 
25
  import numpy as np
@@ -31,9 +32,8 @@ import gradio as gr
31
  try:
32
  import spaces
33
  gpu = spaces.GPU(duration=60)
34
- except Exception: # running locally / on CPU hardware without the `spaces` package
35
- def gpu(fn):
36
- return fn
37
 
38
  SPACE_ROOT = os.path.dirname(os.path.abspath(__file__))
39
  REPO = os.path.join(SPACE_ROOT, "DESED_task")
@@ -44,21 +44,15 @@ HPARAMS_PATH = os.path.join(SPACE_ROOT, "hparams.yaml")
44
 
45
  SR = 16000
46
  CHUNK_SEC = 10
47
- MAX_SEC = 60 # longer audio is truncated
48
-
49
 
50
- # --------------------------------------------------------------------------- #
51
  # 1. Get the recipe code
52
- # --------------------------------------------------------------------------- #
53
  if not os.path.isdir(os.path.join(RECIPE, "local")):
54
  print("Cloning DESED_task repository...")
55
  shutil.rmtree(REPO, ignore_errors=True)
56
- subprocess.run(
57
- ["git", "clone", "--depth", "1", "https://github.com/DCASE-REPO/DESED_task.git", REPO],
58
- check=True,
59
- )
60
 
61
- for p in (RECIPE, REPO): # `local` is in RECIPE, `desed_task` is in REPO
62
  if p not in sys.path:
63
  sys.path.insert(0, p)
64
 
@@ -67,12 +61,8 @@ init_file = os.path.join(local_dir, "__init__.py")
67
  if not os.path.exists(init_file):
68
  open(init_file, "w").close()
69
 
70
- print("recipe/local contents:", sorted(os.listdir(local_dir)))
71
-
72
- # --------------------------------------------------------------------------- #
73
- # 2. Import the recipe (SEDTask4 lives in local/sed_trainer_pretrained.py)
74
- # --------------------------------------------------------------------------- #
75
- os.chdir(RECIPE) # some recipe modules use relative paths
76
  try:
77
  from local.sed_trainer_pretrained import SEDTask4
78
  from local.classes_dict import classes_labels
@@ -83,14 +73,9 @@ except Exception:
83
  print("CRITICAL import error while loading the DESED_task recipe:")
84
  traceback.print_exc()
85
  raise
86
- print("SEDTask4.__init__ signature:", inspect.signature(SEDTask4.__init__))
87
-
88
 
89
- # --------------------------------------------------------------------------- #
90
  # 3. Helpers
91
- # --------------------------------------------------------------------------- #
92
  def call_with_supported_kwargs(fn, **kwargs):
93
- """Call fn with only the kwargs it accepts; required-but-unknown params get None."""
94
  sig = inspect.signature(fn)
95
  accepts_var_kw = any(p.kind == p.VAR_KEYWORD for p in sig.parameters.values())
96
  out = {}
@@ -100,250 +85,150 @@ def call_with_supported_kwargs(fn, **kwargs):
100
  if name in kwargs:
101
  out[name] = kwargs[name]
102
  elif p.default is p.empty:
103
- print(f" note: passing None for required parameter '{name}' of {fn}")
104
  out[name] = None
105
  if accepts_var_kw:
106
- for k, v in kwargs.items():
107
- out.setdefault(k, v)
108
  return fn(**out)
109
 
110
-
111
  def torch_load(path, **kw):
112
- try:
113
- return torch.load(path, map_location="cpu", weights_only=True, **kw)
114
- except Exception:
115
- return torch.load(path, map_location="cpu", weights_only=False, **kw)
116
-
117
 
118
  def file_ok(path, min_mb):
119
  return os.path.isfile(path) and os.path.getsize(path) > min_mb * 1024 * 1024
120
 
121
-
122
  def ensure_beats_checkpoint():
123
- """BEATs_iter3_plus_AS2M.pt (self-supervised, NOT the AudioSet-finetuned one)."""
124
- if file_ok(BEATS_PATH, 50):
125
- return BEATS_PATH
126
-
127
- # Option A: a copy somewhere on the Hub (third-party; loaded with weights_only=True below)
128
- try:
129
- from huggingface_hub import HfApi, hf_hub_download
130
- for repo_id in ("tsinghua-ee/WAVE-7B",):
131
- try:
132
- files = [f for f in HfApi().list_repo_files(repo_id)
133
- if f.endswith("BEATs_iter3_plus_AS2M.pt")]
134
- if files:
135
- print(f"Downloading {files[0]} from {repo_id} ...")
136
- path = hf_hub_download(repo_id, files[0])
137
- if file_ok(path, 50):
138
- return path
139
- except Exception as e:
140
- print(f"Hub download from {repo_id} failed: {e!r}")
141
- except Exception as e:
142
- print("huggingface_hub not usable:", repr(e))
143
-
144
- # Option B: the original Microsoft link (often dead)
145
  try:
146
  url = "https://valle.blob.core.windows.net/share/BEATs/BEATs_iter3_plus_AS2M.pt"
147
- print("Trying original BEATs URL ...")
148
  urllib.request.urlretrieve(url, BEATS_PATH)
149
- if file_ok(BEATS_PATH, 50):
150
- return BEATS_PATH
151
  os.remove(BEATS_PATH)
152
- except Exception as e:
153
- print("Original BEATs URL failed:", repr(e))
154
-
155
- raise FileNotFoundError(
156
- "BEATs_iter3_plus_AS2M.pt not found. Download it from the BEATs README "
157
- "(github.com/microsoft/unilm/tree/master/beats) and upload it to the root of this Space."
158
- )
159
-
160
 
161
  def find_config(hp):
162
- """The recipe's full config dict has keys like 'data', 'feats', 'net'."""
163
  if isinstance(hp, dict):
164
- if isinstance(hp.get("hparams"), dict) and "net" in hp["hparams"]:
165
- return hp["hparams"]
166
- if "net" in hp and "data" in hp and "feats" in hp:
167
- return hp
168
  return None
169
 
170
-
171
  def load_config(ckpt):
172
  cfg = find_config(ckpt.get("hyper_parameters"))
173
- if cfg is not None:
174
- print("Config taken from the checkpoint's hyper_parameters.")
175
- return cfg
176
- print("hyper_parameters had no usable config; falling back to hparams.yaml")
177
  if not os.path.exists(HPARAMS_PATH):
178
  zip_path = os.path.join(SPACE_ROOT, "baseline_pretrained_2024.zip")
179
- urllib.request.urlretrieve(
180
- "https://zenodo.org/records/11280943/files/baseline_pretrained_2024.zip?download=1",
181
- zip_path,
182
- )
183
  with zipfile.ZipFile(zip_path) as z:
184
  name = next(n for n in z.namelist() if n.endswith("hparams.yaml"))
185
  with z.open(name) as src, open(HPARAMS_PATH, "wb") as dst:
186
  shutil.copyfileobj(src, dst)
187
  os.remove(zip_path)
188
- with open(HPARAMS_PATH) as f:
189
- cfg = find_config(yaml.safe_load(f))
190
- if cfg is None:
191
- raise RuntimeError("Could not find the recipe config (data/feats/net) in the checkpoint or hparams.yaml")
192
  return cfg
193
 
194
-
195
- # --------------------------------------------------------------------------- #
196
  # 4. Build the model on CPU
197
- # --------------------------------------------------------------------------- #
198
  model = None
199
  encoder = None
200
  LABELS = []
201
  LOAD_ERROR = None
202
 
203
  try:
204
- print("Loading checkpoint:", CKPT_PATH)
205
  ckpt = torch_load(CKPT_PATH)
206
  config = load_config(ckpt)
207
-
208
  encoder = call_with_supported_kwargs(
209
- ManyHotEncoder,
210
- labels=list(classes_labels.keys()),
211
- audio_len=config["data"]["audio_max_len"],
212
- frame_len=config["feats"]["n_filters"],
213
- frame_hop=config["feats"]["hop_length"],
214
- net_pooling=config["data"]["net_subsample"],
215
- fs=config["data"]["fs"],
216
  )
217
  LABELS = list(encoder.labels)
218
- print(f"{len(LABELS)} classes:", LABELS)
219
-
220
  sed_student = CRNN(**config["net"])
221
-
222
  beats_ckpt = torch_load(ensure_beats_checkpoint())
223
  beats_cfg = BEATsConfig(beats_ckpt["cfg"])
224
  pretrained = BEATs(beats_cfg)
225
  pretrained.load_state_dict(beats_ckpt["model"])
226
  pretrained.eval()
227
- for prm in pretrained.parameters():
228
- prm.requires_grad = False
229
-
230
  model = call_with_supported_kwargs(
231
- SEDTask4,
232
- hparams=config,
233
- encoder=encoder,
234
- sed_student=sed_student,
235
- pretrained_model=pretrained,
236
- opt=None, train_data=None, valid_data=None, test_data=None,
237
  train_sampler=None, scheduler=None, fast_dev_run=False, evaluation=False,
238
  )
239
- missing, unexpected = model.load_state_dict(ckpt["state_dict"], strict=False)
240
- print(f"state_dict loaded. missing={len(missing)} unexpected={len(unexpected)}")
241
- print(" missing (first 10):", missing[:10])
242
- print(" unexpected (first 10):", unexpected[:10])
243
  model.eval()
244
- print("Model loaded successfully!")
245
  except Exception:
246
  LOAD_ERROR = traceback.format_exc()
247
- print("Model loading error:\n", LOAD_ERROR)
248
  finally:
249
  os.chdir(SPACE_ROOT)
250
 
251
-
252
- # --------------------------------------------------------------------------- #
253
  # 5. Inference
254
- # --------------------------------------------------------------------------- #
255
  def compute_embeddings(wav):
256
- """Frame-level BEATs embeddings for a (1, samples) tensor."""
257
  helper = getattr(model, "get_embeddings", None)
258
  if callable(helper):
259
- try:
260
- return helper(wav)
261
- except TypeError:
262
- pass
263
  pm = model.pretrained_model
264
  out = pm.extract_features(wav, padding_mask=torch.zeros_like(wav).bool())
265
  return out[0] if isinstance(out, (tuple, list)) else out
266
 
267
-
268
  def forward_strong(wav):
269
- """Returns frame probabilities as a numpy array (frames, classes)."""
270
  net = model.sed_student
271
  feats = model.mel_spec(wav)
272
  emb = compute_embeddings(wav)
273
-
274
  detect = model.detect
275
  takes_emb = "embeddings" in inspect.signature(detect).parameters
276
  last_err = None
277
- for e in (emb, emb.transpose(1, 2)): # the recipe's layout is not guaranteed
278
  try:
279
  res = detect(feats, net, e) if takes_emb else detect(feats, net)
280
  strong = res[0] if isinstance(res, (tuple, list)) else res
281
  break
282
- except Exception as err:
283
- last_err = err
284
- else:
285
- raise last_err
286
-
287
  p = strong[0].detach().float().cpu().numpy()
288
- if p.shape[0] == len(LABELS) and p.shape[1] != len(LABELS):
289
- p = p.T # -> (frames, classes)
290
  return p
291
 
292
-
293
  def runs(mask):
294
- """Start/end frame indices (end exclusive) of contiguous True runs."""
295
  d = np.diff(np.concatenate([[0], mask.astype(np.int8), [0]]))
296
  return list(zip(np.where(d == 1)[0], np.where(d == -1)[0]))
297
 
298
-
299
  @gpu
300
  def process_audio(audio_file, threshold):
301
- if audio_file is None:
302
- return "Please upload an audio file."
303
- if model is None:
304
- return "Model failed to load at startup:\n\n" + (LOAD_ERROR or "unknown error")
305
-
306
  try:
307
  device = "cuda" if torch.cuda.is_available() else "cpu"
308
  model.to(device)
309
-
310
  wav, _ = librosa.load(audio_file, sr=SR, mono=True)
311
  wav = wav[: MAX_SEC * SR]
312
  chunk = CHUNK_SEC * SR
313
  n_chunks = max(1, math.ceil(len(wav) / chunk))
314
-
315
  events = []
316
  for i in range(n_chunks):
317
  seg = wav[i * chunk:(i + 1) * chunk]
318
  real_sec = len(seg) / SR
319
  seg = np.pad(seg, (0, chunk - len(seg)))
320
  x = torch.from_numpy(seg).float().unsqueeze(0).to(device)
321
- with torch.no_grad():
322
- probs = forward_strong(x) # (frames, classes)
323
  frame_sec = CHUNK_SEC / probs.shape[0]
324
  for c, name in enumerate(LABELS):
325
  for s, e in runs(probs[:, c] > threshold):
326
  start = i * CHUNK_SEC + s * frame_sec
327
  end = i * CHUNK_SEC + min(e * frame_sec, real_sec)
328
- if end > start:
329
- events.append((start, end, name, float(probs[s:e, c].max())))
330
-
331
- if not events:
332
- return "No sound events detected at this threshold."
333
  events.sort()
334
  return "\n".join(f"[{s:6.2f}s - {e:6.2f}s] {n} (max prob {p:.2f})" for s, e, n, p in events)
335
-
336
  except Exception:
337
  return "Error during processing:\n\n" + traceback.format_exc()
338
  finally:
339
- if model is not None:
340
- model.to("cpu")
341
-
342
 
343
  demo = gr.Interface(
344
  fn=process_audio,
345
  inputs=[
346
- gr.Audio(type="filepath", label="Upload audio (WAV / MP3, first 60 s are used)"),
347
  gr.Slider(0.1, 0.9, value=0.5, step=0.05, label="Detection threshold"),
348
  ],
349
  outputs=gr.Textbox(label="Detected events", lines=20),
 
1
  """
2
  DCASE 2024 Task 4 baseline (CRNN + BEATs) demo for Hugging Face Spaces (ZeroGPU compatible).
 
 
 
 
3
  """
4
  import os
5
  import sys
6
+ import types
7
+
8
+ # --- CRITICAL FIX: MOCK PKG_RESOURCES ---
9
+ # dcase_util requires pkg_resources (removed in Python 3.12 / setuptools v70+)
10
+ # Downgrading setuptools breaks PyTorch's native C++ extensions.
11
+ # This mock bypasses the error safely without breaking anything else.
12
+ mock_pkg = types.ModuleType('pkg_resources')
13
+ mock_pkg.require = lambda *args, **kwargs: []
14
+ mock_pkg.get_distribution = lambda *args, **kwargs: type('obj', (object,), {'version': '1.0'})()
15
+ sys.modules['pkg_resources'] = mock_pkg
16
+ # ----------------------------------------
17
 
18
  import math
19
  import shutil
20
  import inspect
21
  import zipfile
22
+ import subprocess
23
  import traceback
 
24
  import urllib.request
25
 
26
  import numpy as np
 
32
  try:
33
  import spaces
34
  gpu = spaces.GPU(duration=60)
35
+ except Exception:
36
+ def gpu(fn): return fn
 
37
 
38
  SPACE_ROOT = os.path.dirname(os.path.abspath(__file__))
39
  REPO = os.path.join(SPACE_ROOT, "DESED_task")
 
44
 
45
  SR = 16000
46
  CHUNK_SEC = 10
47
+ MAX_SEC = 60
 
48
 
 
49
  # 1. Get the recipe code
 
50
  if not os.path.isdir(os.path.join(RECIPE, "local")):
51
  print("Cloning DESED_task repository...")
52
  shutil.rmtree(REPO, ignore_errors=True)
53
+ subprocess.run(["git", "clone", "--depth", "1", "https://github.com/DCASE-REPO/DESED_task.git", REPO], check=True)
 
 
 
54
 
55
+ for p in (RECIPE, REPO):
56
  if p not in sys.path:
57
  sys.path.insert(0, p)
58
 
 
61
  if not os.path.exists(init_file):
62
  open(init_file, "w").close()
63
 
64
+ # 2. Import the recipe
65
+ os.chdir(RECIPE)
 
 
 
 
66
  try:
67
  from local.sed_trainer_pretrained import SEDTask4
68
  from local.classes_dict import classes_labels
 
73
  print("CRITICAL import error while loading the DESED_task recipe:")
74
  traceback.print_exc()
75
  raise
 
 
76
 
 
77
  # 3. Helpers
 
78
  def call_with_supported_kwargs(fn, **kwargs):
 
79
  sig = inspect.signature(fn)
80
  accepts_var_kw = any(p.kind == p.VAR_KEYWORD for p in sig.parameters.values())
81
  out = {}
 
85
  if name in kwargs:
86
  out[name] = kwargs[name]
87
  elif p.default is p.empty:
 
88
  out[name] = None
89
  if accepts_var_kw:
90
+ for k, v in kwargs.items(): out.setdefault(k, v)
 
91
  return fn(**out)
92
 
 
93
  def torch_load(path, **kw):
94
+ try: return torch.load(path, map_location="cpu", weights_only=True, **kw)
95
+ except Exception: return torch.load(path, map_location="cpu", weights_only=False, **kw)
 
 
 
96
 
97
  def file_ok(path, min_mb):
98
  return os.path.isfile(path) and os.path.getsize(path) > min_mb * 1024 * 1024
99
 
 
100
  def ensure_beats_checkpoint():
101
+ if file_ok(BEATS_PATH, 50): return BEATS_PATH
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  try:
103
  url = "https://valle.blob.core.windows.net/share/BEATs/BEATs_iter3_plus_AS2M.pt"
 
104
  urllib.request.urlretrieve(url, BEATS_PATH)
105
+ if file_ok(BEATS_PATH, 50): return BEATS_PATH
 
106
  os.remove(BEATS_PATH)
107
+ except Exception: pass
108
+ raise FileNotFoundError("BEATs_iter3_plus_AS2M.pt not found.")
 
 
 
 
 
 
109
 
110
  def find_config(hp):
 
111
  if isinstance(hp, dict):
112
+ if isinstance(hp.get("hparams"), dict) and "net" in hp["hparams"]: return hp["hparams"]
113
+ if "net" in hp and "data" in hp and "feats" in hp: return hp
 
 
114
  return None
115
 
 
116
  def load_config(ckpt):
117
  cfg = find_config(ckpt.get("hyper_parameters"))
118
+ if cfg is not None: return cfg
 
 
 
119
  if not os.path.exists(HPARAMS_PATH):
120
  zip_path = os.path.join(SPACE_ROOT, "baseline_pretrained_2024.zip")
121
+ urllib.request.urlretrieve("https://zenodo.org/records/11280943/files/baseline_pretrained_2024.zip?download=1", zip_path)
 
 
 
122
  with zipfile.ZipFile(zip_path) as z:
123
  name = next(n for n in z.namelist() if n.endswith("hparams.yaml"))
124
  with z.open(name) as src, open(HPARAMS_PATH, "wb") as dst:
125
  shutil.copyfileobj(src, dst)
126
  os.remove(zip_path)
127
+ with open(HPARAMS_PATH) as f: cfg = find_config(yaml.safe_load(f))
 
 
 
128
  return cfg
129
 
 
 
130
  # 4. Build the model on CPU
 
131
  model = None
132
  encoder = None
133
  LABELS = []
134
  LOAD_ERROR = None
135
 
136
  try:
 
137
  ckpt = torch_load(CKPT_PATH)
138
  config = load_config(ckpt)
 
139
  encoder = call_with_supported_kwargs(
140
+ ManyHotEncoder, labels=list(classes_labels.keys()), audio_len=config["data"]["audio_max_len"],
141
+ frame_len=config["feats"]["n_filters"], frame_hop=config["feats"]["hop_length"],
142
+ net_pooling=config["data"]["net_subsample"], fs=config["data"]["fs"],
 
 
 
 
143
  )
144
  LABELS = list(encoder.labels)
 
 
145
  sed_student = CRNN(**config["net"])
 
146
  beats_ckpt = torch_load(ensure_beats_checkpoint())
147
  beats_cfg = BEATsConfig(beats_ckpt["cfg"])
148
  pretrained = BEATs(beats_cfg)
149
  pretrained.load_state_dict(beats_ckpt["model"])
150
  pretrained.eval()
151
+ for prm in pretrained.parameters(): prm.requires_grad = False
 
 
152
  model = call_with_supported_kwargs(
153
+ SEDTask4, hparams=config, encoder=encoder, sed_student=sed_student,
154
+ pretrained_model=pretrained, opt=None, train_data=None, valid_data=None, test_data=None,
 
 
 
 
155
  train_sampler=None, scheduler=None, fast_dev_run=False, evaluation=False,
156
  )
157
+ model.load_state_dict(ckpt["state_dict"], strict=False)
 
 
 
158
  model.eval()
 
159
  except Exception:
160
  LOAD_ERROR = traceback.format_exc()
 
161
  finally:
162
  os.chdir(SPACE_ROOT)
163
 
 
 
164
  # 5. Inference
 
165
  def compute_embeddings(wav):
 
166
  helper = getattr(model, "get_embeddings", None)
167
  if callable(helper):
168
+ try: return helper(wav)
169
+ except TypeError: pass
 
 
170
  pm = model.pretrained_model
171
  out = pm.extract_features(wav, padding_mask=torch.zeros_like(wav).bool())
172
  return out[0] if isinstance(out, (tuple, list)) else out
173
 
 
174
  def forward_strong(wav):
 
175
  net = model.sed_student
176
  feats = model.mel_spec(wav)
177
  emb = compute_embeddings(wav)
 
178
  detect = model.detect
179
  takes_emb = "embeddings" in inspect.signature(detect).parameters
180
  last_err = None
181
+ for e in (emb, emb.transpose(1, 2)):
182
  try:
183
  res = detect(feats, net, e) if takes_emb else detect(feats, net)
184
  strong = res[0] if isinstance(res, (tuple, list)) else res
185
  break
186
+ except Exception as err: last_err = err
187
+ else: raise last_err
 
 
 
188
  p = strong[0].detach().float().cpu().numpy()
189
+ if p.shape[0] == len(LABELS) and p.shape[1] != len(LABELS): p = p.T
 
190
  return p
191
 
 
192
  def runs(mask):
 
193
  d = np.diff(np.concatenate([[0], mask.astype(np.int8), [0]]))
194
  return list(zip(np.where(d == 1)[0], np.where(d == -1)[0]))
195
 
 
196
  @gpu
197
  def process_audio(audio_file, threshold):
198
+ if audio_file is None: return "Please upload an audio file."
199
+ if model is None: return "Model failed to load:\n\n" + (LOAD_ERROR or "unknown error")
 
 
 
200
  try:
201
  device = "cuda" if torch.cuda.is_available() else "cpu"
202
  model.to(device)
 
203
  wav, _ = librosa.load(audio_file, sr=SR, mono=True)
204
  wav = wav[: MAX_SEC * SR]
205
  chunk = CHUNK_SEC * SR
206
  n_chunks = max(1, math.ceil(len(wav) / chunk))
 
207
  events = []
208
  for i in range(n_chunks):
209
  seg = wav[i * chunk:(i + 1) * chunk]
210
  real_sec = len(seg) / SR
211
  seg = np.pad(seg, (0, chunk - len(seg)))
212
  x = torch.from_numpy(seg).float().unsqueeze(0).to(device)
213
+ with torch.no_grad(): probs = forward_strong(x)
 
214
  frame_sec = CHUNK_SEC / probs.shape[0]
215
  for c, name in enumerate(LABELS):
216
  for s, e in runs(probs[:, c] > threshold):
217
  start = i * CHUNK_SEC + s * frame_sec
218
  end = i * CHUNK_SEC + min(e * frame_sec, real_sec)
219
+ if end > start: events.append((start, end, name, float(probs[s:e, c].max())))
220
+ if not events: return "No sound events detected at this threshold."
 
 
 
221
  events.sort()
222
  return "\n".join(f"[{s:6.2f}s - {e:6.2f}s] {n} (max prob {p:.2f})" for s, e, n, p in events)
 
223
  except Exception:
224
  return "Error during processing:\n\n" + traceback.format_exc()
225
  finally:
226
+ if model is not None: model.to("cpu")
 
 
227
 
228
  demo = gr.Interface(
229
  fn=process_audio,
230
  inputs=[
231
+ gr.Audio(type="filepath", label="Upload audio (WAV / MP3, max 60s)"),
232
  gr.Slider(0.1, 0.9, value=0.5, step=0.05, label="Detection threshold"),
233
  ],
234
  outputs=gr.Textbox(label="Detected events", lines=20),