Skip to content

Commit 65099d3

Browse files
committed
feat(effects): add audio visualizer with multiple visualization styles
- Add audio_visualizer function with 6 visualization modes (spectrum bars, waveform, CQT, spectrogram, vector scope, histogram) - Support multiple resolutions including Full HD, HD, 4K, vertical, and square formats - Implement 6 color schemes (neon, fire, ocean, matrix, rainbow, monochrome) for customizable visual output - Add background options (black, dark gray, gradient, transparent) - Build dynamic FFmpeg filter chains based on selected visualization parameters - Validate audio stream presence and file duration before processing - Generate MP4 output with proper file handling and overwrite protection - Fix README.md markdown syntax for NOTE block formatting - Enhance user experience with questionary-based interactive menu for visualization customization
1 parent a849ee5 commit 65099d3

File tree

3 files changed

+262
-2
lines changed

3 files changed

+262
-2
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ A powerful and user-friendly Python CLI tool for converting, manipulating, and i
176176

177177
### Prerequisite: Install FFmpeg
178178

179-
> [!NOTE]
179+
> [NOTE]
180180
> `peg_this` uses a library called `ffmpeg-python` which acts as a controller for the main FFmpeg program. It does not include FFmpeg itself. Therefore, you must have FFmpeg installed on your system and available in your terminal's PATH.
181181
182182
For **macOS** users, the easiest way to install it is with [Homebrew](https://brew.sh/):

src/peg_this/features/effects.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1539,3 +1539,258 @@ def auto_blur_faces(file_path):
15391539
if os.path.exists(temp_video):
15401540
os.remove(temp_video)
15411541
press_continue()
1542+
1543+
1544+
def audio_visualizer(file_path):
1545+
"""Generate a visualization video from audio or video file."""
1546+
import subprocess
1547+
1548+
if not validate_input_file(file_path):
1549+
press_continue()
1550+
return
1551+
1552+
# Check if file has audio
1553+
if not has_audio_stream(file_path):
1554+
console.print("[bold red]Error: No audio stream found in the file.[/bold red]")
1555+
press_continue()
1556+
return
1557+
1558+
duration = get_video_duration(file_path)
1559+
if duration <= 0:
1560+
console.print("[bold red]Error: Could not determine file duration.[/bold red]")
1561+
press_continue()
1562+
return
1563+
1564+
console.print(f"[dim]Duration: {format_duration(duration)}[/dim]")
1565+
console.print("[bold cyan]Audio Visualizer - Create stunning visualizations[/bold cyan]")
1566+
1567+
# Visualization style
1568+
style = questionary.select(
1569+
"Visualization style:",
1570+
choices=[
1571+
"Spectrum Bars (Classic equalizer bars)",
1572+
"Waveform (Oscilloscope wave)",
1573+
"Showcase CQT (Musical frequency analyzer - Pro look)",
1574+
"Spectrogram (Frequency waterfall)",
1575+
"Vector Scope (Circular stereo display)",
1576+
"Audio Histogram (Frequency histogram)",
1577+
"← Back"
1578+
]
1579+
).ask()
1580+
1581+
if style == "← Back" or style is None:
1582+
return
1583+
1584+
# Resolution
1585+
resolution = questionary.select(
1586+
"Output resolution:",
1587+
choices=[
1588+
"1920x1080 (Full HD)",
1589+
"1280x720 (HD)",
1590+
"3840x2160 (4K)",
1591+
"1080x1920 (Vertical/Phone)",
1592+
"1080x1080 (Square/Instagram)"
1593+
]
1594+
).ask()
1595+
1596+
if resolution is None:
1597+
return
1598+
1599+
res_parts = resolution.split(" ")[0].split("x")
1600+
width, height = int(res_parts[0]), int(res_parts[1])
1601+
1602+
# Color scheme
1603+
color_scheme = questionary.select(
1604+
"Color scheme:",
1605+
choices=[
1606+
"Neon (Cyan/Magenta)",
1607+
"Fire (Red/Orange/Yellow)",
1608+
"Ocean (Blue/Cyan)",
1609+
"Matrix (Green)",
1610+
"Rainbow (Full spectrum)",
1611+
"Monochrome (White)"
1612+
]
1613+
).ask()
1614+
1615+
if color_scheme is None:
1616+
return
1617+
1618+
# Background
1619+
background = questionary.select(
1620+
"Background:",
1621+
choices=[
1622+
"Black",
1623+
"Dark Gray",
1624+
"Gradient (Dark)",
1625+
"Transparent (if supported)"
1626+
]
1627+
).ask()
1628+
1629+
if background is None:
1630+
return
1631+
1632+
# Build FFmpeg filter based on style
1633+
filter_complex = None
1634+
bg_color = "0x000000" if "Black" in background else "0x1a1a1a" if "Gray" in background else "0x000000"
1635+
1636+
if "Spectrum Bars" in style:
1637+
# showspectrum with bars mode
1638+
if "Neon" in color_scheme:
1639+
color = "channel"
1640+
elif "Fire" in color_scheme:
1641+
color = "fire"
1642+
elif "Ocean" in color_scheme:
1643+
color = "cool"
1644+
elif "Matrix" in color_scheme:
1645+
color = "green"
1646+
elif "Rainbow" in color_scheme:
1647+
color = "rainbow"
1648+
else:
1649+
color = "white"
1650+
1651+
filter_complex = (
1652+
f"[0:a]showspectrum=s={width}x{height}:mode=combined:color={color}:"
1653+
f"scale=cbrt:fscale=log:saturation=3:slide=scroll[v]"
1654+
)
1655+
1656+
elif "Waveform" in style:
1657+
# showwaves
1658+
if "Neon" in color_scheme:
1659+
colors = "0x00ffff|0xff00ff"
1660+
elif "Fire" in color_scheme:
1661+
colors = "0xff0000|0xff8800|0xffff00"
1662+
elif "Ocean" in color_scheme:
1663+
colors = "0x0066ff|0x00ccff"
1664+
elif "Matrix" in color_scheme:
1665+
colors = "0x00ff00"
1666+
elif "Rainbow" in color_scheme:
1667+
colors = "0xff0000|0xff8800|0xffff00|0x00ff00|0x0088ff|0x8800ff"
1668+
else:
1669+
colors = "0xffffff"
1670+
1671+
filter_complex = (
1672+
f"[0:a]showwaves=s={width}x{height}:mode=cline:rate=30:colors={colors}:"
1673+
f"scale=cbrt[v]"
1674+
)
1675+
1676+
elif "CQT" in style:
1677+
# showcqt - constant Q transform, looks professional
1678+
if "Neon" in color_scheme:
1679+
bar_g = 2
1680+
sono_g = 4
1681+
elif "Fire" in color_scheme:
1682+
bar_g = 3
1683+
sono_g = 3
1684+
elif "Ocean" in color_scheme:
1685+
bar_g = 1
1686+
sono_g = 4
1687+
elif "Matrix" in color_scheme:
1688+
bar_g = 2
1689+
sono_g = 3
1690+
else:
1691+
bar_g = 2
1692+
sono_g = 4
1693+
1694+
filter_complex = (
1695+
f"[0:a]showcqt=s={width}x{height}:bar_g={bar_g}:sono_g={sono_g}:"
1696+
f"bar_v=10:sono_v=bar_v:tc=0.33:attack=0.033:tlength=1[v]"
1697+
)
1698+
1699+
elif "Spectrogram" in style:
1700+
# showspectrum in separate mode
1701+
if "Neon" in color_scheme:
1702+
color = "channel"
1703+
elif "Fire" in color_scheme:
1704+
color = "fire"
1705+
elif "Ocean" in color_scheme:
1706+
color = "cool"
1707+
elif "Matrix" in color_scheme:
1708+
color = "green"
1709+
elif "Rainbow" in color_scheme:
1710+
color = "rainbow"
1711+
else:
1712+
color = "intensity"
1713+
1714+
filter_complex = (
1715+
f"[0:a]showspectrum=s={width}x{height}:mode=separate:color={color}:"
1716+
f"scale=log:fscale=log:slide=fullframe:saturation=2[v]"
1717+
)
1718+
1719+
elif "Vector" in style:
1720+
# avectorscope
1721+
if "Neon" in color_scheme:
1722+
mode = "lissajous"
1723+
draw = "line"
1724+
elif "Fire" in color_scheme:
1725+
mode = "polar"
1726+
draw = "dot"
1727+
elif "Matrix" in color_scheme:
1728+
mode = "lissajous_xy"
1729+
draw = "line"
1730+
else:
1731+
mode = "lissajous"
1732+
draw = "line"
1733+
1734+
filter_complex = (
1735+
f"[0:a]avectorscope=s={width}x{height}:mode={mode}:draw={draw}:"
1736+
f"scale=cbrt:rate=30[v]"
1737+
)
1738+
1739+
elif "Histogram" in style:
1740+
# ahistogram
1741+
if "Neon" in color_scheme:
1742+
dmode = "separate"
1743+
else:
1744+
dmode = "single"
1745+
1746+
filter_complex = (
1747+
f"[0:a]ahistogram=s={width}x{height}:dmode={dmode}:rate=30:"
1748+
f"scale=log:slide=scroll[v]"
1749+
)
1750+
1751+
if not filter_complex:
1752+
console.print("[bold red]Error: Unknown visualization style.[/bold red]")
1753+
press_continue()
1754+
return
1755+
1756+
suffix = "visualizer"
1757+
output_file = f"{Path(file_path).stem}_{suffix}.mp4"
1758+
action_result, final_output = check_output_file(output_file, "Output file")
1759+
1760+
if action_result == 'cancel':
1761+
console.print("[yellow]Operation cancelled.[/yellow]")
1762+
press_continue()
1763+
return
1764+
1765+
# Build FFmpeg command
1766+
cmd = ['ffmpeg']
1767+
if action_result == 'overwrite':
1768+
cmd.append('-y')
1769+
1770+
cmd.extend(['-i', file_path])
1771+
cmd.extend(['-filter_complex', filter_complex])
1772+
cmd.extend(['-map', '[v]', '-map', '0:a'])
1773+
cmd.extend(['-c:v', 'libx264', '-preset', 'medium', '-crf', '18'])
1774+
cmd.extend(['-c:a', 'aac', '-b:a', '192k'])
1775+
cmd.extend(['-pix_fmt', 'yuv420p'])
1776+
cmd.extend(['-r', '30'])
1777+
cmd.append(final_output)
1778+
1779+
console.print(f"[bold cyan]Generating {style.split(' (')[0]} visualization...[/bold cyan]")
1780+
console.print("[dim]This may take a while for long audio files.[/dim]")
1781+
1782+
result = subprocess.run(cmd, capture_output=True, text=True)
1783+
1784+
if result.returncode == 0:
1785+
console.print(f"[bold green]Successfully created {final_output}[/bold green]")
1786+
else:
1787+
console.print("[bold red]Failed to create visualization.[/bold red]")
1788+
if result.stderr:
1789+
error_lines = result.stderr.strip().split('\n')
1790+
error_found = [l for l in error_lines if 'Error' in l or 'error' in l]
1791+
if error_found:
1792+
console.print(f"[dim]{error_found[-1]}[/dim]")
1793+
else:
1794+
console.print(f"[dim]{error_lines[-3:]}[/dim]")
1795+
1796+
press_continue()

src/peg_this/peg_this.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from peg_this.features.compress import compress_video, change_resolution
1414
from peg_this.features.convert import convert_file, convert_image, resize_image, rotate_image, flip_image
1515
from peg_this.features.crop import crop_video, crop_image
16-
from peg_this.features.effects import add_watermark, merge_audio_video, video_fade, loop_video, color_correction, denoise_video, picture_in_picture, blur_region, auto_blur_faces
16+
from peg_this.features.effects import add_watermark, merge_audio_video, video_fade, loop_video, color_correction, denoise_video, picture_in_picture, blur_region, auto_blur_faces, audio_visualizer
1717
from peg_this.features.frames import extract_frames, split_video
1818
from peg_this.features.inspect import inspect_file
1919
from peg_this.features.join import join_videos
@@ -310,6 +310,7 @@ def main_menu():
310310
"🖼️ Image Tools",
311311
questionary.Separator("─────── Other ───────"),
312312
"🎬 Create Slideshow",
313+
"🎼 Audio Visualizer",
313314
"📝 Metadata Editor",
314315
"📦 Batch Convert",
315316
"🔍 Inspect File",
@@ -330,6 +331,10 @@ def main_menu():
330331
file_path = select_media_file()
331332
if file_path:
332333
metadata_editor(file_path)
334+
elif "Visualizer" in choice:
335+
file_path = select_media_file()
336+
if file_path:
337+
audio_visualizer(file_path)
333338
elif "Edit" in choice:
334339
video_edit_menu()
335340
elif "Audio" in choice:

0 commit comments

Comments
 (0)