commit b7aa066bf6bae6b3127376e7914aa07d6d9d75db
parent c688140c7294c82e20d234082e3c1703949947dd
Author: mtmn <miro@haravara.org>
Date: Mon, 11 May 2026 12:39:11 +0200
chore: remove `aaltomuoto`
Diffstat:
5 files changed, 0 insertions(+), 828 deletions(-)
diff --git a/README.md b/README.md
@@ -4,7 +4,6 @@ Various tools I have been using throughout the years.
| tool | description |
|---|---|
-| [aaltomuoto](aaltomuoto/) | frequency-colored audio waveform generator |
| [backup](backup/) | terminal backup orchestrator |
| [bandeno](bandeno/) | Bandcamp JSON Feed proxy |
| [diffamer](diffamer/) | line-file syncer with rsync and coloured diff |
diff --git a/aaltomuoto/BUILD b/aaltomuoto/BUILD
@@ -1,21 +0,0 @@
-load("@rules_python//python:defs.bzl", "py_binary")
-load("//bazel:local-deploy.bzl", "local_deploy")
-
-py_binary(
- name = "aaltomuoto",
- srcs = ["aaltomuoto.py"],
- main = "aaltomuoto.py",
- python_version = "PY3",
- deps = [
- "@pypi//mutagen",
- "@pypi//numpy",
- "@pypi//pillow",
- "@pypi//scipy",
- ],
-)
-
-local_deploy(
- name = "deploy",
- srcs = [":aaltomuoto"],
- copy_runfiles = True,
-)
diff --git a/aaltomuoto/README.md b/aaltomuoto/README.md
@@ -1,83 +0,0 @@
-# Aaltomuoto
-
-Aaltomuoto generates frequency-colored waveforms that visualize the spectral content of audio files. Each vertical bar in the waveform represents the amplitude of the audio at that moment, with the color indicating the dominant frequency content.
-
-## Features
-
-- **15 Detailed Frequency Bands**: Audio is analyzed across 15 distinct frequency ranges for detailed spectral visualization
-- **Vibrant Color Mapping**: Each frequency band is assigned a unique, vibrant color across the full spectrum
-- **Anti-Aliased Rendering**: Smooth waveform rendering using 4x supersampling
-- **Readable Text**: Track name and timeline markers with high contrast for visibility
-- **Configurable Parameters**: Adjustable width, height, and detail level
-
-## Color Scheme
-
-Each color in the waveform represents a specific frequency range:
-
-| Color | Frequency Range | Description |
-|-------|----------------|-------------|
-| Deep Red | 0-40 Hz | Sub-bass (fundamental tones, kick drums) |
-| Orange-Red | 40-120 Hz | Low bass (bass instruments, low percussion) |
-| Orange | 120-250 Hz | Mid bass (warmth, body of bass instruments) |
-| Yellow-Orange | 250-500 Hz | Lower low-mids (some warmth, texture) |
-| Yellow-Green | 500-750 Hz | Low-mids (fullness, some vocal fundamentals) |
-| Green | 750-1000 Hz | Lower midrange (clarity, presence) |
-| Blue-Green | 1000-2000 Hz | Midrange (vocal presence, snare, guitar) |
-| Blue | 2000-3000 Hz | Upper midrange (brightness, attack transients) |
-| Indigo | 3000-4000 Hz | Lower high-mids (detail, clarity) |
-| Purple | 4000-5000 Hz | High-mids (edge, sibilance) |
-| Pink-Purple | 5000-6000 Hz | Upper high-mids (crispness) |
-| Bright Pink | 6000-7000 Hz | Presence (air, sparkle) |
-| Hot Pink | 7000-8000 Hz | Upper presence (fine detail) |
-| Magenta | 8000-10000 Hz | Brilliance (brightness, shimmer) |
-| Violet | 10000-20000 Hz | Air (ethereal highs, airiness) |
-
-## Usage
-
-```bash
-python aaltomuoto.py <input_audio> [output.png] [width] [height] [pixels_per_second]
-```
-
-### Examples
-
-```bash
-# Basic usage
-python aaltomuoto.py track.flac waveform.png
-
-# Custom dimensions and detail
-python aaltomuoto.py track.flac waveform.png 4000 350 500
-
-# Higher detail level
-python aaltomuoto.py track.flac waveform.png 4000 350 1000
-```
-
-### Parameters
-
-- `<input_audio>`: Input audio file (required)
-- `[output.png]`: Output image filename (default: waveform.png)
-- `[width]`: Image width in pixels (default: 4000)
-- `[height]`: Image height in pixels (default: 350)
-- `[pixels_per_second]`: Detail level (higher = more detail, default: 500)
-
-## Technical Details
-
-- Uses FFT-based frequency analysis for accurate spectral decomposition
-- Implements perceptually uniform color mapping using HSV color space
-- Applies logarithmic amplitude scaling to enhance dynamic range
-- Uses anti-aliasing for smooth waveform rendering
-- Includes adaptive timeline markers based on track duration
-- Renders text elements separately for optimal readability
-
-## Dependencies
-
-- Python 3.x
-- numpy
-- pillow
-- mutagen
-- scipy
-- audiowaveform
-- ffmpeg
-
-## License
-
-MIT License
diff --git a/aaltomuoto/__pycache__/aaltomuoto.cpython-314.pyc b/aaltomuoto/__pycache__/aaltomuoto.cpython-314.pyc
Binary files differ.
diff --git a/aaltomuoto/aaltomuoto.py b/aaltomuoto/aaltomuoto.py
@@ -1,723 +0,0 @@
-#!/usr/bin/env python3
-"""
-Uses audiowaveform and ffmpeg to create frequency-colored waveforms
-with 6 bands mapped to a blue-cyan color gradient:
- - Sub-bass (< 60Hz) → Deep blue
- - Bass (60-250Hz) → Blue
- - Low-mid (250-500Hz) → Light blue
- - Mid (500-2kHz) → Cyan
- - High-mid (2-6kHz) → Bright cyan
- - High (> 6kHz) → White/cyan
-"""
-
-from __future__ import annotations
-
-import concurrent.futures
-import json
-import os
-import subprocess
-import sys
-import tempfile
-from typing import Any
-
-import mutagen
-import numpy as np
-from numpy.typing import NDArray
-from PIL import Image, ImageDraw, ImageFont
-from scipy import signal
-
-# Try to import numba for JIT acceleration (optional)
-try:
- from numba import njit, prange
-
- HAS_NUMBA = True
-except ImportError:
- HAS_NUMBA = False
- # Fallback decorators when numba is not available
- def njit(*args, **kwargs):
- def decorator(func):
- return func
-
- return decorator
-
- prange = range
-
-# Type aliases
-FloatArray = NDArray[np.floating[Any]]
-ColorTuple = tuple[int, int, int]
-
-# Frequency bands: (low_hz, high_hz, name) - based on standard audio engineering divisions with more detail
-BANDS: list[tuple[int, int, str]] = [
- (0, 40, "subbass"), # Sub-bass (deep red)
- (40, 120, "lowbass"), # Low bass (orange-red)
- (120, 250, "midbass"), # Mid bass (orange)
- (250, 500, "lowlowmid"), # Lower low-mids (yellow-orange)
- (500, 750, "lowmid"), # Low-mids (yellow-green)
- (750, 1000, "lowermid"), # Lower midrange (green)
- (1000, 2000, "mid"), # Midrange (blue-green)
- (2000, 3000, "uppermid"), # Upper midrange (blue)
- (3000, 4000, "lowerhighmid"), # Lower high-mids (indigo)
- (4000, 5000, "highmid"), # High-mids (purple)
- (5000, 6000, "upperhighmid"), # Upper high-mids (pink-purple)
- (6000, 7000, "presence"), # Presence (bright pink)
- (7000, 8000, "upperpresence"), # Upper presence (hot pink)
- (8000, 10000, "brilliance"), # Brilliance (magenta)
- (10000, 20000, "air"), # Air (violet)
-]
-
-# Pre-compute band names list for faster iteration
-BAND_NAMES: list[str] = [name for _, _, name in BANDS]
-
-
-def create_perceptually_uniform_colors(num_bands: int) -> list[ColorTuple]:
- """Create vibrant, colorful colors using HSV color space."""
- colors = []
- # Spread hues across the entire color spectrum for maximum color variety
- hue_start = 0 # Red (in degrees on HSV color wheel)
- hue_end = 300 # Back to purple/pink (full spectrum)
-
- for i in range(num_bands):
- # Calculate hue for this band
- ratio = i / max(num_bands - 1, 1) # Avoid division by zero
- hue = hue_start + (hue_end - hue_start) * ratio
-
- # Use high saturation for vivid colors
- saturation = 0.85 # High saturation for vibrant colors
- value = 0.9 # High value for bright colors
-
- # Convert HSV to RGB
- rgb = hsv_to_rgb(hue / 360.0, saturation, value)
- colors.append(rgb)
-
- return colors
-
-
-def hsv_to_rgb(h: float, s: float, v: float) -> ColorTuple:
- """Convert HSV color to RGB color tuple."""
- if s == 0.0:
- r = g = b = int(v * 255)
- return (r, g, b)
-
- i = int(h * 6)
- f = (h * 6) - i
- p = v * (1 - s)
- q = v * (1 - s * f)
- t = v * (1 - s * (1 - f))
-
- i %= 6
- if i == 0:
- r, g, b = v, t, p
- elif i == 1:
- r, g, b = q, v, p
- elif i == 2:
- r, g, b = p, v, t
- elif i == 3:
- r, g, b = p, q, v
- elif i == 4:
- r, g, b = t, p, v
- else:
- r, g, b = v, p, q
-
- return (int(r * 255), int(g * 255), int(b * 255))
-
-
-# Generate perceptually uniform colors for each band
-band_names = [
- "subbass",
- "lowbass",
- "midbass",
- "lowlowmid",
- "lowmid",
- "lowermid",
- "mid",
- "uppermid",
- "lowerhighmid",
- "highmid",
- "upperhighmid",
- "presence",
- "upperpresence",
- "brilliance",
- "air",
-]
-perceptual_colors = create_perceptually_uniform_colors(len(band_names))
-BAND_COLORS: dict[str, NDArray[np.int64]] = {
- name: np.array(color) for name, color in zip(band_names, perceptual_colors)
-}
-
-# Waveform detail level
-PIXELS_PER_SECOND = 500
-
-# Layout constants
-TITLE_HEIGHT = 25
-TIMELINE_HEIGHT = 20
-
-# Color palette
-BG_COLOR: ColorTuple = (5, 10, 20)
-CENTER_LINE_COLOR: ColorTuple = (25, 50, 80)
-QUIET_COLOR: ColorTuple = (40, 80, 120)
-TITLE_COLOR: ColorTuple = (255, 255, 255) # Pure white for maximum visibility
-TIMELINE_COLOR: ColorTuple = (100, 140, 180) # Brighter timeline
-TICK_COLOR: ColorTuple = (200, 230, 255) # Bright white-blue for ticks
-TEXT_COLOR: ColorTuple = (255, 255, 255) # Pure white for text
-
-# Amplitude threshold for "quiet" sections
-QUIET_THRESHOLD = 8
-
-
-def get_track_info(file_path: str) -> str:
- """Extract artist and title from audio file metadata."""
- try:
- audio = mutagen.File(file_path, easy=True)
- if audio is None:
- return os.path.basename(file_path)
-
- artist_list = audio.get("artist")
- title_list = audio.get("title")
-
- artist = str(artist_list[0]) if artist_list else ""
- title = str(title_list[0]) if title_list else ""
-
- if artist and title:
- return f"{artist} - {title}"
- return title or artist or os.path.basename(file_path)
- except Exception:
- return os.path.basename(file_path)
-
-
-def run_ffmpeg_filter(input_file: str, output_file: str, filter_str: str) -> None:
- """Run ffmpeg with an audio filter."""
- subprocess.run(
- [
- "ffmpeg",
- "-y",
- "-i",
- input_file,
- "-af",
- filter_str,
- "-ar",
- "44100",
- output_file,
- ],
- check=True,
- capture_output=True,
- )
-
-
-def generate_waveform_json(
- input_file: str,
- output_json: str,
- pixels_per_second: int,
-) -> dict[str, Any]:
- """Generate waveform data using audiowaveform."""
- subprocess.run(
- [
- "audiowaveform",
- "-i",
- input_file,
- "-o",
- output_json,
- "--pixels-per-second",
- str(pixels_per_second),
- "--bits",
- "8",
- ],
- check=True,
- capture_output=True,
- )
- with open(output_json) as f:
- result: dict[str, Any] = json.load(f)
- return result
-
-
-def extract_amplitudes(data: dict[str, Any]) -> FloatArray:
- """Extract absolute max amplitudes from waveform data."""
- raw_data: list[int] = data["data"]
- raw = np.array(raw_data, dtype=np.int8)
- mins = raw[0::2].astype(np.float64)
- maxs = raw[1::2].astype(np.float64)
- return np.maximum(np.abs(mins), np.abs(maxs))
-
-
-def normalize_amplitudes(amps: FloatArray, target_max: float = 127.0) -> FloatArray:
- """Normalize amplitude values to improve dynamic range."""
- if len(amps) == 0:
- return amps
-
- # Calculate the maximum amplitude in the array
- max_val = np.max(amps)
-
- if max_val == 0:
- return amps # Avoid division by zero
-
- # Apply logarithmic scaling to enhance quieter parts
- # This helps bring out details in lower-amplitude sections
- normalized = np.power(amps / max_val, 0.6) * target_max
-
- return normalized
-
-
-def resize_array(arr: FloatArray, target_len: int) -> FloatArray:
- """Resize array to target length using nearest-neighbor interpolation."""
- if len(arr) == target_len:
- return arr
- indices = np.linspace(0, len(arr) - 1, target_len).astype(np.intp)
- return arr[indices]
-
-
-def load_font(path: str, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- """Load a TrueType font, falling back to default if unavailable."""
- try:
- return ImageFont.truetype(path, size)
- except OSError:
- return ImageFont.load_default()
-
-
-@njit(parallel=True, cache=True)
-def render_waveform_pixels(
- aa_pixels_arr: FloatArray,
- sample_amps: FloatArray,
- band_values: FloatArray,
- band_color_matrix: FloatArray,
- aa_center_y: int,
- aa_waveform_height: int,
- title_height_scaled: int,
- aa_height: int,
- aa_width: int,
- quiet_threshold: float,
- quiet_color: tuple[int, int, int],
- center_line_color: tuple[int, int, int],
-) -> None:
- """JIT-compiled function to render waveform pixels."""
- bar_heights = ((sample_amps / 127.0) * (aa_waveform_height // 2)).astype(np.int32)
-
- for x in prange(aa_width):
- bar_height = bar_heights[x]
- amp = sample_amps[x]
-
- if amp < quiet_threshold:
- # Quiet section
- y_start = max(aa_center_y - 2, title_height_scaled)
- y_end = min(aa_center_y + 2, title_height_scaled + aa_waveform_height)
- for y in range(y_start, y_end):
- aa_pixels_arr[y, x, 0] = quiet_color[0]
- aa_pixels_arr[y, x, 1] = quiet_color[1]
- aa_pixels_arr[y, x, 2] = quiet_color[2]
- else:
- # Calculate color from band weights
- total = 0.0
- for b in range(band_values.shape[1]):
- total += band_values[x, b]
- total += 1e-6
-
- r, g, b = 0.0, 0.0, 0.0
- for band_idx in range(band_values.shape[1]):
- weight = band_values[x, band_idx] / total
- r += band_color_matrix[band_idx, 0] * weight
- g += band_color_matrix[band_idx, 1] * weight
- b += band_color_matrix[band_idx, 2] * weight
-
- # Draw colored bar
- y_start = max(aa_center_y - bar_height, title_height_scaled)
- y_end = min(aa_center_y + bar_height + 1, title_height_scaled + aa_waveform_height)
- for y in range(y_start, y_end):
- aa_pixels_arr[y, x, 0] = min(255, max(0, int(r)))
- aa_pixels_arr[y, x, 1] = min(255, max(0, int(g)))
- aa_pixels_arr[y, x, 2] = min(255, max(0, int(b)))
-
- # Draw center line
- for x in range(aa_width):
- aa_pixels_arr[aa_center_y, x, 0] = center_line_color[0]
- aa_pixels_arr[aa_center_y, x, 1] = center_line_color[1]
- aa_pixels_arr[aa_center_y, x, 2] = center_line_color[2]
-
-
-def render_waveform(
- original_amps: FloatArray,
- band_amps: dict[str, FloatArray],
- duration_seconds: float,
- track_info: str,
- output_file: str,
- width: int = 4000,
- height: int = 350,
-) -> None:
- """Render the CDJ-style waveform image with 8-band coloring."""
- n_samples = len(original_amps)
- waveform_height = height - TIMELINE_HEIGHT - TITLE_HEIGHT
-
- # Normalize the original amplitudes to enhance dynamic range
- normalized_original_amps = normalize_amplitudes(original_amps.copy())
-
- # Normalize all band amplitudes to enhance dynamic range
- normalized_band_amps: dict[str, FloatArray] = {}
- for name, amps in band_amps.items():
- normalized_band_amps[name] = normalize_amplitudes(amps.copy())
-
- # Resize all band arrays to match original
- resized_bands: dict[str, FloatArray] = {
- name: resize_array(amps, n_samples)
- for name, amps in normalized_band_amps.items()
- }
-
- # Anti-aliasing: render at higher resolution then downsample
- aa_scale = 2 # Reduced from 4 for faster rendering
- aa_width = width * aa_scale
- aa_height = height * aa_scale
- aa_waveform_height = waveform_height * aa_scale
- aa_center_y = (TITLE_HEIGHT * aa_scale) + (aa_waveform_height // 2)
- title_height_scaled = TITLE_HEIGHT * aa_scale
-
- # Create numpy array for pixel data
- aa_pixels_arr = np.full((aa_height, aa_width, 3), BG_COLOR, dtype=np.float64)
-
- # Map samples to pixel columns
- x_indices = np.linspace(0, n_samples - 1, aa_width).astype(np.intp)
-
- # Pre-compute band colors as float array
- band_color_matrix = np.array([BAND_COLORS[name].astype(np.float64) for name in BAND_NAMES])
-
- # Pre-compute all band values for all x positions (vectorized)
- band_values = np.array([[resized_bands[name][idx] for name in BAND_NAMES] for idx in x_indices], dtype=np.float64)
-
- # Get sample amplitudes for mapped indices
- sample_amps = normalized_original_amps[x_indices]
-
- # Use JIT-compiled rendering if available
- if HAS_NUMBA:
- render_waveform_pixels(
- aa_pixels_arr,
- sample_amps,
- band_values,
- band_color_matrix,
- aa_center_y,
- aa_waveform_height,
- title_height_scaled,
- aa_height,
- aa_width,
- QUIET_THRESHOLD,
- QUIET_COLOR,
- CENTER_LINE_COLOR,
- )
- aa_pixels_arr = aa_pixels_arr.astype(np.uint8)
- else:
- # Fallback: vectorized numpy operations
- bar_heights = ((sample_amps / 127.0) * (aa_waveform_height // 2)).astype(np.int32)
- quiet_mask = sample_amps < QUIET_THRESHOLD
-
- # Calculate colors for all x positions at once
- band_totals = np.sum(band_values, axis=1, keepdims=True) + 1e-6
- band_weights = band_values / band_totals
- all_colors = np.clip(band_weights @ band_color_matrix, 0, 255).astype(np.uint8)
-
- for x in range(aa_width):
- bar_height = bar_heights[x]
- if quiet_mask[x]:
- y_start = max(aa_center_y - aa_scale, title_height_scaled)
- y_end = min(aa_center_y + aa_scale, title_height_scaled + aa_waveform_height)
- aa_pixels_arr[y_start:y_end, x] = QUIET_COLOR
- else:
- y_start = max(aa_center_y - bar_height, title_height_scaled)
- y_end = min(aa_center_y + bar_height + 1, title_height_scaled + aa_waveform_height)
- aa_pixels_arr[y_start:y_end, x] = all_colors[x]
-
- aa_pixels_arr[aa_center_y, :] = CENTER_LINE_COLOR
- aa_pixels_arr = aa_pixels_arr.astype(np.uint8)
-
- # Create PIL image from numpy array
- aa_img = Image.fromarray(aa_pixels_arr, mode='RGB')
- aa_draw = ImageDraw.Draw(aa_img)
-
- # Draw timeline
- aa_timeline_y = title_height_scaled + aa_waveform_height
- aa_pixels_arr[aa_timeline_y, :] = TIMELINE_COLOR
-
- # Choose appropriate tick interval based on duration
- if duration_seconds <= 60:
- tick_interval = 10
- elif duration_seconds <= 180:
- tick_interval = 30
- else:
- tick_interval = 60
-
- # Draw time markers
- aa_timeline_font = load_font(
- "/usr/share/fonts/TTF/DejaVuSansMono.ttf",
- 16 * aa_scale,
- )
-
- # Draw major ticks and labels
- for i in range(int(duration_seconds / tick_interval) + 2):
- time_sec = i * tick_interval
- if time_sec > duration_seconds:
- break
- x = int((time_sec / duration_seconds) * (aa_width - 1))
-
- tick_end = min(aa_timeline_y + (5 * aa_scale), aa_height)
- aa_pixels_arr[aa_timeline_y:tick_end, x] = TICK_COLOR
-
- minutes = int(time_sec // 60)
- seconds = int(time_sec % 60)
- label = f"{minutes}:{seconds:02d}"
- aa_draw.text(
- (x + (3 * aa_scale), aa_timeline_y + (3 * aa_scale)),
- label,
- fill=TEXT_COLOR,
- font=aa_timeline_font,
- )
-
- # Draw minor ticks
- minor_tick_interval = tick_interval / 2
- for i in range(int(duration_seconds / minor_tick_interval) + 2):
- time_sec = i * minor_tick_interval
- if time_sec > duration_seconds or time_sec % tick_interval == 0:
- continue
- x = int((time_sec / duration_seconds) * (aa_width - 1))
- tick_end = min(aa_timeline_y + (3 * aa_scale), aa_height)
- aa_pixels_arr[aa_timeline_y:tick_end, x] = TICK_COLOR
-
- # Downsample to final size
- final_img = aa_img.resize((width, height), Image.LANCZOS)
-
- # Add text elements
- draw = ImageDraw.Draw(final_img)
-
- # Draw title with background
- title_font = load_font("/usr/share/fonts/TTF/DejaVuSansMono-Bold.ttf", 32)
- bbox = draw.textbbox((10, 5), track_info, font=title_font)
- text_width = bbox[2] - bbox[0]
- text_height = bbox[3] - bbox[1]
-
- draw.rectangle([(5, 3), (text_width + 15, text_height + 7)], fill=(20, 30, 40, 180))
- draw.text((10, 5), track_info, fill=TITLE_COLOR, font=title_font)
-
- # Draw timeline markers
- timeline_font = load_font("/usr/share/fonts/TTF/DejaVuSansMono.ttf", 20)
- timeline_y = TITLE_HEIGHT + waveform_height
-
- for i in range(int(duration_seconds / tick_interval) + 2):
- time_sec = i * tick_interval
- if time_sec > duration_seconds:
- break
- x = int((time_sec / duration_seconds) * (width - 1))
- draw.line([(x, timeline_y), (x, min(timeline_y + 5, height - 1))], fill=TIMELINE_COLOR)
- minutes = int(time_sec // 60)
- seconds = int(time_sec % 60)
- label = f"{minutes}:{seconds:02d}"
- draw.text((x + 3, timeline_y + 3), label, fill=TEXT_COLOR, font=timeline_font)
-
- # Draw minor ticks
- for i in range(int(duration_seconds / minor_tick_interval) + 2):
- time_sec = i * minor_tick_interval
- if time_sec > duration_seconds or time_sec % tick_interval == 0:
- continue
- x = int((time_sec / duration_seconds) * (width - 1))
- draw.line([(x, timeline_y), (x, min(timeline_y + 3, height - 1))], fill=TICK_COLOR)
-
- final_img.save(output_file)
- print(output_file)
-
-
-@njit(parallel=True, cache=True, fastmath=True)
-def compute_band_energies(
- magnitude: FloatArray,
- band_indices: FloatArray,
- n_bands: int,
-) -> FloatArray:
- """JIT-compiled function to compute energy for all frequency bands.
-
- Args:
- magnitude: 2D array of shape (n_freqs, n_times)
- band_indices: 2D array of shape (n_bands, 2) with [low_bin, high_bin] for each band
- n_bands: Number of frequency bands
-
- Returns:
- 2D array of shape (n_bands, n_times) with band energies
- """
- n_times = magnitude.shape[1]
- result = np.zeros((n_bands, n_times), dtype=np.float64)
-
- for band_idx in prange(n_bands):
- low_bin = int(band_indices[band_idx, 0])
- high_bin = int(band_indices[band_idx, 1])
- for t in range(n_times):
- energy = 0.0
- for freq_bin in range(low_bin, high_bin):
- energy += magnitude[freq_bin, t]
- result[band_idx, t] = energy
-
- return result
-
-
-def process_frequency_band(
- args: tuple[int, int, str, FloatArray, FloatArray, int, int]
-) -> tuple[str, FloatArray]:
- """Process a single frequency band (used for parallel processing)."""
- low_hz, high_hz, name, magnitude, frequencies, window_size, hop_length = args
-
- # Find frequency bin indices corresponding to the band
- low_bin = np.searchsorted(frequencies, low_hz)
- high_bin = np.searchsorted(frequencies, high_hz)
-
- # Extract energy in this frequency band
- band_energy = np.sum(magnitude[low_bin:high_bin], axis=0)
-
- return (name, np.abs(band_energy))
-
-
-def perform_frequency_analysis(
- audio_file: str, sample_rate: int = 44100, max_workers: int | None = None
-) -> dict[str, FloatArray]:
- """Perform FFT-based frequency analysis to get more accurate band amplitudes.
-
- Uses multi-threading and JIT compilation for optimal performance.
- """
- # Load audio file using ffmpeg to get raw PCM data
- with tempfile.NamedTemporaryFile(suffix=".raw", delete=False) as temp_raw:
- subprocess.run(
- [
- "ffmpeg",
- "-y",
- "-i",
- audio_file,
- "-f",
- "s16le",
- "-ar",
- str(sample_rate),
- "-ac",
- "1", # Convert to mono for simplicity
- temp_raw.name,
- ],
- check=True,
- capture_output=True,
- )
-
- # Read the raw audio data
- raw_audio = np.frombuffer(open(temp_raw.name, "rb").read(), dtype=np.int16)
- audio_data = raw_audio.astype(np.float32) / 32768.0 # Normalize to [-1, 1]
-
- # Clean up temporary file
- os.unlink(temp_raw.name)
-
- # Define window size and hop length for STFT
- window_size = 2048 # Smaller window for faster processing
- hop_length = window_size // 4
-
- # Perform Short-Time Fourier Transform using optimized scipy.signal
- frequencies, _, stft_matrix = signal.stft(
- audio_data,
- fs=sample_rate,
- nperseg=window_size,
- noverlap=window_size - hop_length,
- window="hann",
- padded=False,
- boundary=None,
- )
-
- # Calculate magnitude spectrogram
- magnitude = np.abs(stft_matrix).astype(np.float64)
-
- # Pre-compute frequency bin indices for all bands (cached)
- n_bands = len(BANDS)
- band_indices = np.zeros((n_bands, 2), dtype=np.float64)
- for i, (low_hz, high_hz, _) in enumerate(BANDS):
- band_indices[i, 0] = np.searchsorted(frequencies, low_hz)
- band_indices[i, 1] = np.searchsorted(frequencies, high_hz)
-
- # Use JIT-compiled function if available, otherwise use parallel processing
- if HAS_NUMBA:
- # Compute all band energies at once with JIT
- all_band_energies = compute_band_energies(magnitude, band_indices, n_bands)
- band_amps: dict[str, FloatArray] = {}
- for i, (_, _, name) in enumerate(BANDS):
- band_amps[name] = np.abs(all_band_energies[i])
- else:
- # Fallback to parallel processing without JIT
- band_args = [
- (low_hz, high_hz, name, magnitude, frequencies, window_size, hop_length)
- for low_hz, high_hz, name in BANDS
- ]
-
- band_amps = {}
- with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
- futures = {
- executor.submit(process_frequency_band, args): args[2]
- for args in band_args
- }
- for future in concurrent.futures.as_completed(futures):
- name, band_energy = future.result()
- band_amps[name] = band_energy
-
- return band_amps
-
-
-def build_ffmpeg_filter(low_hz: int, high_hz: int) -> str:
- """Build ffmpeg filter string for a frequency band."""
- if low_hz == 0:
- return f"lowpass=f={high_hz}"
- if high_hz >= 20000:
- return f"highpass=f={low_hz}"
- return f"highpass=f={low_hz},lowpass=f={high_hz}"
-
-
-def main() -> None:
- """Main entry point."""
- if len(sys.argv) < 2:
- print(
- "aaltomuoto <input_audio> [output.png] [width] [height] [pixels_per_second]"
- )
- print("aaltomuoto track.flac waveform.png 4000 350 500")
- print("Additional options:")
- print(" pixels_per_second: Controls waveform detail level (default: 500)")
- print(" width: Image width in pixels (default: 4000)")
- print(" height: Image height in pixels (default: 350)")
- print(" threads: Number of threads for frequency analysis (default: CPU count)")
- sys.exit(1)
-
- input_file = sys.argv[1]
- output_file = sys.argv[2] if len(sys.argv) > 2 else "waveform.png"
- width = int(sys.argv[3]) if len(sys.argv) > 3 else 4000
- height = int(sys.argv[4]) if len(sys.argv) > 4 else 350
- pixels_per_second = int(sys.argv[5]) if len(sys.argv) > 5 else PIXELS_PER_SECOND
- max_workers = int(sys.argv[6]) if len(sys.argv) > 6 else None
-
- if not os.path.exists(input_file):
- print(f"Error: File '{input_file}' not found")
- sys.exit(1)
-
- print(input_file)
- print(f"{output_file} ({width}x{height})")
- print(f"{pixels_per_second} pixels/second, {len(BANDS)} frequency bands")
-
- # Generate waveform data using audiowaveform for the overall shape
- with tempfile.TemporaryDirectory() as tmpdir:
- orig_json = os.path.join(tmpdir, "original.json")
- orig_data = generate_waveform_json(input_file, orig_json, pixels_per_second)
- orig_amps = extract_amplitudes(orig_data)
-
- # Calculate duration
- samples_per_pixel: int = int(orig_data.get("samples_per_pixel", 256))
- sample_rate: int = int(orig_data.get("sample_rate", 44100))
- raw_data: list[int] = orig_data["data"]
- n_pixels = len(raw_data) // 2
- duration_seconds = float(n_pixels * samples_per_pixel) / sample_rate
-
- # Perform more accurate frequency analysis using FFT (multi-threaded)
- band_amps = perform_frequency_analysis(input_file, sample_rate, max_workers)
-
- # Get track metadata
- track_info = get_track_info(input_file)
- print(track_info)
-
- # Render waveform
- render_waveform(
- orig_amps,
- band_amps,
- duration_seconds,
- track_info,
- output_file,
- width,
- height,
- )
-
-
-if __name__ == "__main__":
- main()