SARATHCHANDRA D.M.P.M commited on
Commit
f587ca1
·
1 Parent(s): 47e7c1c

Refactor Dockerfile and app.py for Flask integration

Browse files
Files changed (3) hide show
  1. Dockerfile +2 -1
  2. app.py +532 -347
  3. requirements.txt +1 -0
Dockerfile CHANGED
@@ -2,6 +2,7 @@ FROM python:3.10-slim
2
 
3
  ENV PYTHONDONTWRITEBYTECODE=1
4
  ENV PYTHONUNBUFFERED=1
 
5
 
6
  WORKDIR /app
7
 
@@ -13,4 +14,4 @@ COPY . .
13
 
14
  EXPOSE 8501
15
 
16
- CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
2
 
3
  ENV PYTHONDONTWRITEBYTECODE=1
4
  ENV PYTHONUNBUFFERED=1
5
+ ENV HOST=0.0.0.0
6
 
7
  WORKDIR /app
8
 
 
14
 
15
  EXPOSE 8501
16
 
17
+ CMD ["python", "app.py"]
app.py CHANGED
@@ -1,366 +1,551 @@
 
1
  import os
2
  import tempfile
 
 
3
 
4
- import streamlit as st
5
  import torch
6
- import matplotlib.pyplot as plt
7
 
 
8
  from preprocessing import preprocess_audio
9
  from utils import load_compressed_model, load_model, predict
10
- from classes import ESC50_CLASSES
11
 
12
 
