Files
Matteo Benedetto 5528581848 feat: Tema sonoro Voyager 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 (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)
2026-09-20 16:12:48 +02:00

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())