#!/usr/bin/env python3 # SPDX-License-Identifier: BSD-2-Clause # # gen_sounds.py - generative sound-theme builder for KDE Plasma / freedesktop. # # Produces a complete, spec-compliant freedesktop sound theme: # //index.theme # //stereo/.oga # # All audio is synthesised from scratch with numpy/scipy and encoded to # 48 kHz stereo Ogg Vorbis with ffmpeg. No sample libraries required. # # Copyright (c) 2026 enne2 """Build the DeepSpace / Interstellar / Voyager sound themes.""" from __future__ import annotations import argparse import math import shutil import subprocess import sys import tempfile from dataclasses import dataclass, field from pathlib import Path import numpy as np from scipy import signal from scipy.io import wavfile SR = 48000 TWO_PI = 2.0 * math.pi # --------------------------------------------------------------------------- # # Basic helpers # --------------------------------------------------------------------------- # def st(n: float) -> float: """Semitone ratio: st(12) == 2.0""" return 2.0 ** (n / 12.0) def n_of(dur: float) -> int: return max(1, int(round(dur * SR))) def t_of(n: int) -> np.ndarray: return np.arange(n, dtype=np.float64) / SR def rng_for(seed: int) -> np.random.Generator: return np.random.default_rng(seed) def as_stereo(x: np.ndarray) -> np.ndarray: if x.ndim == 1: return np.stack([x, x], axis=1) return x def mono(x: np.ndarray) -> np.ndarray: return x.mean(axis=1) if x.ndim == 2 else x # --------------------------------------------------------------------------- # # Envelopes # --------------------------------------------------------------------------- # def env_ad(n: int, attack: float = 0.005, tau: float | None = None, curve: float = 3.2) -> np.ndarray: """Attack ramp + exponential decay (percussive).""" t = t_of(n) if tau is None: tau = max(1e-4, (n / SR) / curve) a = np.minimum(t / attack, 1.0) if attack > 0 else np.ones(n) return a * np.exp(-t / tau) def env_asr(n: int, attack: float, tau: float, sustain: float = 0.35) -> np.ndarray: """Attack ramp, exponential fall to a floor level (sustained textures).""" t = t_of(n) a = np.minimum(t / attack, 1.0) if attack > 0 else np.ones(n) return a * (sustain + (1.0 - sustain) * np.exp(-t / tau)) def env_swell(n: int, attack: float, release: float, curve: float = 2.0) -> np.ndarray: """Cosine swell in, cosine swell out (pads / drones).""" t = t_of(n) a = np.clip(t / max(attack, 1e-4), 0.0, 1.0) a = 0.5 - 0.5 * np.cos(np.pi * a) r = np.clip((n / SR - t) / max(release, 1e-4), 0.0, 1.0) r = 0.5 - 0.5 * np.cos(np.pi * r) return np.power(a * r, 1.0 / curve) def fade_edges(x: np.ndarray, fin: float = 0.004, fout: float = 0.020) -> np.ndarray: """Raise-cosine micro fades: kills clicks without audibly shortening.""" x = np.array(x, dtype=np.float64, copy=True) ni, no = max(2, n_of(fin)), max(2, n_of(fout)) ni, no = min(ni, len(x) // 2), min(no, len(x) // 2) w = 0.5 - 0.5 * np.cos(np.pi * np.arange(ni) / ni) x[:ni] *= w[:, None] if x.ndim == 2 else w w = 0.5 - 0.5 * np.cos(np.pi * np.arange(no)[::-1] / no) x[-no:] *= w[:, None] if x.ndim == 2 else w return x # --------------------------------------------------------------------------- # # Filters # --------------------------------------------------------------------------- # def lowpass(x: np.ndarray, f: float, order: int = 2) -> np.ndarray: f = float(np.clip(f, 20.0, SR / 2 - 500)) sos = signal.butter(order, f, "lowpass", fs=SR, output="sos") return signal.sosfilt(sos, x, axis=0) if x.ndim == 2 else signal.sosfilt(sos, x) def highpass(x: np.ndarray, f: float, order: int = 2) -> np.ndarray: f = float(np.clip(f, 10.0, SR / 2 - 500)) sos = signal.butter(order, f, "highpass", fs=SR, output="sos") return signal.sosfilt(sos, x, axis=0) if x.ndim == 2 else signal.sosfilt(sos, x) def bandpass(x: np.ndarray, flo: float, fhi: float, order: int = 2) -> np.ndarray: flo = float(np.clip(flo, 20.0, SR / 2 - 600)) fhi = float(np.clip(fhi, flo * 1.05, SR / 2 - 400)) sos = signal.butter(order, [flo, fhi], "bandpass", fs=SR, output="sos") return signal.sosfilt(sos, x, axis=0) if x.ndim == 2 else signal.sosfilt(sos, x) def peaking_eq(x: np.ndarray, f: float, gain_db: float, q: float = 0.9) -> np.ndarray: """RBJ peaking EQ biquad (single band).""" if abs(gain_db) < 1e-6: return x f = float(np.clip(f, 20.0, SR / 2 - 500)) A = 10.0 ** (gain_db / 40.0) w0 = TWO_PI * f / SR alpha = math.sin(w0) / (2.0 * q) b = np.array([1 + alpha * A, -2 * math.cos(w0), 1 - alpha * A]) a = np.array([1 + alpha / A, -2 * math.cos(w0), 1 - alpha / A]) b /= a[0] a /= a[0] return signal.lfilter(b, a, x, axis=0) # --------------------------------------------------------------------------- # # Oscillators / voices # --------------------------------------------------------------------------- # def osc_sine(f: float, n: int, phase: float = 0.0) -> np.ndarray: return np.sin(TWO_PI * f * t_of(n) + phase) def osc_tri(f: float, n: int, phase: float = 0.0) -> np.ndarray: return signal.sawtooth(TWO_PI * f * t_of(n) + phase, width=0.5) def osc_square(f: float, n: int, phase: float = 0.0, duty: float = 0.5) -> np.ndarray: return signal.square(TWO_PI * f * t_of(n) + phase, duty=duty) def osc_additive(f: float, n: int, n_harm: int = 12, rolloff: float = 1.0, odd_only: bool = False) -> np.ndarray: """Band-limited additive oscillator (no aliasing).""" t = t_of(n) out = np.zeros(n) top = int(min(n_harm, (SR / 2 - 500) / max(f, 1e-6))) for k in range(1, max(top, 1) + 1): if odd_only and k % 2 == 0: continue if f * k > SR / 2 - 500: break out += (1.0 / (k ** rolloff)) * np.sin(TWO_PI * f * k * t) return out def fm_voice(f: float, n: int, ratio: float = 2.76, index: float = 2.2, index_tau: float = 0.12, feedback: float = 0.0) -> np.ndarray: """FM (Chowning) voice with exponentially decaying modulation index.""" t = t_of(n) idx = index * np.exp(-t / max(index_tau, 1e-4)) mod = np.sin(TWO_PI * f * ratio * t) if feedback > 0.0: # crude one-sample-feedback approximation of a self-modulating operator fb = np.zeros(n) prev = 0.0 for i in range(n): fb[i] = prev prev = math.sin(TWO_PI * f * ratio * t[i] + feedback * prev) mod = 0.5 * (mod + fb) return np.sin(TWO_PI * f * t + idx * mod) def pluck(f: float, n: int, damp: float = 0.5, decay: float = 0.9965, seed: int = 7) -> np.ndarray: """Karplus-Strong plucked string (delay line + averaging filter).""" rng = rng_for(seed) N = max(2, int(round(SR / max(f, 20.0)))) buf = rng.uniform(-1.0, 1.0, N) out = np.empty(n) idx = 0 for i in range(n): out[i] = buf[idx] buf[idx] = decay * damp * (buf[idx] + buf[(idx + 1) % N]) idx = (idx + 1) % N return out def noise_sweep(n: int, flo: float, fhi: float, seed: int = 0, segments: int = 40, order: int = 2, res: float = 0.0) -> np.ndarray: """White noise through a lowpass whose cutoff glides flo -> fhi. Implemented as overlap-add of Hann-windowed, separately-filtered chunks (50% overlap => constant unity gain). """ segs = max(4, int(segments)) hop = max(16, n // segs) wlen = 2 * hop windows = int(np.ceil(n / hop)) + 1 out = np.zeros(windows * hop + wlen) rng = rng_for(seed) ratio = max(fhi, 1.0) / max(flo, 1.0) for i in range(windows): frac = min(1.0, i / max(segs - 1, 1)) fc = flo * (ratio ** frac) fc = float(np.clip(fc, 25.0, SR / 2 - 700)) chunk = rng.standard_normal(wlen + 512) y = lowpass(chunk, fc, order) if res > 0.0: k = np.clip(fc / (SR / 2), 0.001, 0.99) b, a = signal.iirpeak(k, Q=max(0.8, res)) y = signal.lfilter(b, a, y) y = y[512:512 + wlen] out[i * hop:i * hop + wlen] += y * np.hanning(wlen) return out[:n] def bitcrush(x: np.ndarray, bits: int = 8, hold: int = 1, mix: float = 1.0) -> np.ndarray: """Quantise amplitude and hold samples: broken-transmission flavour.""" q = 2.0 ** (bits - 1) y = np.round(np.clip(x, -1.0, 1.0) * q) / q if hold > 1: idx = np.arange(0, len(y), hold) y = np.repeat(y[idx], hold)[:len(y)] return x * (1.0 - mix) + y * mix def tremolo(x: np.ndarray, rate: float, depth: float, phase: float = 0.0, shape: str = "sine") -> np.ndarray: t = t_of(len(x)) if shape == "square": m = 0.5 + 0.5 * np.sign(np.sin(TWO_PI * rate * t + phase)) else: m = 0.5 + 0.5 * np.sin(TWO_PI * rate * t + phase) return x * (1.0 - depth + depth * m) def pitch_bend(x: np.ndarray, f_start: float, f_end: float) -> np.ndarray: """Resample a mono buffer to bend its pitch linearly (crude, fine for short FX).""" if abs(f_end / f_start - 1.0) < 1e-4: return x n = len(x) t = np.linspace(0.0, 1.0, n) phase = np.cumsum((f_start * (1 - t) + f_end * t) / f_start) phase = (phase - phase[0]) / (phase[-1] - phase[0]) * (n - 1) return np.interp(phase, np.arange(n), x) def sub_hit(f: float, n: int, drop: float = 0.35, tau: float = 0.18, click: float = 0.25, seed: int = 3) -> np.ndarray: """Sub-bass impact with a short pitch drop and a transient click.""" t = t_of(n) freq = f * (1.0 + drop * np.exp(-t / (tau * 0.5))) phase = np.cumsum(TWO_PI * freq / SR) body = np.sin(phase) * np.exp(-t / tau) rng = rng_for(seed) tr = highpass(rng.standard_normal(n), 1500) * np.exp(-t / 0.012) * click return body + tr # --------------------------------------------------------------------------- # # Space / stereo / reverb # --------------------------------------------------------------------------- # def pan(x: np.ndarray, pos: float) -> np.ndarray: """pos in [-1, 1] -> constant-power stereo.""" pos = float(np.clip(pos, -1.0, 1.0)) ang = (pos + 1.0) * math.pi / 4.0 xm = mono(x) return np.stack([xm * math.cos(ang), xm * math.sin(ang)], axis=1) def haas(x: np.ndarray, delay_ms: float = 12.0, side: int = 0, mix: float = 0.35) -> np.ndarray: """Haas widening: delays one channel slightly and blends it in.""" x = as_stereo(np.array(x, dtype=np.float64, copy=True)) d = int(abs(delay_ms) * SR / 1000.0) out = x.copy() if d > 0 and d < len(x): if side == 0: out[d:, 1] += mix * x[:-d, 0] out[d:, 0] += mix * x[:-d, 1] elif side < 0: out[d:, 0] += mix * x[:-d, 0] else: out[d:, 1] += mix * x[:-d, 1] return out @dataclass class ImpulseResponse: ir: np.ndarray @property def n(self) -> int: return self.ir.shape[0] def make_ir(decay: float, seed: int = 0, brightness: float = 0.5, predelay: float = 0.018, density: float = 1.0) -> ImpulseResponse: """Synthetic hall IR: decaying noise, progressively darkened over time. brightness: 0 = dark/distant, 1 = bright/metallic. """ n = n_of(decay) pre = n_of(predelay) rng = rng_for(seed) raw = rng.standard_normal((n, 2)) if density < 1.0: # sparse, grainy tail (deep space, not a real hall) mask = (rng.random(n) < density).astype(np.float64) mask = lowpass(mask, 900) * 6.0 raw *= mask[:, None] # One-pole-ish lowpass used for the progressive darkening sos = signal.butter(2, float(np.clip(1200 + 7000 * brightness, 400, 16000)), "lowpass", fs=SR, output="sos") lp = signal.sosfilt(sos, raw, axis=0) t = np.arange(n) / SR w = (1.0 - np.exp(-t / max(decay / 5.0, 1e-3)))[:, None] blend = np.clip(w * (1.0 - 0.75 * brightness), 0.0, 1.0) ir = raw * (1.0 - blend) + lp * blend env = np.exp(-t / max(decay / 6.5, 1e-3)) # a couple of early reflections give the tail a sense of place for dt, g in ((0.011, 0.5), (0.023, 0.38), (0.037, 0.28), (0.061, 0.2)): k = n_of(dt) if k < n: env[k:] += g * np.exp(-t[:n - k] / max(decay / 2.2, 1e-3)) ir *= env[:, None] ir = np.vstack([np.zeros((pre, 2)), ir])[:n] rms = np.sqrt(np.mean(ir ** 2)) if rms > 1e-9: ir /= rms return ImpulseResponse(ir) def convolve_reverb(x: np.ndarray, ir: ImpulseResponse, mix: float = 0.3, tail: float = 0.0) -> np.ndarray: """Wet/dry reverb with an optional extra tail length.""" x = as_stereo(x) n = x.shape[0] total = n + n_of(tail) dry = np.zeros((total, 2)) dry[:n] = x wet = np.empty_like(dry) for ch in range(2): y = signal.fftconvolve(dry[:, ch], ir.ir[:, ch], mode="full")[:total] wet[:, ch] = y peak = np.max(np.abs(wet)) or 1.0 wet /= peak dry_peak = np.max(np.abs(dry)) or 1.0 wet *= dry_peak return dry * (1.0 - mix) + wet * mix def active_rms_db(x: np.ndarray, floor_db: float = -50.0) -> float: """RMS measured over the audible part only, so that long reverb tails do not make a cue look quieter than it is perceived.""" m = mono(np.abs(x)) idx = m > 10.0 ** (floor_db / 20.0) if int(idx.sum()) < 32: return -200.0 return 20.0 * math.log10(float(np.sqrt(np.mean(m[idx] ** 2)))) def master(x: np.ndarray, peak_db: float = -1.5, hp: float = 32.0, fade_in: float = 0.004, fade_out: float = 0.020, ceiling: float = 1.0, presence: float = 2.5, target_rms_db: float = -16.0, max_gain_db: float = 6.0) -> np.ndarray: """Shared final chain. rumble filter -> presence EQ -> soft clip -> fades -> normalise. Normalisation is loudness-aware: after peak-normalising, a bounded gain pulls the *active* RMS towards target_rms_db, so a sparse bell and a dense cluster land at a similar perceived level. The peak ceiling always wins, so nothing is pushed into clipping. """ x = as_stereo(np.asarray(x, dtype=np.float64)) if not np.isfinite(x).all(): x = np.nan_to_num(x) x = highpass(x, hp) if presence > 0.0: x = peaking_eq(x, 2900.0, presence, q=0.85) x = peaking_eq(x, 800.0, -1.0, q=1.1) # de-mud a touch if ceiling > 0: x = np.tanh(x / ceiling) * ceiling x = fade_edges(x, fade_in, fade_out) p = float(np.max(np.abs(x))) if p > 1e-9: x = x * (10.0 ** (peak_db / 20.0) / p) if target_rms_db > -100.0: cur = active_rms_db(x) if cur > -100.0: gain_db = float(np.clip(target_rms_db - cur, -max_gain_db, max_gain_db)) x = x * (10.0 ** (gain_db / 20.0)) p = float(np.max(np.abs(x))) limit = 10.0 ** (peak_db / 20.0) if p > limit: x = x * (limit / p) return x.astype(np.float32) # --------------------------------------------------------------------------- # # Theme palettes ("voices") # --------------------------------------------------------------------------- # @dataclass class Palette: key: str name: str comment: str comment_it: str = "" root: float = 110.0 # Hz of semitone 0 scale: list[int] = field(default_factory=list) # semitone degrees fm_ratio: float = 2.76 fm_index: float = 2.2 fm_index_tau: float = 0.14 brightness: float = 0.55 # lowpass multiplier applied to voices detune: float = 6.0 # cents reverb_decay: float = 2.2 reverb_mix: float = 0.28 reverb_brightness: float = 0.45 reverb_density: float = 1.0 noise: float = 0.25 tremolo_rate: float = 0.0 tremolo_depth: float = 0.0 degrade: float = 0.0 pluck_damp: float = 0.5 pluck_decay: float = 0.9965 shimmer_gain: float = 0.18 pad_attack: float = 0.6 gain: float = 1.0 # Scales the master presence EQ: palettes whose voices are already # narrowband/bright need less of it or they turn pungent. presence_scale: float = 1.0 peak_db: float = -1.5 seed: int = 0 ir: ImpulseResponse = field(default=None, repr=False) # type: ignore[assignment] def f(self, semi: float) -> float: return self.root * st(semi) def voice(self, semi: float, n: int, detune: float | None = None) -> np.ndarray: """Detuned two-operator FM voice, scaled to Nyquist safety.""" f = min(self.f(semi), SR / 2 / (self.fm_ratio + 2.0)) cents = self.detune if detune is None else detune a = fm_voice(f * st(cents / 100.0), n, self.fm_ratio, self.fm_index, self.fm_index_tau) b = fm_voice(f * st(-cents / 100.0), n, self.fm_ratio, self.fm_index * 0.8, self.fm_index_tau) y = 0.5 * (a + b) if self.brightness < 1.0: y = lowpass(y, self.brightness * 16000.0) return y def colour(self, x: np.ndarray) -> np.ndarray: """Per-theme degradation signature (radio artefacts etc.).""" if self.degrade > 0.0: x = bitcrush(x, bits=8, hold=2, mix=self.degrade * 0.6) if self.tremolo_depth > 0.0 and self.tremolo_rate > 0.0: x = tremolo(x, self.tremolo_rate, self.tremolo_depth) return x def space(self, x: np.ndarray, tail: float = 0.0, mix: float | None = None) -> np.ndarray: m = self.reverb_mix if mix is None else mix return convolve_reverb(x, self.ir, m, tail) PALETTES: dict[str, Palette] = { # Cold operational telemetry: metallic FM bells, sub drone, hall of a ship. "deepspace": Palette( key="deepspace", name="DeepSpace", comment="Deep-space operations sound theme for KDE Plasma " "(telemetry, hull, sub-drone)", comment_it="Tema sonoro di operazioni in spazio profondo per KDE Plasma " "(telemetria, scafo, sub-drone)", root=110.00, scale=[0, 3, 5, 7, 10], fm_ratio=2.76, fm_index=2.4, fm_index_tau=0.13, brightness=0.55, detune=6.0, reverb_decay=2.2, reverb_mix=0.30, reverb_brightness=0.45, reverb_density=0.85, noise=0.25, degrade=0.0, pluck_damp=0.5, pluck_decay=0.9960, shimmer_gain=0.14, pad_attack=0.55, seed=101, ), # Vast wonder: harmonic pads, wide detune, long shimmering tail. "interstellar": Palette( key="interstellar", name="Interstellar", comment="Interstellar voyage sound theme for KDE Plasma " "(wide pads, rising fifths, long tail)", comment_it="Tema sonoro del viaggio interstellare per KDE Plasma " "(pad ampi, quinte ascendenti, coda lunga)", root=146.83, scale=[0, 2, 4, 6, 7, 9, 11], fm_ratio=2.0, fm_index=1.15, fm_index_tau=0.45, brightness=0.72, detune=14.0, reverb_decay=4.2, reverb_mix=0.44, reverb_brightness=0.62, reverb_density=1.0, noise=0.12, degrade=0.0, pluck_damp=0.55, pluck_decay=0.9982, shimmer_gain=0.30, pad_attack=1.1, gain=1.0, seed=202, ), # Distant probe: narrowband radio, AM artefacts, telemetry beeps. "voyager": Palette( key="voyager", name="Voyager", comment="Deep-space probe sound theme for KDE Plasma " "(narrowband radio, telemetry, degraded transmission)", comment_it="Tema sonoro della sonda spaziale per KDE Plasma " "(radio a banda stretta, telemetria, trasmissione degradata)", root=164.81, scale=[0, 2, 5, 7, 9], fm_ratio=3.5, fm_index=3.0, fm_index_tau=0.09, brightness=0.40, detune=4.0, reverb_decay=1.7, reverb_mix=0.24, reverb_brightness=0.35, reverb_density=0.7, noise=0.34, tremolo_rate=9.0, tremolo_depth=0.30, degrade=0.45, pluck_damp=0.62, pluck_decay=0.9930, shimmer_gain=0.10, pad_attack=0.4, presence_scale=0.32, seed=303, ), } # --------------------------------------------------------------------------- # # Motifs — reusable building blocks, the theme's "instrument set" # --------------------------------------------------------------------------- # def m_ping(p: Palette, semis=(0,), dur=1.1, note_dur=None, gap=0.0, tau=0.35, stagger=0.0) -> np.ndarray: """Metallic FM bell, optionally a small arpeggio.""" nd = note_dur or dur total = n_of(dur + max(stagger, gap) * (len(semis) - 1)) out = np.zeros((total, 2)) for i, s in enumerate(semis): n = n_of(nd) if i == 0: n = total y = p.voice(s, n, detune=p.detune) * env_ad(n, 0.003, tau) if p.shimmer_gain > 0: y = y + p.shimmer_gain * p.voice(s + 12, n, detune=p.detune * 1.5) \ * env_ad(n, 0.002, tau * 0.6) off = n_of(i * (stagger if stagger else gap)) y = pan(y, 0.12 if i % 2 else -0.08) seg = min(len(y), total - off) if seg > 0: out[off:off + seg] += y[:seg] return out def m_chime(p: Palette, semis=(0, 7, 12), dur=1.8, stagger=0.13, note_dur=1.4, tau=None) -> np.ndarray: """Ascending arpeggio of bells — the theme's 'success' signature.""" tau = tau if tau is not None else 0.45 total = n_of(dur) out = np.zeros((total, 2)) for i, s in enumerate(semis): off = n_of(i * stagger) n = total - off if n <= 0: continue y = p.voice(s, n) * env_ad(n, 0.004, tau) if p.shimmer_gain > 0: y = y + p.shimmer_gain * p.voice(s + 24, n) * env_ad(n, 0.002, tau * 2.0) pos = -0.5 + (i / max(len(semis) - 1, 1)) * 1.0 out[off:] += pan(y, pos) return out def m_blip(p: Palette, semi=0, dur=0.08, shape="tri", tau=0.035) -> np.ndarray: n = n_of(dur) f = min(p.f(semi), 9000.0) if shape == "sq": y = 0.5 * osc_square(f, n, duty=0.3) + 0.5 * osc_sine(f, n) else: y = 0.7 * osc_tri(f, n) + 0.3 * osc_sine(f, n) y = y * env_ad(n, 0.0015, tau) return pan(y, 0.0) def m_tick(p: Palette, dur=0.05, semi=24) -> np.ndarray: n = n_of(dur) rng = rng_for(p.seed + 11) y = bandpass(rng.standard_normal(n), 900.0, 5200.0, order=2) \ * env_ad(n, 0.0006, 0.008) y += 0.4 * p.voice(semi, n) * env_ad(n, 0.0008, 0.015) return pan(y, -0.05) def m_knock(p: Palette, semis=(0, 7), dur=0.45, spread=0.11, tau=0.06) -> np.ndarray: """Percussive wooden knocks — 'a module docked'.""" total = n_of(dur) out = np.zeros((total, 2)) rng = rng_for(p.seed + 23) for i, s in enumerate(semis): off = n_of(i * spread) n = total - off if n <= 0: continue body = p.voice(s, n, detune=2.0) * env_ad(n, 0.001, tau) click = bandpass(rng.standard_normal(n), 300.0, 2600.0) \ * env_ad(n, 0.0006, 0.010) out[off:] += pan(0.8 * body + 0.5 * click, -0.3 + 0.6 * i) return out def m_whoosh(p: Palette, dur=0.7, up=True, flo=180.0, fhi=5200.0, gain=1.0, semi=None) -> np.ndarray: """Filtered-noise airlock sweep.""" n = n_of(dur) a, b = (flo, fhi) if up else (fhi, flo) y = noise_sweep(n, a, b, seed=p.seed + 31, segments=44, res=2.2) y = highpass(y, 120.0) y = y * env_swell(n, 0.03 if up else 0.01, 0.14 if up else 0.28, curve=1.4) y = y * gain + 0.22 * p.colour(p.voice(semi if semi is not None else 0, n)) return haas(pan(y, 0.0), 9.0, mix=0.5) def m_sub(p: Palette, semi=-24, dur=0.9, drop=0.35, tau=0.22) -> np.ndarray: n = n_of(dur) y = sub_hit(max(p.f(semi), 28.0), n, drop=drop, tau=tau) y = y + 0.25 * p.voice(semi + 12, n, detune=3.0) * env_ad(n, 0.002, tau * 0.6) return pan(y, 0.0) def m_rumble(p: Palette, semi=-24, dur=1.6, attack=0.35) -> np.ndarray: n = n_of(dur) f = max(p.f(semi), 26.0) t = t_of(n) phase = np.cumsum(TWO_PI * (f * (1.0 + 0.02 * np.sin(TWO_PI * 0.7 * t))) / SR) y = np.sin(phase) * env_swell(n, attack, 0.5, curve=2.2) y += 0.3 * lowpass(osc_additive(f * 1.5, n, 6, 1.4), 400.0) \ * env_swell(n, attack * 1.4, 0.6) return pan(y, 0.0) def m_pad(p: Palette, semis=(0, 7, 12), dur=3.0, attack=None, release=0.9, detune=None) -> np.ndarray: """Slow detuned additive pad — the theme's 'vast space' signature.""" n = n_of(dur) atk = p.pad_attack if attack is None else attack det = p.detune if detune is None else detune out = np.zeros(n) for s in semis: f = min(p.f(s), SR / 2 / 8) out += 0.6 * osc_additive(f * st(det / 100.0), n, 9, 1.35) \ + 0.5 * osc_additive(f * st(-det / 100.0), n, 9, 1.35) out += 0.25 * osc_sine(f * 0.5, n) out /= max(len(semis), 1) out = out * env_swell(n, atk, release, curve=1.7) if p.brightness < 1.0: out = lowpass(out, p.brightness * 9000.0) return haas(pan(out, 0.0), 16.0, mix=0.55) def m_shimmer(p: Palette, semis=(12, 19, 24), dur=1.6, stagger=0.05) -> np.ndarray: """High sparkling cluster of tiny bells.""" total = n_of(dur) out = np.zeros((total, 2)) for i, s in enumerate(semis): off = n_of(i * stagger) n = total - off if n <= 0: continue y = p.voice(s + 24, n, detune=3.0) * env_ad(n, 0.002, 0.30 - 0.02 * i) out[off:] += pan(y * (0.5 ** i), -0.5 + i * 0.4) return out def m_gliss(p: Palette, n_from=0, n_to=24, dur=1.4, steps=14, tau=0.30) -> np.ndarray: """Fast bell glissando — boarding / leaving.""" total = n_of(dur) out = np.zeros((total, 2)) for i in range(steps): frac = i / max(steps - 1, 1) s = n_from + (n_to - n_from) * frac off = n_of(frac * dur * 0.72) n = total - off if n <= 0: continue y = p.voice(s, n, detune=p.detune) * env_ad(n, 0.002, tau) out[off:] += pan(y * (0.55 + 0.45 * frac), -0.6 + 1.2 * frac) return out def m_pluck_arp(p: Palette, semis=(0, 4, 7, 12), dur=1.8, stagger=0.08, damp=None, decay=None) -> np.ndarray: total = n_of(dur) out = np.zeros((total, 2)) for i, s in enumerate(semis): off = n_of(i * stagger) n = total - off if n <= 0: continue f = min(p.f(s), SR / 2 / 4) y = pluck(f, n, damp=p.pluck_damp if damp is None else damp, decay=p.pluck_decay if decay is None else decay, seed=p.seed + i) y = lowpass(y, p.brightness * 7000.0) * env_ad(n, 0.002, 0.7) out[off:] += pan(y * 0.7, -0.5 + i * 0.33) return out def m_radio(p: Palette, semis=(0, 7), dur=1.1, mod=None, depth=None, reps=2, rate=0.16) -> np.ndarray: """Narrowband AM radio burst: the theme's transmission signature.""" total = n_of(dur) out = np.zeros((total, 2)) rng = rng_for(p.seed + 41) rate = 10.0 if mod is None else mod depth = 0.35 if depth is None else depth for i, s in enumerate(semis): off = n_of(i * rate) n = total - off if n <= 0: continue f = p.f(s) y = osc_sine(f, n) + 0.5 * osc_sine(f * 1.5, n) + 0.3 * osc_sine(f * 2.5, n) y = bandpass(y, f * 0.8, min(f * 3.2, 12000.0), order=2) y = tremolo(y, 11.0, depth, phase=i * 1.1, shape="square") y = lowpass(y, 7000.0) nfloor = bandpass(rng.standard_normal(n), 200.0, 6000.0) * 0.12 y = y + nfloor y = y * env_ad(n, 0.006, 0.09) y = bitcrush(y, bits=7, hold=2, mix=0.35) out[off:] += pan(y, -0.25 + 0.5 * i) # occasional extra burst for k in range(max(0, reps - len(semis))): off = n_of((len(semis) + k) * rate) n = total - off if n <= 0: continue y = bandpass(osc_sine(p.f(semis[0]), n), 200.0, 8000.0) * env_ad(n, 0.004, 0.07) out[off:] += pan(bitcrush(y, 7, 2, 0.4) * 0.8, 0.1) return out def m_telemetry(p: Palette, semis=(12, 12, 19), dur=1.2, gap=0.16, tau=0.03) -> np.ndarray: """Pattern of short telemetry beeps.""" total = n_of(dur) out = np.zeros((total, 2)) for i, s in enumerate(semis): off = n_of(i * gap) n = total - off if n <= 0: continue y = osc_sine(p.f(s), n) + 0.4 * osc_sine(p.f(s) * 2, n) y = lowpass(y, 9000.0) * env_ad(n, 0.0015, tau) y = p.colour(y) out[off:] += pan(y, 0.0) return out def m_cluster(p: Palette, semis=(0, 1, 6), dur=1.3, tau=0.30, sub=True) -> np.ndarray: """Dissonant FM cluster = the theme's 'something is wrong' signature.""" n = n_of(dur) out = np.zeros(n) for s in semis: out += p.voice(s, n, detune=p.detune * 1.6) * env_ad(n, 0.002, tau) out /= max(len(semis) ** 0.5, 1.0) if sub: out = out + 0.6 * sub_hit(max(p.f(-24), 28.0), n, drop=0.4, tau=0.16) return pan(out, 0.0) def m_alarm(p: Palette, semi=7, dur=1.8, reps=4, gap=0.30, tau=0.10) -> np.ndarray: """Repeating rising two-tone alert.""" total = n_of(dur) out = np.zeros((total, 2)) for i in range(reps): off = n_of(i * gap) n = total - off if n <= 0: break s = semi + (0 if i % 2 == 0 else 3) y = p.voice(s, n) * env_ad(n, 0.003, tau) if p.shimmer_gain > 0: y = y + p.shimmer_gain * p.voice(s + 12, n) * env_ad(n, 0.002, tau) out[off:] += pan(y * (0.9 - 0.08 * i), 0.0) return out def m_scan(p: Palette, semi=0, dur=1.6, depth=1.0) -> np.ndarray: """Sonar sweep: a ping whose band glides down (radar/probe return).""" n = n_of(dur) up = n_of(dur * 0.35) body = p.voice(semi + 12, up, detune=2.0) * env_ad(up, 0.003, 0.12) sweep = noise_sweep(n, min(p.f(semi + 12) * 3.0, 9000.0), max(p.f(semi) * 1.2, 200.0), seed=p.seed + 53, segments=40) sweep = sweep * env_swell(n, 0.01, 0.5, curve=1.6) * 0.5 * depth y = np.zeros(n) y[:up] += body y += sweep return haas(pan(y, 0.0), 12.0, mix=0.5) def m_noise_burst(p: Palette, dur=0.35, flo=250.0, fhi=4000.0, up=False, semi=None) -> np.ndarray: n = n_of(dur) a, b = (flo, fhi) if up else (fhi, flo) y = noise_sweep(n, a, b, seed=p.seed + 61, segments=30, res=1.6) y = y * env_ad(n, 0.002, dur / 3.5) if semi is not None: y = y + 0.5 * p.voice(semi, n) * env_ad(n, 0.002, 0.05) return pan(y, 0.0) # --------------------------------------------------------------------------- # # Sound map — the freedesktop/KDE event names this theme provides # --------------------------------------------------------------------------- # # Each entry: sound-name -> (motif, kwargs) SOUNDS: dict[str, tuple] = { # --- generic bells / attention ----------------------------------------- "bell": (m_ping, dict(semis=(0,), dur=1.1, tau=0.35)), "bell-terminal": (m_blip, dict(semi=12, dur=0.09)), "bell-window-system": (m_ping, dict(semis=(0, 7), dur=0.55, stagger=0.13)), "window-attention": (m_ping, dict(semis=(0, 7, 0), dur=0.85, stagger=0.20)), "window-question": (m_ping, dict(semis=(7, 12), dur=0.7, stagger=0.16)), # --- dialogs ------------------------------------------------------------ "dialog-information": (m_ping, dict(semis=(0, 7), dur=0.8, stagger=0.15)), "dialog-question": (m_ping, dict(semis=(7, 12), dur=0.8, stagger=0.16)), "dialog-warning": (m_ping, dict(semis=(5, 3), dur=0.9, stagger=0.17)), "dialog-warning-auth": (m_telemetry, dict(semis=(5, 3, 5), dur=0.9, gap=0.18)), "dialog-error": (m_cluster, dict(semis=(0, 1, 6), dur=1.2)), "dialog-error-serious": (m_cluster, dict(semis=(-12, -11, -6), dur=1.5)), "dialog-error-critical": (m_cluster, dict(semis=(-12, -11, -6), dur=2.0)), "dialog-error-veryserious": (m_alarm, dict(semi=-9, dur=2.4, reps=5, gap=0.32)), "dialog-special": (m_chime, dict(semis=(0, 7, 12, 19), dur=1.6)), # --- outcomes ----------------------------------------------------------- "complete": (m_ping, dict(semis=(0, 12), dur=0.55, stagger=0.12)), "outcome-success": (m_chime, dict(semis=(0, 7, 12), dur=1.4)), "outcome-failure": (m_cluster, dict(semis=(0, 1), dur=1.0)), "completion-success": (m_chime, dict(semis=(0, 4, 7, 12), dur=1.9)), "completion-partial": (m_ping, dict(semis=(0, 5), dur=0.8, stagger=0.16)), "completion-fail": (m_ping, dict(semis=(3, -2), dur=1.0, stagger=0.18)), "completion-rotation": (m_ping, dict(semis=(0, 5, 7), dur=1.1, stagger=0.20)), "complete-media-burn": (m_chime, dict(semis=(0, 4, 12), dur=2.0, stagger=0.18)), "complete-media-error": (m_cluster, dict(semis=(-7, -6), dur=1.4)), # --- session ------------------------------------------------------------ "desktop-login": (m_gliss, dict(n_from=-12, n_to=19, dur=2.4, steps=16)), "desktop-logout": (m_gliss, dict(n_from=12, n_to=-14, dur=1.8, steps=13)), "service-login": (m_ping, dict(semis=(0, 7), dur=1.0, stagger=0.14)), "service-logout": (m_ping, dict(semis=(7, 0), dur=0.9, stagger=0.14)), # --- devices / power ---------------------------------------------------- "device-added": (m_knock, dict(semis=(0, 7), dur=0.5, spread=0.12)), "device-removed": (m_knock, dict(semis=(0, -5), dur=0.5, spread=0.12)), "power-plug": (m_whoosh, dict(dur=0.6, up=True, semi=7)), "power-unplug": (m_whoosh, dict(dur=0.7, up=False, semi=-7)), "battery-full": (m_ping, dict(semis=(0, 12), dur=0.7, stagger=0.15)), "battery-caution": (m_ping, dict(semis=(12, 5), dur=0.85, stagger=0.17)), "battery-low": (m_ping, dict(semis=(7, 0), dur=0.85, stagger=0.17)), "suspend-error": (m_cluster, dict(semis=(-12, -6, -5), dur=1.6)), # --- messaging ---------------------------------------------------------- "message": (m_ping, dict(semis=(7,), dur=0.7, tau=0.28)), "message-attention": (m_radio, dict(semis=(0, 7, 0), dur=1.0)), "message-highlight": (m_ping, dict(semis=(12, 12), dur=0.6, stagger=0.16, tau=0.20)), "message-new-instant": (m_ping, dict(semis=(0, 7), dur=0.7, stagger=0.12)), "message-sent-instant": (m_whoosh, dict(dur=0.35, up=True, flo=400.0, fhi=6000.0)), "message-new-email": (m_chime, dict(semis=(0, 7), dur=1.3, stagger=0.14)), "message-contact-in": (m_ping, dict(semis=(0, 9), dur=0.8, stagger=0.15)), "message-contact-out": (m_ping, dict(semis=(9, 0), dur=0.8, stagger=0.15)), "phone-incoming-call": (m_alarm, dict(semi=7, dur=2.2, reps=5, gap=0.34, tau=0.09)), "phone-outgoing-calling": (m_radio, dict(semis=(0,), dur=1.0, reps=3, rate=0.22)), "phone-outgoing-busy": (m_telemetry, dict(semis=(0, 0, 0), dur=1.2, gap=0.22)), # --- window / desktop --------------------------------------------------- "audio-volume-change": (m_blip, dict(semi=12, dur=0.07, tau=0.03)), "audio-test-signal": (m_telemetry, dict(semis=(0, 12, 0), dur=1.0, gap=0.20)), "button-pressed": (m_tick, dict(dur=0.05, semi=24)), "button-pressed-modifier": (m_tick, dict(dur=0.06, semi=29)), "camera-shutter": (m_noise_burst, dict(dur=0.16, flo=600.0, fhi=7000.0, semi=19)), "screen-capture": (m_noise_burst, dict(dur=0.20, flo=500.0, fhi=8000.0, semi=24)), "trash-empty": (m_noise_burst, dict(dur=0.75, flo=180.0, fhi=3600.0, up=False)), "media-insert-request": (m_whoosh, dict(dur=0.55, up=True, semi=0)), "alarm-clock-elapsed": (m_alarm, dict(semi=0, dur=2.4, reps=6, gap=0.30, tau=0.12)), "network-connectivity-established": (m_chime, dict(semis=(0, 5, 7), dur=1.4)), "network-connectivity-lost": (m_ping, dict(semis=(7, 0), dur=0.9, stagger=0.16)), # --- games -------------------------------------------------------------- "game-over-winner": (m_chime, dict(semis=(0, 4, 7, 12, 19), dur=2.6, stagger=0.16)), "game-over-loser": (m_cluster, dict(semis=(-12, -11, -6), dur=2.2, tau=0.45)), "bell-window-system-attention": (m_ping, dict(semis=(0, 7), dur=0.6, stagger=0.14)), } DEMO_SEQUENCE = [ "bell", "dialog-information", "message-new-instant", "device-added", "power-plug", "completion-success", "dialog-warning", "dialog-error", "trash-empty", "desktop-login", ] # --------------------------------------------------------------------------- # # Reverb bus # --------------------------------------------------------------------------- # # Short, frequent cues (chats, volume, clicks) stay almost dry so that a burst # of notifications cannot pile up; long, rare, dramatic cues get the full hall. # Values: (wet scale, high-pass Hz, extra tail seconds) DRY = (0.34, 130.0, 0.06) SMALL = (0.50, 105.0, 0.10) MEDIUM = (0.80, 60.0, 0.20) WIDE = (1.05, 40.0, 0.32) VAST = (1.20, 30.0, 0.45) WET_BY_MOTIF = { m_tick: DRY, m_blip: SMALL, m_telemetry: SMALL, m_radio: (0.66, 85.0, 0.16), m_ping: MEDIUM, m_knock: MEDIUM, m_chime: (0.92, 50.0, 0.26), m_noise_burst: MEDIUM, m_whoosh: WIDE, m_pluck_arp: (0.90, 45.0, 0.28), m_alarm: (0.90, 48.0, 0.30), m_shimmer: WIDE, m_cluster: WIDE, m_scan: (1.10, 38.0, 0.36), m_sub: (1.12, 30.0, 0.36), m_rumble: (1.15, 28.0, 0.42), m_gliss: VAST, m_pad: VAST, } # Per-sound overrides: name -> (wet scale, high-pass Hz, extra tail s) WET_OVERRIDE: dict[str, tuple] = { "bell": (0.62, 90.0, 0.16), "bell-terminal": DRY, "audio-volume-change": DRY, "button-pressed": DRY, "button-pressed-modifier": DRY, "message-new-instant": SMALL, "message-sent-instant": SMALL, "message-contact-in": SMALL, "message-contact-out": SMALL, "message-highlight": SMALL, "message": SMALL, "message-new-email": (0.62, 80.0, 0.18), "camera-shutter": SMALL, "screen-capture": SMALL, "trash-empty": (0.70, 70.0, 0.20), "battery-low": (0.60, 90.0, 0.16), "battery-caution": (0.60, 90.0, 0.16), "device-added": (0.55, 95.0, 0.14), "device-removed": (0.55, 95.0, 0.14), "power-plug": (0.72, 75.0, 0.18), "power-unplug": (0.72, 75.0, 0.18), "completion-success": (0.85, 55.0, 0.24), "completion-partial": (0.60, 85.0, 0.16), "completion-rotation": (0.62, 85.0, 0.18), "complete": (0.58, 88.0, 0.16), "outcome-success": (0.80, 58.0, 0.22), "desktop-login": (1.15, 30.0, 0.50), "desktop-logout": (1.08, 32.0, 0.40), "service-login": (0.70, 72.0, 0.20), "service-logout": (0.70, 72.0, 0.20), "dialog-information": (0.66, 80.0, 0.18), "dialog-question": (0.66, 80.0, 0.18), "dialog-warning": (0.72, 75.0, 0.20), "window-attention": (0.66, 80.0, 0.18), "window-question": (0.66, 80.0, 0.18), "phone-incoming-call": (0.80, 60.0, 0.24), "alarm-clock-elapsed": (0.82, 58.0, 0.26), "game-over-winner": (1.10, 36.0, 0.42), "game-over-loser": (1.08, 34.0, 0.40), "dialog-error": (0.95, 46.0, 0.28), "dialog-error-serious": (1.00, 42.0, 0.30), "dialog-error-critical": (1.02, 40.0, 0.32), "dialog-error-veryserious": (0.95, 44.0, 0.30), } # --------------------------------------------------------------------------- # # Rendering # --------------------------------------------------------------------------- # def sound_meta(name: str, motif) -> tuple: """(wet scale, high-pass Hz, extra tail s, presence dB) for a sound. Presence is derived from the reverb class: dry clicks get a gentle lift so the 2.9 kHz emphasis does not turn their transients metallic. """ m = WET_OVERRIDE.get(name) or WET_BY_MOTIF.get(motif, MEDIUM) scale, hp, tail = m[:3] presence = m[3] if len(m) > 3 else 1.0 + 1.5 * min(scale, 1.2) return scale, hp, tail, presence def render_sound(p: Palette, name: str) -> np.ndarray: motif, kw = SOUNDS[name] dry = fade_edges(motif(p, **kw), 0.0015, 0.035) scale, hp, tail, presence = sound_meta(name, motif) wet = min(0.55, p.reverb_mix * scale) y = p.space(dry, tail=tail, mix=wet) return master(y, peak_db=p.peak_db, hp=hp, presence=presence * p.presence_scale) def render_demo(p: Palette, gap: float = 0.12, cap: float = 1.30) -> np.ndarray: """theme-demo: the KCM preview — a montage of the theme's signatures.""" parts: list[np.ndarray] = [] for name in DEMO_SEQUENCE: motif, kw = SOUNDS[name] k = dict(kw) if "dur" in k and k["dur"] > cap: k["dur"] = cap y = mono(motif(p, **k)) y = y[:n_of(cap)] parts.append(fade_edges(y, 0.002, 0.12)) pieces: list[np.ndarray] = [] for part in parts: pieces.append(part) pieces.append(np.zeros(n_of(gap))) y = np.concatenate(pieces) if pieces else np.zeros(n_of(1.0)) y = as_stereo(lowpass(y, 17000.0)) y = p.space(y, tail=0.6, mix=min(0.55, p.reverb_mix + 0.08)) return master(y, peak_db=p.peak_db, fade_out=0.25, presence=1.5 * p.presence_scale, target_rms_db=-100.0) def encode_oga(wav_path: Path, oga_path: Path, quality: int = 5) -> None: ff = shutil.which("ffmpeg") if not ff: raise RuntimeError("ffmpeg non trovato: necessario per codificare Ogg Vorbis") cmd = [ff, "-hide_banner", "-loglevel", "error", "-y", "-i", str(wav_path), "-c:a", "libvorbis", "-q:a", str(quality), "-ar", str(SR), "-ac", "2", "-f", "ogg", str(oga_path)] subprocess.run(cmd, check=True) def write_wav(path: Path, x: np.ndarray) -> None: x = np.clip(as_stereo(x), -1.0, 1.0) wavfile.write(str(path), SR, (x * 32767.0).astype(np.int16)) INDEX_THEME_TMPL = """[Sound Theme] Name={name} Name[it]={name_it} Comment={comment} Comment[it]={comment_it} Inherits=freedesktop Directories=stereo Example=theme-demo [stereo] OutputProfile=stereo """ def build_theme(p: Palette, out_root: Path, quality: int = 5, only: list[str] | None = None) -> dict: theme_dir = out_root / p.name stereo = theme_dir / "stereo" stereo.mkdir(parents=True, exist_ok=True) p.ir = make_ir(p.reverb_decay, seed=p.seed, brightness=p.reverb_brightness, density=p.reverb_density) names = list(SOUNDS) if not only else only stats = {} with tempfile.TemporaryDirectory() as td: tmp = Path(td) for i, name in enumerate(names, 1): y = render_sound(p, name) w = tmp / f"{name}.wav" write_wav(w, y) encode_oga(w, stereo / f"{name}.oga", quality) stats[name] = (len(y) / SR, float(np.max(np.abs(y))), float(np.sqrt(np.mean(y ** 2)))) print(f" [{i:>3}/{len(names)}] {name:<36} {len(y)/SR:5.2f}s", flush=True) demo = render_demo(p) with tempfile.TemporaryDirectory() as td: w = Path(td) / "theme-demo.wav" write_wav(w, demo) encode_oga(w, stereo / "theme-demo.oga", quality) stats["theme-demo"] = (len(demo) / SR, float(np.max(np.abs(demo))), float(np.sqrt(np.mean(demo ** 2)))) print(f" [demo] theme-demo{'':<27}{len(demo)/SR:5.2f}s", flush=True) (theme_dir / "index.theme").write_text( INDEX_THEME_TMPL.format( name=p.name, name_it={"DeepSpace": "Spazio Profondo", "Interstellar": "Interstellare", "Voyager": "Voyager"}.get(p.name, p.name), comment=p.comment, comment_it=p.comment_it or p.comment, ), encoding="utf-8") return stats def main() -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--theme", required=True, choices=sorted(PALETTES) + ["all"], help="which palette to build") ap.add_argument("--out", required=True, type=Path, help="output root (theme dir is created inside)") ap.add_argument("--quality", type=int, default=5, help="Ogg Vorbis quality (default 5)") ap.add_argument("--only", nargs="*", default=None, help="render only these sound names (debug)") args = ap.parse_args() keys = sorted(PALETTES) if args.theme == "all" else [args.theme] totals = {} for k in keys: p = PALETTES[k] print(f"== {p.name} ({p.key}) ==", flush=True) totals[k] = build_theme(p, args.out, args.quality, args.only) print("\n== riepilogo ==") for k, s in totals.items(): dur = sum(v[0] for v in s.values()) pk = max(v[1] for v in s.values()) print(f"{k:<14} {len(s):>3} suoni {dur:6.1f}s totali " f"picco max {pk:.3f} ({20*math.log10(max(pk,1e-9)):.2f} dBFS)") return 0 if __name__ == "__main__": sys.exit(main())