Percy3822 commited on
Commit
f772dc7
·
verified ·
1 Parent(s): 79b1fb2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +213 -326
app.py CHANGED
@@ -1,27 +1,25 @@
1
- # app.py
2
- # Brain: hub UI + proxy to Python_ai / TTS / STT
3
- # NOTE: If WhatsApp changes _name_ to name, restore the double underscores.
 
4
 
5
- import os
6
- import json
7
- from typing import Optional, Dict, Any
8
-
9
- from fastapi import FastAPI, UploadFile, File, Form, Request
10
- from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
11
- from fastapi.middleware.cors import CORSMiddleware
12
- from pydantic import BaseModel
13
  import httpx
 
 
 
14
 
15
- # -----------------------------------------------------------------------------
16
- # Config (env overrides allowed)
17
- # -----------------------------------------------------------------------------
18
- PYTHON_AI_URL = os.getenv("PYTHON_AI_URL", "https://Percy3822-Python-ai.hf.space")
19
- TTS_URL = os.getenv("TTS_URL", "https://Percy3822-ActualTTS.hf.space")
20
- STT_URL = os.getenv("STT_URL", "https://Percy3822-ActualSTT.hf.space")
21
- TIMEOUT_S = float(os.getenv("UPSTREAM_TIMEOUT_S", "60"))
22
 
23
- app = FastAPI(title="Brain", version="0.4")
 
 
 
 
 
 
24
 
 
 
