Spaces:
Running on Zero
Running on Zero
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,7 +1,487 @@
|
|
|
|
|
|
|
|
| 1 |
import gradio as gr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright: Shayekh Bin Islam. KAIST, South Korea. 2026.
|
| 2 |
+
|
| 3 |
import gradio as gr
|
| 4 |
+
import fitz # PyMuPDF
|
| 5 |
+
from PIL import Image
|
| 6 |
+
import io
|
| 7 |
+
import json
|
| 8 |
+
import base64
|
| 9 |
+
import soundfile as sf
|
| 10 |
+
import torch
|
| 11 |
+
|
| 12 |
+
from supertonic import TTS
|
| 13 |
+
from vllm import LLM, SamplingParams
|
| 14 |
+
|
| 15 |
+
llm = None
|
| 16 |
+
sampling_params = None
|
| 17 |
+
tts = None
|
| 18 |
+
voice_style = None
|
| 19 |
+
|
| 20 |
+
def extract_pdf_content(pdf_path, max_pages=2):
|
| 21 |
+
"""Extract text and images from up to max_pages of a PDF."""
|
| 22 |
+
doc = fitz.open(pdf_path)
|
| 23 |
+
text = ""
|
| 24 |
+
images = []
|
| 25 |
+
for i in range(min(max_pages, len(doc))):
|
| 26 |
+
page = doc[i]
|
| 27 |
+
text += page.get_text() + "\n"
|
| 28 |
+
pix = page.get_pixmap(dpi=150)
|
| 29 |
+
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
| 30 |
+
images.append(img)
|
| 31 |
+
return text, images
|
| 32 |
+
|
| 33 |
+
import os
|
| 34 |
+
|
| 35 |
+
def get_base64_image(image):
|
| 36 |
+
buffered = io.BytesIO()
|
| 37 |
+
image.save(buffered, format="JPEG")
|
| 38 |
+
img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
|
| 39 |
+
return f"data:image/jpeg;base64,{img_str}"
|
| 40 |
+
|
| 41 |
+
def extract_vocabulary(pdf_text, images, translit_lang, translit_format, target_lang):
|
| 42 |
+
"""Use vLLM to extract vocabulary from text and images."""
|
| 43 |
+
global llm, sampling_params
|
| 44 |
+
|
| 45 |
+
os.makedirs("log", exist_ok=True)
|
| 46 |
+
|
| 47 |
+
prompt_text = f"""Extract 3 to 5 key Korean words or phrases from the following text and images.
|
| 48 |
+
Return ONLY a valid JSON list of dictionaries, where each dictionary has four keys:
|
| 49 |
+
- 'korean' (the Korean text)
|
| 50 |
+
- 'transliteration' (the pronunciation transliterated into {translit_lang.upper()} script/characters, formatted as {translit_format}. CRITICAL: You MUST use the native alphabet/script of {translit_lang.upper()}, do NOT use English letters unless requested.)
|
| 51 |
+
- 'translation' (the translation into {target_lang.upper()})
|
| 52 |
+
- 'explanation' (a brief grammar or context note in {target_lang.upper()}).
|
| 53 |
+
No markdown formatting, just raw JSON.
|
| 54 |
+
|
| 55 |
+
Text:
|
| 56 |
+
{pdf_text[:1500]}
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
# DEBUG: Log prompt text
|
| 60 |
+
with open("log/debug_vlm_prompt.txt", "w", encoding="utf-8") as f:
|
| 61 |
+
f.write(prompt_text)
|
| 62 |
+
|
| 63 |
+
content = [{"type": "text", "text": prompt_text}]
|
| 64 |
+
|
| 65 |
+
for i, img in enumerate(images):
|
| 66 |
+
# DEBUG: Log images
|
| 67 |
+
img.save(f"log/debug_image_{i}.png", format="PNG")
|
| 68 |
+
|
| 69 |
+
content.append({
|
| 70 |
+
"type": "image_url",
|
| 71 |
+
"image_url": {"url": get_base64_image(img)}
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
messages = [
|
| 75 |
+
{
|
| 76 |
+
"role": "user",
|
| 77 |
+
"content": content
|
| 78 |
+
}
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
outputs = llm.chat(messages=messages, sampling_params=sampling_params)
|
| 83 |
+
output_text = outputs[0].outputs[0].text
|
| 84 |
+
|
| 85 |
+
# DEBUG: Log raw output text
|
| 86 |
+
with open("log/debug_vlm_output.txt", "w", encoding="utf-8") as f:
|
| 87 |
+
f.write(output_text)
|
| 88 |
+
|
| 89 |
+
except Exception as e:
|
| 90 |
+
print(f"Error during vLLM inference: {e}")
|
| 91 |
+
return []
|
| 92 |
+
|
| 93 |
+
try:
|
| 94 |
+
clean_text = output_text.strip()
|
| 95 |
+
if clean_text.startswith("```json"):
|
| 96 |
+
clean_text = clean_text[7:]
|
| 97 |
+
if clean_text.startswith("```"):
|
| 98 |
+
clean_text = clean_text[3:]
|
| 99 |
+
if clean_text.endswith("```"):
|
| 100 |
+
clean_text = clean_text[:-3]
|
| 101 |
+
clean_text = clean_text.strip()
|
| 102 |
+
|
| 103 |
+
data = json.loads(clean_text)
|
| 104 |
+
if not isinstance(data, list):
|
| 105 |
+
data = [data]
|
| 106 |
+
return data
|
| 107 |
+
except Exception as e:
|
| 108 |
+
print(f"Error parsing JSON: {e}\nRaw output: {output_text}")
|
| 109 |
+
return []
|
| 110 |
+
|
| 111 |
+
def numpy_to_base64_audio(wav, sample_rate):
|
| 112 |
+
wav = wav.squeeze()
|
| 113 |
+
buffer = io.BytesIO()
|
| 114 |
+
sf.write(buffer, wav, sample_rate, format='WAV')
|
| 115 |
+
buffer.seek(0)
|
| 116 |
+
audio_base64 = base64.b64encode(buffer.read()).decode('utf-8')
|
| 117 |
+
return f"data:audio/wav;base64,{audio_base64}"
|
| 118 |
+
|
| 119 |
+
def process_pdf(pdf_file, translit_lang, translit_format, target_lang):
|
| 120 |
+
global tts, voice_style
|
| 121 |
+
|
| 122 |
+
# Clean language choices from "Family - Language" to just "Language"
|
| 123 |
+
if " - " in translit_lang:
|
| 124 |
+
translit_lang = translit_lang.split(" - ")[-1]
|
| 125 |
+
if " - " in target_lang:
|
| 126 |
+
target_lang = target_lang.split(" - ")[-1]
|
| 127 |
+
|
| 128 |
+
os.makedirs("log", exist_ok=True)
|
| 129 |
+
|
| 130 |
+
if pdf_file is None:
|
| 131 |
+
return "<p>Please upload a PDF.</p>"
|
| 132 |
+
|
| 133 |
+
try:
|
| 134 |
+
pdf_text, images = extract_pdf_content(pdf_file.name)
|
| 135 |
+
if not pdf_text.strip() and not images:
|
| 136 |
+
return "<p>No content found in PDF.</p>"
|
| 137 |
+
except Exception as e:
|
| 138 |
+
return f"<p>Error reading PDF: {e}</p>"
|
| 139 |
+
|
| 140 |
+
vocab_list = extract_vocabulary(pdf_text, images, translit_lang, translit_format, target_lang)
|
| 141 |
+
if not vocab_list:
|
| 142 |
+
return "<p>Failed to extract vocabulary. The model might not have found Korean text or returned an invalid format.</p>"
|
| 143 |
+
|
| 144 |
+
# Pre-generate TTS audio
|
| 145 |
+
for i, item in enumerate(vocab_list):
|
| 146 |
+
korean = item.get("korean", "")
|
| 147 |
+
# Add dot
|
| 148 |
+
if not korean.endswith("."):
|
| 149 |
+
korean += "."
|
| 150 |
+
|
| 151 |
+
try:
|
| 152 |
+
wav, dur = tts.synthesize(korean, voice_style=voice_style, lang="ko")
|
| 153 |
+
|
| 154 |
+
# DEBUG: Save audio locally
|
| 155 |
+
wav_1d = wav.squeeze()
|
| 156 |
+
sf.write(f"log/debug_audio_{i}.wav", wav_1d, tts.sample_rate, format='WAV')
|
| 157 |
+
|
| 158 |
+
audio_data_uri = numpy_to_base64_audio(wav, tts.sample_rate)
|
| 159 |
+
item['audio_uri'] = audio_data_uri
|
| 160 |
+
except Exception as e:
|
| 161 |
+
print(f"TTS error for '{korean}': {e}")
|
| 162 |
+
item['audio_uri'] = None
|
| 163 |
+
|
| 164 |
+
cards_json = json.dumps(vocab_list).replace("</", "<\\/")
|
| 165 |
+
|
| 166 |
+
iframe_html = f"""
|
| 167 |
+
<!DOCTYPE html>
|
| 168 |
+
<html>
|
| 169 |
+
<head>
|
| 170 |
+
<!-- Flaticon UIcons CDN -->
|
| 171 |
+
<link rel='stylesheet' href='https://cdn-uicons.flaticon.com/uicons-regular-rounded/css/uicons-regular-rounded.css'>
|
| 172 |
+
<style>
|
| 173 |
+
body {{
|
| 174 |
+
margin: 0;
|
| 175 |
+
padding: 0;
|
| 176 |
+
background: transparent;
|
| 177 |
+
}}
|
| 178 |
+
.flashcard-container {{
|
| 179 |
+
perspective: 1000px;
|
| 180 |
+
width: 100%;
|
| 181 |
+
max-width: 500px;
|
| 182 |
+
margin: 0 auto;
|
| 183 |
+
font-family: 'Inter', sans-serif;
|
| 184 |
+
padding-top: 20px;
|
| 185 |
+
}}
|
| 186 |
+
.flashcard {{
|
| 187 |
+
width: 100%;
|
| 188 |
+
height: 350px;
|
| 189 |
+
position: relative;
|
| 190 |
+
transition: transform 0.6s cubic-bezier(0.4, 0.2, 0.2, 1);
|
| 191 |
+
transform-style: preserve-3d;
|
| 192 |
+
cursor: pointer;
|
| 193 |
+
}}
|
| 194 |
+
.flashcard.is-flipped {{
|
| 195 |
+
transform: rotateY(180deg);
|
| 196 |
+
}}
|
| 197 |
+
.card-face {{
|
| 198 |
+
position: absolute;
|
| 199 |
+
width: 100%;
|
| 200 |
+
height: 100%;
|
| 201 |
+
backface-visibility: hidden;
|
| 202 |
+
display: flex;
|
| 203 |
+
flex-direction: column;
|
| 204 |
+
justify-content: center;
|
| 205 |
+
align-items: center;
|
| 206 |
+
border-radius: 20px;
|
| 207 |
+
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
| 208 |
+
padding: 30px;
|
| 209 |
+
box-sizing: border-box;
|
| 210 |
+
background: rgba(255, 255, 255, 0.8);
|
| 211 |
+
backdrop-filter: blur(10px);
|
| 212 |
+
border: 1px solid rgba(255,255,255,0.5);
|
| 213 |
+
text-align: center;
|
| 214 |
+
}}
|
| 215 |
+
.card-front {{
|
| 216 |
+
background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%);
|
| 217 |
+
}}
|
| 218 |
+
.card-back {{
|
| 219 |
+
transform: rotateY(180deg);
|
| 220 |
+
background: linear-gradient(135deg, #f6d365 0%, #fda085 100%);
|
| 221 |
+
color: #333;
|
| 222 |
+
}}
|
| 223 |
+
.korean-text {{
|
| 224 |
+
font-size: 54px;
|
| 225 |
+
font-weight: 700;
|
| 226 |
+
color: #2c3e50;
|
| 227 |
+
margin-bottom: 20px;
|
| 228 |
+
}}
|
| 229 |
+
.english-text {{
|
| 230 |
+
font-size: 32px;
|
| 231 |
+
font-weight: 600;
|
| 232 |
+
margin-bottom: 5px;
|
| 233 |
+
}}
|
| 234 |
+
.translit-text {{
|
| 235 |
+
font-size: 18px;
|
| 236 |
+
font-style: italic;
|
| 237 |
+
color: #d35400;
|
| 238 |
+
margin-bottom: 15px;
|
| 239 |
+
}}
|
| 240 |
+
.explanation-text {{
|
| 241 |
+
font-size: 16px;
|
| 242 |
+
color: #555;
|
| 243 |
+
line-height: 1.5;
|
| 244 |
+
}}
|
| 245 |
+
.nav-buttons {{
|
| 246 |
+
display: flex;
|
| 247 |
+
justify-content: space-between;
|
| 248 |
+
margin-top: 30px;
|
| 249 |
+
width: 100%;
|
| 250 |
+
max-width: 500px;
|
| 251 |
+
margin-left: auto;
|
| 252 |
+
margin-right: auto;
|
| 253 |
+
}}
|
| 254 |
+
.nav-btn {{
|
| 255 |
+
padding: 12px 24px;
|
| 256 |
+
border: none;
|
| 257 |
+
border-radius: 12px;
|
| 258 |
+
background: #7c3aed;
|
| 259 |
+
color: white;
|
| 260 |
+
font-weight: 600;
|
| 261 |
+
cursor: pointer;
|
| 262 |
+
transition: all 0.2s;
|
| 263 |
+
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.3);
|
| 264 |
+
flex: 1;
|
| 265 |
+
margin: 0 10px;
|
| 266 |
+
display: flex;
|
| 267 |
+
align-items: center;
|
| 268 |
+
justify-content: center;
|
| 269 |
+
gap: 8px;
|
| 270 |
+
}}
|
| 271 |
+
.nav-btn:hover {{
|
| 272 |
+
background: #6d28d9;
|
| 273 |
+
transform: translateY(-2px);
|
| 274 |
+
}}
|
| 275 |
+
.nav-btn:disabled {{
|
| 276 |
+
background: #ccc;
|
| 277 |
+
cursor: not-allowed;
|
| 278 |
+
transform: none;
|
| 279 |
+
box-shadow: none;
|
| 280 |
+
}}
|
| 281 |
+
.audio-btn {{
|
| 282 |
+
margin-top: 20px;
|
| 283 |
+
padding: 12px 24px;
|
| 284 |
+
border-radius: 50px;
|
| 285 |
+
border: none;
|
| 286 |
+
background: #2c3e50;
|
| 287 |
+
color: white;
|
| 288 |
+
cursor: pointer;
|
| 289 |
+
font-size: 16px;
|
| 290 |
+
font-weight: 600;
|
| 291 |
+
transition: all 0.2s;
|
| 292 |
+
display: flex;
|
| 293 |
+
align-items: center;
|
| 294 |
+
justify-content: center;
|
| 295 |
+
gap: 8px;
|
| 296 |
+
}}
|
| 297 |
+
.audio-btn:hover {{
|
| 298 |
+
background: #34495e;
|
| 299 |
+
transform: scale(1.05);
|
| 300 |
+
}}
|
| 301 |
+
.progress {{
|
| 302 |
+
text-align: center;
|
| 303 |
+
margin-top: 15px;
|
| 304 |
+
color: #666;
|
| 305 |
+
font-size: 14px;
|
| 306 |
+
font-weight: 600;
|
| 307 |
+
}}
|
| 308 |
+
</style>
|
| 309 |
+
</head>
|
| 310 |
+
<body>
|
| 311 |
+
<div id="flashcard-app">
|
| 312 |
+
<div class="flashcard-container">
|
| 313 |
+
<div class="flashcard" id="card" onclick="flipCard()">
|
| 314 |
+
<div class="card-face card-front">
|
| 315 |
+
<div class="korean-text" id="front-text"><i class="fi fi-rr-spinner-third fa-spin"></i> Loading...</div>
|
| 316 |
+
<button class="audio-btn" onclick="playAudio(event)" id="audio-btn" style="display:none;"><i class="fi fi-rr-play-circle"></i> Play Audio</button>
|
| 317 |
+
<p style="margin-top:20px; color:#999; font-size:13px; display:flex; align-items:center; gap:5px;"><i class="fi fi-rr-rotate-right"></i> Click card to flip 🎯</p>
|
| 318 |
+
</div>
|
| 319 |
+
<div class="card-face card-back">
|
| 320 |
+
<div class="english-text" id="back-en"></div>
|
| 321 |
+
<div class="translit-text" id="back-translit"></div>
|
| 322 |
+
<div class="explanation-text"><i class="fi fi-rr-lightbulb-on" style="color:#f1c40f;"></i> <span id="back-exp"></span></div>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
<div class="nav-buttons">
|
| 327 |
+
<button class="nav-btn" id="prev-btn" onclick="prevCard()"><i class="fi fi-rr-angle-left"></i> Previous</button>
|
| 328 |
+
<button class="nav-btn" id="next-btn" onclick="nextCard()">Next <i class="fi fi-rr-angle-right"></i></button>
|
| 329 |
+
</div>
|
| 330 |
+
<div class="progress" id="progress-text"></div>
|
| 331 |
+
</div>
|
| 332 |
+
|
| 333 |
+
<script>
|
| 334 |
+
const cards = {cards_json};
|
| 335 |
+
let currentIndex = 0;
|
| 336 |
+
let audioPlayer = new Audio();
|
| 337 |
+
|
| 338 |
+
function updateCard() {{
|
| 339 |
+
if (!cards || cards.length === 0) {{
|
| 340 |
+
document.getElementById('front-text').innerHTML = "No vocabulary found 😥";
|
| 341 |
+
document.getElementById('prev-btn').disabled = true;
|
| 342 |
+
document.getElementById('next-btn').disabled = true;
|
| 343 |
+
return;
|
| 344 |
+
}}
|
| 345 |
+
const card = cards[currentIndex];
|
| 346 |
+
document.getElementById('front-text').innerText = card.korean || "No word";
|
| 347 |
+
document.getElementById('back-en').innerText = card.translation || card.english || "";
|
| 348 |
+
document.getElementById('back-translit').innerText = card.transliteration ? `[${{card.transliteration}}]` : "";
|
| 349 |
+
document.getElementById('back-exp').innerText = card.explanation || "";
|
| 350 |
+
|
| 351 |
+
document.getElementById('prev-btn').disabled = currentIndex === 0;
|
| 352 |
+
document.getElementById('next-btn').disabled = currentIndex === cards.length - 1;
|
| 353 |
+
document.getElementById('progress-text').innerHTML = `📚 Card ${{currentIndex + 1}} of ${{cards.length}}`;
|
| 354 |
+
|
| 355 |
+
const cardEl = document.getElementById('card');
|
| 356 |
+
cardEl.classList.remove('is-flipped');
|
| 357 |
+
|
| 358 |
+
if(card.audio_uri) {{
|
| 359 |
+
audioPlayer.src = card.audio_uri;
|
| 360 |
+
document.getElementById('audio-btn').style.display = 'flex';
|
| 361 |
+
}} else {{
|
| 362 |
+
document.getElementById('audio-btn').style.display = 'none';
|
| 363 |
+
}}
|
| 364 |
+
}}
|
| 365 |
+
|
| 366 |
+
function flipCard() {{
|
| 367 |
+
if (!cards || cards.length === 0) return;
|
| 368 |
+
document.getElementById('card').classList.toggle('is-flipped');
|
| 369 |
+
}}
|
| 370 |
+
|
| 371 |
+
function playAudio(e) {{
|
| 372 |
+
e.stopPropagation();
|
| 373 |
+
audioPlayer.play().catch(err => console.log("Audio play error:", err));
|
| 374 |
+
}}
|
| 375 |
+
|
| 376 |
+
function nextCard() {{
|
| 377 |
+
if (currentIndex < cards.length - 1) {{
|
| 378 |
+
currentIndex++;
|
| 379 |
+
updateCard();
|
| 380 |
+
}}
|
| 381 |
+
}}
|
| 382 |
+
|
| 383 |
+
function prevCard() {{
|
| 384 |
+
if (currentIndex > 0) {{
|
| 385 |
+
currentIndex--;
|
| 386 |
+
updateCard();
|
| 387 |
+
}}
|
| 388 |
+
}}
|
| 389 |
+
|
| 390 |
+
window.onload = function() {{
|
| 391 |
+
updateCard();
|
| 392 |
+
}};
|
| 393 |
+
</script>
|
| 394 |
+
</body>
|
| 395 |
+
</html>
|
| 396 |
+
"""
|
| 397 |
+
|
| 398 |
+
import html
|
| 399 |
+
safe_srcdoc = html.escape(iframe_html)
|
| 400 |
+
|
| 401 |
+
# Return the iframe containing the whole SPA
|
| 402 |
+
return f'<iframe srcdoc="{safe_srcdoc}" style="width: 100%; height: 500px; border: none; overflow: hidden;"></iframe>'
|
| 403 |
+
|
| 404 |
+
LANGUAGE_DATA = """Indo-European English, French, Portuguese, German, Romanian, Swedish, Danish, Bulgarian, Russian, Czech, Greek, Ukrainian, Spanish, Dutch, Slovak, Croatian, Polish, Lithuanian, Norwegian Bokmål, Norwegian Nynorsk, Persian, Slovenian, Gujarati, Latvian, Italian, Occitan, Nepali, Marathi, Belarusian, Serbian, Luxembourgish, Venetian, Assamese, Welsh, Silesian, Asturian, Chhattisgarhi, Awadhi, Maithili, Bhojpuri, Sindhi, Irish, Faroese, Hindi, Punjabi, Bengali, Oriya, Tajik, Eastern Yiddish, Lombard, Ligurian, Sicilian, Friulian, Sardinian, Galician, Catalan, Icelandic, Tosk Albanian, Limburgish, Dari, Afrikaans, Macedonian, Sinhala, Urdu, Magahi, Bosnian, Armenian, Latgalian, Scottish Gaelic, Central Kurdish, Northern Kurdish, Southern Pashto, Sanskrit, Dhundari, Marwari, Ahirani, Bagheli, Bagri, Bundeli, Braj, Kumaoni, Kashmiri
|
| 405 |
+
Sino-Tibetan Chinese (Simplified), Chinese (Traditional), Cantonese, Burmese, Standard Tibetan, Meitei
|
| 406 |
+
Afro-Asiatic Arabic (Standard), Arabic (Najdi), Arabic (Levantine), Arabic (Egyptian), Arabic (Moroccan), Arabic (Mesopotamian), Arabic (Ta’izzi-Adeni), Arabic (Tunisian), Arabic (Gulf), Arabic (Algerian), Arabic (Sudanese), Arabic (Libyan), Hebrew, Maltese, Amharic, Tigrinya, Kabyle, Somali, West Central Oromo, Hausa
|
| 407 |
+
Austronesian Indonesian, Malay, Tagalog, Cebuano, Javanese, Sundanese, Minangkabau, Balinese, Banjar, Pangasinan, Iloko, Waray (Philippines), Plateau Malagasy, Malagasy, Buginese, Maori, Samoan, Hawaiian, Fijian
|
| 408 |
+
Dravidian Tamil, Telugu, Kannada, Malayalam
|
| 409 |
+
Turkic Turkish, North Azerbaijani, Northern Uzbek, Kazakh, Bashkir, Tatar, Crimean Tatar, Kyrgyz, Turkmen, Uyghur
|
| 410 |
+
Tai-Kadai Thai, Lao, Shan
|
| 411 |
+
Uralic Finnish, Estonian, Hungarian, Meadow Mari
|
| 412 |
+
Austroasiatic Vietnamese, Khmer
|
| 413 |
+
Niger–Congo Yoruba, Ewe, Kinyarwanda, Lingala, Northern Sotho, Nyanja, Shona, Southern Sotho, Tswana, Xhosa, Zulu, Luganda, Swati, Tsonga, Tumbuka, Venda, Chokwe, Luba-Kasai, Rundi, Umbundu, Kikuyu, Kongo, Nigerian Fulfulde, Wolof, Fon, Kabiyè, Mossi, Akan, Twi, Bambara, Igbo
|
| 414 |
+
Other Japanese, Korean, Georgian, Basque, Haitian, Papiamento, Kabuverdianu, Tok Pisin, Swahili, Central Aymara, Tulu, Nagamese, Nigerian Pidgin, Mauritian Creole, Sango, Ayacucho Quechua, Halh Mongolian, Southwestern Dinka, Nuer, Guarani"""
|
| 415 |
+
|
| 416 |
+
LANGUAGE_CHOICES = []
|
| 417 |
+
for line in LANGUAGE_DATA.strip().split('\n'):
|
| 418 |
+
family, langs = line.split('\t')
|
| 419 |
+
for lang in langs.split(', '):
|
| 420 |
+
LANGUAGE_CHOICES.append(f"{family} - {lang}")
|
| 421 |
+
|
| 422 |
+
def create_demo():
|
| 423 |
+
with gr.Blocks(title="LocalDuo") as demo:
|
| 424 |
+
gr.Markdown("# 🇰🇷✨ LocalDuo - Learn Korean from PDFs")
|
| 425 |
+
gr.Markdown("Upload a Korean book 📖 or document PDF 📄. The app uses **vLLM** 🧠 with Qwen3.5-2B to extract vocabulary from text and images, and Supertonic 🗣️ to generate pronunciation audio.")
|
| 426 |
+
|
| 427 |
+
with gr.Row():
|
| 428 |
+
with gr.Column(scale=1):
|
| 429 |
+
pdf_input = gr.File(label="Upload Book PDF 📚", file_types=[".pdf"])
|
| 430 |
+
|
| 431 |
+
gr.Markdown("### ⚙️ Customization Settings")
|
| 432 |
+
translit_lang = gr.Dropdown(
|
| 433 |
+
label="Word Transliteration Language",
|
| 434 |
+
choices=LANGUAGE_CHOICES,
|
| 435 |
+
value="Indo-European - English"
|
| 436 |
+
)
|
| 437 |
+
translit_format = gr.Dropdown(label="Transliteration Format", choices=["dashed syllable", "regular word with space"], value="dashed syllable")
|
| 438 |
+
target_lang = gr.Dropdown(
|
| 439 |
+
label="Target Language (Full App)",
|
| 440 |
+
choices=LANGUAGE_CHOICES,
|
| 441 |
+
value="Indo-European - English"
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
submit_btn = gr.Button("✨ Generate Flashcards ✨", variant="primary")
|
| 445 |
+
|
| 446 |
+
with gr.Column(scale=2):
|
| 447 |
+
output_html = gr.HTML(label="Flashcards will appear here")
|
| 448 |
+
|
| 449 |
+
submit_btn.click(
|
| 450 |
+
fn=process_pdf,
|
| 451 |
+
inputs=[pdf_input, translit_lang, translit_format, target_lang],
|
| 452 |
+
outputs=output_html
|
| 453 |
+
)
|
| 454 |
+
return demo
|
| 455 |
+
|
| 456 |
+
if __name__ == "__main__":
|
| 457 |
+
print("Loading Qwen3.5-2B model via vLLM...")
|
| 458 |
+
llm = LLM(
|
| 459 |
+
model="Qwen/Qwen3.5-2B",
|
| 460 |
+
# model="Qwen/Qwen3.5-9B",
|
| 461 |
+
max_model_len=65536, # Reduced from 262144 to fit on single GPU
|
| 462 |
+
tensor_parallel_size=1, # Kept at 1 since CUDA_VISIBLE_DEVICES=1
|
| 463 |
+
gpu_memory_utilization=0.5,
|
| 464 |
+
enable_prefix_caching=True,
|
| 465 |
+
trust_remote_code=True,
|
| 466 |
+
limit_mm_per_prompt={"image": 10} # Added image limits
|
| 467 |
+
)
|
| 468 |
|
| 469 |
+
sampling_params = SamplingParams(
|
| 470 |
+
temperature=1.0,
|
| 471 |
+
top_p=0.95,
|
| 472 |
+
top_k=20,
|
| 473 |
+
min_p=0.0,
|
| 474 |
+
presence_penalty=0.0,
|
| 475 |
+
repetition_penalty=1.0,
|
| 476 |
+
max_tokens=2048,
|
| 477 |
+
)
|
| 478 |
|
| 479 |
+
print("Loading Supertonic TTS...")
|
| 480 |
+
tts = TTS(model="supertonic-3")
|
| 481 |
+
try:
|
| 482 |
+
voice_style = tts.get_voice_style("F1")
|
| 483 |
+
except Exception:
|
| 484 |
+
voice_style = tts.get_voice_style(tts.voice_style_names[0])
|
| 485 |
+
|
| 486 |
+
demo = create_demo()
|
| 487 |
+
demo.launch(server_name="0.0.0.0", server_port=7861)
|