diff --git a/core/logger.py b/core/logger.py index 3c1be9f..63a4956 100644 --- a/core/logger.py +++ b/core/logger.py @@ -2,11 +2,34 @@ from __future__ import annotations +import io import logging import sys from logging.handlers import RotatingFileHandler 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 _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) if to_console: - sh = logging.StreamHandler(sys.stdout) + sh = logging.StreamHandler(_ascii_safe_stream(sys.stdout)) sh.setFormatter(_formatter) sh.setLevel(logging.INFO) logger.addHandler(sh) diff --git a/dashboard/app.py b/dashboard/app.py index cb55f02..bf498fd 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -56,8 +56,21 @@ async def _auth_gate(request: Request, call_next): # Cookie session — secret regenerates on every restart, so all sessions # 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), - 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 -- diff --git a/dashboard/routes/audio_control.py b/dashboard/routes/audio_control.py index 3fc21a2..93773c8 100644 --- a/dashboard/routes/audio_control.py +++ b/dashboard/routes/audio_control.py @@ -6,6 +6,7 @@ import asyncio import os import subprocess import threading +from typing import Optional from fastapi import APIRouter, HTTPException from pydantic import BaseModel @@ -170,7 +171,7 @@ async def audio_status(): @router.post("/mic/mute") -async def toggle_mic(muted: bool | None = None): +async def toggle_mic(muted: Optional[bool] = None): def _do(): _, source = _current_sink_source() if not source: @@ -185,7 +186,7 @@ async def toggle_mic(muted: bool | None = None): @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 built-in speaker, so the effect is audible regardless of which 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") -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. Useful for testing the DDS path in isolation — the normal diff --git a/data/audio/records.json b/data/audio/records.json index d15bab5..2d84464 100644 --- a/data/audio/records.json +++ b/data/audio/records.json @@ -1,5 +1,42 @@ { - "records": [], - "total_records": 0, + "records": [ + { + "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": "" } \ No newline at end of file diff --git a/data/audio/يا_هلا_مسهلا_معاك_بُو_سانِييْدَة،_حاب_تع_20260513_113525.wav b/data/audio/يا_هلا_مسهلا_معاك_بُو_سانِييْدَة،_حاب_تع_20260513_113525.wav new file mode 100644 index 0000000..291deb3 Binary files /dev/null and b/data/audio/يا_هلا_مسهلا_معاك_بُو_سانِييْدَة،_حاب_تع_20260513_113525.wav differ diff --git a/data/audio/يا_هلا_مسهلا_معاك_بُو_سانِييْدَة،_حاب_تع_20260513_113525_raw.wav b/data/audio/يا_هلا_مسهلا_معاك_بُو_سانِييْدَة،_حاب_تع_20260513_113525_raw.wav new file mode 100644 index 0000000..291deb3 Binary files /dev/null and b/data/audio/يا_هلا_مسهلا_معاك_بُو_سانِييْدَة،_حاب_تع_20260513_113525_raw.wav differ diff --git a/main.py b/main.py index 6420228..0820ef7 100644 --- a/main.py +++ b/main.py @@ -116,7 +116,7 @@ brain = _safe_construct("brain", Brain) if Brain else None audio_mgr = _safe_construct("audio_mgr", AudioManager) voice_client = _safe_construct("voice_client", GeminiVoiceClient) 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) def _safe_attach(method_name: str, value): diff --git a/passenger_wsgi.py b/passenger_wsgi.py new file mode 100644 index 0000000..d2a4280 --- /dev/null +++ b/passenger_wsgi.py @@ -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) diff --git a/requirements.txt b/requirements.txt index 595b33f..f6a9804 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,25 +1,14 @@ -# Sanad — Python dependencies -# Install: pip install -r requirements.txt +# Sanad_lite — cPanel / Passenger deployment deps +# 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 -uvicorn[standard]>=0.29.0 +pydantic>=2.0 python-multipart>=0.0.9 -itsdangerous>=2.1.0 # required by Starlette's SessionMiddleware - -# Gemini voice -websockets>=12.0 -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 +itsdangerous>=2.1.0 # Starlette SessionMiddleware (login cookie) +websockets>=12.0 # gemini/client.py — Gemini Live WebSocket +a2wsgi>=1.10.0 # ASGI → WSGI adapter for Passenger / LiteSpeed lswsgi +eval_type_backport>=0.2.0 # required on Python 3.8 so pydantic can resolve `X | Y` annotations diff --git a/voice/typed_replay.py b/voice/typed_replay.py index 6bea261..aeee8fe 100644 --- a/voice/typed_replay.py +++ b/voice/typed_replay.py @@ -353,11 +353,14 @@ class TypedReplayEngine: def _resolve_monitor_config(self) -> Optional[dict[str, Any]]: """Pick the backend for capturing speaker output. - Priority: - 1. parec (cleanest — just listens to the speaker monitor source) - 2. PyAudio input device matching 'pulse' or 'default' - 3. None → capture disabled (generation still works) + Lite plays generated audio in the browser, so speaker capture is + never actually needed — _persist_session falls back to writing the + raw Gemini bytes as both speaker.wav and raw.wav. Returning None + 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"): log.info("speaker capture: parec monitor=%s", DEFAULT_MONITOR_SOURCE) return {