Update 2026-05-13 14:41:49
This commit is contained in:
parent
ed95a68b0e
commit
c2ca8eea72
@ -2,11 +2,34 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _ascii_safe_stream(stream):
|
||||||
|
"""Wrap an ASCII-only stream so non-ASCII chars don't crash logging.
|
||||||
|
|
||||||
|
cPanel / LiteSpeed often hands Python a stream with `errors='strict'` and
|
||||||
|
encoding='ANSI_X3.4-1968'. Any em-dash in a log message then raises
|
||||||
|
UnicodeEncodeError and dumps a "--- Logging error ---" block into
|
||||||
|
stderr.log. This wrapper reopens the stream's buffer with errors='replace'
|
||||||
|
so unencodable chars become '?' instead of crashing.
|
||||||
|
"""
|
||||||
|
enc = (getattr(stream, "encoding", None) or "").lower()
|
||||||
|
if "utf" in enc:
|
||||||
|
return stream # already Unicode-safe
|
||||||
|
buf = getattr(stream, "buffer", None)
|
||||||
|
if buf is None:
|
||||||
|
return stream
|
||||||
|
try:
|
||||||
|
return io.TextIOWrapper(buf, encoding="utf-8", errors="replace",
|
||||||
|
line_buffering=True, write_through=True)
|
||||||
|
except Exception:
|
||||||
|
return stream
|
||||||
|
|
||||||
from Project.Sanad.config import LOGS_DIR
|
from Project.Sanad.config import LOGS_DIR
|
||||||
|
|
||||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||||
@ -53,7 +76,7 @@ def get_logger(name: str, *, to_console: bool = True) -> logging.Logger:
|
|||||||
logger.addHandler(fh)
|
logger.addHandler(fh)
|
||||||
|
|
||||||
if to_console:
|
if to_console:
|
||||||
sh = logging.StreamHandler(sys.stdout)
|
sh = logging.StreamHandler(_ascii_safe_stream(sys.stdout))
|
||||||
sh.setFormatter(_formatter)
|
sh.setFormatter(_formatter)
|
||||||
sh.setLevel(logging.INFO)
|
sh.setLevel(logging.INFO)
|
||||||
logger.addHandler(sh)
|
logger.addHandler(sh)
|
||||||
|
|||||||
@ -56,8 +56,21 @@ async def _auth_gate(request: Request, call_next):
|
|||||||
|
|
||||||
# Cookie session — secret regenerates on every restart, so all sessions
|
# Cookie session — secret regenerates on every restart, so all sessions
|
||||||
# invalidate on a server restart (acceptable for a local robot dashboard).
|
# invalidate on a server restart (acceptable for a local robot dashboard).
|
||||||
|
# https_only=True means the cookie is only sent over HTTPS — fine when this
|
||||||
|
# app sits behind a TLS-terminating reverse proxy (LiteSpeed) on the public
|
||||||
|
# domain. same_site="lax" plus the Secure flag mitigates CSRF on the login form.
|
||||||
app.add_middleware(SessionMiddleware, secret_key=secrets.token_urlsafe(32),
|
app.add_middleware(SessionMiddleware, secret_key=secrets.token_urlsafe(32),
|
||||||
session_cookie="sanad_session", max_age=60 * 60 * 24)
|
session_cookie="sanad_session", max_age=60 * 60 * 24,
|
||||||
|
https_only=True, same_site="lax")
|
||||||
|
|
||||||
|
|
||||||
|
# When LiteSpeed reverse-proxies into uvicorn, the request hits us as HTTP
|
||||||
|
# from 127.0.0.1. ProxyHeadersMiddleware reads X-Forwarded-Proto / -For so
|
||||||
|
# request.url.scheme reports https (for redirects) and request.client.host
|
||||||
|
# reports the real visitor IP (for logs/auth). Only trusts these headers
|
||||||
|
# when the proxy is on localhost.
|
||||||
|
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware # noqa: E402
|
||||||
|
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="127.0.0.1")
|
||||||
|
|
||||||
|
|
||||||
# -- isolated route registration --
|
# -- isolated route registration --
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@ -170,7 +171,7 @@ async def audio_status():
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/mic/mute")
|
@router.post("/mic/mute")
|
||||||
async def toggle_mic(muted: bool | None = None):
|
async def toggle_mic(muted: Optional[bool] = None):
|
||||||
def _do():
|
def _do():
|
||||||
_, source = _current_sink_source()
|
_, source = _current_sink_source()
|
||||||
if not source:
|
if not source:
|
||||||
@ -185,7 +186,7 @@ async def toggle_mic(muted: bool | None = None):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/speaker/mute")
|
@router.post("/speaker/mute")
|
||||||
async def toggle_speaker(muted: bool | None = None):
|
async def toggle_speaker(muted: Optional[bool] = None):
|
||||||
"""Mute/unmute the SPEAKER — both the PulseAudio sink AND the G1
|
"""Mute/unmute the SPEAKER — both the PulseAudio sink AND the G1
|
||||||
built-in speaker, so the effect is audible regardless of which
|
built-in speaker, so the effect is audible regardless of which
|
||||||
playback path is currently active (Anker PowerConf via PyAudio vs
|
playback path is currently active (Anker PowerConf via PyAudio vs
|
||||||
@ -254,7 +255,7 @@ async def toggle_speaker(muted: bool | None = None):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/g1-speaker/mute")
|
@router.post("/g1-speaker/mute")
|
||||||
async def toggle_g1_speaker_only(muted: bool | None = None):
|
async def toggle_g1_speaker_only(muted: Optional[bool] = None):
|
||||||
"""Mute/unmute ONLY the G1 built-in speaker via DDS AudioClient.
|
"""Mute/unmute ONLY the G1 built-in speaker via DDS AudioClient.
|
||||||
|
|
||||||
Useful for testing the DDS path in isolation — the normal
|
Useful for testing the DDS path in isolation — the normal
|
||||||
|
|||||||
@ -1,5 +1,42 @@
|
|||||||
{
|
{
|
||||||
"records": [],
|
"records": [
|
||||||
"total_records": 0,
|
{
|
||||||
|
"record_name": "يا_هلا_مسهلا_معاك_بُو_سانِييْدَة،_حاب_تع_20260513_113525",
|
||||||
|
"text": "يا هلا مسهلا معاك بُو سانِييْدَة،\nحاب تعمل معي فيديو مميز؟\nتواصل وياي وانا فريقي جاهزين خلنا نُبدع معا ونعمل ترند.",
|
||||||
|
"replay_count": 1,
|
||||||
|
"timeline": {
|
||||||
|
"audio_generated_at": "2026-05-13 11:35:00",
|
||||||
|
"last_playback_finished_at": "2026-05-13 11:35:00",
|
||||||
|
"saved_at": "2026-05-13 11:35:25"
|
||||||
|
},
|
||||||
|
"audio_capture": {
|
||||||
|
"backend": "pyaudio",
|
||||||
|
"sink": "alsa_output.platform-sound.analog-stereo",
|
||||||
|
"monitor_source": "alsa_output.platform-sound.analog-stereo.monitor",
|
||||||
|
"restored_microphone_source": "alsa_input.platform-sound.analog-stereo"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"speaker_recording": {
|
||||||
|
"name": "يا_هلا_مسهلا_معاك_بُو_سانِييْدَة،_حاب_تع_20260513_113525.wav",
|
||||||
|
"path": "يا_هلا_مسهلا_معاك_بُو_سانِييْدَة،_حاب_تع_20260513_113525.wav",
|
||||||
|
"size_bytes": 451200,
|
||||||
|
"sample_rate": 24000,
|
||||||
|
"channels": 1,
|
||||||
|
"sample_width_bytes": 2,
|
||||||
|
"duration_seconds": 9.4
|
||||||
|
},
|
||||||
|
"gemini_raw_output": {
|
||||||
|
"name": "يا_هلا_مسهلا_معاك_بُو_سانِييْدَة،_حاب_تع_20260513_113525_raw.wav",
|
||||||
|
"path": "يا_هلا_مسهلا_معاك_بُو_سانِييْدَة،_حاب_تع_20260513_113525_raw.wav",
|
||||||
|
"size_bytes": 451200,
|
||||||
|
"sample_rate": 24000,
|
||||||
|
"channels": 1,
|
||||||
|
"sample_width_bytes": 2,
|
||||||
|
"duration_seconds": 9.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_records": 1,
|
||||||
"last_updated": ""
|
"last_updated": ""
|
||||||
}
|
}
|
||||||
Binary file not shown.
Binary file not shown.
2
main.py
2
main.py
@ -116,7 +116,7 @@ brain = _safe_construct("brain", Brain) if Brain else None
|
|||||||
audio_mgr = _safe_construct("audio_mgr", AudioManager)
|
audio_mgr = _safe_construct("audio_mgr", AudioManager)
|
||||||
voice_client = _safe_construct("voice_client", GeminiVoiceClient)
|
voice_client = _safe_construct("voice_client", GeminiVoiceClient)
|
||||||
local_tts = _safe_construct("local_tts", LocalTTSEngine)
|
local_tts = _safe_construct("local_tts", LocalTTSEngine)
|
||||||
typed_replay = _safe_construct("typed_replay", (lambda: TypedReplayEngine(voice_client, audio_mgr)) if (TypedReplayEngine and voice_client and audio_mgr) else None)
|
typed_replay = _safe_construct("typed_replay", (lambda: TypedReplayEngine(voice_client, audio_mgr)) if (TypedReplayEngine and voice_client) else None)
|
||||||
|
|
||||||
# Wire everything into the Brain (only what was constructed)
|
# Wire everything into the Brain (only what was constructed)
|
||||||
def _safe_attach(method_name: str, value):
|
def _safe_attach(method_name: str, value):
|
||||||
|
|||||||
36
passenger_wsgi.py
Normal file
36
passenger_wsgi.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""Passenger / cPanel WSGI entry point.
|
||||||
|
|
||||||
|
cPanel's Python Application uses Phusion Passenger which only speaks WSGI.
|
||||||
|
Sanad_lite is FastAPI/ASGI, so this adapter wraps it with a2wsgi.
|
||||||
|
|
||||||
|
Caveat: WebSocket routes (/ws/logs) will NOT work over WSGI — the live
|
||||||
|
log-tail card in Settings & Logs will silently fail to connect. Everything
|
||||||
|
else (login, records, typed-replay, scripts, prompt) works fine.
|
||||||
|
|
||||||
|
If you want WebSockets too, deploy via Path B in the README (uvicorn
|
||||||
|
standalone + reverse proxy) instead of this Passenger setup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Make the app directory importable.
|
||||||
|
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
if _APP_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, _APP_DIR)
|
||||||
|
|
||||||
|
# Run main.py's module-level bootstrap — this sets up the Project.Sanad
|
||||||
|
# alias, constructs Brain + voice_client + typed_replay, attaches them.
|
||||||
|
# Routes import these globals via `from Project.Sanad.main import ...`.
|
||||||
|
import main # noqa: F401
|
||||||
|
|
||||||
|
# main.py only sets sys.modules["Project.Sanad.main"] under its
|
||||||
|
# `if __name__ == "__main__"` guard. Under Passenger we import it as a
|
||||||
|
# module, so replicate that alias by hand.
|
||||||
|
sys.modules.setdefault("Project.Sanad.main", sys.modules["main"])
|
||||||
|
|
||||||
|
# Wrap the FastAPI ASGI app as WSGI.
|
||||||
|
from Project.Sanad.dashboard.app import app as _fastapi_app # noqa: E402
|
||||||
|
from a2wsgi import ASGIMiddleware # noqa: E402
|
||||||
|
|
||||||
|
application = ASGIMiddleware(_fastapi_app)
|
||||||
@ -1,25 +1,14 @@
|
|||||||
# Sanad — Python dependencies
|
# Sanad_lite — cPanel / Passenger deployment deps
|
||||||
# Install: pip install -r requirements.txt
|
# Install: pip install -r requirements.txt (or click "Run Pip Install" in cPanel)
|
||||||
|
#
|
||||||
|
# Everything heavy (pyaudio, torch, transformers, uvicorn) was removed —
|
||||||
|
# the lite dashboard runs under Passenger WSGI via a2wsgi and plays all
|
||||||
|
# audio in the user's browser, so no host-side audio stack is needed.
|
||||||
|
|
||||||
# Dashboard
|
|
||||||
fastapi>=0.110.0
|
fastapi>=0.110.0
|
||||||
uvicorn[standard]>=0.29.0
|
pydantic>=2.0
|
||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
itsdangerous>=2.1.0 # required by Starlette's SessionMiddleware
|
itsdangerous>=2.1.0 # Starlette SessionMiddleware (login cookie)
|
||||||
|
websockets>=12.0 # gemini/client.py — Gemini Live WebSocket
|
||||||
# Gemini voice
|
a2wsgi>=1.10.0 # ASGI → WSGI adapter for Passenger / LiteSpeed lswsgi
|
||||||
websockets>=12.0
|
eval_type_backport>=0.2.0 # required on Python 3.8 so pydantic can resolve `X | Y` annotations
|
||||||
pyaudio>=0.2.13
|
|
||||||
|
|
||||||
# Camera proxy
|
|
||||||
httpx>=0.27.0
|
|
||||||
|
|
||||||
# Local TTS (optional — only needed for MBZUAI model)
|
|
||||||
transformers>=4.40.0
|
|
||||||
sentencepiece>=0.2.0
|
|
||||||
torch>=2.2.0
|
|
||||||
datasets>=2.19.0
|
|
||||||
soundfile>=0.12.0
|
|
||||||
|
|
||||||
# General
|
|
||||||
numpy>=1.24.0
|
|
||||||
|
|||||||
@ -353,11 +353,14 @@ class TypedReplayEngine:
|
|||||||
def _resolve_monitor_config(self) -> Optional[dict[str, Any]]:
|
def _resolve_monitor_config(self) -> Optional[dict[str, Any]]:
|
||||||
"""Pick the backend for capturing speaker output.
|
"""Pick the backend for capturing speaker output.
|
||||||
|
|
||||||
Priority:
|
Lite plays generated audio in the browser, so speaker capture is
|
||||||
1. parec (cleanest — just listens to the speaker monitor source)
|
never actually needed — _persist_session falls back to writing the
|
||||||
2. PyAudio input device matching 'pulse' or 'default'
|
raw Gemini bytes as both speaker.wav and raw.wav. Returning None
|
||||||
3. None → capture disabled (generation still works)
|
here keeps every PyAudio/ALSA touchpoint out of the cold path,
|
||||||
|
which is critical on hosts without portaudio (e.g. shared cPanel).
|
||||||
"""
|
"""
|
||||||
|
if self.audio_mgr is None:
|
||||||
|
return None
|
||||||
if shutil.which("parec"):
|
if shutil.which("parec"):
|
||||||
log.info("speaker capture: parec monitor=%s", DEFAULT_MONITOR_SOURCE)
|
log.info("speaker capture: parec monitor=%s", DEFAULT_MONITOR_SOURCE)
|
||||||
return {
|
return {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user