Upload 2 files
Browse files- app.py +48 -1
- index.html +8 -4
app.py
CHANGED
|
@@ -6,6 +6,7 @@ Flask + SQLite backend. Deploy on HF Spaces (Docker) or any server.
|
|
| 6 |
import os
|
| 7 |
import sqlite3
|
| 8 |
import uuid
|
|
|
|
| 9 |
from datetime import datetime
|
| 10 |
|
| 11 |
from flask import (
|
|
@@ -148,8 +149,10 @@ def serve_audio(filename):
|
|
| 148 |
|
| 149 |
@app.route("/api/export")
|
| 150 |
def export_excel():
|
|
|
|
| 151 |
import pandas as pd
|
| 152 |
|
|
|
|
| 153 |
with get_db() as db:
|
| 154 |
rows = db.execute("SELECT * FROM recordings ORDER BY created_at DESC").fetchall()
|
| 155 |
|
|
@@ -162,16 +165,60 @@ def export_excel():
|
|
| 162 |
"Duration (s)": round(r["duration"], 1),
|
| 163 |
"Notes": r["notes"],
|
| 164 |
"Audio File": r["filename"],
|
|
|
|
| 165 |
})
|
| 166 |
|
| 167 |
df = pd.DataFrame(data) if data else pd.DataFrame(
|
| 168 |
-
columns=["Name", "User", "Timestamp", "Duration (s)", "Notes", "Audio File"]
|
| 169 |
)
|
| 170 |
path = os.path.join(DATA_DIR, "export.xlsx")
|
| 171 |
df.to_excel(path, index=False)
|
| 172 |
return send_file(path, as_attachment=True, download_name="recordings.xlsx")
|
| 173 |
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
if __name__ == "__main__":
|
| 176 |
port = int(os.environ.get("PORT", 7860))
|
| 177 |
app.run(host="0.0.0.0", port=port, debug=False)
|
|
|
|
| 6 |
import os
|
| 7 |
import sqlite3
|
| 8 |
import uuid
|
| 9 |
+
import zipfile
|
| 10 |
from datetime import datetime
|
| 11 |
|
| 12 |
from flask import (
|
|
|
|
| 149 |
|
| 150 |
@app.route("/api/export")
|
| 151 |
def export_excel():
|
| 152 |
+
"""Export Excel only (with playback URLs)."""
|
| 153 |
import pandas as pd
|
| 154 |
|
| 155 |
+
base_url = request.host_url.rstrip("/")
|
| 156 |
with get_db() as db:
|
| 157 |
rows = db.execute("SELECT * FROM recordings ORDER BY created_at DESC").fetchall()
|
| 158 |
|
|
|
|
| 165 |
"Duration (s)": round(r["duration"], 1),
|
| 166 |
"Notes": r["notes"],
|
| 167 |
"Audio File": r["filename"],
|
| 168 |
+
"Audio URL": f"{base_url}/audio/{r['filename']}",
|
| 169 |
})
|
| 170 |
|
| 171 |
df = pd.DataFrame(data) if data else pd.DataFrame(
|
| 172 |
+
columns=["Name", "User", "Timestamp", "Duration (s)", "Notes", "Audio File", "Audio URL"]
|
| 173 |
)
|
| 174 |
path = os.path.join(DATA_DIR, "export.xlsx")
|
| 175 |
df.to_excel(path, index=False)
|
| 176 |
return send_file(path, as_attachment=True, download_name="recordings.xlsx")
|
| 177 |
|
| 178 |
|
| 179 |
+
@app.route("/api/export-zip")
|
| 180 |
+
def export_zip():
|
| 181 |
+
"""Export a ZIP containing the Excel + all audio files."""
|
| 182 |
+
import pandas as pd
|
| 183 |
+
|
| 184 |
+
base_url = request.host_url.rstrip("/")
|
| 185 |
+
with get_db() as db:
|
| 186 |
+
rows = db.execute("SELECT * FROM recordings ORDER BY created_at DESC").fetchall()
|
| 187 |
+
|
| 188 |
+
zip_path = os.path.join(DATA_DIR, "recordings_bundle.zip")
|
| 189 |
+
excel_path = os.path.join(DATA_DIR, "recordings.xlsx")
|
| 190 |
+
|
| 191 |
+
# Build Excel
|
| 192 |
+
data = []
|
| 193 |
+
for r in rows:
|
| 194 |
+
data.append({
|
| 195 |
+
"Name": r["name"],
|
| 196 |
+
"User": r["user"],
|
| 197 |
+
"Timestamp": r["created_at"],
|
| 198 |
+
"Duration (s)": round(r["duration"], 1),
|
| 199 |
+
"Notes": r["notes"],
|
| 200 |
+
"Audio File": r["filename"],
|
| 201 |
+
"Audio URL": f"{base_url}/audio/{r['filename']}",
|
| 202 |
+
})
|
| 203 |
+
|
| 204 |
+
df = pd.DataFrame(data) if data else pd.DataFrame(
|
| 205 |
+
columns=["Name", "User", "Timestamp", "Duration (s)", "Notes", "Audio File", "Audio URL"]
|
| 206 |
+
)
|
| 207 |
+
df.to_excel(excel_path, index=False)
|
| 208 |
+
|
| 209 |
+
# Build ZIP: excel + audio files
|
| 210 |
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 211 |
+
zf.write(excel_path, "recordings.xlsx")
|
| 212 |
+
for r in rows:
|
| 213 |
+
audio_path = os.path.join(AUDIO_DIR, r["filename"])
|
| 214 |
+
if os.path.exists(audio_path):
|
| 215 |
+
# Store as audio/<original_name>.webm inside the zip
|
| 216 |
+
safe_name = r["name"].replace("/", "_").replace("\\", "_")
|
| 217 |
+
zf.write(audio_path, f"audio/{safe_name}.webm")
|
| 218 |
+
|
| 219 |
+
return send_file(zip_path, as_attachment=True, download_name="recordings_bundle.zip")
|
| 220 |
+
|
| 221 |
+
|
| 222 |
if __name__ == "__main__":
|
| 223 |
port = int(os.environ.get("PORT", 7860))
|
| 224 |
app.run(host="0.0.0.0", port=port, debug=False)
|
index.html
CHANGED
|
@@ -131,6 +131,7 @@ tbody tr.playing{background:#eef1ff}
|
|
| 131 |
<input class="search-box" id="searchBox" placeholder="Searchβ¦" autocomplete="off">
|
| 132 |
<button class="btn-outline" id="btnRefresh">Refresh</button>
|
| 133 |
<button class="btn-outline" id="btnExport">Export Excel</button>
|
|
|
|
| 134 |
</div>
|
| 135 |
|
| 136 |
<table>
|
|
@@ -162,7 +163,7 @@ tbody tr.playing{background:#eef1ff}
|
|
| 162 |
var searchBox=$("searchBox"),tablePlayer=$("tablePlayer");
|
| 163 |
|
| 164 |
var mediaRecorder,audioChunks,recordedBlob;
|
| 165 |
-
var timerInt,startTime,analyser,animFrame,audioCtx,stream;
|
| 166 |
var allRecordings=[];
|
| 167 |
var playingId=null;
|
| 168 |
|
|
@@ -173,7 +174,7 @@ tbody tr.playing{background:#eef1ff}
|
|
| 173 |
/* ββ Timer ββββββββββββββββββββββββββββββββββββ */
|
| 174 |
function resetTimer(){timerEl.textContent="00:00";timerEl.classList.remove("active")}
|
| 175 |
function startTimerFn(){
|
| 176 |
-
|
| 177 |
timerInt=setInterval(function(){
|
| 178 |
var s=Math.floor((Date.now()-startTime)/1000);
|
| 179 |
timerEl.textContent=String(Math.floor(s/60)).padStart(2,"0")+":"+String(s%60).padStart(2,"0");
|
|
@@ -221,13 +222,15 @@ tbody tr.playing{background:#eef1ff}
|
|
| 221 |
btnSave.disabled=false;
|
| 222 |
setStatus("Ready to save.");
|
| 223 |
};
|
| 224 |
-
mediaRecorder.start(1000);
|
|
|
|
| 225 |
btnRecord.disabled=true;btnStop.disabled=false;
|
| 226 |
setStatus("Recordingβ¦");
|
| 227 |
}).catch(function(e){setStatus("Mic denied: "+e.message,false)});
|
| 228 |
});
|
| 229 |
|
| 230 |
btnStop.addEventListener("click",function(){
|
|
|
|
| 231 |
if(mediaRecorder&&mediaRecorder.state!=="inactive")mediaRecorder.stop();
|
| 232 |
stopTimerFn();stopVis();
|
| 233 |
if(stream){stream.getTracks().forEach(function(t){t.stop()});stream=null}
|
|
@@ -245,7 +248,7 @@ tbody tr.playing{background:#eef1ff}
|
|
| 245 |
fd.append("user",userName.value.trim()||"Anonymous");
|
| 246 |
fd.append("name",recName.value.trim());
|
| 247 |
fd.append("notes",recNotes.value.trim());
|
| 248 |
-
fd.append("duration",(
|
| 249 |
|
| 250 |
fetch("/api/recordings",{method:"POST",body:fd})
|
| 251 |
.then(function(r){if(!r.ok)throw new Error(r.statusText);return r.json()})
|
|
@@ -345,6 +348,7 @@ tbody tr.playing{background:#eef1ff}
|
|
| 345 |
/* Refresh & export */
|
| 346 |
$("btnRefresh").addEventListener("click",loadTable);
|
| 347 |
$("btnExport").addEventListener("click",function(){window.location="/api/export"});
|
|
|
|
| 348 |
|
| 349 |
/* Auto-refresh every 10s for collaboration */
|
| 350 |
setInterval(loadTable,10000);
|
|
|
|
| 131 |
<input class="search-box" id="searchBox" placeholder="Searchβ¦" autocomplete="off">
|
| 132 |
<button class="btn-outline" id="btnRefresh">Refresh</button>
|
| 133 |
<button class="btn-outline" id="btnExport">Export Excel</button>
|
| 134 |
+
<button class="btn-outline" id="btnExportZip">Download All (ZIP)</button>
|
| 135 |
</div>
|
| 136 |
|
| 137 |
<table>
|
|
|
|
| 163 |
var searchBox=$("searchBox"),tablePlayer=$("tablePlayer");
|
| 164 |
|
| 165 |
var mediaRecorder,audioChunks,recordedBlob;
|
| 166 |
+
var timerInt,startTime,recordedDuration,analyser,animFrame,audioCtx,stream;
|
| 167 |
var allRecordings=[];
|
| 168 |
var playingId=null;
|
| 169 |
|
|
|
|
| 174 |
/* ββ Timer ββββββββββββββββββββββββββββββββββββ */
|
| 175 |
function resetTimer(){timerEl.textContent="00:00";timerEl.classList.remove("active")}
|
| 176 |
function startTimerFn(){
|
| 177 |
+
timerEl.classList.add("active");
|
| 178 |
timerInt=setInterval(function(){
|
| 179 |
var s=Math.floor((Date.now()-startTime)/1000);
|
| 180 |
timerEl.textContent=String(Math.floor(s/60)).padStart(2,"0")+":"+String(s%60).padStart(2,"0");
|
|
|
|
| 222 |
btnSave.disabled=false;
|
| 223 |
setStatus("Ready to save.");
|
| 224 |
};
|
| 225 |
+
mediaRecorder.start(1000);
|
| 226 |
+
startTime=Date.now();recordedDuration=0;startTimerFn();
|
| 227 |
btnRecord.disabled=true;btnStop.disabled=false;
|
| 228 |
setStatus("Recordingβ¦");
|
| 229 |
}).catch(function(e){setStatus("Mic denied: "+e.message,false)});
|
| 230 |
});
|
| 231 |
|
| 232 |
btnStop.addEventListener("click",function(){
|
| 233 |
+
recordedDuration=(Date.now()-startTime)/1000;
|
| 234 |
if(mediaRecorder&&mediaRecorder.state!=="inactive")mediaRecorder.stop();
|
| 235 |
stopTimerFn();stopVis();
|
| 236 |
if(stream){stream.getTracks().forEach(function(t){t.stop()});stream=null}
|
|
|
|
| 248 |
fd.append("user",userName.value.trim()||"Anonymous");
|
| 249 |
fd.append("name",recName.value.trim());
|
| 250 |
fd.append("notes",recNotes.value.trim());
|
| 251 |
+
fd.append("duration",(recordedDuration||0).toFixed(1));
|
| 252 |
|
| 253 |
fetch("/api/recordings",{method:"POST",body:fd})
|
| 254 |
.then(function(r){if(!r.ok)throw new Error(r.statusText);return r.json()})
|
|
|
|
| 348 |
/* Refresh & export */
|
| 349 |
$("btnRefresh").addEventListener("click",loadTable);
|
| 350 |
$("btnExport").addEventListener("click",function(){window.location="/api/export"});
|
| 351 |
+
$("btnExportZip").addEventListener("click",function(){window.location="/api/export-zip"});
|
| 352 |
|
| 353 |
/* Auto-refresh every 10s for collaboration */
|
| 354 |
setInterval(loadTable,10000);
|