Spaces:
Paused
Paused
Upload folder using huggingface_hub
Browse files- app.py +74 -0
- frontend/index.html +153 -22
app.py
CHANGED
|
@@ -272,6 +272,80 @@ async def generate_patch(request: Request):
|
|
| 272 |
}
|
| 273 |
|
| 274 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
# In-memory artifact store: patch_hash -> {code, output, timestamp}
|
| 276 |
artifact_store: dict[str, dict] = {}
|
| 277 |
|
|
|
|
| 272 |
}
|
| 273 |
|
| 274 |
|
| 275 |
+
@app.post("/audio/generate")
|
| 276 |
+
async def audio_generate(request: Request):
|
| 277 |
+
"""Generate code from an audio transcript without requiring an existing session.
|
| 278 |
+
Creates a temporary session, injects the transcript as audio evidence,
|
| 279 |
+
runs observer β builder β receipt."""
|
| 280 |
+
body = {}
|
| 281 |
+
try:
|
| 282 |
+
body = await request.json()
|
| 283 |
+
except Exception:
|
| 284 |
+
pass
|
| 285 |
+
transcript = body.get("transcript", "") or request.query_params.get("transcript", "")
|
| 286 |
+
audio_file = body.get("audio_file", "") or request.query_params.get("audio_file", "")
|
| 287 |
+
mode = body.get("mode", "continuous_code") or request.query_params.get("mode", "continuous_code")
|
| 288 |
+
if not transcript:
|
| 289 |
+
return JSONResponse({"error": "transcript required"}, 400)
|
| 290 |
+
|
| 291 |
+
# Create a temporary session
|
| 292 |
+
state = SessionState()
|
| 293 |
+
state.mode = mode
|
| 294 |
+
sessions[state.session_id] = state
|
| 295 |
+
|
| 296 |
+
# Inject transcript as an audio chunk
|
| 297 |
+
chunk = process_transcript(transcript, state)
|
| 298 |
+
state.add_audio_chunk(chunk)
|
| 299 |
+
|
| 300 |
+
# Compress and run the full pipeline
|
| 301 |
+
compact = compress_state(state)
|
| 302 |
+
ev_result = evidence_gate("", compact)
|
| 303 |
+
log_gate_decision("evidence_gate", ev_result, patch_hash="")
|
| 304 |
+
observation = observe(state, compact)
|
| 305 |
+
state.observer_state = observation
|
| 306 |
+
state.last_observer_ts = time.time()
|
| 307 |
+
ev_result2 = evidence_gate(observation.get("observer_output", ""), compact)
|
| 308 |
+
log_gate_decision("evidence_gate", ev_result2, patch_hash="")
|
| 309 |
+
result = build(state, observation, gate_result=ev_result2)
|
| 310 |
+
state.current_artifact = result["patch_output"]
|
| 311 |
+
state.last_patch_hash = result["patch_hash"]
|
| 312 |
+
state.last_builder_ts = time.time()
|
| 313 |
+
state.receipt_count += 1
|
| 314 |
+
save_receipt(result["receipt"])
|
| 315 |
+
|
| 316 |
+
patch_output = result["patch_output"]
|
| 317 |
+
extracted_code = extract_code_from_output(patch_output)
|
| 318 |
+
if not extracted_code:
|
| 319 |
+
extracted_code = patch_output
|
| 320 |
+
artifact_data = {
|
| 321 |
+
"patch_hash": result["patch_hash"],
|
| 322 |
+
"code": extracted_code,
|
| 323 |
+
"observer_output": patch_output,
|
| 324 |
+
"timestamp": time.time(),
|
| 325 |
+
"session_id": state.session_id,
|
| 326 |
+
"fallback_level": result.get("fallback_level", 0),
|
| 327 |
+
"artifact_type": result.get("artifact_type", ""),
|
| 328 |
+
"sensory_channels": ev_result2.sensory_channels,
|
| 329 |
+
"feature_attribution": ev_result2.feature_attribution,
|
| 330 |
+
"audio_file": audio_file,
|
| 331 |
+
"transcript": transcript,
|
| 332 |
+
}
|
| 333 |
+
artifact_store[result["patch_hash"]] = artifact_data
|
| 334 |
+
save_artifact_disk(result["patch_hash"], artifact_data)
|
| 335 |
+
|
| 336 |
+
return {
|
| 337 |
+
**result,
|
| 338 |
+
"session_id": state.session_id,
|
| 339 |
+
"gate": {
|
| 340 |
+
"fallback_level": ev_result2.fallback_level,
|
| 341 |
+
"artifact_type": ev_result2.artifact_type,
|
| 342 |
+
"sensory_channels": ev_result2.sensory_channels,
|
| 343 |
+
"feature_attribution": ev_result2.feature_attribution,
|
| 344 |
+
"reason": ev_result2.reason,
|
| 345 |
+
},
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
|
| 349 |
# In-memory artifact store: patch_hash -> {code, output, timestamp}
|
| 350 |
artifact_store: dict[str, dict] = {}
|
| 351 |
|
frontend/index.html
CHANGED
|
@@ -1738,6 +1738,24 @@
|
|
| 1738 |
<div class="etl-stage-status" id="etlReceiptStatus">idle</div>
|
| 1739 |
</div>
|
| 1740 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1741 |
</div>
|
| 1742 |
|
| 1743 |
<!-- CONTROLS -->
|
|
@@ -1930,6 +1948,7 @@
|
|
| 1930 |
if (view) view.classList.add('active');
|
| 1931 |
const link = document.querySelector(`.nav-link[data-view="${name}"]`);
|
| 1932 |
if (link) link.classList.add('active');
|
|
|
|
| 1933 |
}
|
| 1934 |
|
| 1935 |
// ββ STATUS ββ
|
|
@@ -2181,43 +2200,86 @@
|
|
| 2181 |
}
|
| 2182 |
}
|
| 2183 |
|
|
|
|
|
|
|
|
|
|
| 2184 |
async function startAudioRecording() {
|
| 2185 |
try {
|
| 2186 |
audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 2187 |
audioChunks = [];
|
| 2188 |
currentAudioHash = null;
|
|
|
|
| 2189 |
mediaRecorder = new MediaRecorder(audioStream);
|
| 2190 |
mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunks.push(e.data); };
|
| 2191 |
mediaRecorder.onstop = () => {
|
| 2192 |
if (audioStream) { audioStream.getTracks().forEach(t => t.stop()); audioStream = null; }
|
| 2193 |
-
if (
|
| 2194 |
-
|
| 2195 |
-
|
| 2196 |
-
|
| 2197 |
-
|
| 2198 |
-
|
| 2199 |
-
|
| 2200 |
-
|
| 2201 |
-
|
| 2202 |
-
|
| 2203 |
-
method: "POST",
|
| 2204 |
-
headers: { "Content-Type": "application/json" },
|
| 2205 |
-
body: JSON.stringify({ audio_b64: b64, label: "standalone" })
|
| 2206 |
-
}).then(() => {
|
| 2207 |
-
audioChunks = [];
|
| 2208 |
-
document.getElementById("recStatus").textContent = "Stored";
|
| 2209 |
-
document.getElementById("audioStatus").textContent = "stored";
|
| 2210 |
-
}).catch(() => { });
|
| 2211 |
-
}
|
| 2212 |
-
};
|
| 2213 |
-
reader.readAsDataURL(blob);
|
| 2214 |
}
|
| 2215 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2216 |
};
|
| 2217 |
mediaRecorder.start();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2218 |
document.getElementById("recButton").classList.add("recording");
|
| 2219 |
document.getElementById("recStatus").textContent = "Recording...";
|
| 2220 |
document.getElementById("audioStatus").textContent = "recording";
|
|
|
|
|
|
|
| 2221 |
setEtlStage('capture', 'active');
|
| 2222 |
recSeconds = 0;
|
| 2223 |
recTimer = setInterval(() => {
|
|
@@ -2234,12 +2296,81 @@
|
|
| 2234 |
bar.style.height = (Math.random() * 36 + 4) + "px";
|
| 2235 |
});
|
| 2236 |
}, 100);
|
| 2237 |
-
setStatus("Audio recording started", "dot-active");
|
| 2238 |
} catch (e) {
|
| 2239 |
setStatus("Audio recording error: " + e.message, "dot-error");
|
| 2240 |
}
|
| 2241 |
}
|
| 2242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2243 |
function finishAudioUpload(patchHash) {
|
| 2244 |
if (audioChunks.length === 0) return;
|
| 2245 |
const blob = new Blob(audioChunks, { type: "audio/webm" });
|
|
|
|
| 1738 |
<div class="etl-stage-status" id="etlReceiptStatus">idle</div>
|
| 1739 |
</div>
|
| 1740 |
</div>
|
| 1741 |
+
<!-- LIVE TRANSCRIPT DURING RECORDING -->
|
| 1742 |
+
<div id="liveTranscript"
|
| 1743 |
+
style="margin-top:12px;padding:10px 14px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;font-family:var(--mono);font-size:12px;color:var(--text2);min-height:40px;display:none">
|
| 1744 |
+
<div style="font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:1px;margin-bottom:4px">
|
| 1745 |
+
Live Transcript</div>
|
| 1746 |
+
<div id="liveTranscriptText" style="line-height:1.6">Waiting for speech...</div>
|
| 1747 |
+
</div>
|
| 1748 |
+
|
| 1749 |
+
<!-- RECORDED AUDIO LIST -->
|
| 1750 |
+
<div id="audioListPanel" style="margin-top:12px">
|
| 1751 |
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
| 1752 |
+
<div style="font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:1px;font-weight:700">
|
| 1753 |
+
Recorded Audio Files</div>
|
| 1754 |
+
<button class="toolbar-btn" onclick="loadAudioList()"
|
| 1755 |
+
style="font-size:10px;padding:2px 8px">Refresh</button>
|
| 1756 |
+
</div>
|
| 1757 |
+
<div id="audioList" style="display:flex;flex-direction:column;gap:4px"></div>
|
| 1758 |
+
</div>
|
| 1759 |
</div>
|
| 1760 |
|
| 1761 |
<!-- CONTROLS -->
|
|
|
|
| 1948 |
if (view) view.classList.add('active');
|
| 1949 |
const link = document.querySelector(`.nav-link[data-view="${name}"]`);
|
| 1950 |
if (link) link.classList.add('active');
|
| 1951 |
+
if (name === 'engine') loadAudioList();
|
| 1952 |
}
|
| 1953 |
|
| 1954 |
// ββ STATUS ββ
|
|
|
|
| 2200 |
}
|
| 2201 |
}
|
| 2202 |
|
| 2203 |
+
let recRecognition = null;
|
| 2204 |
+
let recTranscriptText = "";
|
| 2205 |
+
|
| 2206 |
async function startAudioRecording() {
|
| 2207 |
try {
|
| 2208 |
audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 2209 |
audioChunks = [];
|
| 2210 |
currentAudioHash = null;
|
| 2211 |
+
recTranscriptText = "";
|
| 2212 |
mediaRecorder = new MediaRecorder(audioStream);
|
| 2213 |
mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunks.push(e.data); };
|
| 2214 |
mediaRecorder.onstop = () => {
|
| 2215 |
if (audioStream) { audioStream.getTracks().forEach(t => t.stop()); audioStream = null; }
|
| 2216 |
+
if (recRecognition) { try { recRecognition.stop(); } catch (e) { } recRecognition = null; }
|
| 2217 |
+
// Show transcript
|
| 2218 |
+
if (recTranscriptText.trim()) {
|
| 2219 |
+
document.getElementById("liveTranscriptText").textContent = recTranscriptText;
|
| 2220 |
+
setEtlStage('transcribe', 'done');
|
| 2221 |
+
// Send transcript to WebSocket to trigger observer β builder
|
| 2222 |
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
| 2223 |
+
ws.send(JSON.stringify({ type: "transcript", text: recTranscriptText.trim() }));
|
| 2224 |
+
setEtlStage('observe', 'active');
|
| 2225 |
+
setStatus("Transcript sent to observer LLM", "dot-thinking");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2226 |
}
|
| 2227 |
}
|
| 2228 |
+
// Upload audio file
|
| 2229 |
+
if (currentAudioHash) finishAudioUpload(currentAudioHash);
|
| 2230 |
+
else if (audioChunks.length > 0) {
|
| 2231 |
+
const blob = new Blob(audioChunks, { type: "audio/webm" });
|
| 2232 |
+
const reader = new FileReader();
|
| 2233 |
+
reader.onloadend = () => {
|
| 2234 |
+
const b64 = reader.result.split(",")[1];
|
| 2235 |
+
if (b64) {
|
| 2236 |
+
fetch("/audio/store", {
|
| 2237 |
+
method: "POST",
|
| 2238 |
+
headers: { "Content-Type": "application/json" },
|
| 2239 |
+
body: JSON.stringify({ audio_b64: b64, label: "standalone", transcript: recTranscriptText })
|
| 2240 |
+
}).then(r => r.json()).then(data => {
|
| 2241 |
+
audioChunks = [];
|
| 2242 |
+
document.getElementById("recStatus").textContent = "Stored";
|
| 2243 |
+
document.getElementById("audioStatus").textContent = "stored";
|
| 2244 |
+
loadAudioList();
|
| 2245 |
+
// If we have transcript but no WS, trigger generate directly
|
| 2246 |
+
if (recTranscriptText.trim() && (!ws || ws.readyState !== WebSocket.OPEN)) {
|
| 2247 |
+
triggerGenerateFromAudio(recTranscriptText.trim(), data.audio_file);
|
| 2248 |
+
}
|
| 2249 |
+
}).catch(() => { });
|
| 2250 |
+
}
|
| 2251 |
+
};
|
| 2252 |
+
reader.readAsDataURL(blob);
|
| 2253 |
+
}
|
| 2254 |
};
|
| 2255 |
mediaRecorder.start();
|
| 2256 |
+
// Start speech recognition in parallel for live transcription
|
| 2257 |
+
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
| 2258 |
+
if (SR) {
|
| 2259 |
+
recRecognition = new SR();
|
| 2260 |
+
recRecognition.lang = "en-US";
|
| 2261 |
+
recRecognition.continuous = true;
|
| 2262 |
+
recRecognition.interimResults = true;
|
| 2263 |
+
recRecognition.onresult = (event) => {
|
| 2264 |
+
let interim = "";
|
| 2265 |
+
let final = "";
|
| 2266 |
+
for (let i = event.resultIndex; i < event.results.length; i++) {
|
| 2267 |
+
if (event.results[i].isFinal) final += event.results[i][0].transcript + " ";
|
| 2268 |
+
else interim += event.results[i][0].transcript;
|
| 2269 |
+
}
|
| 2270 |
+
recTranscriptText += final;
|
| 2271 |
+
const display = recTranscriptText + (interim ? '<span style="color:var(--text3)">' + interim + '</span>' : '');
|
| 2272 |
+
document.getElementById("liveTranscriptText").innerHTML = display || "Listening...";
|
| 2273 |
+
if (final.trim()) setEtlStage('transcribe', 'active');
|
| 2274 |
+
};
|
| 2275 |
+
recRecognition.onerror = (e) => { console.warn('Speech error during recording:', e.error); };
|
| 2276 |
+
try { recRecognition.start(); } catch (e) { console.warn('SR already started'); }
|
| 2277 |
+
}
|
| 2278 |
document.getElementById("recButton").classList.add("recording");
|
| 2279 |
document.getElementById("recStatus").textContent = "Recording...";
|
| 2280 |
document.getElementById("audioStatus").textContent = "recording";
|
| 2281 |
+
document.getElementById("liveTranscript").style.display = "block";
|
| 2282 |
+
document.getElementById("liveTranscriptText").textContent = "Listening...";
|
| 2283 |
setEtlStage('capture', 'active');
|
| 2284 |
recSeconds = 0;
|
| 2285 |
recTimer = setInterval(() => {
|
|
|
|
| 2296 |
bar.style.height = (Math.random() * 36 + 4) + "px";
|
| 2297 |
});
|
| 2298 |
}, 100);
|
| 2299 |
+
setStatus("Audio recording + transcription started", "dot-active");
|
| 2300 |
} catch (e) {
|
| 2301 |
setStatus("Audio recording error: " + e.message, "dot-error");
|
| 2302 |
}
|
| 2303 |
}
|
| 2304 |
|
| 2305 |
+
// Trigger code generation from audio transcript when WS not connected
|
| 2306 |
+
async function triggerGenerateFromAudio(transcript, audioFile) {
|
| 2307 |
+
setEtlStage('observe', 'active');
|
| 2308 |
+
setStatus("Sending audio transcript to LLM...", "dot-thinking");
|
| 2309 |
+
try {
|
| 2310 |
+
const resp = await fetch('/audio/generate', {
|
| 2311 |
+
method: 'POST',
|
| 2312 |
+
headers: { 'Content-Type': 'application/json' },
|
| 2313 |
+
body: JSON.stringify({ transcript: transcript, audio_file: audioFile, mode: document.getElementById('mode')?.value || 'continuous_code' })
|
| 2314 |
+
});
|
| 2315 |
+
const data = await resp.json();
|
| 2316 |
+
if (data.patch_output) {
|
| 2317 |
+
setEtlStage('observe', 'done');
|
| 2318 |
+
setEtlStage('build', 'done');
|
| 2319 |
+
setEtlStage('receipt', 'done');
|
| 2320 |
+
addPatchEntry(data.patch_output, data.patch_hash, data.receipt, undefined);
|
| 2321 |
+
patchCount++;
|
| 2322 |
+
document.getElementById("patchCount").textContent = patchCount + " patch" + (patchCount !== 1 ? "es" : "");
|
| 2323 |
+
setStatus("Code patch generated from audio", "dot-active");
|
| 2324 |
+
// Store artifact
|
| 2325 |
+
const sections = parseSections(data.patch_output);
|
| 2326 |
+
const codeText = (sections.CODE || "").replace(/^```python\s*/i, "").replace(/^```\s*/, "").replace(/```$/, "").trim();
|
| 2327 |
+
if (codeText && data.patch_hash) {
|
| 2328 |
+
fetch("/store-artifact?patch_hash=" + data.patch_hash + "&code=" + encodeURIComponent(codeText) + "&reasoning=" + encodeURIComponent(sections.REASONING || "") + "&evidence=" + encodeURIComponent(sections.EVIDENCE || "") + "&observer_output=" + encodeURIComponent(data.patch_output), { method: "POST" }).catch(() => { });
|
| 2329 |
+
// Link audio to this artifact
|
| 2330 |
+
if (audioChunks.length > 0) {
|
| 2331 |
+
currentAudioHash = data.patch_hash;
|
| 2332 |
+
finishAudioUpload(data.patch_hash);
|
| 2333 |
+
}
|
| 2334 |
+
if (document.getElementById("autoRun") && document.getElementById("autoRun").checked) {
|
| 2335 |
+
setTimeout(() => runCode(data.patch_hash, null), 800);
|
| 2336 |
+
}
|
| 2337 |
+
}
|
| 2338 |
+
} else if (data.error) {
|
| 2339 |
+
setStatus("LLM error: " + data.error, "dot-error");
|
| 2340 |
+
}
|
| 2341 |
+
} catch (e) {
|
| 2342 |
+
setStatus("Generate error: " + e.message, "dot-error");
|
| 2343 |
+
}
|
| 2344 |
+
}
|
| 2345 |
+
|
| 2346 |
+
// Load recorded audio files from server
|
| 2347 |
+
async function loadAudioList() {
|
| 2348 |
+
try {
|
| 2349 |
+
const resp = await fetch('/audio/list');
|
| 2350 |
+
const data = await resp.json();
|
| 2351 |
+
const el = document.getElementById('audioList');
|
| 2352 |
+
if (data.total === 0) {
|
| 2353 |
+
el.innerHTML = '<div style="font-size:11px;color:var(--text3);padding:8px;font-family:var(--mono)">No recordings yet.</div>';
|
| 2354 |
+
return;
|
| 2355 |
+
}
|
| 2356 |
+
el.innerHTML = data.files.map(f => {
|
| 2357 |
+
const sizeKB = (f.size_bytes / 1024).toFixed(1);
|
| 2358 |
+
const date = new Date(f.modified * 1000).toLocaleTimeString();
|
| 2359 |
+
return '<div style="display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--panel2);border:1px solid var(--border);border-radius:8px">' +
|
| 2360 |
+
'<span style="font-size:16px">π΅</span>' +
|
| 2361 |
+
'<div style="flex:1;min-width:0">' +
|
| 2362 |
+
'<div style="font-size:11px;font-family:var(--mono);color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + f.filename + '</div>' +
|
| 2363 |
+
'<div style="font-size:9px;color:var(--text3)">' + sizeKB + 'KB Β· ' + date + '</div>' +
|
| 2364 |
+
'</div>' +
|
| 2365 |
+
'<audio controls preload="none" src="/audio/' + f.filename + '" style="height:28px;width:200px" />' +
|
| 2366 |
+
'<a href="/audio/' + f.filename + '" download style="font-size:11px;color:var(--accent);text-decoration:none">Download</a>' +
|
| 2367 |
+
'</div>';
|
| 2368 |
+
}).join('');
|
| 2369 |
+
} catch (e) {
|
| 2370 |
+
document.getElementById('audioList').innerHTML = '<div style="font-size:11px;color:var(--red);padding:8px">Error: ' + escapeHtml(e.message) + '</div>';
|
| 2371 |
+
}
|
| 2372 |
+
}
|
| 2373 |
+
|
| 2374 |
function finishAudioUpload(patchHash) {
|
| 2375 |
if (audioChunks.length === 0) return;
|
| 2376 |
const blob = new Blob(audioChunks, { type: "audio/webm" });
|