digifreely commited on
Commit
b9cacf3
Β·
verified Β·
1 Parent(s): e681415

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +41 -0
  2. app.py +169 -0
  3. requirements.txt +4 -0
Dockerfile ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Base image ────────────────────────────────────────────────
2
+ FROM python:3.11-slim
3
+
4
+ # ── System dependencies ────────────────────────────────────────
5
+ # libgomp1 is required by ONNX Runtime (used internally by piper-tts)
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ build-essential \
8
+ curl \
9
+ libgomp1 \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # ── Non-root user required by HuggingFace Spaces ──────────────
13
+ RUN useradd -m -u 1000 user
14
+ USER user
15
+ ENV HOME=/home/user \
16
+ PATH=/home/user/.local/bin:$PATH
17
+
18
+ # ── Working directory ──────────────────────────────────────────
19
+ WORKDIR $HOME/app
20
+
21
+ # ── Install Python dependencies ────────────────────────────────
22
+ COPY --chown=user requirements.txt .
23
+ RUN pip install --no-cache-dir --upgrade pip \
24
+ && pip install --no-cache-dir -r requirements.txt
25
+
26
+ # ── Download Piper voice model (en_US-lessac-medium) ──────────
27
+ # Model and its JSON config are fetched from the official Piper voices repo
28
+ RUN mkdir -p $HOME/app/models \
29
+ && curl -L -o $HOME/app/models/en_US-lessac-medium.onnx \
30
+ "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/lessac/medium/en_US-lessac-medium.onnx" \
31
+ && curl -L -o $HOME/app/models/en_US-lessac-medium.onnx.json \
32
+ "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json"
33
+
34
+ # ── Copy application code ──────────────────────────────────────
35
+ COPY --chown=user app.py .
36
+
37
+ # ── Expose the port HuggingFace Spaces expects ─────────────────
38
+ EXPOSE 7860
39
+
40
+ # ── Start the server ───────────────────────────────────────────
41
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
app.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import hashlib
3
+ import base64
4
+ import asyncio
5
+ import wave
6
+ import io
7
+ import httpx
8
+ from piper.voice import PiperVoice
9
+ from fastapi import FastAPI, Request, HTTPException
10
+ from fastapi.responses import JSONResponse
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+
13
+ # ── Secrets (set these in HuggingFace Space β†’ Settings β†’ Variables and secrets) ──
14
+ EXPECTED_HASH = os.environ.get("HASH_VALUE")
15
+ SERVER_MAIN = os.environ.get("SERVER_MAIN_URL")
16
+ SERV_CODE = os.environ.get("SERV_CODE")
17
+ CF_SECRET_KEY = os.environ.get("CF_SECRET_KEY") # reserved for Cloudflare token verification if needed
18
+
19
+ ALLOWED_DOMAIN = os.environ.get("ALLOWED_DOMAIN", "buildwithsupratim.github.io")
20
+
21
+ # ── Piper TTS config ──
22
+ PIPER_MODEL_PATH = os.path.join(os.path.dirname(__file__), "models", "en_US-lessac-medium.onnx")
23
+
24
+ # Load voice once at startup (heavy; reuse across requests)
25
+ _piper_voice: PiperVoice = PiperVoice.load(PIPER_MODEL_PATH)
26
+
27
+ app = FastAPI(title="Maria Middleware", version="1.0.0")
28
+
29
+ app.add_middleware(
30
+ CORSMiddleware,
31
+ allow_origins=["https://buildwithsupratim.github.io"],
32
+ allow_methods=["GET", "POST"],
33
+ allow_headers=["*"],
34
+ )
35
+
36
+ # ═══════════════════════════════════════════════════════════════
37
+ # AUTH HELPERS
38
+ # ═══════════════════════════════════════════════════════════════
39
+
40
+ def _hash_auth_code(auth_code: str) -> str:
41
+ return hashlib.sha256(auth_code.encode()).hexdigest()
42
+
43
+
44
+ def _check_first(request: Request) -> bool:
45
+ """Primary check: hash of auth_code header must match EXPECTED_HASH."""
46
+ auth_code = request.headers.get("auth_code") or request.headers.get("Auth-Code")
47
+ if not auth_code:
48
+ return False
49
+ return _hash_auth_code(auth_code) == EXPECTED_HASH
50
+
51
+
52
+ def _check_second(request: Request) -> bool:
53
+ """Fallback check: request must originate from the allowed domain.
54
+
55
+ Cloudflare adds CF-Referer / the standard Referer / Origin headers.
56
+ We validate at the domain level so any path under the GitHub Pages site passes.
57
+ """
58
+ referer = request.headers.get("referer", "")
59
+ origin = request.headers.get("origin", "")
60
+
61
+ for value in (referer, origin):
62
+ if value and ALLOWED_DOMAIN in value:
63
+ return True
64
+ return False
65
+
66
+
67
+ async def _authorize(request: Request) -> None:
68
+ """Raise 403 if neither check passes."""
69
+ if _check_first(request):
70
+ return
71
+ if _check_second(request):
72
+ return
73
+ raise HTTPException(status_code=403, detail="Forbidden: invalid auth_code and domain not allowed.")
74
+
75
+
76
+ # ═══════════════════════════════════════════════════════════════
77
+ # TTS HELPER
78
+ # ═══════════════════════════════════════════════════════════════
79
+
80
+ def _generate_tts_base64(text: str) -> str:
81
+ """Synthesize *text* with Piper TTS and return base64-encoded WAV bytes."""
82
+ wav_buffer = io.BytesIO()
83
+ with wave.open(wav_buffer, "wb") as wav_file:
84
+ _piper_voice.synthesize(text, wav_file)
85
+ return base64.b64encode(wav_buffer.getvalue()).decode("utf-8")
86
+
87
+
88
+ # ═══════════════════════════════════════════════════════════════
89
+ # ROUTES
90
+ # ═══════════════════════════════════════════════════════════════
91
+
92
+ @app.get("/")
93
+ async def root():
94
+ """Root endpoint for HuggingFace health checks."""
95
+ return {"status": "alive"}
96
+
97
+
98
+ @app.get("/ping")
99
+ async def ping():
100
+ """Health-check endpoint. Wakes the Space if it was sleeping."""
101
+ return {"status": "alive"}
102
+
103
+
104
+ @app.post("/chat_start")
105
+ async def chat_start(request: Request):
106
+ """
107
+ 1. Authenticate the caller.
108
+ 2. Forward the payload to SERVER_MAIN with serv_code in headers.
109
+ 3. Override / add audio_output with Piper TTS base64 WAV audio.
110
+ 4. Return the final response.
111
+ """
112
+ await _authorize(request)
113
+
114
+ # ── Parse incoming JSON ──────────────────────────────────────
115
+ try:
116
+ payload = await request.json()
117
+ except Exception:
118
+ raise HTTPException(status_code=400, detail="Invalid JSON body.")
119
+
120
+ # ── Forward to SERVER_MAIN ───────────────────────────────────
121
+ forward_headers = {
122
+ "Content-Type": "application/json",
123
+ "serv_code": SERV_CODE,
124
+ }
125
+
126
+ async with httpx.AsyncClient(timeout=120.0) as client:
127
+ try:
128
+ server_response = await client.post(
129
+ SERVER_MAIN,
130
+ json=payload,
131
+ headers=forward_headers,
132
+ )
133
+ server_response.raise_for_status()
134
+ response_data = server_response.json()
135
+ except httpx.HTTPStatusError as exc:
136
+ raise HTTPException(
137
+ status_code=exc.response.status_code,
138
+ detail=f"SERVER_MAIN error: {exc.response.text}",
139
+ )
140
+ except httpx.RequestError as exc:
141
+ raise HTTPException(status_code=502, detail=f"SERVER_MAIN unreachable: {str(exc)}")
142
+
143
+ # ── Extract text for TTS ─────────────────────────────────────
144
+ try:
145
+ response_message = response_data["query"]["response_message"]
146
+ tts_text = response_message.get("text", "")
147
+ except (KeyError, TypeError):
148
+ raise HTTPException(status_code=502, detail="Unexpected response schema from SERVER_MAIN.")
149
+
150
+ # ── Generate TTS and override audio_output ───────────────────
151
+ if tts_text:
152
+ # Run blocking Piper synthesis in a thread pool to avoid blocking the event loop
153
+ loop = asyncio.get_event_loop()
154
+ audio_b64 = await loop.run_in_executor(None, _generate_tts_base64, tts_text)
155
+ response_data["query"]["response_message"]["audio_output"] = audio_b64
156
+ else:
157
+ # No text β†’ clear audio_output to avoid stale base64
158
+ response_data["query"]["response_message"]["audio_output"] = ""
159
+
160
+ return JSONResponse(content=response_data)
161
+
162
+
163
+ # ═══════════════════════════════════════════════════════════════
164
+ # ENTRY POINT
165
+ # ═══════════════════════════════════════════════════════════════
166
+
167
+ if __name__ == "__main__":
168
+ import uvicorn
169
+ uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=False)
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi==0.111.1
2
+ uvicorn[standard]==0.30.1
3
+ httpx==0.27.0
4
+ piper-tts==1.2.0