from __future__ import annotations from dataclasses import asdict from pathlib import Path import os from flask import Flask, abort, jsonify, render_template, request, send_from_directory from flask_socketio import SocketIO, emit from audio_capture import AudioEngine from recorder import list_recordings, resolve_recording_path from stt_bridge import SttBridge BASE_DIR = Path(__file__).resolve().parent RECORDINGS_DIR = BASE_DIR / "recordings" app = Flask(__name__, template_folder="templates", static_folder="static") app.config["SECRET_KEY"] = os.environ.get("MIC_SYSTEM_SECRET", "mic-system-dev") socketio = SocketIO(app, cors_allowed_origins="*", async_mode="eventlet") audio_engine = AudioEngine(RECORDINGS_DIR) def _stt_message_callback(msg): """Called when STT worker posts a result via /internal/stt.""" socketio.emit("stt_message", msg) stt_bridge = SttBridge(on_message=_stt_message_callback) # Attach STT bridge to audio engine so it receives processed audio audio_engine.set_stt_bridge(stt_bridge) _stream_task_started = False audio_start_error = "" def ensure_audio_started() -> None: global audio_start_error if audio_engine.is_running(): return try: audio_engine.start() audio_start_error = "" except Exception as exc: # pragma: no cover - runtime environment specific audio_start_error = str(exc) def stream_audio_packets() -> None: while True: packet = audio_engine.get_latest_packet() socketio.emit("audio_data", packet) socketio.sleep(0.08) @app.route("/") def index() -> str: ensure_audio_started() return render_template("index.html") @app.route("/api/status") def api_status(): ensure_audio_started() status = audio_engine.get_status() status["audio_error"] = audio_start_error status.update(stt_bridge.get_settings()) return jsonify(status) @app.route("/internal/stt", methods=["POST"]) def internal_stt(): """Receives JSON messages from stt_worker subprocess via HTTP POST.""" msg = request.get_json(silent=True) if msg: stt_bridge.handle_worker_message(msg) return "", 204 @app.route("/api/recordings") def api_list_recordings(): return jsonify(list_recordings(RECORDINGS_DIR)) @app.route("/api/recordings/") def api_get_recording(filename: str): try: path = resolve_recording_path(RECORDINGS_DIR, filename) except ValueError: abort(400, description="Invalid filename") if not path.exists(): abort(404) return send_from_directory(RECORDINGS_DIR, path.name, as_attachment=True) @app.route("/api/recordings/", methods=["DELETE"]) def api_delete_recording(filename: str): try: path = resolve_recording_path(RECORDINGS_DIR, filename) except ValueError: abort(400, description="Invalid filename") if not path.exists(): abort(404) path.unlink() socketio.emit("recordings_updated", {"ok": True}) return jsonify({"ok": True}) def _handle_command(payload: dict) -> dict: msg_type = payload.get("type") if msg_type == "settings": settings = audio_engine.update_settings(payload) stt_keys = {k: v for k, v in payload.items() if k.startswith("stt_")} if stt_keys: stt_settings = stt_bridge.update_settings(**stt_keys) settings.update(stt_settings) return {"type": "settings_applied", "settings": settings} if msg_type == "stt_settings": stt_keys = {k: v for k, v in payload.items() if k.startswith("stt_")} stt_settings = stt_bridge.update_settings(**stt_keys) return {"type": "stt_settings_applied", "settings": stt_settings} if msg_type == "record_start": source = str(payload.get("source", "mic1")) duration_sec = payload.get("duration_sec") record_info = audio_engine.start_recording(source, duration_sec=duration_sec) socketio.emit("recordings_updated", {"ok": True}) return {"type": "record_started", **record_info} if msg_type == "record_stop": status = audio_engine.stop_recording() socketio.emit("recordings_updated", {"ok": True}) return {"type": "record_stopped", "status": asdict(status)} if msg_type == "delete_recording": filename = str(payload.get("filename", "")) if not filename: raise ValueError("Missing filename") path = resolve_recording_path(RECORDINGS_DIR, filename) if path.exists(): path.unlink() socketio.emit("recordings_updated", {"ok": True}) return {"type": "recording_deleted", "filename": filename} raise ValueError("Unsupported message type: " + str(msg_type)) @socketio.on("connect") def ws_connect(): global _stream_task_started ensure_audio_started() if not _stream_task_started: socketio.start_background_task(stream_audio_packets) _stream_task_started = True status = audio_engine.get_status() status.update(stt_bridge.get_settings()) emit("status", status) if audio_start_error: emit("server_error", {"message": audio_start_error}) @socketio.on("client_message") def ws_client_message(payload): if not isinstance(payload, dict): emit("server_error", {"message": "Invalid payload"}) return try: response = _handle_command(payload) except Exception as exc: emit("server_error", {"message": str(exc)}) return emit("server_ack", response) @socketio.on("disconnect") def ws_disconnect(): return if __name__ == "__main__": ensure_audio_started() host = os.environ.get("MIC_SYSTEM_HOST", "0.0.0.0") port = int(os.environ.get("MIC_SYSTEM_PORT", "5000")) socketio.run(app, host=host, port=port)