
Media Toolkit
- 149 installs
- 84 repo stars
- Updated April 8, 2026
- dkyazzentwatwa/chatgpt-skills
Generate, transform, and package images, audio, or video assets inside agent workflows without leaving the coding session.
About
Equips Claude Code to orchestrate generative and manipulative media workflows—image, audio, and video—through a consolidated toolkit of prompts, tool calls, and conventions suited to agent-driven builds and content products.
- Multi-format asset handling
- Agent-friendly media prompts
- Generation and conversion recipes
- Export and optimization guidance
- Reusable toolkit patterns
Media Toolkit by the numbers
- 149 all-time installs (skills.sh)
- Ranked #715 of 1,337 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dkyazzentwatwa/chatgpt-skills --skill media-toolkitAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 149 |
|---|---|
| repo stars | ★ 84 |
| Last updated | April 8, 2026 |
| Repository | dkyazzentwatwa/chatgpt-skills ↗ |
What it does
Generate, transform, and package images, audio, or video assets inside agent workflows without leaving the coding session.
Files
Media Toolkit
Use this suite for practical audio and video operations that were previously spread across many narrow skills.
Included Tools
- Audio:
audio_analyzer.py,audio_converter.py,audio_normalizer.py,audio_trimmer.py,podcast_splitter.py,sfx_generator.py - Video:
video_captioner.py,video_clipper.py,video_metadata_inspector.py,video_thumbnail_extractor.py,gif_workshop.py,thumbnail_gen.py,timelapse_creator.py
Workflow
1. Identify the medium, input format, and target output. 2. Pick the narrowest script that completes the job. 3. Keep transformations explicit: clip times, output codec, target size, caption source, or thumbnail cadence. 4. Verify output duration, dimensions, and bitrate-sensitive settings when the request depends on platform limits.
Guardrails
- Avoid recompression churn when a simple trim or extract is enough.
- Call out when generated captions or “best frame” choices are heuristic.
display_name: 'Media Toolkit'
short_description: 'Handle practical audio and video conversion, trimming, and analysis.'
default_prompt: 'Help me process this audio or video file.'
#!/usr/bin/env python3
"""
Audio Analyzer - Comprehensive audio analysis toolkit.
Features:
- Tempo/BPM detection
- Key detection
- Frequency analysis
- Loudness metrics
- Waveform visualization
- Spectrogram generation
- Chromagram plotting
- Beat grid visualization
"""
import argparse
import json
import os
from pathlib import Path
from typing import Dict, List, Optional, Union, Any
from dataclasses import dataclass, asdict
import numpy as np
@dataclass
class TempoResult:
"""Tempo analysis results."""
bpm: float
confidence: float
beats: List[float]
beat_count: int
@dataclass
class KeyResult:
"""Key detection results."""
key: str
mode: str
confidence: float
profile: Dict[str, float]
@dataclass
class LoudnessResult:
"""Loudness analysis results."""
rms_db: float
peak_db: float
lufs: float
dynamic_range_db: float
crest_factor: float
@dataclass
class FrequencyResult:
"""Frequency analysis results."""
dominant_freq: float
spectral_centroid: float
spectral_rolloff: float
bands: Dict[str, float]
class AudioAnalyzer:
"""Comprehensive audio analysis toolkit."""
# Key names
KEY_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
# Frequency bands (Hz)
FREQ_BANDS = {
'sub_bass': (20, 60),
'bass': (60, 250),
'low_mid': (250, 500),
'mid': (500, 2000),
'high_mid': (2000, 4000),
'high': (4000, 20000)
}
def __init__(self, filepath: str, sr: int = 22050):
"""
Initialize analyzer with audio file.
Args:
filepath: Path to audio file
sr: Sample rate for analysis (default 22050)
"""
self.filepath = Path(filepath)
self.sr = sr
self.y = None
self.duration = 0.0
# Results
self._tempo: Optional[TempoResult] = None
self._key: Optional[KeyResult] = None
self._loudness: Optional[LoudnessResult] = None
self._frequency: Optional[FrequencyResult] = None
# Load audio
self._load_audio()
def _load_audio(self):
"""Load audio file using librosa."""
try:
import librosa
self.y, self.sr = librosa.load(self.filepath, sr=self.sr, mono=True)
self.duration = librosa.get_duration(y=self.y, sr=self.sr)
except ImportError:
raise ImportError("librosa is required. Install with: pip install librosa")
except Exception as e:
raise ValueError(f"Failed to load audio file: {e}")
def analyze(self) -> 'AudioAnalyzer':
"""Run all analyses."""
self.analyze_tempo()
self.analyze_key()
self.analyze_loudness()
self.analyze_frequency()
return self
def analyze_tempo(self) -> 'AudioAnalyzer':
"""Detect tempo and beat positions."""
import librosa
# Get tempo and beat frames
tempo, beat_frames = librosa.beat.beat_track(y=self.y, sr=self.sr)
# Convert frames to time
beat_times = librosa.frames_to_time(beat_frames, sr=self.sr)
# Calculate confidence based on beat regularity
if len(beat_times) > 1:
intervals = np.diff(beat_times)
expected_interval = 60.0 / float(tempo)
interval_variance = np.var(intervals - expected_interval)
confidence = max(0, min(1, 1 - interval_variance * 10))
else:
confidence = 0.0
self._tempo = TempoResult(
bpm=float(tempo),
confidence=float(confidence),
beats=beat_times.tolist(),
beat_count=len(beat_times)
)
return self
def analyze_key(self) -> 'AudioAnalyzer':
"""Detect musical key."""
import librosa
# Compute chromagram
chroma = librosa.feature.chroma_cqt(y=self.y, sr=self.sr)
# Average chroma over time
chroma_avg = np.mean(chroma, axis=1)
# Key profiles (Krumhansl-Schmuckler)
major_profile = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
minor_profile = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
# Correlate with all keys
correlations = []
for i in range(12):
# Rotate profiles
major_rot = np.roll(major_profile, i)
minor_rot = np.roll(minor_profile, i)
# Compute correlations
corr_major = np.corrcoef(chroma_avg, major_rot)[0, 1]
corr_minor = np.corrcoef(chroma_avg, minor_rot)[0, 1]
correlations.append((i, 'major', corr_major))
correlations.append((i, 'minor', corr_minor))
# Find best match
best = max(correlations, key=lambda x: x[2])
key_idx, mode, confidence = best
# Build profile dict
profile = {self.KEY_NAMES[i]: float(chroma_avg[i]) for i in range(12)}
self._key = KeyResult(
key=self.KEY_NAMES[key_idx],
mode=mode,
confidence=float(max(0, confidence)),
profile=profile
)
return self
def analyze_loudness(self) -> 'AudioAnalyzer':
"""Analyze loudness metrics."""
# RMS
rms = np.sqrt(np.mean(self.y ** 2))
rms_db = 20 * np.log10(rms + 1e-10)
# Peak
peak = np.max(np.abs(self.y))
peak_db = 20 * np.log10(peak + 1e-10)
# Simplified LUFS (approximation)
# Real LUFS requires K-weighting filter
lufs = rms_db - 0.691 # Rough approximation
# Dynamic range (difference between 95th and 5th percentile)
y_abs = np.abs(self.y)
y_abs = y_abs[y_abs > 1e-10] # Remove silence
if len(y_abs) > 0:
high = np.percentile(y_abs, 95)
low = np.percentile(y_abs, 5)
dynamic_range = 20 * np.log10(high / low + 1e-10)
else:
dynamic_range = 0.0
# Crest factor
crest_factor = peak / (rms + 1e-10)
self._loudness = LoudnessResult(
rms_db=float(rms_db),
peak_db=float(peak_db),
lufs=float(lufs),
dynamic_range_db=float(dynamic_range),
crest_factor=float(crest_factor)
)
return self
def analyze_frequency(self) -> 'AudioAnalyzer':
"""Analyze frequency content."""
import librosa
# Compute spectrum
D = np.abs(librosa.stft(self.y))
freqs = librosa.fft_frequencies(sr=self.sr)
# Average magnitude spectrum
mag_avg = np.mean(D, axis=1)
# Dominant frequency
dominant_idx = np.argmax(mag_avg)
dominant_freq = freqs[dominant_idx]
# Spectral centroid
centroid = librosa.feature.spectral_centroid(y=self.y, sr=self.sr)
spectral_centroid = float(np.mean(centroid))
# Spectral rolloff
rolloff = librosa.feature.spectral_rolloff(y=self.y, sr=self.sr, roll_percent=0.85)
spectral_rolloff = float(np.mean(rolloff))
# Frequency band energy
bands = {}
for band_name, (low, high) in self.FREQ_BANDS.items():
mask = (freqs >= low) & (freqs < high)
if np.any(mask):
band_energy = np.mean(mag_avg[mask])
band_db = 20 * np.log10(band_energy + 1e-10)
else:
band_db = -60.0
bands[band_name] = float(band_db)
self._frequency = FrequencyResult(
dominant_freq=float(dominant_freq),
spectral_centroid=spectral_centroid,
spectral_rolloff=spectral_rolloff,
bands=bands
)
return self
def get_tempo(self) -> Dict[str, Any]:
"""Get tempo analysis results."""
if self._tempo is None:
self.analyze_tempo()
return asdict(self._tempo)
def get_key(self) -> Dict[str, Any]:
"""Get key detection results."""
if self._key is None:
self.analyze_key()
return asdict(self._key)
def get_loudness(self) -> Dict[str, Any]:
"""Get loudness analysis results."""
if self._loudness is None:
self.analyze_loudness()
return asdict(self._loudness)
def get_frequency(self) -> Dict[str, Any]:
"""Get frequency analysis results."""
if self._frequency is None:
self.analyze_frequency()
return asdict(self._frequency)
def get_results(self) -> Dict[str, Any]:
"""Get all analysis results."""
return {
'file': str(self.filepath),
'duration': self.duration,
'sample_rate': self.sr,
'tempo': self.get_tempo(),
'key': self.get_key(),
'loudness': self.get_loudness(),
'frequency': self.get_frequency()
}
def get_summary(self) -> str:
"""Get human-readable summary."""
results = self.get_results()
lines = [
f"Audio Analysis: {self.filepath.name}",
f"Duration: {results['duration']:.1f} seconds",
"",
f"Tempo: {results['tempo']['bpm']:.1f} BPM (confidence: {results['tempo']['confidence']:.0%})",
f"Key: {results['key']['key']} {results['key']['mode']} (confidence: {results['key']['confidence']:.0%})",
"",
"Loudness:",
f" RMS: {results['loudness']['rms_db']:.1f} dB",
f" Peak: {results['loudness']['peak_db']:.1f} dB",
f" LUFS: {results['loudness']['lufs']:.1f}",
f" Dynamic Range: {results['loudness']['dynamic_range_db']:.1f} dB",
"",
"Frequency:",
f" Dominant: {results['frequency']['dominant_freq']:.1f} Hz",
f" Centroid: {results['frequency']['spectral_centroid']:.1f} Hz",
f" Rolloff: {results['frequency']['spectral_rolloff']:.1f} Hz"
]
return "\n".join(lines)
def plot_waveform(
self,
output: str = "waveform.png",
figsize: tuple = (12, 4),
color: str = "#1f77b4",
show_rms: bool = True
) -> str:
"""
Plot waveform visualization.
Args:
output: Output file path
figsize: Figure size (width, height)
color: Waveform color
show_rms: Show RMS envelope
Returns:
Path to saved file
"""
import matplotlib.pyplot as plt
import librosa.display
fig, ax = plt.subplots(figsize=figsize)
# Time axis
times = np.linspace(0, self.duration, len(self.y))
# Plot waveform
ax.plot(times, self.y, color=color, alpha=0.7, linewidth=0.5)
# RMS envelope
if show_rms:
import librosa
rms = librosa.feature.rms(y=self.y)[0]
rms_times = librosa.frames_to_time(np.arange(len(rms)), sr=self.sr)
ax.plot(rms_times, rms, color='red', alpha=0.8, linewidth=1.5, label='RMS')
ax.plot(rms_times, -rms, color='red', alpha=0.8, linewidth=1.5)
ax.set_xlabel('Time (s)')
ax.set_ylabel('Amplitude')
ax.set_title(f'Waveform: {self.filepath.name}')
ax.set_xlim(0, self.duration)
ax.axhline(0, color='gray', linewidth=0.5)
plt.tight_layout()
plt.savefig(output, dpi=150, bbox_inches='tight')
plt.close()
return output
def plot_spectrogram(
self,
output: str = "spectrogram.png",
figsize: tuple = (12, 6),
cmap: str = "magma",
freq_scale: str = "log",
max_freq: int = 8000
) -> str:
"""
Plot spectrogram visualization.
Args:
output: Output file path
figsize: Figure size
cmap: Colormap (viridis, plasma, inferno, magma)
freq_scale: Frequency scale (linear, log, mel)
max_freq: Maximum frequency to display (Hz)
Returns:
Path to saved file
"""
import matplotlib.pyplot as plt
import librosa
import librosa.display
fig, ax = plt.subplots(figsize=figsize)
if freq_scale == 'mel':
# Mel spectrogram
S = librosa.feature.melspectrogram(y=self.y, sr=self.sr, fmax=max_freq)
S_db = librosa.power_to_db(S, ref=np.max)
img = librosa.display.specshow(
S_db, x_axis='time', y_axis='mel',
sr=self.sr, fmax=max_freq, cmap=cmap, ax=ax
)
else:
# Regular spectrogram
D = librosa.amplitude_to_db(np.abs(librosa.stft(self.y)), ref=np.max)
img = librosa.display.specshow(
D, x_axis='time', y_axis=freq_scale,
sr=self.sr, cmap=cmap, ax=ax
)
fig.colorbar(img, ax=ax, format='%+2.0f dB')
ax.set_title(f'Spectrogram: {self.filepath.name}')
plt.tight_layout()
plt.savefig(output, dpi=150, bbox_inches='tight')
plt.close()
return output
def plot_chromagram(
self,
output: str = "chromagram.png",
figsize: tuple = (12, 4)
) -> str:
"""
Plot chromagram (pitch class distribution over time).
Args:
output: Output file path
figsize: Figure size
Returns:
Path to saved file
"""
import matplotlib.pyplot as plt
import librosa
import librosa.display
fig, ax = plt.subplots(figsize=figsize)
chroma = librosa.feature.chroma_cqt(y=self.y, sr=self.sr)
img = librosa.display.specshow(
chroma, x_axis='time', y_axis='chroma',
sr=self.sr, cmap='coolwarm', ax=ax
)
fig.colorbar(img, ax=ax)
ax.set_title(f'Chromagram: {self.filepath.name}')
plt.tight_layout()
plt.savefig(output, dpi=150, bbox_inches='tight')
plt.close()
return output
def plot_beats(
self,
output: str = "beats.png",
figsize: tuple = (12, 4),
show_strength: bool = True
) -> str:
"""
Plot beat grid with onset strength.
Args:
output: Output file path
figsize: Figure size
show_strength: Show onset strength envelope
Returns:
Path to saved file
"""
import matplotlib.pyplot as plt
import librosa
fig, ax = plt.subplots(figsize=figsize)
# Ensure tempo is analyzed
if self._tempo is None:
self.analyze_tempo()
times = np.linspace(0, self.duration, len(self.y))
# Plot waveform lightly
ax.plot(times, self.y, color='gray', alpha=0.3, linewidth=0.5)
# Onset strength
if show_strength:
onset_env = librosa.onset.onset_strength(y=self.y, sr=self.sr)
onset_times = librosa.frames_to_time(np.arange(len(onset_env)), sr=self.sr)
# Normalize
onset_env = onset_env / np.max(onset_env) * np.max(np.abs(self.y))
ax.plot(onset_times, onset_env, color='blue', alpha=0.6, label='Onset strength')
# Plot beat markers
for beat_time in self._tempo.beats:
ax.axvline(beat_time, color='red', alpha=0.7, linewidth=1)
ax.set_xlabel('Time (s)')
ax.set_ylabel('Amplitude')
ax.set_title(f'Beat Grid ({self._tempo.bpm:.1f} BPM): {self.filepath.name}')
ax.set_xlim(0, self.duration)
plt.tight_layout()
plt.savefig(output, dpi=150, bbox_inches='tight')
plt.close()
return output
def plot_dashboard(
self,
output: str = "dashboard.png",
figsize: tuple = (14, 10)
) -> str:
"""
Plot comprehensive analysis dashboard.
Args:
output: Output file path
figsize: Figure size
Returns:
Path to saved file
"""
import matplotlib.pyplot as plt
import librosa
import librosa.display
# Ensure all analyses are done
self.analyze()
fig = plt.figure(figsize=figsize)
# Create grid
gs = fig.add_gridspec(3, 2, hspace=0.3, wspace=0.3)
# 1. Waveform (top, full width)
ax1 = fig.add_subplot(gs[0, :])
times = np.linspace(0, self.duration, len(self.y))
ax1.plot(times, self.y, color='#1f77b4', alpha=0.7, linewidth=0.5)
for beat_time in self._tempo.beats[:50]: # Limit beats shown
ax1.axvline(beat_time, color='red', alpha=0.3, linewidth=0.5)
ax1.set_xlabel('Time (s)')
ax1.set_ylabel('Amplitude')
ax1.set_title(f'Waveform with Beats ({self._tempo.bpm:.1f} BPM)')
ax1.set_xlim(0, self.duration)
# 2. Spectrogram (middle left)
ax2 = fig.add_subplot(gs[1, 0])
D = librosa.amplitude_to_db(np.abs(librosa.stft(self.y)), ref=np.max)
img = librosa.display.specshow(D, x_axis='time', y_axis='log', sr=self.sr, cmap='magma', ax=ax2)
fig.colorbar(img, ax=ax2, format='%+2.0f dB')
ax2.set_title('Spectrogram')
# 3. Chromagram (middle right)
ax3 = fig.add_subplot(gs[1, 1])
chroma = librosa.feature.chroma_cqt(y=self.y, sr=self.sr)
img2 = librosa.display.specshow(chroma, x_axis='time', y_axis='chroma', sr=self.sr, cmap='coolwarm', ax=ax3)
fig.colorbar(img2, ax=ax3)
ax3.set_title(f'Chromagram (Key: {self._key.key} {self._key.mode})')
# 4. Frequency bands (bottom left)
ax4 = fig.add_subplot(gs[2, 0])
bands = list(self._frequency.bands.keys())
values = list(self._frequency.bands.values())
colors = plt.cm.viridis(np.linspace(0.2, 0.8, len(bands)))
ax4.barh(bands, values, color=colors)
ax4.set_xlabel('Energy (dB)')
ax4.set_title('Frequency Bands')
ax4.axvline(0, color='gray', linewidth=0.5)
# 5. Info panel (bottom right)
ax5 = fig.add_subplot(gs[2, 1])
ax5.axis('off')
info_text = f"""
FILE: {self.filepath.name}
Duration: {self.duration:.1f}s
TEMPO
BPM: {self._tempo.bpm:.1f}
Confidence: {self._tempo.confidence:.0%}
Beats: {self._tempo.beat_count}
KEY
Key: {self._key.key} {self._key.mode}
Confidence: {self._key.confidence:.0%}
LOUDNESS
RMS: {self._loudness.rms_db:.1f} dB
Peak: {self._loudness.peak_db:.1f} dB
LUFS: {self._loudness.lufs:.1f}
Dynamic Range: {self._loudness.dynamic_range_db:.1f} dB
FREQUENCY
Dominant: {self._frequency.dominant_freq:.0f} Hz
Centroid: {self._frequency.spectral_centroid:.0f} Hz
"""
ax5.text(0.1, 0.95, info_text, transform=ax5.transAxes,
fontsize=10, verticalalignment='top', fontfamily='monospace',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
plt.suptitle(f'Audio Analysis Dashboard', fontsize=14, fontweight='bold')
plt.savefig(output, dpi=150, bbox_inches='tight')
plt.close()
return output
def save_report(self, path: str) -> str:
"""
Save analysis report to JSON.
Args:
path: Output file path
Returns:
Path to saved file
"""
results = self.get_results()
with open(path, 'w') as f:
json.dump(results, f, indent=2)
return path
def main():
"""CLI entry point."""
parser = argparse.ArgumentParser(
description='Comprehensive audio analysis toolkit',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --input song.mp3 --output-dir ./analysis/
%(prog)s --input song.mp3 --analyze tempo key --output report.json
%(prog)s --input song.mp3 --plot spectrogram --output spec.png
%(prog)s --input song.mp3 --dashboard --output dashboard.png
"""
)
parser.add_argument('--input', '-i', required=True, help='Input audio file')
parser.add_argument('--input-dir', help='Directory of audio files for batch processing')
parser.add_argument('--output', '-o', help='Output file path')
parser.add_argument('--output-dir', default='.', help='Output directory (default: current)')
parser.add_argument('--analyze', nargs='+',
choices=['tempo', 'key', 'loudness', 'frequency', 'all'],
default=['all'], help='Analysis types to run')
parser.add_argument('--plot', choices=['waveform', 'spectrogram', 'chromagram', 'beats', 'dashboard'],
help='Generate specific plot')
parser.add_argument('--dashboard', action='store_true', help='Generate full dashboard')
parser.add_argument('--format', choices=['json', 'txt'], default='json', help='Output format')
parser.add_argument('--sr', type=int, default=22050, help='Sample rate for analysis')
args = parser.parse_args()
# Create output directory
os.makedirs(args.output_dir, exist_ok=True)
# Process files
if args.input_dir:
# Batch mode
audio_extensions = {'.mp3', '.wav', '.flac', '.ogg', '.m4a', '.aiff'}
files = [f for f in Path(args.input_dir).iterdir()
if f.suffix.lower() in audio_extensions]
else:
files = [Path(args.input)]
for filepath in files:
print(f"Analyzing: {filepath.name}")
try:
analyzer = AudioAnalyzer(str(filepath), sr=args.sr)
# Run analyses
if 'all' in args.analyze:
analyzer.analyze()
else:
if 'tempo' in args.analyze:
analyzer.analyze_tempo()
if 'key' in args.analyze:
analyzer.analyze_key()
if 'loudness' in args.analyze:
analyzer.analyze_loudness()
if 'frequency' in args.analyze:
analyzer.analyze_frequency()
# Generate outputs
base_name = filepath.stem
if args.dashboard or args.plot == 'dashboard':
out_path = args.output or os.path.join(args.output_dir, f"{base_name}_dashboard.png")
analyzer.plot_dashboard(out_path)
print(f" Dashboard: {out_path}")
elif args.plot:
out_path = args.output or os.path.join(args.output_dir, f"{base_name}_{args.plot}.png")
if args.plot == 'waveform':
analyzer.plot_waveform(out_path)
elif args.plot == 'spectrogram':
analyzer.plot_spectrogram(out_path)
elif args.plot == 'chromagram':
analyzer.plot_chromagram(out_path)
elif args.plot == 'beats':
analyzer.plot_beats(out_path)
print(f" Plot: {out_path}")
else:
# Output report
if args.format == 'json':
out_path = args.output or os.path.join(args.output_dir, f"{base_name}_analysis.json")
analyzer.save_report(out_path)
print(f" Report: {out_path}")
else:
summary = analyzer.get_summary()
if args.output:
with open(args.output, 'w') as f:
f.write(summary)
print(f" Summary: {args.output}")
else:
print(summary)
except Exception as e:
print(f" Error: {e}")
print("Done!")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Audio Converter - Convert audio files between formats.
Features:
- Format conversion (MP3, WAV, FLAC, OGG, M4A, AIFF)
- Bitrate and sample rate control
- Batch processing
- Volume normalization
"""
import argparse
import os
from pathlib import Path
from typing import Optional, List
class AudioConverter:
"""Convert audio files between formats."""
SUPPORTED_FORMATS = {
'mp3': {'codec': 'mp3', 'lossy': True},
'wav': {'codec': 'pcm_s16le', 'lossy': False},
'flac': {'codec': 'flac', 'lossy': False},
'ogg': {'codec': 'libvorbis', 'lossy': True},
'm4a': {'codec': 'aac', 'lossy': True},
'aiff': {'codec': 'pcm_s16be', 'lossy': False}
}
def __init__(self, filepath: str):
"""
Initialize converter with audio file.
Args:
filepath: Path to input audio file
"""
self.filepath = Path(filepath)
if not self.filepath.exists():
raise FileNotFoundError(f"Audio file not found: {filepath}")
self._bitrate: Optional[int] = None
self._sample_rate: Optional[int] = None
self._channels: Optional[int] = None
self._normalize: bool = False
# Load audio
self._audio = None
self._load_audio()
def _load_audio(self):
"""Load audio file using pydub."""
try:
from pydub import AudioSegment
self._audio = AudioSegment.from_file(str(self.filepath))
except ImportError:
raise ImportError("pydub is required. Install with: pip install pydub")
except Exception as e:
raise ValueError(f"Failed to load audio file: {e}")
def bitrate(self, kbps: int) -> 'AudioConverter':
"""
Set output bitrate for lossy formats.
Args:
kbps: Bitrate in kilobits per second (e.g., 128, 192, 320)
Returns:
Self for chaining
"""
self._bitrate = kbps
return self
def sample_rate(self, hz: int) -> 'AudioConverter':
"""
Set output sample rate.
Args:
hz: Sample rate in Hz (e.g., 44100, 48000)
Returns:
Self for chaining
"""
self._sample_rate = hz
return self
def channels(self, num: int) -> 'AudioConverter':
"""
Set number of output channels.
Args:
num: Number of channels (1=mono, 2=stereo)
Returns:
Self for chaining
"""
self._channels = num
return self
def normalize(self, enable: bool = True) -> 'AudioConverter':
"""
Enable/disable volume normalization.
Args:
enable: Whether to normalize
Returns:
Self for chaining
"""
self._normalize = enable
return self
def convert(self, output: str, format: Optional[str] = None) -> str:
"""
Convert audio to specified format.
Args:
output: Output file path
format: Output format (optional, inferred from extension)
Returns:
Path to converted file
"""
from pydub import AudioSegment
output_path = Path(output)
# Determine format
if format is None:
format = output_path.suffix.lstrip('.').lower()
if format not in self.SUPPORTED_FORMATS:
raise ValueError(f"Unsupported format: {format}. Supported: {list(self.SUPPORTED_FORMATS.keys())}")
# Prepare audio
audio = self._audio
# Apply sample rate
if self._sample_rate:
audio = audio.set_frame_rate(self._sample_rate)
# Apply channels
if self._channels:
if self._channels == 1:
audio = audio.set_channels(1)
elif self._channels == 2:
audio = audio.set_channels(2)
# Normalize
if self._normalize:
# Normalize to -3 dB
change_in_dBFS = -3 - audio.dBFS
audio = audio.apply_gain(change_in_dBFS)
# Ensure output directory exists
output_path.parent.mkdir(parents=True, exist_ok=True)
# Build export parameters
export_params = {}
if self.SUPPORTED_FORMATS[format]['lossy']:
# Lossy format - set bitrate
bitrate = self._bitrate or 192
export_params['bitrate'] = f"{bitrate}k"
# Export
audio.export(
str(output_path),
format=format,
**export_params
)
return str(output_path)
def get_info(self) -> dict:
"""
Get information about the input audio.
Returns:
Dict with audio properties
"""
return {
'file': str(self.filepath),
'format': self.filepath.suffix.lstrip('.'),
'duration_ms': len(self._audio),
'duration_sec': len(self._audio) / 1000,
'channels': self._audio.channels,
'sample_rate': self._audio.frame_rate,
'sample_width': self._audio.sample_width,
'frame_count': self._audio.frame_count(),
'dBFS': self._audio.dBFS
}
@classmethod
def batch_convert(
cls,
input_dir: str,
output_dir: str,
format: str,
bitrate: Optional[int] = None,
sample_rate: Optional[int] = None,
channels: Optional[int] = None,
normalize: bool = False
) -> List[str]:
"""
Batch convert all audio files in a directory.
Args:
input_dir: Input directory path
output_dir: Output directory path
format: Output format
bitrate: Bitrate in kbps (for lossy formats)
sample_rate: Sample rate in Hz
channels: Number of channels
normalize: Whether to normalize volume
Returns:
List of converted file paths
"""
input_path = Path(input_dir)
output_path = Path(output_dir)
if not input_path.exists():
raise FileNotFoundError(f"Input directory not found: {input_dir}")
output_path.mkdir(parents=True, exist_ok=True)
# Find audio files
audio_extensions = {'.mp3', '.wav', '.flac', '.ogg', '.m4a', '.aiff', '.aac', '.wma'}
files = [f for f in input_path.iterdir() if f.suffix.lower() in audio_extensions]
converted = []
for filepath in files:
try:
converter = cls(str(filepath))
if bitrate:
converter.bitrate(bitrate)
if sample_rate:
converter.sample_rate(sample_rate)
if channels:
converter.channels(channels)
if normalize:
converter.normalize(True)
output_file = output_path / f"{filepath.stem}.{format}"
converter.convert(str(output_file), format=format)
converted.append(str(output_file))
print(f"Converted: {filepath.name} -> {output_file.name}")
except Exception as e:
print(f"Failed to convert {filepath.name}: {e}")
return converted
def main():
"""CLI entry point."""
parser = argparse.ArgumentParser(
description='Convert audio files between formats',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --input song.wav --output song.mp3
%(prog)s --input song.flac --output song.mp3 --bitrate 320
%(prog)s --input-dir ./wavs --output-dir ./mp3s --format mp3 --bitrate 192
"""
)
parser.add_argument('--input', '-i', help='Input audio file')
parser.add_argument('--output', '-o', help='Output file path')
parser.add_argument('--input-dir', help='Input directory for batch processing')
parser.add_argument('--output-dir', help='Output directory for batch processing')
parser.add_argument('--format', '-f', help='Output format')
parser.add_argument('--bitrate', '-b', type=int, help='Bitrate in kbps (for lossy formats)')
parser.add_argument('--sample-rate', '-s', type=int, help='Sample rate in Hz')
parser.add_argument('--channels', '-c', type=int, choices=[1, 2], help='Number of channels')
parser.add_argument('--normalize', '-n', action='store_true', help='Normalize volume')
parser.add_argument('--info', action='store_true', help='Show info about input file')
args = parser.parse_args()
# Validate arguments
if args.input_dir:
# Batch mode
if not args.output_dir:
parser.error("--output-dir is required for batch processing")
if not args.format:
parser.error("--format is required for batch processing")
converted = AudioConverter.batch_convert(
input_dir=args.input_dir,
output_dir=args.output_dir,
format=args.format,
bitrate=args.bitrate,
sample_rate=args.sample_rate,
channels=args.channels,
normalize=args.normalize
)
print(f"\nConverted {len(converted)} files")
elif args.input:
# Single file mode
converter = AudioConverter(args.input)
if args.info:
info = converter.get_info()
print(f"File: {info['file']}")
print(f"Format: {info['format']}")
print(f"Duration: {info['duration_sec']:.2f} seconds")
print(f"Channels: {info['channels']}")
print(f"Sample Rate: {info['sample_rate']} Hz")
print(f"Level: {info['dBFS']:.1f} dBFS")
return
if not args.output:
parser.error("--output is required for conversion")
if args.bitrate:
converter.bitrate(args.bitrate)
if args.sample_rate:
converter.sample_rate(args.sample_rate)
if args.channels:
converter.channels(args.channels)
if args.normalize:
converter.normalize(True)
output = converter.convert(args.output, format=args.format)
print(f"Converted: {args.input} -> {output}")
else:
parser.error("Either --input or --input-dir is required")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Audio Normalizer - Normalize audio volume using peak or RMS methods.
Features:
- Peak normalization to target dBFS
- RMS normalization for average loudness
- Loudness analysis
- Batch processing
- Format preservation
"""
import argparse
import os
import sys
from pathlib import Path
from typing import Dict, List, Optional
import numpy as np
from pydub import AudioSegment
from pydub.utils import db_to_float, ratio_to_db
class AudioNormalizer:
"""Normalize audio volume levels."""
def __init__(self):
self.audio = None
self.filepath = None
self.sample_rate = None
def load(self, filepath: str) -> 'AudioNormalizer':
"""
Load audio file.
Args:
filepath: Path to audio file
Returns:
Self for method chaining
"""
if not os.path.exists(filepath):
raise FileNotFoundError(f"Audio file not found: {filepath}")
self.filepath = filepath
# Auto-detect format from extension
ext = Path(filepath).suffix[1:].lower()
self.audio = AudioSegment.from_file(filepath, format=ext)
self.sample_rate = self.audio.frame_rate
return self
def analyze_levels(self) -> Dict[str, float]:
"""
Analyze current audio levels.
Returns:
Dictionary with peak and RMS levels in dBFS
"""
if self.audio is None:
raise ValueError("No audio loaded. Call load() first.")
# Peak level (max dBFS)
peak_dbfs = self.audio.max_dBFS
# RMS level (average loudness)
rms_dbfs = self.audio.dBFS
# Crest factor (peak to RMS ratio)
crest_factor = peak_dbfs - rms_dbfs
return {
'peak_dbfs': round(peak_dbfs, 2),
'rms_dbfs': round(rms_dbfs, 2),
'crest_factor': round(crest_factor, 2),
'duration_seconds': len(self.audio) / 1000.0,
'sample_rate': self.sample_rate,
'channels': self.audio.channels
}
def normalize_peak(self, target_dbfs: float = -1.0, headroom: float = 0.1) -> 'AudioNormalizer':
"""
Normalize audio to target peak level.
Args:
target_dbfs: Target peak level in dBFS (default: -1.0)
headroom: Safety headroom in dB to prevent clipping (default: 0.1)
Returns:
Self for method chaining
"""
if self.audio is None:
raise ValueError("No audio loaded. Call load() first.")
# Calculate current peak
current_peak = self.audio.max_dBFS
# Calculate gain needed (with headroom)
gain_needed = (target_dbfs - headroom) - current_peak
# Apply gain
self.audio = self.audio.apply_gain(gain_needed)
return self
def normalize_rms(self, target_dbfs: float = -20.0) -> 'AudioNormalizer':
"""
Normalize audio to target RMS (average loudness) level.
Args:
target_dbfs: Target RMS level in dBFS (default: -20.0)
Returns:
Self for method chaining
"""
if self.audio is None:
raise ValueError("No audio loaded. Call load() first.")
# Calculate current RMS
current_rms = self.audio.dBFS
# Calculate gain needed
gain_needed = target_dbfs - current_rms
# Apply gain
self.audio = self.audio.apply_gain(gain_needed)
# Check for clipping after normalization
if self.audio.max_dBFS > -0.1:
print(f"Warning: Audio may clip (peak at {self.audio.max_dBFS:.2f} dBFS). "
f"Consider using peak normalization or lower target.")
return self
def match_loudness(self, reference_audio: 'AudioSegment') -> 'AudioNormalizer':
"""
Match loudness to reference audio.
Args:
reference_audio: AudioSegment to match loudness to
Returns:
Self for method chaining
"""
if self.audio is None:
raise ValueError("No audio loaded. Call load() first.")
# Get reference RMS
reference_rms = reference_audio.dBFS
# Normalize to match
return self.normalize_rms(target_dbfs=reference_rms)
def save(self, output: str, format: str = None, bitrate: str = '192k') -> str:
"""
Save normalized audio.
Args:
output: Output filepath
format: Output format (auto-detected from extension if None)
bitrate: Output bitrate for compressed formats
Returns:
Path to saved file
"""
if self.audio is None:
raise ValueError("No audio loaded. Call load() first.")
# Auto-detect format from extension
if format is None:
format = Path(output).suffix[1:].lower()
# Create output directory if needed
os.makedirs(os.path.dirname(output) or '.', exist_ok=True)
# Export with parameters
if format in ['mp3', 'ogg', 'm4a']:
self.audio.export(output, format=format, bitrate=bitrate)
else:
self.audio.export(output, format=format)
return output
def batch_normalize(self, input_files: List[str], output_dir: str,
method: str = 'rms', target_dbfs: float = -20.0,
format: str = None) -> List[str]:
"""
Normalize multiple audio files to same level.
Args:
input_files: List of input file paths
output_dir: Output directory for normalized files
method: Normalization method ('peak' or 'rms')
target_dbfs: Target level in dBFS
format: Output format (preserves input format if None)
Returns:
List of output file paths
"""
os.makedirs(output_dir, exist_ok=True)
output_files = []
for i, input_file in enumerate(input_files, 1):
print(f"Processing {i}/{len(input_files)}: {os.path.basename(input_file)}")
# Load and analyze
self.load(input_file)
levels = self.analyze_levels()
print(f" Current: Peak={levels['peak_dbfs']:.2f} dB, RMS={levels['rms_dbfs']:.2f} dB")
# Normalize
if method == 'peak':
self.normalize_peak(target_dbfs=target_dbfs)
elif method == 'rms':
self.normalize_rms(target_dbfs=target_dbfs)
else:
raise ValueError(f"Unknown method: {method}")
# Save
output_format = format or Path(input_file).suffix[1:]
output_filename = Path(input_file).stem + f'_normalized.{output_format}'
output_path = os.path.join(output_dir, output_filename)
self.save(output_path, format=output_format)
# Verify
self.load(output_path)
new_levels = self.analyze_levels()
print(f" Normalized: Peak={new_levels['peak_dbfs']:.2f} dB, RMS={new_levels['rms_dbfs']:.2f} dB")
output_files.append(output_path)
return output_files
def main():
parser = argparse.ArgumentParser(
description='Normalize audio volume levels',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Peak normalization to -1 dBFS
python audio_normalizer.py input.mp3 --output normalized.mp3 --method peak --target -1.0
# RMS normalization for podcasts
python audio_normalizer.py input.mp3 --output normalized.mp3 --method rms --target -19.0
# Analyze current levels
python audio_normalizer.py input.mp3 --analyze-only
# Batch normalize all MP3s
python audio_normalizer.py *.mp3 --output-dir normalized/ --method rms --target -20.0
"""
)
parser.add_argument('input', nargs='+', help='Input audio file(s)')
parser.add_argument('--output', '-o', help='Output file (single file mode)')
parser.add_argument('--output-dir', '-d', help='Output directory (batch mode)')
parser.add_argument('--method', '-m', choices=['peak', 'rms'], default='rms',
help='Normalization method (default: rms)')
parser.add_argument('--target', '-t', type=float, default=-20.0,
help='Target level in dBFS (default: -20.0 for RMS, -1.0 for peak)')
parser.add_argument('--format', '-f', help='Output format (auto-detected from extension)')
parser.add_argument('--bitrate', '-b', default='192k', help='Bitrate for compressed formats (default: 192k)')
parser.add_argument('--analyze-only', '-a', action='store_true',
help='Analyze levels without normalizing')
args = parser.parse_args()
normalizer = AudioNormalizer()
# Single file mode
if len(args.input) == 1 and not args.output_dir:
input_file = args.input[0]
# Load and analyze
normalizer.load(input_file)
levels = normalizer.analyze_levels()
print(f"\nAudio Analysis: {os.path.basename(input_file)}")
print(f" Peak Level: {levels['peak_dbfs']:.2f} dBFS")
print(f" RMS Level: {levels['rms_dbfs']:.2f} dBFS")
print(f" Crest Factor: {levels['crest_factor']:.2f} dB")
print(f" Duration: {levels['duration_seconds']:.2f} seconds")
print(f" Sample Rate: {levels['sample_rate']} Hz")
print(f" Channels: {levels['channels']}")
if args.analyze_only:
return
if not args.output:
print("\nError: --output required for single file normalization")
return
# Normalize
if args.method == 'peak':
target = args.target if args.target != -20.0 else -1.0
normalizer.normalize_peak(target_dbfs=target)
else:
normalizer.normalize_rms(target_dbfs=args.target)
# Save
normalizer.save(args.output, format=args.format, bitrate=args.bitrate)
# Verify
normalizer.load(args.output)
new_levels = normalizer.analyze_levels()
print(f"\nNormalized Audio:")
print(f" Peak Level: {new_levels['peak_dbfs']:.2f} dBFS")
print(f" RMS Level: {new_levels['rms_dbfs']:.2f} dBFS")
print(f"\nSaved: {args.output}")
# Batch mode
else:
if not args.output_dir:
print("Error: --output-dir required for batch processing")
return
target = args.target
if args.method == 'peak' and target == -20.0:
target = -1.0
print(f"\nBatch normalizing {len(args.input)} files...")
print(f"Method: {args.method.upper()} to {target:.1f} dBFS")
print(f"Output directory: {args.output_dir}\n")
output_files = normalizer.batch_normalize(
input_files=args.input,
output_dir=args.output_dir,
method=args.method,
target_dbfs=target,
format=args.format
)
print(f"\n✓ Normalized {len(output_files)} files to {args.output_dir}")
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
Audio Trimmer - Cut, trim, and edit audio segments.
Features:
- Precise trimming by timestamp
- Fade in/out effects
- Speed control
- Concatenation with crossfade
- Basic audio effects
- Volume adjustment
"""
import argparse
import re
from pathlib import Path
from typing import List, Optional, Tuple, Union
class AudioTrimmer:
"""Cut, trim, and edit audio segments."""
def __init__(self, filepath: str):
"""
Initialize trimmer with audio file.
Args:
filepath: Path to input audio file
"""
self.filepath = Path(filepath)
if not self.filepath.exists():
raise FileNotFoundError(f"Audio file not found: {filepath}")
self._audio = None
self._load_audio()
def _load_audio(self):
"""Load audio file using pydub."""
try:
from pydub import AudioSegment
self._audio = AudioSegment.from_file(str(self.filepath))
except ImportError:
raise ImportError("pydub is required. Install with: pip install pydub")
except Exception as e:
raise ValueError(f"Failed to load audio file: {e}")
@staticmethod
def _parse_timestamp(ts: str) -> int:
"""
Parse timestamp string to milliseconds.
Args:
ts: Timestamp string (HH:MM:SS, MM:SS, or seconds)
Returns:
Milliseconds
"""
if isinstance(ts, (int, float)):
return int(ts * 1000) if ts < 1000 else int(ts)
ts = str(ts).strip()
# Try HH:MM:SS.ms or HH:MM:SS
match = re.match(r'^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$', ts)
if match:
h, m, s = int(match.group(1)), int(match.group(2)), int(match.group(3))
ms = int(match.group(4) or 0)
return (h * 3600 + m * 60 + s) * 1000 + ms
# Try MM:SS.ms or MM:SS
match = re.match(r'^(\d+):(\d{2})(?:\.(\d+))?$', ts)
if match:
m, s = int(match.group(1)), int(match.group(2))
ms = int(match.group(3) or 0)
return (m * 60 + s) * 1000 + ms
# Try seconds.ms
match = re.match(r'^(\d+)(?:\.(\d+))?$', ts)
if match:
s = int(match.group(1))
ms = int(match.group(2) or 0)
return s * 1000 + ms
raise ValueError(f"Invalid timestamp format: {ts}")
def trim(
self,
start: Optional[Union[str, int]] = None,
end: Optional[Union[str, int]] = None,
start_ms: Optional[int] = None,
end_ms: Optional[int] = None
) -> 'AudioTrimmer':
"""
Trim audio to segment.
Args:
start: Start timestamp (HH:MM:SS, MM:SS, or seconds)
end: End timestamp
start_ms: Start position in milliseconds
end_ms: End position in milliseconds
Returns:
Self for chaining
"""
# Parse timestamps
if start is not None:
start_ms = self._parse_timestamp(start)
if end is not None:
end_ms = self._parse_timestamp(end)
# Default values
if start_ms is None:
start_ms = 0
if end_ms is None:
end_ms = len(self._audio)
# Validate
if start_ms < 0:
start_ms = 0
if end_ms > len(self._audio):
end_ms = len(self._audio)
if start_ms >= end_ms:
raise ValueError("Start must be before end")
self._audio = self._audio[start_ms:end_ms]
return self
def fade_in(self, duration_ms: int) -> 'AudioTrimmer':
"""
Apply fade in effect.
Args:
duration_ms: Fade duration in milliseconds
Returns:
Self for chaining
"""
if duration_ms > len(self._audio):
duration_ms = len(self._audio)
self._audio = self._audio.fade_in(duration_ms)
return self
def fade_out(self, duration_ms: int) -> 'AudioTrimmer':
"""
Apply fade out effect.
Args:
duration_ms: Fade duration in milliseconds
Returns:
Self for chaining
"""
if duration_ms > len(self._audio):
duration_ms = len(self._audio)
self._audio = self._audio.fade_out(duration_ms)
return self
def speed(self, factor: float) -> 'AudioTrimmer':
"""
Change playback speed (affects pitch).
Args:
factor: Speed multiplier (1.5 = 50% faster, 0.5 = half speed)
Returns:
Self for chaining
"""
if factor <= 0:
raise ValueError("Speed factor must be positive")
# Change frame rate to adjust speed
new_frame_rate = int(self._audio.frame_rate * factor)
self._audio = self._audio._spawn(
self._audio.raw_data,
overrides={'frame_rate': new_frame_rate}
).set_frame_rate(self._audio.frame_rate)
return self
def reverse(self) -> 'AudioTrimmer':
"""
Reverse the audio.
Returns:
Self for chaining
"""
self._audio = self._audio.reverse()
return self
def loop(self, times: int) -> 'AudioTrimmer':
"""
Loop the audio N times.
Args:
times: Number of times to repeat
Returns:
Self for chaining
"""
if times < 1:
raise ValueError("Times must be at least 1")
self._audio = self._audio * times
return self
def gain(self, db: float) -> 'AudioTrimmer':
"""
Adjust volume by dB.
Args:
db: Volume change in decibels (positive = louder)
Returns:
Self for chaining
"""
self._audio = self._audio + db
return self
def normalize(self, target_dbfs: float = -3.0) -> 'AudioTrimmer':
"""
Normalize audio to target level.
Args:
target_dbfs: Target level in dBFS
Returns:
Self for chaining
"""
change_in_dbfs = target_dbfs - self._audio.dBFS
self._audio = self._audio.apply_gain(change_in_dbfs)
return self
def add_silence_start(self, duration_ms: int) -> 'AudioTrimmer':
"""
Add silence at the start.
Args:
duration_ms: Silence duration in milliseconds
Returns:
Self for chaining
"""
from pydub import AudioSegment
silence = AudioSegment.silent(
duration=duration_ms,
frame_rate=self._audio.frame_rate
)
self._audio = silence + self._audio
return self
def add_silence_end(self, duration_ms: int) -> 'AudioTrimmer':
"""
Add silence at the end.
Args:
duration_ms: Silence duration in milliseconds
Returns:
Self for chaining
"""
from pydub import AudioSegment
silence = AudioSegment.silent(
duration=duration_ms,
frame_rate=self._audio.frame_rate
)
self._audio = self._audio + silence
return self
def strip_silence(
self,
threshold: float = -50.0,
chunk_size: int = 10,
min_silence_len: int = 100
) -> 'AudioTrimmer':
"""
Strip leading and trailing silence.
Args:
threshold: Silence threshold in dBFS
chunk_size: Analysis chunk size in ms
min_silence_len: Minimum silence length to strip
Returns:
Self for chaining
"""
from pydub.silence import detect_leading_silence
# Strip leading silence
start_trim = detect_leading_silence(self._audio, silence_threshold=threshold, chunk_size=chunk_size)
# Strip trailing silence
reversed_audio = self._audio.reverse()
end_trim = detect_leading_silence(reversed_audio, silence_threshold=threshold, chunk_size=chunk_size)
if start_trim + end_trim < len(self._audio):
self._audio = self._audio[start_trim:len(self._audio) - end_trim]
return self
def overlay(
self,
other_file: str,
position_ms: int = 0,
volume: float = 0,
loop: bool = False
) -> 'AudioTrimmer':
"""
Overlay another audio file.
Args:
other_file: Path to audio file to overlay
position_ms: Position to start overlay
volume: Volume adjustment for overlay (dB)
loop: Loop overlay to fill duration
Returns:
Self for chaining
"""
from pydub import AudioSegment
other = AudioSegment.from_file(other_file)
# Adjust volume
if volume != 0:
other = other + volume
# Loop if needed
if loop:
remaining = len(self._audio) - position_ms
if len(other) < remaining:
repetitions = (remaining // len(other)) + 1
other = other * repetitions
other = other[:remaining]
self._audio = self._audio.overlay(other, position=position_ms)
return self
def get_duration_ms(self) -> int:
"""Get current audio duration in milliseconds."""
return len(self._audio)
def get_duration_str(self) -> str:
"""Get current audio duration as formatted string."""
total_seconds = len(self._audio) / 1000
hours = int(total_seconds // 3600)
minutes = int((total_seconds % 3600) // 60)
seconds = total_seconds % 60
if hours > 0:
return f"{hours}:{minutes:02d}:{seconds:05.2f}"
else:
return f"{minutes}:{seconds:05.2f}"
def save(
self,
output: str,
format: Optional[str] = None,
bitrate: int = 192
) -> str:
"""
Save audio to file.
Args:
output: Output file path
format: Output format (optional, from extension)
bitrate: Bitrate for lossy formats (kbps)
Returns:
Path to saved file
"""
output_path = Path(output)
output_path.parent.mkdir(parents=True, exist_ok=True)
if format is None:
format = output_path.suffix.lstrip('.').lower()
# Export parameters
params = {}
if format in ('mp3', 'ogg', 'm4a'):
params['bitrate'] = f"{bitrate}k"
self._audio.export(str(output_path), format=format, **params)
return str(output_path)
@classmethod
def concatenate(
cls,
files: List[str],
output: str,
format: Optional[str] = None,
bitrate: int = 192
) -> str:
"""
Concatenate multiple audio files.
Args:
files: List of input file paths
output: Output file path
format: Output format
bitrate: Bitrate for lossy formats
Returns:
Path to saved file
"""
from pydub import AudioSegment
if not files:
raise ValueError("No files to concatenate")
combined = AudioSegment.empty()
for filepath in files:
segment = AudioSegment.from_file(filepath)
combined += segment
output_path = Path(output)
output_path.parent.mkdir(parents=True, exist_ok=True)
if format is None:
format = output_path.suffix.lstrip('.').lower()
params = {}
if format in ('mp3', 'ogg', 'm4a'):
params['bitrate'] = f"{bitrate}k"
combined.export(str(output_path), format=format, **params)
return str(output_path)
@classmethod
def concatenate_with_crossfade(
cls,
files: List[str],
output: str,
crossfade_ms: int = 1000,
format: Optional[str] = None,
bitrate: int = 192
) -> str:
"""
Concatenate files with crossfade transitions.
Args:
files: List of input file paths
output: Output file path
crossfade_ms: Crossfade duration in milliseconds
format: Output format
bitrate: Bitrate for lossy formats
Returns:
Path to saved file
"""
from pydub import AudioSegment
if not files:
raise ValueError("No files to concatenate")
combined = AudioSegment.from_file(files[0])
for filepath in files[1:]:
segment = AudioSegment.from_file(filepath)
combined = combined.append(segment, crossfade=crossfade_ms)
output_path = Path(output)
output_path.parent.mkdir(parents=True, exist_ok=True)
if format is None:
format = output_path.suffix.lstrip('.').lower()
params = {}
if format in ('mp3', 'ogg', 'm4a'):
params['bitrate'] = f"{bitrate}k"
combined.export(str(output_path), format=format, **params)
return str(output_path)
def main():
"""CLI entry point."""
parser = argparse.ArgumentParser(
description='Cut, trim, and edit audio segments',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --input podcast.mp3 --output segment.mp3 --start 05:30 --end 10:00
%(prog)s --input song.mp3 --output faded.mp3 --fade-in 3000 --fade-out 5000
%(prog)s --input lecture.mp3 --output fast.mp3 --speed 1.5
%(prog)s --concat file1.mp3 file2.mp3 file3.mp3 --output merged.mp3
"""
)
parser.add_argument('--input', '-i', help='Input audio file')
parser.add_argument('--output', '-o', required=True, help='Output file path')
parser.add_argument('--start', '-s', help='Start timestamp (HH:MM:SS or MM:SS)')
parser.add_argument('--end', '-e', help='End timestamp')
parser.add_argument('--fade-in', type=int, help='Fade in duration (ms)')
parser.add_argument('--fade-out', type=int, help='Fade out duration (ms)')
parser.add_argument('--speed', type=float, default=1.0, help='Speed multiplier')
parser.add_argument('--gain', type=float, default=0, help='Volume adjustment (dB)')
parser.add_argument('--normalize', type=float, help='Normalize to dBFS level')
parser.add_argument('--reverse', action='store_true', help='Reverse audio')
parser.add_argument('--loop', type=int, help='Loop N times')
parser.add_argument('--concat', nargs='+', help='Files to concatenate')
parser.add_argument('--crossfade', type=int, default=0, help='Crossfade duration (ms)')
parser.add_argument('--bitrate', type=int, default=192, help='Output bitrate (kbps)')
parser.add_argument('--segments', help='Multiple segments: "00:00-05:00,10:00-15:00"')
parser.add_argument('--output-dir', help='Output directory for multiple segments')
args = parser.parse_args()
# Concatenation mode
if args.concat:
if args.crossfade > 0:
output = AudioTrimmer.concatenate_with_crossfade(
args.concat, args.output,
crossfade_ms=args.crossfade,
bitrate=args.bitrate
)
else:
output = AudioTrimmer.concatenate(
args.concat, args.output,
bitrate=args.bitrate
)
print(f"Concatenated {len(args.concat)} files -> {output}")
return
# Single file mode
if not args.input:
parser.error("--input is required (unless using --concat)")
# Multiple segments mode
if args.segments:
if not args.output_dir:
parser.error("--output-dir is required when using --segments")
Path(args.output_dir).mkdir(parents=True, exist_ok=True)
segment_pairs = args.segments.split(',')
for i, segment in enumerate(segment_pairs):
start, end = segment.strip().split('-')
trimmer = AudioTrimmer(args.input)
trimmer.trim(start=start.strip(), end=end.strip())
if args.fade_in:
trimmer.fade_in(args.fade_in)
if args.fade_out:
trimmer.fade_out(args.fade_out)
output_file = Path(args.output_dir) / f"segment_{i+1:02d}.mp3"
trimmer.save(str(output_file), bitrate=args.bitrate)
print(f"Segment {i+1}: {start.strip()} - {end.strip()} -> {output_file}")
return
# Standard trimming mode
trimmer = AudioTrimmer(args.input)
# Apply operations
if args.start or args.end:
trimmer.trim(start=args.start, end=args.end)
if args.speed != 1.0:
trimmer.speed(args.speed)
if args.reverse:
trimmer.reverse()
if args.loop:
trimmer.loop(args.loop)
if args.gain != 0:
trimmer.gain(args.gain)
if args.normalize is not None:
trimmer.normalize(args.normalize)
if args.fade_in:
trimmer.fade_in(args.fade_in)
if args.fade_out:
trimmer.fade_out(args.fade_out)
# Save
output = trimmer.save(args.output, bitrate=args.bitrate)
print(f"Saved: {output} (duration: {trimmer.get_duration_str()})")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Video to GIF Workshop - Convert videos to optimized GIFs
Features: clipping, speed control, text overlays, and smart optimization.
"""
import io
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
import numpy as np
from PIL import Image, ImageDraw, ImageFont
try:
from moviepy.editor import (
ColorClip,
CompositeVideoClip,
TextClip,
VideoFileClip,
concatenate_videoclips,
vfx,
)
HAS_MOVIEPY = True
except ImportError:
HAS_MOVIEPY = False
try:
import imageio
HAS_IMAGEIO = True
except ImportError:
HAS_IMAGEIO = False
class GifError(Exception):
"""Custom exception for GIF processing errors."""
pass
@dataclass
class GifConfig:
"""Configuration for GIF generation."""
default_fps: int = 15
default_width: int = 480
default_colors: int = 256
max_duration: float = 30.0
default_loop: int = 0 # 0 = infinite
# Presets for common use cases
PRESETS = {
'twitter': {'width': 512, 'fps': 15, 'max_size_kb': 5000, 'colors': 256},
'discord': {'width': 256, 'fps': 15, 'max_size_kb': 8000, 'colors': 256},
'slack': {'width': 480, 'fps': 15, 'max_size_kb': 5000, 'colors': 256},
'reddit': {'width': 720, 'fps': 20, 'colors': 256},
'high': {'width': 640, 'fps': 20, 'colors': 256},
'medium': {'width': 480, 'fps': 15, 'colors': 256},
'low': {'width': 320, 'fps': 12, 'colors': 128},
'thumbnail': {'width': 200, 'fps': 10, 'colors': 64, 'max_duration': 3},
'reaction': {'width': 256, 'fps': 10, 'colors': 64, 'max_duration': 5},
}
# Text position mappings
TEXT_POSITIONS = {
'top': ('center', 'top'),
'bottom': ('center', 'bottom'),
'center': ('center', 'center'),
'top-left': ('left', 'top'),
'top-right': ('right', 'top'),
'bottom-left': ('left', 'bottom'),
'bottom-right': ('right', 'bottom'),
}
class GifWorkshop:
"""
Main class for video to GIF conversion.
Supports chaining operations:
GifWorkshop("video.mp4").clip(0, 10).resize(480).to_gif("out.gif")
"""
def __init__(
self,
source: Union[str, Path],
fps: Optional[int] = None,
width: Optional[int] = None
):
"""
Initialize GIF Workshop.
Args:
source: Path to video file
fps: Target FPS (default: 15)
width: Target width (default: original)
"""
if not HAS_MOVIEPY:
raise ImportError("moviepy is required. Install with: pip install moviepy")
self.config = GifConfig()
self._source_path = Path(source)
if not self._source_path.exists():
raise FileNotFoundError(f"Video not found: {source}")
# Load video
self._clip = VideoFileClip(str(self._source_path))
self._original_duration = self._clip.duration
self._original_size = self._clip.size
# Settings
self._fps = fps or self.config.default_fps
self._width = width
self._colors = self.config.default_colors
self._loop = self.config.default_loop
self._max_size_kb: Optional[int] = None
self._text_overlays: List[Dict] = []
self._operations: List[str] = []
def __del__(self):
"""Clean up video clip."""
if hasattr(self, '_clip') and self._clip:
self._clip.close()
def get_info(self) -> Dict[str, Any]:
"""Get video information."""
return {
'source': str(self._source_path),
'duration': self._clip.duration,
'width': self._clip.size[0],
'height': self._clip.size[1],
'fps': self._clip.fps,
'frame_count': int(self._clip.duration * self._clip.fps),
}
def clip(
self,
start: Optional[Union[float, str]] = None,
end: Optional[Union[float, str]] = None
) -> 'GifWorkshop':
"""
Select time range from video.
Args:
start: Start time (seconds or "MM:SS" format)
end: End time (seconds or "MM:SS" format)
"""
start_sec = self._parse_time(start) if start is not None else 0
end_sec = self._parse_time(end) if end is not None else self._clip.duration
# Validate
if start_sec < 0:
start_sec = 0
if end_sec > self._clip.duration:
end_sec = self._clip.duration
if start_sec >= end_sec:
raise GifError(f"Invalid time range: {start_sec} to {end_sec}")
self._clip = self._clip.subclip(start_sec, end_sec)
self._operations.append(f"clip({start_sec:.1f}s-{end_sec:.1f}s)")
return self
def clip_multi(self, ranges: List[Tuple[float, float]]) -> 'GifWorkshop':
"""Clip and concatenate multiple time ranges."""
clips = []
for start, end in ranges:
clip = self._clip.subclip(start, end)
clips.append(clip)
self._clip = concatenate_videoclips(clips)
self._operations.append(f"clip_multi({len(ranges)} clips)")
return self
def _parse_time(self, time_val: Union[float, str]) -> float:
"""Parse time value to seconds."""
if isinstance(time_val, (int, float)):
return float(time_val)
# Parse MM:SS or HH:MM:SS format
parts = str(time_val).split(':')
if len(parts) == 2:
return float(parts[0]) * 60 + float(parts[1])
elif len(parts) == 3:
return float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2])
else:
return float(time_val)
def speed(self, factor: float) -> 'GifWorkshop':
"""
Adjust playback speed.
Args:
factor: Speed multiplier (2.0 = 2x faster, 0.5 = half speed)
"""
if factor <= 0:
raise GifError("Speed factor must be positive")
self._clip = self._clip.fx(vfx.speedx, factor)
self._operations.append(f"speed({factor}x)")
return self
def reverse(self) -> 'GifWorkshop':
"""Reverse the clip."""
self._clip = self._clip.fx(vfx.time_mirror)
self._operations.append("reverse()")
return self
def boomerang(self) -> 'GifWorkshop':
"""Create boomerang effect (forward then reverse)."""
reversed_clip = self._clip.fx(vfx.time_mirror)
self._clip = concatenate_videoclips([self._clip, reversed_clip])
self._operations.append("boomerang()")
return self
def resize(
self,
width: Optional[int] = None,
height: Optional[int] = None
) -> 'GifWorkshop':
"""
Resize the video.
Args:
width: Target width (maintains aspect if height not specified)
height: Target height (maintains aspect if width not specified)
"""
if width and height:
self._clip = self._clip.resize((width, height))
elif width:
self._clip = self._clip.resize(width=width)
self._width = width
elif height:
self._clip = self._clip.resize(height=height)
self._operations.append(f"resize({width}x{height})")
return self
def crop(
self,
x: int = 0,
y: int = 0,
width: Optional[int] = None,
height: Optional[int] = None
) -> 'GifWorkshop':
"""
Crop video to region.
Args:
x: Left position
y: Top position
width: Crop width
height: Crop height
"""
w = width or (self._clip.size[0] - x)
h = height or (self._clip.size[1] - y)
self._clip = self._clip.crop(x1=x, y1=y, x2=x + w, y2=y + h)
self._operations.append(f"crop({x},{y},{w},{h})")
return self
def crop_to_aspect(self, aspect_w: int, aspect_h: int) -> 'GifWorkshop':
"""
Crop to specific aspect ratio.
Args:
aspect_w: Width ratio (e.g., 16 for 16:9)
aspect_h: Height ratio (e.g., 9 for 16:9)
"""
current_w, current_h = self._clip.size
current_aspect = current_w / current_h
target_aspect = aspect_w / aspect_h
if current_aspect > target_aspect:
# Too wide, crop sides
new_w = int(current_h * target_aspect)
x = (current_w - new_w) // 2
self._clip = self._clip.crop(x1=x, x2=x + new_w)
else:
# Too tall, crop top/bottom
new_h = int(current_w / target_aspect)
y = (current_h - new_h) // 2
self._clip = self._clip.crop(y1=y, y2=y + new_h)
self._operations.append(f"crop_to_aspect({aspect_w}:{aspect_h})")
return self
def set_fps(self, fps: int) -> 'GifWorkshop':
"""Set output FPS."""
self._fps = fps
self._operations.append(f"set_fps({fps})")
return self
def add_text(
self,
text: str,
position: str = 'bottom',
fontsize: int = 24,
color: str = 'white',
font: str = 'Arial',
stroke_color: Optional[str] = 'black',
stroke_width: int = 1,
start_time: Optional[float] = None,
end_time: Optional[float] = None,
bg_color: Optional[str] = None
) -> 'GifWorkshop':
"""
Add text overlay.
Args:
text: Text to display
position: Position on screen
fontsize: Font size
color: Text color
font: Font name
stroke_color: Outline color (None for no outline)
stroke_width: Outline width
start_time: When to start showing (None = start)
end_time: When to stop showing (None = end)
bg_color: Background color (None for transparent)
"""
self._text_overlays.append({
'text': text,
'position': position,
'fontsize': fontsize,
'color': color,
'font': font,
'stroke_color': stroke_color,
'stroke_width': stroke_width,
'start_time': start_time or 0,
'end_time': end_time or self._clip.duration,
'bg_color': bg_color,
})
self._operations.append(f"add_text('{text[:20]}...')")
return self
def add_caption_bar(
self,
text: str,
position: str = 'bottom',
background: str = 'black',
padding: int = 10,
fontsize: int = 20,
color: str = 'white'
) -> 'GifWorkshop':
"""Add caption with background bar."""
return self.add_text(
text,
position=position,
fontsize=fontsize,
color=color,
bg_color=background
)
def _apply_text_overlays(self) -> None:
"""Apply all text overlays to clip."""
if not self._text_overlays:
return
for overlay in self._text_overlays:
try:
txt_clip = TextClip(
overlay['text'],
fontsize=overlay['fontsize'],
color=overlay['color'],
font=overlay['font'],
stroke_color=overlay['stroke_color'],
stroke_width=overlay['stroke_width']
)
# Position
pos = overlay['position']
if pos in TEXT_POSITIONS:
txt_clip = txt_clip.set_position(TEXT_POSITIONS[pos])
else:
txt_clip = txt_clip.set_position(('center', 'bottom'))
# Timing
txt_clip = txt_clip.set_start(overlay['start_time'])
txt_clip = txt_clip.set_duration(overlay['end_time'] - overlay['start_time'])
self._clip = CompositeVideoClip([self._clip, txt_clip])
except Exception as e:
# Text rendering can fail, continue without
print(f"Warning: Could not add text overlay: {e}")
def filter(self, filter_name: str) -> 'GifWorkshop':
"""
Apply color filter.
Available: grayscale, sepia, invert, mirror
"""
if filter_name == 'grayscale':
self._clip = self._clip.fx(vfx.blackwhite)
elif filter_name == 'invert':
self._clip = self._clip.fx(vfx.invert_colors)
elif filter_name == 'mirror':
self._clip = self._clip.fx(vfx.mirror_x)
elif filter_name == 'sepia':
# Apply sepia via color matrix
def sepia_filter(get_frame, t):
frame = get_frame(t)
sepia_matrix = np.array([
[0.393, 0.769, 0.189],
[0.349, 0.686, 0.168],
[0.272, 0.534, 0.131]
])
sepia_frame = frame @ sepia_matrix.T
return np.clip(sepia_frame, 0, 255).astype(np.uint8)
self._clip = self._clip.fl(sepia_filter)
self._operations.append(f"filter({filter_name})")
return self
def adjust(
self,
brightness: float = 0,
contrast: float = 0
) -> 'GifWorkshop':
"""
Adjust brightness and contrast.
Args:
brightness: -1.0 to 1.0
contrast: -1.0 to 1.0
"""
if brightness:
factor = 1 + brightness
self._clip = self._clip.fx(vfx.colorx, factor)
if contrast:
self._clip = self._clip.fx(vfx.lum_contrast, contrast=int(contrast * 100))
self._operations.append(f"adjust(b={brightness}, c={contrast})")
return self
def fade_in(self, duration: float = 0.5) -> 'GifWorkshop':
"""Add fade in effect."""
self._clip = self._clip.fx(vfx.fadein, duration)
self._operations.append(f"fade_in({duration}s)")
return self
def fade_out(self, duration: float = 0.5) -> 'GifWorkshop':
"""Add fade out effect."""
self._clip = self._clip.fx(vfx.fadeout, duration)
self._operations.append(f"fade_out({duration}s)")
return self
def blur(self, intensity: float = 2) -> 'GifWorkshop':
"""Apply blur effect (requires OpenCV)."""
try:
import cv2
def blur_filter(get_frame, t):
frame = get_frame(t)
return cv2.GaussianBlur(frame, (0, 0), intensity)
self._clip = self._clip.fl(blur_filter)
self._operations.append(f"blur({intensity})")
except ImportError:
print("Warning: OpenCV required for blur effect")
return self
def optimize(
self,
max_size_kb: Optional[int] = None,
quality: str = 'medium',
colors: Optional[int] = None,
lossy: Optional[int] = None
) -> 'GifWorkshop':
"""
Configure optimization settings.
Args:
max_size_kb: Target maximum file size
quality: Preset quality ('low', 'medium', 'high')
colors: Color palette size (2-256)
lossy: Lossy compression level (0-100)
"""
self._max_size_kb = max_size_kb
if quality == 'low':
self._fps = min(self._fps, 10)
self._colors = 64
elif quality == 'high':
self._colors = 256
else: # medium
self._colors = 128
if colors:
self._colors = min(256, max(2, colors))
self._operations.append(f"optimize(max={max_size_kb}kb)")
return self
def preset(self, preset_name: str) -> 'GifWorkshop':
"""Apply a named preset."""
if preset_name not in PRESETS:
raise GifError(f"Unknown preset: {preset_name}")
settings = PRESETS[preset_name]
if 'width' in settings:
self.resize(width=settings['width'])
if 'fps' in settings:
self._fps = settings['fps']
if 'colors' in settings:
self._colors = settings['colors']
if 'max_size_kb' in settings:
self._max_size_kb = settings['max_size_kb']
if 'max_duration' in settings and self._clip.duration > settings['max_duration']:
self.clip(end=settings['max_duration'])
self._operations.append(f"preset({preset_name})")
return self
def apply_filter(self, filter_func: Callable) -> 'GifWorkshop':
"""
Apply custom filter function to each frame.
Args:
filter_func: Function that takes PIL Image and returns PIL Image
"""
def frame_filter(get_frame, t):
frame = get_frame(t)
pil_img = Image.fromarray(frame)
filtered = filter_func(pil_img)
return np.array(filtered)
self._clip = self._clip.fl(frame_filter)
self._operations.append("apply_filter(custom)")
return self
def get_frame_at(self, time: float) -> Image.Image:
"""Get frame at specific time as PIL Image."""
frame = self._clip.get_frame(time)
return Image.fromarray(frame)
def get_best_frame(self) -> Image.Image:
"""Get the best frame (middle of clip) as thumbnail."""
middle = self._clip.duration / 2
return self.get_frame_at(middle)
def export_frames(
self,
output_dir: Union[str, Path],
format: str = 'png',
every_n: int = 1
) -> List[str]:
"""
Export frames as images.
Args:
output_dir: Output directory
format: Image format ('png', 'jpg')
every_n: Export every Nth frame
"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
exported = []
fps = self._clip.fps
total_frames = int(self._clip.duration * fps)
for i in range(0, total_frames, every_n):
t = i / fps
frame = self.get_frame_at(t)
filepath = output_dir / f"frame_{i:05d}.{format}"
frame.save(filepath)
exported.append(str(filepath))
return exported
def to_gif(
self,
output_path: Union[str, Path],
optimize: bool = True,
colors: Optional[int] = None,
loop: Optional[int] = None
) -> str:
"""
Export as GIF.
Args:
output_path: Output file path
optimize: Enable optimization
colors: Color palette size
loop: Loop count (0 = infinite)
Returns:
Path to created GIF
"""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Apply text overlays
self._apply_text_overlays()
# Determine colors
n_colors = colors or self._colors
loop_count = loop if loop is not None else self._loop
# Generate GIF
self._clip.write_gif(
str(output_path),
fps=self._fps,
colors=n_colors,
opt='nq', # Neural quantization
loop=loop_count
)
# Check file size and re-optimize if needed
if self._max_size_kb:
current_size = output_path.stat().st_size / 1024
if current_size > self._max_size_kb:
self._optimize_file_size(output_path)
return str(output_path)
def _optimize_file_size(self, filepath: Path) -> None:
"""Try to reduce file size to meet target."""
if not self._max_size_kb:
return
current_size = filepath.stat().st_size / 1024
# Try reducing colors
colors = self._colors
fps = self._fps
while current_size > self._max_size_kb and (colors > 16 or fps > 5):
if colors > 16:
colors = max(16, colors // 2)
else:
fps = max(5, fps - 2)
self._clip.write_gif(
str(filepath),
fps=fps,
colors=colors,
opt='nq',
loop=self._loop
)
current_size = filepath.stat().st_size / 1024
def to_video(
self,
output_path: Union[str, Path],
codec: str = 'libx264'
) -> str:
"""Export as video file (for comparison)."""
output_path = Path(output_path)
self._clip.write_videofile(
str(output_path),
codec=codec,
fps=self._fps
)
return str(output_path)
def __repr__(self) -> str:
return f"GifWorkshop('{self._source_path.name}', {self._clip.duration:.1f}s, {self._clip.size})"
# ==================== CLI ====================
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='Video to GIF Workshop')
parser.add_argument('input', help='Input video file')
parser.add_argument('-o', '--output', required=True, help='Output GIF path')
parser.add_argument('--start', type=float, help='Start time (seconds)')
parser.add_argument('--end', type=float, help='End time (seconds)')
parser.add_argument('--width', type=int, help='Output width')
parser.add_argument('--fps', type=int, default=15, help='Frames per second')
parser.add_argument('--speed', type=float, default=1.0, help='Speed multiplier')
parser.add_argument('--max-size', type=int, help='Max file size in KB')
parser.add_argument('--text', help='Text overlay')
parser.add_argument('--text-position', default='bottom', help='Text position')
parser.add_argument('--preset', help='Apply preset')
parser.add_argument('--reverse', action='store_true', help='Reverse clip')
parser.add_argument('--boomerang', action='store_true', help='Boomerang effect')
args = parser.parse_args()
# Create workshop
workshop = GifWorkshop(args.input, fps=args.fps)
# Apply options
if args.start is not None or args.end is not None:
workshop.clip(start=args.start, end=args.end)
if args.width:
workshop.resize(width=args.width)
if args.speed != 1.0:
workshop.speed(args.speed)
if args.reverse:
workshop.reverse()
if args.boomerang:
workshop.boomerang()
if args.text:
workshop.add_text(args.text, position=args.text_position)
if args.preset:
workshop.preset(args.preset)
if args.max_size:
workshop.optimize(max_size_kb=args.max_size)
# Export
output = workshop.to_gif(args.output)
print(f"Created: {output}")
# Show file size
size_kb = Path(output).stat().st_size / 1024
print(f"Size: {size_kb:.1f} KB")
#!/usr/bin/env python3
"""
Podcast Splitter - Split audio files by detecting silence.
Features:
- Silence detection with configurable threshold
- Auto-split into segments/chapters
- Silence removal and shortening
- Batch processing
"""
import argparse
from pathlib import Path
from typing import List, Tuple, Dict, Optional
class PodcastSplitter:
"""Split audio files based on silence detection."""
def __init__(
self,
filepath: str,
silence_thresh: float = -40,
min_silence_len: int = 1000,
keep_silence: int = 300
):
"""
Initialize splitter with audio file.
Args:
filepath: Path to input audio file
silence_thresh: Silence threshold in dBFS (default -40)
min_silence_len: Minimum silence length to detect in ms (default 1000)
keep_silence: Silence to keep at segment edges in ms (default 300)
"""
self.filepath = Path(filepath)
if not self.filepath.exists():
raise FileNotFoundError(f"Audio file not found: {filepath}")
self.silence_thresh = silence_thresh
self.min_silence_len = min_silence_len
self.keep_silence = keep_silence
self._audio = None
self._segments: List[Dict] = []
self._silences: List[Tuple[int, int]] = []
self._load_audio()
def _load_audio(self):
"""Load audio file using pydub."""
try:
from pydub import AudioSegment
self._audio = AudioSegment.from_file(str(self.filepath))
except ImportError:
raise ImportError("pydub is required. Install with: pip install pydub")
except Exception as e:
raise ValueError(f"Failed to load audio file: {e}")
def detect_silence(self) -> List[Tuple[int, int]]:
"""
Detect silence regions in the audio.
Returns:
List of (start_ms, end_ms) tuples for each silence region
"""
from pydub.silence import detect_silence
self._silences = detect_silence(
self._audio,
min_silence_len=self.min_silence_len,
silence_thresh=self.silence_thresh
)
return self._silences
def print_silence_report(self):
"""Print a report of detected silences."""
if not self._silences:
self.detect_silence()
total_duration = len(self._audio)
total_silence = sum(end - start for start, end in self._silences)
content_duration = total_duration - total_silence
print(f"\nSilence Detection Report: {self.filepath.name}")
print("=" * 50)
print(f"Total Duration: {self._format_time(total_duration)}")
print(f"Content Duration: {self._format_time(content_duration)}")
print(f"Total Silence: {self._format_time(total_silence)}")
print(f"Silence Regions: {len(self._silences)}")
print(f"\nSettings:")
print(f" Threshold: {self.silence_thresh} dBFS")
print(f" Min Length: {self.min_silence_len} ms")
if self._silences:
print(f"\nDetected Silences:")
for i, (start, end) in enumerate(self._silences[:20]): # Show first 20
duration = end - start
print(f" {i+1:3d}. {self._format_time(start)} - {self._format_time(end)} ({duration}ms)")
if len(self._silences) > 20:
print(f" ... and {len(self._silences) - 20} more")
@staticmethod
def _format_time(ms: int) -> str:
"""Format milliseconds as MM:SS."""
seconds = ms / 1000
minutes = int(seconds // 60)
secs = seconds % 60
return f"{minutes:02d}:{secs:05.2f}"
def split_by_silence(
self,
min_silence_len: Optional[int] = None,
max_segments: Optional[int] = None
) -> List[Dict]:
"""
Split audio at silence regions.
Args:
min_silence_len: Override minimum silence length for splitting
max_segments: Maximum number of segments to create
Returns:
List of segment info dicts with start, end, duration
"""
from pydub.silence import split_on_silence
# Use overrides if provided
silence_len = min_silence_len or self.min_silence_len
# Perform split
chunks = split_on_silence(
self._audio,
min_silence_len=silence_len,
silence_thresh=self.silence_thresh,
keep_silence=self.keep_silence
)
# Build segment info
self._segments = []
position = 0
for i, chunk in enumerate(chunks):
if max_segments and i >= max_segments:
# Merge remaining chunks
remaining = chunks[i:]
merged = sum(remaining[1:], remaining[0])
self._segments.append({
'index': i,
'start': position,
'end': position + len(merged),
'duration': len(merged),
'audio': merged
})
break
self._segments.append({
'index': i,
'start': position,
'end': position + len(chunk),
'duration': len(chunk),
'audio': chunk
})
position += len(chunk)
return [
{'index': s['index'], 'start': s['start'], 'end': s['end'], 'duration': s['duration']}
for s in self._segments
]
def remove_silence(self, min_length: int = 2000) -> 'PodcastSplitter':
"""
Remove silences longer than specified length.
Args:
min_length: Remove silences longer than this (ms)
Returns:
Self for chaining
"""
from pydub import AudioSegment
# Detect silences
if not self._silences:
self.detect_silence()
# Build new audio without long silences
result = AudioSegment.empty()
last_end = 0
for start, end in self._silences:
duration = end - start
# Add content before this silence
result += self._audio[last_end:start]
# If silence is short enough, keep it
if duration < min_length:
result += self._audio[start:end]
last_end = end
# Add remaining content
result += self._audio[last_end:]
self._audio = result
self._silences = [] # Reset for re-detection
return self
def shorten_silence(self, max_length: int = 500) -> 'PodcastSplitter':
"""
Shorten all silences to maximum length.
Args:
max_length: Maximum silence length (ms)
Returns:
Self for chaining
"""
from pydub import AudioSegment
# Detect silences
if not self._silences:
self.detect_silence()
# Build new audio with shortened silences
result = AudioSegment.empty()
last_end = 0
for start, end in self._silences:
duration = end - start
# Add content before this silence
result += self._audio[last_end:start]
# Add shortened silence
silence_to_keep = min(duration, max_length)
result += self._audio[start:start + silence_to_keep]
last_end = end
# Add remaining content
result += self._audio[last_end:]
self._audio = result
self._silences = [] # Reset for re-detection
return self
def strip_silence(self, threshold: Optional[float] = None) -> 'PodcastSplitter':
"""
Remove leading and trailing silence only.
Args:
threshold: Silence threshold (optional, uses instance default)
Returns:
Self for chaining
"""
from pydub.silence import detect_leading_silence
thresh = threshold or self.silence_thresh
# Strip leading silence
start_trim = detect_leading_silence(self._audio, silence_threshold=thresh)
# Strip trailing silence
reversed_audio = self._audio.reverse()
end_trim = detect_leading_silence(reversed_audio, silence_threshold=thresh)
if start_trim + end_trim < len(self._audio):
self._audio = self._audio[start_trim:len(self._audio) - end_trim]
return self
def export_segments(
self,
output_dir: str,
prefix: str = "segment",
format: str = "mp3",
bitrate: int = 192
) -> List[str]:
"""
Export all segments to files.
Args:
output_dir: Output directory path
prefix: Filename prefix
format: Output format
bitrate: Bitrate for lossy formats
Returns:
List of exported file paths
"""
if not self._segments:
self.split_by_silence()
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
exported = []
for segment in self._segments:
filename = f"{prefix}_{segment['index'] + 1:02d}.{format}"
filepath = output_path / filename
params = {}
if format in ('mp3', 'ogg', 'm4a'):
params['bitrate'] = f"{bitrate}k"
segment['audio'].export(str(filepath), format=format, **params)
exported.append(str(filepath))
duration_str = self._format_time(segment['duration'])
print(f"Exported: {filename} ({duration_str})")
return exported
def export_segment(
self,
index: int,
output: str,
format: Optional[str] = None,
bitrate: int = 192
) -> str:
"""
Export a specific segment.
Args:
index: Segment index (0-based)
output: Output file path
format: Output format (from extension if not specified)
bitrate: Bitrate for lossy formats
Returns:
Path to exported file
"""
if not self._segments:
self.split_by_silence()
if index < 0 or index >= len(self._segments):
raise IndexError(f"Segment index {index} out of range (0-{len(self._segments)-1})")
output_path = Path(output)
output_path.parent.mkdir(parents=True, exist_ok=True)
if format is None:
format = output_path.suffix.lstrip('.').lower()
params = {}
if format in ('mp3', 'ogg', 'm4a'):
params['bitrate'] = f"{bitrate}k"
self._segments[index]['audio'].export(str(output_path), format=format, **params)
return str(output_path)
def save(
self,
output: str,
format: Optional[str] = None,
bitrate: int = 192
) -> str:
"""
Save the (possibly modified) audio.
Args:
output: Output file path
format: Output format
bitrate: Bitrate for lossy formats
Returns:
Path to saved file
"""
output_path = Path(output)
output_path.parent.mkdir(parents=True, exist_ok=True)
if format is None:
format = output_path.suffix.lstrip('.').lower()
params = {}
if format in ('mp3', 'ogg', 'm4a'):
params['bitrate'] = f"{bitrate}k"
self._audio.export(str(output_path), format=format, **params)
return str(output_path)
def get_segment_count(self) -> int:
"""Get number of segments after splitting."""
if not self._segments:
self.split_by_silence()
return len(self._segments)
def get_duration(self) -> int:
"""Get current audio duration in milliseconds."""
return len(self._audio)
def main():
"""CLI entry point."""
parser = argparse.ArgumentParser(
description='Split audio files by detecting silence',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --input episode.mp3 --output-dir ./chapters/
%(prog)s --input episode.mp3 --detect-only
%(prog)s --input raw.mp3 --output clean.mp3 --remove-silence 2000
%(prog)s --input episode.mp3 --output-dir ./chapters/ --threshold -35 --min-silence 2000
"""
)
parser.add_argument('--input', '-i', required=True, help='Input audio file')
parser.add_argument('--output', '-o', help='Output file (for silence removal)')
parser.add_argument('--output-dir', '-d', help='Output directory for segments')
parser.add_argument('--detect-only', action='store_true', help='Only detect/report silences')
parser.add_argument('--threshold', '-t', type=float, default=-40, help='Silence threshold (dBFS)')
parser.add_argument('--min-silence', '-m', type=int, default=1000, help='Minimum silence length (ms)')
parser.add_argument('--keep-silence', '-k', type=int, default=300, help='Silence to keep at edges (ms)')
parser.add_argument('--max-segments', type=int, help='Maximum segments to create')
parser.add_argument('--remove-silence', type=int, help='Remove silences longer than (ms)')
parser.add_argument('--shorten-silence', type=int, help='Cap silence length at (ms)')
parser.add_argument('--strip', action='store_true', help='Strip leading/trailing silence')
parser.add_argument('--prefix', default='segment', help='Output filename prefix')
parser.add_argument('--format', '-f', default='mp3', help='Output format')
parser.add_argument('--bitrate', '-b', type=int, default=192, help='Output bitrate (kbps)')
args = parser.parse_args()
# Initialize splitter
splitter = PodcastSplitter(
args.input,
silence_thresh=args.threshold,
min_silence_len=args.min_silence,
keep_silence=args.keep_silence
)
# Detect-only mode
if args.detect_only:
splitter.print_silence_report()
return
# Silence modification modes
if args.remove_silence:
print(f"Removing silences > {args.remove_silence}ms...")
splitter.remove_silence(args.remove_silence)
if args.shorten_silence:
print(f"Shortening silences to max {args.shorten_silence}ms...")
splitter.shorten_silence(args.shorten_silence)
if args.strip:
print("Stripping leading/trailing silence...")
splitter.strip_silence()
# Output handling
if args.output:
# Save modified audio
output = splitter.save(args.output, format=args.format, bitrate=args.bitrate)
duration = splitter._format_time(splitter.get_duration())
print(f"Saved: {output} ({duration})")
elif args.output_dir:
# Split and export segments
segments = splitter.split_by_silence(max_segments=args.max_segments)
print(f"Found {len(segments)} segments")
exported = splitter.export_segments(
args.output_dir,
prefix=args.prefix,
format=args.format,
bitrate=args.bitrate
)
print(f"\nExported {len(exported)} segments to {args.output_dir}")
else:
# Just show detection results
splitter.print_silence_report()
segments = splitter.split_by_silence()
print(f"\nWould create {len(segments)} segments")
if __name__ == "__main__":
main()
Pillow>=10.0.0
ffmpeg-python>=0.2.0
imageio>=2.31.0
librosa>=0.10.0
matplotlib>=3.7.0
moviepy>=1.0.3
numpy>=1.24.0
pandas>=2.0.0
pillow>=10.0.0
pydub>=0.25.0
scipy>=1.10.0
soundfile>=0.12.0
#!/usr/bin/env python3
"""
Sound Effects Generator - Generate programmatic audio.
Features:
- Tone generation (sine, square, sawtooth, triangle)
- Noise generation (white, pink, brown)
- DTMF tones
- Beep sequences
- Fade effects
"""
import argparse
from pathlib import Path
from typing import List, Union, Optional
import numpy as np
class SoundEffectsGenerator:
"""Generate programmatic audio effects."""
# DTMF frequencies (row, column)
DTMF_FREQ = {
'1': (697, 1209), '2': (697, 1336), '3': (697, 1477), 'A': (697, 1633),
'4': (770, 1209), '5': (770, 1336), '6': (770, 1477), 'B': (770, 1633),
'7': (852, 1209), '8': (852, 1336), '9': (852, 1477), 'C': (852, 1633),
'*': (941, 1209), '0': (941, 1336), '#': (941, 1477), 'D': (941, 1633),
}
def __init__(self, sample_rate: int = 44100):
"""
Initialize generator.
Args:
sample_rate: Sample rate in Hz (default 44100)
"""
self.sample_rate = sample_rate
self._audio = np.array([], dtype=np.float32)
def _generate_waveform(
self,
frequency: float,
duration_ms: int,
waveform: str = "sine"
) -> np.ndarray:
"""Generate a waveform at given frequency."""
num_samples = int(self.sample_rate * duration_ms / 1000)
t = np.linspace(0, duration_ms / 1000, num_samples, dtype=np.float32)
if waveform == "sine":
signal = np.sin(2 * np.pi * frequency * t)
elif waveform == "square":
signal = np.sign(np.sin(2 * np.pi * frequency * t))
elif waveform == "sawtooth":
signal = 2 * (t * frequency - np.floor(0.5 + t * frequency))
elif waveform == "triangle":
signal = 2 * np.abs(2 * (t * frequency - np.floor(0.5 + t * frequency))) - 1
else:
raise ValueError(f"Unknown waveform: {waveform}")
return signal.astype(np.float32)
def tone(
self,
frequency: float,
duration: int = 1000,
waveform: str = "sine",
volume: float = 0.8
) -> 'SoundEffectsGenerator':
"""
Generate a tone.
Args:
frequency: Frequency in Hz
duration: Duration in milliseconds
waveform: Waveform type (sine, square, sawtooth, triangle)
volume: Volume level (0.0 to 1.0)
Returns:
Self for chaining
"""
signal = self._generate_waveform(frequency, duration, waveform)
signal *= volume
self._audio = np.concatenate([self._audio, signal])
return self
def noise(
self,
noise_type: str = "white",
duration: int = 1000,
volume: float = 0.5
) -> 'SoundEffectsGenerator':
"""
Generate noise.
Args:
noise_type: Type of noise (white, pink, brown)
duration: Duration in milliseconds
volume: Volume level (0.0 to 1.0)
Returns:
Self for chaining
"""
num_samples = int(self.sample_rate * duration / 1000)
if noise_type == "white":
signal = np.random.uniform(-1, 1, num_samples)
elif noise_type == "pink":
# Generate pink noise using Voss-McCartney algorithm
white = np.random.randn(num_samples)
# Apply 1/f filter
from scipy import signal as scipy_signal
b, a = scipy_signal.butter(1, 0.01)
pink = scipy_signal.lfilter(b, a, white)
# Normalize
signal = pink / np.max(np.abs(pink))
elif noise_type == "brown":
# Brown noise is integrated white noise
white = np.random.randn(num_samples)
brown = np.cumsum(white)
# Normalize
signal = brown / np.max(np.abs(brown))
else:
raise ValueError(f"Unknown noise type: {noise_type}")
signal = signal.astype(np.float32) * volume
self._audio = np.concatenate([self._audio, signal])
return self
def silence(self, duration: int = 1000) -> 'SoundEffectsGenerator':
"""
Generate silence.
Args:
duration: Duration in milliseconds
Returns:
Self for chaining
"""
num_samples = int(self.sample_rate * duration / 1000)
signal = np.zeros(num_samples, dtype=np.float32)
self._audio = np.concatenate([self._audio, signal])
return self
def dtmf(
self,
digit: str,
duration: int = 200,
volume: float = 0.7
) -> 'SoundEffectsGenerator':
"""
Generate a DTMF tone for a single digit.
Args:
digit: DTMF digit (0-9, *, #, A-D)
duration: Duration in milliseconds
volume: Volume level
Returns:
Self for chaining
"""
digit = digit.upper()
if digit not in self.DTMF_FREQ:
raise ValueError(f"Invalid DTMF digit: {digit}")
f1, f2 = self.DTMF_FREQ[digit]
num_samples = int(self.sample_rate * duration / 1000)
t = np.linspace(0, duration / 1000, num_samples, dtype=np.float32)
# DTMF is sum of two frequencies
signal = np.sin(2 * np.pi * f1 * t) + np.sin(2 * np.pi * f2 * t)
signal = signal / 2 * volume # Normalize
self._audio = np.concatenate([self._audio, signal])
return self
def dtmf_sequence(
self,
digits: str,
tone_duration: int = 150,
gap: int = 50
) -> 'SoundEffectsGenerator':
"""
Generate DTMF sequence for multiple digits.
Args:
digits: String of DTMF digits
tone_duration: Duration of each tone (ms)
gap: Gap between tones (ms)
Returns:
Self for chaining
"""
for i, digit in enumerate(digits):
if digit == ' ':
self.silence(gap)
continue
self.dtmf(digit, tone_duration)
if i < len(digits) - 1:
self.silence(gap)
return self
def beep(
self,
frequency: float = 800,
duration: int = 200,
waveform: str = "sine",
volume: float = 0.8
) -> 'SoundEffectsGenerator':
"""
Generate a single beep.
Args:
frequency: Beep frequency in Hz
duration: Duration in milliseconds
waveform: Waveform type
volume: Volume level
Returns:
Self for chaining
"""
return self.tone(frequency, duration, waveform, volume)
def beep_sequence(
self,
frequencies: List[float],
durations: Union[int, List[int]] = 200,
gap: int = 100,
waveform: str = "sine",
volume: float = 0.8
) -> 'SoundEffectsGenerator':
"""
Generate a sequence of beeps.
Args:
frequencies: List of frequencies for each beep
durations: Duration (or list of durations) in ms
gap: Gap between beeps (ms)
waveform: Waveform type
volume: Volume level
Returns:
Self for chaining
"""
if isinstance(durations, int):
durations = [durations] * len(frequencies)
if len(durations) != len(frequencies):
raise ValueError("Durations list must match frequencies list")
for i, (freq, dur) in enumerate(zip(frequencies, durations)):
self.tone(freq, dur, waveform, volume)
if i < len(frequencies) - 1:
self.silence(gap)
return self
def fade_in(self, duration_ms: int) -> 'SoundEffectsGenerator':
"""
Apply fade in effect.
Args:
duration_ms: Fade duration in milliseconds
Returns:
Self for chaining
"""
if len(self._audio) == 0:
return self
num_samples = int(self.sample_rate * duration_ms / 1000)
num_samples = min(num_samples, len(self._audio))
fade = np.linspace(0, 1, num_samples, dtype=np.float32)
self._audio[:num_samples] *= fade
return self
def fade_out(self, duration_ms: int) -> 'SoundEffectsGenerator':
"""
Apply fade out effect.
Args:
duration_ms: Fade duration in milliseconds
Returns:
Self for chaining
"""
if len(self._audio) == 0:
return self
num_samples = int(self.sample_rate * duration_ms / 1000)
num_samples = min(num_samples, len(self._audio))
fade = np.linspace(1, 0, num_samples, dtype=np.float32)
self._audio[-num_samples:] *= fade
return self
def volume(self, level: float) -> 'SoundEffectsGenerator':
"""
Adjust volume.
Args:
level: Volume level (0.0 to 1.0+)
Returns:
Self for chaining
"""
self._audio *= level
return self
def get_duration_ms(self) -> int:
"""Get current audio duration in milliseconds."""
return int(len(self._audio) / self.sample_rate * 1000)
def clear(self) -> 'SoundEffectsGenerator':
"""Clear all audio data."""
self._audio = np.array([], dtype=np.float32)
return self
def save(
self,
output: str,
bitrate: int = 192
) -> str:
"""
Save audio to file.
Args:
output: Output file path
bitrate: Bitrate for MP3 (kbps)
Returns:
Path to saved file
"""
output_path = Path(output)
output_path.parent.mkdir(parents=True, exist_ok=True)
format_ext = output_path.suffix.lower()
if format_ext == '.wav':
# Save directly using soundfile
import soundfile as sf
sf.write(str(output_path), self._audio, self.sample_rate)
elif format_ext == '.mp3':
# Use pydub for MP3
try:
from pydub import AudioSegment
import io
# Convert to 16-bit PCM
audio_int = (self._audio * 32767).astype(np.int16)
# Create AudioSegment
audio_segment = AudioSegment(
audio_int.tobytes(),
frame_rate=self.sample_rate,
sample_width=2, # 16-bit
channels=1
)
audio_segment.export(str(output_path), format='mp3', bitrate=f'{bitrate}k')
except ImportError:
raise ImportError("pydub is required for MP3 export. Install with: pip install pydub")
else:
# Try soundfile for other formats
import soundfile as sf
sf.write(str(output_path), self._audio, self.sample_rate)
return str(output_path)
def main():
"""CLI entry point."""
parser = argparse.ArgumentParser(
description='Generate programmatic audio effects',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --tone 440 --duration 1000 --output tone.wav
%(prog)s --noise white --duration 2000 --output noise.wav
%(prog)s --dtmf "5551234" --output phone.wav
%(prog)s --beeps "800,800,800" --duration 100 --gap 100 --output alert.wav
"""
)
parser.add_argument('--tone', '-t', type=float, help='Generate tone at frequency (Hz)')
parser.add_argument('--noise', '-n', choices=['white', 'pink', 'brown'], help='Generate noise')
parser.add_argument('--dtmf', help='Generate DTMF tones for digits')
parser.add_argument('--beeps', help='Comma-separated frequencies for beep sequence')
parser.add_argument('--duration', '-d', type=int, default=1000, help='Duration in ms')
parser.add_argument('--gap', '-g', type=int, default=100, help='Gap between sounds (ms)')
parser.add_argument('--waveform', '-w', default='sine',
choices=['sine', 'square', 'sawtooth', 'triangle'],
help='Waveform type for tones')
parser.add_argument('--volume', '-v', type=float, default=0.8, help='Volume (0.0-1.0)')
parser.add_argument('--sample-rate', type=int, default=44100, help='Sample rate (Hz)')
parser.add_argument('--fade-in', type=int, help='Fade in duration (ms)')
parser.add_argument('--fade-out', type=int, help='Fade out duration (ms)')
parser.add_argument('--output', '-o', required=True, help='Output file')
args = parser.parse_args()
sfx = SoundEffectsGenerator(sample_rate=args.sample_rate)
# Generate audio based on options
if args.tone:
sfx.tone(args.tone, args.duration, args.waveform, args.volume)
print(f"Generated {args.waveform} tone at {args.tone}Hz")
elif args.noise:
sfx.noise(args.noise, args.duration, args.volume)
print(f"Generated {args.noise} noise")
elif args.dtmf:
sfx.dtmf_sequence(args.dtmf, tone_duration=args.duration, gap=args.gap)
print(f"Generated DTMF for: {args.dtmf}")
elif args.beeps:
frequencies = [float(f) for f in args.beeps.split(',')]
sfx.beep_sequence(frequencies, args.duration, args.gap, args.waveform, args.volume)
print(f"Generated beep sequence: {frequencies}")
else:
parser.error("Must specify --tone, --noise, --dtmf, or --beeps")
# Apply effects
if args.fade_in:
sfx.fade_in(args.fade_in)
if args.fade_out:
sfx.fade_out(args.fade_out)
# Save
output = sfx.save(args.output)
print(f"Saved: {output} ({sfx.get_duration_ms()}ms)")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Timelapse Creator - Create timelapse videos from images.
"""
import argparse
from pathlib import Path
from typing import List
from moviepy.editor import ImageSequenceClip
from PIL import Image
class TimelapseCreator:
"""Create timelapse videos."""
def __init__(self):
"""Initialize creator."""
self.images = []
def load_images_from_dir(self, directory: str, pattern: str = '*') -> 'TimelapseCreator':
"""Load images from directory."""
path = Path(directory)
self.images = sorted([
str(p) for p in path.glob(pattern)
if p.suffix.lower() in ['.jpg', '.jpeg', '.png', '.bmp']
])
return self
def create_timelapse(self, output: str, fps: int = 30) -> str:
"""Create timelapse video."""
if not self.images:
raise ValueError("No images loaded")
print(f"Creating timelapse from {len(self.images)} images at {fps} FPS...")
clip = ImageSequenceClip(self.images, fps=fps)
clip.write_videofile(output, logger=None)
return output
def main():
parser = argparse.ArgumentParser(description="Timelapse Creator")
parser.add_argument("--input", "-i", required=True, help="Input directory with images")
parser.add_argument("--output", "-o", required=True, help="Output video file")
parser.add_argument("--fps", type=int, default=30, help="Frames per second")
parser.add_argument("--pattern", default="*", help="File pattern (e.g., '*.jpg')")
args = parser.parse_args()
creator = TimelapseCreator()
creator.load_images_from_dir(args.input, pattern=args.pattern)
print(f"Found {len(creator.images)} images")
creator.create_timelapse(args.output, fps=args.fps)
print(f"\nTimelapse created: {args.output}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Video Clipper - Cut and trim video segments.
"""
import argparse
from pathlib import Path
from typing import List
from moviepy.editor import VideoFileClip
class VideoClipper:
"""Clip and trim videos."""
def __init__(self):
"""Initialize clipper."""
self.clip = None
def load(self, filepath: str) -> 'VideoClipper':
"""Load video."""
self.clip = VideoFileClip(filepath)
return self
def extract_segment(self, start: float, end: float, output: str) -> str:
"""Extract segment between start and end times."""
subclip = self.clip.subclip(start, end)
subclip.write_videofile(output, logger=None)
subclip.close()
return output
def split_by_duration(self, chunk_duration: float, output_dir: str) -> List[str]:
"""Split video into chunks of specified duration."""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
duration = self.clip.duration
chunks = []
i = 0
current_time = 0
while current_time < duration:
end_time = min(current_time + chunk_duration, duration)
output_file = output_path / f"chunk_{i:03d}.mp4"
subclip = self.clip.subclip(current_time, end_time)
subclip.write_videofile(str(output_file), logger=None)
subclip.close()
chunks.append(str(output_file))
current_time = end_time
i += 1
return chunks
def trim(self, start: float = None, end: float = None, output: str = None) -> str:
"""Trim start and/or end of video."""
start = start or 0
end = end or self.clip.duration
trimmed = self.clip.subclip(start, end)
trimmed.write_videofile(output, logger=None)
trimmed.close()
return output
def close(self):
"""Close video clip."""
if self.clip:
self.clip.close()
def parse_time(time_str: str) -> float:
"""Parse time string (HH:MM:SS or seconds)."""
if ':' in time_str:
parts = time_str.split(':')
if len(parts) == 3:
h, m, s = map(float, parts)
return h * 3600 + m * 60 + s
elif len(parts) == 2:
m, s = map(float, parts)
return m * 60 + s
return float(time_str)
def main():
parser = argparse.ArgumentParser(description="Video Clipper")
parser.add_argument("--input", "-i", required=True, help="Input video")
parser.add_argument("--output", "-o", required=True, help="Output file/directory")
parser.add_argument("--start", help="Start time (HH:MM:SS or seconds)")
parser.add_argument("--end", help="End time (HH:MM:SS or seconds)")
parser.add_argument("--split", type=float, help="Split into chunks (duration in seconds)")
args = parser.parse_args()
clipper = VideoClipper()
clipper.load(args.input)
if args.split:
chunks = clipper.split_by_duration(args.split, args.output)
print(f"Split into {len(chunks)} chunks in {args.output}/")
elif args.start or args.end:
start = parse_time(args.start) if args.start else 0
end = parse_time(args.end) if args.end else clipper.clip.duration
clipper.extract_segment(start, end, args.output)
print(f"Clip extracted ({args.start or '0'} - {args.end or 'end'}) → {args.output}")
clipper.close()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Video Thumbnail Extractor - Extract frames from videos.
"""
import argparse
from pathlib import Path
from typing import List, Tuple
from moviepy.editor import VideoFileClip
from PIL import Image
import numpy as np
class VideoThumbnailExtractor:
"""Extract thumbnails from videos."""
def __init__(self):
"""Initialize extractor."""
self.clip = None
def load(self, filepath: str) -> 'VideoThumbnailExtractor':
"""Load video."""
self.clip = VideoFileClip(filepath)
return self
def extract_at_time(self, time: float, output: str) -> str:
"""Extract frame at specific time (seconds)."""
frame = self.clip.get_frame(time)
img = Image.fromarray(frame)
img.save(output)
return output
def extract_interval(self, interval: float, output_dir: str) -> List[str]:
"""Extract frames at regular intervals."""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
duration = self.clip.duration
times = np.arange(0, duration, interval)
frames = []
for i, t in enumerate(times):
frame = self.clip.get_frame(t)
output_file = output_path / f"frame_{i:04d}.jpg"
img = Image.fromarray(frame)
img.save(str(output_file))
frames.append(str(output_file))
return frames
def create_grid(self, grid: Tuple[int, int], output: str) -> str:
"""Create thumbnail grid preview."""
cols, rows = grid
n_thumbs = cols * rows
duration = self.clip.duration
times = np.linspace(0, duration * 0.95, n_thumbs)
# Get first frame to determine size
first_frame = self.clip.get_frame(times[0])
h, w = first_frame.shape[:2]
# Calculate thumbnail size
thumb_w = w // 4
thumb_h = h // 4
# Create grid image
grid_w = cols * thumb_w
grid_h = rows * thumb_h
grid_img = Image.new('RGB', (grid_w, grid_h))
for i, t in enumerate(times):
row = i // cols
col = i % cols
frame = self.clip.get_frame(t)
thumb = Image.fromarray(frame).resize((thumb_w, thumb_h))
x = col * thumb_w
y = row * thumb_h
grid_img.paste(thumb, (x, y))
grid_img.save(output)
return output
def close(self):
"""Close video clip."""
if self.clip:
self.clip.close()
def parse_time(time_str: str) -> float:
"""Parse time string (HH:MM:SS or seconds)."""
if ':' in time_str:
parts = time_str.split(':')
if len(parts) == 3:
h, m, s = map(float, parts)
return h * 3600 + m * 60 + s
elif len(parts) == 2:
m, s = map(float, parts)
return m * 60 + s
return float(time_str)
def main():
parser = argparse.ArgumentParser(description="Video Thumbnail Extractor")
parser.add_argument("--input", "-i", required=True, help="Input video")
parser.add_argument("--output", "-o", required=True, help="Output file/directory")
group = parser.add_mutually_exclusive_group()
group.add_argument("--time", help="Extract at time (HH:MM:SS or seconds)")
group.add_argument("--interval", type=float, help="Interval in seconds")
group.add_argument("--grid", help="Grid size (e.g., 4x4)")
args = parser.parse_args()
extractor = VideoThumbnailExtractor()
extractor.load(args.input)
if args.time:
time_sec = parse_time(args.time)
extractor.extract_at_time(time_sec, args.output)
print(f"Frame extracted at {args.time} → {args.output}")
elif args.interval:
frames = extractor.extract_interval(args.interval, args.output)
print(f"Extracted {len(frames)} frames to {args.output}/")
elif args.grid:
cols, rows = map(int, args.grid.split('x'))
extractor.create_grid((cols, rows), args.output)
print(f"Grid preview ({args.grid}) → {args.output}")
extractor.close()
if __name__ == "__main__":
main()