Spaces:
Paused
Paused
Download app.py from hodde/accentbridge: direct link, hf CLI and curl.
- Browser
- Download file 21.8 kB
-
https://huggingface.co/spaces/hodde/accentbridge/resolve/main/app.py
- Command line
-
hf download hf://spaces/hodde/accentbridge/app.py
-
curl -L -o app.py https://huggingface.co/spaces/hodde/accentbridge/resolve/main/app.py
21.8 kB
| import os | |
| import re | |
| import json | |
| from collections import Counter | |
| from difflib import SequenceMatcher | |
| import numpy as np | |
| import soundfile as sf | |
| import gradio as gr | |
| from accent_engine import AccentBridgeEngine, phonetic_similarity, normalize_audio_safe, STOPWORDS | |
| from slide_parser import ( | |
| extract_slides_from_file, | |
| extract_priority_keywords, | |
| extract_acronyms, | |
| get_distinctive_next_keywords, | |
| should_auto_advance, | |
| ) | |
| engine = AccentBridgeEngine(finetuned_dir="./saved_model") | |
| SAMPLE_DECK_TEXT = """Slide 1: Project Overview & API Architecture | |
| Today we present our baseline model on the right slide and explain three core API modules in our open source file system. | |
| --- | |
| Slide 2: Dataset & WER Evaluation | |
| Next we evaluate WER and keyword recall across Asian accents using unseen test speakers on GPU. | |
| --- | |
| Slide 3: Failure Cases & Future Strategy | |
| Finally we analyze where the algorithm fails on rapid overlapping speech and outline our future strategy.""" | |
| def load_judge_demo_clips() -> list: | |
| manifest_path = "./demo_samples/manifest.json" | |
| if not os.path.exists(manifest_path): | |
| return [] | |
| try: | |
| with open(manifest_path, "r", encoding="utf-8") as f: | |
| items = json.load(f) | |
| return [x for x in items if os.path.exists(x.get("file", ""))] | |
| except Exception: | |
| return [] | |
| def format_srt_time(seconds: float) -> str: | |
| hrs, mins, secs = int(seconds // 3600), int((seconds % 3600) // 60), int(seconds % 60) | |
| millis = int((seconds - int(seconds)) * 1000) | |
| return f"{hrs:02d}:{mins:02d}:{secs:02d},{millis:03d}" | |
| def export_srt_file(history: list) -> str: | |
| path = "./accentbridge_subtitles.srt" | |
| lines, cursor = [], 0.0 | |
| for idx, item in enumerate(history, start=1): | |
| dur = max(2.0, item.get("duration", 4.0)) | |
| lines.append(f"{idx}\n{format_srt_time(cursor)} --> {format_srt_time(cursor + dur)}\n{item['text']}\n") | |
| cursor += dur + 0.3 | |
| with open(path, "w", encoding="utf-8") as f: | |
| f.write("\n".join(lines) if lines else "1\n00:00:00,000 --> 00:00:02,000\nNo subtitles recorded yet.\n") | |
| return path | |
| def build_dropdown_choices(slides: list) -> list: | |
| return [f"หน้าที่ {i + 1}: {s['title'][:45]}" for i, s in enumerate(slides)] | |
| def render_slide_card(slides: list, idx: int) -> str: | |
| if not slides: | |
| return "📌 **ยังไม่ได้โหลดสไลด์** — กดปุ่ม **'🎯 โหลดสไลด์ตัวอย่าง 3 หน้า'** ด้านบนเพื่อทดลองทันที หรืออัปโหลดไฟล์ของคุณเองได้เลยค่ะ" | |
| idx = max(0, min(idx, len(slides) - 1)) | |
| s = slides[idx] | |
| ac_note = f" | 🔤 ตัวย่อบนสไลด์: {', '.join(a.upper() for a in s.get('acronyms', []))}" if s.get("acronyms") else "" | |
| return f"### 📑 สไลด์หน้าที่ {idx + 1} / {len(slides)}: {s['title']}{ac_note}\n\n{s['text'][:500]}" | |
| def render_word_diff_html(base_txt: str, ab_txt: str) -> str: | |
| if not base_txt and not ab_txt: | |
| return "<div style='color:#64748b;padding:10px;background:#f8fafc;border-radius:8px;'><b>Visual Word Diff:</b> รอเปรียบเทียบคำผิด (ขีดฆ่าสีแดง) กับคำที่แก้สำเร็จ (สีเขียว)...</div>" | |
| b_words, a_words = base_txt.split(), ab_txt.split() | |
| chunks = [] | |
| for tag, i1, i2, j1, j2 in SequenceMatcher(None, b_words, a_words).get_opcodes(): | |
| if tag == "equal": | |
| chunks.append(" ".join(a_words[j1:j2])) | |
| else: | |
| if i1 < i2: | |
| chunks.append(f"<del style='background:#fee2e2;color:#991b1b;padding:1px 6px;border-radius:4px;'>{' '.join(b_words[i1:i2])}</del>") | |
| if j1 < j2: | |
| chunks.append(f"<span style='background:#dcfce7;color:#166534;padding:1px 6px;border-radius:4px;font-weight:bold;'>{' '.join(a_words[j1:j2])} ✨</span>") | |
| return "<div style='font-size:1.08rem;line-height:1.8;padding:10px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;'><b>Visual Diff (Baseline → AccentBridge):</b><br>" + " ".join(chunks) + "</div>" | |
| def load_presentation(slide_file, rough_script): | |
| slides = extract_slides_from_file(slide_file) | |
| if not slides and rough_script and rough_script.strip(): | |
| blocks = [b.strip() for b in re.split(r"\n\s*---\s*\n|\n\s*\n", rough_script) if b.strip()] | |
| slides = [ | |
| {"index": i, "title": b.splitlines()[0][:65], "text": b, "keywords": extract_priority_keywords(b, 32), "acronyms": extract_acronyms(b)} | |
| for i, b in enumerate(blocks) | |
| ] | |
| if not slides: | |
| return [], 0, render_slide_card([], 0), "file, right, slide, three, write, open source, api, wer", gr.update(choices=[], value=None) | |
| choices = build_dropdown_choices(slides) | |
| return slides, 0, render_slide_card(slides, 0), ", ".join(slides[0]["keywords"]), gr.update(choices=choices, value=choices[0]) | |
| def load_sample_deck(): | |
| slides, idx, card, kws, dd_update = load_presentation(None, SAMPLE_DECK_TEXT) | |
| return SAMPLE_DECK_TEXT, slides, idx, card, kws, dd_update | |
| def pick_judge_clip(clip_idx: int): | |
| clips = load_judge_demo_clips() | |
| if clip_idx < len(clips): | |
| c = clips[clip_idx] | |
| wav_arr, sr = sf.read(c["file"], dtype="float32") | |
| int16_arr = (np.clip(wav_arr, -1.0, 1.0) * 32767).astype(np.int16) | |
| return (sr, int16_arr), c["keywords"] | |
| return None, "file, right, slide, three, model" | |
| def save_current_keywords(slides: list, idx: int, edited_keywords: str) -> list: | |
| if slides and 0 <= idx < len(slides): | |
| slides[idx]["keywords"] = engine.parse_hotwords(edited_keywords) | |
| return slides | |
| def change_slide_manual(slides: list, current_idx: int, target_idx: int, current_keywords: str): | |
| if not slides: | |
| return slides, 0, render_slide_card([], 0), current_keywords, gr.update() | |
| slides = save_current_keywords(slides, current_idx, current_keywords) | |
| new_idx = max(0, min(target_idx, len(slides) - 1)) | |
| choices = build_dropdown_choices(slides) | |
| return slides, new_idx, render_slide_card(slides, new_idx), ", ".join(slides[new_idx]["keywords"]), gr.update(choices=choices, value=choices[new_idx]) | |
| def jump_to_slide_dd(selected_label: str, slides: list, current_idx: int, current_keywords: str): | |
| if not slides or not selected_label: | |
| return slides, current_idx, render_slide_card(slides, current_idx), current_keywords | |
| choices = build_dropdown_choices(slides) | |
| target_idx = choices.index(selected_label) if selected_label in choices else current_idx | |
| slides = save_current_keywords(slides, current_idx, current_keywords) | |
| return slides, target_idx, render_slide_card(slides, target_idx), ", ".join(slides[target_idx]["keywords"]) | |
| def extra_sensitivity_adjust(text: str, rescued: list, hotwords: list, mode: str, word_confs: dict): | |
| if mode == "Conservative (แก้เฉพาะคำที่เสียงใกล้เคียงมาก)": | |
| filtered_rescued = [(o, n) for o, n in rescued if phonetic_similarity(o, n) >= 0.87] | |
| reverted = {n: o for o, n in rescued if (o, n) not in filtered_rescued} | |
| return " ".join([reverted.get(w, w) for w in text.split()]), filtered_rescued | |
| if mode == "Aggressive (ช่วยสำเนียงไม่ชัดเต็มที่)": | |
| words, single_hw, new_rescued = text.split(), [h for h in hotwords if " " not in h], list(rescued) | |
| already_fixed = {w_part for _, n in rescued for w_part in n.split()} | |
| for i, w in enumerate(words): | |
| if w in STOPWORDS or w in single_hw or w in already_fixed or word_confs.get(w, 0.65) >= 0.92: | |
| continue | |
| best_hw, best_sim = None, 0.0 | |
| for hw in single_hw: | |
| if abs(len(hw) - len(w)) <= 3: | |
| sim = phonetic_similarity(w, hw) | |
| if sim > best_sim: | |
| best_sim, best_hw = sim, hw | |
| if best_hw and best_sim >= 0.74: | |
| words[i] = best_hw | |
| new_rescued.append((w, best_hw)) | |
| return " ".join(words), new_rescued | |
| return text, rescued | |
| def render_live_banner(text: str, rescued_pairs: list, hotwords: list, word_confs: dict) -> str: | |
| rescued_targets = {part for _, hw in rescued_pairs for part in hw.split()} | |
| hw_set = {part for hw in hotwords for part in hw.split()} | |
| styled_words = [] | |
| for w in text.split(): | |
| conf = word_confs.get(w, 0.75) | |
| if w in rescued_targets: | |
| styled_words.append(f"<span style='background:#22c55e;color:#ffffff;padding:2px 8px;border-radius:6px;'>{w} ✨</span>") | |
| elif w in hw_set: | |
| styled_words.append(f"<span style='background:#3b82f6;color:#ffffff;padding:2px 8px;border-radius:6px;'>{w}</span>") | |
| elif conf < 0.50 and w not in STOPWORDS: | |
| styled_words.append(f"<span style='border-bottom:3px dashed #f59e0b;color:#fde68a;'>{w}</span>") | |
| else: | |
| styled_words.append(w) | |
| return ( | |
| "<div style='background:#0f172a;color:#f8fafc;padding:18px 24px;border-radius:12px;" | |
| "font-size:1.45rem;font-weight:600;text-align:center;line-height:1.75;box-shadow:0 4px 12px rgba(0,0,0,0.18);'>" | |
| + (" ".join(styled_words) if styled_words else "🎙️ รอรับเสียงพูดของคุณ... (เขียว ✨ = แก้คำเพี้ยนด้วย CMU Phonemes | ฟ้า = คำตรงสไลด์)") | |
| + "</div>" | |
| ) | |
| def format_coaching_summary(history: list) -> str: | |
| all_pairs = [pair for h in history for pair in h.get("rescued", [])] | |
| if not all_pairs: | |
| return "ยังไม่มีคำที่ต้องช่วยแก้" | |
| counts = Counter(all_pairs) | |
| return "💡 สรุปคำศัพท์เฉพาะตัวที่ผู้พูดควรเน้นเสียง:\n" + "\n".join( | |
| [f"• คำว่า '{target}' (ได้ยินคล้าย '{heard}' — แก้ช่วย {cnt} ครั้ง)" for (heard, target), cnt in counts.most_common(6)] | |
| ) | |
| def process_speech(audio_tuple, custom_keywords, rough_script, slides, current_idx, auto_advance_on, sensitivity_mode, gate_on, history): | |
| if audio_tuple is None: | |
| return ( | |
| render_live_banner("", [], [], {}), render_word_diff_html("", ""), "", slides, current_idx, | |
| render_slide_card(slides, current_idx), custom_keywords, gr.update(), | |
| history, "ยังไม่มีประวัติการพูด", format_coaching_summary(history), None, | |
| ) | |
| slides = save_current_keywords(slides, current_idx, custom_keywords) | |
| sr, raw_arr = audio_tuple | |
| arr = normalize_audio_safe(raw_arr, sr) | |
| duration_sec = len(arr) / 16000.0 | |
| curr_slide = slides[current_idx] if (slides and 0 <= current_idx < len(slides)) else None | |
| next_slide = slides[current_idx + 1] if (slides and auto_advance_on and current_idx + 1 < len(slides)) else None | |
| active_context = curr_slide["text"] if curr_slide else (rough_script or "") | |
| slide_topic = curr_slide["title"] if curr_slide else "" | |
| lookahead_words = get_distinctive_next_keywords(curr_slide, next_slide, limit=8) if next_slide else [] | |
| if next_slide: | |
| active_context = active_context + "\n" + next_slide["text"] | |
| combined_hw_str = (custom_keywords + ", " + ", ".join(lookahead_words)).strip(", ") if lookahead_words else custom_keywords | |
| baseline_txt = engine.transcribe_baseline(arr) | |
| ab_txt, rescued = engine.transcribe_accentbridge( | |
| arr, hotwords_raw=combined_hw_str, context_text=active_context, slide_topic=slide_topic, use_confidence_gate=gate_on | |
| ) | |
| if not ab_txt: | |
| return ( | |
| render_live_banner("", [], [], {}), render_word_diff_html("", ""), "⚠️ ไม่พบเสียงพูดที่ชัดเจน", | |
| slides, current_idx, render_slide_card(slides, current_idx), custom_keywords, | |
| gr.update(), history, "\n".join([f"[Slide {h['slide']}] {h['text']}" for h in history]), | |
| format_coaching_summary(history), None, | |
| ) | |
| word_confs = dict(engine.last_word_confidences) | |
| active_hotwords = engine.parse_hotwords(combined_hw_str, context_text=active_context) | |
| ab_txt, rescued = extra_sensitivity_adjust(ab_txt, rescued, active_hotwords, sensitivity_mode, word_confs) | |
| new_idx, updated_keywords, advance_note, dd_update = current_idx, custom_keywords, "", gr.update() | |
| if curr_slide and next_slide and auto_advance_on: | |
| should_move, reason = should_auto_advance(ab_txt, curr_slide, next_slide, phonetic_similarity) | |
| if should_move: | |
| new_idx = current_idx + 1 | |
| updated_keywords = ", ".join(slides[new_idx]["keywords"]) | |
| choices = build_dropdown_choices(slides) | |
| dd_update = gr.update(choices=choices, value=choices[new_idx]) | |
| advance_note = reason | |
| used_slide_num = (new_idx + 1) if advance_note else (current_idx + 1 if curr_slide else 1) | |
| history = list(history) + [{"slide": used_slide_num, "text": ab_txt, "duration": duration_sec, "rescued": rescued}] | |
| srt_path = export_srt_file(history) | |
| banner_html = render_live_banner(ab_txt, rescued, active_hotwords, word_confs) | |
| diff_html = render_word_diff_html(baseline_txt, ab_txt) | |
| avg_conf = (sum(word_confs.values()) / len(word_confs) * 100.0) if word_confs else 75.0 | |
| logs = [f"🎙️ ความยาว {duration_sec:.1f}s | ความมั่นใจเฉลี่ย {avg_conf:.0f}%"] | |
| if rescued: | |
| logs.append("✅ แก้คำ/ตัวย่อสำเร็จ: " + ", ".join([f"'{o}' → '{n}'" for o, n in rescued])) | |
| if advance_note: | |
| logs.append(advance_note) | |
| return ( | |
| banner_html, diff_html, " | ".join(logs), slides, new_idx, | |
| render_slide_card(slides, new_idx), updated_keywords, dd_update, | |
| history, "\n".join([f"[Slide {h['slide']}] {h['text']}" for h in history]), | |
| format_coaching_summary(history), srt_path, | |
| ) | |
| def clear_transcript_history(): | |
| return [], "ล้างประวัติซับไตเติลแล้ว", "ล้างข้อมูลสรุปคำฝึกออกเสียงแล้ว", None | |
| status_msg = ( | |
| "✅ โหลดโมเดล Fine-tuned AccentBridge + พจนานุกรมเสียง CMU ARPAbet สำเร็จ" | |
| if engine.using_finetuned | |
| else "⚡ โหมดพร้อมทดสอบทันที (รัน python train_and_eval.py เพื่อสร้างไฟล์โมเดล Fine-tuned)" | |
| ) | |
| with gr.Blocks(title="AccentBridge — Phoneme & Slide-Synced Subtitle AI") as demo: | |
| slides_state, idx_state, history_state = gr.State([]), gr.State(0), gr.State([]) | |
| gr.Markdown(f"# 🎙️ AccentBridge: Phoneme-Aligned & Slide-Synced Subtitle AI\n**สถานะ:** {status_msg}") | |
| live_banner = gr.HTML(render_live_banner("", [], [], {})) | |
| with gr.Accordion("📄 ขั้นตอนที่ 1: โหลดไฟล์ Presentation หรือกดปุ่มโหลดสไลด์ตัวอย่าง 1 คลิก", open=True): | |
| sample_btn = gr.Button("🎯 โหลดสไลด์ตัวอย่าง 3 หน้าพร้อมทดลองทันที (1-Click Sample Deck)", variant="secondary") | |
| with gr.Row(): | |
| slide_upload = gr.File(file_types=[".pptx", ".pdf", ".docx", ".txt", ".md"], type="filepath", label="อัปโหลดไฟล์สไลด์ (.pptx, .pdf, .docx, .txt)") | |
| rough_script_box = gr.Textbox(lines=3, label="หรือวางบทพูดแบ่งหน้าด้วยเครื่องหมาย ---") | |
| load_btn = gr.Button("📂 แยกหน้าสไลด์จากไฟล์/ข้อความด้านบน") | |
| with gr.Group(): | |
| gr.Markdown("### 🖥️ ขั้นตอนที่ 2: แผงควบคุมหน้าสไลด์ (กดเปลี่ยนมือ + ข้ามหน้า Q&A + เลื่อนหน้าอัตโนมัติ)") | |
| slide_display_md = gr.Markdown(render_slide_card([], 0)) | |
| with gr.Row(): | |
| prev_btn = gr.Button("◀ หน้าก่อนหน้า (Prev)") | |
| slide_jump_dd = gr.Dropdown(choices=[], label="เลือกข้ามไปหน้าสไลด์ทันที (ช่วง Q&A)", interactive=True) | |
| next_btn = gr.Button("หน้าถัดไป (Next) ▶") | |
| with gr.Row(): | |
| auto_switch_cb = gr.Checkbox(value=True, label="🔄 เลื่อนหน้าตามเสียงพูดอัตโนมัติ") | |
| gate_cb = gr.Checkbox(value=True, label="🛡️ เปิด Acoustic Confidence Gate") | |
| sensitivity_radio = gr.Radio( | |
| choices=["Conservative (แก้เฉพาะคำที่เสียงใกล้เคียงมาก)", "Balanced (สมดุล แนะนำ)", "Aggressive (ช่วยสำเนียงไม่ชัดเต็มที่)"], | |
| value="Balanced (สมดุล แนะนำ)", label="🎚️ ระดับความเข้มข้นในการดักคำจากสไลด์", | |
| ) | |
| keywords_box = gr.Textbox(value="file, right, slide, three, write, open source, api, wer, baseline", label="🎯 คำและตัวย่อเฉพาะของหน้านี้", lines=2) | |
| sample_btn.click(fn=load_sample_deck, inputs=[], outputs=[rough_script_box, slides_state, idx_state, slide_display_md, keywords_box, slide_jump_dd]) | |
| load_btn.click(fn=load_presentation, inputs=[slide_upload, rough_script_box], outputs=[slides_state, idx_state, slide_display_md, keywords_box, slide_jump_dd]) | |
| prev_btn.click(fn=lambda s, i, k: change_slide_manual(s, i, i - 1, k), inputs=[slides_state, idx_state, keywords_box], outputs=[slides_state, idx_state, slide_display_md, keywords_box, slide_jump_dd]) | |
| next_btn.click(fn=lambda s, i, k: change_slide_manual(s, i, i + 1, k), inputs=[slides_state, idx_state, keywords_box], outputs=[slides_state, idx_state, slide_display_md, keywords_box, slide_jump_dd]) | |
| slide_jump_dd.select(fn=jump_to_slide_dd, inputs=[slide_jump_dd, slides_state, idx_state, keywords_box], outputs=[slides_state, idx_state, slide_display_md, keywords_box]) | |
| gr.Markdown("### 🎤 ขั้นตอนที่ 3: พูดใส่ไมค์ หรือกดปุ่มคลิปเสียงตัวอย่างเพื่อให้กรรมการทดสอบได้ใน 1 คลิก") | |
| with gr.Row(): | |
| audio_in = gr.Audio(sources=["microphone", "upload"], type="numpy", label="อัดเสียงพูด หรืออัปโหลดไฟล์เสียง") | |
| with gr.Column(): | |
| run_btn = gr.Button("🚀 แปลงเสียงเป็นซับไตเติล", variant="primary") | |
| diff_out = gr.HTML(render_word_diff_html("", "")) | |
| rescued_log = gr.Textbox(label="🔍 สถานะเสียง & บันทึกการแก้คำ/เปลี่ยนหน้า") | |
| judge_clips = load_judge_demo_clips() | |
| if judge_clips: | |
| with gr.Row(): | |
| clip1_btn = gr.Button(f"🎧 โหลดคลิปเสียงจริงตัวอย่างที่ 1 (สำเนียง {judge_clips[0]['l1']})") | |
| clip1_btn.click(fn=lambda: pick_judge_clip(0), inputs=[], outputs=[audio_in, keywords_box]) | |
| if len(judge_clips) > 1: | |
| clip2_btn = gr.Button(f"🎧 โหลดคลิปเสียงจริงตัวอย่างที่ 2 (สำเนียง {judge_clips[1]['l1']})") | |
| clip2_btn.click(fn=lambda: pick_judge_clip(1), inputs=[], outputs=[audio_in, keywords_box]) | |
| with gr.Accordion("📝 ประวัติซับไตเติลสะสม, คำแนะนำฝึกออกเสียง & ดาวน์โหลดไฟล์ .SRT", open=True): | |
| history_box = gr.Textbox(lines=4, label="บทบันทึกซับไตเติลเรียงตามหน้าสไลด์", interactive=False) | |
| coaching_box = gr.Textbox(lines=3, label="🎓 สรุปคำที่ผู้พูดออกเสียงเพี้ยนบ่อย", interactive=False) | |
| with gr.Row(): | |
| srt_file_out = gr.File(label="📥 ดาวน์โหลดไฟล์ซับไตเติล (.srt)") | |
| clear_btn = gr.Button("🗑️ ล้างประวัติซับไตเติล") | |
| speech_inputs = [audio_in, keywords_box, rough_script_box, slides_state, idx_state, auto_switch_cb, sensitivity_radio, gate_cb, history_state] | |
| speech_outputs = [live_banner, diff_out, rescued_log, slides_state, idx_state, slide_display_md, keywords_box, slide_jump_dd, history_state, history_box, coaching_box, srt_file_out] | |
| audio_in.stop_recording(fn=process_speech, inputs=speech_inputs, outputs=speech_outputs) | |
| run_btn.click(fn=process_speech, inputs=speech_inputs, outputs=speech_outputs) | |
| clear_btn.click(fn=clear_transcript_history, inputs=[], outputs=[history_state, history_box, coaching_box, srt_file_out]) | |
| if __name__ == "__main__": | |
| demo.launch(share=("SPACE_ID" not in os.environ)) | |