Files
Matteo Benedetto 8741d7a65f feat: Tema sonoro Interstellar per KDE Plasma
- index.theme con Directories=stereo, OutputProfile=stereo,
  Inherits=freedesktop ed Example=theme-demo
- 61 cue sintetizzati da zero (Ogg Vorbis 48 kHz stereo) piu' il montaggio
  di anteprima theme-demo.oga
- tools/gen_sounds.py: motore di sintesi riproducibile (voci FM armoniche,
  pad additivi ampiamente detunati, shimmer lidio, riverbero da 4,2 s)
- tools/verify_theme.py: verifica della spec, igiene audio (clipping, DC,
  click ai bordi) e copertura dei nomi suono richiesti dai notifyrc di KDE
- preview/index.html: player nel browser per ogni cue del tema
- Makefile, install.sh, LICENSES (audio CC-BY-SA-4.0, codice BSD-2-Clause)
2026-09-20 16:12:47 +02:00

1148 lines
44 KiB
Python

#!/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:
# <out>/<ThemeName>/index.theme
# <out>/<ThemeName>/stereo/<sound-name>.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())