- 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 (radio a banda stretta con AM a onda quadra, quantizzazione a 7 bit, beep di telemetria, sweep) - 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)
318 lines
11 KiB
Python
318 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: BSD-2-Clause
|
|
#
|
|
# verify_theme.py - validate a freedesktop/KDE sound theme.
|
|
#
|
|
# Checks, per audio file:
|
|
# * decodes with ffmpeg, is Ogg Vorbis, 48 kHz, stereo
|
|
# * no clipping, no DC offset, no click at the edges
|
|
# * duration within a sane range for a desktop cue
|
|
# and, per theme:
|
|
# * index.theme parses and declares [Sound Theme] + a matching section for
|
|
# every entry of Directories
|
|
# * Inherits/Example point at things that exist
|
|
# * every sound requested by the installed KDE notifyrc files is covered
|
|
# (directly or via the Inherits chain)
|
|
#
|
|
# Copyright (c) 2026 enne2
|
|
"""Verify a sound theme against the freedesktop spec and KDE usage."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import configparser
|
|
import math
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from scipy.io import wavfile
|
|
|
|
SR = 48000
|
|
CLIP_THRESHOLD = 0.999
|
|
DC_LIMIT = 0.005
|
|
EDGE_LIMIT = 0.02 # absolute amplitude allowed in the first/last sample
|
|
MIN_DUR = 0.05
|
|
MAX_DUR = 4.5
|
|
PEAK_MIN_DB = -14.0
|
|
PEAK_MAX_DB = -0.5
|
|
|
|
|
|
def decode(path: Path) -> np.ndarray:
|
|
"""Decode any audio file to float64 stereo via ffmpeg."""
|
|
ff = shutil.which("ffmpeg")
|
|
if not ff:
|
|
raise RuntimeError("ffmpeg non trovato")
|
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as t:
|
|
tmp = t.name
|
|
try:
|
|
subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-y",
|
|
"-i", str(path), "-ac", "2", "-ar", str(SR), tmp],
|
|
check=True)
|
|
sr, x = wavfile.read(tmp)
|
|
finally:
|
|
os.unlink(tmp)
|
|
if x.dtype.kind == "i":
|
|
x = x.astype(np.float64) / (2 ** (8 * x.dtype.itemsize - 1))
|
|
return x
|
|
|
|
|
|
def probe(path: Path) -> dict:
|
|
ffp = shutil.which("ffprobe") or "ffprobe"
|
|
out = subprocess.run(
|
|
[ffp, "-v", "error", "-select_streams", "a:0",
|
|
"-show_entries", "stream=codec_name,sample_rate,channels",
|
|
"-show_entries", "format=duration",
|
|
"-of", "default=nw=1:nk=1", str(path)],
|
|
capture_output=True, text=True, check=True).stdout.split()
|
|
return {"codec": out[0], "sample_rate": int(out[1]),
|
|
"channels": int(out[2]), "duration": float(out[3])}
|
|
|
|
|
|
def check_audio(path: Path, is_example: bool = False) -> tuple[list[str], list[str], dict]:
|
|
errs: list[str] = []
|
|
warns: list[str] = []
|
|
info = probe(path)
|
|
if info["codec"] != "vorbis":
|
|
errs.append(f"codec {info['codec']} != vorbis")
|
|
if info["sample_rate"] != SR:
|
|
errs.append(f"sample rate {info['sample_rate']} != {SR}")
|
|
if info["channels"] != 2:
|
|
errs.append(f"channels {info['channels']} != 2")
|
|
|
|
x = decode(path)
|
|
mono = x.mean(axis=1)
|
|
peak = float(np.max(np.abs(x)))
|
|
rms = float(np.sqrt(np.mean(x ** 2)))
|
|
peak_db = 20 * math.log10(peak) if peak > 0 else -200.0
|
|
dc = float(mono.mean())
|
|
start = float(np.mean(np.abs(x[:8])))
|
|
end = float(np.mean(np.abs(x[-8:])))
|
|
clipped = int((np.abs(x) >= CLIP_THRESHOLD).sum())
|
|
silent_tail = 0
|
|
thr = 10 ** (-60 / 20)
|
|
for s in range(len(mono) - 1, 0, -1):
|
|
if abs(mono[s]) > thr:
|
|
silent_tail = (len(mono) - 1 - s) / SR
|
|
break
|
|
|
|
if clipped:
|
|
errs.append(f"clipping: {clipped} campioni a >= {CLIP_THRESHOLD}")
|
|
if abs(dc) > DC_LIMIT:
|
|
errs.append(f"DC offset {dc:+.4f} (limite {DC_LIMIT})")
|
|
if start > EDGE_LIMIT:
|
|
errs.append(f"attacco non a zero ({start:.4f}) -> click")
|
|
if end > EDGE_LIMIT:
|
|
warns.append(f"coda non a zero ({end:.4f})")
|
|
if info["duration"] < MIN_DUR:
|
|
errs.append(f"durata {info['duration']:.3f}s < {MIN_DUR}s")
|
|
if info["duration"] > MAX_DUR and not is_example:
|
|
warns.append(f"durata {info['duration']:.2f}s > {MAX_DUR}s")
|
|
if not (PEAK_MIN_DB <= peak_db <= PEAK_MAX_DB):
|
|
warns.append(f"picco {peak_db:+.2f} dBFS fuori range "
|
|
f"[{PEAK_MIN_DB}, {PEAK_MAX_DB}]")
|
|
if silent_tail > 0.5:
|
|
warns.append(f"coda di silenzio {silent_tail:.2f}s")
|
|
|
|
return errs, warns, {"dur": info["duration"], "peak_db": peak_db,
|
|
"rms_db": 20 * math.log10(rms) if rms > 0 else -200.0,
|
|
"dc": dc}
|
|
|
|
|
|
SECT = re.compile(r"^\[(.+)\]$")
|
|
|
|
|
|
def parse_index(path: Path) -> tuple[dict, dict]:
|
|
"""Minimal ini parser that tolerates KDE's repeated/localised keys.
|
|
|
|
The metadata lives in the mandatory ``[Sound Theme]`` group (not in a
|
|
pre-section area), so that group is returned as the "top" dict; a bare
|
|
pre-section area would be merged in too, for robustness.
|
|
"""
|
|
top: dict[str, str] = {}
|
|
sections: dict[str, dict] = {}
|
|
cur = None
|
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
line = raw.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
m = SECT.match(line)
|
|
if m:
|
|
cur = m.group(1)
|
|
sections.setdefault(cur, {})
|
|
continue
|
|
if "=" not in line:
|
|
continue
|
|
k, v = line.split("=", 1)
|
|
k, v = k.strip(), v.strip()
|
|
if cur is None:
|
|
top[k] = v
|
|
else:
|
|
sections[cur][k] = v
|
|
root = sections.get("Sound Theme") or sections.get("SoundTheme")
|
|
if root is None:
|
|
for name, body in sections.items():
|
|
if "Directories" in body:
|
|
root = body
|
|
break
|
|
if root:
|
|
top = {**top, **root}
|
|
return top, sections
|
|
|
|
|
|
def resolve(name: str, dirs: list[Path]) -> Path | None:
|
|
"""Find theme `name` inside one of the `sounds` base directories."""
|
|
for base in dirs:
|
|
cand = base / name
|
|
if cand.is_dir():
|
|
return cand
|
|
if base.name == name and base.is_dir():
|
|
return base
|
|
return None
|
|
return None
|
|
|
|
|
|
def theme_dirs_chain(theme: Path, search: list[Path]) -> list[Path]:
|
|
"""Follow Inherits recursively, returning the lookup order."""
|
|
chain: list[Path] = [theme]
|
|
seen = {theme.resolve()}
|
|
queue = [theme]
|
|
while queue:
|
|
cur = queue.pop(0)
|
|
top, _ = parse_index(cur / "index.theme")
|
|
for parent in (top.get("Inherits") or "").split(","):
|
|
parent = parent.strip()
|
|
if not parent:
|
|
continue
|
|
d = resolve(parent, search)
|
|
if d and d.resolve() not in seen:
|
|
seen.add(d.resolve())
|
|
chain.append(d)
|
|
queue.append(d)
|
|
return chain
|
|
|
|
|
|
def kde_requested_names(search: list[Path]) -> set[str]:
|
|
"""Sound names Plasma/KDE applications actually ask for."""
|
|
names: set[str] = set()
|
|
roots = [Path("/usr/share/knotifications6"), Path("/usr/share/knotifications5")]
|
|
for root in roots:
|
|
if not root.is_dir():
|
|
continue
|
|
for f in root.rglob("*.notifyrc"):
|
|
try:
|
|
text = f.read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
continue
|
|
for m in re.finditer(r"^\s*Sound\s*=\s*(\S+)", text, re.M):
|
|
v = m.group(1)
|
|
if v.endswith((".oga", ".ogg", ".wav")):
|
|
v = Path(v).stem
|
|
names.add(v)
|
|
return names
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("theme", type=Path, help="theme directory (contains index.theme)")
|
|
ap.add_argument("--quiet", action="store_true")
|
|
ap.add_argument("--no-kde-check", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
theme = args.theme.resolve()
|
|
errs: list[str] = []
|
|
warns: list[str] = []
|
|
|
|
idx = theme / "index.theme"
|
|
if not idx.is_file():
|
|
print(f"ERRORE: {idx} non esiste")
|
|
return 2
|
|
|
|
top, sections = parse_index(idx)
|
|
for req in ("Name", "Directories"):
|
|
if req not in top:
|
|
errs.append(f"index.theme: chiave obbligatoria '{req}' mancante")
|
|
dirs = [d.strip() for d in top.get("Directories", "").split(",") if d.strip()]
|
|
for d in dirs:
|
|
if d not in sections:
|
|
errs.append(f"index.theme: manca la sezione [{d}] dichiarata in Directories")
|
|
if not (theme / d).is_dir():
|
|
errs.append(f"index.theme: la directory '{d}' non esiste")
|
|
for d in dirs:
|
|
prof = sections.get(d, {}).get("OutputProfile")
|
|
if prof is None:
|
|
warns.append(f"[{d}] senza OutputProfile")
|
|
if "stereo" not in dirs:
|
|
errs.append("index.theme: manca 'stereo' in Directories "
|
|
"(obbligatorio come profilo di ripiego)")
|
|
|
|
example = top.get("Example")
|
|
if example:
|
|
if not any((theme / d / f"{example}.oga").is_file()
|
|
or (theme / d / f"{example}.ogg").is_file() for d in dirs):
|
|
errs.append(f"index.theme: Example='{example}' non trovato")
|
|
|
|
audio_files = sorted((theme / "stereo").glob("*.oga"))
|
|
if not audio_files:
|
|
errs.append("nessun file .oga in stereo/")
|
|
print("\n".join(errs))
|
|
return 1
|
|
|
|
print(f"Tema: {theme.name} (Name={top.get('Name')!r})")
|
|
print(f"Directories: {', '.join(dirs)} Inherits: {top.get('Inherits','-')} "
|
|
f"Example: {example or '-'}")
|
|
print(f"File audio: {len(audio_files)}\n")
|
|
if not args.quiet:
|
|
print(f"{'suono':<38}{'dur':>7}{'picco':>9}{'rms':>9}")
|
|
print("-" * 63)
|
|
|
|
total = 0.0
|
|
for f in audio_files:
|
|
e, w, info = check_audio(f, is_example=(f.stem == example))
|
|
errs += [f"{f.name}: {m}" for m in e]
|
|
warns += [f"{f.name}: {m}" for m in w]
|
|
total += info["dur"]
|
|
if not args.quiet:
|
|
print(f"{f.stem:<38}{info['dur']:6.2f}s{info['peak_db']:8.2f}dB"
|
|
f"{info['rms_db']:8.2f}dB")
|
|
print(f"\nDurata totale: {total:.1f}s media {total/len(audio_files):.2f}s")
|
|
|
|
# --- coverage against the installed KDE sound requests ----------------- #
|
|
if not args.no_kde_check:
|
|
search = [theme.parent, Path("/usr/share/sounds"),
|
|
Path.home() / ".local/share/sounds"]
|
|
chain = theme_dirs_chain(theme, search)
|
|
available: set[str] = set()
|
|
for d in chain:
|
|
available |= {p.stem for p in (d / "stereo").glob("*.oga")}
|
|
available |= {p.stem for p in (d / "stereo").glob("*.ogg")}
|
|
available |= {p.stem for p in (d / "stereo").glob("*.wav")}
|
|
requested = kde_requested_names(search)
|
|
missing = sorted(requested - available)
|
|
print(f"\nCatena Inherits: {' -> '.join(d.name for d in chain)}")
|
|
print(f"Nomi richiesti dai notifyrc KDE: {len(requested)}; "
|
|
f"risolti: {len(requested) - len(missing)}")
|
|
if missing:
|
|
for m in missing:
|
|
print(f" [i] non fornito (ripiega su freedesktop o silenzio): {m}")
|
|
|
|
print()
|
|
for w in warns:
|
|
print(f"WARN {w}")
|
|
for e in errs:
|
|
print(f"FAIL {e}")
|
|
if errs:
|
|
print(f"\n{len(errs)} errori, {len(warns)} avvisi")
|
|
return 1
|
|
print(f"OK — nessun errore, {len(warns)} avvisi")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|