Update 2026-05-13 14:41:49
This commit is contained in:
parent
ed95a68b0e
commit
c2ca8eea72
@ -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)
|
||||
|
||||
@ -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 --
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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": ""
|
||||
}
|
||||
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)
|
||||
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):
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user