audio_recorder_server.py 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057
  1. """
  2. =============================================================
  3. Audio Recorder Server for Windows
  4. Controlled by ESP32-S3 via HTTP or web browser
  5. =============================================================
  6. SETUP
  7. -----
  8. 1. Install Python 3.8+ from python.org
  9. 2. Install dependencies:
  10. pip install flask sounddevice soundfile numpy
  11. 3. Run:
  12. python audio_recorder_server.py
  13. API ENDPOINTS
  14. -------------
  15. GET /api/start → Start a new recording
  16. GET /api/stop → Stop recording
  17. GET /api/pause → Pause / resume recording
  18. GET /api/resume → Resume paused recording
  19. GET /api/save → Save the last stopped recording to disk
  20. GET /api/status → Get current state, elapsed time, file list
  21. GET /api/devices → List available audio input devices
  22. POST /api/setdevice → Set input device {device: index_or_null}
  23. =============================================================
  24. """
  25. import os
  26. import sys
  27. import time
  28. import json
  29. import queue
  30. import signal
  31. import threading
  32. import argparse
  33. import datetime
  34. import numpy as np
  35. from pathlib import Path
  36. import sounddevice as sd
  37. import soundfile as sf
  38. try:
  39. import lameenc
  40. MP3_AVAILABLE = True
  41. except ImportError:
  42. MP3_AVAILABLE = False
  43. print("[Server] lameenc not installed — MP3 encoding unavailable")
  44. from flask import Flask, jsonify, request, send_from_directory
  45. # ─── ARGUMENT PARSING ────────────────────────────────────────────────────────
  46. parser = argparse.ArgumentParser(description="Audio Recorder Server")
  47. parser.add_argument("--port", type=int, default=5000)
  48. parser.add_argument("--host", type=str, default="0.0.0.0")
  49. parser.add_argument("--outdir", type=str, default="./recordings")
  50. parser.add_argument("--device", type=int, default=None)
  51. parser.add_argument("--samplerate", type=int, default=44100)
  52. parser.add_argument("--channels", type=int, default=2)
  53. parser.add_argument("--format", type=str, default="WAV",
  54. choices=["WAV", "FLAC", "OGG", "MP3"])
  55. parser.add_argument("--list-devices", action="store_true")
  56. # ─── CONFIG GLOBALS ──────────────────────────────────────────────────────────
  57. OUTPUT_DIR = Path("./recordings")
  58. SAMPLE_RATE = 44100
  59. CHANNELS = 2
  60. DEVICE = None
  61. FILE_FORMAT = "WAV"
  62. PORT = 5000
  63. def init_from_args():
  64. global OUTPUT_DIR, SAMPLE_RATE, CHANNELS, DEVICE, FILE_FORMAT, PORT
  65. args = parser.parse_args()
  66. if args.list_devices:
  67. print("\n=== Available Audio Input Devices ===\n")
  68. for i, d in enumerate(sd.query_devices()):
  69. if d['max_input_channels'] > 0:
  70. print(f" [{i:2d}] {d['name']}")
  71. print(f" Channels: {d['max_input_channels']} |"
  72. f" Rate: {int(d['default_samplerate'])} Hz")
  73. sys.exit(0)
  74. OUTPUT_DIR = Path(args.outdir)
  75. SAMPLE_RATE = args.samplerate
  76. CHANNELS = args.channels
  77. DEVICE = args.device
  78. FILE_FORMAT = args.format.upper()
  79. PORT = args.port
  80. OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
  81. return args
  82. # ─── HELPERS ─────────────────────────────────────────────────────────────────
  83. def get_device_name():
  84. try:
  85. if DEVICE is None:
  86. idx = sd.default.device[0]
  87. return "Default: " + sd.query_devices(idx)['name']
  88. return sd.query_devices(DEVICE)['name']
  89. except Exception:
  90. return "Unknown device"
  91. # ─── DEVICE CAPABILITY RESOLVER ─────────────────────────────────────────────
  92. def resolve_device_settings(device, wanted_rate, wanted_ch):
  93. """
  94. Query what the device actually supports and return the best
  95. (sample_rate, channels) we can use without PortAudio rejecting it.
  96. Falls back gracefully so recording always starts.
  97. """
  98. try:
  99. info = sd.query_devices(device, 'input')
  100. max_ch = int(info['max_input_channels'])
  101. default_rate = int(info['default_samplerate'])
  102. # Clamp channels to what device supports
  103. actual_ch = min(wanted_ch, max_ch)
  104. if actual_ch < 1:
  105. actual_ch = 1
  106. # Try requested rate first, then device default, then common fallbacks
  107. candidate_rates = [wanted_rate, default_rate, 48000, 44100, 22050, 16000]
  108. seen = set()
  109. for rate in candidate_rates:
  110. if rate in seen:
  111. continue
  112. seen.add(rate)
  113. try:
  114. sd.check_input_settings(device=device, channels=actual_ch,
  115. samplerate=rate, dtype='float32')
  116. return rate, actual_ch
  117. except Exception:
  118. continue
  119. # Last resort — let sounddevice use whatever it wants
  120. return default_rate, actual_ch
  121. except Exception as e:
  122. print(f"[Resolver] Could not query device {device}: {e} — using defaults")
  123. return wanted_rate, wanted_ch
  124. # ─── RECORDER ────────────────────────────────────────────────────────────────
  125. class RecorderState:
  126. IDLE = "idle"
  127. RECORDING = "recording"
  128. PAUSED = "paused"
  129. SAVING = "saving"
  130. class AudioRecorder:
  131. def __init__(self):
  132. self.state = RecorderState.IDLE
  133. self.audio_data = []
  134. self.stream = None
  135. self.lock = threading.Lock()
  136. self.start_time = None
  137. self.pause_time = None
  138. self.paused_secs = 0.0
  139. self.current_file = ""
  140. self.last_saved = ""
  141. self.error_msg = ""
  142. self.level = 0.0 # RMS level 0.0–1.0, updated per callback
  143. self.peak = 0.0 # peak hold, decays slowly
  144. self._peak_decay = 0.0
  145. @property
  146. def elapsed_seconds(self):
  147. if self.start_time is None:
  148. return 0
  149. if self.state == RecorderState.PAUSED:
  150. return self.pause_time - self.start_time - self.paused_secs
  151. if self.state == RecorderState.RECORDING:
  152. return time.time() - self.start_time - self.paused_secs
  153. return 0
  154. @property
  155. def elapsed_str(self):
  156. s = int(self.elapsed_seconds)
  157. return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}"
  158. def _audio_callback(self, indata, frames, time_info, status):
  159. # Compute RMS level from this block (all channels mixed to mono)
  160. mono = indata.mean(axis=1) if indata.ndim > 1 else indata[:,0]
  161. rms = float(np.sqrt(np.mean(mono ** 2)))
  162. # Convert to 0–1 with a log scale so quiet signals are visible
  163. # -60 dB floor → 0.0, 0 dB → 1.0
  164. if rms > 0:
  165. db = 20 * np.log10(rms + 1e-9)
  166. level = max(0.0, min(1.0, (db + 60) / 60))
  167. else:
  168. level = 0.0
  169. self.level = level
  170. # Peak hold — snap up instantly, decay at ~10 dB/s
  171. if level >= self.peak:
  172. self.peak = level
  173. else:
  174. self.peak = max(0.0, self.peak - 0.012)
  175. with self.lock:
  176. if self.state == RecorderState.RECORDING:
  177. self.audio_data.append(indata.copy())
  178. def start(self):
  179. with self.lock:
  180. if self.state != RecorderState.IDLE:
  181. return False, f"Cannot start — currently {self.state}"
  182. try:
  183. self.audio_data = []
  184. self.paused_secs = 0.0
  185. self.start_time = time.time()
  186. self.pause_time = None
  187. self.error_msg = ""
  188. self.current_file = self._make_filename()
  189. use_device = DEVICE # None = system default
  190. # Query actual device capabilities and clamp to what it supports
  191. actual_rate, actual_ch = resolve_device_settings(use_device, SAMPLE_RATE, CHANNELS)
  192. print(f"[Recorder] Using device={use_device} rate={actual_rate} ch={actual_ch}")
  193. self.stream = sd.InputStream(
  194. samplerate = actual_rate,
  195. channels = actual_ch,
  196. device = use_device,
  197. callback = self._audio_callback,
  198. dtype = 'float32',
  199. blocksize = 4096
  200. )
  201. self.stream.start()
  202. # Store actual settings used (for correct save)
  203. self._actual_rate = actual_rate
  204. self._actual_ch = actual_ch
  205. with self.lock:
  206. self.state = RecorderState.RECORDING
  207. print(f"[Recorder] Started: {self.current_file} device={use_device}")
  208. return True, "Recording started"
  209. except Exception as e:
  210. self.error_msg = str(e)
  211. print(f"[Recorder] Start error: {e}")
  212. return False, str(e)
  213. def pause(self):
  214. with self.lock:
  215. if self.state != RecorderState.RECORDING:
  216. return False, f"Cannot pause — currently {self.state}"
  217. self.state = RecorderState.PAUSED
  218. self.pause_time = time.time()
  219. return True, "Paused"
  220. def resume(self):
  221. with self.lock:
  222. if self.state != RecorderState.PAUSED:
  223. return False, f"Cannot resume — currently {self.state}"
  224. self.paused_secs += time.time() - self.pause_time
  225. self.pause_time = None
  226. self.state = RecorderState.RECORDING
  227. return True, "Resumed"
  228. def stop(self):
  229. with self.lock:
  230. if self.state not in (RecorderState.RECORDING, RecorderState.PAUSED):
  231. return False, f"Cannot stop — currently {self.state}"
  232. self.state = RecorderState.IDLE
  233. if self.stream:
  234. self.stream.stop()
  235. self.stream.close()
  236. self.stream = None
  237. print(f"[Recorder] Stopped. Blocks: {len(self.audio_data)}")
  238. return True, "Stopped"
  239. def save(self):
  240. if not self.audio_data:
  241. return False, "No audio data to save"
  242. self.state = RecorderState.SAVING
  243. def _save_thread():
  244. try:
  245. filepath = OUTPUT_DIR / self.current_file
  246. audio_np = np.concatenate(self.audio_data, axis=0)
  247. ext = FILE_FORMAT.lower()
  248. actual_rate = getattr(recorder, '_actual_rate', SAMPLE_RATE)
  249. actual_ch = getattr(recorder, '_actual_ch', CHANNELS)
  250. if ext == "mp3":
  251. if not MP3_AVAILABLE:
  252. raise Exception("lameenc not installed. Run: pip install lameenc")
  253. # Convert float32 [-1,1] to int16 PCM for lameenc
  254. pcm16 = (np.clip(audio_np, -1.0, 1.0) * 32767).astype(np.int16)
  255. enc = lameenc.Encoder()
  256. enc.set_bit_rate(192) # 192 kbps — good quality/size balance
  257. enc.set_in_sample_rate(actual_rate)
  258. enc.set_channels(actual_ch)
  259. enc.set_quality(2) # 2=high quality, 7=fastest
  260. mp3_data = enc.encode(pcm16.tobytes()) + enc.flush()
  261. with open(str(filepath), 'wb') as mp3f:
  262. mp3f.write(mp3_data)
  263. else:
  264. fmt_map = {"wav": ("WAV", "PCM_16"),
  265. "flac": ("FLAC", "PCM_16"),
  266. "ogg": ("OGG", "VORBIS")}
  267. fmt, sub = fmt_map.get(ext, ("WAV", "PCM_16"))
  268. sf.write(str(filepath), audio_np, actual_rate,
  269. format=fmt, subtype=sub)
  270. kb = filepath.stat().st_size // 1024
  271. self.last_saved = self.current_file
  272. self.audio_data = []
  273. self.state = RecorderState.IDLE
  274. print(f"[Recorder] Saved: {filepath} ({kb} KB)")
  275. except Exception as e:
  276. self.error_msg = str(e)
  277. self.state = RecorderState.IDLE
  278. print(f"[Recorder] Save error: {e}")
  279. threading.Thread(target=_save_thread, daemon=True).start()
  280. return True, f"Saving {self.current_file}"
  281. def list_files(self):
  282. files = []
  283. for f in sorted(OUTPUT_DIR.iterdir(), reverse=True):
  284. if f.suffix.lower() in {".wav", ".flac", ".ogg", ".mp3"}:
  285. kb = f.stat().st_size // 1024
  286. size_str = f"{kb} KB" if kb < 1024 else f"{kb//1024:.1f} MB"
  287. files.append({
  288. "name": f.name,
  289. "size": size_str,
  290. "modified": datetime.datetime.fromtimestamp(
  291. f.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
  292. })
  293. return files[:50]
  294. def status_dict(self):
  295. return {
  296. "state": self.state,
  297. "elapsed": self.elapsed_str,
  298. "elapsed_seconds": int(self.elapsed_seconds),
  299. "file": self.current_file,
  300. "last_saved": self.last_saved,
  301. "error": self.error_msg,
  302. "device": DEVICE,
  303. "device_name": get_device_name(),
  304. "mp3_available": MP3_AVAILABLE,
  305. "files": self.list_files()
  306. }
  307. def _make_filename(self):
  308. now = datetime.datetime.now()
  309. day = now.strftime("%a").upper() # MON, TUE, WED ...
  310. ts = now.strftime(f"%Y%m%d_{day}_%H%M")
  311. return f"{ts}.{FILE_FORMAT.lower()}"
  312. # ─── FLASK APP ────────────────────────────────────────────────────────────────
  313. app = Flask(__name__)
  314. recorder = AudioRecorder()
  315. import logging
  316. logging.getLogger('werkzeug').setLevel(logging.WARNING)
  317. # ─── API ROUTES ───────────────────────────────────────────────────────────────
  318. @app.route("/api/start")
  319. def api_start():
  320. ok, msg = recorder.start()
  321. r = recorder.status_dict(); r["message"] = msg
  322. return jsonify(r), (200 if ok else 400)
  323. @app.route("/api/stop")
  324. def api_stop():
  325. ok, msg = recorder.stop()
  326. r = recorder.status_dict(); r["message"] = msg
  327. return jsonify(r), (200 if ok else 400)
  328. @app.route("/api/pause")
  329. def api_pause():
  330. if recorder.state == RecorderState.RECORDING:
  331. ok, msg = recorder.pause()
  332. elif recorder.state == RecorderState.PAUSED:
  333. ok, msg = recorder.resume()
  334. else:
  335. ok, msg = False, f"Cannot pause — currently {recorder.state}"
  336. r = recorder.status_dict(); r["message"] = msg
  337. return jsonify(r), (200 if ok else 400)
  338. @app.route("/api/resume")
  339. def api_resume():
  340. ok, msg = recorder.resume()
  341. r = recorder.status_dict(); r["message"] = msg
  342. return jsonify(r), (200 if ok else 400)
  343. @app.route("/api/save")
  344. def api_save():
  345. if recorder.state in (RecorderState.RECORDING, RecorderState.PAUSED):
  346. recorder.stop()
  347. ok, msg = recorder.save()
  348. r = recorder.status_dict(); r["message"] = msg
  349. return jsonify(r), (200 if ok else 400)
  350. @app.route("/api/status")
  351. def api_status():
  352. return jsonify(recorder.status_dict())
  353. @app.route("/api/level")
  354. def api_level():
  355. """Lightweight level poll — returns current RMS + peak (0.0–1.0)."""
  356. return jsonify({
  357. "level": round(recorder.level, 4),
  358. "peak": round(recorder.peak, 4),
  359. "state": recorder.state
  360. })
  361. @app.route("/api/devices")
  362. def api_devices():
  363. devices = []
  364. for i, d in enumerate(sd.query_devices()):
  365. if d['max_input_channels'] > 0:
  366. devices.append({
  367. "index": i,
  368. "name": d['name'],
  369. "channels": int(d['max_input_channels']),
  370. "rate": int(d['default_samplerate']),
  371. "selected": (DEVICE == i)
  372. })
  373. # Also include resolved settings for current device
  374. r, ch = resolve_device_settings(DEVICE, SAMPLE_RATE, CHANNELS)
  375. return jsonify({"devices": devices, "current": DEVICE,
  376. "current_name": get_device_name(),
  377. "resolved_rate": r, "resolved_ch": ch})
  378. @app.route("/api/setdevice", methods=["POST"])
  379. def api_set_device():
  380. global DEVICE
  381. if recorder.state != RecorderState.IDLE:
  382. return jsonify({"ok": False,
  383. "error": "Cannot change device while recording"}), 400
  384. data = request.get_json(force=True, silent=True) or {}
  385. raw = data.get("device", None)
  386. if raw is None or raw == "" or str(raw) == "-1":
  387. DEVICE = None
  388. name = get_device_name()
  389. else:
  390. try:
  391. idx = int(raw)
  392. sd.query_devices(idx, 'input')
  393. DEVICE = idx
  394. name = sd.query_devices(idx)['name']
  395. except Exception as e:
  396. return jsonify({"ok": False, "error": str(e)}), 400
  397. # Optional: also update channels/samplerate/format
  398. global CHANNELS, SAMPLE_RATE, FILE_FORMAT
  399. data2 = data # already parsed above
  400. if "channels" in data2:
  401. CHANNELS = int(data2["channels"])
  402. if "samplerate" in data2:
  403. SAMPLE_RATE = int(data2["samplerate"])
  404. if "format" in data2 and data2["format"] in ("WAV","FLAC","OGG","MP3"):
  405. FILE_FORMAT = data2["format"]
  406. print(f"[Server] Device={name} ch={CHANNELS} rate={SAMPLE_RATE} fmt={FILE_FORMAT}")
  407. return jsonify({"ok": True, "device": DEVICE, "device_name": name})
  408. @app.route("/recordings/<path:filename>")
  409. def serve_recording(filename):
  410. return send_from_directory(str(OUTPUT_DIR), filename, as_attachment=True)
  411. @app.route("/api/rename", methods=["POST"])
  412. def api_rename():
  413. data = request.get_json(force=True, silent=True) or {}
  414. old_name = data.get("old", "").strip()
  415. new_name = data.get("new", "").strip()
  416. if not old_name or not new_name:
  417. return jsonify({"ok": False, "error": "Missing filename"}), 400
  418. # Sanitise — no path separators allowed
  419. for ch in ("/", "\\", "..", ":"):
  420. if ch in new_name:
  421. return jsonify({"ok": False, "error": "Invalid filename"}), 400
  422. old_path = OUTPUT_DIR / old_name
  423. # Preserve extension from original file
  424. ext = Path(old_name).suffix
  425. if not new_name.endswith(ext):
  426. new_name = new_name + ext
  427. new_path = OUTPUT_DIR / new_name
  428. if not old_path.exists():
  429. return jsonify({"ok": False, "error": "File not found"}), 404
  430. if new_path.exists():
  431. return jsonify({"ok": False, "error": "A file with that name already exists"}), 409
  432. old_path.rename(new_path)
  433. print(f"[Server] Renamed: {old_name} -> {new_name}")
  434. return jsonify({"ok": True, "new_name": new_name})
  435. # ─── WEB UI ───────────────────────────────────────────────────────────────────
  436. @app.route("/")
  437. def index():
  438. # Return raw string — do NOT use render_template_string as Jinja2
  439. # will try to parse CSS/JS curly braces as template variables
  440. html = build_ui_html()
  441. return html, 200, {"Content-Type": "text/html; charset=utf-8"}
  442. def build_ui_html():
  443. """Build the web UI — plain string, no Jinja2."""
  444. return """\
  445. <!DOCTYPE html>
  446. <html lang='en'>
  447. <head>
  448. <meta charset='UTF-8'>
  449. <meta name='viewport' content='width=device-width,initial-scale=1'>
  450. <title>Audio Recorder</title>
  451. <style>
  452. *{box-sizing:border-box;margin:0;padding:0}
  453. body{font-family:'Segoe UI',sans-serif;background:#1a1a2e;color:#eee;
  454. min-height:100vh;display:flex;flex-direction:column;align-items:center;padding:20px 16px}
  455. h1{color:#e94560;font-size:1.5em;letter-spacing:2px;margin-bottom:2px}
  456. .sub{color:#446;font-size:.78em;margin-bottom:14px}
  457. .tabs{display:flex;gap:4px;margin-bottom:14px;width:100%;max-width:500px}
  458. .tab{flex:1;padding:9px;text-align:center;border-radius:8px;cursor:pointer;
  459. font-size:.85em;font-weight:bold;border:none;transition:background .2s}
  460. .tab.on{background:#e94560;color:#fff}
  461. .tab:not(.on){background:#16213e;color:#667}
  462. .tab:not(.on):hover{background:#1e2d50;color:#a8dadc}
  463. .pg{display:none;width:100%;max-width:500px}
  464. .pg.on{display:block}
  465. .card{background:#16213e;border-radius:14px;padding:18px;margin-bottom:14px}
  466. /* Pill */
  467. #pill{display:inline-block;padding:4px 14px;border-radius:20px;font-weight:bold;
  468. font-size:.82em;text-transform:uppercase;letter-spacing:1px}
  469. .p-idle{background:#333;color:#888}
  470. .p-recording{background:#e94560;color:#fff;animation:blink 1.4s infinite}
  471. .p-paused{background:#f4a261;color:#1a1a2e}
  472. .p-saving{background:#457b9d;color:#fff}
  473. @keyframes blink{0%,100%{opacity:1}50%{opacity:.5}}
  474. /* Timer */
  475. #timer{font-size:3em;font-family:monospace;color:#a8dadc;letter-spacing:4px;
  476. text-align:center;padding:8px 0 4px}
  477. #fname{font-size:.75em;color:#446;text-align:center;min-height:1.2em}
  478. #msg{font-size:.78em;min-height:1.3em;color:#f4a261;text-align:center;margin-top:2px}
  479. /* Visualiser */
  480. #viz-wrap{height:54px;display:flex;align-items:flex-end;justify-content:center;
  481. gap:3px;padding:6px 0;opacity:0.3;transition:opacity .4s}
  482. #viz-wrap.active{opacity:1}
  483. .vbar{width:6px;background:#a8dadc;border-radius:3px 3px 0 0;
  484. min-height:3px;transition:height .07s ease-out}
  485. /* Buttons */
  486. .grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px}
  487. .span2{grid-column:span 2}
  488. button{border:none;border-radius:9px;padding:13px;font-size:.95em;
  489. font-weight:bold;cursor:pointer;transition:transform .1s,opacity .2s}
  490. button:active{transform:scale(.97)}
  491. button:disabled{opacity:.3;cursor:not-allowed}
  492. .bs{background:#2dc653;color:#fff}
  493. .bp{background:#f4a261;color:#1a1a2e}
  494. .bx{background:#e94560;color:#fff}
  495. .bv{background:#457b9d;color:#fff}
  496. /* Settings */
  497. .srow{display:flex;justify-content:space-between;align-items:center;
  498. padding:12px 0;border-bottom:1px solid #0f3460}
  499. .srow:last-child{border-bottom:none}
  500. .slbl{font-size:.88em;color:#a8dadc}
  501. .ssub{font-size:.73em;color:#446;margin-top:2px}
  502. select{background:#0f3460;color:#eee;border:1px solid #1e3a5f;
  503. border-radius:6px;padding:7px 10px;font-size:.85em;cursor:pointer;max-width:210px}
  504. .abtn{width:100%;margin-top:4px;padding:12px;background:#e94560;color:#fff;
  505. border:none;border-radius:9px;font-size:.95em;font-weight:bold;cursor:pointer}
  506. .abtn:disabled{opacity:.4;cursor:not-allowed}
  507. /* Files */
  508. .stitle{font-size:.73em;text-transform:uppercase;letter-spacing:1px;
  509. color:#a8dadc;margin-bottom:10px}
  510. .frow{display:flex;align-items:center;padding:9px 0;
  511. border-bottom:1px solid #0f3460;font-size:.83em;gap:6px}
  512. .frow:last-child{border-bottom:none}
  513. .fn{color:#ccc;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  514. .fm{color:#446;font-size:.76em;white-space:nowrap}
  515. .ficn{background:none;border:none;cursor:pointer;font-size:1em;padding:2px 4px;
  516. color:#a8dadc;border-radius:4px}
  517. .ficn:hover{background:#0f3460}
  518. .empty{color:#334;font-size:.83em;padding:8px 0}
  519. /* Rename modal */
  520. .modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);
  521. z-index:100;align-items:center;justify-content:center}
  522. .modal-bg.show{display:flex}
  523. .modal{background:#16213e;border-radius:14px;padding:24px;width:90%;max-width:380px}
  524. .modal h3{color:#e94560;margin-bottom:14px;font-size:1em}
  525. .modal input{width:100%;background:#0f3460;color:#eee;border:1px solid #1e3a5f;
  526. border-radius:7px;padding:10px;font-size:.95em;margin-bottom:12px}
  527. .modal input:focus{outline:none;border-color:#e94560}
  528. .mrow{display:flex;gap:8px}
  529. .mrow button{flex:1;padding:10px;border:none;border-radius:8px;
  530. font-weight:bold;cursor:pointer;font-size:.9em}
  531. .mok{background:#e94560;color:#fff}
  532. .mcancel{background:#0f3460;color:#a8dadc}
  533. /* Toast */
  534. .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);
  535. background:#2dc653;color:#fff;padding:10px 24px;border-radius:10px;
  536. font-size:.88em;font-weight:bold;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200}
  537. .toast.show{opacity:1}
  538. #devinfo{font-size:.75em;color:#446;margin-top:8px;text-align:center}
  539. </style>
  540. </head>
  541. <body>
  542. <h1>&#127908; Audio Recorder</h1>
  543. <div class='sub'>Windows PC Server</div>
  544. <div class='tabs'>
  545. <button class='tab on' id='t0' onclick='tab(0)'>&#9654; Recorder</button>
  546. <button class='tab' id='t1' onclick='tab(1)'>&#128190; Files</button>
  547. <button class='tab' id='t2' onclick='tab(2)'>&#9881; Settings</button>
  548. </div>
  549. <!-- RECORDER TAB -->
  550. <div class='pg on' id='p0'>
  551. <div class='card'>
  552. <div style='display:flex;justify-content:space-between;align-items:center'>
  553. <span id='pill' class='p-idle'>Idle</span>
  554. <span id='fname'>&nbsp;</span>
  555. </div>
  556. <div id='timer'>00:00:00</div>
  557. <div id='msg'>&nbsp;</div>
  558. <!-- Audio visualiser: 24 bars -->
  559. <div id='viz-wrap'>
  560. <div class='vbar' id='vb0'></div><div class='vbar' id='vb1'></div>
  561. <div class='vbar' id='vb2'></div><div class='vbar' id='vb3'></div>
  562. <div class='vbar' id='vb4'></div><div class='vbar' id='vb5'></div>
  563. <div class='vbar' id='vb6'></div><div class='vbar' id='vb7'></div>
  564. <div class='vbar' id='vb8'></div><div class='vbar' id='vb9'></div>
  565. <div class='vbar' id='vb10'></div><div class='vbar' id='vb11'></div>
  566. <div class='vbar' id='vb12'></div><div class='vbar' id='vb13'></div>
  567. <div class='vbar' id='vb14'></div><div class='vbar' id='vb15'></div>
  568. <div class='vbar' id='vb16'></div><div class='vbar' id='vb17'></div>
  569. <div class='vbar' id='vb18'></div><div class='vbar' id='vb19'></div>
  570. <div class='vbar' id='vb20'></div><div class='vbar' id='vb21'></div>
  571. <div class='vbar' id='vb22'></div><div class='vbar' id='vb23'></div>
  572. </div>
  573. <div class='grid'>
  574. <button class='bs span2' id='b0' onclick="cmd('start')">&#9654; START</button>
  575. <button class='bp' id='b1' onclick="cmd('pause')" disabled>&#9646;&#9646; PAUSE</button>
  576. <button class='bx' id='b2' onclick="cmd('stop')" disabled>&#9632; STOP</button>
  577. <button class='bv span2' id='b3' onclick="cmd('save')" disabled>&#128190; STOP &amp; SAVE</button>
  578. </div>
  579. </div>
  580. <div id='devinfo'>Input: loading...</div>
  581. </div>
  582. <!-- FILES TAB -->
  583. <div class='pg' id='p1'>
  584. <div class='card'>
  585. <div class='stitle'>Saved Recordings</div>
  586. <div id='flist'><div class='empty'>No recordings yet</div></div>
  587. </div>
  588. </div>
  589. <!-- SETTINGS TAB -->
  590. <div class='pg' id='p2'>
  591. <div class='card'>
  592. <div class='stitle'>Audio Input Device</div>
  593. <div class='srow'>
  594. <div><div class='slbl'>Input Device</div>
  595. <div class='ssub'>Microphone, line-in, or Stereo Mix</div></div>
  596. <select id='dsel'><option value='-1'>Loading...</option></select>
  597. </div>
  598. <div class='srow'>
  599. <div><div class='slbl'>Channels</div></div>
  600. <select id='chsel'>
  601. <option value='1'>Mono</option>
  602. <option value='2' selected>Stereo</option>
  603. </select>
  604. </div>
  605. <div class='srow'>
  606. <div><div class='slbl'>Sample Rate</div></div>
  607. <select id='srsel'>
  608. <option value='22050'>22050 Hz</option>
  609. <option value='44100' selected>44100 Hz</option>
  610. <option value='48000'>48000 Hz</option>
  611. </select>
  612. </div>
  613. <div class='srow'>
  614. <div><div class='slbl'>File Format</div>
  615. <div class='ssub' id='fmt-hint'>WAV = lossless, MP3 = ~10x smaller</div></div>
  616. <select id='fmsel' onchange='fmtHint()'>
  617. <option value='WAV' selected>WAV (lossless)</option>
  618. <option value='FLAC'>FLAC (lossless compressed)</option>
  619. <option value='OGG'>OGG Vorbis</option>
  620. <option value='MP3' id='mp3opt'>MP3 192kbps</option>
  621. </select>
  622. </div>
  623. </div>
  624. <div class='card' style='font-size:.8em;color:#557;line-height:1.7'>
  625. <div class='stitle'>Tip: Capture System Audio</div>
  626. To record audio playing on your PC, enable <b style='color:#a8dadc'>Stereo Mix</b>
  627. in Windows: Right-click speaker &rarr; Sounds &rarr; Recording tab &rarr;
  628. right-click empty area &rarr; Show Disabled Devices &rarr; enable Stereo Mix.
  629. Then select it above.
  630. </div>
  631. <button class='abtn' id='abtn' onclick='applySettings()'>Apply Settings</button>
  632. </div>
  633. <!-- RENAME MODAL -->
  634. <div class='modal-bg' id='modal'>
  635. <div class='modal'>
  636. <h3>&#9998; Rename Recording</h3>
  637. <input type='text' id='ren-input' placeholder='New filename (no extension)'>
  638. <div class='mrow'>
  639. <button class='mok' onclick='doRename()'>Rename</button>
  640. <button class='mcancel' onclick='closeModal()'>Cancel</button>
  641. </div>
  642. </div>
  643. </div>
  644. <div class='toast' id='toast'></div>
  645. <script>
  646. // ── State ────────────────────────────────────────────────────────────────────
  647. var _files=[], _devLoaded=false, _localStart=null, _tick=null;
  648. var _renameTarget='', _renameExt='';
  649. var _vizCtx=null, _vizAnalyser=null, _vizSrc=null, _vizStream=null, _vizAnim=null;
  650. var _vizBars=[];
  651. // ── Tabs ─────────────────────────────────────────────────────────────────────
  652. function tab(n){
  653. for(var i=0;i<3;i++){
  654. document.getElementById('p'+i).className='pg'+(i==n?' on':'');
  655. document.getElementById('t'+i).className='tab'+(i==n?' on':'');
  656. }
  657. if(n==1) renderFiles();
  658. if(n==2) loadDevices();
  659. }
  660. // ── Toast ────────────────────────────────────────────────────────────────────
  661. function toast(m,ok){
  662. var e=document.getElementById('toast');
  663. e.textContent=m; e.style.background=(ok===false)?'#e94560':'#2dc653';
  664. e.className='toast show'; setTimeout(function(){e.className='toast';},2800);
  665. }
  666. // ── Time format ──────────────────────────────────────────────────────────────
  667. function fmtTime(s){
  668. return String(Math.floor(s/3600)).padStart(2,'0')+':'+
  669. String(Math.floor((s%3600)/60)).padStart(2,'0')+':'+
  670. String(s%60).padStart(2,'0');
  671. }
  672. // ── API commands ─────────────────────────────────────────────────────────────
  673. async function cmd(a){
  674. document.getElementById('msg').textContent='...';
  675. try{
  676. var r=await fetch('/api/'+a), d=await r.json();
  677. document.getElementById('msg').textContent=d.message||'';
  678. apply(d);
  679. }catch(e){document.getElementById('msg').textContent='Error: '+e;}
  680. }
  681. async function poll(){
  682. try{var r=await fetch('/api/status'),d=await r.json();apply(d);}catch(e){}
  683. }
  684. function apply(d){
  685. var s=d.state||'idle';
  686. var pill=document.getElementById('pill');
  687. pill.textContent=s.toUpperCase(); pill.className='p-'+s;
  688. document.getElementById('fname').textContent=d.file||'\\u00a0';
  689. if(d.device_name) document.getElementById('devinfo').textContent='Input: '+d.device_name;
  690. var idle=s=='idle', busy=s=='saving';
  691. document.getElementById('b0').disabled=!idle;
  692. document.getElementById('b1').disabled=idle||busy;
  693. document.getElementById('b2').disabled=idle||busy;
  694. document.getElementById('b3').disabled=idle||busy;
  695. document.getElementById('b1').textContent=s=='paused'?'\\u25b6 RESUME':'\\u2016 PAUSE';
  696. clearInterval(_tick);
  697. if(s=='recording'){
  698. _localStart=Date.now()-(d.elapsed_seconds*1000);
  699. _tick=setInterval(function(){
  700. document.getElementById('timer').textContent=
  701. fmtTime(Math.floor((Date.now()-_localStart)/1000));
  702. },500);
  703. startViz();
  704. } else {
  705. document.getElementById('timer').textContent=d.elapsed||'00:00:00';
  706. if(s=='idle'||s=='paused') stopViz(s=='idle');
  707. }
  708. if(d.files&&d.files.length) _files=d.files;
  709. }
  710. // ── Audio Visualiser ─────────────────────────────────────────────────────────
  711. // Strategy:
  712. // localhost → getUserMedia (Web Audio API, real frequency data, high refresh)
  713. // remote → poll /api/level every 80ms (server-side RMS, works everywhere)
  714. var _isLocalhost = (location.hostname==='localhost'||location.hostname==='127.0.0.1');
  715. var _levelPoll = null;
  716. var BARS = 24, MAXH = 46;
  717. function getVizBars(){
  718. if(_vizBars.length) return _vizBars;
  719. for(var i=0;i<BARS;i++) _vizBars.push(document.getElementById('vb'+i));
  720. return _vizBars;
  721. }
  722. function colourBar(v){
  723. var r=Math.round(Math.min(255,v*2*255));
  724. var g=Math.round(Math.min(255,(1-v)*2*255));
  725. return 'rgb('+r+','+g+',80)';
  726. }
  727. // ── Localhost: getUserMedia + Web Audio API ───────────────────────────────────
  728. function startVizLocal(){
  729. if(_vizCtx) return;
  730. document.getElementById('viz-wrap').classList.add('active');
  731. navigator.mediaDevices.getUserMedia({audio:true,video:false})
  732. .then(function(stream){
  733. _vizStream=stream;
  734. _vizCtx=new (window.AudioContext||window.webkitAudioContext)();
  735. _vizAnalyser=_vizCtx.createAnalyser();
  736. _vizAnalyser.fftSize=64;
  737. _vizSrc=_vizCtx.createMediaStreamSource(stream);
  738. _vizSrc.connect(_vizAnalyser);
  739. var buf=new Uint8Array(_vizAnalyser.frequencyBinCount);
  740. var bars=getVizBars();
  741. function draw(){
  742. _vizAnim=requestAnimationFrame(draw);
  743. _vizAnalyser.getByteFrequencyData(buf);
  744. for(var i=0;i<BARS;i++){
  745. var bi=Math.floor((i/BARS)*buf.length);
  746. var v=Math.max(buf[bi]/255, 0.03+Math.random()*0.03);
  747. var h=Math.max(3,Math.round(v*MAXH));
  748. bars[i].style.height=h+'px';
  749. bars[i].style.background=colourBar(v);
  750. }
  751. }
  752. draw();
  753. })
  754. .catch(function(){ startVizRemote(); }); // fallback if mic denied
  755. }
  756. // ── Remote: poll /api/level ───────────────────────────────────────────────────
  757. // Spreads a single RMS value across all bars with natural-looking variation
  758. var _lvlHistory = new Array(BARS).fill(0); // per-bar smoothed value
  759. var _peakHistory= new Array(BARS).fill(0);
  760. function startVizRemote(){
  761. if(_levelPoll) return;
  762. document.getElementById('viz-wrap').classList.add('active');
  763. var bars=getVizBars();
  764. _levelPoll = setInterval(function(){
  765. fetch('/api/level').then(function(r){return r.json();})
  766. .then(function(d){
  767. var lvl = d.level || 0;
  768. var peak = d.peak || 0;
  769. // Distribute level across bars: centre bars get full level,
  770. // edge bars slightly lower — gives a natural spectrum shape
  771. for(var i=0;i<BARS;i++){
  772. var pos = Math.abs(i - (BARS/2 - 0.5)) / (BARS/2); // 0=centre 1=edge
  773. var noise = (Math.random()-0.5)*0.08;
  774. var target= Math.max(0, lvl * (1 - pos*0.45) + noise);
  775. // Smooth: fast attack, slow decay
  776. if(target > _lvlHistory[i]) _lvlHistory[i] = target;
  777. else _lvlHistory[i] = _lvlHistory[i]*0.72 + target*0.28;
  778. var v = Math.max(0.02, _lvlHistory[i]);
  779. var h = Math.max(3, Math.round(v*MAXH));
  780. bars[i].style.height=h+'px';
  781. bars[i].style.background=colourBar(v);
  782. }
  783. }).catch(function(){});
  784. }, 80);
  785. }
  786. function startViz(){
  787. if(_isLocalhost) startVizLocal();
  788. else startVizRemote();
  789. }
  790. function stopViz(reset){
  791. // Stop local viz
  792. if(_vizAnim){ cancelAnimationFrame(_vizAnim); _vizAnim=null; }
  793. if(_vizSrc){ try{_vizSrc.disconnect();}catch(e){} _vizSrc=null; }
  794. if(_vizCtx){ try{_vizCtx.close();}catch(e){} _vizCtx=null; }
  795. if(_vizStream){ _vizStream.getTracks().forEach(function(t){t.stop();}); _vizStream=null; }
  796. _vizAnalyser=null;
  797. // Stop remote poll
  798. if(_levelPoll){ clearInterval(_levelPoll); _levelPoll=null; }
  799. var bars=getVizBars();
  800. if(reset){
  801. document.getElementById('viz-wrap').classList.remove('active');
  802. _lvlHistory.fill(0); _peakHistory.fill(0);
  803. for(var i=0;i<BARS;i++){bars[i].style.height='3px';bars[i].style.background='#a8dadc';}
  804. }
  805. }
  806. // ── Files tab ────────────────────────────────────────────────────────────────
  807. function renderFiles(){
  808. var el=document.getElementById('flist');
  809. if(!_files.length){el.innerHTML="<div class='empty'>No recordings yet</div>";return;}
  810. el.innerHTML=_files.map(function(f){
  811. var stem=f.name.replace(/\\.[^.]+$/,'');
  812. return "<div class='frow'>"
  813. +"<span class='fn' title='"+f.name+"'>"+f.name+"</span>"
  814. +"<span class='fm'>"+f.size+"</span>"
  815. +"<button class='ficn' title='Rename' onclick='openModal(\\'"+f.name+"\\')'>&#9998;</button>"
  816. +"<a class='ficn' title='Download' href='/recordings/"+encodeURIComponent(f.name)+"' download>&#11015;</a>"
  817. +"</div>";
  818. }).join('');
  819. }
  820. // ── Rename modal ─────────────────────────────────────────────────────────────
  821. function openModal(fname){
  822. _renameTarget=fname;
  823. _renameExt=fname.match(/\\.[^.]+$/)?fname.match(/\\.[^.]+$/)[0]:'';
  824. var stem=fname.replace(/\\.[^.]+$/,'');
  825. document.getElementById('ren-input').value=stem;
  826. document.getElementById('modal').className='modal-bg show';
  827. setTimeout(function(){
  828. var inp=document.getElementById('ren-input');
  829. inp.focus(); inp.select();
  830. },80);
  831. }
  832. function closeModal(){
  833. document.getElementById('modal').className='modal-bg';
  834. _renameTarget='';
  835. }
  836. async function doRename(){
  837. var newStem=document.getElementById('ren-input').value.trim();
  838. if(!newStem){toast('Enter a filename',false);return;}
  839. try{
  840. var r=await fetch('/api/rename',{
  841. method:'POST',
  842. headers:{'Content-Type':'application/json'},
  843. body:JSON.stringify({old:_renameTarget, new:newStem})
  844. });
  845. var d=await r.json();
  846. if(d.ok){
  847. toast('Renamed to '+d.new_name);
  848. closeModal();
  849. // Refresh file list from server
  850. var rs=await fetch('/api/status');
  851. var ds=await rs.json();
  852. if(ds.files&&ds.files.length){_files=ds.files; renderFiles();}
  853. } else {
  854. toast(d.error||'Rename failed',false);
  855. }
  856. }catch(e){toast('Error: '+e,false);}
  857. }
  858. // Close modal on background click
  859. document.getElementById('modal').addEventListener('click',function(e){
  860. if(e.target===this) closeModal();
  861. });
  862. // Enter key submits rename
  863. document.getElementById('ren-input').addEventListener('keydown',function(e){
  864. if(e.key==='Enter') doRename();
  865. if(e.key==='Escape') closeModal();
  866. });
  867. // ── Settings tab ─────────────────────────────────────────────────────────────
  868. async function loadDevices(){
  869. if(_devLoaded)return;
  870. try{
  871. var r=await fetch('/api/devices'),d=await r.json();
  872. var sel=document.getElementById('dsel');
  873. sel.innerHTML="<option value='-1'>System Default</option>";
  874. (d.devices||[]).forEach(function(dev){
  875. var o=document.createElement('option');
  876. o.value=dev.index;
  877. o.textContent='['+dev.index+'] '+dev.name+' ('+dev.channels+'ch, '+dev.rate+'Hz)';
  878. if(dev.selected)o.selected=true;
  879. sel.appendChild(o);
  880. });
  881. if(d.resolved_rate){
  882. var sr=document.getElementById('srsel');
  883. for(var i=0;i<sr.options.length;i++){
  884. if(parseInt(sr.options[i].value)===d.resolved_rate){sr.selectedIndex=i;break;}
  885. }
  886. }
  887. if(d.resolved_ch){
  888. var ch=document.getElementById('chsel');
  889. for(var i=0;i<ch.options.length;i++){
  890. if(parseInt(ch.options[i].value)===d.resolved_ch){ch.selectedIndex=i;break;}
  891. }
  892. }
  893. _devLoaded=true;
  894. // Show/hide MP3 based on server capability
  895. if(d.mp3_available===false){
  896. var o=document.getElementById('mp3opt');
  897. if(o){o.disabled=true;o.textContent='MP3 (pip install lameenc)';o.style.color='#446';}
  898. }
  899. }catch(e){
  900. document.getElementById('dsel').innerHTML="<option>Error loading devices</option>";
  901. }
  902. }
  903. function fmtHint(){
  904. var v=document.getElementById('fmsel').value;
  905. var h={WAV:'Lossless, ~10 MB/min',FLAC:'Lossless compressed, ~5 MB/min',
  906. OGG:'Lossy, ~1.5 MB/min',MP3:'Lossy 192kbps, ~1.4 MB/min'};
  907. var el=document.getElementById('fmt-hint');
  908. if(el) el.textContent=h[v]||'';
  909. }
  910. async function applySettings(){
  911. var btn=document.getElementById('abtn');
  912. btn.disabled=true; btn.textContent='Applying...';
  913. var dev=document.getElementById('dsel').value;
  914. var ch=parseInt(document.getElementById('chsel').value);
  915. var sr=parseInt(document.getElementById('srsel').value);
  916. var fm=document.getElementById('fmsel').value;
  917. try{
  918. var r=await fetch('/api/setdevice',{
  919. method:'POST',
  920. headers:{'Content-Type':'application/json'},
  921. body:JSON.stringify({device:dev=='-1'?null:parseInt(dev),channels:ch,samplerate:sr,format:fm})
  922. });
  923. var d=await r.json();
  924. if(d.ok){
  925. toast('Settings saved \\u2713');
  926. document.getElementById('devinfo').textContent='Input: '+d.device_name;
  927. _devLoaded=false;
  928. }else{
  929. toast(d.error||'Failed',false);
  930. }
  931. }catch(e){toast('Error: '+e,false);}
  932. btn.disabled=false; btn.textContent='Apply Settings';
  933. }
  934. // ── Init ─────────────────────────────────────────────────────────────────────
  935. poll();
  936. setInterval(poll,4000);
  937. </script>
  938. </body>
  939. </html>"""
  940. # ─── STARTUP INFO ─────────────────────────────────────────────────────────────
  941. def print_startup_info():
  942. import socket
  943. try:
  944. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  945. s.connect(("8.8.8.8", 80))
  946. ip = s.getsockname()[0]
  947. s.close()
  948. except Exception:
  949. ip = "unknown"
  950. print()
  951. print("=" * 50)
  952. print(" Audio Recorder Server")
  953. print("=" * 50)
  954. print(f" URL : http://{ip}:{PORT}")
  955. print(f" Output : {OUTPUT_DIR.resolve()}")
  956. print(f" Device : {get_device_name()}")
  957. print(f" Format : {FILE_FORMAT} {SAMPLE_RATE}Hz {CHANNELS}ch")
  958. print("=" * 50)
  959. print()
  960. if __name__ == "__main__":
  961. signal.signal(signal.SIGINT, lambda s,f: sys.exit(0))
  962. signal.signal(signal.SIGTERM, lambda s,f: sys.exit(0))
  963. args = init_from_args()
  964. print_startup_info()
  965. app.run(host=args.host, port=PORT, debug=False, threaded=True)