13
- ORIGINAL_MODEL_PATH = "weights/esc50_model.pth"
14
- COMPRESSED_MODEL_PATH = "weights/esc50_model_compressed.pth"
15
- STATS_PATH = "stats/esc50_mel_stats.json"
16
- SAMPLES_DIR = "samples"
17
-
18
- # ── Page config ───────────────────────────────────────────────────────────────
19
- st.set_page_config(
20
- page_title="SoundEdge - Environmental Sound Classification",
21
- page_icon="🔊",
22
- layout="wide",
23
- )
24
-
25
- # ── Custom CSS ────────────────────────────────────────────────────────────────
26
- st.markdown("""
27
- <style>
28
- /* Global */
29
- html, body, [data-testid="stAppViewContainer"] {
30
- background-color: #f8fafc;
31
- color: #1e293b;
32
- font-size: 1.05rem;
33
- }
34
- [data-testid="stSidebar"] { display: none; }
35
- .block-container {
36
- padding-top: 2rem !important;
37
- max-width: 1100px !important;
38
- margin: 0 auto !important;
39
- padding-left: 2rem !important;
40
- padding-right: 2rem !important;
41
- }
42
- #MainMenu, footer, header { visibility: hidden; }
43
-
44
- /* Hero banner */
45
- .hero {
46
- background: linear-gradient(135deg, #e0f2fe 0%, #ede9fe 60%, #dbeafe 100%);
47
- border: 1px solid #bfdbfe;
48
- border-radius: 16px;
49
- padding: 1rem 2rem 1rem 2rem;
50
- margin-bottom: 2rem;
51
- text-align: center;
52
- }
53
- .hero h1 {
54
- font-size: 3.2rem;
55
- font-weight: 800;
56
- background: linear-gradient(90deg, #0284c7, #7c3aed);
57
- -webkit-background-clip: text;
58
- -webkit-text-fill-color: transparent;
59
- margin: 0 0 0.5rem 0;
60
- }
61
- .hero p {
62
- color: #475569;
63
- font-size: 1.2rem;
64
- margin: 0;
65
- }
66
-
67
- /* Section headings */
68
- .section-title {
69
- font-size: 1rem;
70
- font-weight: 700;
71
- color: #7c3aed;
72
- text-transform: uppercase;
73
- letter-spacing: 0.1em;
74
- margin-bottom: 0.85rem;
75
- }
76
-
77
- /* Class badge pills */
78
- .badge-grid {
79
- display: flex;
80
- flex-wrap: wrap;
81
- gap: 0.4rem;
82
- margin-bottom: 0.5rem;
83
- }
84
- .badge {
85
- background: #f1f5f9;
86
- border: 1px solid #e2e8f0;
87
- border-radius: 9px;
88
- padding: 0.3rem 0.65rem;
89
- font-size: 0.88rem;
90
- color: #475569;
91
- display: inline-flex;
92
- align-items: center;
93
- gap: 0.4rem;
94
- }
95
-
96
- /* Result card */
97
- .result-card {
98
- background: linear-gradient(135deg, #dbeafe 0%, #ede9fe 100%);
99
- border: 1px solid #3b82f6;
100
- border-radius: 14px;
101
- padding: 1.8rem 2rem;
102
- margin: 1.2rem 0 1.4rem 0;
103
- text-align: center;
104
- }
105
- .result-card .label {
106
- font-size: 0.95rem;
107
- color: #64748b;
108
- text-transform: uppercase;
109
- letter-spacing: 0.09em;
110
- margin-bottom: 0.5rem;
111
- }
112
- .result-card .class-name {
113
- font-size: 2.6rem;
114
- font-weight: 800;
115
- color: #0369a1;
116
- margin: 0;
117
- }
118
- .result-card .confidence {
119
- font-size: 1.35rem;
120
- color: #7c3aed;
121
- margin-top: 0.5rem;
122
- }
123
-
124
- /* Top-3 progress bars */
125
- .prob-row { margin-bottom: 1.1rem; }
126
- .prob-label {
127
- display: flex;
128
- justify-content: space-between;
129
- font-size: 1.05rem;
130
- margin-bottom: 0.4rem;
131
- color: #334155;
132
- }
133
- .prob-bar-bg {
134
- background: #e2e8f0;
135
- border-radius: 999px;
136
- height: 13px;
137
- width: 100%;
138
- overflow: hidden;
139
- }
140
- .prob-bar-fill {
141
- height: 100%;
142
- border-radius: 999px;
143
- background: linear-gradient(90deg, #0284c7, #7c3aed);
144
- }
145
-
146
- /* Divider */
147
- hr { border-color: #e2e8f0 !important; }
148
- </style>
149
- """, unsafe_allow_html=True)
150
-
151
- # ── Hero ──────────────────────────────────────────────────────────────────────
152
- st.markdown("""
153
- <div class="hero">
154
- <h1>🔊 SoundEdge</h1>
155
- <p>Environmental Sound Classification &mdash; upload a short audio clip and let the model identify the sound.</p>
156
- </div>
157
- """, unsafe_allow_html=True)
158
-
159
- # ── Supported classes ─────────────────────────────────────────────────────────
160
- st.markdown('<div class="section-title">Supported Sound Classes</div>', unsafe_allow_html=True)
161
- badges_html = '<div class="badge-grid">'
162
- for cls in ESC50_CLASSES:
163
- label = cls.replace('_', ' ').title()
164
- badges_html += f'<span class="badge">{label}</span>'
165
- badges_html += '</div>'
166
- st.markdown(badges_html, unsafe_allow_html=True)
167
-
168
- st.divider()
169
-
170
- # ── Model ───────────────────────────────���─────────────────────────────────────
171
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
172
-
173
- @st.cache_resource
174
- def get_original_model():
175
- return load_model(ORIGINAL_MODEL_PATH, device, num_classes=len(ESC50_CLASSES))
176
-
177
- @st.cache_resource
178
- def get_compressed_model():
179
- return load_compressed_model(ORIGINAL_MODEL_PATH, COMPRESSED_MODEL_PATH, num_classes=len(ESC50_CLASSES))
180
-
181
- # ── Model selector ────────────────────────────────────────────────────────────
182
- st.markdown('<div class="section-title">Select Model</div>', unsafe_allow_html=True)
183
- _model_choice = st.radio(
184
- "model_selector",
185
- options=["Original", "Compressed"],
186
- horizontal=True,
187
- label_visibility="collapsed",
188
- )
189
- if _model_choice == "Compressed":
190
- active_model = get_compressed_model()
191
- active_device = torch.device("cpu")
192
- else:
193
- active_model = get_original_model()
194
- active_device = device
195
-
196
- st.divider()
197
-
198
- # ── Session state ────────────────────────────────────────────────────────────
199
- if "sample_to_classify" not in st.session_state:
200
- st.session_state.sample_to_classify = None
201
- if "active_source" not in st.session_state:
202
- st.session_state.active_source = None
203
-
204
- def _clear_classification_output():
205
- st.session_state.sample_to_classify = None
206
- st.session_state.active_source = None
207
-
208
- def _activate_upload_source():
209
- st.session_state.sample_to_classify = None
210
- st.session_state.active_source = "upload"
211
-
212
- # ── Audio input tabs ──────────────────────────────────────────────────────────
213
- st.markdown('<div class="section-title">Choose Audio Input</div>', unsafe_allow_html=True)
214
-
215
- tab_upload, tab_sample = st.tabs([" ⬆️ Upload a File ", " 🎵 Try a Sample "])
216
-
217
- uploaded_file = None
218
-
219
- with tab_upload:
220
- st.markdown("""
221
- <div style="background:#f0f9ff;border:1px solid #bae6fd;border-radius:12px;
222
- padding:1rem 1.25rem;margin-bottom:1rem;">
223
- <div style="font-weight:700;color:#0369a1;font-size:0.95rem;margin-bottom:0.5rem;">
224
- 📋 Upload Guide
225
- </div>
226
- <ul style="margin:0;padding-left:1.2rem;color:#475569;font-size:0.92rem;line-height:1.8;">
227
- <li><strong>Format:</strong> WAV (<code>.wav</code>) only</li>
228
- <li><strong>Duration:</strong> ~5 seconds recommended &mdash; clips longer than 5 s will be trimmed, shorter clips will be zero-padded</li>
229
- <li><strong>File size:</strong> Keep under 5 MB for best performance</li>
230
- </ul>
231
- </div>
232
- """, unsafe_allow_html=True)
233
- uploaded_file = st.file_uploader(
234
- "WAV audio file (5 seconds recommended)",
235
- type=["wav"],
236
- key="uploaded_audio",
237
- on_change=_activate_upload_source,
238
- label_visibility="collapsed",
239
  )
