youusseeff commited on
Commit
ae6d1e2
·
verified ·
1 Parent(s): ddbd30b

Upload 21 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
config.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+
3
+
4
+ class Settings(BaseSettings):
5
+ """Application settings loaded from .env file."""
6
+
7
+ GEMINI_API_KEY: str = ""
8
+ SPEECHMATICS_API_KEY: str = ""
9
+ GRADIO_TTS_URL: str = ""
10
+
11
+ model_config = {
12
+ "env_file": ".env",
13
+ "env_file_encoding": "utf-8",
14
+ }
15
+
16
+
17
+ settings = Settings()
main.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from uuid import uuid4
3
+
4
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.responses import FileResponse
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from personas import get_persona
11
+ from services.gemini_service import generate_response
12
+ from services.stt_service import transcribe_audio
13
+ from services.tts_service import synthesize_speech, save_character, saved_characters
14
+
15
+ # Configure logging
16
+ logging.basicConfig(
17
+ level=logging.INFO,
18
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
19
+ )
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # ─── FastAPI App ───────────────────────────────────────────────────────────────
23
+
24
+ app = FastAPI(
25
+ title="Hikawi API — حكاوي",
26
+ description="Interactive Egyptian Oral Heritage Chatbot API",
27
+ version="1.0.0",
28
+ )
29
+
30
+ # CORS — allow everything for local hackathon demo
31
+ app.add_middleware(
32
+ CORSMiddleware,
33
+ allow_origins=["*"],
34
+ allow_credentials=True,
35
+ allow_methods=["*"],
36
+ allow_headers=["*"],
37
+ )
38
+
39
+
40
+
41
+ # ─── In-Memory Conversation Store ─────────────────────────────────────────────
42
+
43
+ # Key: session_id (UUID string)
44
+ # Value: list of {"role": "user"/"model", "parts": [{"text": "..."}]}
45
+ conversation_history: dict[str, list[dict]] = {}
46
+
47
+ # ─── Pydantic Models ──────────────────────────────────────────────────────────
48
+
49
+
50
+ class TextChatRequest(BaseModel):
51
+ text: str
52
+ session_id: str | None = None
53
+
54
+
55
+ class TextChatResponse(BaseModel):
56
+ response: str
57
+ session_id: str
58
+
59
+
60
+ class AudioChatResponse(BaseModel):
61
+ transcribed_text: str
62
+ response: str
63
+ session_id: str
64
+
65
+
66
+ class TTSRequest(BaseModel):
67
+ text: str
68
+ character_name: str | None = None
69
+
70
+
71
+ # ─── Endpoints ─────────────────────────────────────────────────────────────────
72
+
73
+
74
+
75
+
76
+ @app.get("/health")
77
+ async def health_check():
78
+ """Health check endpoint."""
79
+ return {"status": "ok", "service": "hikawi"}
80
+
81
+
82
+ @app.post("/api/chat/text", response_model=TextChatResponse)
83
+ async def chat_text(request: TextChatRequest):
84
+ """
85
+ Text chat with the Aswan regional persona.
86
+
87
+ Sends the user's text to Gemini 2.5 Flash with the Aswan persona
88
+ system prompt and returns a response in Sa'idi/Nubian dialect.
89
+ """
90
+ try:
91
+ # Generate or use existing session ID
92
+ session_id = request.session_id or str(uuid4())
93
+
94
+ # Get Aswan persona
95
+ persona = get_persona("aswan")
96
+
97
+ # Get or create conversation history
98
+ history = conversation_history.setdefault(session_id, [])
99
+
100
+ # Generate response from Gemini
101
+ ai_response = generate_response(
102
+ user_text=request.text,
103
+ system_prompt=persona["system_prompt"],
104
+ history=history,
105
+ )
106
+
107
+ # Update conversation history
108
+ history.append({"role": "user", "parts": [{"text": request.text}]})
109
+ history.append({"role": "model", "parts": [{"text": ai_response}]})
110
+
111
+ logger.info(f"Text chat | session={session_id[:8]}... | user={request.text[:30]}...")
112
+
113
+ return TextChatResponse(response=ai_response, session_id=session_id)
114
+
115
+ except ValueError as e:
116
+ raise HTTPException(status_code=400, detail=str(e))
117
+ except RuntimeError as e:
118
+ raise HTTPException(status_code=500, detail=str(e))
119
+ except Exception as e:
120
+ logger.error(f"Unexpected error in chat_text: {e}")
121
+ raise HTTPException(status_code=500, detail="Internal server error")
122
+
123
+
124
+ @app.post("/api/stt")
125
+ async def speech_to_text(file: UploadFile = File(...)):
126
+ """
127
+ Transcribe audio to text only (no AI response).
128
+ Returns the transcribed text for user review before sending.
129
+ """
130
+ try:
131
+ audio_bytes = await file.read()
132
+ if not audio_bytes:
133
+ raise HTTPException(status_code=400, detail="Empty audio file")
134
+
135
+ filename = file.filename or "recording.webm"
136
+ transcribed_text = transcribe_audio(audio_bytes, filename)
137
+
138
+ if not transcribed_text.strip():
139
+ raise HTTPException(
140
+ status_code=400,
141
+ detail="Could not transcribe any text from the audio",
142
+ )
143
+
144
+ return {"text": transcribed_text.strip()}
145
+
146
+ except HTTPException:
147
+ raise
148
+ except Exception as e:
149
+ logger.error(f"STT error: {e}")
150
+ raise HTTPException(status_code=500, detail=f"Transcription failed: {e}")
151
+
152
+ @app.post("/api/chat/audio", response_model=AudioChatResponse)
153
+ async def chat_audio(
154
+ file: UploadFile = File(...),
155
+ session_id: str = Form(default=None),
156
+ ):
157
+ """
158
+ Audio chat with the Aswan regional persona.
159
+
160
+ Receives an audio file (WebM/OGG/WAV), transcribes it via Speechmatics,
161
+ then sends the transcribed text to Gemini for a persona response.
162
+ """
163
+ try:
164
+ # Read audio bytes
165
+ audio_bytes = await file.read()
166
+
167
+ if not audio_bytes:
168
+ raise HTTPException(status_code=400, detail="Empty audio file")
169
+
170
+ # Transcribe audio to text
171
+ filename = file.filename or "recording.webm"
172
+ transcribed_text = transcribe_audio(audio_bytes, filename)
173
+
174
+ if not transcribed_text.strip():
175
+ raise HTTPException(
176
+ status_code=400,
177
+ detail="Could not transcribe any text from the audio",
178
+ )
179
+
180
+ # Generate or use existing session ID
181
+ session_id = session_id or str(uuid4())
182
+
183
+ # Get Aswan persona
184
+ persona = get_persona("aswan")
185
+
186
+ # Get or create conversation history
187
+ history = conversation_history.setdefault(session_id, [])
188
+
189
+ # Generate response from Gemini
190
+ ai_response = generate_response(
191
+ user_text=transcribed_text,
192
+ system_prompt=persona["system_prompt"],
193
+ history=history,
194
+ )
195
+
196
+ # Update conversation history
197
+ history.append({"role": "user", "parts": [{"text": transcribed_text}]})
198
+ history.append({"role": "model", "parts": [{"text": ai_response}]})
199
+
200
+ logger.info(
201
+ f"Audio chat | session={session_id[:8]}... | "
202
+ f"transcribed={transcribed_text[:30]}..."
203
+ )
204
+
205
+ return AudioChatResponse(
206
+ transcribed_text=transcribed_text,
207
+ response=ai_response,
208
+ session_id=session_id,
209
+ )
210
+
211
+ except HTTPException:
212
+ raise
213
+ except RuntimeError as e:
214
+ raise HTTPException(status_code=500, detail=str(e))
215
+ except Exception as e:
216
+ logger.error(f"Unexpected error in chat_audio: {e}")
217
+ raise HTTPException(status_code=500, detail="Internal server error")
218
+
219
+
220
+ @app.post("/api/tts")
221
+ async def text_to_speech(request: TTSRequest):
222
+ """
223
+ Convert text to speech using Gradio TTS API.
224
+
225
+ Returns the generated audio file for playback in the browser.
226
+ """
227
+ try:
228
+ # Call Gradio TTS API
229
+ filepath, error = synthesize_speech(
230
+ text=request.text,
231
+ character_name=request.character_name,
232
+ )
233
+
234
+ if error:
235
+ logger.error(f"TTS error: {error}")
236
+ raise HTTPException(status_code=500, detail=error)
237
+
238
+ # Return the audio file
239
+ return FileResponse(
240
+ filepath,
241
+ media_type="audio/wav",
242
+ headers={
243
+ "Content-Disposition": "inline",
244
+ "Cache-Control": "no-cache",
245
+ },
246
+ )
247
+
248
+ except HTTPException:
249
+ raise
250
+ except Exception as e:
251
+ logger.error(f"Unexpected error in TTS: {e}")
252
+ raise HTTPException(status_code=500, detail="Internal server error")
253
+
254
+
255
+ # ─── Character Management ──────────────────────────────────────────────────────
256
+
257
+
258
+ @app.post("/api/characters/add")
259
+ async def add_character(
260
+ char_name: str = Form(...),
261
+ ref_text: str = Form(...),
262
+ audio_file: UploadFile = File(...),
263
+ ):
264
+ """
265
+ Save a new voice character to the Gradio TTS model.
266
+
267
+ Requires: character name, reference audio clip, and the text spoken in that clip.
268
+ """
269
+ try:
270
+ if not char_name.strip():
271
+ raise HTTPException(status_code=400, detail="Character name is required")
272
+ if not ref_text.strip():
273
+ raise HTTPException(status_code=400, detail="Reference text is required")
274
+
275
+ audio_bytes = await audio_file.read()
276
+ if not audio_bytes:
277
+ raise HTTPException(status_code=400, detail="Audio file is empty")
278
+
279
+ filename = audio_file.filename or "reference.wav"
280
+ message, error = save_character(
281
+ char_name=char_name.strip(),
282
+ audio_bytes=audio_bytes,
283
+ audio_filename=filename,
284
+ ref_text=ref_text.strip(),
285
+ )
286
+
287
+ if error:
288
+ logger.error(f"Save character error: {error}")
289
+ raise HTTPException(status_code=500, detail=error)
290
+
291
+ return {"message": message, "character_name": char_name.strip()}
292
+
293
+ except HTTPException:
294
+ raise
295
+ except Exception as e:
296
+ logger.error(f"Unexpected error saving character: {e}")
297
+ raise HTTPException(status_code=500, detail="Internal server error")
298
+
299
+
300
+ @app.get("/api/characters")
301
+ async def list_characters():
302
+ """List all saved voice characters."""
303
+ return {"characters": saved_characters}
304
+
305
+
306
+ # ─── Run ─────────────────────────────────────────���─────────────────────────────
307
+
308
+ if __name__ == "__main__":
309
+ import uvicorn
310
+
311
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
personas.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ REGIONAL_PERSONAS = {
4
+ "aswan": {
5
+ "name": "عم محمد",
6
+ "system_prompt": (
7
+ "أنت عم محمد من أسوان، حارس التراث النوبي. "
8
+ "تتحدث بلهجة أهل أسوان الطيبة، وتستخدم كلمات مثل 'يا ولدي' و'يا حبيبي'.\n"
9
+ "أنت شخص كبير في السن وحكيم، عندك حكايات كثيرة عن أسوان وتاريخها والنوبة.\n"
10
+ "لو حد سألك عن حاجة مش متعلقة بأسوان أو التراث، حوّل الكلام بأسلوب لطيف لحكاية عن أسوان.\n"
11
+ "إجابتك تكون قصيرة وطبيعية زي ما بتحكي لحد قاعد جنبك."
12
+ ),
13
+ },
14
+ }
15
+
16
+
17
+ def get_persona(region: str) -> dict:
18
+ """Get persona config for a region. Raises ValueError if not found."""
19
+ region = region.lower().strip()
20
+ if region not in REGIONAL_PERSONAS:
21
+ available = ", ".join(REGIONAL_PERSONAS.keys())
22
+ raise ValueError(
23
+ f"Region '{region}' not found. Available regions: {available}"
24
+ )
25
+ return REGIONAL_PERSONAS[region]
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.115.0
2
+ uvicorn[standard]>=0.30.0
3
+ python-dotenv>=1.0.0
4
+ python-multipart>=0.0.9
5
+ google-genai>=1.0.0
6
+ requests>=2.32.0
7
+ pydantic>=2.0.0
8
+ pydantic-settings>=2.0.0
9
+ gradio_client>=2.0.0
services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Services package
services/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (185 Bytes). View file
 
