app.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. from __future__ import annotations
  2. from dataclasses import asdict
  3. from pathlib import Path
  4. import os
  5. from flask import Flask, abort, jsonify, render_template, request, send_from_directory
  6. from flask_socketio import SocketIO, emit
  7. from audio_capture import AudioEngine
  8. from recorder import list_recordings, resolve_recording_path
  9. from stt_bridge import SttBridge
  10. BASE_DIR = Path(__file__).resolve().parent
  11. RECORDINGS_DIR = BASE_DIR / "recordings"
  12. app = Flask(__name__, template_folder="templates", static_folder="static")
  13. app.config["SECRET_KEY"] = os.environ.get("MIC_SYSTEM_SECRET", "mic-system-dev")
  14. socketio = SocketIO(app, cors_allowed_origins="*", async_mode="eventlet")
  15. audio_engine = AudioEngine(RECORDINGS_DIR)
  16. def _stt_message_callback(msg):
  17. """Called when STT worker posts a result via /internal/stt."""
  18. socketio.emit("stt_message", msg)
  19. stt_bridge = SttBridge(on_message=_stt_message_callback)
  20. # Attach STT bridge to audio engine so it receives processed audio
  21. audio_engine.set_stt_bridge(stt_bridge)
  22. _stream_task_started = False
  23. audio_start_error = ""
  24. def ensure_audio_started() -> None:
  25. global audio_start_error
  26. if audio_engine.is_running():
  27. return
  28. try:
  29. audio_engine.start()
  30. audio_start_error = ""
  31. except Exception as exc: # pragma: no cover - runtime environment specific
  32. audio_start_error = str(exc)
  33. def stream_audio_packets() -> None:
  34. while True:
  35. packet = audio_engine.get_latest_packet()
  36. socketio.emit("audio_data", packet)
  37. socketio.sleep(0.08)
  38. @app.route("/")
  39. def index() -> str:
  40. ensure_audio_started()
  41. return render_template("index.html")
  42. @app.route("/api/status")
  43. def api_status():
  44. ensure_audio_started()
  45. status = audio_engine.get_status()
  46. status["audio_error"] = audio_start_error
  47. status.update(stt_bridge.get_settings())
  48. return jsonify(status)
  49. @app.route("/internal/stt", methods=["POST"])
  50. def internal_stt():
  51. """Receives JSON messages from stt_worker subprocess via HTTP POST."""
  52. msg = request.get_json(silent=True)
  53. if msg:
  54. stt_bridge.handle_worker_message(msg)
  55. return "", 204
  56. @app.route("/api/recordings")
  57. def api_list_recordings():
  58. return jsonify(list_recordings(RECORDINGS_DIR))
  59. @app.route("/api/recordings/<path:filename>")
  60. def api_get_recording(filename: str):
  61. try:
  62. path = resolve_recording_path(RECORDINGS_DIR, filename)
  63. except ValueError:
  64. abort(400, description="Invalid filename")
  65. if not path.exists():
  66. abort(404)
  67. return send_from_directory(RECORDINGS_DIR, path.name, as_attachment=True)
  68. @app.route("/api/recordings/<path:filename>", methods=["DELETE"])
  69. def api_delete_recording(filename: str):
  70. try:
  71. path = resolve_recording_path(RECORDINGS_DIR, filename)
  72. except ValueError:
  73. abort(400, description="Invalid filename")
  74. if not path.exists():
  75. abort(404)
  76. path.unlink()
  77. socketio.emit("recordings_updated", {"ok": True})
  78. return jsonify({"ok": True})
  79. def _handle_command(payload: dict) -> dict:
  80. msg_type = payload.get("type")
  81. if msg_type == "settings":
  82. settings = audio_engine.update_settings(payload)
  83. stt_keys = {k: v for k, v in payload.items() if k.startswith("stt_")}
  84. if stt_keys:
  85. stt_settings = stt_bridge.update_settings(**stt_keys)
  86. settings.update(stt_settings)
  87. return {"type": "settings_applied", "settings": settings}
  88. if msg_type == "stt_settings":
  89. stt_keys = {k: v for k, v in payload.items() if k.startswith("stt_")}
  90. stt_settings = stt_bridge.update_settings(**stt_keys)
  91. return {"type": "stt_settings_applied", "settings": stt_settings}
  92. if msg_type == "record_start":
  93. source = str(payload.get("source", "mic1"))
  94. duration_sec = payload.get("duration_sec")
  95. record_info = audio_engine.start_recording(source, duration_sec=duration_sec)
  96. socketio.emit("recordings_updated", {"ok": True})
  97. return {"type": "record_started", **record_info}
  98. if msg_type == "record_stop":
  99. status = audio_engine.stop_recording()
  100. socketio.emit("recordings_updated", {"ok": True})
  101. return {"type": "record_stopped", "status": asdict(status)}
  102. if msg_type == "delete_recording":
  103. filename = str(payload.get("filename", ""))
  104. if not filename:
  105. raise ValueError("Missing filename")
  106. path = resolve_recording_path(RECORDINGS_DIR, filename)
  107. if path.exists():
  108. path.unlink()
  109. socketio.emit("recordings_updated", {"ok": True})
  110. return {"type": "recording_deleted", "filename": filename}
  111. raise ValueError("Unsupported message type: " + str(msg_type))
  112. @socketio.on("connect")
  113. def ws_connect():
  114. global _stream_task_started
  115. ensure_audio_started()
  116. if not _stream_task_started:
  117. socketio.start_background_task(stream_audio_packets)
  118. _stream_task_started = True
  119. status = audio_engine.get_status()
  120. status.update(stt_bridge.get_settings())
  121. emit("status", status)
  122. if audio_start_error:
  123. emit("server_error", {"message": audio_start_error})
  124. @socketio.on("client_message")
  125. def ws_client_message(payload):
  126. if not isinstance(payload, dict):
  127. emit("server_error", {"message": "Invalid payload"})
  128. return
  129. try:
  130. response = _handle_command(payload)
  131. except Exception as exc:
  132. emit("server_error", {"message": str(exc)})
  133. return
  134. emit("server_ack", response)
  135. @socketio.on("disconnect")
  136. def ws_disconnect():
  137. return
  138. if __name__ == "__main__":
  139. ensure_audio_started()
  140. host = os.environ.get("MIC_SYSTEM_HOST", "0.0.0.0")
  141. port = int(os.environ.get("MIC_SYSTEM_PORT", "5000"))
  142. socketio.run(app, host=host, port=port)