240
- if uploaded_file is not None:
241
- if not uploaded_file.name.lower().endswith(".wav"):
242
- st.error("Only .wav files are supported.")
243
- st.stop()
244
- st.audio(uploaded_file)
245
- elif st.session_state.active_source == "upload":
246
- st.session_state.active_source = None
247
-
248
- with tab_sample:
249
- _sample_files = (
250
- sorted([f for f in os.listdir(SAMPLES_DIR) if f.lower().endswith(".wav")])
251
- if os.path.isdir(SAMPLES_DIR) else []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  )
253
 
254
- if not _sample_files:
255
- st.info("No sample files found in the samples/ folder.")
256
- else:
257
- def _parse_sample_name(fn: str) -> str:
258
- name = os.path.splitext(fn)[0]
259
- return name.replace('_', ' ').title()
260
-
261
- _options = {_parse_sample_name(f): f for f in _sample_files}
262
- _selected = st.selectbox(
263
- "Pick a sample clip",
264
- options=list(_options.keys()),
265
- key="sample_selector",
266
- on_change=_clear_classification_output,
267
- label_visibility="collapsed",
268
- )
269
- _sample_path = os.path.join(SAMPLES_DIR, _options[_selected])
270
- st.audio(_sample_path)
271
- if st.button("Classify this sample ›", use_container_width=True):
272
- st.session_state.sample_to_classify = _sample_path
273
- st.session_state.active_source = "sample"
274
-
275
- # ── Determine active source ───────────────────────────────────────────────────
276
- source_path = None
277
- cleanup_temp = False
278
- temp_path = None
279
-
280
- if st.session_state.active_source == "upload" and uploaded_file is not None:
281
- with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as _tmp:
282
- uploaded_file.seek(0)
283
- _tmp.write(uploaded_file.read())
284
- temp_path = _tmp.name
285
- source_path = temp_path
286
- cleanup_temp = True
287
- elif st.session_state.active_source == "sample" and st.session_state.sample_to_classify:
288
- source_path = st.session_state.sample_to_classify
289
-
290
- # ── Inference & results ───────────────────────────────────────────────────────
291
- if source_path:
292
  try:
