Language & Programming / Rap · Destination 6 of 6

Future Projects

What comes next: the scripts that turn new recordings into visuals.

The production tools

From l2mcgrat/music_work ↗ at commit 5285918. Scripts for turning recordings into visuals, shown as written; this site never runs them.

Upcoming releases go through these: convert a recording to WAV, then export a waveform and spectrum video for it. Lyric folders not yet placed on a Rap page also appear here.

music_visualizer.pyDraws a .wav file as a waveform and/or spectrum, live or exported to PNG or MP4.
"""WAV music visualizer.

Visualizes a .wav file as a scrolling waveform and/or spectrum.

Dependencies:
- numpy
- matplotlib
- sounddevice (optional, for playback)
"""

from __future__ import annotations

import argparse
import math
import subprocess
import shutil
import tempfile
import time
import wave
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple

import numpy as np


@dataclass(frozen=True)
class WavData:
	sample_rate: int
	samples: np.ndarray  # shape: (n_samples, channels), float32 in [-1, 1]

	@property
	def n_samples(self) -> int:
		return int(self.samples.shape[0])

	@property
	def channels(self) -> int:
		return int(self.samples.shape[1])

	@property
	def duration_s(self) -> float:
		return float(self.n_samples) / float(self.sample_rate)


def _int24_bytes_to_int32(raw: bytes, n_channels: int) -> np.ndarray:
	"""Convert little-endian 24-bit PCM bytes to int32 array.

	Returns array shaped (n_frames, n_channels).
	"""

	if len(raw) % (3 * n_channels) != 0:
		raise ValueError("Invalid 24-bit PCM byte length")

	b = np.frombuffer(raw, dtype=np.uint8)
	frames = b.reshape(-1, n_channels, 3)
	values = (
		frames[:, :, 0].astype(np.int32)
		| (frames[:, :, 1].astype(np.int32) << 8)
		| (frames[:, :, 2].astype(np.int32) << 16)
	)
	sign_bit = 1 << 23
	values = (values ^ sign_bit) - sign_bit
	return values


def read_wav(path: Path) -> WavData:
	"""Read a PCM .wav and return float32 samples in [-1, 1]."""

	path = Path(path)
	if not path.exists():
		raise FileNotFoundError(str(path))
	if path.suffix.lower() != ".wav":
		raise ValueError(f"Expected a .wav file, got: {path.name}")

	# Sniff header for clearer errors when a non-WAV file is renamed to .wav.
	with path.open("rb") as f:
		header = f.read(16)
	if len(header) < 12:
		raise ValueError("File is too small to be a valid WAV")

	is_riff_wave = header[0:4] == b"RIFF" and header[8:12] == b"WAVE"
	if not is_riff_wave:
		# Common non-WAV signatures
		if header[0:3] == b"ID3" or (len(header) >= 2 and header[0] == 0xFF and (header[1] & 0xE0) == 0xE0):
			raise ValueError(
				"This file is not a RIFF/WAVE .wav (it looks like an MP3 with an ID3 tag). "
				"Rename it to .mp3 (optional) and convert to WAV, e.g. with ffmpeg:\n"
				"  ffmpeg -i \"input.mp3\" -ac 1 -ar 44100 \"output.wav\"\n"
				"(ffmpeg can usually detect the format even if the extension is wrong.)"
			)
		if header[0:4] == b"fLaC":
			raise ValueError(
				"This file looks like FLAC, not WAV. Convert to WAV first (e.g. ffmpeg -i input.flac output.wav)."
			)
		if header[4:8] == b"ftyp":
			raise ValueError(
				"This file looks like an MP4/M4A container, not WAV. Convert to WAV first (e.g. ffmpeg -i input.m4a output.wav)."
			)
		if header[0:4] == b"RF64":
			raise ValueError(
				"This file is RF64 (extended WAV). The built-in WAV reader can't open it; convert to standard WAV (RIFF) first."
			)
		raise ValueError(
			"File does not start with a RIFF/WAVE header; it may not actually be a WAV file. "
			"Convert it to a standard PCM WAV (RIFF) and try again."
		)

	with wave.open(str(path), "rb") as wf:
		n_channels = wf.getnchannels()
		sampwidth = wf.getsampwidth()  # bytes per sample
		sample_rate = wf.getframerate()
		n_frames = wf.getnframes()

		if wf.getcomptype() != "NONE":
			raise ValueError(f"Unsupported WAV compression type: {wf.getcomptype()}")
		if sampwidth not in (1, 2, 3, 4):
			raise ValueError(f"Unsupported sample width: {sampwidth} bytes")

		raw = wf.readframes(n_frames)

	if sampwidth == 1:
		# 8-bit PCM is unsigned
		arr = np.frombuffer(raw, dtype=np.uint8).astype(np.int16)
		arr = arr - 128
		max_int = 127
		arr = arr.reshape(-1, n_channels)
		samples = (arr.astype(np.float32) / float(max_int)).clip(-1.0, 1.0)
		return WavData(sample_rate=sample_rate, samples=samples)

	if sampwidth == 2:
		arr = np.frombuffer(raw, dtype="<i2").reshape(-1, n_channels)
		samples = (arr.astype(np.float32) / 32768.0).clip(-1.0, 1.0)
		return WavData(sample_rate=sample_rate, samples=samples)

	if sampwidth == 3:
		arr = _int24_bytes_to_int32(raw, n_channels)
		samples = (arr.astype(np.float32) / float(1 << 23)).clip(-1.0, 1.0)
		return WavData(sample_rate=sample_rate, samples=samples)

	# sampwidth == 4
	arr = np.frombuffer(raw, dtype="<i4").reshape(-1, n_channels)
	samples = (arr.astype(np.float32) / 2147483648.0).clip(-1.0, 1.0)
	return WavData(sample_rate=sample_rate, samples=samples)


