- 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)
1148 lines
44 KiB
Python
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())
|