dwarkesh commited on
Commit
169a94e
Β·
verified Β·
1 Parent(s): ca213c8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +591 -195
app.py CHANGED
@@ -1,266 +1,662 @@
1
  import gradio as gr
2
  import asyncio
 
3
  from pathlib import Path
4
- from google import genai
5
- from google.genai import types
6
  import os
7
- from dataclasses import dataclass
8
- from typing import Dict
9
- from youtube_transcript_api import YouTubeTranscriptApi
10
  import re
11
  import pandas as pd
12
- import assemblyai as aai
 
 
 
 
 
 
13
 
14
- # Move relevant classes and functions into app.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  @dataclass
16
  class ContentRequest:
17
  prompt_key: str
18
 
19
  class ContentGenerator:
20
- def __init__(self,api_key):
21
  self.current_prompts = self._load_default_prompts()
22
- self.client = genai.Client(api_key=api_key)
23
 
24
  def _load_default_prompts(self) -> Dict[str, str]:
25
- """Load default prompts and examples from files and CSVs."""
26
-
27
- # Load CSV examples
28
- try:
29
- timestamps_df = pd.read_csv("data/Timestamps.csv")
30
- titles_df = pd.read_csv("data/Titles & Thumbnails.csv")
31
- descriptions_df = pd.read_csv("data/Viral Episode Descriptions.csv")
32
- clips_df = pd.read_csv("data/Viral Twitter Clips.csv")
33
-
34
- # Format timestamp examples
35
- timestamp_examples = "\n\n".join(timestamps_df['Timestamps'].dropna().tolist())
36
-
37
- # Format title examples
38
- title_examples = "\n".join([
39
- f'Title: "{row.Titles}"\nThumbnail: "{row.Thumbnail}"'
40
- for _, row in titles_df.iterrows()
41
- ])
42
-
43
- # Format description examples
44
- description_examples = "\n".join([
45
- f'Tweet: "{row["Tweet Text"]}"'
46
- for _, row in descriptions_df.iterrows()
47
- ])
48
-
49
- # Format clip examples
50
- clip_examples = "\n\n".join([
51
- f'Tweet Text: "{row["Tweet Text"]}"\nClip Transcript: "{row["Clip Transcript"]}"'
52
- for _, row in clips_df.iterrows() if pd.notna(row["Tweet Text"])
53
- ])
54
-
55
- except Exception as e:
56
- print(f"Warning: Error loading CSV examples: {e}")
57
- timestamp_examples = ""
58
- title_examples = ""
59
- description_examples = ""
60
- clip_examples = ""
61
-
62
- # Load base prompts and inject examples
63
  prompts = {}
64
- for key in ["previews", "clips", "description", "timestamps", "titles_and_thumbnails"]:
65
- prompt = Path(f"prompts/{key}.txt").read_text()
66
-
67
- # Inject relevant examples
68
- if key == "timestamps":
69
- prompt = prompt.replace("{timestamps_examples}", timestamp_examples)
70
- elif key == "titles_and_thumbnails":
71
- prompt = prompt.replace("{title_examples}", title_examples)
72
- elif key == "description":
73
- prompt = prompt.replace("{description_examples}", description_examples)
74
- elif key == "clips":
75
- prompt = prompt.replace("{clip_examples}", clip_examples)
76
-
77
- prompts[key] = prompt
78
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  return prompts
80
 
81
  async def generate_content(self, request: ContentRequest, transcript: str) -> str:
82
- """Generate content using Gemini asynchronously."""
 
 
83
  try:
84
- print(f"\nFull prompt for {request.prompt_key}:")
85
- print("=== SYSTEM PROMPT ===")
86
- print(self.current_prompts[request.prompt_key])
87
- print("=== END SYSTEM PROMPT ===\n")
88
-
89
- response = self.client.models.generate_content(
90
- model="gemini-2.5-pro-exp-03-25",
91
- config=types.GenerateContentConfig(system_instruction=self.current_prompts[request.prompt_key]),
92
- contents=transcript
93
  )
94
-
95
- if response and hasattr(response, 'candidates'):
96
- return response.text
97
- else:
98
- return f"Error: Unexpected response structure for {request.prompt_key}"
99
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  except Exception as e:
101
- return f"Error generating content: {str(e)}"
102
-
103
- def extract_video_id(url: str) -> str:
104
- """Extract video ID from various YouTube URL formats."""
105
- match = re.search(
106
- r"(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([A-Za-z0-9_-]+)",
107
- url
108
- )
109
- return match.group(1) if match else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
  def get_transcript(video_id: str) -> str:
112
- """Get transcript from YouTube video ID."""
113
  try:
114
- transcript = YouTubeTranscriptApi.list_transcripts(video_id).find_transcript(["en"])
115
- return " ".join(entry["text"] for entry in transcript.fetch())
 
 
 
116
  except Exception as e:
117
- return f"Error fetching transcript: {str(e)}"
118
 
 
119
  class TranscriptProcessor:
120
  def __init__(self):
121
- self.generator = ContentGenerator(api_key=os.getenv("GOOGLE_API_KEY"))
122
-
123
 
 
124
  def _get_youtube_transcript(self, url: str) -> str:
125
- """Get transcript from YouTube URL."""
 
 
 
 
126
  try:
127
- if video_id := extract_video_id(url):
128
- return get_transcript(video_id)
129
- raise Exception("Invalid YouTube URL")
130
- except Exception as e:
131
- raise Exception(f"Error fetching YouTube transcript: {str(e)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
- async def process_transcript(self, audio_file):
134
- """Process input and generate all content."""
135
- audio_path = audio_file.name
136
  try:
137
- aai.settings.api_key = os.getenv("ASSEMBLYAI_API_KEY")
138
- config = aai.TranscriptionConfig(speaker_labels=True, language_code="en")
139
- transcript_iter = aai.Transcriber().transcribe(str(audio_path), config=config)
140
- transcript = transcript_iter.text
141
-
142
- # Process each type sequentially
143
- sections = {}
144
- for key in ["titles_and_thumbnails", "description", "previews", "clips", "timestamps"]:
145
- result = await self.generator.generate_content(ContentRequest(key), transcript)
146
- sections[key] = result
147
-
148
- # Combine into markdown with H2 headers
149
- markdown = f"""
150
- ## Titles and Thumbnails
151
- {sections['titles_and_thumbnails']}
152
- ## Twitter Description
153
- {sections['description']}
154
- ## Preview Clips
155
- {sections['previews']}
156
- ## Twitter Clips
157
- {sections['clips']}
158
- ## Timestamps
159
- {sections['timestamps']}
160
- """
161
- return markdown
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
- except Exception as e:
164
- return f"Error processing input: {str(e)}"
 
 
 
 
 
 
 
 
 
165
 
166
  def update_prompts(self, *values) -> str:
167
- """Update the current session's prompts."""
168
- self.generator.current_prompts.update(zip(
169
- ["previews", "clips", "description", "timestamps", "titles_and_thumbnails"],
170
- values
171
- ))
172
- return "Prompts updated for this session!"
173
 
174
 
 
175
 
176
  def create_interface():
177
- """Create the Gradio interface."""
178
  processor = TranscriptProcessor()
179
 
 
 
 
 
 
 
 
 
 
 
 
180
  with gr.Blocks(title="Gemini Podcast Content Generator") as app:
181
  gr.Markdown(
182
  """
183
  # Gemini Podcast Content Generator
184
- Generate preview clips, timestamps, descriptions and more from an audio file using Gemini.
185
- Simply upload an audio file to get started and Gemini handles the rest.
 
186
  """
187
- )
188
 
189
  with gr.Tab("Generate Content"):
 
 
 
 
 
190
  input_audio = gr.File(
191
- label="Upload Audio File",
192
- file_count="single",
193
- file_types=["audio"]
194
  )
195
- submit_btn = gr.Button("Generate Content with Gemini")
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
- output = gr.Markdown() # Single markdown output
 
198
 
199
- async def process_wrapper(text):
200
- print("Process wrapper started")
201
- print(f"Input text: {text[:100]}...")
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  try:
204
- result = await processor.process_transcript(text)
205
- print("Process completed, got results")
206
- return result
 
207
  except Exception as e:
208
- print(f"Error in process_wrapper: {str(e)}")
209
- return f"# Error\n\n{str(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
  submit_btn.click(
212
  fn=process_wrapper,
213
- inputs=input_audio,
214
- outputs=output,
215
- queue=True
216
  )
217
 
218
  with gr.Tab("Customize Prompts"):
219
- gr.Markdown(
220
- """
221
- ## Customize Generation Prompts
222
- Here you can experiment with different prompts during your session.
223
- Changes will remain active until you reload the page.
224
- Tip: Copy your preferred prompts somewhere safe if you want to reuse them later!
225
- """
226
- )
 
 
 
 
227
 
228
- prompt_inputs = [
229
- gr.Textbox(
230
- label=f"{key.replace('_', ' ').title()} Prompt",
231
- lines=10,
232
- value=processor.generator.current_prompts[key]
233
- )
234
- for key in [
235
- "previews",
236
- "clips",
237
- "description",
238
- "timestamps",
239
- "titles_and_thumbnails"
240
- ]
241
- ]
242
- status = gr.Textbox(label="Status", interactive=False)
243
-
244
- # Update prompts when they change
245
- for prompt in prompt_inputs:
246
- prompt.change(
247
- fn=processor.update_prompts,
248
- inputs=prompt_inputs,
249
- outputs=[status]
250
- )
251
-
252
- # Reset button
253
  reset_btn = gr.Button("Reset to Default Prompts")
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  reset_btn.click(
255
- fn=lambda: (
256
- processor.update_prompts(*processor.generator.current_prompts.values()),
257
- *processor.generator.current_prompts.values(),
258
- ),
259
- outputs=[status] + prompt_inputs,
260
  )
261
 
262
  return app
263
 
 
264
  if __name__ == "__main__":
265
- create_interface().launch()
266
- app.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
2
  import asyncio
3
+ from asyncio import Semaphore # Added for concurrency control
4
  from pathlib import Path
 
 
5
  import os
6
+ import tempfile # Added for temporary chunk files
7
+ import traceback # Import traceback for better error logging
 
8
  import re
9
  import pandas as pd
10
+ from dataclasses import dataclass
11
+ from typing import Dict, AsyncGenerator, Tuple, Any, List
12
+
13
+ # Use standard import convention for genai
14
+ # Assuming genai is installed and configured elsewhere
15
+ from google import genai
16
+ from youtube_transcript_api import YouTubeTranscriptApi
17
 
18
+ # Import pydub for audio manipulation
19
+ from pydub import AudioSegment
20
+ from pydub.exceptions import CouldntDecodeError
21
+
22
+
23
+ # --- Constants ---
24
+ PROMPT_KEYS = ["titles_and_thumbnails", "description", "previews", "clips", "timestamps"]
25
+ PROMPT_DISPLAY_NAMES = {
26
+ "titles_and_thumbnails": "Titles and Thumbnails",
27
+ "description": "Twitter Description",
28
+ "previews": "Preview Clips",
29
+ "clips": "Twitter Clips",
30
+ "timestamps": "Timestamps"
31
+ }
32
+ # --- MODIFIED: Increased chunk size to 30 minutes ---
33
+ AUDIO_CHUNK_DURATION_MS = 30 * 60 * 1000 # Process audio in 30-minute chunks
34
+ # --- ADDED: Concurrency Limits ---
35
+ MAX_CONCURRENT_TRANSCRIPTIONS = 3 # Limit simultaneous transcription API calls
36
+ MAX_CONCURRENT_GENERATIONS = 4 # Limit simultaneous content generation API calls
37
+
38
+ # --- Core Classes (ContentRequest, ContentGenerator) ---
39
+ # (ContentRequest and ContentGenerator remain unchanged)
40
  @dataclass
41
  class ContentRequest:
42
  prompt_key: str
43
 
44
  class ContentGenerator:
45
+ def __init__(self):
46
  self.current_prompts = self._load_default_prompts()
47
+ self.client: genai.Client | None = None
48
 
49
  def _load_default_prompts(self) -> Dict[str, str]:
50
+ # (Implementation identical to previous version)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  prompts = {}
52
+ timestamp_examples, title_examples, description_examples, clip_examples = "", "", "", ""
53
+ try:
54
+ data_dir = Path("data")
55
+ if data_dir.is_dir():
56
+ try: timestamps_df = pd.read_csv(data_dir / "Timestamps.csv"); timestamp_examples = "\n\n".join(timestamps_df['Timestamps'].dropna().tolist())
57
+ except Exception as e: print(f"Warning: Loading Timestamps.csv failed: {e}")
58
+ try: titles_df = pd.read_csv(data_dir / "Titles & Thumbnails.csv"); title_examples = "\n".join([f'Title: "{r.Titles}"\nThumbnail: "{r.Thumbnail}"' for _, r in titles_df.iterrows() if pd.notna(r.Titles) and pd.notna(r.Thumbnail)])
59
+ except Exception as e: print(f"Warning: Loading Titles & Thumbnails.csv failed: {e}")
60
+ try: descriptions_df = pd.read_csv(data_dir / "Viral Episode Descriptions.csv"); description_examples = "\n".join([f'Tweet: "{r["Tweet Text"]}"' for _, r in descriptions_df.iterrows() if pd.notna(r["Tweet Text"])])
61
+ except Exception as e: print(f"Warning: Loading Viral Episode Descriptions.csv failed: {e}")
62
+ try: clips_df = pd.read_csv(data_dir / "Viral Twitter Clips.csv"); clip_examples = "\n\n".join([f'Tweet Text: "{r["Tweet Text"]}"\nClip Transcript: "{r["Clip Transcript"]}"' for _, r in clips_df.iterrows() if pd.notna(r["Tweet Text"]) and pd.notna(r["Clip Transcript"])])
63
+ except Exception as e: print(f"Warning: Loading Viral Twitter Clips.csv failed: {e}")
64
+ else: print("Warning: 'data' directory not found.")
65
+ except Exception as e: print(f"Warning: Error accessing 'data' directory: {e}")
66
+
67
+ prompts_dir = Path("prompts")
68
+ if not prompts_dir.is_dir():
69
+ print("Error: 'prompts' directory not found.")
70
+ return {key: f"ERROR: Prompt directory missing." for key in PROMPT_KEYS}
71
+ for key in PROMPT_KEYS:
72
+ try:
73
+ prompt = (prompts_dir / f"{key}.txt").read_text(encoding='utf-8')
74
+ if key == "timestamps": prompt = prompt.replace("{timestamps_examples}", timestamp_examples)
75
+ elif key == "titles_and_thumbnails": prompt = prompt.replace("{title_examples}", title_examples)
76
+ elif key == "description": prompt = prompt.replace("{description_examples}", description_examples)
77
+ elif key == "clips": prompt = prompt.replace("{clip_examples}", clip_examples)
78
+ prompts[key] = prompt
79
+ except Exception as e:
80
+ print(f"Warning: Loading prompt prompts/{key}.txt failed: {e}")
81
+ prompts[key] = f"Generate {key} based on the transcript. Do not use markdown formatting." # Fallback
82
+ for key in PROMPT_KEYS: prompts.setdefault(key, f"Generate {key} based on the transcript. Do not use markdown formatting.")
83
  return prompts
84
 
85
  async def generate_content(self, request: ContentRequest, transcript: str) -> str:
86
+ # (Implementation identical to previous version)
87
+ if not self.client: return "ERROR_CONFIGURATION: Gemini Client not initialized."
88
+ if not transcript: return "ERROR_INTERNAL: Empty transcript provided for content generation."
89
  try:
90
+ system_prompt = self.current_prompts.get(request.prompt_key)
91
+ if not system_prompt: return f"ERROR_INTERNAL: System prompt for '{request.prompt_key}' missing."
92
+ contents_for_api = [system_prompt, transcript]
93
+ # --- IMPORTANT: Model kept as gemini-1.5-flash ---
94
+ model_name = "gemini-2.5-pro-preview-03-25"
95
+ response = await asyncio.to_thread(
96
+ self.client.models.generate_content, model=model_name, contents=contents_for_api
 
 
97
  )
98
+ if not response: return f"ERROR_API: No response received for {request.prompt_key}."
99
+ try:
100
+ if response.text:
101
+ try:
102
+ if hasattr(response, 'prompt_feedback') and response.prompt_feedback.block_reason:
103
+ reason = response.prompt_feedback.block_reason.name; return f"ERROR_BLOCKED: Blocked by API. Reason: {reason}"
104
+ except AttributeError: pass
105
+ return str(response.text.strip())
106
+ else:
107
+ if response.candidates and response.candidates[0].content and response.candidates[0].content.parts:
108
+ full_text = "".join(part.text for part in response.candidates[0].content.parts if hasattr(part, 'text')).strip()
109
+ if full_text:
110
+ print(f"Warning: Used fallback text extraction via candidates for {request.prompt_key}")
111
+ return str(full_text)
112
+ return f"ERROR_NO_TEXT: Could not extract text from response for {request.prompt_key}."
113
+ except (ValueError, AttributeError) as e:
114
+ print(f"Error accessing response text/feedback for {request.prompt_key} (potentially blocked): {e}")
115
+ reason = "Unknown"
116
+ try:
117
+ if hasattr(response, 'prompt_feedback') and response.prompt_feedback.block_reason: reason = response.prompt_feedback.block_reason.name
118
+ except AttributeError: pass
119
+ return f"ERROR_BLOCKED: Content generation failed (possibly blocked). Reason: {reason}"
120
  except Exception as e:
121
+ print(f"Error generating content for {request.prompt_key}: {traceback.format_exc()}")
122
+ error_str = str(e).lower()
123
+ # Add specific check for rate limit errors if the API provides clear indicators
124
+ if "rate limit exceeded" in error_str or "quota exceeded" in error_str or "429" in error_str:
125
+ return f"ERROR_RATE_LIMIT: API limit likely exceeded. Details: {str(e)}"
126
+ elif "permission denied" in error_str or "api key not valid" in error_str: return f"ERROR_PERMISSION_DENIED: API Error (Permission Denied?). Check Key. Details: {str(e)}"
127
+ # elif "quota" in error_str: return f"ERROR_QUOTA: API Quota Error. Details: {str(e)}" # Covered by rate limit check above
128
+ elif "model" in error_str and "not found" in error_str: return f"ERROR_MODEL_NOT_FOUND: Model name likely incorrect. Details: {str(e)}"
129
+ else: return f"ERROR_API_GENERAL: API Error during generation. Details: {str(e)}"
130
+
131
+ def update_prompts(self, *values):
132
+ # (Implementation identical to previous version)
133
+ updated_keys = []
134
+ for key, value in zip(PROMPT_KEYS, values):
135
+ if isinstance(value, str): self.current_prompts[key] = value; updated_keys.append(key)
136
+ return f"Prompts updated: {', '.join(updated_keys)}" if updated_keys else "No prompts updated."
137
+
138
+ # (extract_video_id and get_transcript remain unchanged)
139
+ def extract_video_id(url: str) -> str | None:
140
+ patterns = [r"(?:v=|\/)([0-9A-Za-z_-]{11}).*", r"youtu\.be\/([0-9A-Za-z_-]{11})"]
141
+ for pattern in patterns:
142
+ match = re.search(pattern, url);
143
+ if match: return match.group(1)
144
+ return None
145
 
146
  def get_transcript(video_id: str) -> str:
147
+ if not video_id: raise ValueError("Invalid Video ID")
148
  try:
149
+ t_list = YouTubeTranscriptApi.list_transcripts(video_id)
150
+ transcript = t_list.find_transcript(['en', 'en-US'])
151
+ fetched = transcript.fetch()
152
+ if not fetched: raise ValueError("Fetched transcript empty")
153
+ return " ".join(entry.get("text", "") for entry in fetched).strip()
154
  except Exception as e:
155
+ return f"ERROR_TRANSCRIPT_FETCH: Failed for ID '{video_id}'. Reason: {e}"
156
 
157
+ # --- TranscriptProcessor Class (Refactored for Concurrency Control) ---
158
  class TranscriptProcessor:
159
  def __init__(self):
160
+ self.generator = ContentGenerator()
 
161
 
162
+ # (Helper _get_youtube_transcript remains unchanged)
163
  def _get_youtube_transcript(self, url: str) -> str:
164
+ # ... (identical implementation)
165
+ print(f"Extracting Video ID from: {url}")
166
+ video_id = extract_video_id(url)
167
+ if not video_id: raise ValueError(f"Invalid YouTube URL/ID: {url}")
168
+ print(f"Video ID: {video_id}. Fetching transcript...")
169
  try:
170
+ transcript = get_transcript(video_id)
171
+ if transcript.startswith("ERROR_TRANSCRIPT_FETCH"): raise Exception(transcript)
172
+ if not transcript: raise ValueError(f"Empty transcript for ID: {video_id}")
173
+ print(f"Transcript fetched (length: {len(transcript)}).")
174
+ return transcript
175
+ except Exception as e: print(f"Error fetching YouTube transcript: {e}"); raise Exception(f"Failed to get YouTube transcript: {str(e)}")
176
+
177
+
178
+ # --- MODIFIED: Added Semaphore argument ---
179
+ async def _transcribe_chunk(self, client: genai.Client, chunk_path: Path, chunk_index: int, total_chunks: int, semaphore: Semaphore) -> str:
180
+ """Transcribes a single audio chunk using Gemini API, respecting the semaphore."""
181
+ # Acquire semaphore before proceeding
182
+ async with semaphore:
183
+ print(f"Semaphore acquired for chunk {chunk_index + 1}/{total_chunks}. Processing...")
184
+ gemini_audio_file_ref = None
185
+ try:
186
+ print(f"Uploading chunk {chunk_index + 1}/{total_chunks}: {chunk_path.name}")
187
+ gemini_audio_file_ref = await asyncio.to_thread(client.files.upload, file=chunk_path)
188
+ print(f"Chunk {chunk_index + 1} uploaded. File Ref: {gemini_audio_file_ref.name}")
189
+
190
+ prompt_for_transcription = "Transcribe the following audio file accurately."
191
+ contents = [prompt_for_transcription, gemini_audio_file_ref]
192
+ # --- IMPORTANT: Model kept as gemini-1.5-flash ---
193
+ model_name = "gemini-2.5-pro-preview-03-25"
194
+
195
+ print(f"Requesting transcription for chunk {chunk_index + 1}...")
196
+ # Make the API call *within* the semaphore lock
197
+ transcription_response = await asyncio.to_thread(
198
+ client.models.generate_content, model=model_name, contents=contents
199
+ )
200
+ print(f"Transcription response received for chunk {chunk_index + 1}.")
201
+
202
+ # Extract transcript text (identical logic)
203
+ transcript_piece = ""
204
+ try:
205
+ if transcription_response.text:
206
+ transcript_piece = transcription_response.text.strip()
207
+ elif transcription_response.candidates and transcription_response.candidates[0].content and transcription_response.candidates[0].content.parts:
208
+ transcript_piece = "".join(part.text for part in transcription_response.candidates[0].content.parts if hasattr(part, 'text')).strip()
209
+
210
+ if not transcript_piece and hasattr(transcription_response, 'prompt_feedback') and transcription_response.prompt_feedback.block_reason:
211
+ reason = transcription_response.prompt_feedback.block_reason.name
212
+ print(f"Warning: Transcription blocked for chunk {chunk_index + 1}. Reason: {reason}")
213
+ return f"[CHUNK_ERROR: Blocked - {reason}]"
214
+
215
+ print(f"Chunk {chunk_index + 1} transcript length: {len(transcript_piece)}")
216
+ return str(transcript_piece)
217
+
218
+ except (ValueError, AttributeError, Exception) as extraction_err:
219
+ print(f"Error extracting transcript for chunk {chunk_index + 1}: {extraction_err}. Response: {transcription_response}")
220
+ return f"[CHUNK_ERROR: Extraction Failed - {str(extraction_err)}]"
221
+
222
+ except Exception as e:
223
+ print(f"Error processing chunk {chunk_index + 1} (within semaphore): {traceback.format_exc()}")
224
+ error_str = str(e).lower()
225
+ # Add specific check for rate limit errors
226
+ if "rate limit exceeded" in error_str or "quota exceeded" in error_str or "429" in error_str:
227
+ return f"[CHUNK_ERROR: API Rate Limit Exceeded - {str(e)}]"
228
+ elif "permission denied" in error_str or "api key not valid" in error_str:
229
+ return f"[CHUNK_ERROR: API Permission Denied - {str(e)}]"
230
+ elif "file size" in error_str:
231
+ return f"[CHUNK_ERROR: File Size Limit Exceeded - {str(e)}]"
232
+ else:
233
+ return f"[CHUNK_ERROR: General API/Processing Error - {str(e)}]"
234
+ finally:
235
+ # Cleanup happens *before* semaphore is released automatically by 'async with'
236
+ if gemini_audio_file_ref:
237
+ # Run cleanup in background to avoid blocking semaphore release if deletion is slow
238
+ asyncio.create_task(self.delete_uploaded_file(client, gemini_audio_file_ref.name, f"chunk {chunk_index + 1} cleanup"))
239
+ if chunk_path.exists():
240
+ try:
241
+ os.remove(chunk_path)
242
+ except OSError as e:
243
+ print(f"Warning: Could not delete local temp chunk file {chunk_path}: {e}")
244
+ print(f"Semaphore released for chunk {chunk_index + 1}/{total_chunks}.")
245
+ # Semaphore is automatically released when exiting 'async with' block
246
+
247
+
248
+ async def process_transcript(self, client: genai.Client, audio_file: Any) -> AsyncGenerator[Tuple[str, Any], None]:
249
+ """
250
+ Processes audio with larger chunks and controlled concurrency using Semaphores.
251
+ """
252
+ if AudioSegment is None:
253
+ yield "error", "Audio processing library (pydub) not loaded. Cannot proceed."
254
+ return
255
+ if not client:
256
+ yield "error", "Gemini Client object was not provided."
257
+ return
258
+ self.generator.client = client
259
+ if not audio_file:
260
+ yield "error", "No audio file provided."
261
+ return
262
+
263
+ audio_path_str = getattr(audio_file, 'name', None)
264
+ if not audio_path_str:
265
+ yield "error", "Invalid audio file object."
266
+ return
267
+ original_audio_path = Path(audio_path_str)
268
+ if not original_audio_path.exists():
269
+ yield "error", f"Audio file not found: {original_audio_path}"
270
+ return
271
+
272
+ # --- ADDED: Initialize Semaphores ---
273
+ transcription_semaphore = Semaphore(MAX_CONCURRENT_TRANSCRIPTIONS)
274
+ generation_semaphore = Semaphore(MAX_CONCURRENT_GENERATIONS)
275
 
 
 
 
276
  try:
277
+ yield "status", f"Loading audio file: {original_audio_path.name}..."
278
+ print(f"Loading audio file with pydub: {original_audio_path}")
279
+ try:
280
+ file_format = original_audio_path.suffix.lower().replace('.', '')
281
+ audio = AudioSegment.from_file(original_audio_path, format=file_format if file_format else None)
282
+ except CouldntDecodeError as decode_error:
283
+ print(f"pydub decode error: {decode_error}. Make sure ffmpeg is installed.")
284
+ yield "error", f"Failed to load/decode audio file: {original_audio_path.name}. Ensure valid format and ffmpeg."
285
+ return
286
+ except Exception as load_err:
287
+ print(f"Error loading audio with pydub: {traceback.format_exc()}")
288
+ yield "error", f"Error loading audio file {original_audio_path.name}: {load_err}"
289
+ return
290
+
291
+ duration_ms = len(audio)
292
+ # --- MODIFIED: Chunk duration increased ---
293
+ total_chunks = (duration_ms + AUDIO_CHUNK_DURATION_MS - 1) // AUDIO_CHUNK_DURATION_MS
294
+ print(f"Audio loaded. Duration: {duration_ms / 1000:.2f}s. Splitting into {total_chunks} x {AUDIO_CHUNK_DURATION_MS / 60000:.1f}min chunks.")
295
+ yield "status", f"Audio loaded ({duration_ms / 1000:.2f}s). Transcribing in {total_chunks} chunks (max {MAX_CONCURRENT_TRANSCRIPTIONS} concurrent)..."
296
+
297
+ transcript_pieces = [""] * total_chunks # Pre-allocate list to store pieces in order
298
+ transcription_tasks = []
299
+
300
+ # --- MODIFIED: Create tasks with semaphore ---
301
+ for i in range(total_chunks):
302
+ start_ms = i * AUDIO_CHUNK_DURATION_MS
303
+ end_ms = min((i + 1) * AUDIO_CHUNK_DURATION_MS, duration_ms)
304
+ chunk = audio[start_ms:end_ms]
305
+
306
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_chunk_file:
307
+ chunk_path = Path(temp_chunk_file.name)
308
+ try:
309
+ chunk.export(chunk_path, format="wav")
310
+ except Exception as export_err:
311
+ print(f"Error exporting chunk {i+1}: {traceback.format_exc()}")
312
+ yield "error", f"Failed to create temporary audio chunk file: {export_err}"
313
+ if chunk_path.exists(): os.remove(chunk_path)
314
+ return
315
+
316
+ # Pass semaphore to the chunk transcription function
317
+ task = asyncio.create_task(self._transcribe_chunk(client, chunk_path, i, total_chunks, transcription_semaphore))
318
+ # Store task along with its index to place result correctly
319
+ transcription_tasks.append((i, task))
320
+
321
+ # Process transcription results as they complete, maintaining order
322
+ processed_chunks = 0
323
+ # Wait for all tasks using gather, but process results as they come in via callbacks or checking task states?
324
+ # Using asyncio.gather might be simpler here if we need all results before proceeding. Let's try gather.
325
+ # results = await asyncio.gather(*(task for _, task in transcription_tasks), return_exceptions=True)
326
+
327
+ # Alternative: Process as completed, but store in correct order
328
+ temp_results = {}
329
+ for index, task in transcription_tasks:
330
+ try:
331
+ result = await task
332
+ temp_results[index] = result
333
+ processed_chunks += 1
334
+ yield "status", f"Transcribed chunk {processed_chunks}/{total_chunks}..."
335
+ # Check for critical chunk errors immediately if needed
336
+ if isinstance(result, str) and ("[CHUNK_ERROR: API Rate Limit Exceeded" in result or \
337
+ "[CHUNK_ERROR: API Permission Denied" in result or \
338
+ "[CHUNK_ERROR: API Quota Exceeded" in result):
339
+ print(f"Critical API error in chunk {index + 1}, stopping transcription. Error: {result}")
340
+ yield "error", f"Transcription stopped. Critical API error in chunk {index + 1}: {result.split('-', 1)[-1].strip()}"
341
+ # Cancel remaining tasks (important!)
342
+ for j, other_task in transcription_tasks:
343
+ if not other_task.done():
344
+ other_task.cancel()
345
+ return # Stop processing
346
+ except asyncio.CancelledError:
347
+ print(f"Transcription task for chunk {index + 1} was cancelled.")
348
+ temp_results[index] = "[CHUNK_ERROR: Cancelled]"
349
+ # If one task is cancelled due to an error in another, we might stop everything
350
+ if processed_chunks < total_chunks: # Avoid double error message if already stopped
351
+ yield "error", "Transcription process was cancelled."
352
+ return
353
+ except Exception as e:
354
+ print(f"Error waiting for transcription task {index + 1}: {traceback.format_exc()}")
355
+ temp_results[index] = f"[CHUNK_ERROR: Task Processing Failed - {str(e)}]"
356
+ processed_chunks += 1 # Count as processed even though it failed
357
+
358
+ # Reconstruct the transcript in the correct order
359
+ transcript_pieces = [temp_results.get(i, "[CHUNK_ERROR: Missing Result]") for i in range(total_chunks)]
360
+ full_transcript = " ".join(transcript_pieces).strip()
361
+
362
+ # Improved check for transcription failure
363
+ if not full_transcript or full_transcript.isspace() or all(s.startswith("[CHUNK_ERROR") for s in transcript_pieces if s):
364
+ error_summary = " ".join(p for p in transcript_pieces if p.startswith("[CHUNK_ERROR"))
365
+ print(f"Transcription failed or resulted in only errors. Summary: {error_summary}")
366
+ yield "error", f"Failed to transcribe audio or all chunks failed. Errors: {error_summary[:200]}"
367
+ return
368
+
369
+ print(f"Full transcript concatenated (length: {len(full_transcript)}).")
370
+ yield "status", "Transcription complete. Generating content..."
371
+
372
+ # --- Generate other content using the FULL transcript with Semaphore ---
373
+ generation_tasks = []
374
+ for key in PROMPT_KEYS:
375
+ # Pass generation semaphore to the item generation function
376
+ task = asyncio.create_task(self._generate_single_item(key, full_transcript, generation_semaphore))
377
+ generation_tasks.append(task)
378
+
379
+ generated_items = 0
380
+ total_items = len(PROMPT_KEYS)
381
+ # Process generation results as they complete
382
+ for future in asyncio.as_completed(generation_tasks):
383
+ try:
384
+ key, result = await future # Result from _generate_single_item
385
+ yield "progress", (key, result)
386
+ generated_items += 1
387
+ # More granular status for generation
388
+ yield "status", f"Generating content ({key} done, {generated_items}/{total_items} total)..."
389
+ except asyncio.CancelledError:
390
+ # Should not happen unless transcription failed and cancelled tasks
391
+ print("Content generation task was cancelled.")
392
+ yield "error", "Content generation cancelled."
393
+ return
394
+ except Exception as e:
395
+ print(f"Error processing completed generation task: {traceback.format_exc()}")
396
+ yield "status", f"Error during content generation phase: {str(e)}"
397
+ # Optionally yield an error for the specific item?
398
+ # key_if_possible = "unknown_key" # How to get key here? Task doesn't easily pass it back on exception
399
+ # yield "progress", (key_if_possible, f"ERROR_GENERATION: {str(e)}")
400
+
401
+ yield "status", "All content generation tasks complete."
402
+
403
+ except FileNotFoundError as e:
404
+ yield "error", f"File Error: {str(e)}"
405
+ return
406
+ except Exception as e: # Catch-all for transcription setup phase
407
+ print(f"Error during transcription setup/chunking phase: {traceback.format_exc()}")
408
+ yield "error", f"System Error during transcription setup: {str(e)}"
409
+ return
410
+
411
+
412
+ async def delete_uploaded_file(self, client: genai.Client, file_name: str, context: str):
413
+ # (Implementation identical - called in background now)
414
+ if not client or not file_name:
415
+ # print(f"Skipping deletion: Invalid client or file name ({context}).") # Reduce noise
416
+ return
417
+ try:
418
+ # print(f"Attempting background cleanup: {file_name} ({context})")
419
+ await asyncio.to_thread(client.files.delete, name=file_name)
420
+ print(f"Successfully cleaned up Gemini file: {file_name} ({context})")
421
+ except Exception as cleanup_err:
422
+ if "not found" in str(cleanup_err).lower() or "404" in str(cleanup_err):
423
+ pass # Ignore file not found during cleanup
424
+ # print(f"Info: File {file_name} likely already deleted ({context}).")
425
+ else:
426
+ print(f"Warning: Failed Gemini file cleanup for {file_name} ({context}): {cleanup_err}")
427
 
428
+
429
+ # --- MODIFIED: Added Semaphore argument ---
430
+ async def _generate_single_item(self, key: str, transcript: str, semaphore: Semaphore) -> Tuple[str, str]:
431
+ """Helper to generate one piece of content, respecting the semaphore."""
432
+ # Acquire semaphore before calling the API
433
+ async with semaphore:
434
+ print(f"Semaphore acquired for generating: {key}. Calling API...")
435
+ result = await self.generator.generate_content(ContentRequest(key), transcript)
436
+ print(f"Finished generation task for: {key}. Semaphore released.")
437
+ # Semaphore is released automatically by 'async with'
438
+ return key, result
439
 
440
  def update_prompts(self, *values) -> str:
441
+ # (Implementation identical to previous version)
442
+ return self.generator.update_prompts(*values)
 
 
 
 
443
 
444
 
445
+ # --- Gradio Interface Creation (UI remains unchanged from previous version) ---
446
 
447
  def create_interface():
448
+ """Create the Gradio interface (UI definition identical to last version)."""
449
  processor = TranscriptProcessor()
450
 
451
+ key_titles = "titles_and_thumbnails"
452
+ key_desc = "description"
453
+ key_previews = "previews"
454
+ key_clips = "clips"
455
+ key_timestamps = "timestamps"
456
+ display_titles = PROMPT_DISPLAY_NAMES[key_titles]
457
+ display_desc = PROMPT_DISPLAY_NAMES[key_desc]
458
+ display_previews = PROMPT_DISPLAY_NAMES[key_previews]
459
+ display_clips = PROMPT_DISPLAY_NAMES[key_clips]
460
+ display_timestamps = PROMPT_DISPLAY_NAMES[key_timestamps]
461
+
462
  with gr.Blocks(title="Gemini Podcast Content Generator") as app:
463
  gr.Markdown(
464
  """
465
  # Gemini Podcast Content Generator
466
+ Generate social media content from podcast audio using Gemini.
467
+ Enter your Google API key below and upload an audio file.
468
+ Audio will be processed in larger (~30min) chunks with controlled concurrency.
469
  """
470
+ ) # Updated description slightly
471
 
472
  with gr.Tab("Generate Content"):
473
+ google_api_key_input = gr.Textbox(
474
+ label="Google API Key", type="password",
475
+ placeholder="Enter your Google API Key here",
476
+ info="Ensure the API key is valid and has necessary permissions."
477
+ )
478
  input_audio = gr.File(
479
+ label="Upload Audio File", file_count="single",
480
+ file_types=["audio", ".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac"]
 
481
  )
482
+ submit_btn = gr.Button("Generate Content", variant="huggingface")
483
+
484
+ gr.Markdown("### Processing Status")
485
+ output_status = gr.Textbox(label="Current Status", value="Idle.", interactive=False, lines=1, max_lines=5)
486
+
487
+ gr.Markdown(f"### {display_titles}")
488
+ output_titles = gr.Textbox(value="...", interactive=False, lines=3, max_lines=10) # No label
489
+
490
+ gr.Markdown(f"### {display_desc}")
491
+ output_desc = gr.Textbox(value="...", interactive=False, lines=3, max_lines=10) # No label
492
+
493
+ gr.Markdown(f"### {display_previews}")
494
+ output_previews = gr.Textbox(value="...", interactive=False, lines=3, max_lines=10) # No label
495
 
496
+ gr.Markdown(f"### {display_clips}")
497
+ output_clips = gr.Textbox(value="...", interactive=False, lines=3, max_lines=10) # No label
498
 
499
+ gr.Markdown(f"### {display_timestamps}")
500
+ output_timestamps = gr.Textbox(value="...", interactive=False, lines=3, max_lines=10) # No label
 
501
 
502
+ outputs_list = [
503
+ output_status,
504
+ output_titles, output_desc, output_previews,
505
+ output_clips, output_timestamps
506
+ ]
507
+ results_component_map = {
508
+ key_titles: output_titles, key_desc: output_desc, key_previews: output_previews,
509
+ key_clips: output_clips, key_timestamps: output_timestamps
510
+ }
511
+
512
+ # --- process_wrapper (UI Update Logic - largely unchanged) ---
513
+ async def process_wrapper(google_key, audio_file_obj, progress=gr.Progress(track_tqdm=True)):
514
+ print("Started Processing...")
515
+ initial_updates = {
516
+ output_status: gr.update(value="Initiating..."),
517
+ output_titles: gr.update(value="⏳ Pending..."),
518
+ output_desc: gr.update(value="⏳ Pending..."),
519
+ output_previews: gr.update(value="⏳ Pending..."),
520
+ output_clips: gr.update(value="⏳ Pending..."),
521
+ output_timestamps: gr.update(value="⏳ Pending..."),
522
+ }
523
+ yield initial_updates
524
+
525
+ if not google_key:
526
+ yield {output_status: gr.update(value="πŸ›‘ Error: Missing Google API Key.")}
527
+ return
528
+ if not audio_file_obj:
529
+ yield {output_status: gr.update(value="πŸ›‘ Error: No audio file uploaded.")}
530
+ return
531
+
532
+ masked_key = f"{'*'*(len(google_key)-4)}{google_key[-4:]}" if len(google_key) > 4 else "****"
533
+ print(f"Using Google Key: {masked_key}")
534
+ print(f"Audio file: Name='{getattr(audio_file_obj, 'name', 'N/A')}'")
535
+ client: genai.Client | None = None
536
  try:
537
+ yield {output_status: gr.update(value="⏳ Initializing Gemini Client...")}
538
+ client = await asyncio.to_thread(genai.Client, api_key=google_key)
539
+ print("Gemini Client initialized successfully.")
540
+ yield {output_status: gr.update(value="βœ… Client Initialized.")}
541
  except Exception as e:
542
+ error_msg = f"πŸ›‘ Error: Failed Client Initialization: {e}"
543
+ print(f"Client Init Error: {traceback.format_exc()}")
544
+ yield {output_status: gr.update(value=error_msg)}
545
+ return
546
+
547
+ updates_to_yield = {}
548
+ try:
549
+ # Call the refactored processor
550
+ async for update_type, data in processor.process_transcript(client, audio_file_obj):
551
+ updates_to_yield = {}
552
+ if update_type == "status":
553
+ updates_to_yield[output_status] = gr.update(value=f"⏳ {data}")
554
+ elif update_type == "progress":
555
+ key, result = data
556
+ component_to_update = results_component_map.get(key)
557
+ if component_to_update:
558
+ ui_result = ""
559
+ if isinstance(result, str) and result.startswith("ERROR_"):
560
+ # Handle specific rate limit error display
561
+ if result.startswith("ERROR_RATE_LIMIT"):
562
+ ui_result = f"❌ Error (Rate Limit):\n{result.split(':', 1)[-1].strip()}"
563
+ else:
564
+ try:
565
+ error_type, error_detail = result.split(':', 1)
566
+ error_type_display = error_type.replace('ERROR_', '').replace('_', ' ').title()
567
+ ui_result = f"❌ Error ({error_type_display}):\n{error_detail.strip()}"
568
+ except ValueError:
569
+ ui_result = f"❌ Error:\n{result}"
570
+ else:
571
+ ui_result = str(result)
572
+ updates_to_yield[component_to_update] = gr.update(value=ui_result)
573
+ else:
574
+ print(f"Warning: No UI component mapped for result key '{key}'")
575
+ elif update_type == "error":
576
+ error_message = f"πŸ›‘ Processing Error: {data}"
577
+ updates_to_yield[output_status] = gr.update(value=error_message)
578
+ yield updates_to_yield
579
+ return
580
+
581
+ if updates_to_yield:
582
+ yield updates_to_yield
583
+
584
+ final_success_update = {output_status: gr.update(value="βœ… Processing Complete.")}
585
+ final_success_update.update(updates_to_yield) # Include any final progress updates
586
+ yield final_success_update
587
+ print("Process wrapper finished successfully.")
588
+
589
+ except Exception as e:
590
+ print(f"Error in process_wrapper async loop: {traceback.format_exc()}")
591
+ error_msg = f"πŸ›‘ Unexpected wrapper error: {e}"
592
+ yield {output_status: gr.update(value=error_msg)}
593
 
594
  submit_btn.click(
595
  fn=process_wrapper,
596
+ inputs=[google_api_key_input, input_audio],
597
+ outputs=outputs_list
 
598
  )
599
 
600
  with gr.Tab("Customize Prompts"):
601
+ # (Customize Prompts tab UI remains unchanged)
602
+ gr.Markdown("## Customize Generation Prompts")
603
+ prompt_inputs = []
604
+ default_prompts = processor.generator.current_prompts
605
+ for key in PROMPT_KEYS:
606
+ display_name = PROMPT_DISPLAY_NAMES.get(key, key.replace('_', ' ').title())
607
+ default_value = default_prompts.get(key, "")
608
+ prompt_inputs.append(gr.Textbox(label=f"{display_name} Prompt", lines=10, value=default_value or ""))
609
+
610
+ status_prompt_tab = gr.Textbox(label="Status", interactive=False)
611
+ update_btn = gr.Button("Update Session Prompts")
612
+ update_btn.click(fn=processor.update_prompts, inputs=prompt_inputs, outputs=[status_prompt_tab])
613
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614
  reset_btn = gr.Button("Reset to Default Prompts")
615
+ def reset_prompts_ui():
616
+ try:
617
+ defaults = processor.generator._load_default_prompts()
618
+ if any(isinstance(v, str) and v.startswith("ERROR:") for v in defaults.values()): raise ValueError("Failed to load one or more default prompts.")
619
+ processor.generator.current_prompts = defaults
620
+ updates = {status_prompt_tab: gr.update(value="Prompts reset to defaults!")}
621
+ for i, key in enumerate(PROMPT_KEYS):
622
+ updates[prompt_inputs[i]] = gr.update(value=defaults.get(key, ""))
623
+ return updates
624
+ except Exception as e:
625
+ print(f"Error during prompt reset: {e}")
626
+ return {status_prompt_tab: gr.update(value=f"Error resetting prompts: {str(e)}")}
627
+
628
  reset_btn.click(
629
+ fn=reset_prompts_ui,
630
+ inputs=None,
631
+ outputs=[status_prompt_tab] + prompt_inputs
 
 
632
  )
633
 
634
  return app
635
 
636
+ # --- Main Execution Block (Unchanged) ---
637
  if __name__ == "__main__":
638
+ if AudioSegment is None:
639
+ print("\nFATAL ERROR: pydub is required but could not be imported.")
640
+ print("Please install it ('pip install pydub') and ensure ffmpeg is available.")
641
+ print("Application cannot start correctly.")
642
+ exit(1)
643
+
644
+ Path("prompts").mkdir(exist_ok=True)
645
+ Path("data").mkdir(exist_ok=True)
646
+ _prompt_dir = Path("prompts")
647
+ for key in PROMPT_KEYS:
648
+ prompt_file = _prompt_dir / f"{key}.txt"
649
+ if not prompt_file.exists():
650
+ # Ensure default prompts advise against markdown
651
+ default_content = f"This is the default placeholder prompt for {PROMPT_DISPLAY_NAMES[key]}. Process the transcript provided. Important: Generate the response as plain text only. Do not use any Markdown formatting (no '#', '*', '_', list formatting, bolding, etc.)."
652
+ if key == "titles_and_thumbnails": default_content += "\n\nExamples:\n{title_examples}"
653
+ elif key == "description": default_content += "\n\nExamples:\n{description_examples}"
654
+ elif key == "clips": default_content += "\n\nExamples:\n{clip_examples}"
655
+ elif key == "timestamps": default_content += "\n\nExamples:\n{timestamps_examples}"
656
+ prompt_file.write_text(default_content, encoding='utf-8')
657
+ print(f"Created dummy prompt file: {prompt_file}")
658
+
659
+ print("Starting Gradio application...")
660
+ app = create_interface()
661
+ app.launch()
662
+ print("Gradio application stopped.")