services/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (199 Bytes). View file
 
services/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (168 Bytes). View file
 
services/__pycache__/gemini_service.cpython-312.pyc ADDED
Binary file (2.71 kB). View file
 
services/__pycache__/gemini_service.cpython-313.pyc ADDED
Binary file (3.1 kB). View file
 
services/__pycache__/gemini_service.cpython-314.pyc ADDED
Binary file (3.29 kB). View file
 
services/__pycache__/stt_service.cpython-312.pyc ADDED
Binary file (4.94 kB). View file
 
services/__pycache__/stt_service.cpython-313.pyc ADDED
Binary file (4.96 kB). View file
 
services/__pycache__/stt_service.cpython-314.pyc ADDED
Binary file (5.34 kB). View file
 
services/__pycache__/tts_service.cpython-312.pyc ADDED
Binary file (4.59 kB). View file
 
services/__pycache__/tts_service.cpython-313.pyc ADDED
Binary file (4.62 kB). View file
 
services/__pycache__/tts_service.cpython-314.pyc ADDED
Binary file (5.44 kB). View file
 
services/gemini_service.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import time
3
+
4
+ from google import genai
5
+ from google.genai import types
6
+
7
+ from config import settings
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # Initialize the Gemini client
12
+ client = genai.Client(api_key=settings.GEMINI_API_KEY)
13
+
14
+ MODEL_ID = "gemini-2.5-flash"
15
+
16
+ MAX_RETRIES = 3
17
+ RETRY_DELAY = 2 # seconds
18
+
19
+
20
+ def generate_response(
21
+ user_text: str,
22
+ system_prompt: str,
23
+ history: list[dict],
24
+ ) -> str:
25
+ """
26
+ Generate a response from Gemini 2.5 Flash with persona and conversation history.
27
+ Retries up to 3 times on temporary API failures (e.g., high demand).
28
+
29
+ Args:
30
+ user_text: The user's current message (Arabic text).
31
+ system_prompt: The persona's system prompt from personas.py.
32
+ history: List of previous messages in format:
33
+ [{"role": "user", "parts": [{"text": "..."}]},
34
+ {"role": "model", "parts": [{"text": "..."}]}]
35
+
36
+ Returns:
37
+ The generated text response in the regional dialect.
38
+ """
39
+ # Build contents: history + current user message
40
+ contents = list(history) + [
41
+ {"role": "user", "parts": [{"text": user_text}]}
42
+ ]
43
+
44
+ last_error = None
45
+ for attempt in range(1, MAX_RETRIES + 1):
46
+ try:
47
+ response = client.models.generate_content(
48
+ model=MODEL_ID,
49
+ contents=contents,
50
+ config=types.GenerateContentConfig(
51
+ system_instruction=system_prompt,
52
+ temperature=0.8,
53
+ max_output_tokens=500,
54
+ ),
55
+ )
56
+ return response.text
57
+
58
+ except Exception as e:
59
+ last_error = e
60
+ error_str = str(e)
61
+ logger.warning(f"Gemini API error (attempt {attempt}/{MAX_RETRIES}): {error_str}")
62
+
63
+ # If it's a rate limit, don't just quickly retry, it needs more time
64
+ if "429" in error_str or "RESOURCE_EXHAUSTED" in error_str:
65
+ logger.error("Rate limit hit, stopping retries.")
66
+ raise ValueError("لقد تجاوزت الحد المسموح به من الرسائل. يرجى الانتظار دقيقة والمحاولة مرة أخرى.")
67
+
68
+ if attempt < MAX_RETRIES:
69
+ time.sleep(RETRY_DELAY * attempt)
70
+
71
+ logger.error(f"Gemini API failed after {MAX_RETRIES} attempts: {last_error}")
72
+ raise RuntimeError(f"Failed to generate response from Gemini: {last_error}")
services/stt_service.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import time
3
+ import logging
4
+
5
+ import requests
6
+
7
+ from config import settings
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ API_BASE = "https://asr.api.speechmatics.com/v2"
12
+
13
+ # Content type mapping for supported audio formats
14
+ CONTENT_TYPE_MAP = {
15
+ ".webm": "audio/webm",
16
+ ".ogg": "audio/ogg",
17
+ ".wav": "audio/wav",
18
+ ".mp3": "audio/mpeg",
19
+ ".m4a": "audio/mp4",
20
+ }
21
+
22
+
23
+ def _get_content_type(filename: str) -> str:
24
+ """Determine content type from filename extension."""
25
+ filename = filename.lower()
26
+ for ext, content_type in CONTENT_TYPE_MAP.items():
27
+ if filename.endswith(ext):
28
+ return content_type
29
+ # Default to webm (browser MediaRecorder default)
30
+ return "audio/webm"
31
+
32
+
33
+ def transcribe_audio(audio_bytes: bytes, filename: str = "audio.webm") -> str:
34
+ """
35
+ Transcribe Arabic audio using Speechmatics batch API.
36
+
37
+ Args:
38
+ audio_bytes: Raw bytes of the audio file.
39
+ filename: Original filename (used to detect content type).
40
+ Supported: .webm, .ogg, .wav, .mp3, .m4a
41
+
42
+ Returns:
43
+ Transcribed Arabic text string.
44
+
45
+ Raises:
46
+ RuntimeError: If transcription fails or times out.
47
+ """
48
+ headers = {
49
+ "Authorization": f"Bearer {settings.SPEECHMATICS_API_KEY}",
50
+ }
51
+
52
+ content_type = _get_content_type(filename)
53
+
54
+ # STEP 1: Submit transcription job
55
+ config = json.dumps({
56
+ "type": "transcription",
57
+ "transcription_config": {"language": "ar"},
58
+ })
59
+
60
+ files = {
61
+ "data_file": (filename, audio_bytes, content_type),
62
+ "config": (None, config, "application/json"),
63
+ }
64
+
65
+ try:
66
+ response = requests.post(
67
+ f"{API_BASE}/jobs/",
68
+ headers=headers,
69
+ files=files,
70
+ )
71
+ response.raise_for_status()
72
+ job_id = response.json()["id"]
73
+ logger.info(f"Speechmatics job submitted: {job_id}")
74
+ except Exception as e:
75
+ logger.error(f"Failed to submit STT job: {e}")
76
+ raise RuntimeError(f"Failed to submit transcription job: {e}")
77
+
78
+ # STEP 2: Poll for completion
79
+ max_attempts = 60
80
+ for attempt in range(max_attempts):
81
+ time.sleep(1)
82
+
83
+ try:
84
+ response = requests.get(
85
+ f"{API_BASE}/jobs/{job_id}",
86
+ headers=headers,
87
+ )
88
+ # CRITICAL: Set UTF-8 encoding before reading Arabic text
89
+ response.encoding = "utf-8"
90
+ response.raise_for_status()
91
+
92
+ job_status = response.json()["job"]["status"]
93
+ logger.debug(f"Job {job_id} status: {job_status} (attempt {attempt + 1})")
94
+
95
+ if job_status == "done":
96
+ break
97
+ elif job_status == "rejected":
98
+ raise RuntimeError(
99
+ f"Transcription job {job_id} was rejected by Speechmatics"
100
+ )
101
+ except RuntimeError:
102
+ raise
103
+ except Exception as e:
104
+ logger.warning(f"Error polling job status: {e}")
105
+ else:
106
+ raise RuntimeError(
107
+ f"Transcription job {job_id} timed out after {max_attempts} seconds"
108
+ )
109
+
110
+ # STEP 3: Fetch transcript
111
+ try:
112
+ response = requests.get(
113
+ f"{API_BASE}/jobs/{job_id}/transcript",
114
+ headers={
115
+ **headers,
116
+ "Accept": "application/json",
117
+ },
118
+ )
119
+ # CRITICAL: Set UTF-8 encoding before reading Arabic text
120
+ response.encoding = "utf-8"
121
+ response.raise_for_status()
122
+
123
+ transcript_data = response.json()
124
+ # Extract text from all results
125
+ texts = []
126
+ for result in transcript_data.get("results", []):
127
+ for alt in result.get("alternatives", []):
128
+ content = alt.get("content", "")
129
+ if content:
130
+ texts.append(content)
131
+
132
+ transcribed_text = " ".join(texts)
133
+ logger.info(f"Transcription complete: {transcribed_text[:50]}...")
134
+ return transcribed_text
135
+
136
+ except Exception as e:
137
+ logger.error(f"Failed to fetch transcript: {e}")
138
+ raise RuntimeError(f"Failed to fetch transcript for job {job_id}: {e}")
services/tts_service.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import tempfile
4
+
5
+ from gradio_client import Client, handle_file
6
+
7
+ from config import settings
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # Initialize Gradio client (lazy — connects on first call)
12
+ _gradio_client = None
13
+
14
+ # Track saved characters
15
+ saved_characters: list[str] = []
16
+
17
+
18
+ def _get_client() -> Client:
19
+ """Lazy-initialize the Gradio TTS client."""
20
+ global _gradio_client
21
+ if _gradio_client is None:
22
+ logger.info(f"Connecting to Gradio TTS at: {settings.GRADIO_TTS_URL}")
23
+ _gradio_client = Client(settings.GRADIO_TTS_URL)
24
+ return _gradio_client
25
+
26
+
27
+ def save_character(
28
+ char_name: str,
29
+ audio_bytes: bytes,
30
+ audio_filename: str,
31
+ ref_text: str,
32
+ ) -> tuple[str, None] | tuple[None, str]:
33
+ """
34
+ Save a new character voice to the Gradio TTS model.
35
+
36
+ Args:
37
+ char_name: Name for the character.
38
+ audio_bytes: Raw audio bytes of the reference voice clip.
39
+ audio_filename: Original filename of the audio.
40
+ ref_text: The reference text spoken in the audio clip.
41
+
42
+ Returns:
43
+ (success_message, None) on success.
44
+ (None, error_message) on failure.
45
+ """
46
+ try:
47
+ client = _get_client()
48
+
49
+ # Write audio bytes to a temp file (Gradio needs a file path)
50
+ suffix = os.path.splitext(audio_filename)[1] or ".wav"
51
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
52
+ tmp.write(audio_bytes)
53
+ tmp_path = tmp.name
54
+
55
+ try:
56
+ result = client.predict(
57
+ char_name=char_name,
58
+ audio_path=handle_file(tmp_path),
59
+ ref_text=ref_text,
60
+ api_name="/save_new_character",
61
+ )
62
+
63
+ # result is (markdown_message, dropdown_options)
64
+ message = result[0] if isinstance(result, (list, tuple)) else str(result)
65
+ logger.info(f"Character saved: {char_name} — {message}")
66
+
67
+ # Track the saved character
68
+ if char_name not in saved_characters:
69
+ saved_characters.append(char_name)
70
+
71
+ return message, None
72
+
73
+ finally:
74
+ # Clean up temp file
75
+ if os.path.exists(tmp_path):
76
+ os.unlink(tmp_path)
77
+
78
+ except Exception as e:
79
+ logger.error(f"Failed to save character: {e}")
80
+ global _gradio_client
81
+ _gradio_client = None
82
+ return None, f"Failed to save character: {e}"
83
+
84
+
85
+ def synthesize_speech(text: str, character_name: str) -> tuple[str, None] | tuple[None, str]:
86
+ """
87
+ Synthesize speech using the Gradio TTS API.
88
+
89
+ Args:
90
+ text: The Arabic text to synthesize.
91
+ character_name: Character name from the saved characters.
92
+
93
+ Returns:
94
+ (filepath, None) on success — path to the generated audio file.
95
+ (None, error_message) on failure.
96
+ """
97
+ try:
98
+ client = _get_client()
99
+
100
+ result = client.predict(
101
+ text=text,
102
+ custom_name=character_name,
103
+ api_name="/generate",
104
+ )
105
+
106
+ # result is a filepath to the generated audio
107
+ if result and os.path.exists(result):
108
+ logger.info(f"TTS audio generated: {result}")
109
+ return result, None
110
+ else:
111
+ return None, f"Gradio TTS returned no audio file: {result}"
112
+
113
+ except Exception as e:
114
+ logger.error(f"Gradio TTS error: {e}")
115
+ global _gradio_client
116
+ _gradio_client = None
117
+ return None, f"Gradio TTS failed: {e}"