commit a51ce1e8160fb198b9b8a862f3798d100853570f Author: Matteo Benedetto Date: Sun Sep 20 16:12:47 2026 +0200 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a181538 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +*.wav diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e2f91f2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to this project are documented here. +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); +versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-09-20 + +### Added + +* Initial release: 61 cues plus a `theme-demo` preview montage, all + synthesised from scratch and encoded as 48 kHz stereo Ogg Vorbis. +* `index.theme` declaring `Directories=stereo`, `OutputProfile=stereo`, + `Inherits=freedesktop` and `Example=theme-demo`. +* `tools/gen_sounds.py` — the complete synthesis engine (FM voices, Karplus-Strong + plucks, swept band-passed noise, sub impacts, additive pads, synthetic + convolution reverb, degradation modelling). +* `tools/verify_theme.py` — validates the theme against the freedesktop spec and + reports coverage of the sound names requested by the installed KDE + `*.notifyrc` files. +* `preview/index.html` — a browser player for every cue in the theme. +* `Makefile` and `install.sh` for user-local and system-wide installation. + +### Fixed + +* Reverb is no longer applied uniformly: recurring cues are near-dry so that a + burst of notifications cannot pile up, while rare cues keep the full hall. +* Added a bounded, loudness-aware normalisation stage so that sparse and dense + cues land at a comparable perceived level. diff --git a/DeepSpace/index.theme b/DeepSpace/index.theme new file mode 100644 index 0000000..68196eb --- /dev/null +++ b/DeepSpace/index.theme @@ -0,0 +1,11 @@ +[Sound Theme] +Name=DeepSpace +Name[it]=Spazio Profondo +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) +Inherits=freedesktop +Directories=stereo +Example=theme-demo + +[stereo] +OutputProfile=stereo diff --git a/DeepSpace/stereo/alarm-clock-elapsed.oga b/DeepSpace/stereo/alarm-clock-elapsed.oga new file mode 100644 index 0000000..2de0b80 Binary files /dev/null and b/DeepSpace/stereo/alarm-clock-elapsed.oga differ diff --git a/DeepSpace/stereo/audio-test-signal.oga b/DeepSpace/stereo/audio-test-signal.oga new file mode 100644 index 0000000..22f0f79 Binary files /dev/null and b/DeepSpace/stereo/audio-test-signal.oga differ diff --git a/DeepSpace/stereo/audio-volume-change.oga b/DeepSpace/stereo/audio-volume-change.oga new file mode 100644 index 0000000..32db348 Binary files /dev/null and b/DeepSpace/stereo/audio-volume-change.oga differ diff --git a/DeepSpace/stereo/battery-caution.oga b/DeepSpace/stereo/battery-caution.oga new file mode 100644 index 0000000..762b8e5 Binary files /dev/null and b/DeepSpace/stereo/battery-caution.oga differ diff --git a/DeepSpace/stereo/battery-full.oga b/DeepSpace/stereo/battery-full.oga new file mode 100644 index 0000000..d2a0c29 Binary files /dev/null and b/DeepSpace/stereo/battery-full.oga differ diff --git a/DeepSpace/stereo/battery-low.oga b/DeepSpace/stereo/battery-low.oga new file mode 100644 index 0000000..6805eca Binary files /dev/null and b/DeepSpace/stereo/battery-low.oga differ diff --git a/DeepSpace/stereo/bell-terminal.oga b/DeepSpace/stereo/bell-terminal.oga new file mode 100644 index 0000000..4dd1a7f Binary files /dev/null and b/DeepSpace/stereo/bell-terminal.oga differ diff --git a/DeepSpace/stereo/bell-window-system-attention.oga b/DeepSpace/stereo/bell-window-system-attention.oga new file mode 100644 index 0000000..8481380 Binary files /dev/null and b/DeepSpace/stereo/bell-window-system-attention.oga differ diff --git a/DeepSpace/stereo/bell-window-system.oga b/DeepSpace/stereo/bell-window-system.oga new file mode 100644 index 0000000..63313de Binary files /dev/null and b/DeepSpace/stereo/bell-window-system.oga differ diff --git a/DeepSpace/stereo/bell.oga b/DeepSpace/stereo/bell.oga new file mode 100644 index 0000000..6193bd5 Binary files /dev/null and b/DeepSpace/stereo/bell.oga differ diff --git a/DeepSpace/stereo/button-pressed-modifier.oga b/DeepSpace/stereo/button-pressed-modifier.oga new file mode 100644 index 0000000..0517c1e Binary files /dev/null and b/DeepSpace/stereo/button-pressed-modifier.oga differ diff --git a/DeepSpace/stereo/button-pressed.oga b/DeepSpace/stereo/button-pressed.oga new file mode 100644 index 0000000..b2ca235 Binary files /dev/null and b/DeepSpace/stereo/button-pressed.oga differ diff --git a/DeepSpace/stereo/camera-shutter.oga b/DeepSpace/stereo/camera-shutter.oga new file mode 100644 index 0000000..136fe07 Binary files /dev/null and b/DeepSpace/stereo/camera-shutter.oga differ diff --git a/DeepSpace/stereo/complete-media-burn.oga b/DeepSpace/stereo/complete-media-burn.oga new file mode 100644 index 0000000..ac4d554 Binary files /dev/null and b/DeepSpace/stereo/complete-media-burn.oga differ diff --git a/DeepSpace/stereo/complete-media-error.oga b/DeepSpace/stereo/complete-media-error.oga new file mode 100644 index 0000000..19a261c Binary files /dev/null and b/DeepSpace/stereo/complete-media-error.oga differ diff --git a/DeepSpace/stereo/complete.oga b/DeepSpace/stereo/complete.oga new file mode 100644 index 0000000..45e3b69 Binary files /dev/null and b/DeepSpace/stereo/complete.oga differ diff --git a/DeepSpace/stereo/completion-fail.oga b/DeepSpace/stereo/completion-fail.oga new file mode 100644 index 0000000..ce232ad Binary files /dev/null and b/DeepSpace/stereo/completion-fail.oga differ diff --git a/DeepSpace/stereo/completion-partial.oga b/DeepSpace/stereo/completion-partial.oga new file mode 100644 index 0000000..d0e0468 Binary files /dev/null and b/DeepSpace/stereo/completion-partial.oga differ diff --git a/DeepSpace/stereo/completion-rotation.oga b/DeepSpace/stereo/completion-rotation.oga new file mode 100644 index 0000000..6d314fc Binary files /dev/null and b/DeepSpace/stereo/completion-rotation.oga differ diff --git a/DeepSpace/stereo/completion-success.oga b/DeepSpace/stereo/completion-success.oga new file mode 100644 index 0000000..eb95ff5 Binary files /dev/null and b/DeepSpace/stereo/completion-success.oga differ diff --git a/DeepSpace/stereo/desktop-login.oga b/DeepSpace/stereo/desktop-login.oga new file mode 100644 index 0000000..84aaeeb Binary files /dev/null and b/DeepSpace/stereo/desktop-login.oga differ diff --git a/DeepSpace/stereo/desktop-logout.oga b/DeepSpace/stereo/desktop-logout.oga new file mode 100644 index 0000000..b633568 Binary files /dev/null and b/DeepSpace/stereo/desktop-logout.oga differ diff --git a/DeepSpace/stereo/device-added.oga b/DeepSpace/stereo/device-added.oga new file mode 100644 index 0000000..ce58e27 Binary files /dev/null and b/DeepSpace/stereo/device-added.oga differ diff --git a/DeepSpace/stereo/device-removed.oga b/DeepSpace/stereo/device-removed.oga new file mode 100644 index 0000000..2d0b6f6 Binary files /dev/null and b/DeepSpace/stereo/device-removed.oga differ diff --git a/DeepSpace/stereo/dialog-error-critical.oga b/DeepSpace/stereo/dialog-error-critical.oga new file mode 100644 index 0000000..7223b34 Binary files /dev/null and b/DeepSpace/stereo/dialog-error-critical.oga differ diff --git a/DeepSpace/stereo/dialog-error-serious.oga b/DeepSpace/stereo/dialog-error-serious.oga new file mode 100644 index 0000000..c6f40d0 Binary files /dev/null and b/DeepSpace/stereo/dialog-error-serious.oga differ diff --git a/DeepSpace/stereo/dialog-error-veryserious.oga b/DeepSpace/stereo/dialog-error-veryserious.oga new file mode 100644 index 0000000..fa1bfa7 Binary files /dev/null and b/DeepSpace/stereo/dialog-error-veryserious.oga differ diff --git a/DeepSpace/stereo/dialog-error.oga b/DeepSpace/stereo/dialog-error.oga new file mode 100644 index 0000000..4155b29 Binary files /dev/null and b/DeepSpace/stereo/dialog-error.oga differ diff --git a/DeepSpace/stereo/dialog-information.oga b/DeepSpace/stereo/dialog-information.oga new file mode 100644 index 0000000..91673a3 Binary files /dev/null and b/DeepSpace/stereo/dialog-information.oga differ diff --git a/DeepSpace/stereo/dialog-question.oga b/DeepSpace/stereo/dialog-question.oga new file mode 100644 index 0000000..9552ed9 Binary files /dev/null and b/DeepSpace/stereo/dialog-question.oga differ diff --git a/DeepSpace/stereo/dialog-special.oga b/DeepSpace/stereo/dialog-special.oga new file mode 100644 index 0000000..cd3821a Binary files /dev/null and b/DeepSpace/stereo/dialog-special.oga differ diff --git a/DeepSpace/stereo/dialog-warning-auth.oga b/DeepSpace/stereo/dialog-warning-auth.oga new file mode 100644 index 0000000..f8e0fd7 Binary files /dev/null and b/DeepSpace/stereo/dialog-warning-auth.oga differ diff --git a/DeepSpace/stereo/dialog-warning.oga b/DeepSpace/stereo/dialog-warning.oga new file mode 100644 index 0000000..b49cc71 Binary files /dev/null and b/DeepSpace/stereo/dialog-warning.oga differ diff --git a/DeepSpace/stereo/game-over-loser.oga b/DeepSpace/stereo/game-over-loser.oga new file mode 100644 index 0000000..fe47d1d Binary files /dev/null and b/DeepSpace/stereo/game-over-loser.oga differ diff --git a/DeepSpace/stereo/game-over-winner.oga b/DeepSpace/stereo/game-over-winner.oga new file mode 100644 index 0000000..5db399f Binary files /dev/null and b/DeepSpace/stereo/game-over-winner.oga differ diff --git a/DeepSpace/stereo/media-insert-request.oga b/DeepSpace/stereo/media-insert-request.oga new file mode 100644 index 0000000..e42c46a Binary files /dev/null and b/DeepSpace/stereo/media-insert-request.oga differ diff --git a/DeepSpace/stereo/message-attention.oga b/DeepSpace/stereo/message-attention.oga new file mode 100644 index 0000000..21d68ae Binary files /dev/null and b/DeepSpace/stereo/message-attention.oga differ diff --git a/DeepSpace/stereo/message-contact-in.oga b/DeepSpace/stereo/message-contact-in.oga new file mode 100644 index 0000000..e0e3be6 Binary files /dev/null and b/DeepSpace/stereo/message-contact-in.oga differ diff --git a/DeepSpace/stereo/message-contact-out.oga b/DeepSpace/stereo/message-contact-out.oga new file mode 100644 index 0000000..055db41 Binary files /dev/null and b/DeepSpace/stereo/message-contact-out.oga differ diff --git a/DeepSpace/stereo/message-highlight.oga b/DeepSpace/stereo/message-highlight.oga new file mode 100644 index 0000000..1831d69 Binary files /dev/null and b/DeepSpace/stereo/message-highlight.oga differ diff --git a/DeepSpace/stereo/message-new-email.oga b/DeepSpace/stereo/message-new-email.oga new file mode 100644 index 0000000..1fec96f Binary files /dev/null and b/DeepSpace/stereo/message-new-email.oga differ diff --git a/DeepSpace/stereo/message-new-instant.oga b/DeepSpace/stereo/message-new-instant.oga new file mode 100644 index 0000000..b43420e Binary files /dev/null and b/DeepSpace/stereo/message-new-instant.oga differ diff --git a/DeepSpace/stereo/message-sent-instant.oga b/DeepSpace/stereo/message-sent-instant.oga new file mode 100644 index 0000000..f21a2a9 Binary files /dev/null and b/DeepSpace/stereo/message-sent-instant.oga differ diff --git a/DeepSpace/stereo/message.oga b/DeepSpace/stereo/message.oga new file mode 100644 index 0000000..a413aca Binary files /dev/null and b/DeepSpace/stereo/message.oga differ diff --git a/DeepSpace/stereo/network-connectivity-established.oga b/DeepSpace/stereo/network-connectivity-established.oga new file mode 100644 index 0000000..f46aa9d Binary files /dev/null and b/DeepSpace/stereo/network-connectivity-established.oga differ diff --git a/DeepSpace/stereo/network-connectivity-lost.oga b/DeepSpace/stereo/network-connectivity-lost.oga new file mode 100644 index 0000000..a2d9b1c Binary files /dev/null and b/DeepSpace/stereo/network-connectivity-lost.oga differ diff --git a/DeepSpace/stereo/outcome-failure.oga b/DeepSpace/stereo/outcome-failure.oga new file mode 100644 index 0000000..ba6c84d Binary files /dev/null and b/DeepSpace/stereo/outcome-failure.oga differ diff --git a/DeepSpace/stereo/outcome-success.oga b/DeepSpace/stereo/outcome-success.oga new file mode 100644 index 0000000..7fc544c Binary files /dev/null and b/DeepSpace/stereo/outcome-success.oga differ diff --git a/DeepSpace/stereo/phone-incoming-call.oga b/DeepSpace/stereo/phone-incoming-call.oga new file mode 100644 index 0000000..a3efa7d Binary files /dev/null and b/DeepSpace/stereo/phone-incoming-call.oga differ diff --git a/DeepSpace/stereo/phone-outgoing-busy.oga b/DeepSpace/stereo/phone-outgoing-busy.oga new file mode 100644 index 0000000..5b3c12f Binary files /dev/null and b/DeepSpace/stereo/phone-outgoing-busy.oga differ diff --git a/DeepSpace/stereo/phone-outgoing-calling.oga b/DeepSpace/stereo/phone-outgoing-calling.oga new file mode 100644 index 0000000..1cb2e5b Binary files /dev/null and b/DeepSpace/stereo/phone-outgoing-calling.oga differ diff --git a/DeepSpace/stereo/power-plug.oga b/DeepSpace/stereo/power-plug.oga new file mode 100644 index 0000000..6ba41fb Binary files /dev/null and b/DeepSpace/stereo/power-plug.oga differ diff --git a/DeepSpace/stereo/power-unplug.oga b/DeepSpace/stereo/power-unplug.oga new file mode 100644 index 0000000..3fb7188 Binary files /dev/null and b/DeepSpace/stereo/power-unplug.oga differ diff --git a/DeepSpace/stereo/screen-capture.oga b/DeepSpace/stereo/screen-capture.oga new file mode 100644 index 0000000..b0360b0 Binary files /dev/null and b/DeepSpace/stereo/screen-capture.oga differ diff --git a/DeepSpace/stereo/service-login.oga b/DeepSpace/stereo/service-login.oga new file mode 100644 index 0000000..6aab8d2 Binary files /dev/null and b/DeepSpace/stereo/service-login.oga differ diff --git a/DeepSpace/stereo/service-logout.oga b/DeepSpace/stereo/service-logout.oga new file mode 100644 index 0000000..b74b05d Binary files /dev/null and b/DeepSpace/stereo/service-logout.oga differ diff --git a/DeepSpace/stereo/suspend-error.oga b/DeepSpace/stereo/suspend-error.oga new file mode 100644 index 0000000..46b446a Binary files /dev/null and b/DeepSpace/stereo/suspend-error.oga differ diff --git a/DeepSpace/stereo/theme-demo.oga b/DeepSpace/stereo/theme-demo.oga new file mode 100644 index 0000000..931d87a Binary files /dev/null and b/DeepSpace/stereo/theme-demo.oga differ diff --git a/DeepSpace/stereo/trash-empty.oga b/DeepSpace/stereo/trash-empty.oga new file mode 100644 index 0000000..e397deb Binary files /dev/null and b/DeepSpace/stereo/trash-empty.oga differ diff --git a/DeepSpace/stereo/window-attention.oga b/DeepSpace/stereo/window-attention.oga new file mode 100644 index 0000000..ab645a1 Binary files /dev/null and b/DeepSpace/stereo/window-attention.oga differ diff --git a/DeepSpace/stereo/window-question.oga b/DeepSpace/stereo/window-question.oga new file mode 100644 index 0000000..60ef1e0 Binary files /dev/null and b/DeepSpace/stereo/window-question.oga differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..91b1e9a --- /dev/null +++ b/LICENSE @@ -0,0 +1,4 @@ +DeepSpace is dual-licensed by content type. + + Audio and theme metadata: CC BY-SA 4.0 -> LICENSES/CC-BY-SA-4.0.txt + Code and documentation: BSD-2-Clause -> LICENSES/BSD-2-Clause.txt diff --git a/LICENSES/BSD-2-Clause.txt b/LICENSES/BSD-2-Clause.txt new file mode 100644 index 0000000..b346238 --- /dev/null +++ b/LICENSES/BSD-2-Clause.txt @@ -0,0 +1,23 @@ +Copyright (c) 2026, enne2 +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/LICENSES/CC-BY-SA-4.0.txt b/LICENSES/CC-BY-SA-4.0.txt new file mode 100644 index 0000000..2d58298 --- /dev/null +++ b/LICENSES/CC-BY-SA-4.0.txt @@ -0,0 +1,428 @@ +Attribution-ShareAlike 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-ShareAlike 4.0 International Public +License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-ShareAlike 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. BY-SA Compatible License means a license listed at + creativecommons.org/compatiblelicenses, approved by Creative + Commons as essentially the equivalent of this Public License. + + d. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + e. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + f. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + g. License Elements means the license attributes listed in the name + of a Creative Commons Public License. The License Elements of this + Public License are Attribution and ShareAlike. + + h. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + i. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + j. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + k. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + l. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + m. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. Additional offer from the Licensor -- Adapted Material. + Every recipient of Adapted Material from You + automatically receives an offer from the Licensor to + exercise the Licensed Rights in the Adapted Material + under the conditions of the Adapter's License You apply. + + c. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + b. ShareAlike. + + In addition to the conditions in Section 3(a), if You Share + Adapted Material You produce, the following conditions also apply. + + 1. The Adapter's License You apply must be a Creative Commons + license with the same License Elements, this version or + later, or a BY-SA Compatible License. + + 2. You must include the text of, or the URI or hyperlink to, the + Adapter's License You apply. You may satisfy this condition + in any reasonable manner based on the medium, means, and + context in which You Share Adapted Material. + + 3. You may not offer or impose any additional or different terms + or conditions on, or apply any Effective Technological + Measures to, Adapted Material that restrict exercise of the + rights granted under the Adapter's License You apply. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material, + including for purposes of Section 3(b); and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..71c589b --- /dev/null +++ b/Makefile @@ -0,0 +1,56 @@ +# DeepSpace — sound theme for KDE Plasma +# SPDX-License-Identifier: BSD-2-Clause + +THEME := DeepSpace +PALETTE := deepspace +VERSION := 1.0.0 +PREFIX ?= $(HOME)/.local +DESTDIR ?= +REPO := kde-sound-theme-deepspace + +.DEFAULT_GOAL := verify + +.PHONY: all generate verify preview dist install install-system uninstall clean FORCE + +all: verify + +## Regenerate every cue in $(THEME)/stereo from the synthesis script. +generate: + python3 tools/gen_sounds.py --theme $(PALETTE) --out . + @echo "-> rigenerati i cue di $(THEME)" + +## Validate the theme (spec, audio hygiene, KDE sound-name coverage). +verify: + python3 tools/verify_theme.py $(THEME) + +## Rebuild the browser preview page. +preview: FORCE + python3 tools/make_preview.py $(THEME) preview/index.html + +## Build the distributable tarball. +dist: + @mkdir -p dist + @tar --exclude-vcs --exclude=dist --exclude=__pycache__ \ + -czf dist/$(REPO)-$(VERSION).tar.gz \ + $(THEME) tools Makefile install.sh README.md CHANGELOG.md LICENSES preview + @echo "-> dist/$(REPO)-$(VERSION).tar.gz" + +## Install for the current user into $(PREFIX)/share/sounds. +install: + install -d $(DESTDIR)$(PREFIX)/share/sounds/$(THEME) + cp -r $(THEME)/. $(DESTDIR)$(PREFIX)/share/sounds/$(THEME)/ + @echo "-> $(DESTDIR)$(PREFIX)/share/sounds/$(THEME)" + @echo " selezionalo da Impostazioni di sistema > Colori e temi > Suoni di sistema" + +## Install system-wide (needs root). +install-system: + $(MAKE) install PREFIX=/usr + +uninstall: + rm -rf $(DESTDIR)$(PREFIX)/share/sounds/$(THEME) + @echo "-> rimosso $(DESTDIR)$(PREFIX)/share/sounds/$(THEME)" + +clean: + rm -rf dist __pycache__ tools/__pycache__ + +FORCE: diff --git a/README.md b/README.md new file mode 100644 index 0000000..c12d677 --- /dev/null +++ b/README.md @@ -0,0 +1,232 @@ +# DeepSpace — sound theme for KDE Plasma + +*Operational telemetry for the long haul* + +A complete, [freedesktop Sound Theme +specification](https://specifications.freedesktop.org/sound-theme-spec/latest/) +compliant sound theme for **KDE Plasma 6** (and any other desktop that honours +the spec). 61 cues, all of them synthesised from scratch — no sample +library, no third-party audio, no attribution burden. The whole theme is +reproducible from the script in `tools/`. + +``` +kde-sound-theme-deepspace/ +├── DeepSpace/ +│ ├── index.theme # [Sound Theme] + [stereo] OutputProfile +│ └── stereo/ # 61 × Ogg Vorbis 48 kHz stereo +├── tools/ +│ ├── gen_sounds.py # the synthesis engine (reproducible) +│ ├── verify_theme.py # spec + audio + KDE coverage checks +│ ├── make_preview.py # rebuilds preview/index.html +│ └── preview_template.html +├── preview/index.html +├── LICENSES/ +├── Makefile +└── install.sh +``` + +## Character + +DeepSpace is the operational voice of a ship underway: inharmonic FM bells (ratio 2.76) over a sub-bass drone rooted on A2, short band-limited noise sweeps for anything that opens, closes or docks, and a hall that is present but never washy. Cues are meant to be read at a glance: rising fifths say *nominal*, a detuned cluster over a sub impact says *attention*. + +| Motif | Used for | +|---|---| +| Rising perfect fifth (0 → 7) | nominal, completed, received | +| Falling fourth (7 → 0) | warning, lost, removed | +| Detuned cluster + sub impact | error, failure, suspend | +| Band-limited noise sweep | plug, unplug, dock, trash | +| Metallic bell arpeggio | success, winner, connectivity | + +## Requirements + +* KDE Plasma 6, or any desktop implementing the freedesktop sound theme spec +* nothing at runtime — the cues are plain Ogg Vorbis files +* `ffmpeg` (libvorbis), `python3`, `numpy`, `scipy` **only** if you want to + regenerate the audio + +## Install + +User-local (recommended, no root needed): + +```sh +git clone https://git.enne2.net/enne2/kde-sound-theme-deepspace.git +cd kde-sound-theme-deepspace +./install.sh # copies DeepSpace/ into ~/.local/share/sounds/ +``` + +System-wide: + +```sh +sudo make install-system # copies DeepSpace/ into /usr/share/sounds/ +``` + +## Select the theme + +**System Settings → Colors & Themes → System Sounds → DeepSpace** + +Or from the command line: + +```sh +kwriteconfig6 --file kdeglobals --group Sounds --key Theme DeepSpace +``` + +To go back to the Plasma default: + +```sh +kwriteconfig6 --file kdeglobals --group Sounds --key Theme ocean +``` + +The theme declares `Inherits=freedesktop`, so any event it does not provide +(e.g. multi-channel test signals) falls back to the stock desktop theme instead +of going silent. + +## What is inside + +`DeepSpace/index.theme` declares the theme, `Directories=stereo`, +`OutputProfile=stereo` and `Example=theme-demo`. The `Example` key is what the +Plasma *System Sounds* KCM uses for its preview button, so `theme-demo.oga` is a +montage of the theme's signatures rather than an arbitrary cue. + +`Name[it]` and `Comment[it]` are also provided, so Plasma in an Italian locale +lists the theme as **Spazio Profondo**. That is a display name only: the theme id — the +folder name in `~/.local/share/sounds/` and the value written to +`kdeglobals` — is always `DeepSpace`, exactly like KDE's own Ocean theme shows up +as *Oceano* in Italian. + +| sound name | duration | size | +|---|---:|---:| +| `alarm-clock-elapsed` | 2.66 s | 37.7 KiB | +| `audio-test-signal` | 1.10 s | 20.5 KiB | +| `audio-volume-change` | 0.13 s | 6.7 KiB | +| `battery-caution` | 1.18 s | 19.9 KiB | +| `battery-full` | 1.05 s | 18.4 KiB | +| `battery-low` | 1.18 s | 18.2 KiB | +| `bell-terminal` | 0.15 s | 7.0 KiB | +| `bell-window-system-attention` | 0.94 s | 15.6 KiB | +| `bell-window-system` | 0.88 s | 15.0 KiB | +| `bell` | 1.26 s | 19.8 KiB | +| `button-pressed-modifier` | 0.12 s | 7.1 KiB | +| `button-pressed` | 0.11 s | 6.7 KiB | +| `camera-shutter` | 0.26 s | 9.7 KiB | +| `complete-media-burn` | 2.26 s | 37.9 KiB | +| `complete-media-error` | 1.72 s | 36.1 KiB | +| `complete` | 0.83 s | 15.2 KiB | +| `completion-fail` | 1.38 s | 19.9 KiB | +| `completion-partial` | 1.12 s | 18.3 KiB | +| `completion-rotation` | 1.68 s | 24.6 KiB | +| `completion-success` | 2.14 s | 36.6 KiB | +| `desktop-login` | 2.90 s | 39.6 KiB | +| `desktop-logout` | 2.20 s | 28.0 KiB | +| `device-added` | 0.64 s | 15.2 KiB | +| `device-removed` | 0.64 s | 15.1 KiB | +| `dialog-error-critical` | 2.32 s | 45.9 KiB | +| `dialog-error-serious` | 1.80 s | 37.3 KiB | +| `dialog-error-veryserious` | 2.70 s | 35.9 KiB | +| `dialog-error` | 1.48 s | 31.5 KiB | +| `dialog-information` | 1.13 s | 18.2 KiB | +| `dialog-question` | 1.14 s | 19.1 KiB | +| `dialog-special` | 1.86 s | 33.4 KiB | +| `dialog-warning-auth` | 1.00 s | 19.1 KiB | +| `dialog-warning` | 1.27 s | 19.8 KiB | +| `game-over-loser` | 2.60 s | 50.0 KiB | +| `game-over-winner` | 3.02 s | 50.3 KiB | +| `media-insert-request` | 0.87 s | 16.5 KiB | +| `message-attention` | 1.16 s | 23.8 KiB | +| `message-contact-in` | 1.05 s | 17.0 KiB | +| `message-contact-out` | 1.05 s | 16.5 KiB | +| `message-highlight` | 0.86 s | 16.8 KiB | +| `message-new-email` | 1.48 s | 25.0 KiB | +| `message-new-instant` | 0.92 s | 15.6 KiB | +| `message-sent-instant` | 0.45 s | 10.3 KiB | +| `message` | 0.80 s | 14.8 KiB | +| `network-connectivity-established` | 1.66 s | 28.8 KiB | +| `network-connectivity-lost` | 1.26 s | 19.2 KiB | +| `outcome-failure` | 1.32 s | 28.6 KiB | +| `outcome-success` | 1.62 s | 28.4 KiB | +| `phone-incoming-call` | 2.44 s | 39.4 KiB | +| `phone-outgoing-busy` | 1.30 s | 23.6 KiB | +| `phone-outgoing-calling` | 1.16 s | 23.8 KiB | +| `power-plug` | 0.78 s | 15.3 KiB | +| `power-unplug` | 0.88 s | 18.9 KiB | +| `screen-capture` | 0.30 s | 10.4 KiB | +| `service-login` | 1.34 s | 20.7 KiB | +| `service-logout` | 1.24 s | 19.0 KiB | +| `suspend-error` | 1.92 s | 39.5 KiB | +| `theme-demo` | 11.39 s | 168.6 KiB | +| `trash-empty` | 0.95 s | 20.3 KiB | +| `window-attention` | 1.43 s | 21.4 KiB | +| `window-question` | 1.04 s | 18.0 KiB | + +## Rebuilding from source + +The audio is generated, not sampled, so it is fully reproducible: + +```sh +make generate # rewrites DeepSpace/stereo/*.oga from tools/gen_sounds.py +make verify # spec + audio + KDE coverage checks +make preview # rebuild preview/index.html +make dist # build dist/kde-sound-theme-deepspace-1.0.0.tar.gz +``` + +`make verify` fails loudly on a clipped sample, a DC offset, a click at a file +edge, a wrong sample rate or channel count, a missing `stereo` directory in +`Directories=`, a broken `Example`/`Inherits` reference, and it reports how many +of the sound names actually requested by the installed KDE `*.notifyrc` files +the theme resolves. + +## Design notes + +### One instrument set, not sixty unrelated beeps + +Every cue is assembled from the same small library of motifs — FM bells, sub +impacts, band-limited noise sweeps, additive pads, plucked strings, telemetry +blips — so the theme reads as a single instrument rather than a folder of +noises. The palette below is what turns that library into *this* theme: + +| Parameter | DeepSpace | +|---|---| +| Root | A2 — 110.00 Hz | +| Scale | minor pentatonic (0 3 5 7 10) | +| FM voice | ratio 2.76, index 2.4, index decay 130 ms | +| Detune | ±6 cents | +| Space | 2.2 s synthetic hall, 28–36 % wet on medium cues, near-dry on clicks | + +### A differentiated reverb bus + +A single reverb setting is wrong for a desktop theme: frequent cues (chat, +volume, button clicks) would smear into each other during a burst, while rare +dramatic cues would sound dry and small. Cues here are assigned one of five +reverb classes — from near-dry clicks through a small room up to a vast hall +with an extra half-second of tail — by motif, with per-sound overrides. The +result is that a chat notification stays crisp and a login blooms. + +### Loudness-aware normalisation + +Peak-normalising every file is not enough: a sparse bell and a dense error +cluster end up at wildly different perceived levels. After peak normalisation +each cue gets a bounded gain (±6 dB) that pulls the RMS of its *active* portion +towards −16 dBFS, with the peak ceiling always winning. That keeps the theme +consistent without introducing clipping. + +### No clicks + +Every cue is high-passed (30–130 Hz depending on the class), given a 1.5 ms +raised-cosine fade in and a 35 ms fade out before the reverb is applied, so the +convolution never sees a discontinuity. `make verify` enforces this: it rejects +any file whose first samples are not at zero. + +## Licence + +* **Audio** (`DeepSpace/stereo/*.oga`, `DeepSpace/index.theme`) — + [CC BY-SA 4.0](LICENSES/CC-BY-SA-4.0.txt), the same licence the KDE sound + themes use. +* **Code and documentation** (`tools/`, `Makefile`, `install.sh`, `preview/`, + `README.md`) — [BSD-2-Clause](LICENSES/BSD-2-Clause.txt). + +The sounds are original synthesis; nothing is derived from the KDE Ocean sound +theme or any other recording. You may therefore use this theme commercially and +adapt it freely, subject to CC BY-SA 4.0's attribution and share-alike terms. + +## Credits + +Created by **enne2** (2026). Designed for Plasma 6.3 on Debian 13. diff --git a/dist/kde-sound-theme-deepspace-1.0.0.tar.gz b/dist/kde-sound-theme-deepspace-1.0.0.tar.gz new file mode 100644 index 0000000..6875c7a Binary files /dev/null and b/dist/kde-sound-theme-deepspace-1.0.0.tar.gz differ diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..9950e00 --- /dev/null +++ b/install.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# DeepSpace — user-local installer for the DeepSpace sound theme. +# SPDX-License-Identifier: BSD-2-Clause +# +# Usage: +# ./install.sh install into ~/.local/share/sounds +# ./install.sh -d /some/dir install into /sounds +# ./install.sh -s install into /usr/share/sounds (needs root) +# ./install.sh -y activate the theme without asking +set -eu + +THEME=DeepSpace +SRC=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +DEST="${HOME}/.local/share" +ASSUME_YES=0 + +usage() { + sed -n '3,10p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +while [ $# -gt 0 ]; do + case "$1" in + -d) DEST="${2:?-d richiede un percorso}"; shift 2 ;; + -s) DEST=/usr; shift ;; + -y) ASSUME_YES=1; shift ;; + -h|--help) usage 0 ;; + *) usage 1 ;; + esac +done + +TARGET="$DEST/share/sounds/$THEME" +mkdir -p "$TARGET" +cp -r "$SRC/$THEME/." "$TARGET/" + +echo "installato: $TARGET" +echo "cue installati: $(find "$TARGET/stereo" -name '*.oga' | wc -l)" + +if command -v kwriteconfig6 >/dev/null 2>&1; then + echo + if [ "$ASSUME_YES" = 1 ]; then + answer=y + elif [ -t 0 ]; then + printf 'attivare ora %s come tema sonoro? [s/N] ' "$THEME" + read -r answer || answer=n + else + answer=n + fi + case "$answer" in + s|S|y|Y) + kwriteconfig6 --file kdeglobals --group Sounds --key Theme "$THEME" + echo "tema attivo: $THEME" + ;; + *) echo "tema non attivato (Impostazioni di sistema > Colori e temi > Suoni)" ;; + esac +fi diff --git a/preview.meta b/preview.meta new file mode 100644 index 0000000..11ac154 --- /dev/null +++ b/preview.meta @@ -0,0 +1,6 @@ +title=DeepSpace +tagline=Operational telemetry for the long haul +accent=#2fb4d2 +accent2=#70c493 +bg=#0c1822 +fg=#d7e6ee diff --git a/preview/index.html b/preview/index.html new file mode 100644 index 0000000..35591d8 --- /dev/null +++ b/preview/index.html @@ -0,0 +1,108 @@ + + + + + + +DeepSpace — sound theme + + + +
+