def to_mono(samples: np.ndarray) -> np.ndarray:
	if samples.ndim != 2:
		raise ValueError("Expected samples shaped (n_samples, channels)")
	if samples.shape[1] == 1:
		return samples[:, 0]
	return samples.mean(axis=1)


def slice_audio(
	mono: np.ndarray,
	sample_rate: int,
	start_s: float = 0.0,
	duration_s: Optional[float] = None,
) -> Tuple[np.ndarray, int]:
	start_idx = max(0, int(round(start_s * sample_rate)))
	end_idx = mono.shape[0] if duration_s is None else min(mono.shape[0], start_idx + int(round(duration_s * sample_rate)))
	if start_idx >= end_idx:
		raise ValueError("Selected segment is empty (check --start/--duration)")
	return mono[start_idx:end_idx], start_idx


def compute_spectrum(
	segment: np.ndarray,
	sample_rate: int,
	window: np.ndarray,
	db: bool,
	eps: float = 1e-10,
) -> Tuple[np.ndarray, np.ndarray]:
	if segment.shape[0] != window.shape[0]:
		raise ValueError("segment/window length mismatch")

	x = segment.astype(np.float32) * window
	fft = np.fft.rfft(x)
	mag = np.abs(fft).astype(np.float32)
	freqs = np.fft.rfftfreq(x.shape[0], d=1.0 / float(sample_rate)).astype(np.float32)
	if db:
		mag = 20.0 * np.log10(mag + eps)
	return freqs, mag


