Spaces:
Sleeping
Sleeping
Download app.py from l1n4n/shadowloop: direct link, hf CLI and curl.
- Browser
- Download file 15.4 kB
-
https://huggingface.co/spaces/l1n4n/shadowloop/resolve/main/app.py
- Command line
-
hf download hf://spaces/l1n4n/shadowloop/app.py
-
curl -L -o app.py https://huggingface.co/spaces/l1n4n/shadowloop/resolve/main/app.py
15.4 kB
| """ShadowLoop - Gradio UI for shadowing practice.""" | |
| import os | |
| import sys | |
| import tempfile | |
| from typing import List | |
| # Ensure src/ is on the path for HF Spaces deployment | |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) | |
| import gradio as gr | |
| from shadowloop.config import Segment, ShadowingConfig, segments_from_dicts | |
| from shadowloop.ingest import check_ffmpeg, load_audio, prepare_for_transcription, export_temp_wav | |
| from shadowloop.segmenter import segment_audio, merge_segments, split_segment, delete_segment, segments_from_transcript | |
| from shadowloop.planner import build_plan, estimate_duration_ms | |
| from shadowloop.builder import build_audio | |
| from shadowloop.exporters import export_mp3 | |
| CUSTOM_CSS = """ | |
| /* Tight section spacing */ | |
| .phase-section { padding: 8px 12px !important; margin-bottom: 4px !important; border-radius: 6px; } | |
| /* Section titles — via elem_id */ | |
| #title-upload h3 { font-size: 20px !important; font-weight: 600; color: #5b5ea6 !important; margin: 0 !important; } | |
| #title-review h3 { font-size: 20px !important; font-weight: 600; color: #9a7b4f !important; margin: 0 !important; } | |
| #title-build h3 { font-size: 20px !important; font-weight: 600; color: #4a8c72 !important; margin: 0 !important; } | |
| /* Description text — same color as titles, via elem_id */ | |
| #desc-upload p { font-size: 15px !important; font-weight: 400; color: #5b5ea6 !important; margin: 0 0 4px 0 !important; padding: 0 !important; } | |
| #desc-review p { font-size: 15px !important; font-weight: 400; color: #9a7b4f !important; margin: 0 0 4px 0 !important; padding: 0 !important; } | |
| #desc-build p { font-size: 15px !important; font-weight: 400; color: #4a8c72 !important; margin: 0 0 4px 0 !important; padding: 0 !important; } | |
| /* Intro — compact */ | |
| #intro-text h1 { font-size: 26px !important; margin-bottom: 0 !important; } | |
| #intro-text p { font-size: 18px !important; color: #78716c !important; margin: 0 !important; padding: 0 !important; } | |
| /* Color-coded How it works steps */ | |
| .step-upload { color: #5b5ea6; font-weight: 600; } | |
| .step-review { color: #9a7b4f; font-weight: 600; } | |
| .step-build { color: #4a8c72; font-weight: 600; } | |
| /* Remove group borders/padding that Gradio adds */ | |
| .phase-section > .panel { padding: 0 !important; border: none !important; } | |
| """ | |
| def generate_transcript_file(segments: List[Segment]) -> str | None: | |
| """Write segments to a temp .txt file for download.""" | |
| if not segments: | |
| return None | |
| path = os.path.join(tempfile.gettempdir(), "shadowloop_transcript.txt") | |
| with open(path, "w", encoding="utf-8") as f: | |
| for seg in segments: | |
| f.write(f"[{seg.start_ms / 1000:.1f}s - {seg.end_ms / 1000:.1f}s] {seg.text}\n") | |
| return path | |
| def segments_to_html(segments: List[Segment]) -> str: | |
| """Render segments as an HTML table.""" | |
| if not segments: | |
| return "<p>No segments yet.</p>" | |
| rows = "" | |
| for seg in segments: | |
| start_s = seg.start_ms / 1000 | |
| end_s = seg.end_ms / 1000 | |
| dur_s = seg.duration_ms / 1000 | |
| rows += ( | |
| f"<tr><td>{seg.id}</td><td>{seg.text}</td>" | |
| f"<td>{start_s:.1f}s</td><td>{end_s:.1f}s</td><td>{dur_s:.1f}s</td></tr>" | |
| ) | |
| return ( | |
| '<table style="width:100%;border-collapse:collapse;font-size:16px;">' | |
| '<tr style="background:#f0f0f0;"><th>ID</th><th>Text</th>' | |
| "<th>Start</th><th>End</th><th>Duration</th></tr>" | |
| f"{rows}</table>" | |
| ) | |
| def on_transcribe(audio_file): | |
| """Transcribe audio file and segment into sentences.""" | |
| if audio_file is None: | |
| return None, None, "<p>Please upload an audio file.</p>", "Please upload an audio file.", None | |
| try: | |
| audio = load_audio(audio_file) | |
| asr_audio = prepare_for_transcription(audio) | |
| wav_path = export_temp_wav(asr_audio) | |
| try: | |
| segments = segment_audio(wav_path, len(audio)) | |
| finally: | |
| os.unlink(wav_path) | |
| if not segments: | |
| return audio, None, "<p>No speech detected.</p>", "No speech detected in the audio.", None | |
| html = segments_to_html(segments) | |
| transcript_path = generate_transcript_file(segments) | |
| return audio, segments, html, f"Found {len(segments)} sentences. Review and edit below.", transcript_path | |
| except Exception as e: | |
| return None, None, f"<p>Error: {e}</p>", f"Error: {e}", None | |
| def on_upload_transcript(audio_state, transcript_file): | |
| """Load segments from uploaded transcript file.""" | |
| if audio_state is None: | |
| return None, None, "<p>Please upload audio first.</p>", "Please upload audio first.", None | |
| if transcript_file is None: | |
| return None, None, "<p>Please upload a transcript file.</p>", "Please upload a transcript file.", None | |
| try: | |
| with open(transcript_file, "r", encoding="utf-8") as f: | |
| text = f.read() | |
| audio = load_audio(audio_state) | |
| audio_duration_ms = len(audio) | |
| segments = segments_from_transcript(text, audio_duration_ms) | |
| if not segments: | |
| return None, None, "<p>No text found in transcript.</p>", "No text found in transcript.", None | |
| html = segments_to_html(segments) | |
| transcript_path = generate_transcript_file(segments) | |
| return audio, segments, html, f"Loaded {len(segments)} segments from transcript.", transcript_path | |
| except Exception as e: | |
| return None, None, f"<p>Error: {e}</p>", f"Error: {e}", None | |
| def on_merge(segments_state, selected_ids_text): | |
| """Merge two adjacent segments.""" | |
| segments = segments_from_dicts(segments_state) if segments_state else [] | |
| if not segments: | |
| return segments_state, "<p>No segments loaded.</p>", "No segments loaded." | |
| try: | |
| ids = [int(x.strip()) for x in selected_ids_text.split(",")] | |
| if len(ids) != 2: | |
| return segments_state, segments_to_html(segments), "Select exactly 2 adjacent segment IDs (e.g. '0,1')." | |
| new_segments = merge_segments(segments, ids[0], ids[1]) | |
| return new_segments, segments_to_html(new_segments), f"Merged segments {ids[0]} and {ids[1]}." | |
| except Exception as e: | |
| return segments_state, segments_to_html(segments), f"Merge error: {e}" | |
| def on_split(segments_state, segment_id, split_time_s): | |
| """Split a segment at the specified time offset.""" | |
| segments = segments_from_dicts(segments_state) if segments_state else [] | |
| if not segments: | |
| return segments_state, "<p>No segments loaded.</p>", "No segments loaded." | |
| try: | |
| seg_id = int(segment_id) | |
| offset_ms = int(float(split_time_s) * 1000) | |
| new_segments = split_segment(segments, seg_id, offset_ms) | |
| return new_segments, segments_to_html(new_segments), f"Split segment {seg_id} at {split_time_s}s." | |
| except Exception as e: | |
| return segments_state, segments_to_html(segments), f"Split error: {e}" | |
| def on_delete(segments_state, segment_id): | |
| """Delete a segment.""" | |
| segments = segments_from_dicts(segments_state) if segments_state else [] | |
| if not segments: | |
| return segments_state, "<p>No segments loaded.</p>", "No segments loaded." | |
| try: | |
| seg_id = int(segment_id) | |
| new_segments = delete_segment(segments, seg_id) | |
| return new_segments, segments_to_html(new_segments), f"Deleted segment {seg_id}." | |
| except Exception as e: | |
| return segments_state, segments_to_html(segments), f"Delete error: {e}" | |
| def on_estimate(segments_state, repeat_count, pause_repeats, pause_sentences, speed): | |
| """Estimate output duration.""" | |
| segments = segments_from_dicts(segments_state) if segments_state else [] | |
| if not segments: | |
| return "No segments loaded." | |
| config = ShadowingConfig( | |
| repeat_count=int(repeat_count), | |
| pause_between_repeats_ms=int(float(pause_repeats) * 1000), | |
| pause_between_sentences_ms=int(float(pause_sentences) * 1000), | |
| playback_speed=float(speed), | |
| ) | |
| plan = build_plan(segments, config) | |
| total_ms = estimate_duration_ms(plan, config.playback_speed) | |
| minutes = total_ms // 60000 | |
| seconds = (total_ms % 60000) // 1000 | |
| return f"Estimated output: {minutes}m {seconds}s ({len(segments)} sentences x {config.repeat_count} repeats)" | |
| def on_build(audio_state, segments_state, repeat_count, pause_repeats, pause_sentences, speed): | |
| """Build the practice audio file.""" | |
| segments = segments_from_dicts(segments_state) if segments_state else [] | |
| if audio_state is None or not segments: | |
| return None, None, "Upload and transcribe audio first." | |
| config = ShadowingConfig( | |
| repeat_count=int(repeat_count), | |
| pause_between_repeats_ms=int(float(pause_repeats) * 1000), | |
| pause_between_sentences_ms=int(float(pause_sentences) * 1000), | |
| playback_speed=float(speed), | |
| ) | |
| errors = config.validate() | |
| if errors: | |
| return None, None, "Config errors: " + "; ".join(errors) | |
| try: | |
| plan = build_plan(segments, config) | |
| result_audio = build_audio(audio_state, plan, config) | |
| output_path = os.path.join(tempfile.gettempdir(), "shadowloop_output.mp3") | |
| export_mp3(result_audio, output_path) | |
| total_ms = len(result_audio) | |
| minutes = total_ms // 60000 | |
| seconds = (total_ms % 60000) // 1000 | |
| return output_path, output_path, f"Built practice audio: {minutes}m {seconds}s" | |
| except Exception as e: | |
| return None, None, f"Build error: {e}" | |
| def create_app(): | |
| """Create and return the Gradio app.""" | |
| with gr.Blocks(title="ShadowLoop") as app: | |
| gr.Markdown( | |
| "# ShadowLoop\n" | |
| "Turn any audio into a shadowing practice loop.\n\n" | |
| '**How it works:** <span class="step-upload">Upload audio + transcribe</span> → ' | |
| '<span class="step-review">review the detected sentences</span> → ' | |
| '<span class="step-build">build a practice file</span> that repeats each sentence with pauses.', | |
| elem_id="intro-text", | |
| ) | |
| audio_state = gr.State(value=None) | |
| segments_state = gr.State(value=None) | |
| # Phase 1: Upload | |
| with gr.Group(elem_classes=["phase-upload", "phase-section"]): | |
| gr.Markdown("### 1. Upload Audio", elem_id="title-upload") | |
| gr.Markdown("Upload audio and transcribe, or provide your own transcript.", elem_id="desc-upload") | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| audio_input = gr.Audio(label="Audio file", type="filepath") | |
| transcribe_btn = gr.Button("Transcribe", variant="primary") | |
| with gr.Column(scale=2): | |
| transcript_input = gr.File(label="Upload transcript (.txt)", file_types=[".txt"]) | |
| transcript_btn = gr.Button("Use Transcript") | |
| status_text = gr.Textbox(label="Status", interactive=False) | |
| # Phase 2: Review | |
| with gr.Group(elem_classes=["phase-review", "phase-section"]): | |
| gr.Markdown("### 2. Review Sentences", elem_id="title-review") | |
| gr.Markdown("Check detected sentences. Expand 'Edit Segments' to merge, split, or delete.", elem_id="desc-review") | |
| segments_html = gr.HTML(value="<p>No segments yet.</p>") | |
| transcript_download = gr.File(label="Download Transcript", interactive=False, elem_classes=["compact-file"]) | |
| with gr.Accordion("Edit Segments", open=False): | |
| with gr.Row(): | |
| with gr.Column(): | |
| merge_ids = gr.Textbox(label="Merge IDs (e.g. '0,1')", placeholder="0,1") | |
| merge_btn = gr.Button("Merge") | |
| with gr.Column(): | |
| split_id = gr.Number(label="Split segment ID", precision=0) | |
| split_time = gr.Number(label="Split at (seconds from segment start)") | |
| split_btn = gr.Button("Split") | |
| with gr.Column(): | |
| delete_id = gr.Number(label="Delete segment ID", precision=0) | |
| delete_btn = gr.Button("Delete") | |
| # Phase 3: Build | |
| with gr.Group(elem_classes=["phase-build", "phase-section"]): | |
| gr.Markdown("### 3. Configure & Build", elem_id="title-build") | |
| gr.Markdown("Set repeats and pauses, then build your practice audio.", elem_id="desc-build") | |
| with gr.Row(): | |
| repeat_count = gr.Slider(1, 20, value=3, step=1, label="Repeat count") | |
| pause_repeats = gr.Slider(0, 10, value=2.5, step=0.5, label="Pause between repeats (s)") | |
| pause_sentences = gr.Slider(1, 15, value=4, step=0.5, label="Pause between sentences (s)") | |
| speed = gr.Slider(0.5, 2.0, value=1.0, step=0.01, label="Playback speed") | |
| with gr.Row(): | |
| estimate_btn = gr.Button("Estimate Duration") | |
| estimate_text = gr.Textbox(label="Estimate", interactive=False) | |
| build_btn = gr.Button("Build Practice Audio", variant="primary") | |
| build_status = gr.Textbox(label="Build Status", interactive=False) | |
| with gr.Row(equal_height=True): | |
| output_audio = gr.Audio(label="Preview", type="filepath") | |
| download_file = gr.File(label="Download MP3") | |
| # Wire callbacks (KEEP EXACTLY AS-IS) | |
| transcribe_btn.click( | |
| on_transcribe, | |
| inputs=[audio_input], | |
| outputs=[audio_state, segments_state, segments_html, status_text, transcript_download], | |
| ) | |
| transcript_btn.click( | |
| on_upload_transcript, | |
| inputs=[audio_input, transcript_input], | |
| outputs=[audio_state, segments_state, segments_html, status_text, transcript_download], | |
| ) | |
| merge_btn.click( | |
| on_merge, | |
| inputs=[segments_state, merge_ids], | |
| outputs=[segments_state, segments_html, status_text], | |
| ) | |
| split_btn.click( | |
| on_split, | |
| inputs=[segments_state, split_id, split_time], | |
| outputs=[segments_state, segments_html, status_text], | |
| ) | |
| delete_btn.click( | |
| on_delete, | |
| inputs=[segments_state, delete_id], | |
| outputs=[segments_state, segments_html, status_text], | |
| ) | |
| estimate_btn.click( | |
| on_estimate, | |
| inputs=[segments_state, repeat_count, pause_repeats, pause_sentences, speed], | |
| outputs=[estimate_text], | |
| ) | |
| build_btn.click( | |
| on_build, | |
| inputs=[audio_state, segments_state, repeat_count, pause_repeats, pause_sentences, speed], | |
| outputs=[output_audio, download_file, build_status], | |
| ) | |
| return app | |
| if __name__ == "__main__": | |
| check_ffmpeg() | |
| app = create_app() | |
| theme = gr.themes.Soft( | |
| primary_hue=gr.themes.colors.stone, | |
| secondary_hue=gr.themes.colors.stone, | |
| neutral_hue=gr.themes.colors.stone, | |
| font=gr.themes.GoogleFont("Inter"), | |
| ).set( | |
| slider_color="#78716c", | |
| color_accent="#78716c", | |
| color_accent_soft="#e7e5e4", | |
| button_primary_background_fill="#57534e", | |
| button_primary_background_fill_hover="#44403c", | |
| button_primary_text_color="white", | |
| block_border_width="0px", | |
| ) | |
| app.launch(server_name="0.0.0.0", theme=theme, css=CUSTOM_CSS) | |