DeepSpace

+

Operational telemetry for the long haul

+
+ 61 cue + 48 kHz stereo Ogg Vorbis + freedesktop sound theme spec + KDE Plasma 6 +
+ +
+

theme-demo — anteprima del tema

+ +
+ +
+ +
+ DeepSpace — CC BY-SA 4.0. I cue sono sintetizzati da zero con + tools/gen_sounds.py: nessun campione di terze parti. +
+
+ + + diff --git a/tools/gen_sounds.py b/tools/gen_sounds.py new file mode 100644 index 0000000..cb2bf28 --- /dev/null +++ b/tools/gen_sounds.py @@ -0,0 +1,1147 @@ +#!/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: +# //index.theme +# //stereo/.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()) diff --git a/tools/make_preview.py b/tools/make_preview.py new file mode 100755 index 0000000..61ff50a --- /dev/null +++ b/tools/make_preview.py @@ -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()) diff --git a/tools/preview_template.html b/tools/preview_template.html new file mode 100644 index 0000000..074338f --- /dev/null +++ b/tools/preview_template.html @@ -0,0 +1,108 @@ + + + + + + +{title} — sound theme + + + +
+

{title}

+

{tagline}

+
+ {nsounds} cue + 48 kHz stereo Ogg Vorbis + freedesktop sound theme spec + KDE Plasma 6 +
+ +
+

theme-demo — anteprima del tema

+ +
+ +
+ +
+ {title} — {license}. I cue sono sintetizzati da zero con + tools/gen_sounds.py: nessun campione di terze parti. +
+
+ + + diff --git a/tools/verify_theme.py b/tools/verify_theme.py new file mode 100644 index 0000000..e576877 --- /dev/null +++ b/tools/verify_theme.py @@ -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())