Update 2026-05-13 14:41:49

This commit is contained in:
kassam 2026-05-13 14:41:51 +04:00
parent ed95a68b0e
commit c2ca8eea72
10 changed files with 136 additions and 34 deletions

View File

@ -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)

View File

@ -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 --

View File

@ -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

View File

@ -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": ""
} }

View File

@ -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
View 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)

View File

@ -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

View File

@ -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 {