def visualize(
	wav: WavData,
	*,
	source_path: Optional[Path] = None,
	mono: bool,
	mode: str,
	play: bool,
	start_s: float,
	duration_s: Optional[float],
	waveform_ms: float,
	waveform_view: str,
	max_waveform_points: int,
	window_ms: float,
	hop_ms: float,
	fps: float,
	db: bool,
	title: Optional[str],
	export_path: Optional[Path] = None,
	export_seconds: Optional[float] = None,
	no_gui: bool = False,
	overwrite_export: bool = False,
	export_audio: bool = True,
) -> None:
	import matplotlib.pyplot as plt
	from matplotlib.animation import FuncAnimation

	audio = to_mono(wav.samples) if mono else wav.samples[:, 0]
	audio_seg, seg_start_idx = slice_audio(audio, wav.sample_rate, start_s=start_s, duration_s=duration_s)

	waveform_view = (waveform_view or "scroll").lower()
	if waveform_view not in ("scroll", "full"):
		raise ValueError("waveform_view must be 'scroll' or 'full'")

	waveform_len = max(16, int(round(wav.sample_rate * waveform_ms / 1000.0)))
	window_len = max(32, int(round(wav.sample_rate * window_ms / 1000.0)))
	hop_len = max(1, int(round(wav.sample_rate * hop_ms / 1000.0)))

	window = np.hanning(window_len).astype(np.float32)
	total_len = audio_seg.shape[0]

	nrows = 2 if mode == "both" else 1
	fig, axes = plt.subplots(nrows=nrows, ncols=1, figsize=(10, 6), constrained_layout=True)
	if nrows == 1:
		axes = [axes]

	waveform_ax = None
	spectrum_ax = None
	if mode in ("both", "waveform"):
		waveform_ax = axes[0]
	if mode in ("both", "spectrum"):
		spectrum_ax = axes[-1]

	shown_name = source_path.name if source_path is not None else ""
	start_title = title or f"{wav.sample_rate} Hz | {shown_name}".strip(" |")
	fig.suptitle(start_title if start_title else "WAV Visualizer")

	# Waveform setup
	waveform_line = None
	waveform_playhead = None
	if waveform_ax is not None:
		waveform_ax.set_ylim(-1.05, 1.05)
		waveform_ax.set_ylabel("Amplitude")
		waveform_ax.grid(True, alpha=0.2)

		if waveform_view == "full":
			seg_seconds = float(total_len) / float(wav.sample_rate)
			max_points = max(200, int(max_waveform_points))
			if total_len <= max_points:
				t_bins = (np.arange(total_len, dtype=np.float32) / float(wav.sample_rate)).astype(np.float32)
				mins = audio_seg.astype(np.float32, copy=False)
				maxs = audio_seg.astype(np.float32, copy=False)
			else:
				step = int(math.ceil(float(total_len) / float(max_points)))
				n_bins = int(math.ceil(float(total_len) / float(step)))
				pad_len = (n_bins * step) - total_len
				padded = np.pad(audio_seg.astype(np.float32, copy=False), (0, pad_len), mode="constant")
				reshaped = padded.reshape(n_bins, step)
				mins = reshaped.min(axis=1)
				maxs = reshaped.max(axis=1)
				t_bins = (np.arange(n_bins, dtype=np.float32) * float(step) / float(wav.sample_rate)).astype(np.float32)

			waveform_ax.fill_between(t_bins, mins, maxs, color="C0", alpha=0.7, linewidth=0)
			waveform_ax.axhline(0.0, color="black", lw=0.8, alpha=0.2)
			waveform_playhead = waveform_ax.axvline(0.0, color="C3", lw=1.4)
			waveform_ax.set_xlim(0.0, seg_seconds)
			waveform_ax.set_xlabel("Time (s) (segment)")
		else:
			t = np.arange(waveform_len, dtype=np.float32) / float(wav.sample_rate)
			(waveform_line,) = waveform_ax.plot(t, np.zeros_like(t), lw=1)
			waveform_ax.set_xlim(0.0, float(waveform_len) / float(wav.sample_rate))
			waveform_ax.set_xlabel("Time (s)")

	# Spectrum setup
	spectrum_line = None
	spectrum_freqs = None
	if spectrum_ax is not None:
		freqs = np.fft.rfftfreq(window_len, d=1.0 / float(wav.sample_rate)).astype(np.float32)
		spectrum_freqs = freqs
		(spectrum_line,) = spectrum_ax.plot(freqs, np.zeros_like(freqs), lw=1)
		spectrum_ax.set_xlim(0.0, float(wav.sample_rate) / 2.0)
		if db:
			spectrum_ax.set_ylabel("Magnitude (dB)")
			spectrum_ax.set_ylim(-120.0, 20.0)
		else:
			spectrum_ax.set_ylabel("Magnitude")
			spectrum_ax.set_ylim(0.0, 50.0)
		spectrum_ax.set_xlabel("Frequency (Hz)")
		spectrum_ax.grid(True, alpha=0.2)

	# Playback (optional). Disabled during export.
	playback_started_at = None
	if play and export_path is None:
		try:
			import sounddevice as sd

			sd.play(audio_seg.astype(np.float32), wav.sample_rate)
			playback_started_at = time.perf_counter()
		except Exception as e:  # noqa: BLE001
			print(f"Playback disabled: {e}")
			play = False

	# Animation state
	t0 = time.perf_counter()

	exporting = export_path is not None

	def _current_index(frame_i: int) -> int:
		if play and playback_started_at is not None:
			elapsed = time.perf_counter() - playback_started_at
			return int(elapsed * wav.sample_rate)
		if exporting:
			# For exported video, align frames to time so audio mux stays in sync.
			return int((float(frame_i) / float(max(1e-6, fps))) * wav.sample_rate)
		# Non-playback: advance by hop on each frame
		return frame_i * hop_len

	def _get_windowed_segment(idx: int) -> np.ndarray:
		if idx < 0:
			idx = 0
		seg = audio_seg[idx : idx + window_len]
		if seg.shape[0] < window_len:
			pad = np.zeros(window_len - seg.shape[0], dtype=np.float32)
			seg = np.concatenate([seg.astype(np.float32, copy=False), pad])
		return seg.astype(np.float32, copy=False)

	def _get_waveform_segment(idx: int) -> np.ndarray:
		start = max(0, idx - waveform_len)
		seg = audio_seg[start:idx]
		if seg.shape[0] < waveform_len:
			pad = np.zeros(waveform_len - seg.shape[0], dtype=np.float32)
			seg = np.concatenate([pad, seg.astype(np.float32, copy=False)])
		return seg.astype(np.float32, copy=False)

	def update(frame_i: int):
		idx = _current_index(frame_i)
		if idx >= total_len:
			if exporting:
				idx = max(0, total_len - 1)
			else:
				return ()

		artists = []
		if waveform_view == "scroll" and waveform_line is not None:
			wseg = _get_waveform_segment(idx)
			waveform_line.set_ydata(wseg)
			artists.append(waveform_line)
		elif waveform_view == "full" and waveform_playhead is not None:
			seg_t = float(idx) / float(wav.sample_rate)
			waveform_playhead.set_xdata([seg_t, seg_t])
			artists.append(waveform_playhead)

		if spectrum_line is not None and spectrum_freqs is not None:
			sseg = _get_windowed_segment(idx)
			_, mag = compute_spectrum(sseg, wav.sample_rate, window, db=db)
			spectrum_line.set_ydata(mag)
			if not db:
				# Auto-scale gently for linear magnitudes
				ymax = float(np.percentile(mag, 99.0))
				ymax = max(1e-6, ymax)
				spectrum_ax.set_ylim(0.0, ymax * 1.2)
			artists.append(spectrum_line)

		# Update title with position
		now_s = (seg_start_idx + idx) / float(wav.sample_rate)
		fig.suptitle(f"{start_title or 'WAV Visualizer'}  |  t={now_s:0.2f}s")

		# Stop automatically if not playing and we reached end
		if not play and idx + window_len >= total_len:
			# Let the last frame draw, then close.
			if (time.perf_counter() - t0) > 0.5:
				plt.close(fig)

		return tuple(artists)

	interval_ms = 1000.0 / float(max(1.0, fps))

	if export_path is not None:
		export_path = Path(export_path)
		export_path.parent.mkdir(parents=True, exist_ok=True)

		if overwrite_export and export_path.exists():
			export_path.unlink()

		ext = export_path.suffix.lower()
		if ext in (".png", ".jpg", ".jpeg", ".webp"):
			# Save a single snapshot at the start position.
			update(0)
			fig.savefig(str(export_path), dpi=160)
			plt.close(fig)
			return

		if ext in (".mp4", ".gif"):
			# Save a short animation.
			seg_seconds = float(total_len) / float(wav.sample_rate)
			if export_seconds is None or float(export_seconds) <= 0:
				seconds = seg_seconds
			else:
				seconds = min(seg_seconds, float(export_seconds))
			seconds = max(0.25, float(seconds))
			frames = max(1, int(math.ceil(seconds * float(fps))))
			anim = FuncAnimation(
				fig,
				update,
				interval=interval_ms,
				blit=False,
				cache_frame_data=False,
				save_count=frames,
			)
			if ext == ".mp4":
				from matplotlib.animation import FFMpegWriter

				writer = FFMpegWriter(fps=float(fps), bitrate=1800)

				# Write silent video first, then mux audio (Matplotlib doesn't reliably embed audio).
				tmp_video_path = None
				tmp_audio_path = None
				try:
					with tempfile.NamedTemporaryFile(
						mode="wb",
						suffix=".mp4",
						prefix="viz_video_",
						dir=str(export_path.parent),
						delete=False,
					) as tmp:
						tmp_video_path = Path(tmp.name)

					anim.save(str(tmp_video_path), writer=writer, dpi=160)

					if export_audio:
						ffmpeg = shutil.which("ffmpeg")
						if not ffmpeg:
							raise RuntimeError("ffmpeg not found on PATH; cannot mux audio into MP4")

						audio_len = int(round(seconds * wav.sample_rate))
						audio_out = audio_seg[:audio_len].astype(np.float32, copy=False)
						pcm = np.clip(audio_out, -1.0, 1.0)
						pcm16 = (pcm * 32767.0).astype("<i2")

						with tempfile.NamedTemporaryFile(
							mode="wb",
							suffix=".wav",
							prefix="viz_audio_",
							dir=str(export_path.parent),
							delete=False,
						) as tmp:
							tmp_audio_path = Path(tmp.name)

						with wave.open(str(tmp_audio_path), "wb") as wf:
							wf.setnchannels(1)
							wf.setsampwidth(2)
							wf.setframerate(wav.sample_rate)
							wf.writeframes(pcm16.tobytes())

						mux = subprocess.run(
							[
								ffmpeg,
								"-y",
								"-i",
								str(tmp_video_path),
								"-i",
								str(tmp_audio_path),
								"-c:v",
								"copy",
								"-c:a",
								"aac",
								"-b:a",
								"192k",
								"-shortest",
								str(export_path),
							],
							capture_output=True,
							text=True,
							check=False,
						)
						if mux.returncode != 0:
							raise RuntimeError(f"ffmpeg mux failed: {mux.stderr.strip()}")
					else:
						tmp_video_path.replace(export_path)
				finally:
					if tmp_audio_path is not None and tmp_audio_path.exists():
						try:
							tmp_audio_path.unlink()
						except OSError:
							pass
					if tmp_video_path is not None and tmp_video_path.exists() and tmp_video_path != export_path:
						try:
							tmp_video_path.unlink()
						except OSError:
							pass
			else:
				# GIF requires pillow
				from matplotlib.animation import PillowWriter

				writer = PillowWriter(fps=float(fps))
				anim.save(str(export_path), writer=writer, dpi=120)

			plt.close(fig)
			return

		raise ValueError(f"Unsupported export extension: {ext} (use .png or .mp4 or .gif)")

	anim = FuncAnimation(fig, update, interval=interval_ms, blit=False, cache_frame_data=False)

	if not no_gui:
		plt.show()
	else:
		plt.close(fig)

	if play:
		try:
			import sounddevice as sd

			sd.stop()
		except Exception:
			pass


