feat: Tema sonoro DeepSpace 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, pluck
  Karplus-Strong, rumore a banda variabile, sub-impact, pad additivi,
  riverbero a convoluzione, modello di degrado)
- 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)
This commit is contained in:
Matteo Benedetto
2026-09-20 16:12:47 +02:00
commit a51ce1e816
77 changed files with 2586 additions and 0 deletions
+1147
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: BSD-2-Clause
"""Generate preview/index.html for a sound theme."""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
TEMPLATE = Path(__file__).with_name("preview_template.html")
def duration(path: Path) -> float:
ffp = shutil.which("ffprobe") or "ffprobe"
return float(subprocess.run(
[ffp, "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", str(path)],
capture_output=True, text=True, check=True).stdout.strip())
def main() -> int:
theme = Path(sys.argv[1])
out = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("preview/index.html")
files = sorted((theme / "stereo").glob("*.oga"))
names = ", ".join(f'["{f.stem}", {duration(f):.2f}]' for f in files
if f.stem != "theme-demo")
html = TEMPLATE.read_text(encoding="utf-8")
extra = {}
for cand in (Path("preview.meta"), theme.parent / "preview.meta",
theme / "preview.meta"):
if cand.is_file():
meta = cand
break
else:
meta = None
if meta is not None:
for line in meta.read_text(encoding="utf-8").splitlines():
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
extra[k.strip()] = v.strip()
extra.setdefault("title", theme.name)
extra.setdefault("tagline", "")
extra.setdefault("accent", "#2fb4d2")
extra.setdefault("accent2", "#70c493")
extra.setdefault("bg", "#0c1822")
extra.setdefault("fg", "#d7e6ee")
extra.setdefault("license", "CC BY-SA 4.0")
extra["names"] = "[ " + names + " ]"
extra["nsounds"] = str(len(files))
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(html.format(**extra), encoding="utf-8")
print(f"scritto {out} ({len(files)} cue)")
return 0
if __name__ == "__main__":
sys.exit(main())
+108
View File
@@ -0,0 +1,108 @@
<!-- preview_template.html — sorgente di preview/index.html.
Generato da scaffold_repo.py; usa str.format(), quindi le graffe
di CSS e JavaScript sono raddoppiate. Rigenera la pagina con:
python3 tools/make_preview.py DeepSpace preview/index.html
SPDX-License-Identifier: BSD-2-Clause -->
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} — sound theme</title>
<style>
:root {{ --accent: {accent}; --accent2: {accent2}; --bg: {bg}; --fg: {fg}; }}
* {{ box-sizing: border-box; }}
body {{ margin: 0; padding: 2.5rem 1.25rem 4rem;
background: radial-gradient(120% 80% at 50% 0%, #1a2634 0%, var(--bg) 55%, #05080c 100%);
color: var(--fg); min-height: 100vh;
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }}
.wrap {{ max-width: 960px; margin: 0 auto; }}
h1 {{ font-size: 2.4rem; font-weight: 650; letter-spacing: -0.02em; margin: 0 0 .2rem; }}
h1 span {{ color: var(--accent); }}
.tag {{ color: var(--accent); font-style: italic; margin: 0 0 1.2rem; }}
.meta {{ display: flex; flex-wrap: wrap; gap: .5rem 1.4rem; margin: 0 0 2rem;
opacity: .75; font-size: .9rem; }}
.meta b {{ font-weight: 600; color: var(--accent2); }}
.demo {{ background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.10);
border-left: 3px solid var(--accent); border-radius: 10px;
padding: 1rem 1.2rem; margin: 0 0 2rem; }}
.demo h2 {{ margin: 0 0 .5rem; font-size: 1rem; font-weight: 600; }}
.grid {{ display: grid; gap: 6px;
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); }}
.row {{ display: flex; align-items: center; gap: .7rem;
padding: .45rem .7rem; border-radius: 8px;
background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.06);
transition: background .15s, border-color .15s; }}
.row:hover {{ background: rgba(255,255,255,.07); border-color: var(--accent); }}
.row.playing {{ border-color: var(--accent); background: rgba(255,255,255,.09); }}
.row button {{ flex: 0 0 30px; height: 30px; border-radius: 50%; cursor: pointer;
border: 1px solid var(--accent); background: transparent;
color: var(--accent); font-size: 12px; line-height: 1;
display: grid; place-items: center; }}
.row button:hover {{ background: var(--accent); color: var(--bg); }}
.row .n {{ flex: 1; font-size: .87rem; font-family: ui-monospace, monospace;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
.row .d {{ flex: 0 0 auto; font-size: .76rem; opacity: .55;
font-variant-numeric: tabular-nums; }}
footer {{ margin-top: 3rem; padding-top: 1.2rem; font-size: .82rem; opacity: .6;
border-top: 1px solid rgba(255,255,255,.08); }}
footer a {{ color: var(--accent); }}
</style>
</head>
<body>
<div class="wrap">
<h1>{title}</h1>
<p class="tag">{tagline}</p>
<div class="meta">
<span><b>{nsounds}</b> cue</span>
<span><b>48 kHz</b> stereo Ogg Vorbis</span>
<span><b>freedesktop</b> sound theme spec</span>
<span><b>KDE Plasma 6</b></span>
</div>
<div class="demo">
<h2>theme-demo &mdash; anteprima del tema</h2>
<audio id="demo" controls preload="none" src="../{title}/stereo/theme-demo.oga"></audio>
</div>
<div class="grid" id="grid"></div>
<footer>
{title} — {license}. I cue sono sintetizzati da zero con
<code>tools/gen_sounds.py</code>: nessun campione di terze parti.
</footer>
</div>
<script>
const NAMES = {names};
const grid = document.getElementById('grid');
let current = null;
for (const [name, dur] of NAMES) {{
const row = document.createElement('div');
row.className = 'row';
const btn = document.createElement('button');
btn.textContent = '▶';
btn.setAttribute('aria-label', 'riproduci ' + name);
const label = document.createElement('span');
label.className = 'n';
label.textContent = name;
const d = document.createElement('span');
d.className = 'd';
d.textContent = dur + 's';
const audio = new Audio('../{title}/stereo/' + name + '.oga');
audio.preload = 'none';
btn.onclick = () => {{
if (current && current !== audio) {{ current.pause(); current.currentTime = 0; }}
document.querySelectorAll('.row.playing').forEach(r => r.classList.remove('playing'));
if (!audio.paused) {{ audio.pause(); audio.currentTime = 0; btn.textContent = '▶'; return; }}
current = audio;
btn.textContent = '❚❚';
row.classList.add('playing');
audio.play();
}};
audio.onended = () => {{ btn.textContent = '▶'; row.classList.remove('playing'); }};
row.append(btn, label, d);
grid.append(row);
}}
</script>
</body>
</html>
+317
View File
@@ -0,0 +1,317 @@
#!/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())