| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057 |
- """
- =============================================================
- Audio Recorder Server for Windows
- Controlled by ESP32-S3 via HTTP or web browser
- =============================================================
- SETUP
- -----
- 1. Install Python 3.8+ from python.org
- 2. Install dependencies:
- pip install flask sounddevice soundfile numpy
- 3. Run:
- python audio_recorder_server.py
- API ENDPOINTS
- -------------
- GET /api/start → Start a new recording
- GET /api/stop → Stop recording
- GET /api/pause → Pause / resume recording
- GET /api/resume → Resume paused recording
- GET /api/save → Save the last stopped recording to disk
- GET /api/status → Get current state, elapsed time, file list
- GET /api/devices → List available audio input devices
- POST /api/setdevice → Set input device {device: index_or_null}
- =============================================================
- """
- import os
- import sys
- import time
- import json
- import queue
- import signal
- import threading
- import argparse
- import datetime
- import numpy as np
- from pathlib import Path
- import sounddevice as sd
- import soundfile as sf
- try:
- import lameenc
- MP3_AVAILABLE = True
- except ImportError:
- MP3_AVAILABLE = False
- print("[Server] lameenc not installed — MP3 encoding unavailable")
- from flask import Flask, jsonify, request, send_from_directory
- # ─── ARGUMENT PARSING ────────────────────────────────────────────────────────
- parser = argparse.ArgumentParser(description="Audio Recorder Server")
- parser.add_argument("--port", type=int, default=5000)
- parser.add_argument("--host", type=str, default="0.0.0.0")
- parser.add_argument("--outdir", type=str, default="./recordings")
- parser.add_argument("--device", type=int, default=None)
- parser.add_argument("--samplerate", type=int, default=44100)
- parser.add_argument("--channels", type=int, default=2)
- parser.add_argument("--format", type=str, default="WAV",
- choices=["WAV", "FLAC", "OGG", "MP3"])
- parser.add_argument("--list-devices", action="store_true")
- # ─── CONFIG GLOBALS ──────────────────────────────────────────────────────────
- OUTPUT_DIR = Path("./recordings")
- SAMPLE_RATE = 44100
- CHANNELS = 2
- DEVICE = None
- FILE_FORMAT = "WAV"
- PORT = 5000
- def init_from_args():
- global OUTPUT_DIR, SAMPLE_RATE, CHANNELS, DEVICE, FILE_FORMAT, PORT
- args = parser.parse_args()
- if args.list_devices:
- print("\n=== Available Audio Input Devices ===\n")
- for i, d in enumerate(sd.query_devices()):
- if d['max_input_channels'] > 0:
- print(f" [{i:2d}] {d['name']}")
- print(f" Channels: {d['max_input_channels']} |"
- f" Rate: {int(d['default_samplerate'])} Hz")
- sys.exit(0)
- OUTPUT_DIR = Path(args.outdir)
- SAMPLE_RATE = args.samplerate
- CHANNELS = args.channels
- DEVICE = args.device
- FILE_FORMAT = args.format.upper()
- PORT = args.port
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- return args
- # ─── HELPERS ─────────────────────────────────────────────────────────────────
- def get_device_name():
- try:
- if DEVICE is None:
- idx = sd.default.device[0]
- return "Default: " + sd.query_devices(idx)['name']
- return sd.query_devices(DEVICE)['name']
- except Exception:
- return "Unknown device"
- # ─── DEVICE CAPABILITY RESOLVER ─────────────────────────────────────────────
- def resolve_device_settings(device, wanted_rate, wanted_ch):
- """
- Query what the device actually supports and return the best
- (sample_rate, channels) we can use without PortAudio rejecting it.
- Falls back gracefully so recording always starts.
- """
- try:
- info = sd.query_devices(device, 'input')
- max_ch = int(info['max_input_channels'])
- default_rate = int(info['default_samplerate'])
- # Clamp channels to what device supports
- actual_ch = min(wanted_ch, max_ch)
- if actual_ch < 1:
- actual_ch = 1
- # Try requested rate first, then device default, then common fallbacks
- candidate_rates = [wanted_rate, default_rate, 48000, 44100, 22050, 16000]
- seen = set()
- for rate in candidate_rates:
- if rate in seen:
- continue
- seen.add(rate)
- try:
- sd.check_input_settings(device=device, channels=actual_ch,
- samplerate=rate, dtype='float32')
- return rate, actual_ch
- except Exception:
- continue
- # Last resort — let sounddevice use whatever it wants
- return default_rate, actual_ch
- except Exception as e:
- print(f"[Resolver] Could not query device {device}: {e} — using defaults")
- return wanted_rate, wanted_ch
- # ─── RECORDER ────────────────────────────────────────────────────────────────
- class RecorderState:
- IDLE = "idle"
- RECORDING = "recording"
- PAUSED = "paused"
- SAVING = "saving"
- class AudioRecorder:
- def __init__(self):
- self.state = RecorderState.IDLE
- self.audio_data = []
- self.stream = None
- self.lock = threading.Lock()
- self.start_time = None
- self.pause_time = None
- self.paused_secs = 0.0
- self.current_file = ""
- self.last_saved = ""
- self.error_msg = ""
- self.level = 0.0 # RMS level 0.0–1.0, updated per callback
- self.peak = 0.0 # peak hold, decays slowly
- self._peak_decay = 0.0
- @property
- def elapsed_seconds(self):
- if self.start_time is None:
- return 0
- if self.state == RecorderState.PAUSED:
- return self.pause_time - self.start_time - self.paused_secs
- if self.state == RecorderState.RECORDING:
- return time.time() - self.start_time - self.paused_secs
- return 0
- @property
- def elapsed_str(self):
- s = int(self.elapsed_seconds)
- return f"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}"
- def _audio_callback(self, indata, frames, time_info, status):
- # Compute RMS level from this block (all channels mixed to mono)
- mono = indata.mean(axis=1) if indata.ndim > 1 else indata[:,0]
- rms = float(np.sqrt(np.mean(mono ** 2)))
- # Convert to 0–1 with a log scale so quiet signals are visible
- # -60 dB floor → 0.0, 0 dB → 1.0
- if rms > 0:
- db = 20 * np.log10(rms + 1e-9)
- level = max(0.0, min(1.0, (db + 60) / 60))
- else:
- level = 0.0
- self.level = level
- # Peak hold — snap up instantly, decay at ~10 dB/s
- if level >= self.peak:
- self.peak = level
- else:
- self.peak = max(0.0, self.peak - 0.012)
- with self.lock:
- if self.state == RecorderState.RECORDING:
- self.audio_data.append(indata.copy())
- def start(self):
- with self.lock:
- if self.state != RecorderState.IDLE:
- return False, f"Cannot start — currently {self.state}"
- try:
- self.audio_data = []
- self.paused_secs = 0.0
- self.start_time = time.time()
- self.pause_time = None
- self.error_msg = ""
- self.current_file = self._make_filename()
- use_device = DEVICE # None = system default
- # Query actual device capabilities and clamp to what it supports
- actual_rate, actual_ch = resolve_device_settings(use_device, SAMPLE_RATE, CHANNELS)
- print(f"[Recorder] Using device={use_device} rate={actual_rate} ch={actual_ch}")
- self.stream = sd.InputStream(
- samplerate = actual_rate,
- channels = actual_ch,
- device = use_device,
- callback = self._audio_callback,
- dtype = 'float32',
- blocksize = 4096
- )
- self.stream.start()
- # Store actual settings used (for correct save)
- self._actual_rate = actual_rate
- self._actual_ch = actual_ch
- with self.lock:
- self.state = RecorderState.RECORDING
- print(f"[Recorder] Started: {self.current_file} device={use_device}")
- return True, "Recording started"
- except Exception as e:
- self.error_msg = str(e)
- print(f"[Recorder] Start error: {e}")
- return False, str(e)
- def pause(self):
- with self.lock:
- if self.state != RecorderState.RECORDING:
- return False, f"Cannot pause — currently {self.state}"
- self.state = RecorderState.PAUSED
- self.pause_time = time.time()
- return True, "Paused"
- def resume(self):
- with self.lock:
- if self.state != RecorderState.PAUSED:
- return False, f"Cannot resume — currently {self.state}"
- self.paused_secs += time.time() - self.pause_time
- self.pause_time = None
- self.state = RecorderState.RECORDING
- return True, "Resumed"
- def stop(self):
- with self.lock:
- if self.state not in (RecorderState.RECORDING, RecorderState.PAUSED):
- return False, f"Cannot stop — currently {self.state}"
- self.state = RecorderState.IDLE
- if self.stream:
- self.stream.stop()
- self.stream.close()
- self.stream = None
- print(f"[Recorder] Stopped. Blocks: {len(self.audio_data)}")
- return True, "Stopped"
- def save(self):
- if not self.audio_data:
- return False, "No audio data to save"
- self.state = RecorderState.SAVING
- def _save_thread():
- try:
- filepath = OUTPUT_DIR / self.current_file
- audio_np = np.concatenate(self.audio_data, axis=0)
- ext = FILE_FORMAT.lower()
- actual_rate = getattr(recorder, '_actual_rate', SAMPLE_RATE)
- actual_ch = getattr(recorder, '_actual_ch', CHANNELS)
- if ext == "mp3":
- if not MP3_AVAILABLE:
- raise Exception("lameenc not installed. Run: pip install lameenc")
- # Convert float32 [-1,1] to int16 PCM for lameenc
- pcm16 = (np.clip(audio_np, -1.0, 1.0) * 32767).astype(np.int16)
- enc = lameenc.Encoder()
- enc.set_bit_rate(192) # 192 kbps — good quality/size balance
- enc.set_in_sample_rate(actual_rate)
- enc.set_channels(actual_ch)
- enc.set_quality(2) # 2=high quality, 7=fastest
- mp3_data = enc.encode(pcm16.tobytes()) + enc.flush()
- with open(str(filepath), 'wb') as mp3f:
- mp3f.write(mp3_data)
- else:
- fmt_map = {"wav": ("WAV", "PCM_16"),
- "flac": ("FLAC", "PCM_16"),
- "ogg": ("OGG", "VORBIS")}
- fmt, sub = fmt_map.get(ext, ("WAV", "PCM_16"))
- sf.write(str(filepath), audio_np, actual_rate,
- format=fmt, subtype=sub)
- kb = filepath.stat().st_size // 1024
- self.last_saved = self.current_file
- self.audio_data = []
- self.state = RecorderState.IDLE
- print(f"[Recorder] Saved: {filepath} ({kb} KB)")
- except Exception as e:
- self.error_msg = str(e)
- self.state = RecorderState.IDLE
- print(f"[Recorder] Save error: {e}")
- threading.Thread(target=_save_thread, daemon=True).start()
- return True, f"Saving {self.current_file}"
- def list_files(self):
- files = []
- for f in sorted(OUTPUT_DIR.iterdir(), reverse=True):
- if f.suffix.lower() in {".wav", ".flac", ".ogg", ".mp3"}:
- kb = f.stat().st_size // 1024
- size_str = f"{kb} KB" if kb < 1024 else f"{kb//1024:.1f} MB"
- files.append({
- "name": f.name,
- "size": size_str,
- "modified": datetime.datetime.fromtimestamp(
- f.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
- })
- return files[:50]
- def status_dict(self):
- return {
- "state": self.state,
- "elapsed": self.elapsed_str,
- "elapsed_seconds": int(self.elapsed_seconds),
- "file": self.current_file,
- "last_saved": self.last_saved,
- "error": self.error_msg,
- "device": DEVICE,
- "device_name": get_device_name(),
- "mp3_available": MP3_AVAILABLE,
- "files": self.list_files()
- }
- def _make_filename(self):
- now = datetime.datetime.now()
- day = now.strftime("%a").upper() # MON, TUE, WED ...
- ts = now.strftime(f"%Y%m%d_{day}_%H%M")
- return f"{ts}.{FILE_FORMAT.lower()}"
- # ─── FLASK APP ────────────────────────────────────────────────────────────────
- app = Flask(__name__)
- recorder = AudioRecorder()
- import logging
- logging.getLogger('werkzeug').setLevel(logging.WARNING)
- # ─── API ROUTES ───────────────────────────────────────────────────────────────
- @app.route("/api/start")
- def api_start():
- ok, msg = recorder.start()
- r = recorder.status_dict(); r["message"] = msg
- return jsonify(r), (200 if ok else 400)
- @app.route("/api/stop")
- def api_stop():
- ok, msg = recorder.stop()
- r = recorder.status_dict(); r["message"] = msg
- return jsonify(r), (200 if ok else 400)
- @app.route("/api/pause")
- def api_pause():
- if recorder.state == RecorderState.RECORDING:
- ok, msg = recorder.pause()
- elif recorder.state == RecorderState.PAUSED:
- ok, msg = recorder.resume()
- else:
- ok, msg = False, f"Cannot pause — currently {recorder.state}"
- r = recorder.status_dict(); r["message"] = msg
- return jsonify(r), (200 if ok else 400)
- @app.route("/api/resume")
- def api_resume():
- ok, msg = recorder.resume()
- r = recorder.status_dict(); r["message"] = msg
- return jsonify(r), (200 if ok else 400)
- @app.route("/api/save")
- def api_save():
- if recorder.state in (RecorderState.RECORDING, RecorderState.PAUSED):
- recorder.stop()
- ok, msg = recorder.save()
- r = recorder.status_dict(); r["message"] = msg
- return jsonify(r), (200 if ok else 400)
- @app.route("/api/status")
- def api_status():
- return jsonify(recorder.status_dict())
- @app.route("/api/level")
- def api_level():
- """Lightweight level poll — returns current RMS + peak (0.0–1.0)."""
- return jsonify({
- "level": round(recorder.level, 4),
- "peak": round(recorder.peak, 4),
- "state": recorder.state
- })
- @app.route("/api/devices")
- def api_devices():
- devices = []
- for i, d in enumerate(sd.query_devices()):
- if d['max_input_channels'] > 0:
- devices.append({
- "index": i,
- "name": d['name'],
- "channels": int(d['max_input_channels']),
- "rate": int(d['default_samplerate']),
- "selected": (DEVICE == i)
- })
- # Also include resolved settings for current device
- r, ch = resolve_device_settings(DEVICE, SAMPLE_RATE, CHANNELS)
- return jsonify({"devices": devices, "current": DEVICE,
- "current_name": get_device_name(),
- "resolved_rate": r, "resolved_ch": ch})
- @app.route("/api/setdevice", methods=["POST"])
- def api_set_device():
- global DEVICE
- if recorder.state != RecorderState.IDLE:
- return jsonify({"ok": False,
- "error": "Cannot change device while recording"}), 400
- data = request.get_json(force=True, silent=True) or {}
- raw = data.get("device", None)
- if raw is None or raw == "" or str(raw) == "-1":
- DEVICE = None
- name = get_device_name()
- else:
- try:
- idx = int(raw)
- sd.query_devices(idx, 'input')
- DEVICE = idx
- name = sd.query_devices(idx)['name']
- except Exception as e:
- return jsonify({"ok": False, "error": str(e)}), 400
- # Optional: also update channels/samplerate/format
- global CHANNELS, SAMPLE_RATE, FILE_FORMAT
- data2 = data # already parsed above
- if "channels" in data2:
- CHANNELS = int(data2["channels"])
- if "samplerate" in data2:
- SAMPLE_RATE = int(data2["samplerate"])
- if "format" in data2 and data2["format"] in ("WAV","FLAC","OGG","MP3"):
- FILE_FORMAT = data2["format"]
- print(f"[Server] Device={name} ch={CHANNELS} rate={SAMPLE_RATE} fmt={FILE_FORMAT}")
- return jsonify({"ok": True, "device": DEVICE, "device_name": name})
- @app.route("/recordings/<path:filename>")
- def serve_recording(filename):
- return send_from_directory(str(OUTPUT_DIR), filename, as_attachment=True)
- @app.route("/api/rename", methods=["POST"])
- def api_rename():
- data = request.get_json(force=True, silent=True) or {}
- old_name = data.get("old", "").strip()
- new_name = data.get("new", "").strip()
- if not old_name or not new_name:
- return jsonify({"ok": False, "error": "Missing filename"}), 400
- # Sanitise — no path separators allowed
- for ch in ("/", "\\", "..", ":"):
- if ch in new_name:
- return jsonify({"ok": False, "error": "Invalid filename"}), 400
- old_path = OUTPUT_DIR / old_name
- # Preserve extension from original file
- ext = Path(old_name).suffix
- if not new_name.endswith(ext):
- new_name = new_name + ext
- new_path = OUTPUT_DIR / new_name
- if not old_path.exists():
- return jsonify({"ok": False, "error": "File not found"}), 404
- if new_path.exists():
- return jsonify({"ok": False, "error": "A file with that name already exists"}), 409
- old_path.rename(new_path)
- print(f"[Server] Renamed: {old_name} -> {new_name}")
- return jsonify({"ok": True, "new_name": new_name})
- # ─── WEB UI ───────────────────────────────────────────────────────────────────
- @app.route("/")
- def index():
- # Return raw string — do NOT use render_template_string as Jinja2
- # will try to parse CSS/JS curly braces as template variables
- html = build_ui_html()
- return html, 200, {"Content-Type": "text/html; charset=utf-8"}
- def build_ui_html():
- """Build the web UI — plain string, no Jinja2."""
- return """\
- <!DOCTYPE html>
- <html lang='en'>
- <head>
- <meta charset='UTF-8'>
- <meta name='viewport' content='width=device-width,initial-scale=1'>
- <title>Audio Recorder</title>
- <style>
- *{box-sizing:border-box;margin:0;padding:0}
- body{font-family:'Segoe UI',sans-serif;background:#1a1a2e;color:#eee;
- min-height:100vh;display:flex;flex-direction:column;align-items:center;padding:20px 16px}
- h1{color:#e94560;font-size:1.5em;letter-spacing:2px;margin-bottom:2px}
- .sub{color:#446;font-size:.78em;margin-bottom:14px}
- .tabs{display:flex;gap:4px;margin-bottom:14px;width:100%;max-width:500px}
- .tab{flex:1;padding:9px;text-align:center;border-radius:8px;cursor:pointer;
- font-size:.85em;font-weight:bold;border:none;transition:background .2s}
- .tab.on{background:#e94560;color:#fff}
- .tab:not(.on){background:#16213e;color:#667}
- .tab:not(.on):hover{background:#1e2d50;color:#a8dadc}
- .pg{display:none;width:100%;max-width:500px}
- .pg.on{display:block}
- .card{background:#16213e;border-radius:14px;padding:18px;margin-bottom:14px}
- /* Pill */
- #pill{display:inline-block;padding:4px 14px;border-radius:20px;font-weight:bold;
- font-size:.82em;text-transform:uppercase;letter-spacing:1px}
- .p-idle{background:#333;color:#888}
- .p-recording{background:#e94560;color:#fff;animation:blink 1.4s infinite}
- .p-paused{background:#f4a261;color:#1a1a2e}
- .p-saving{background:#457b9d;color:#fff}
- @keyframes blink{0%,100%{opacity:1}50%{opacity:.5}}
- /* Timer */
- #timer{font-size:3em;font-family:monospace;color:#a8dadc;letter-spacing:4px;
- text-align:center;padding:8px 0 4px}
- #fname{font-size:.75em;color:#446;text-align:center;min-height:1.2em}
- #msg{font-size:.78em;min-height:1.3em;color:#f4a261;text-align:center;margin-top:2px}
- /* Visualiser */
- #viz-wrap{height:54px;display:flex;align-items:flex-end;justify-content:center;
- gap:3px;padding:6px 0;opacity:0.3;transition:opacity .4s}
- #viz-wrap.active{opacity:1}
- .vbar{width:6px;background:#a8dadc;border-radius:3px 3px 0 0;
- min-height:3px;transition:height .07s ease-out}
- /* Buttons */
- .grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px}
- .span2{grid-column:span 2}
- button{border:none;border-radius:9px;padding:13px;font-size:.95em;
- font-weight:bold;cursor:pointer;transition:transform .1s,opacity .2s}
- button:active{transform:scale(.97)}
- button:disabled{opacity:.3;cursor:not-allowed}
- .bs{background:#2dc653;color:#fff}
- .bp{background:#f4a261;color:#1a1a2e}
- .bx{background:#e94560;color:#fff}
- .bv{background:#457b9d;color:#fff}
- /* Settings */
- .srow{display:flex;justify-content:space-between;align-items:center;
- padding:12px 0;border-bottom:1px solid #0f3460}
- .srow:last-child{border-bottom:none}
- .slbl{font-size:.88em;color:#a8dadc}
- .ssub{font-size:.73em;color:#446;margin-top:2px}
- select{background:#0f3460;color:#eee;border:1px solid #1e3a5f;
- border-radius:6px;padding:7px 10px;font-size:.85em;cursor:pointer;max-width:210px}
- .abtn{width:100%;margin-top:4px;padding:12px;background:#e94560;color:#fff;
- border:none;border-radius:9px;font-size:.95em;font-weight:bold;cursor:pointer}
- .abtn:disabled{opacity:.4;cursor:not-allowed}
- /* Files */
- .stitle{font-size:.73em;text-transform:uppercase;letter-spacing:1px;
- color:#a8dadc;margin-bottom:10px}
- .frow{display:flex;align-items:center;padding:9px 0;
- border-bottom:1px solid #0f3460;font-size:.83em;gap:6px}
- .frow:last-child{border-bottom:none}
- .fn{color:#ccc;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
- .fm{color:#446;font-size:.76em;white-space:nowrap}
- .ficn{background:none;border:none;cursor:pointer;font-size:1em;padding:2px 4px;
- color:#a8dadc;border-radius:4px}
- .ficn:hover{background:#0f3460}
- .empty{color:#334;font-size:.83em;padding:8px 0}
- /* Rename modal */
- .modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);
- z-index:100;align-items:center;justify-content:center}
- .modal-bg.show{display:flex}
- .modal{background:#16213e;border-radius:14px;padding:24px;width:90%;max-width:380px}
- .modal h3{color:#e94560;margin-bottom:14px;font-size:1em}
- .modal input{width:100%;background:#0f3460;color:#eee;border:1px solid #1e3a5f;
- border-radius:7px;padding:10px;font-size:.95em;margin-bottom:12px}
- .modal input:focus{outline:none;border-color:#e94560}
- .mrow{display:flex;gap:8px}
- .mrow button{flex:1;padding:10px;border:none;border-radius:8px;
- font-weight:bold;cursor:pointer;font-size:.9em}
- .mok{background:#e94560;color:#fff}
- .mcancel{background:#0f3460;color:#a8dadc}
- /* Toast */
- .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);
- background:#2dc653;color:#fff;padding:10px 24px;border-radius:10px;
- font-size:.88em;font-weight:bold;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200}
- .toast.show{opacity:1}
- #devinfo{font-size:.75em;color:#446;margin-top:8px;text-align:center}
- </style>
- </head>
- <body>
- <h1>🎤 Audio Recorder</h1>
- <div class='sub'>Windows PC Server</div>
- <div class='tabs'>
- <button class='tab on' id='t0' onclick='tab(0)'>▶ Recorder</button>
- <button class='tab' id='t1' onclick='tab(1)'>💾 Files</button>
- <button class='tab' id='t2' onclick='tab(2)'>⚙ Settings</button>
- </div>
- <!-- RECORDER TAB -->
- <div class='pg on' id='p0'>
- <div class='card'>
- <div style='display:flex;justify-content:space-between;align-items:center'>
- <span id='pill' class='p-idle'>Idle</span>
- <span id='fname'> </span>
- </div>
- <div id='timer'>00:00:00</div>
- <div id='msg'> </div>
- <!-- Audio visualiser: 24 bars -->
- <div id='viz-wrap'>
- <div class='vbar' id='vb0'></div><div class='vbar' id='vb1'></div>
- <div class='vbar' id='vb2'></div><div class='vbar' id='vb3'></div>
- <div class='vbar' id='vb4'></div><div class='vbar' id='vb5'></div>
- <div class='vbar' id='vb6'></div><div class='vbar' id='vb7'></div>
- <div class='vbar' id='vb8'></div><div class='vbar' id='vb9'></div>
- <div class='vbar' id='vb10'></div><div class='vbar' id='vb11'></div>
- <div class='vbar' id='vb12'></div><div class='vbar' id='vb13'></div>
- <div class='vbar' id='vb14'></div><div class='vbar' id='vb15'></div>
- <div class='vbar' id='vb16'></div><div class='vbar' id='vb17'></div>
- <div class='vbar' id='vb18'></div><div class='vbar' id='vb19'></div>
- <div class='vbar' id='vb20'></div><div class='vbar' id='vb21'></div>
- <div class='vbar' id='vb22'></div><div class='vbar' id='vb23'></div>
- </div>
- <div class='grid'>
- <button class='bs span2' id='b0' onclick="cmd('start')">▶ START</button>
- <button class='bp' id='b1' onclick="cmd('pause')" disabled>▮▮ PAUSE</button>
- <button class='bx' id='b2' onclick="cmd('stop')" disabled>■ STOP</button>
- <button class='bv span2' id='b3' onclick="cmd('save')" disabled>💾 STOP & SAVE</button>
- </div>
- </div>
- <div id='devinfo'>Input: loading...</div>
- </div>
- <!-- FILES TAB -->
- <div class='pg' id='p1'>
- <div class='card'>
- <div class='stitle'>Saved Recordings</div>
- <div id='flist'><div class='empty'>No recordings yet</div></div>
- </div>
- </div>
- <!-- SETTINGS TAB -->
- <div class='pg' id='p2'>
- <div class='card'>
- <div class='stitle'>Audio Input Device</div>
- <div class='srow'>
- <div><div class='slbl'>Input Device</div>
- <div class='ssub'>Microphone, line-in, or Stereo Mix</div></div>
- <select id='dsel'><option value='-1'>Loading...</option></select>
- </div>
- <div class='srow'>
- <div><div class='slbl'>Channels</div></div>
- <select id='chsel'>
- <option value='1'>Mono</option>
- <option value='2' selected>Stereo</option>
- </select>
- </div>
- <div class='srow'>
- <div><div class='slbl'>Sample Rate</div></div>
- <select id='srsel'>
- <option value='22050'>22050 Hz</option>
- <option value='44100' selected>44100 Hz</option>
- <option value='48000'>48000 Hz</option>
- </select>
- </div>
- <div class='srow'>
- <div><div class='slbl'>File Format</div>
- <div class='ssub' id='fmt-hint'>WAV = lossless, MP3 = ~10x smaller</div></div>
- <select id='fmsel' onchange='fmtHint()'>
- <option value='WAV' selected>WAV (lossless)</option>
- <option value='FLAC'>FLAC (lossless compressed)</option>
- <option value='OGG'>OGG Vorbis</option>
- <option value='MP3' id='mp3opt'>MP3 192kbps</option>
- </select>
- </div>
- </div>
- <div class='card' style='font-size:.8em;color:#557;line-height:1.7'>
- <div class='stitle'>Tip: Capture System Audio</div>
- To record audio playing on your PC, enable <b style='color:#a8dadc'>Stereo Mix</b>
- in Windows: Right-click speaker → Sounds → Recording tab →
- right-click empty area → Show Disabled Devices → enable Stereo Mix.
- Then select it above.
- </div>
- <button class='abtn' id='abtn' onclick='applySettings()'>Apply Settings</button>
- </div>
- <!-- RENAME MODAL -->
- <div class='modal-bg' id='modal'>
- <div class='modal'>
- <h3>✎ Rename Recording</h3>
- <input type='text' id='ren-input' placeholder='New filename (no extension)'>
- <div class='mrow'>
- <button class='mok' onclick='doRename()'>Rename</button>
- <button class='mcancel' onclick='closeModal()'>Cancel</button>
- </div>
- </div>
- </div>
- <div class='toast' id='toast'></div>
- <script>
- // ── State ────────────────────────────────────────────────────────────────────
- var _files=[], _devLoaded=false, _localStart=null, _tick=null;
- var _renameTarget='', _renameExt='';
- var _vizCtx=null, _vizAnalyser=null, _vizSrc=null, _vizStream=null, _vizAnim=null;
- var _vizBars=[];
- // ── Tabs ─────────────────────────────────────────────────────────────────────
- function tab(n){
- for(var i=0;i<3;i++){
- document.getElementById('p'+i).className='pg'+(i==n?' on':'');
- document.getElementById('t'+i).className='tab'+(i==n?' on':'');
- }
- if(n==1) renderFiles();
- if(n==2) loadDevices();
- }
- // ── Toast ────────────────────────────────────────────────────────────────────
- function toast(m,ok){
- var e=document.getElementById('toast');
- e.textContent=m; e.style.background=(ok===false)?'#e94560':'#2dc653';
- e.className='toast show'; setTimeout(function(){e.className='toast';},2800);
- }
- // ── Time format ──────────────────────────────────────────────────────────────
- function fmtTime(s){
- return String(Math.floor(s/3600)).padStart(2,'0')+':'+
- String(Math.floor((s%3600)/60)).padStart(2,'0')+':'+
- String(s%60).padStart(2,'0');
- }
- // ── API commands ─────────────────────────────────────────────────────────────
- async function cmd(a){
- document.getElementById('msg').textContent='...';
- try{
- var r=await fetch('/api/'+a), d=await r.json();
- document.getElementById('msg').textContent=d.message||'';
- apply(d);
- }catch(e){document.getElementById('msg').textContent='Error: '+e;}
- }
- async function poll(){
- try{var r=await fetch('/api/status'),d=await r.json();apply(d);}catch(e){}
- }
- function apply(d){
- var s=d.state||'idle';
- var pill=document.getElementById('pill');
- pill.textContent=s.toUpperCase(); pill.className='p-'+s;
- document.getElementById('fname').textContent=d.file||'\\u00a0';
- if(d.device_name) document.getElementById('devinfo').textContent='Input: '+d.device_name;
- var idle=s=='idle', busy=s=='saving';
- document.getElementById('b0').disabled=!idle;
- document.getElementById('b1').disabled=idle||busy;
- document.getElementById('b2').disabled=idle||busy;
- document.getElementById('b3').disabled=idle||busy;
- document.getElementById('b1').textContent=s=='paused'?'\\u25b6 RESUME':'\\u2016 PAUSE';
- clearInterval(_tick);
- if(s=='recording'){
- _localStart=Date.now()-(d.elapsed_seconds*1000);
- _tick=setInterval(function(){
- document.getElementById('timer').textContent=
- fmtTime(Math.floor((Date.now()-_localStart)/1000));
- },500);
- startViz();
- } else {
- document.getElementById('timer').textContent=d.elapsed||'00:00:00';
- if(s=='idle'||s=='paused') stopViz(s=='idle');
- }
- if(d.files&&d.files.length) _files=d.files;
- }
- // ── Audio Visualiser ─────────────────────────────────────────────────────────
- // Strategy:
- // localhost → getUserMedia (Web Audio API, real frequency data, high refresh)
- // remote → poll /api/level every 80ms (server-side RMS, works everywhere)
- var _isLocalhost = (location.hostname==='localhost'||location.hostname==='127.0.0.1');
- var _levelPoll = null;
- var BARS = 24, MAXH = 46;
- function getVizBars(){
- if(_vizBars.length) return _vizBars;
- for(var i=0;i<BARS;i++) _vizBars.push(document.getElementById('vb'+i));
- return _vizBars;
- }
- function colourBar(v){
- var r=Math.round(Math.min(255,v*2*255));
- var g=Math.round(Math.min(255,(1-v)*2*255));
- return 'rgb('+r+','+g+',80)';
- }
- // ── Localhost: getUserMedia + Web Audio API ───────────────────────────────────
- function startVizLocal(){
- if(_vizCtx) return;
- document.getElementById('viz-wrap').classList.add('active');
- navigator.mediaDevices.getUserMedia({audio:true,video:false})
- .then(function(stream){
- _vizStream=stream;
- _vizCtx=new (window.AudioContext||window.webkitAudioContext)();
- _vizAnalyser=_vizCtx.createAnalyser();
- _vizAnalyser.fftSize=64;
- _vizSrc=_vizCtx.createMediaStreamSource(stream);
- _vizSrc.connect(_vizAnalyser);
- var buf=new Uint8Array(_vizAnalyser.frequencyBinCount);
- var bars=getVizBars();
- function draw(){
- _vizAnim=requestAnimationFrame(draw);
- _vizAnalyser.getByteFrequencyData(buf);
- for(var i=0;i<BARS;i++){
- var bi=Math.floor((i/BARS)*buf.length);
- var v=Math.max(buf[bi]/255, 0.03+Math.random()*0.03);
- var h=Math.max(3,Math.round(v*MAXH));
- bars[i].style.height=h+'px';
- bars[i].style.background=colourBar(v);
- }
- }
- draw();
- })
- .catch(function(){ startVizRemote(); }); // fallback if mic denied
- }
- // ── Remote: poll /api/level ───────────────────────────────────────────────────
- // Spreads a single RMS value across all bars with natural-looking variation
- var _lvlHistory = new Array(BARS).fill(0); // per-bar smoothed value
- var _peakHistory= new Array(BARS).fill(0);
- function startVizRemote(){
- if(_levelPoll) return;
- document.getElementById('viz-wrap').classList.add('active');
- var bars=getVizBars();
- _levelPoll = setInterval(function(){
- fetch('/api/level').then(function(r){return r.json();})
- .then(function(d){
- var lvl = d.level || 0;
- var peak = d.peak || 0;
- // Distribute level across bars: centre bars get full level,
- // edge bars slightly lower — gives a natural spectrum shape
- for(var i=0;i<BARS;i++){
- var pos = Math.abs(i - (BARS/2 - 0.5)) / (BARS/2); // 0=centre 1=edge
- var noise = (Math.random()-0.5)*0.08;
- var target= Math.max(0, lvl * (1 - pos*0.45) + noise);
- // Smooth: fast attack, slow decay
- if(target > _lvlHistory[i]) _lvlHistory[i] = target;
- else _lvlHistory[i] = _lvlHistory[i]*0.72 + target*0.28;
- var v = Math.max(0.02, _lvlHistory[i]);
- var h = Math.max(3, Math.round(v*MAXH));
- bars[i].style.height=h+'px';
- bars[i].style.background=colourBar(v);
- }
- }).catch(function(){});
- }, 80);
- }
- function startViz(){
- if(_isLocalhost) startVizLocal();
- else startVizRemote();
- }
- function stopViz(reset){
- // Stop local viz
- if(_vizAnim){ cancelAnimationFrame(_vizAnim); _vizAnim=null; }
- if(_vizSrc){ try{_vizSrc.disconnect();}catch(e){} _vizSrc=null; }
- if(_vizCtx){ try{_vizCtx.close();}catch(e){} _vizCtx=null; }
- if(_vizStream){ _vizStream.getTracks().forEach(function(t){t.stop();}); _vizStream=null; }
- _vizAnalyser=null;
- // Stop remote poll
- if(_levelPoll){ clearInterval(_levelPoll); _levelPoll=null; }
- var bars=getVizBars();
- if(reset){
- document.getElementById('viz-wrap').classList.remove('active');
- _lvlHistory.fill(0); _peakHistory.fill(0);
- for(var i=0;i<BARS;i++){bars[i].style.height='3px';bars[i].style.background='#a8dadc';}
- }
- }
- // ── Files tab ────────────────────────────────────────────────────────────────
- function renderFiles(){
- var el=document.getElementById('flist');
- if(!_files.length){el.innerHTML="<div class='empty'>No recordings yet</div>";return;}
- el.innerHTML=_files.map(function(f){
- var stem=f.name.replace(/\\.[^.]+$/,'');
- return "<div class='frow'>"
- +"<span class='fn' title='"+f.name+"'>"+f.name+"</span>"
- +"<span class='fm'>"+f.size+"</span>"
- +"<button class='ficn' title='Rename' onclick='openModal(\\'"+f.name+"\\')'>✎</button>"
- +"<a class='ficn' title='Download' href='/recordings/"+encodeURIComponent(f.name)+"' download>⬇</a>"
- +"</div>";
- }).join('');
- }
- // ── Rename modal ─────────────────────────────────────────────────────────────
- function openModal(fname){
- _renameTarget=fname;
- _renameExt=fname.match(/\\.[^.]+$/)?fname.match(/\\.[^.]+$/)[0]:'';
- var stem=fname.replace(/\\.[^.]+$/,'');
- document.getElementById('ren-input').value=stem;
- document.getElementById('modal').className='modal-bg show';
- setTimeout(function(){
- var inp=document.getElementById('ren-input');
- inp.focus(); inp.select();
- },80);
- }
- function closeModal(){
- document.getElementById('modal').className='modal-bg';
- _renameTarget='';
- }
- async function doRename(){
- var newStem=document.getElementById('ren-input').value.trim();
- if(!newStem){toast('Enter a filename',false);return;}
- try{
- var r=await fetch('/api/rename',{
- method:'POST',
- headers:{'Content-Type':'application/json'},
- body:JSON.stringify({old:_renameTarget, new:newStem})
- });
- var d=await r.json();
- if(d.ok){
- toast('Renamed to '+d.new_name);
- closeModal();
- // Refresh file list from server
- var rs=await fetch('/api/status');
- var ds=await rs.json();
- if(ds.files&&ds.files.length){_files=ds.files; renderFiles();}
- } else {
- toast(d.error||'Rename failed',false);
- }
- }catch(e){toast('Error: '+e,false);}
- }
- // Close modal on background click
- document.getElementById('modal').addEventListener('click',function(e){
- if(e.target===this) closeModal();
- });
- // Enter key submits rename
- document.getElementById('ren-input').addEventListener('keydown',function(e){
- if(e.key==='Enter') doRename();
- if(e.key==='Escape') closeModal();
- });
- // ── Settings tab ─────────────────────────────────────────────────────────────
- async function loadDevices(){
- if(_devLoaded)return;
- try{
- var r=await fetch('/api/devices'),d=await r.json();
- var sel=document.getElementById('dsel');
- sel.innerHTML="<option value='-1'>System Default</option>";
- (d.devices||[]).forEach(function(dev){
- var o=document.createElement('option');
- o.value=dev.index;
- o.textContent='['+dev.index+'] '+dev.name+' ('+dev.channels+'ch, '+dev.rate+'Hz)';
- if(dev.selected)o.selected=true;
- sel.appendChild(o);
- });
- if(d.resolved_rate){
- var sr=document.getElementById('srsel');
- for(var i=0;i<sr.options.length;i++){
- if(parseInt(sr.options[i].value)===d.resolved_rate){sr.selectedIndex=i;break;}
- }
- }
- if(d.resolved_ch){
- var ch=document.getElementById('chsel');
- for(var i=0;i<ch.options.length;i++){
- if(parseInt(ch.options[i].value)===d.resolved_ch){ch.selectedIndex=i;break;}
- }
- }
- _devLoaded=true;
- // Show/hide MP3 based on server capability
- if(d.mp3_available===false){
- var o=document.getElementById('mp3opt');
- if(o){o.disabled=true;o.textContent='MP3 (pip install lameenc)';o.style.color='#446';}
- }
- }catch(e){
- document.getElementById('dsel').innerHTML="<option>Error loading devices</option>";
- }
- }
- function fmtHint(){
- var v=document.getElementById('fmsel').value;
- var h={WAV:'Lossless, ~10 MB/min',FLAC:'Lossless compressed, ~5 MB/min',
- OGG:'Lossy, ~1.5 MB/min',MP3:'Lossy 192kbps, ~1.4 MB/min'};
- var el=document.getElementById('fmt-hint');
- if(el) el.textContent=h[v]||'';
- }
- async function applySettings(){
- var btn=document.getElementById('abtn');
- btn.disabled=true; btn.textContent='Applying...';
- var dev=document.getElementById('dsel').value;
- var ch=parseInt(document.getElementById('chsel').value);
- var sr=parseInt(document.getElementById('srsel').value);
- var fm=document.getElementById('fmsel').value;
- try{
- var r=await fetch('/api/setdevice',{
- method:'POST',
- headers:{'Content-Type':'application/json'},
- body:JSON.stringify({device:dev=='-1'?null:parseInt(dev),channels:ch,samplerate:sr,format:fm})
- });
- var d=await r.json();
- if(d.ok){
- toast('Settings saved \\u2713');
- document.getElementById('devinfo').textContent='Input: '+d.device_name;
- _devLoaded=false;
- }else{
- toast(d.error||'Failed',false);
- }
- }catch(e){toast('Error: '+e,false);}
- btn.disabled=false; btn.textContent='Apply Settings';
- }
- // ── Init ─────────────────────────────────────────────────────────────────────
- poll();
- setInterval(poll,4000);
- </script>
- </body>
- </html>"""
- # ─── STARTUP INFO ─────────────────────────────────────────────────────────────
- def print_startup_info():
- import socket
- try:
- s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- s.connect(("8.8.8.8", 80))
- ip = s.getsockname()[0]
- s.close()
- except Exception:
- ip = "unknown"
- print()
- print("=" * 50)
- print(" Audio Recorder Server")
- print("=" * 50)
- print(f" URL : http://{ip}:{PORT}")
- print(f" Output : {OUTPUT_DIR.resolve()}")
- print(f" Device : {get_device_name()}")
- print(f" Format : {FILE_FORMAT} {SAMPLE_RATE}Hz {CHANNELS}ch")
- print("=" * 50)
- print()
- if __name__ == "__main__":
- signal.signal(signal.SIGINT, lambda s,f: sys.exit(0))
- signal.signal(signal.SIGTERM, lambda s,f: sys.exit(0))
- args = init_from_args()
- print_startup_info()
- app.run(host=args.host, port=PORT, debug=False, threaded=True)
|