def build_parser() -> argparse.ArgumentParser:
	p = argparse.ArgumentParser(description="Visualize a .wav file (waveform and/or spectrum).")
	p.add_argument("wav", nargs="?", help="Path to a .wav file")
	p.add_argument("--mono", action="store_true", help="Mix down to mono")
	p.add_argument("--mode", choices=["both", "waveform", "spectrum"], default="both")
	p.add_argument("--play", action="store_true", help="Play audio while visualizing (requires sounddevice)")
	p.add_argument("--start", type=float, default=0.0, help="Start time in seconds")
	p.add_argument("--duration", type=float, default=None, help="Duration to visualize (seconds)")
	p.add_argument("--waveform-ms", type=float, default=250.0, help="Waveform window length (ms)")
	p.add_argument("--waveform-view", choices=["scroll", "full"], default="scroll", help="Waveform view: scrolling window or full-track overview")
	p.add_argument("--max-waveform-points", type=int, default=20000, help="Max points for full waveform overview (higher = more detail, slower)")
	p.add_argument("--window-ms", type=float, default=50.0, help="Spectrum FFT window length (ms)")
	p.add_argument("--hop-ms", type=float, default=20.0, help="Hop size between FFTs (ms)")
	p.add_argument("--fps", type=float, default=30.0, help="Animation frames per second")
	p.add_argument("--db", action="store_true", help="Show spectrum in dB")
	p.add_argument("--title", type=str, default=None, help="Custom title")
	p.add_argument("--export", type=str, default=None, help="Export to a file (.png/.mp4/.gif) instead of only showing")
	p.add_argument("--out-dir", type=str, default=None, help="Output directory (used with --export-auto)")
	p.add_argument(
		"--export-auto",
		action="store_true",
		help="Auto-name the export file from tags/filename. If --out-dir is omitted, writes to a 'visualizer_exports' folder next to the input wav.",
	)
	p.add_argument("--export-format", choices=["png", "mp4", "gif"], default="png", help="Export format for --export-auto")
	p.add_argument("--export-seconds", type=float, default=10.0, help="Seconds to export for mp4/gif")
	p.add_argument("--export-full", action="store_true", help="Export the full selected audio segment (ignores --export-seconds)")
	p.add_argument("--no-gui", action="store_true", help="Do not open a window (useful with --export)")
	p.add_argument("--overwrite-export", action="store_true", help="Overwrite export file if it already exists")
	p.add_argument(
		"--no-export-audio",
		dest="export_audio",
		action="store_false",
		default=True,
		help="Do not mux audio into exported MP4",
	)
	p.add_argument(
		"--preview-export",
		action="store_true",
		help="When exporting, show a live preview window first, then export after you close it",
	)
	return p


