1 # Audio Processing
2
3 Whisper transcription setup, audiogram generation, and audio clip extraction.
4
5 ## Table of Contents
6
7 - [Whisper Transcription](#whisper-transcription) — API setup, basic and large-file transcription
8 - [Parsing SRT/VTT Files](#parsing-srtvtt-files) — Convert subtitle files to normalized segments
9
10 - [Audiogram Generation](#audiogram-generation) — Extract clips and create waveform videos
11 - [Clip Extraction](#clip-extraction)
12
13 - [Waveform Video Generation](#waveform-video-generation)
14 - [Audiogram Best Practices](#audiogram-best-practices)
15
16 ## Whisper Transcription
17
18 Use OpenAI's Whisper API to transcribe podcast audio. This produces timestamped segments essential for show notes and audiogram clips.
19
20 ### Setup
21
22 ```bash
23
24 pip install openai pydub
25
26 ```
27
28 Requires `OPENAI_API_KEY` environment variable. Check if it's set before proceeding — if not, request it from the user via the environment-secrets skill.
29
30 ### Transcription Script
31
32 ```python
33
34 import os
35
36 from openai import OpenAI
37
38 from pydub import AudioSegment
39
40 client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
41
42 def transcribe_episode(audio_path: str) -> dict:
43
44 """Transcribe a podcast episode with timestamps.
45
46 Whisper API has a 25MB file size limit. For longer episodes,
47
48 split into chunks first.
49
50 """
51
52 file_size = os.path.getsize(audio_path)
53
54 if file_size > 24 * 1024 * 1024:
55
56 return transcribe_large_file(audio_path)
57
58 with open(audio_path, "rb") as f:
59
60 result = client.audio.transcriptions.create(
61
62 model="whisper-1",
63
64 file=f,
65
66 response_format="verbose_json",
67
68 timestamp_granularities=["segment"],
69
70 )
71
72 return {
73
74 "text": result.text,
75
76 "segments": [
77
78 {
79
80 "start": seg.start,
81
82 "end": seg.end,
83
84 "text": seg.text.strip(),
85
86 }
87
88 for seg in result.segments
89
90 ],
91
92 }
93
94 def transcribe_large_file(audio_path: str, chunk_minutes: int = 10) -> dict:
95
96 """Split large files into chunks and transcribe each."""
97
98 audio = AudioSegment.from_file(audio_path)
99
100 chunk_ms = chunk_minutes * 60 * 1000
101
102 chunks = [audio[i : i + chunk_ms] for i in range(0, len(audio), chunk_ms)]
103
104 all_segments = []
105
106 full_text = []
107
108 offset = 0.0
109
110 for i, chunk in enumerate(chunks):
111
112 chunk_path = f"/tmp/chunk_{i}.mp3"
113
114 chunk.export(chunk_path, format="mp3", bitrate="128k")
115
116 with open(chunk_path, "rb") as f:
117
118 result = client.audio.transcriptions.create(
119
120 model="whisper-1",
121
122 file=f,
123
124 response_format="verbose_json",
125
126 timestamp_granularities=["segment"],
127
128 )
129
130 for seg in result.segments:
131
132 all_segments.append({
133
134 "start": seg.start + offset,
135
136 "end": seg.end + offset,
137
138 "text": seg.text.strip(),
139
140 })
141
142 full_text.append(result.text)
143
144 offset += chunk_minutes * 60
145
146 os.remove(chunk_path)
147
148 return {"text": " ".join(full_text), "segments": all_segments}
149
150 ```
151
152 ### Parsing SRT/VTT Files
153
154 If the user provides an existing transcript file instead of audio:
155
156 ```python
157
158 import re
159
160 def parse_srt(content: str) -> list[dict]:
161
162 """Parse SRT subtitle file into segments."""
163
164 blocks = re.split(r"\n\n+", content.strip())
165
166 segments = []
167
168 for block in blocks:
169
170 lines = block.strip().split("\n")
171
172 if len(lines) < 3:
173
174 continue
175
176 time_match = re.match(
177
178 r"(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})",
179
180 lines[1],
181
182 )
183
184 if not time_match:
185
186 continue
187
188 g = time_match.groups()
189
190 start = int(g[0]) * 3600 + int(g[1]) * 60 + int(g[2]) + int(g[3]) / 1000
191
192 end = int(g[4]) * 3600 + int(g[5]) * 60 + int(g[6]) + int(g[7]) / 1000
193
194 text = " ".join(lines[2:])
195
196 segments.append({"start": start, "end": end, "text": text})
197
198 return segments
199
200 def parse_vtt(content: str) -> list[dict]:
201
202 """Parse WebVTT file into segments."""
203
204 lines = content.strip().split("\n")
205
206 if lines[0].startswith("WEBVTT"):
207
208 lines = lines[1:]
209
210 segments = []
211
212 i = 0
213
214 while i < len(lines):
215
216 line = lines[i].strip()
217
218 time_match = re.match(
219
220 r"(\d{2}):(\d{2}):(\d{2})\(\d{3}) --> (\d{2}):(\d{2}):(\d{2})\(\d{3})",
221
222 line,
223
224 )
225
226 if time_match:
227
228 g = time_match.groups()
229
230 start = int(g[0]) * 3600 + int(g[1]) * 60 + int(g[2]) + int(g[3]) / 1000
231
232 end = int(g[4]) * 3600 + int(g[5]) * 60 + int(g[6]) + int(g[7]) / 1000
233
234 i += 1
235
236 text_lines = []
237
238 while i < len(lines) and lines[i].strip():
239
240 text_lines.append(lines[i].strip())
241
242 i += 1
243
244 segments.append({"start": start, "end": end, "text": " ".join(text_lines)})
245
246 i += 1
247
248 return segments
249
250 ```
251
252 ## Audiogram Generation
253
254 Audiograms are short audio clips (30-60 seconds) with a waveform visualization, designed for social media. They turn a pull quote into a shareable video clip.
255
256 ### Dependencies
257
258 ```bash
259
260 pip install pydub numpy Pillow moviepy
261
262 ```
263
264 Also requires `ffmpeg` (available in the Replit environment).
265
266 ### Clip Extraction
267
268 Extract the audio segment around a notable quote:
269
270 ```python
271
272 from pydub import AudioSegment
273
274 def extract_clip(
275
276 audio_path: str,
277
278 start_seconds: float,
279
280 end_seconds: float,
281
282 output_path: str,
283
284 fade_ms: int = 500,
285
286 ) -> str:
287
288 """Extract an audio clip with fade in/out."""
289
290 audio = AudioSegment.from_file(audio_path)
291
292 clip = audio[int(start_seconds * 1000) : int(end_seconds * 1000)]
293
294 clip = clip.fade_in(fade_ms).fade_out(fade_ms)
295
296 clip.export(output_path, format="mp3", bitrate="128k")
297
298 return output_path
299
300 ```
301
302 ### Waveform Video Generation
303
304 Create a simple waveform visualization video from an audio clip:
305
306 ```python
307
308 import numpy as np
309
310 from PIL import Image, ImageDraw, ImageFont
311
312 from pydub import AudioSegment
313
314 import subprocess
315
316 import os
317
318 def generate_audiogram(
319
320 audio_path: str,
321
322 quote_text: str,
323
324 speaker_name: str,
325
326 output_path: str,
327
328 width: int = 1080,
329
330 height: int = 1080,
331
332 bg_color: str = "#1a1a2e",
333
334 wave_color: str = "#e94560",
335
336 text_color: str = "#ffffff",
337
338 fps: int = 30,
339
340 ):
341
342 """Generate an audiogram video with waveform and quote overlay.
343
344 Creates a square video (1080x1080) optimized for Instagram/LinkedIn.
345
346 The video shows the quote text with an animated waveform underneath.
347
348 """
349
350 audio = AudioSegment.from_file(audio_path)
351
352 samples = np.array(audio.get_array_of_samples(), dtype=np.float32)
353
354 if audio.channels == 2:
355
356 samples = samples.reshape(-1, 2).mean(axis=1)
357
358 samples = samples / np.max(np.abs(samples))
359
360 duration_s = len(audio) / 1000.0
361
362 total_frames = int(duration_s * fps)
363
364 samples_per_frame = len(samples) // total_frames
365
366 frame_dir = "/tmp/audiogram_frames"
367
368 os.makedirs(frame_dir, exist_ok=True)
369
370 for frame_idx in range(total_frames):
371
372 img = Image.new("RGB", (width, height), bg_color)
373
374 draw = ImageDraw.Draw(img)
375
376 try:
377
378 font_quote = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 36)
379
380 font_speaker = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 28)
381
382 except OSError:
383
384 font_quote = ImageFont.load_default()
385
386 font_speaker = font_quote
387
388 margin = 80
389
390 max_text_width = width - 2 * margin
391
392 words = quote_text.split()
393
394 lines = []
395
396 current_line = ""
397
398 for word in words:
399
400 test = f"{current_line} {word}".strip()
401
402 bbox = draw.textbbox((0, 0), test, font=font_quote)
403
404 if bbox[2] - bbox[0] <= max_text_width:
405
406 current_line = test
407
408 else:
409
410 if current_line:
411
412 lines.append(current_line)
413
414 current_line = word
415
416 if current_line:
417
418 lines.append(current_line)
419
420 text_block = f'"{chr(10).join(lines)}"'
421
422 y_text = height // 4
423
424 for line in text_block.split("\n"):
425
426 bbox = draw.textbbox((0, 0), line, font=font_quote)
427
428 x_text = (width - (bbox[2] - bbox[0])) // 2
429
430 draw.text((x_text, y_text), line, fill=text_color, font=font_quote)
431
432 y_text += bbox[3] - bbox[1] + 10
433
434 speaker_text = f"— {speaker_name}"
435
436 bbox = draw.textbbox((0, 0), speaker_text, font=font_speaker)
437
438 draw.text(
439
440 ((width - (bbox[2] - bbox[0])) // 2, y_text + 20),
441
442 speaker_text,
443
444 fill=wave_color,
445
446 font=font_speaker,
447
448 )
449
450 wave_y = int(height * 0.75)
451
452 wave_height_max = 80
453
454 start_sample = frame_idx * samples_per_frame
455
456 end_sample = start_sample + samples_per_frame
457
458 frame_samples = samples[start_sample:end_sample]
459
460 num_bars = 60
461
462 bar_width = (width - 2 * margin) // num_bars
463
464 chunk_size = max(1, len(frame_samples) // num_bars)
465
466 for i in range(num_bars):
467
468 chunk = frame_samples[i * chunk_size : (i + 1) * chunk_size]
469
470 if len(chunk) == 0:
471
472 continue
473
474 amplitude = np.mean(np.abs(chunk))
475
476 bar_h = int(amplitude * wave_height_max * 2) + 2
477
478 x = margin + i * bar_width
479
480 draw.rectangle(
481
482 [x, wave_y - bar_h // 2, x + bar_width - 2, wave_y + bar_h // 2],
483
484 fill=wave_color,
485
486 )
487
488 img.save(f"{frame_dir}/frame_{frame_idx:05d}.png")
489
490 subprocess.run([
491
492 "ffmpeg", "-y",
493
494 "-framerate", str(fps),
495
496 "-i", f"{frame_dir}/frame_%05d.png",
497
498 "-i", audio_path,
499
500 "-c:v", "libx264",
501
502 "-pix_fmt", "yuv420p",
503
504 "-c:a", "aac",
505
506 "-shortest",
507
508 output_path,
509
510 ], check=True, capture_output=True)
511
512 for f in os.listdir(frame_dir):
513
514 os.remove(os.path.join(frame_dir, f))
515
516 os.rmdir(frame_dir)
517
518 return output_path
519
520 ```
521
522 ### Audiogram Best Practices
523
524 - **Duration**: 30-60 seconds max. Social feeds auto-play muted — the visual must hook in 3 seconds.
525 - **Aspect ratios**: 1:1 (1080x1080) for Instagram/LinkedIn feed, 9:16 (1080x1920) for Stories/Reels/TikTok.
526
527 - **Always include the quote as text overlay** — most viewers watch without sound.
528 - **Brand colors**: Use the podcast's brand colors for the background and waveform. Ask the user for hex codes if not provided.
529
530 - **File format**: MP4 with H.264 video and AAC audio for maximum platform compatibility.