25
  app.add_middleware(
26
  CORSMiddleware,
27
  allow_origins=["*"],
@@ -29,318 +27,207 @@ app.add_middleware(
29
  allow_headers=["*"],
30
  )
31
 
32
- # -----------------------------------------------------------------------------
33
- # Models
34
- # -----------------------------------------------------------------------------
35
- class CodeHelpPayload(BaseModel):
36
- utterance: str
37
- telemetry: Dict[str, Any]
38
- memory: Dict[str, Any]
39
- response_mode: Optional[str] = "patch"
40
-
41
- class TTSPayload(BaseModel):
42
- text: str
43
-
44
- # -----------------------------------------------------------------------------
45
- # Helpers
46
- # -----------------------------------------------------------------------------
47
- def _make_abs_url(url_or_path: Optional[str]) -> Optional[str]:
48
- """Ensure a TTS path or hostname becomes a full https URL."""
49
- if not url_or_path:
50
- return None
51
- s = url_or_path.strip()
52
- if s.startswith("http://") or s.startswith("https://"):
53
- return s
54
- # Accept outputs like "percy3822-actualtts.hf.space/file/xxx.wav" or "/file/xxx.wav"
55
- s = s.lstrip("/")
56
- if s.startswith("file/"):
57
- # file path without host -> attach TTS host
58
- return f"{TTS_URL.rstrip('/')}/{s}"
59
- # likely host/path without scheme
60
- return "https://" + s
61
-
62
- async def _post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
63
- async with httpx.AsyncClient(timeout=TIMEOUT_S) as client:
64
- r = await client.post(url, json=payload)
65
- r.raise_for_status()
66
- return r.json()
67
-
68
- async def _post_multipart(url: str, files: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
69
- async with httpx.AsyncClient(timeout=TIMEOUT_S) as client:
70
- r = await client.post(url, files=files, params=params or {})
71
- r.raise_for_status()
72
- return r.json()
73
-
74
- # -----------------------------------------------------------------------------
75
- # UI
76
- # -----------------------------------------------------------------------------
77
- INDEX_HTML = """<!doctype html>
78
- <html lang="en">
79
- <head>
80
- <meta charset="utf-8" />
81
- <meta name="viewport" content="width=device-width, initial-scale=1" />
82
- <title>Brain – Control Panel</title>
83
- <style>
84
- :root { color-scheme: light dark; }
85
- body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 24px; line-height: 1.4; }
86
- h1 { margin: 0 0 8px; }
87
- .grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
88
- .card { border: 1px solid #8883; border-radius: 12px; padding: 16px; }
89
- textarea, input[type="text"] { width: 100%; box-sizing: border-box; padding: 8px; border-radius: 8px; border: 1px solid #8885; }
90
- button { padding: 8px 12px; border-radius: 8px; border: 1px solid #4444; cursor: pointer; }
91
- .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
92
- .muted { opacity: .8; font-size: .9em; }
93
- .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; }
94
- audio { width: 100%; margin-top: 8px; }
95
- .ok { color: #15803d; }
96
- .err { color: #b91c1c; }
97
- </style>
98
- </head>
99
- <body>
100
- <h1>🧠 Brain – Control Panel</h1>
101
- <p class="muted">Test each feature from Brain. If WhatsApp changed <code>_name</code> to <code>_name</code> in files, fix before deploying.</p>
102
-
103
- <div class="grid">
104
- <div class="card">
105
- <h2>TTS → Play & Download</h2>
106
- <textarea id="ttsText" rows="3" placeholder="Type text to speak...">Brain speaking through ActualTTS on CPU.</textarea>
107
- <div class="row" style="margin-top:8px;">
108
- <button id="ttsBtn">Speak</button>
109
- <span id="ttsStatus" class="muted"></span>
110
- </div>
111
- <div id="ttsOut" style="margin-top:10px;"></div>
112
- </div>
113
-
114
- <div class="card">
115
- <h2>STT → Transcribe File</h2>
116
- <input type="file" id="sttFile" accept="audio/*" />
117
- <div class="row" style="margin-top:8px;">
118
- <button id="sttBtn">Transcribe</button>
119
- <span id="sttStatus" class="muted"></span>
120
- </div>
121
- <div id="sttOut" style="margin-top:10px;"></div>
122
- </div>
123
-
124
- <div class="card">
125
- <h2>Code Help (dummy patch)</h2>
126
- <textarea id="codeViewport" rows="5">def foo():
127
- return bar
128
-
129
- result = foo()
130
- print(reslt)
131
- </textarea>
132
- <div class="row" style="margin-top:8px;">
133
- <button id="codeBtn">Ask Python_ai via Brain</button>
134
- <span id="codeStatus" class="muted"></span>
135
- </div>
136
- <div id="codeOut" class="mono" style="margin-top:10px;"></div>
137
- </div>
138
- </div>
139
-
140
- <script>
141
- const byId = (x) => document.getElementById(x);
142
-
143
- // TTS
144
- byId('ttsBtn').onclick = async () => {
145
- const text = byId('ttsText').value.trim();
146
- byId('ttsStatus').textContent = 'Calling /speak...';
147
- byId('ttsOut').innerHTML = '';
148
- try {
149
- const r = await fetch('/speak', {
150
- method: 'POST',
151
- headers: {'Content-Type':'application/json'},
152
- body: JSON.stringify({text})
153
- });
154
- const j = await r.json();
155
- if (!r.ok) throw new Error(j.detail || JSON.stringify(j));
156
- const url = j.audio_url;
157
- const a = document.createElement('audio');
158
- a.controls = true;
159
- a.src = url;
160
-
161
- const dl = document.createElement('a');
162
- dl.href = url;
163
- dl.download = (url.split('/').pop() || 'tts.wav');
164
- dl.textContent = 'Download audio';
165
-
166
- byId('ttsOut').appendChild(a);
167
- byId('ttsOut').appendChild(document.createElement('br'));
168
- byId('ttsOut').appendChild(dl);
169
- byId('ttsStatus').textContent = 'Done.';
170
- byId('ttsStatus').className = 'ok';
171
- } catch (e) {
172
- byId('ttsStatus').textContent = String(e);
173
- byId('ttsStatus').className = 'err';
174
- }
175
- };
176
-
177
- // STT
178
- byId('sttBtn').onclick = async () => {
179
- const f = byId('sttFile').files[0];
180
- if (!f) {
181
- byId('sttStatus').textContent = 'Pick an audio file first.';
182
- byId('sttStatus').className = 'err';
183
- return;
184
- }
185
- byId('sttStatus').textContent = 'Uploading to /transcribe...';
186
- byId('sttOut').innerHTML = '';
187
- try {
188
- const form = new FormData();
189
- form.append('file', f, f.name);
190
- const r = await fetch('/transcribe', { method: 'POST', body: form });
191
- const j = await r.json();
192
- if (!r.ok) throw new Error(j.detail || JSON.stringify(j));
193
- const pre = document.createElement('pre');
194
- pre.className = 'mono';
195
- pre.textContent = JSON.stringify(j, null, 2);
196
- byId('sttOut').appendChild(pre);
197
- byId('sttStatus').textContent = 'Done.';
198
- byId('sttStatus').className = 'ok';
199
- } catch (e) {
200
- byId('sttStatus').textContent = String(e);
201
- byId('sttStatus').className = 'err';
202
- }
203
- };
204
-
205
- // Code Help
206
- byId('codeBtn').onclick = async () => {
207
- const viewportText = byId('codeViewport').value;
208
- byId('codeStatus').textContent = 'Calling /code_help...';
209
- byId('codeOut').textContent = '';
210
- try {
211
- const payload = {
212
- utterance: "please fix the error and make it run",
213
- telemetry: {
214
- file: "main.py",
215
- lang: "python",
216
- cursor: { l: 88, c: 12 },
217
- viewport: { start: 72, end: 110, text: viewportText },
218
- diag: [{ l: 90, sev: "error", msg: "NameError: reslt is not defined" }],
219
- term: "Traceback... NameError: reslt"
220
- },
221
- memory: { short: [], sess: ["Prefer list comprehensions."], proj: [] },
222
- response_mode: "patch"
223
- };
224
- const r = await fetch('/code_help', {
225
- method: 'POST',
226
- headers: {'Content-Type':'application/json'},
227
- body: JSON.stringify(payload)
228
- });
229
- const j = await r.json();
230
- if (!r.ok) throw new Error(j.detail || JSON.stringify(j));
231
- byId('codeOut').textContent = JSON.stringify(j, null, 2);
232
- byId('codeStatus').textContent = 'Done.';
233
- byId('codeStatus').className = 'ok';
234
- } catch (e) {
235
- byId('codeStatus').textContent = String(e);
236
- byId('codeStatus').className = 'err';
237
- }
238
- };
239
- </script>
240
- </body>
241
- </html>
242
- """
243
-
244
- # -----------------------------------------------------------------------------
245
- # Routes
246
- # -----------------------------------------------------------------------------
247
- @app.get("/", response_class=HTMLResponse)
248
- async def index():
249
- return HTMLResponse(INDEX_HTML)
250
-
251
- @app.get("/health")
252
- async def health():
253
- return {"ok": True, "services": {"python_ai": PYTHON_AI_URL, "tts": TTS_URL, "stt": STT_URL}}
254
-
255
  @app.post("/warmup")
256
  async def warmup():
257
- notes = {}
258
- # ping python_ai
259
- try:
260
- # Intentionally minimal/invalid to confirm it's up (expect 200 or 4xx)
261
- payload = {
262
- "utterance": "ping",
263
- "telemetry": {"file":"x.py","lang":"python","cursor":{"l":1,"c":1},
264
- "viewport":{"start":1,"end":1,"text":"print(reslt)\\n"},
265
- "diag":[], "term":""},
266
- "memory": {"short":[], "sess":[], "proj":[]},
267
- "response_mode": "patch"
268
- }
269
- async with httpx.AsyncClient(timeout=TIMEOUT_S) as client:
270
- r = await client.post(f"{PYTHON_AI_URL}/code_help", json=payload)
271
- notes["python_ai"] = "ok" if r.status_code == 200 else f"ok(ping-{r.status_code})"
272
- except Exception as e:
273
- notes["python_ai"] = f"err: {e}"
274
-
275
- # ping TTS
276
- try:
277
- async with httpx.AsyncClient(timeout=TIMEOUT_S) as client:
278
- r = await client.post(f"{TTS_URL}/speak", json={"text": "warmup"})
279
- notes["tts"] = "ok" if r.status_code == 200 else f"status {r.status_code}"
280
- except Exception as e:
281
- notes["tts"] = f"err: {e}"
282
-
283
- # ping STT
284
- try:
285
- async with httpx.AsyncClient(timeout=TIMEOUT_S) as client:
286
- r = await client.get(f"{STT_URL}/openapi.json")
287
- notes["stt"] = "ok" if r.status_code == 200 else f"status {r.status_code}"
288
- except Exception as e:
289
- notes["stt"] = f"err: {e}"
290
-
291
- return {"ok": True, "notes": notes}
292
-
293
- @app.post("/code_help")
294
- async def code_help(body: CodeHelpPayload):
295
- try:
296
- data = await _post_json(f"{PYTHON_AI_URL}/code_help", json.loads(body.model_dump_json()))
297
- return JSONResponse(data)
298
- except httpx.HTTPStatusError as e:
299
- return JSONResponse({"detail": str(e), "upstream": e.response.text}, status_code=502)
300
- except Exception as e:
301
- return JSONResponse({"detail": str(e)}, status_code=502)
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  @app.post("/speak")
304
- async def speak(body: TTSPayload):
305
- try:
306
- # Call TTS space
307
- async with httpx.AsyncClient(timeout=TIMEOUT_S) as client:
308
- r = await client.post(f"{TTS_URL}/speak", json={"text": body.text})
309
- r.raise_for_status()
310
- j = r.json()
311
-
312
- # Normalize keys: handle audio_path | audio_url | path
313
- raw = j.get("audio_path") or j.get("audio_url") or j.get("path")
314
- audio_url = _make_abs_url(raw)
315
-
316
- # Force fully-qualified https:// if scheme missing (extra guard)
317
- if audio_url and not audio_url.startswith("http"):
318
- audio_url = "https://" + audio_url.lstrip("/")
319
-
320
- if not audio_url:
321
- return JSONResponse({"detail": f"Upstream returned no audio path: {j}"}, status_code=502)
322
-
323
- return {"audio_url": audio_url}
324
- except httpx.HTTPStatusError as e:
325
- return JSONResponse({"detail": f"TTS upstream error: {str(e)}", "upstream": e.response.text}, status_code=502)
326
- except Exception as e:
327
- return JSONResponse({"detail": f"TTS error: {str(e)}"}, status_code=502)
328
 
329
  @app.post("/transcribe")
330
  async def transcribe(file: UploadFile = File(...), beam_size: int = 1, vad_filter: bool = True):
331
- try:
332
- files = {"file": (file.filename, await file.read(), file.content_type or "application/octet-stream")}
333
- params = {"beam_size": beam_size, "vad_filter": str(vad_filter).lower()}
334
- data = await _post_multipart(f"{STT_URL}/transcribe", files=files, params=params)
335
- return JSONResponse(data)
336
- except httpx.HTTPStatusError as e:
337
- return JSONResponse({"detail": f"STT upstream error: {str(e)}", "upstream": e.response.text}, status_code=502)
338
- except Exception as e:
339
- return JSONResponse({"detail": f"STT error: {str(e)}"}, status_code=502)
340
 
341
- # -----------------------------------------------------------------------------
342
- # Entrypoint
343
- # -----------------------------------------------------------------------------
344
- if __name__ == "__main__": # restore to __main_ if WhatsApp changed it
345
- import uvicorn
346
- uvicorn.run("app:app", host="0.0.0.0", port=int(os.getenv("PORT", "7860")), reload=False)
 
1
+ # app.py — Brain (Milestone 1: streaming + bus + telemetry)
2
+ import asyncio, json, os, time, uuid
3
+ from datetime import datetime, timezone
4
+ from typing import AsyncGenerator, Dict, List, Optional
5
 
 
 
 
 
 
 
 
 
6
  import httpx
7
+ from fastapi import FastAPI, Form, UploadFile, File, Request
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.responses import StreamingResponse, JSONResponse
10
 
11
+ APP_START = time.time()
 
 
 
 
 
 
12
 
13
+ # ---- Config -----------------------------------------------------------------
14
+ ACTUAL_TTS = os.getenv("ACTUAL_TTS_URL", "https://Percy3822-ActualTTS.hf.space")
15
+ ACTUAL_STT = os.getenv("ACTUAL_STT_URL", "https://Percy3822-ActualSTT.hf.space")
16
+ PY_AI = os.getenv("PYTHON_AI_URL", "https://Percy3822-Python-ai.hf.space")
17
+ FILES_DIR = os.getenv("FILES_DIR", "/home/user/files")
18
+ LOG_DIR = os.path.join(FILES_DIR, "logs")
19
+ os.makedirs(LOG_DIR, exist_ok=True)
20
 
21
+ # ---- App --------------------------------------------------------------------
22
+ app = FastAPI(title="Brain Streaming")
23
  app.add_middleware(
24
  CORSMiddleware,
25
  allow_origins=["*"],
 
27
  allow_headers=["*"],
28
  )
29
 
30
+ # ---- Simple pub/sub for SSE -------------------------------------------------
31
+ class Bus:
32
+ def __init__(self):
33
+ self.subs: List[asyncio.Queue] = []
34
+
35
+ def sub(self) -> asyncio.Queue:
36
+ q = asyncio.Queue()
37
+ self.subs.append(q)
38
+ return q
39
+
40
+ async def pub(self, event: Dict):
41
+ # Ensure an iso timestamp and monotonic ms
42
+ event.setdefault("ts_iso", datetime.now(timezone.utc).isoformat())
43
+ event.setdefault("ts_ms", int(time.time() * 1000))
44
+ # log to jsonl
45
+ with open(os.path.join(LOG_DIR, "events.jsonl"), "a", encoding="utf-8") as f:
46
+ f.write(json.dumps(event, ensure_ascii=False) + "\n")
47
+ # fan out
48
+ for q in list(self.subs):
49
+ try:
50
+ q.put_nowait(event)
51
+ except asyncio.QueueFull:
52
+ pass
53
+
54
+ BUS = Bus()
55
+
56
+ def _sse_encode(ev: Dict) -> bytes:
57
+ return f"data: {json.dumps(ev, ensure_ascii=False)}\n\n".encode("utf-8")
58
+
59
+ async def _sse_stream(q: asyncio.Queue) -> AsyncGenerator[bytes, None]:
60
+ # Heartbeat to keep connections alive
61
+ try:
62
+ while True:
63
+ try:
64
+ ev = await asyncio.wait_for(q.get(), timeout=15)
65
+ yield _sse_encode(ev)
66
+ except asyncio.TimeoutError:
67
+ yield b": hb\n\n"
68
+ except asyncio.CancelledError:
69
+ return
70
+
71
+ @app.get("/events")
72
+ async def events():
73
+ q = BUS.sub()
74
+ return StreamingResponse(_sse_stream(q), media_type="text/event-stream")
75
+
76
+ # ---- Health + warmup --------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  @app.post("/warmup")
78
  async def warmup():
79
+ out = {}
80
+ async with httpx.AsyncClient(timeout=15) as client:
81
+ try:
82
+ r = await client.get(f"{ACTUAL_TTS}/health")
83
+ out["tts"] = ("ok" if r.status_code == 200 else f"status {r.status_code}")
84
+ except Exception as e:
85
+ out["tts"] = f"err: {e!r}"
86
+ try:
87
+ r = await client.get(f"{ACTUAL_STT}/health")
88
+ out["stt"] = ("ok" if r.status_code == 200 else f"status {r.status_code}")
89
+ except Exception as e:
90
+ out["stt"] = f"err: {e!r}"
91
+ try:
92
+ r = await client.post(f"{PY_AI}/code_help", json={"utterance":"ping"})
93
+ out["python_ai"] = "ok" if r.status_code in (200,422) else f"status {r.status_code}"
94
+ except Exception as e:
95
+ out["python_ai"] = f"err: {e!r}"
96
+ return {"ok": True, "notes": out}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
+ @app.get("/health")
99
+ async def health():
100
+ up = time.time() - APP_START
101
+ return {"ok": True, "uptime_sec": round(up,2)}
102
+
103
+ # ---- Telemetry intake -------------------------------------------------------
104
+ @app.post("/telemetry")
105
+ async def telemetry(payload: Dict):
106
+ # payload example: { kind, cpu, mem, active_app, net, meter, etc. }
107
+ await BUS.pub({"type":"telemetry", "payload":payload})
108
+ return {"ok": True}
109
+
110
+ # ---- Streaming NLM (demo) ---------------------------------------------------
111
+ @app.post("/stream/nlm")
112
+ async def stream_nlm(body: Dict):
113
+ """
114
+ Body:
115
+ { "id": "...(optional)", "prompt": "text to answer", "answer": "(optional: if you want me to stream this string)" }
116
+ For now we stream the provided 'answer' (or a canned one) token-by-token to let you wire the UI.
117
+ """
118
+ req_id = body.get("id") or str(uuid.uuid4())
119
+ prompt = body.get("prompt", "")
120
+ answer = body.get("answer") or f"Ok! I received your prompt: {prompt[:100]}"
121
+
122
+ async def gen():
123
+ start = time.time()
124
+ await BUS.pub({"type":"nlm_start", "id":req_id, "prompt":prompt})
125
+ buf = []
126
+ for token in answer.split(" "):
127
+ buf.append(token)
128
+ chunk = " ".join(buf)
129
+ ev = {"type":"nlm_token", "id":req_id, "delta":token, "text":chunk}
130
+ yield _sse_encode(ev)
131
+ await asyncio.sleep(0.03) # tiny delay so UI shows a stream
132
+ dur = time.time() - start
133
+ await BUS.pub({"type":"nlm_done", "id":req_id, "duration_ms":int(dur*1000)})
134
+ yield _sse_encode({"type":"nlm_done", "id":req_id})
135
+ return StreamingResponse(gen(), media_type="text/event-stream")
136
+
137
+ # ---- Streaming TTS orchestration (status stream) ----------------------------
138
+ @app.post("/stream/tts")
139
+ async def stream_tts(body: Dict):
140
+ """
141
+ Body: { "text": "...", "voice": "en_US-amy-medium", "description": "optional" }
142
+ Streams status events; ends with {type: "tts_audio", audio_url: "..."} when ready.
143
+ """
144
+ req_id = str(uuid.uuid4())
145
+ text = body.get("text","").strip()
146
+ voice = body.get("voice")
147
+ desc = body.get("description")
148
+
149
+ async def gen():
150
+ start = time.time()
151
+ yield _sse_encode({"type":"tts_status","id":req_id,"phase":"queued"})
152
+ await BUS.pub({"type":"tts_start","id":req_id,"text":text,"voice":voice})
153
+
154
+ try:
155
+ async with httpx.AsyncClient(timeout=120) as client:
156
+ yield _sse_encode({"type":"tts_status","id":req_id,"phase":"synthesizing"})
157
+ r = await client.post(f"{ACTUAL_TTS}/speak", json={"text":text, **({"voice":voice} if voice else {}), **({"description":desc} if desc else {})})
158
+ r.raise_for_status()
159
+ data = r.json()
160
+ if not data.get("ok"):
161
+ raise RuntimeError(data.get("error") or "TTS failed")
162
+ audio_url = data.get("audio_url")
163
+ yield _sse_encode({"type":"tts_audio","id":req_id,"audio_url":audio_url})
164
+ dur = time.time() - start
165
+ await BUS.pub({"type":"tts_done","id":req_id,"audio_url":audio_url,"duration_ms":int(dur*1000)})
166
+ yield _sse_encode({"type":"tts_done","id":req_id})
167
+ except Exception as e:
168
+ err = {"type":"error","scope":"tts","id":req_id,"msg":repr(e)}
169
+ await BUS.pub(err)
170
+ yield _sse_encode(err)
171
+
172
+ return StreamingResponse(gen(), media_type="text/event-stream")
173
+
174
+ # ---- Streaming STT orchestration (status stream) ----------------------------
175
+ @app.post("/stream/stt")
176
+ async def stream_stt(file: UploadFile = File(...), beam_size: int = 1, vad_filter: bool = True):
177
+ """
178
+ Uploads one file, streams status, ends with text result.
179
+ """
180
+ req_id = str(uuid.uuid4())
181
+
182
+ async def gen():
183
+ start = time.time()
184
+ yield _sse_encode({"type":"stt_status","id":req_id,"phase":"received","filename":file.filename})
185
+ await BUS.pub({"type":"stt_start","id":req_id,"filename":file.filename})
186
+
187
+ try:
188
+ # forward to ActualSTT
189
+ files = {"file": (file.filename, await file.read(), file.content_type or "audio/wav")}
190
+ params = {"beam_size":beam_size, "vad_filter":"true" if vad_filter else "false"}
191
+ async with httpx.AsyncClient(timeout=120) as client:
192
+ yield _sse_encode({"type":"stt_status","id":req_id,"phase":"processing"})
193
+ r = await client.post(f"{ACTUAL_STT}/transcribe", params=params, files=files)
194
+ r.raise_for_status()
195
+ data = r.json()
196
+ text = data.get("text","")
197
+ lang = data.get("language","")
198
+ dur_a = data.get("duration",0)
199
+ yield _sse_encode({"type":"stt_text","id":req_id,"text":text,"language":lang,"audio_duration":dur_a})
200
+ dur = time.time() - start
201
+ await BUS.pub({"type":"stt_done","id":req_id,"text":text,"duration_ms":int(dur*1000)})
202
+ yield _sse_encode({"type":"stt_done","id":req_id})
203
+ except Exception as e:
204
+ err = {"type":"error","scope":"stt","id":req_id,"msg":repr(e)}
205
+ await BUS.pub(err)
206
+ yield _sse_encode(err)
207
+
208
+ return StreamingResponse(gen(), media_type="text/event-stream")
209
+
210
+ # ---- Existing compatibility endpoints (non-stream) --------------------------
211
  @app.post("/speak")
212
+ async def speak(body: Dict):
213
+ text = body.get("text","")
214
+ voice = body.get("voice")
215
+ desc = body.get("description")
216
+ async with httpx.AsyncClient(timeout=120) as client:
217
+ r = await client.post(f"{ACTUAL_TTS}/speak", json={"text":text, **({"voice":voice} if voice else {}), **({"description":desc} if desc else {})})
218
+ return JSONResponse(r.json(), status_code=r.status_code)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
  @app.post("/transcribe")
221
  async def transcribe(file: UploadFile = File(...), beam_size: int = 1, vad_filter: bool = True):
222
+ files = {"file": (file.filename, await file.read(), file.content_type or "audio/wav")}
223
+ params = {"beam_size":beam_size, "vad_filter":"true" if vad_filter else "false"}
224
+ async with httpx.AsyncClient(timeout=120) as client:
225
+ r = await client.post(f"{ACTUAL_STT}/transcribe", params=params, files=files)
226
+ return JSONResponse(r.json(), status_code=r.status_code)
 
 
 
 
227
 
228
+ # (Optional) passthrough to python_ai/code_help so your tests still work
229
+ @app.post("/code_help")
230
+ async def code_help(body: Dict):
231
+ async with httpx.AsyncClient(timeout=60) as client:
232
+ r = await client.post(f"{PY_AI}/code_help", json=body)
233
+ return JSONResponse(r.json(), status_code=r.status_code)