def _ffprobe_tags(path: Path) -> dict:
	"""Return tags via ffprobe when available (best-effort)."""

	try:
		result = subprocess.run(
			[
				"ffprobe",
				"-v",
				"error",
				"-print_format",
				"json",
				"-show_format",
				"-show_entries",
				"format_tags=title,artist,album",
				str(path),
			],
			capture_output=True,
			text=True,
			check=False,
		)
		if result.returncode != 0 or not result.stdout.strip():
			return {}
		import json

		obj = json.loads(result.stdout)
		tags = obj.get("format", {}).get("tags", {}) or {}
		# normalize keys to lower
		return {str(k).lower(): str(v) for k, v in tags.items() if v is not None}
	except Exception:
		return {}


def _suggest_song_stem(audio_path: Path) -> str:
	# Prefer embedded title tag if present.
	tags = _ffprobe_tags(audio_path)
	title = tags.get("title")
	if title:
		stem = title.strip()
	else:
		stem = audio_path.stem
		if stem.lower().endswith("_converted"):
			stem = stem[: -len("_converted")]
	# basic sanitization
	bad = '<>:"/\\|?*'
	for ch in bad:
		stem = stem.replace(ch, "_")
	stem = " ".join(stem.split()).strip(" .")
	return stem or audio_path.stem


def main(argv: Optional[list[str]] = None) -> int:
	args = build_parser().parse_args(argv)
	if not args.wav:
		print("Provide a .wav file path. Example: python music_visualizer.py path\\to\\song.wav")
		return 2

	wav_path = Path(args.wav)
	wav = read_wav(wav_path)

	export_path: Optional[Path] = None
	if args.export and args.export_auto:
		raise ValueError("Use either --export or --export-auto (not both)")
	if args.export:
		export_path = Path(args.export)
	elif args.export_auto:
		out_dir = Path(args.out_dir) if args.out_dir else (wav_path.parent / "visualizer_exports")
		out_dir.mkdir(parents=True, exist_ok=True)
		stem = _suggest_song_stem(wav_path)
		export_path = out_dir / f"{stem}.{args.export_format}"

	# If user requested preview+export, do preview first, then export.
	if export_path is not None and args.preview_export and not args.no_gui:
		visualize(
			wav,
			source_path=wav_path,
			mono=args.mono,
			mode=args.mode,
			play=bool(args.play),
			start_s=float(args.start),
			duration_s=None if args.duration is None else float(args.duration),
			waveform_ms=float(args.waveform_ms),
			waveform_view=str(args.waveform_view),
			max_waveform_points=int(args.max_waveform_points),
			window_ms=float(args.window_ms),
			hop_ms=float(args.hop_ms),
			fps=float(args.fps),
			db=args.db,
			title=args.title,
			export_path=None,
			export_seconds=None,
			no_gui=False,
			overwrite_export=False,
		)

		# Export pass (no playback, no GUI)
		visualize(
			wav,
			source_path=wav_path,
			mono=args.mono,
			mode=args.mode,
			play=False,
			start_s=float(args.start),
			duration_s=None if args.duration is None else float(args.duration),
			waveform_ms=float(args.waveform_ms),
			waveform_view=str(args.waveform_view),
			max_waveform_points=int(args.max_waveform_points),
			window_ms=float(args.window_ms),
			hop_ms=float(args.hop_ms),
			fps=float(args.fps),
			db=args.db,
			title=args.title,
			export_path=export_path,
			export_seconds=float(args.export_seconds),
			no_gui=True,
			overwrite_export=bool(args.overwrite_export),
			export_audio=bool(args.export_audio),
		)
		return 0

	# Basic validation
	if args.window_ms <= 0 or args.hop_ms <= 0 or args.waveform_ms <= 0:
		raise ValueError("--window-ms/--hop-ms/--waveform-ms must be positive")
	if args.fps <= 0:
		raise ValueError("--fps must be positive")

	visualize(
		wav,
		source_path=wav_path,
		mono=args.mono,
		mode=args.mode,
		play=bool(args.play),
		start_s=float(args.start),
		duration_s=None if args.duration is None else float(args.duration),
		waveform_ms=float(args.waveform_ms),
		waveform_view=str(args.waveform_view),
		max_waveform_points=int(args.max_waveform_points),
		window_ms=float(args.window_ms),
		hop_ms=float(args.hop_ms),
		fps=float(args.fps),
		db=args.db,
		title=args.title,
		export_path=export_path,
		export_seconds=None
		if export_path is None
		else (0.0 if bool(args.export_full) else float(args.export_seconds)),
		no_gui=bool(args.no_gui),
		overwrite_export=bool(args.overwrite_export),
		export_audio=bool(args.export_audio),
	)
	return 0