293
- with st.spinner("Analysing audio…"):
294
- input_tensor = preprocess_audio(source_path, STATS_PATH)
295
- top_class, top_prob, all_probs = predict(active_model, input_tensor, active_device)
296
-
297
- # ── Low-confidence guard ──────────────────────────────────────────────
298
- if top_prob < 0.6:
299
- st.markdown("""
300
- <div style="background:#fff1f2;border:1px solid #fecdd3;border-radius:14px;
301
- padding:1.8rem 2rem;margin:1.2rem 0 1.4rem 0;text-align:center;">
302
- <div style="font-size:2rem;margin-bottom:0.6rem;">🎙️</div>
303
- <div style="font-size:1.1rem;font-weight:700;color:#dc2626;margin-bottom:0.4rem;">
304
- Unable to confidently identify the sound
305
- </div>
306
- <div style="color:#64748b;font-size:0.95rem;">
307
- Please upload a clearer audio file and try again.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  </div>
 
 
 
309
  </div>
310
- """, unsafe_allow_html=True)
311
- else:
312
- # ── Result card ───────────────────────────────────────────────────
313
- top_label = top_class.replace('_', ' ').title()
314
- st.markdown(f"""
315
- <div class="result-card">
316
- <div class="label">Predicted Sound</div>
317
- <div class="class-name">{top_label}</div>
318
- <div class="confidence">Confidence &nbsp;{top_prob * 100:.1f}%</div>
 
 
 
 
 
 
319
  </div>
320
- """, unsafe_allow_html=True)
321
-
322
- # ── Top-3 predictions ─────────────────────────────────────────────
323
- st.markdown('<div class="section-title">Top 3 Predictions</div>', unsafe_allow_html=True)
324
- for item in all_probs[:3]:
325
- pct = item['probability'] * 100
326
- lbl = item['class_name'].replace('_', ' ').title()
327
- st.markdown(f"""
328
- <div class="prob-row">
329
- <div class="prob-label">
330
- <span>{lbl}</span><span>{pct:.1f}%</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  </div>
332
- <div class="prob-bar-bg">
333
- <div class="prob-bar-fill" style="width:{pct:.1f}%;"></div>
 
 
 
 
 
 
 
 
334
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  </div>
336
- """, unsafe_allow_html=True)
337
-
338
- # ── All-class probability chart ───────────────────────────────────
339
- st.markdown('<div class="section-title" style="margin-top:1.6rem;">All Class Probabilities</div>', unsafe_allow_html=True)
340
-
341
- chart_labels = [x["class_name"].replace('_', ' ').title() for x in all_probs]
342
- chart_values = [x["probability"] * 100 for x in all_probs]
343
- bar_colors = ['#0284c7' if chart_labels[i] == top_label else '#bfdbfe'
344
- for i in range(len(chart_labels))]
345
- # increase figure height if many classes to avoid cramped labels
346
- fig, ax = plt.subplots(figsize=(9, 4 + len(chart_labels) * 0.1))
347
- fig.patch.set_facecolor('#f8fafc')
348
- ax.set_facecolor('#f8fafc')
349
- ax.barh(chart_labels[::-1], chart_values[::-1],
350
- color=bar_colors[::-1], height=0.6, edgecolor='none')
351
- ax.set_xlabel("Probability (%)", color='#475569', fontsize=9)
352
- ax.tick_params(colors='#334155', labelsize=8.5)
353
- for spine in ax.spines.values():
354
- spine.set_visible(False)
355
- ax.grid(axis='x', color='#e2e8f0', linewidth=0.8)
356
- ax.set_xlim(0, max(chart_values) * 1.18 if max(chart_values) > 0 else 100)
357
- plt.tight_layout(pad=1.0)
358
- st.pyplot(fig)
359
- plt.close(fig)
360
-
361
- except Exception as e:
362
- st.error(f"Error during inference: {e}")
363
-
364
- finally:
365
- if cleanup_temp and temp_path and os.path.exists(temp_path):
366
- os.remove(temp_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import atexit
2
  import os
3
  import tempfile
4
+ import uuid
5
+ from pathlib import Path
6
 
 
7
  import torch
8
+ from flask import Flask, jsonify, render_template_string, request, send_from_directory
9
 
10
+ from classes import ESC50_CLASSES
11
  from preprocessing import preprocess_audio
12
  from utils import load_compressed_model, load_model, predict
 
13
 
14
 
15
+ BASE_DIR = Path(__file__).resolve().parent
16
+ ORIGINAL_MODEL_PATH = BASE_DIR / "weights" / "esc50_model.pth"
17
+ COMPRESSED_MODEL_PATH = BASE_DIR / "weights" / "esc50_model_compressed.pth"
18
+ STATS_PATH = BASE_DIR / "stats" / "esc50_mel_stats.json"
19
+ SAMPLES_DIR = BASE_DIR / "samples"
20
+ UPLOAD_DIR = Path(tempfile.gettempdir()) / "soundedge_uploads"
21
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
22
+
23
+ ALLOWED_EXTENSIONS = {".wav"}
24
+ LOW_CONFIDENCE_THRESHOLD = 0.6
25
+ MAX_UPLOAD_BYTES = 5 * 1024 * 1024
26
+
27
+ app = Flask(__name__)
28
+ app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_BYTES
29
+
30
+ _gpu_device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
31
+ _model_cache = {"Original": None, "Compressed": None}
32
+
33
+
34
+ def _safe_label(raw_name: str) -> str:
35
+ return raw_name.replace("_", " ").title()
36
+
37
+
38
+ def _sample_files() -> list[str]:
39
+ if not SAMPLES_DIR.is_dir():
40
+ return []
41
+ return sorted([f.name for f in SAMPLES_DIR.iterdir() if f.suffix.lower() == ".wav"])
42
+
43
+
44
+ def _validate_wav_filename(filename: str) -> bool:
45
+ ext = Path(filename).suffix.lower()
46
+ return ext in ALLOWED_EXTENSIONS
47
+
48
+
49
+ def _get_active_model(model_choice: str):
50
+ if model_choice == "Compressed":
51
+ if _model_cache["Compressed"] is None:
52
+ _model_cache["Compressed"] = load_compressed_model(
53
+ str(ORIGINAL_MODEL_PATH),
54
+ str(COMPRESSED_MODEL_PATH),
55
+ num_classes=len(ESC50_CLASSES),
56
+ )
57
+ return _model_cache["Compressed"], torch.device("cpu")
58
+
59
+ if _model_cache["Original"] is None:
60
+ _model_cache["Original"] = load_model(
61
+ str(ORIGINAL_MODEL_PATH),
62
+ _gpu_device,
63
+ num_classes=len(ESC50_CLASSES),
64
+ )
65
+ return _model_cache["Original"], _gpu_device
66
+
67
+
68
+ def _prediction_payload(model_choice: str, source_path: Path):
69
+ model, device = _get_active_model(model_choice)
70
+ input_tensor = preprocess_audio(str(source_path), str(STATS_PATH))
71
+ top_class, top_prob, all_probs = predict(model, input_tensor, device)
72
+
73
+ return {
74
+ "topClass": top_class,
75
+ "topClassLabel": _safe_label(top_class),
76
+ "topProbability": top_prob,
77
+ "topProbabilityPct": round(top_prob * 100, 2),
78
+ "lowConfidence": top_prob < LOW_CONFIDENCE_THRESHOLD,
79
+ "top3": [
80
+ {
81
+ "className": p["class_name"],
82
+ "classLabel": _safe_label(p["class_name"]),
83
+ "probability": p["probability"],
84
+ "probabilityPct": round(p["probability"] * 100, 2),
85
+ }
86
+ for p in all_probs[:3]
87
+ ],
88
+ "allProbs": [
89
+ {
90
+ "className": p["class_name"],
91
+ "classLabel": _safe_label(p["class_name"]),
92
+ "probability": p["probability"],
93
+ "probabilityPct": round(p["probability"] * 100, 2),
94
+ }
95
+ for p in all_probs
96
+ ],
97
+ }
98
+
99
+
100
+ @app.get("/")
101
+ def index():
102
+ classes = [_safe_label(c) for c in ESC50_CLASSES]
103
+ samples = _sample_files()
104
+ return render_template_string(
105
+ PAGE_TEMPLATE,
106
+ class_badges=classes,
107
+ samples=samples,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  )
109
+
110
+
111
+ @app.get("/samples/<path:filename>")
112
+ def serve_sample(filename: str):
113
+ return send_from_directory(SAMPLES_DIR, filename)
114
+
115
+
116
+ @app.get("/uploads/<path:filename>")
117
+ def serve_uploaded_file(filename: str):
118
+ return send_from_directory(UPLOAD_DIR, filename)
119
+
120
+
121
+ @app.post("/upload")
122
+ def upload_audio():
123
+ uploaded = request.files.get("file")
124
+ if uploaded is None:
125
+ return jsonify({"error": "Missing file field. Use multipart form key 'file'."}), 400
126
+
127
+ filename = uploaded.filename or ""
128
+ if not filename:
129
+ return jsonify({"error": "No file selected."}), 400
130
+ if not _validate_wav_filename(filename):
131
+ return jsonify({"error": "Only .wav files are supported."}), 400
132
+
133
+ file_id = uuid.uuid4().hex
134
+ stored_name = f"{file_id}.wav"
135
+ destination = UPLOAD_DIR / stored_name
136
+ uploaded.save(destination)
137
+
138
+ return jsonify(
139
+ {
140
+ "fileId": file_id,
141
+ "filename": filename,
142
+ "audioUrl": f"/uploads/{stored_name}",
143
+ }
144
  )
145
 
146
+
147
+ @app.post("/predict")
148
+ def predict_audio():
149
+ payload = request.get_json(silent=True) or {}
150
+ source_type = payload.get("source", "upload")
151
+ model_choice = payload.get("model", "Original")
152
+ if model_choice not in ("Original", "Compressed"):
153
+ return jsonify({"error": "Invalid model. Use 'Original' or 'Compressed'."}), 400
154
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  try:
156
+ if source_type == "upload":
157
+ file_id = payload.get("fileId", "")
158
+ if not file_id:
159
+ return jsonify({"error": "Missing fileId for uploaded source."}), 400
160
+ source_path = UPLOAD_DIR / f"{file_id}.wav"
161
+ if not source_path.exists():
162
+ return jsonify({"error": "Uploaded file not found. Upload again."}), 404
163
+ elif source_type == "sample":
164
+ sample_name = payload.get("sampleName", "")
165
+ if not sample_name:
166
+ return jsonify({"error": "Missing sampleName for sample source."}), 400
167
+ source_path = SAMPLES_DIR / sample_name
168
+ if not source_path.exists() or source_path.suffix.lower() != ".wav":
169
+ return jsonify({"error": "Sample not found."}), 404
170
+ else:
171
+ return jsonify({"error": "Invalid source. Use 'upload' or 'sample'."}), 400
172
+
173
+ result = _prediction_payload(model_choice=model_choice, source_path=source_path)
174
+ return jsonify(result)
175
+ except Exception as exc:
176
+ return jsonify({"error": f"Error during inference: {exc}"}), 500
177
+
178
+
179
+ @app.get("/health")
180
+ def health():
181
+ return jsonify({"status": "ok"})
182
+
183
+
184
+ def _cleanup_uploads():
185
+ if not UPLOAD_DIR.exists():
186
+ return
187
+ for wav in UPLOAD_DIR.glob("*.wav"):
188
+ try:
189
+ wav.unlink()
190
+ except OSError:
191
+ pass
192
+
193
+
194
+ atexit.register(_cleanup_uploads)
195
+
196
+
197
+ PAGE_TEMPLATE = """
198
+ <!doctype html>
199
+ <html lang="en">
200
+ <head>
201
+ <meta charset="utf-8" />
202
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
203
+ <title>SoundEdge - Environmental Sound Classification</title>
204
+ <style>
205
+ :root {
206
+ --bg: #f8fafc;
207
+ --text: #1e293b;
208
+ --muted: #64748b;
209
+ --border: #cbd5e1;
210
+ --panel: #ffffff;
211
+ --primary: #0369a1;
212
+ --accent: #0ea5e9;
213
+ --danger-bg: #fff1f2;
214
+ --danger: #dc2626;
215
+ }
216
+ * { box-sizing: border-box; }
217
+ body {
218
+ margin: 0;
219
+ font-family: "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif;
220
+ background: linear-gradient(140deg, #f8fafc 0%, #eff6ff 50%, #e0f2fe 100%);
221
+ color: var(--text);
222
+ }
223
+ .container {
224
+ width: min(1100px, 92vw);
225
+ margin: 2rem auto 3rem;
226
+ }
227
+ .hero {
228
+ background: linear-gradient(135deg, #e0f2fe 0%, #ede9fe 60%, #dbeafe 100%);
229
+ border: 1px solid #bfdbfe;
230
+ border-radius: 16px;
231
+ padding: 1rem 1.4rem;
232
+ text-align: center;
233
+ margin-bottom: 1rem;
234
+ }
235
+ .hero h1 { margin: 0 0 0.4rem; color: #075985; }
236
+ .hero p { margin: 0; color: #475569; }
237
+ .panel {
238
+ background: var(--panel);
239
+ border: 1px solid var(--border);
240
+ border-radius: 14px;
241
+ padding: 1rem;
242
+ margin-top: 1rem;
243
+ }
244
+ .section-title {
245
+ font-size: 0.85rem;
246
+ letter-spacing: 0.09em;
247
+ text-transform: uppercase;
248
+ color: #7c3aed;
249
+ font-weight: 700;
250
+ margin-bottom: 0.7rem;
251
+ }
252
+ .badge-grid { display: flex; flex-wrap: wrap; gap: 0.4rem; }
253
+ .badge {
254
+ background: #f1f5f9;
255
+ border: 1px solid #e2e8f0;
256
+ border-radius: 8px;
257
+ padding: 0.28rem 0.58rem;
258
+ font-size: 0.85rem;
259
+ }
260
+ .input-grid {
261
+ display: grid;
262
+ grid-template-columns: 1fr 1fr;
263
+ gap: 1rem;
264
+ }
265
+ @media (max-width: 860px) {
266
+ .input-grid { grid-template-columns: 1fr; }
267
+ }
268
+ .row { margin-bottom: 0.8rem; }
269
+ .hint { color: var(--muted); font-size: 0.9rem; margin: 0.2rem 0 0.8rem; }
270
+ .btn {
271
+ border: 1px solid #0369a1;
272
+ background: #0369a1;
273
+ color: #fff;
274
+ border-radius: 10px;
275
+ padding: 0.55rem 0.9rem;
276
+ font-weight: 600;
277
+ cursor: pointer;
278
+ }
279
+ .btn.secondary {
280
+ border-color: #7c3aed;
281
+ background: #7c3aed;
282
+ }
283
+ .btn:disabled {
284
+ opacity: 0.55;
285
+ cursor: not-allowed;
286
+ }
287
+ .status {
288
+ margin-top: 0.55rem;
289
+ font-size: 0.9rem;
290
+ color: var(--muted);
291
+ min-height: 1.2rem;
292
+ }
293
+ .error {
294
+ color: var(--danger);
295
+ background: var(--danger-bg);
296
+ border: 1px solid #fecdd3;
297
+ border-radius: 10px;
298
+ padding: 0.65rem;
299
+ margin-top: 0.7rem;
300
+ }
301
+ .result-card {
302
+ background: linear-gradient(135deg, #dbeafe 0%, #ede9fe 100%);
303
+ border: 1px solid #60a5fa;
304
+ border-radius: 14px;
305
+ padding: 1.2rem;
306
+ text-align: center;
307
+ }
308
+ .result-label { font-size: 0.82rem; letter-spacing: 0.09em; color: #64748b; text-transform: uppercase; }
309
+ .result-class { margin: 0.3rem 0 0; color: #0c4a6e; font-size: 1.9rem; font-weight: 800; }
310
+ .result-prob { margin-top: 0.45rem; color: #6d28d9; font-size: 1.15rem; font-weight: 700; }
311
+ .bar-row { margin-top: 0.7rem; }
312
+ .bar-label {
313
+ display: flex;
314
+ justify-content: space-between;
315
+ font-size: 0.95rem;
316
+ margin-bottom: 0.25rem;
317
+ }
318
+ .bar-bg {
319
+ height: 11px;
320
+ border-radius: 999px;
321
+ background: #e2e8f0;
322
+ overflow: hidden;
323
+ }
324
+ .bar-fill {
325
+ height: 11px;
326
+ border-radius: 999px;
327
+ background: linear-gradient(90deg, #0284c7, #7c3aed);
328
+ }
329
+ </style>
330
+ </head>
331
+ <body>
332
+ <div class="container">
333
+ <div class="hero">
334
+ <h1>SoundEdge</h1>
335
+ <p>Environmental Sound Classification with backend-managed uploads.</p>
336
+ </div>
337
+
338
+ <div class="panel">
339
+ <div class="section-title">Supported Sound Classes</div>
340
+ <div class="badge-grid">
341
+ {% for cls in class_badges %}
342
+ <span class="badge">{{ cls }}</span>
343
+ {% endfor %}
344
+ </div>
345
+ </div>
346
+
347
+ <div class="panel">
348
+ <div class="section-title">Select Model</div>
349
+ <label><input type="radio" name="model" value="Original" checked> Original</label>
350
+ <label style="margin-left:1rem;"><input type="radio" name="model" value="Compressed"> Compressed</label>
351
+ </div>
352
+
353
+ <div class="input-grid">
354
+ <div class="panel">
355
+ <div class="section-title">Upload Via Backend API</div>
356
+ <div class="row"><input id="uploadInput" type="file" accept=".wav,audio/wav" /></div>
357
+ <div class="row">
358
+ <button id="uploadBtn" class="btn">Upload</button>
359
+ <button id="predictUploadedBtn" class="btn secondary" disabled>Classify Uploaded Audio</button>
360
  </div>
361
+ <audio id="uploadedAudio" controls style="width:100%;display:none;margin-top:0.6rem;"></audio>
362
+ <div id="uploadStatus" class="status"></div>
363
+ <div id="uploadError"></div>
364
  </div>
365
+
366
+ <div class="panel">
367
+ <div class="section-title">Try Sample Audio</div>
368
+ {% if samples %}
369
+ <select id="sampleSelect" style="width:100%;padding:0.5rem;border-radius:8px;border:1px solid var(--border);">
370
+ {% for sample in samples %}
371
+ <option value="{{ sample }}">{{ sample }}</option>
372
+ {% endfor %}
373
+ </select>
374
+ <audio id="sampleAudio" controls style="width:100%;margin-top:0.6rem;"></audio>
375
+ <div class="row" style="margin-top:0.7rem;"><button id="predictSampleBtn" class="btn">Classify Sample</button></div>
376
+ {% else %}
377
+ <p class="hint">No sample files found in the samples folder.</p>
378
+ {% endif %}
379
+ <div id="sampleStatus" class="status"></div>
380
  </div>
381
+ </div>
382
+
383
+ <div id="resultsPanel" class="panel" style="display:none;"></div>
384
+ </div>
385
+
386
+ <script>
387
+ let uploadedFileId = null;
388
+ const uploadInput = document.getElementById("uploadInput");
389
+ const uploadBtn = document.getElementById("uploadBtn");
390
+ const predictUploadedBtn = document.getElementById("predictUploadedBtn");
391
+ const uploadStatus = document.getElementById("uploadStatus");
392
+ const uploadError = document.getElementById("uploadError");
393
+ const uploadedAudio = document.getElementById("uploadedAudio");
394
+ const sampleSelect = document.getElementById("sampleSelect");
395
+ const sampleAudio = document.getElementById("sampleAudio");
396
+ const sampleStatus = document.getElementById("sampleStatus");
397
+ const predictSampleBtn = document.getElementById("predictSampleBtn");
398
+ const resultsPanel = document.getElementById("resultsPanel");
399
+
400
+ function currentModel() {
401
+ const selected = document.querySelector('input[name="model"]:checked');
402
+ return selected ? selected.value : "Original";
403
+ }
404
+
405
+ function setError(target, message) {
406
+ target.innerHTML = message ? `<div class="error">${message}</div>` : "";
407
+ }
408
+
409
+ function renderResult(result) {
410
+ resultsPanel.style.display = "block";
411
+ if (result.lowConfidence) {
412
+ resultsPanel.innerHTML = `
413
+ <div class="error" style="margin-top:0;">
414
+ Unable to confidently identify the sound. Please upload a clearer clip and try again.
415
  </div>
416
+ `;
417
+ return;
418
+ }
419
+
420
+ let top3Html = "";
421
+ result.top3.forEach(item => {
422
+ top3Html += `
423
+ <div class="bar-row">
424
+ <div class="bar-label"><span>${item.classLabel}</span><span>${item.probabilityPct.toFixed(2)}%</span></div>
425
+ <div class="bar-bg"><div class="bar-fill" style="width:${item.probabilityPct}%;"></div></div>
426
  </div>
427
+ `;
428
+ });
429
+
430
+ let allHtml = "";
431
+ result.allProbs.forEach(item => {
432
+ const isTop = item.className === result.topClass;
433
+ const bg = isTop ? "#dbeafe" : "#f8fafc";
434
+ allHtml += `<div style="display:flex;justify-content:space-between;padding:0.35rem 0.55rem;border-radius:7px;background:${bg};margin-top:0.2rem;">
435
+ <span>${item.classLabel}</span><span>${item.probabilityPct.toFixed(2)}%</span>
436
+ </div>`;
437
+ });
438
+
439
+ resultsPanel.innerHTML = `
440
+ <div class="result-card">
441
+ <div class="result-label">Predicted Sound</div>
442
+ <h2 class="result-class">${result.topClassLabel}</h2>
443
+ <div class="result-prob">Confidence ${result.topProbabilityPct.toFixed(2)}%</div>
444
  </div>
445
+ <div class="section-title" style="margin-top:1rem;">Top 3 Predictions</div>
446
+ ${top3Html}
447
+ <div class="section-title" style="margin-top:1.2rem;">All Class Probabilities</div>
448
+ ${allHtml}
449
+ `;
450
+ }
451
+
452
+ async function predict(payload, statusEl) {
453
+ statusEl.textContent = "Analysing audio...";
454
+ const resp = await fetch("/predict", {
455
+ method: "POST",
456
+ headers: { "Content-Type": "application/json" },
457
+ body: JSON.stringify({ ...payload, model: currentModel() })
458
+ });
459
+ const data = await resp.json();
460
+ if (!resp.ok) {
461
+ throw new Error(data.error || "Prediction failed.");
462
+ }
463
+ statusEl.textContent = "Classification completed.";
464
+ renderResult(data);
465
+ }
466
+
467
+ uploadInput?.addEventListener("change", () => {
468
+ setError(uploadError, "");
469
+ uploadStatus.textContent = "";
470
+ uploadedFileId = null;
471
+ predictUploadedBtn.disabled = true;
472
+
473
+ const file = uploadInput.files && uploadInput.files[0];
474
+ if (!file) {
475
+ uploadedAudio.style.display = "none";
476
+ return;
477
+ }
478
+ const objectUrl = URL.createObjectURL(file);
479
+ uploadedAudio.src = objectUrl;
480
+ uploadedAudio.style.display = "block";
481
+ });
482
+
483
+ uploadBtn?.addEventListener("click", async () => {
484
+ setError(uploadError, "");
485
+ uploadStatus.textContent = "";
486
+ const file = uploadInput.files && uploadInput.files[0];
487
+ if (!file) {
488
+ setError(uploadError, "Select a WAV file before uploading.");
489
+ return;
490
+ }
491
+
492
+ const formData = new FormData();
493
+ formData.append("file", file);
494
+
495
+ try {
496
+ uploadStatus.textContent = "Uploading...";
497
+ const resp = await fetch("/upload", { method: "POST", body: formData });
498
+ const data = await resp.json();
499
+ if (!resp.ok) {
500
+ throw new Error(data.error || "Upload failed.");
501
+ }
502
+ uploadedFileId = data.fileId;
503
+ uploadedAudio.src = data.audioUrl;
504
+ predictUploadedBtn.disabled = false;
505
+ uploadStatus.textContent = `Uploaded: ${data.filename}`;
506
+ } catch (err) {
507
+ setError(uploadError, err.message || "Upload failed.");
508
+ uploadStatus.textContent = "";
509
+ }
510
+ });
511
+
512
+ predictUploadedBtn?.addEventListener("click", async () => {
513
+ setError(uploadError, "");
514
+ if (!uploadedFileId) {
515
+ setError(uploadError, "Upload a file first.");
516
+ return;
517
+ }
518
+ try {
519
+ await predict({ source: "upload", fileId: uploadedFileId }, uploadStatus);
520
+ } catch (err) {
521
+ setError(uploadError, err.message || "Prediction failed.");
522
+ uploadStatus.textContent = "";
523
+ }
524
+ });
525
+
526
+ if (sampleSelect && sampleAudio) {
527
+ function syncSampleAudio() {
528
+ sampleAudio.src = `/samples/${encodeURIComponent(sampleSelect.value)}`;
529
+ }
530
+ sampleSelect.addEventListener("change", syncSampleAudio);
531
+ syncSampleAudio();
532
+ }
533
+
534
+ predictSampleBtn?.addEventListener("click", async () => {
535
+ sampleStatus.textContent = "";
536
+ try {
537
+ await predict({ source: "sample", sampleName: sampleSelect.value }, sampleStatus);
538
+ } catch (err) {
539
+ sampleStatus.textContent = err.message || "Prediction failed.";
540
+ }
541
+ });
542
+ </script>
543
+ </body>
544
+ </html>
545
+ """
546
+
547
+
548
+ if __name__ == "__main__":
549
+ port = int(os.environ.get("PORT", "8501"))
550
+ host = os.environ.get("HOST", "127.0.0.1")
551
+ app.run(host=host, port=port, debug=False)
requirements.txt CHANGED
@@ -10,6 +10,7 @@ colorama==0.4.6
10
  contourpy==1.3.2
11
  cycler==0.12.1
12
  filelock==3.25.0
 
13
  fonttools==4.61.1
14
  fsspec==2026.2.0
15
  gitdb==4.0.12
 
10
  contourpy==1.3.2
11
  cycler==0.12.1
12
  filelock==3.25.0
13
+ Flask==3.1.1
14
  fonttools==4.61.1
15
  fsspec==2026.2.0
16
  gitdb==4.0.12