if __name__ == "__main__":
	raise SystemExit(main())
export_visualizations.ps1Batch-exports a folder of songs through the visualizer into one output folder.
[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string]$SourceDir,

    [Parameter(Mandatory = $true)]
    [string]$OutDir,

    [Parameter(Mandatory = $false)]
    [ValidateSet('png','mp4','gif')]
    [string]$Format = 'mp4',

    [Parameter(Mandatory = $false)]
    [ValidateSet('scroll','full')]
    [string]$WaveformView = 'scroll',

    [Parameter(Mandatory = $false)]
    [int]$MaxWaveformPoints = 20000,

    [Parameter(Mandatory = $false)]
    [int]$Fps = 30,

    [Parameter(Mandatory = $false)]
    [switch]$UseDb,

    [Parameter(Mandatory = $false)]
    [switch]$Mono,

    [Parameter(Mandatory = $false)]
    [int]$Seconds = 10,

    [Parameter(Mandatory = $false)]
    [switch]$Full,

    [Parameter(Mandatory = $false)]
    [switch]$Overwrite,

    [Parameter(Mandatory = $false)]
    [switch]$NoAudio,

    [Parameter(Mandatory = $false)]
    [switch]$DryRun
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$repoRoot = $PSScriptRoot
if (-not $repoRoot) {
    $repoRoot = (Get-Location).Path
}

function Test-IsRiffWave {
    param([Parameter(Mandatory = $true)][string]$Path)

    try {
        $fs = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
        try {
            $buf = New-Object byte[] 12
            $read = $fs.Read($buf, 0, 12)
            if ($read -lt 12) { return $false }

            $riff = [System.Text.Encoding]::ASCII.GetString($buf, 0, 4)
            $wave = [System.Text.Encoding]::ASCII.GetString($buf, 8, 4)
            return ($riff -eq 'RIFF' -and $wave -eq 'WAVE')
        } finally {
            $fs.Dispose()
        }
    } catch {
        return $false
    }
}

$src = (Resolve-Path -LiteralPath $SourceDir).Path
$out = $OutDir
if (-not (Test-Path -LiteralPath $out)) {
    if ($DryRun) {
        Write-Host "DRY RUN: mkdir '$out'"
    } else {
        New-Item -ItemType Directory -Path $out -Force | Out-Null
    }
}

$py = Join-Path $repoRoot '.venv\Scripts\python.exe'
if (-not (Test-Path -LiteralPath $py)) {
    throw "Could not find venv python at: $py (expected at repoRoot/.venv/Scripts/python.exe)"
}

$viz = Join-Path $repoRoot 'music_visualizer.py'
if (-not (Test-Path -LiteralPath $viz)) {
    throw "Could not find visualizer script at: $viz"
}

$allWavs = Get-ChildItem -Path $src -File -Filter "*.wav"
if (-not $allWavs) {
    throw "No .wav files found in: $src"
}

# Prefer *_converted.wav when present for the same stem.
$selected = @()
$groups = $allWavs | Group-Object {
    if ($_.BaseName -match '^(.*)_converted$') { $Matches[1] } else { $_.BaseName }
}
foreach ($g in $groups) {
    $converted = $g.Group | Where-Object { $_.BaseName -match '_converted$' } | Select-Object -First 1
    $candidate = if ($converted) { $converted } else { $g.Group | Select-Object -First 1 }
    if (-not (Test-IsRiffWave -Path $candidate.FullName)) {
        Write-Warning "Skip (not RIFF/WAVE): $($candidate.FullName)"
        continue
    }
    $selected += $candidate
}

$wavs = $selected | Sort-Object FullName

foreach ($w in $wavs) {
    $args = @(
        $viz,
        $w.FullName,
        '--mode','both',
        '--no-gui',
        '--waveform-view', $WaveformView,
        '--max-waveform-points', "$MaxWaveformPoints",
        '--export-auto',
        '--out-dir', $out,
        '--export-format', $Format
    )

    if ($Fps -gt 0) {
        $args += @('--fps', "$Fps")
    }

    if ($Full) {
        $args += '--export-full'
    } else {
        $args += @('--export-seconds', "$Seconds")
    }

    if ($UseDb) { $args += '--db' }
    if ($Mono) { $args += '--mono' }
    if ($Overwrite) { $args += '--overwrite-export' }
    if ($NoAudio) { $args += '--no-export-audio' }

    $cmdLine = "$py " + ($args | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } }) -join ' '
    Write-Host "Export: $($w.Name) -> *.$Format"

    if ($DryRun) {
        Write-Host "DRY RUN: $cmdLine"
        continue
    }

    & $py @args
    if ($LASTEXITCODE -ne 0) {
        Write-Warning "FAILED: $($w.FullName)"
    }
}
convert_audio_to_wav.ps1Converts common audio formats (and mislabelled .wav files) to standard PCM .wav with ffmpeg.
[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string]$SourceDir,

    [Parameter(Mandatory = $false)]
    [string]$OutDir = "",

    [Parameter(Mandatory = $false)]
    [string]$Suffix = "_converted",

    [Parameter(Mandatory = $false)]
    [ValidateSet("flat", "mirror", "tags")]
    [string]$Layout = "flat",

    [Parameter(Mandatory = $false)]
    [switch]$NameFromTags,

    [Parameter(Mandatory = $false)]
    [switch]$FoldersFromTags,

    [Parameter(Mandatory = $false)]
    [ValidateSet("copy", "convert", "skip")]
    [string]$OnValidWav = "copy",

    [Parameter(Mandatory = $false)]
    [switch]$Mono,

    [Parameter(Mandatory = $false)]
    [int]$SampleRate = 44100,

    [Parameter(Mandatory = $false)]
    [switch]$Force,

    [Parameter(Mandatory = $false)]
    [switch]$DryRun
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

function Test-IsRiffWave {
    param([Parameter(Mandatory = $true)][string]$Path)

    try {
        $fs = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
        try {
            $buf = New-Object byte[] 12
            $read = $fs.Read($buf, 0, 12)
            if ($read -lt 12) { return $false }

            $riff = [System.Text.Encoding]::ASCII.GetString($buf, 0, 4)
            $wave = [System.Text.Encoding]::ASCII.GetString($buf, 8, 4)
            return ($riff -eq "RIFF" -and $wave -eq "WAVE")
        } finally {
            $fs.Dispose()
        }
    } catch {
        return $false
    }
}

function Get-SafeName {
    param(
        [Parameter(Mandatory = $true)][string]$Name,
        [int]$MaxLen = 120
    )

    $n = $Name.Trim()
    if ([string]::IsNullOrWhiteSpace($n)) { return "unknown" }

    # Replace invalid Windows filename characters
    $n = $n -replace '[<>:"/\\|?*]', '_'
    # Remove control chars
    $n = -join ($n.ToCharArray() | Where-Object { [int]$_ -ge 32 })
    # Collapse whitespace
    $n = $n -replace '\s+', ' '
    $n = $n.Trim(' .')
    if ($n.Length -gt $MaxLen) { $n = $n.Substring(0, $MaxLen).Trim() }
    if ([string]::IsNullOrWhiteSpace($n)) { return "unknown" }
    return $n
}

function Get-UniquePath {
    param(
        [Parameter(Mandatory = $true)][string]$Path
    )

    if (-not (Test-Path -LiteralPath $Path)) { return $Path }

    $dir = Split-Path -Parent $Path
    $leaf = Split-Path -Leaf $Path
    $base = [System.IO.Path]::GetFileNameWithoutExtension($leaf)
    $ext = [System.IO.Path]::GetExtension($leaf)

    for ($i = 2; $i -lt 1000; $i++) {
        $candidate = Join-Path $dir ("$base ($i)$ext")
        if (-not (Test-Path -LiteralPath $candidate)) { return $candidate }
    }
    throw "Could not find a unique filename for: $Path"
}

function Get-TagValue {
    param(
        [Parameter(Mandatory = $false)]$Tags,
        [Parameter(Mandatory = $true)][string]$Key
    )

    if (-not $Tags) { return $null }

    # Tags can be emitted with varying case. Try a few common variations.
    foreach ($k in @($Key, $Key.ToLowerInvariant(), $Key.ToUpperInvariant())) {
        try {
            $v = $Tags.$k
            if ($v) { return [string]$v }
        } catch {
            # ignore
        }
    }
    return $null
}

function Get-AudioTags {
    param(
        [Parameter(Mandatory = $true)][string]$Path,
        [Parameter(Mandatory = $true)][string]$FfprobePath
    )

    try {
        $json = & $FfprobePath -v error -print_format json -show_format -show_entries format_tags=title,artist,album,track,disc $Path
        if ($LASTEXITCODE -ne 0) { return $null }
        $obj = $json | ConvertFrom-Json
        return $obj.format.tags
    } catch {
        return $null
    }
}

$src = (Resolve-Path -LiteralPath $SourceDir).Path
if ([string]::IsNullOrWhiteSpace($OutDir)) {
    $OutDir = Join-Path $src "converted_wav"
}
$dst = $OutDir

if (-not (Test-Path -LiteralPath $dst)) {
    if ($DryRun) {
        Write-Host "DRY RUN: mkdir '$dst'"
    } else {
        New-Item -ItemType Directory -Path $dst | Out-Null
    }
}

$dstResolved = $dst
try {
    $dstResolved = (Resolve-Path -LiteralPath $dst).Path
} catch {
    # ignore
}

$ffmpeg = (Get-Command ffmpeg -ErrorAction SilentlyContinue)
if (-not $ffmpeg) {
    throw "ffmpeg not found on PATH. Install it or run 'winget install Gyan.FFmpeg'."
}

$ffprobe = (Get-Command ffprobe -ErrorAction SilentlyContinue)
if (-not $ffprobe) {
    throw "ffprobe not found on PATH (it usually comes with ffmpeg). Ensure ffprobe is installed and on PATH."
}

$audioExts = @(".wav", ".mp3", ".m4a", ".flac", ".aac", ".ogg")
$excludeOutputDir = $false
try {
    $srcResolved = (Resolve-Path -LiteralPath $src).Path
    if (-not [string]::IsNullOrWhiteSpace($dstResolved) -and ($dstResolved -ne $srcResolved)) {
        $excludeOutputDir = $true
    }
} catch {
    # If we can't resolve paths for any reason, err on the safe side and don't exclude.
    $excludeOutputDir = $false
}

$files = Get-ChildItem -Path $src -Recurse -File |
    Where-Object {
        ($audioExts -contains $_.Extension.ToLowerInvariant()) -and
        (-not $excludeOutputDir -or ($_.FullName -notlike ($dstResolved + "\\*")))
    }

# Skip files that already look like outputs from this script (prevents double-work/clutter)
if (-not [string]::IsNullOrWhiteSpace($Suffix)) {
    $files = $files | Where-Object { -not $_.BaseName.EndsWith($Suffix, [System.StringComparison]::OrdinalIgnoreCase) }
}

if (-not $files) {
    Write-Host "No audio files found under: $src"
    exit 0
}

$converted = 0
$copied = 0
$skipped = 0
$failed = 0

foreach ($f in $files) {
    $base = $f.BaseName

    $tags = $null
    if ($NameFromTags -or $FoldersFromTags -or $Layout -eq "tags") {
        $tags = Get-AudioTags -Path $f.FullName -FfprobePath $ffprobe.Source
    }

    $title = if ($NameFromTags -or $Layout -eq "tags") { Get-TagValue -Tags $tags -Key "title" } else { $null }
    $artist = if ($FoldersFromTags -or $Layout -eq "tags") { Get-TagValue -Tags $tags -Key "artist" } else { $null }
    $album = if ($FoldersFromTags -or $Layout -eq "tags") { Get-TagValue -Tags $tags -Key "album" } else { $null }

    $safeTitle = if ($title) { Get-SafeName -Name $title } else { Get-SafeName -Name $base }
    $safeArtist = if ($artist) { Get-SafeName -Name $artist } else { "unknown_artist" }
    $safeAlbum = if ($album) { Get-SafeName -Name $album } else { "unknown_album" }

    $targetDir = $dst
    if ($Layout -eq "mirror") {
        $relDir = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetRelativePath($src, $f.FullName))
        if (-not [string]::IsNullOrWhiteSpace($relDir)) {
            $targetDir = Join-Path $dst $relDir
        }
    } elseif ($Layout -eq "tags" -or $FoldersFromTags) {
        $targetDir = Join-Path (Join-Path $dst $safeArtist) $safeAlbum
    }

    if (-not (Test-Path -LiteralPath $targetDir)) {
        if ($DryRun) {
            Write-Host "DRY RUN: mkdir '$targetDir'"
        } else {
            New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
        }
    }

    $targetDirResolved = $targetDir
    try {
        $targetDirResolved = (Resolve-Path -LiteralPath $targetDir).Path
    } catch {
        # ignore
    }

    $outNameBase = $safeTitle
    if ($targetDirResolved -eq $f.DirectoryName) {
        if ([string]::IsNullOrWhiteSpace($Suffix)) {
            throw "OutDir is the same as the source file folder, but -Suffix is empty. Refusing to overwrite in-place. Provide -Suffix (e.g. _converted) or choose a different -OutDir."
        }
        $outNameBase = $outNameBase + $Suffix
    }
    $outPath = Join-Path $targetDir ($outNameBase + ".wav")
    if ((Test-Path -LiteralPath $outPath) -and (-not $Force)) {
        Write-Host "Skip (exists): $outPath"
        $skipped++
        continue
    }

    # If not forcing and the output path exists due to collisions, pick a unique name.
    if ((Test-Path -LiteralPath $outPath) -and $Force) {
        # We'll overwrite.
    } elseif (Test-Path -LiteralPath $outPath) {
        $outPath = Get-UniquePath -Path $outPath
    }

    $isValidWav = $false
    if ($f.Extension.ToLowerInvariant() -eq ".wav") {
        $isValidWav = Test-IsRiffWave -Path $f.FullName
    }

    if ($isValidWav -and $OnValidWav -eq "skip") {
        Write-Host "Skip (valid WAV): $($f.FullName)"
        $skipped++
        continue
    }

    if ($isValidWav -and $OnValidWav -eq "copy") {
        Write-Host "Copy: $($f.FullName) -> $outPath"
        if (-not $DryRun) {
            Copy-Item -LiteralPath $f.FullName -Destination $outPath -Force
        }
        $copied++
        continue
    }

    $args = @(
        "-hide_banner",
        "-y",
        "-i", $f.FullName
    )
    if ($Mono) {
        $args += @("-ac", "1")
    }
    if ($SampleRate -gt 0) {
        $args += @("-ar", "$SampleRate")
    }
    $args += @($outPath)

    Write-Host "Convert: $($f.FullName) -> $outPath"
    if ($DryRun) {
        Write-Host "DRY RUN: ffmpeg $($args -join ' ')"
        continue
    }

    try {
        & $ffmpeg.Source @args | Out-Host
        if ($LASTEXITCODE -ne 0) {
            throw "ffmpeg failed with exit code $LASTEXITCODE"
        }
        $converted++
    } catch {
        Write-Warning "FAILED: $($f.FullName) ($($_.Exception.Message))"
        $failed++
    }
}

Write-Host ""
Write-Host "Done. Copied=$copied Converted=$converted Skipped=$skipped Failed=$failed"
if ($failed -gt 0) { exit 1 } else { exit 0 }

Already in this destination